Update PDF export models

Clean up exportPDF and ExporterCardPDF server-side code to align
with the BlazeComponent removal changes.
This commit is contained in:
Harry Adel 2026-03-08 11:02:40 +02:00
parent 477e1c89e5
commit 6953cc0670
2 changed files with 332 additions and 634 deletions

View file

@ -32,8 +32,8 @@ runOnServer(function() {
*/
Picker.route('/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF', async function (params, req, res) {
const boardId = params.boardId;
const paramListId = req.params.listId;
const paramCardId = req.params.cardId;
const paramListId = params.listId;
const paramCardId = params.cardId;
let user = null;
let impersonateDone = false;
let adminId = null;
@ -48,8 +48,12 @@ runOnServer(function() {
// If board is public, skip expensive authentication operations
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporterCardPDF = new ExporterCardPDF(boardId);
exporterCardPDF.build(res);
const exporterCardPDF = new ExporterCardPDF(
boardId,
paramListId,
paramCardId,
);
await exporterCardPDF.build(res);
return;
}
@ -69,6 +73,11 @@ runOnServer(function() {
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
if (!user) {
res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
adminId = user._id.toString();
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
} else if (!Meteor.settings.public.sandstorm) {
@ -79,8 +88,12 @@ runOnServer(function() {
});
}
const exporterCardPDF = new ExporterCardPDF(boardId);
if (exporterCardPDF.canExport(user) || impersonateDone) {
const exporterCardPDF = new ExporterCardPDF(
boardId,
paramListId,
paramCardId,
);
if (await exporterCardPDF.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
adminId: adminId,
@ -89,7 +102,7 @@ runOnServer(function() {
});
}
exporterCardPDF.build(res);
await exporterCardPDF.build(res);
} else {
res.end(TAPi18n.__('user-can-not-export-card-to-pdf'));
}

View file

@ -1,643 +1,328 @@
import { ReactiveCache } from '/imports/reactiveCache';
// exporter maybe is broken since Gridfs introduced, add fs and path
import { createWorkbook } from './createWorkbook';
import {
formatDateTime,
formatDate,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
class ExporterCardPDF {
constructor(boardId) {
this._boardId = boardId;
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 [''];
}
build(res) {
const words = line.split(/\s+/).filter(Boolean);
if (words.length === 0) {
return [''];
}
/*
const fs = Npm.require('fs');
const os = Npm.require('os');
const path = Npm.require('path');
const wrapped = [];
let current = '';
const byBoard = {
boardId: this._boardId,
};
const byBoardNoLinked = {
boardId: this._boardId,
linkedId: {
$in: ['', null],
},
};
// we do not want to retrieve boardId in related elements
const noBoardId = {
fields: {
boardId: 0,
},
};
const result = {
_format: 'wekan-board-1.0.0',
};
_.extend(
result,
ReactiveCache.getBoard(this._boardId, {
fields: {
stars: 0,
},
}),
);
result.lists = ReactiveCache.getLists(byBoard, noBoardId);
result.cards = ReactiveCache.getCards(byBoardNoLinked, noBoardId);
result.swimlanes = ReactiveCache.getSwimlanes(byBoard, noBoardId);
result.customFields = ReactiveCache.getCustomFields(
{
boardIds: {
$in: [this.boardId],
},
},
{
fields: {
boardId: 0,
},
},
);
result.comments = ReactiveCache.getCardComments(byBoard, noBoardId);
result.activities = ReactiveCache.getActivities(byBoard, noBoardId);
result.rules = ReactiveCache.getRules(byBoard, noBoardId);
result.checklists = [];
result.checklistItems = [];
result.subtaskItems = [];
result.triggers = [];
result.actions = [];
result.cards.forEach((card) => {
result.checklists.push(
...ReactiveCache.getChecklists({
cardId: card._id,
}),
);
result.checklistItems.push(
...ReactiveCache.getChecklistItems({
cardId: card._id,
}),
);
result.subtaskItems.push(
...ReactiveCache.getCards({
parentId: card._id,
}),
);
});
result.rules.forEach((rule) => {
result.triggers.push(
...ReactiveCache.getTriggers(
{
_id: rule.triggerId,
},
noBoardId,
),
);
result.actions.push(
...ReactiveCache.getActions(
{
_id: rule.actionId,
},
noBoardId,
),
);
});
for (const word of words) {
if (!current) {
current = word;
continue;
}
// we also have to export some user data - as the other elements only
// include id but we have to be careful:
// 1- only exports users that are linked somehow to that board
// 2- do not export any sensitive information
const users = {};
result.members.forEach((member) => {
users[member.userId] = true;
});
result.lists.forEach((list) => {
users[list.userId] = true;
});
result.cards.forEach((card) => {
users[card.userId] = true;
if (card.members) {
card.members.forEach((memberId) => {
users[memberId] = true;
});
}
if (card.assignees) {
card.assignees.forEach((memberId) => {
users[memberId] = true;
});
}
});
result.comments.forEach((comment) => {
users[comment.userId] = true;
});
result.activities.forEach((activity) => {
users[activity.userId] = true;
});
result.checklists.forEach((checklist) => {
users[checklist.userId] = true;
});
const byUserIds = {
_id: {
$in: Object.getOwnPropertyNames(users),
},
};
// we use whitelist to be sure we do not expose inadvertently
// some secret fields that gets added to User later.
const userFields = {
fields: {
_id: 1,
username: 1,
'profile.initials': 1,
'profile.avatarUrl': 1,
},
};
result.users = ReactiveCache.getUsers(byUserIds, userFields)
.map((user) => {
// user avatar is stored as a relative url, we export absolute
if ((user.profile || {}).avatarUrl) {
user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
}
return user;
});
if (`${current} ${word}`.length <= width) {
current = `${current} ${word}`;
continue;
}
//init exceljs workbook
const workbook = createWorkbook();
workbook.creator = TAPi18n.__('export-board');
workbook.lastModifiedBy = TAPi18n.__('export-board');
workbook.created = new Date();
workbook.modified = new Date();
workbook.lastPrinted = new Date();
const filename = `${result.title}.xlsx`;
//init worksheet
const worksheet = workbook.addWorksheet(result.title, {
properties: {
tabColor: {
argb: 'FFC0000',
},
},
pageSetup: {
paperSize: 9,
orientation: 'landscape',
},
});
//get worksheet
const ws = workbook.getWorksheet(result.title);
ws.properties.defaultRowHeight = 20;
//init columns
//Excel font. Western: Arial. zh-CN: 宋体
ws.columns = [
{
key: 'a',
width: 14,
},
{
key: 'b',
width: 40,
},
{
key: 'c',
width: 60,
},
{
key: 'd',
width: 40,
},
{
key: 'e',
width: 20,
},
{
key: 'f',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'g',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'h',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'i',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'j',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'k',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'l',
width: 20,
},
{
key: 'm',
width: 20,
},
{
key: 'n',
width: 20,
},
{
key: 'o',
width: 20,
},
{
key: 'p',
width: 20,
},
{
key: 'q',
width: 20,
},
{
key: 'r',
width: 20,
},
];
wrapped.push(current);
current = word;
}
//add title line
ws.mergeCells('A1:H1');
ws.getCell('A1').value = result.title;
ws.getCell('A1').style = {
font: {
name: TAPi18n.__('excel-font'),
size: '20',
},
};
ws.getCell('A1').alignment = {
vertical: 'middle',
horizontal: 'center',
};
ws.getRow(1).height = 40;
//get member and assignee info
let jmem = '';
let jassig = '';
const jmeml = {};
const jassigl = {};
for (const i in result.users) {
jmem = `${jmem + result.users[i].username},`;
jmeml[result.users[i]._id] = result.users[i].username;
}
jmem = jmem.substr(0, jmem.length - 1);
for (const ia in result.users) {
jassig = `${jassig + result.users[ia].username},`;
jassigl[result.users[ia]._id] = result.users[ia].username;
}
jassig = jassig.substr(0, jassig.length - 1);
//get kanban list info
const jlist = {};
for (const klist in result.lists) {
jlist[result.lists[klist]._id] = result.lists[klist].title;
}
//get kanban swimlanes info
const jswimlane = {};
for (const kswimlane in result.swimlanes) {
jswimlane[result.swimlanes[kswimlane]._id] =
result.swimlanes[kswimlane].title;
}
//get kanban label info
const jlabel = {};
var isFirst = 1;
for (const klabel in result.labels) {
// console.log(klabel);
if (isFirst == 0) {
jlabel[result.labels[klabel]._id] = `,${result.labels[klabel].name}`;
} else {
isFirst = 0;
jlabel[result.labels[klabel]._id] = result.labels[klabel].name;
}
}
//add data +8 hours
function addTZhours(jdate) {
const curdate = new Date(jdate);
const checkCorrectDate = new Date(curdate);
if (isValidDate(checkCorrectDate)) {
return curdate;
} else {
return ' ';
}
////Do not add 8 hours to GMT. Use GMT instead.
////Could not yet figure out how to get localtime.
//return new Date(curdate.setHours(curdate.getHours() + 8));
//return curdate;
}
//add blank row
ws.addRow().values = ['', '', '', '', '', ''];
//add kanban info
ws.addRow().values = [
TAPi18n.__('createdAt'),
addTZhours(result.createdAt),
TAPi18n.__('modifiedAt'),
addTZhours(result.modifiedAt),
TAPi18n.__('members'),
jmem,
];
ws.getRow(3).font = {
name: TAPi18n.__('excel-font'),
size: 10,
bold: true,
};
ws.mergeCells('F3:R3');
ws.getCell('B3').style = {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
bold: true,
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
};
//cell center
function cellCenter(cellno) {
ws.getCell(cellno).alignment = {
vertical: 'middle',
horizontal: 'center',
wrapText: true,
};
}
function cellLeft(cellno) {
ws.getCell(cellno).alignment = {
vertical: 'middle',
horizontal: 'left',
wrapText: true,
};
}
cellCenter('A3');
cellCenter('B3');
cellCenter('C3');
cellCenter('D3');
cellCenter('E3');
cellLeft('F3');
ws.getRow(3).height = 20;
//all border
function allBorder(cellno) {
ws.getCell(cellno).border = {
top: {
style: 'thin',
},
left: {
style: 'thin',
},
bottom: {
style: 'thin',
},
right: {
style: 'thin',
},
};
}
allBorder('A3');
allBorder('B3');
allBorder('C3');
allBorder('D3');
allBorder('E3');
allBorder('F3');
//add blank row
ws.addRow().values = [
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
];
//add card title
//ws.addRow().values = ['编号', '标题', '创建人', '创建时间', '更新时间', '列表', '成员', '描述', '标签'];
//this is where order in which the excel file generates
ws.addRow().values = [
TAPi18n.__('number'),
TAPi18n.__('title'),
TAPi18n.__('description'),
TAPi18n.__('parent-card'),
TAPi18n.__('owner'),
TAPi18n.__('createdAt'),
TAPi18n.__('last-modified-at'),
TAPi18n.__('card-received'),
TAPi18n.__('card-start'),
TAPi18n.__('card-due'),
TAPi18n.__('card-end'),
TAPi18n.__('list'),
TAPi18n.__('swimlane'),
TAPi18n.__('assignee'),
TAPi18n.__('members'),
TAPi18n.__('labels'),
TAPi18n.__('overtime-hours'),
TAPi18n.__('spent-time-hours'),
];
ws.getRow(5).height = 20;
allBorder('A5');
allBorder('B5');
allBorder('C5');
allBorder('D5');
allBorder('E5');
allBorder('F5');
allBorder('G5');
allBorder('H5');
allBorder('I5');
allBorder('J5');
allBorder('K5');
allBorder('L5');
allBorder('M5');
allBorder('N5');
allBorder('O5');
allBorder('P5');
allBorder('Q5');
allBorder('R5');
cellCenter('A5');
cellCenter('B5');
cellCenter('C5');
cellCenter('D5');
cellCenter('E5');
cellCenter('F5');
cellCenter('G5');
cellCenter('H5');
cellCenter('I5');
cellCenter('J5');
cellCenter('K5');
cellCenter('L5');
cellCenter('M5');
cellCenter('N5');
cellCenter('O5');
cellCenter('P5');
cellCenter('Q5');
cellCenter('R5');
ws.getRow(5).font = {
name: TAPi18n.__('excel-font'),
size: 12,
bold: true,
};
//add blank row
//add card info
for (const i in result.cards) {
const jcard = result.cards[i];
//get member info
let jcmem = '';
for (const j in jcard.members) {
jcmem += jmeml[jcard.members[j]];
jcmem += ' ';
}
//get assignee info
let jcassig = '';
for (const ja in jcard.assignees) {
jcassig += jassigl[jcard.assignees[ja]];
jcassig += ' ';
}
//get card label info
let jclabel = '';
for (const jl in jcard.labelIds) {
jclabel += jlabel[jcard.labelIds[jl]];
jclabel += ' ';
}
//get parent name
if (jcard.parentId) {
const parentCard = result.cards.find(
(card) => card._id === jcard.parentId,
);
jcard.parentCardTitle = parentCard ? parentCard.title : '';
}
if (current) {
wrapped.push(current);
}
//add card detail
const t = Number(i) + 1;
ws.addRow().values = [
t.toString(),
jcard.title,
jcard.description,
jcard.parentCardTitle,
jmeml[jcard.userId],
addTZhours(jcard.createdAt),
addTZhours(jcard.dateLastActivity),
addTZhours(jcard.receivedAt),
addTZhours(jcard.startAt),
addTZhours(jcard.dueAt),
addTZhours(jcard.endAt),
jlist[jcard.listId],
jswimlane[jcard.swimlaneId],
jcassig,
jcmem,
jclabel,
jcard.isOvertime ? 'true' : 'false',
jcard.spentTime,
];
const y = Number(i) + 6;
//ws.getRow(y).height = 25;
allBorder(`A${y}`);
allBorder(`B${y}`);
allBorder(`C${y}`);
allBorder(`D${y}`);
allBorder(`E${y}`);
allBorder(`F${y}`);
allBorder(`G${y}`);
allBorder(`H${y}`);
allBorder(`I${y}`);
allBorder(`J${y}`);
allBorder(`K${y}`);
allBorder(`L${y}`);
allBorder(`M${y}`);
allBorder(`N${y}`);
allBorder(`O${y}`);
allBorder(`P${y}`);
allBorder(`Q${y}`);
allBorder(`R${y}`);
cellCenter(`A${y}`);
ws.getCell(`B${y}`).alignment = {
wrapText: true,
};
ws.getCell(`C${y}`).alignment = {
wrapText: true,
};
ws.getCell(`M${y}`).alignment = {
wrapText: true,
};
ws.getCell(`N${y}`).alignment = {
wrapText: true,
};
ws.getCell(`O${y}`).alignment = {
wrapText: true,
};
}
workbook.xlsx.write(res).then(function () {});
*/
const splitLongWords = [];
for (const item of wrapped) {
if (item.length <= width) {
splitLongWords.push(item);
continue;
}
var doc = new PDFDocument({size: 'A4', margin: 50});
doc.fontSize(12);
doc.text('Some test text', 10, 30, {align: 'center', width: 200});
this.response.writeHead(200, {
'Content-type': 'application/pdf',
'Content-Disposition': "attachment; filename=test.pdf"
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`);
});
this.response.end( doc.outputSync() );
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) {