mirror of
https://github.com/wekan/wekan.git
synced 2026-03-13 17:06:13 +01:00
Clean up exportPDF and ExporterCardPDF server-side code to align with the BlazeComponent removal changes.
334 lines
8.9 KiB
JavaScript
334 lines
8.9 KiB
JavaScript
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 };
|