mirror of
https://github.com/wekan/wekan.git
synced 2026-03-13 17:06:13 +01:00
Update PDF export models
Clean up exportPDF and ExporterCardPDF server-side code to align with the BlazeComponent removal changes.
This commit is contained in:
parent
477e1c89e5
commit
6953cc0670
2 changed files with 332 additions and 634 deletions
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue