diff --git a/models/exportPDF.js b/models/exportPDF.js index 14173c711..4b485e93e 100644 --- a/models/exportPDF.js +++ b/models/exportPDF.js @@ -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')); } diff --git a/models/server/ExporterCardPDF.js b/models/server/ExporterCardPDF.js index 454c17f4c..fada5d593 100644 --- a/models/server/ExporterCardPDF.js +++ b/models/server/ExporterCardPDF.js @@ -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) {