wekan/models/server/ExporterCardPDF.js

335 lines
8.9 KiB
JavaScript
Raw Normal View History

import { ReactiveCache } from '/imports/reactiveCache';
const PAGE_WIDTH = 595;
const PAGE_HEIGHT = 842;
const PAGE_MARGIN = 50;
const LINE_HEIGHT = 14;
const FONT_SIZE = 10;
const TEXT_WIDTH = 90;
function sanitizeFilename(value) {
return String(value || 'export-card')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80) || 'export-card';
}
function normalizePdfText(value) {
return String(value ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/\r/g, '')
.replace(/\t/g, ' ')
.replace(/[^\x20-\x7E\n]/g, '?');
}
function escapePdfText(value) {
return normalizePdfText(value)
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
}
function wrapLine(line, width = TEXT_WIDTH) {
if (!line) {
return [''];
}
const words = line.split(/\s+/).filter(Boolean);
if (words.length === 0) {
return [''];
}
const wrapped = [];
let current = '';
for (const word of words) {
if (!current) {
current = word;
continue;
}
if (`${current} ${word}`.length <= width) {
current = `${current} ${word}`;
continue;
}
wrapped.push(current);
current = word;
}
if (current) {
wrapped.push(current);
}
const splitLongWords = [];
for (const item of wrapped) {
if (item.length <= width) {
splitLongWords.push(item);
continue;
}
for (let index = 0; index < item.length; index += width) {
splitLongWords.push(item.slice(index, index + width));
}
}
return splitLongWords;
}
function wrapTextBlock(text) {
return normalizePdfText(text)
.split('\n')
.flatMap(line => wrapLine(line));
}
function paginateLines(lines) {
const linesPerPage = Math.floor(
(PAGE_HEIGHT - PAGE_MARGIN * 2) / LINE_HEIGHT,
);
const pages = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
pages.push(lines.slice(index, index + linesPerPage));
}
return pages.length > 0 ? pages : [['No data']];
}
function buildPdfBuffer(lines) {
const pages = paginateLines(lines);
const objects = [];
const addObject = content => {
objects.push(content);
return objects.length;
};
const catalogId = addObject('');
const pagesId = addObject('');
const fontId = addObject(
'<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>',
);
const pageIds = [];
for (const pageLines of pages) {
const textCommands = ['BT', `/F1 ${FONT_SIZE} Tf`, `${LINE_HEIGHT} TL`];
textCommands.push(
`1 0 0 1 ${PAGE_MARGIN} ${PAGE_HEIGHT - PAGE_MARGIN - FONT_SIZE} Tm`,
);
pageLines.forEach((line, index) => {
if (index > 0) {
textCommands.push('T*');
}
textCommands.push(`(${escapePdfText(line)}) Tj`);
});
textCommands.push('ET');
const stream = textCommands.join('\n');
const contentId = addObject(
`<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`,
);
const pageId = addObject(
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${contentId} 0 R >>`,
);
pageIds.push(pageId);
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'utf8'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (let index = 1; index < offsets.length; index += 1) {
pdf += `${String(offsets[index]).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, 'utf8');
}
function formatDateValue(value) {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return '-';
}
return value.toISOString().replace('T', ' ').slice(0, 16);
}
function formatUser(user) {
if (!user) {
return 'Unknown';
}
return user.profile?.fullname || user.username || user.profile?.initials || user._id;
}
class ExporterCardPDF {
constructor(boardId, listId, cardId) {
this._boardId = boardId;
this._listId = listId;
this._cardId = cardId;
}
async _getCardData() {
const board = await ReactiveCache.getBoard(this._boardId);
const list = await ReactiveCache.getList({
_id: this._listId,
boardId: this._boardId,
});
const card = await ReactiveCache.getCard({
_id: this._cardId,
boardId: this._boardId,
listId: this._listId,
});
if (!board || !list || !card) {
return null;
}
const swimlane = card.swimlaneId
? await ReactiveCache.getSwimlane({ _id: card.swimlaneId })
: null;
const checklists = await ReactiveCache.getChecklists(
{ cardId: this._cardId },
{ sort: { sort: 1 } },
);
const comments = await ReactiveCache.getCardComments(
{ cardId: this._cardId },
{ sort: { createdAt: 1 } },
);
const checklistItemsByChecklistId = {};
for (const checklist of checklists) {
checklistItemsByChecklistId[checklist._id] = await ReactiveCache.getChecklistItems(
{ checklistId: checklist._id },
{ sort: { sort: 1 } },
);
}
const userIds = new Set([
card.userId,
...(card.members || []),
...(card.assignees || []),
...comments.map(comment => comment.userId),
]);
const usersById = {};
await Promise.all(
[...userIds]
.filter(Boolean)
.map(async userId => {
usersById[userId] = await ReactiveCache.getUser({ _id: userId });
}),
);
return {
board,
list,
card,
swimlane,
checklists,
checklistItemsByChecklistId,
comments,
usersById,
};
}
async build(res) {
const data = await this._getCardData();
if (!data) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Card not found');
return;
}
const { board, list, card, swimlane, checklists, checklistItemsByChecklistId, comments, usersById } = data;
const labelsById = Object.fromEntries(
(board.labels || [])
.filter(label => label && label._id)
.map(label => [label._id, label.name || label.color || label._id]),
);
const lines = [
'Wekan Card Export',
'',
`Title: ${card.title || '-'}`,
`Board: ${board.title || '-'}`,
`List: ${list.title || '-'}`,
`Swimlane: ${swimlane?.title || '-'}`,
`Created by: ${formatUser(usersById[card.userId])}`,
`Members: ${(card.members || []).map(userId => formatUser(usersById[userId])).join(', ') || '-'}`,
`Assignees: ${(card.assignees || []).map(userId => formatUser(usersById[userId])).join(', ') || '-'}`,
`Labels: ${(card.labelIds || []).map(labelId => labelsById[labelId] || labelId).join(', ') || '-'}`,
`Created: ${formatDateValue(card.createdAt)}`,
`Last activity: ${formatDateValue(card.dateLastActivity)}`,
`Received: ${formatDateValue(card.receivedAt)}`,
`Start: ${formatDateValue(card.startAt)}`,
`Due: ${formatDateValue(card.dueAt)}`,
`End: ${formatDateValue(card.endAt)}`,
`Spent time: ${card.spentTime ?? '-'}`,
'',
'Description:',
...wrapTextBlock(card.description || '-'),
];
lines.push('', 'Checklists:');
if (checklists.length === 0) {
lines.push('-');
} else {
for (const checklist of checklists) {
lines.push(...wrapTextBlock(`- ${checklist.title || 'Checklist'}`));
const items = checklistItemsByChecklistId[checklist._id] || [];
if (items.length === 0) {
lines.push(' (no items)');
continue;
}
for (const item of items) {
lines.push(...wrapTextBlock(` ${item.isFinished ? '[x]' : '[ ]'} ${item.title || ''}`));
}
}
}
lines.push('', 'Comments:');
if (comments.length === 0) {
lines.push('-');
} else {
for (const comment of comments) {
lines.push(
...wrapTextBlock(
`- ${formatDateValue(comment.createdAt)} ${formatUser(usersById[comment.userId])}: ${comment.text || ''}`,
),
);
}
}
const filename = `${sanitizeFilename(card.title)}.pdf`;
const pdf = buildPdfBuffer(lines);
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdf.length,
});
res.end(pdf);
}
async canExport(user) {
const board = await ReactiveCache.getBoard(this._boardId);
return board && board.isVisibleBy(user);
}
}
export { ExporterCardPDF };