exportPDF.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. if (Meteor.isServer) {
  2. // todo XXX once we have a real API in place, move that route there
  3. // todo XXX also share the route definition between the client and the server
  4. // so that we could use something like
  5. // `ApiRoutes.path('boards/exportExcel', boardId)``
  6. // on the client instead of copy/pasting the route path manually between the
  7. // client and the server.
  8. /**
  9. * @operation exportExcel
  10. * @tag Boards
  11. *
  12. * @summary This route is used to export the board Excel.
  13. *
  14. * @description If user is already logged-in, pass loginToken as param
  15. * "authToken": '/api/boards/:boardId/exportExcel?authToken=:token'
  16. *
  17. * See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
  18. * for detailed explanations
  19. *
  20. * @param {string} boardId the ID of the board we are exporting
  21. * @param {string} authToken the loginToken
  22. */
  23. const Excel = require('exceljs');
  24. Picker.route('/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF', function (params, req, res) {
  25. const boardId = params.boardId;
  26. const paramListId = req.params.listId;
  27. const paramCardId = req.params.cardId;
  28. let user = null;
  29. let impersonateDone = false;
  30. let adminId = null;
  31. const loginToken = params.query.authToken;
  32. if (loginToken) {
  33. const hashToken = Accounts._hashLoginToken(loginToken);
  34. user = Meteor.users.findOne({
  35. 'services.resume.loginTokens.hashedToken': hashToken,
  36. });
  37. adminId = user._id.toString();
  38. impersonateDone = ImpersonatedUsers.findOne({
  39. adminId: adminId,
  40. });
  41. } else if (!Meteor.settings.public.sandstorm) {
  42. Authentication.checkUserId(req.userId);
  43. user = Users.findOne({
  44. _id: req.userId,
  45. isAdmin: true,
  46. });
  47. }
  48. const exporterExcel = new ExporterCardPDF(boardId);
  49. if (exporterCardPDF.canExport(user) || impersonateDone) {
  50. if (impersonateDone) {
  51. ImpersonatedUsers.insert({
  52. adminId: adminId,
  53. boardId: boardId,
  54. reason: 'exportCardPDF',
  55. });
  56. }
  57. exporterCardPDF.build(res);
  58. } else {
  59. res.end(TAPi18n.__('user-can-not-export-card-to-pdf'));
  60. }
  61. });
  62. }
  63. // exporter maybe is broken since Gridfs introduced, add fs and path
  64. export class ExporterCardPDF {
  65. constructor(boardId) {
  66. this._boardId = boardId;
  67. }
  68. build(res) {
  69. /*
  70. const fs = Npm.require('fs');
  71. const os = Npm.require('os');
  72. const path = Npm.require('path');
  73. const byBoard = {
  74. boardId: this._boardId,
  75. };
  76. const byBoardNoLinked = {
  77. boardId: this._boardId,
  78. linkedId: {
  79. $in: ['', null],
  80. },
  81. };
  82. // we do not want to retrieve boardId in related elements
  83. const noBoardId = {
  84. fields: {
  85. boardId: 0,
  86. },
  87. };
  88. const result = {
  89. _format: 'wekan-board-1.0.0',
  90. };
  91. _.extend(
  92. result,
  93. Boards.findOne(this._boardId, {
  94. fields: {
  95. stars: 0,
  96. },
  97. }),
  98. );
  99. result.lists = Lists.find(byBoard, noBoardId).fetch();
  100. result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
  101. result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
  102. result.customFields = CustomFields.find(
  103. {
  104. boardIds: {
  105. $in: [this.boardId],
  106. },
  107. },
  108. {
  109. fields: {
  110. boardId: 0,
  111. },
  112. },
  113. ).fetch();
  114. result.comments = CardComments.find(byBoard, noBoardId).fetch();
  115. result.activities = Activities.find(byBoard, noBoardId).fetch();
  116. result.rules = Rules.find(byBoard, noBoardId).fetch();
  117. result.checklists = [];
  118. result.checklistItems = [];
  119. result.subtaskItems = [];
  120. result.triggers = [];
  121. result.actions = [];
  122. result.cards.forEach((card) => {
  123. result.checklists.push(
  124. ...Checklists.find({
  125. cardId: card._id,
  126. }).fetch(),
  127. );
  128. result.checklistItems.push(
  129. ...ChecklistItems.find({
  130. cardId: card._id,
  131. }).fetch(),
  132. );
  133. result.subtaskItems.push(
  134. ...Cards.find({
  135. parentId: card._id,
  136. }).fetch(),
  137. );
  138. });
  139. result.rules.forEach((rule) => {
  140. result.triggers.push(
  141. ...Triggers.find(
  142. {
  143. _id: rule.triggerId,
  144. },
  145. noBoardId,
  146. ).fetch(),
  147. );
  148. result.actions.push(
  149. ...Actions.find(
  150. {
  151. _id: rule.actionId,
  152. },
  153. noBoardId,
  154. ).fetch(),
  155. );
  156. });
  157. // we also have to export some user data - as the other elements only
  158. // include id but we have to be careful:
  159. // 1- only exports users that are linked somehow to that board
  160. // 2- do not export any sensitive information
  161. const users = {};
  162. result.members.forEach((member) => {
  163. users[member.userId] = true;
  164. });
  165. result.lists.forEach((list) => {
  166. users[list.userId] = true;
  167. });
  168. result.cards.forEach((card) => {
  169. users[card.userId] = true;
  170. if (card.members) {
  171. card.members.forEach((memberId) => {
  172. users[memberId] = true;
  173. });
  174. }
  175. if (card.assignees) {
  176. card.assignees.forEach((memberId) => {
  177. users[memberId] = true;
  178. });
  179. }
  180. });
  181. result.comments.forEach((comment) => {
  182. users[comment.userId] = true;
  183. });
  184. result.activities.forEach((activity) => {
  185. users[activity.userId] = true;
  186. });
  187. result.checklists.forEach((checklist) => {
  188. users[checklist.userId] = true;
  189. });
  190. const byUserIds = {
  191. _id: {
  192. $in: Object.getOwnPropertyNames(users),
  193. },
  194. };
  195. // we use whitelist to be sure we do not expose inadvertently
  196. // some secret fields that gets added to User later.
  197. const userFields = {
  198. fields: {
  199. _id: 1,
  200. username: 1,
  201. 'profile.initials': 1,
  202. 'profile.avatarUrl': 1,
  203. },
  204. };
  205. result.users = Users.find(byUserIds, userFields)
  206. .fetch()
  207. .map((user) => {
  208. // user avatar is stored as a relative url, we export absolute
  209. if ((user.profile || {}).avatarUrl) {
  210. user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
  211. }
  212. return user;
  213. });
  214. //init exceljs workbook
  215. const Excel = require('exceljs');
  216. const workbook = new Excel.Workbook();
  217. workbook.creator = TAPi18n.__('export-board');
  218. workbook.lastModifiedBy = TAPi18n.__('export-board');
  219. workbook.created = new Date();
  220. workbook.modified = new Date();
  221. workbook.lastPrinted = new Date();
  222. const filename = `${result.title}.xlsx`;
  223. //init worksheet
  224. const worksheet = workbook.addWorksheet(result.title, {
  225. properties: {
  226. tabColor: {
  227. argb: 'FFC0000',
  228. },
  229. },
  230. pageSetup: {
  231. paperSize: 9,
  232. orientation: 'landscape',
  233. },
  234. });
  235. //get worksheet
  236. const ws = workbook.getWorksheet(result.title);
  237. ws.properties.defaultRowHeight = 20;
  238. //init columns
  239. //Excel font. Western: Arial. zh-CN: 宋体
  240. ws.columns = [
  241. {
  242. key: 'a',
  243. width: 14,
  244. },
  245. {
  246. key: 'b',
  247. width: 40,
  248. },
  249. {
  250. key: 'c',
  251. width: 60,
  252. },
  253. {
  254. key: 'd',
  255. width: 40,
  256. },
  257. {
  258. key: 'e',
  259. width: 20,
  260. },
  261. {
  262. key: 'f',
  263. width: 20,
  264. style: {
  265. font: {
  266. name: TAPi18n.__('excel-font'),
  267. size: '10',
  268. },
  269. numFmt: 'yyyy/mm/dd hh:mm:ss',
  270. },
  271. },
  272. {
  273. key: 'g',
  274. width: 20,
  275. style: {
  276. font: {
  277. name: TAPi18n.__('excel-font'),
  278. size: '10',
  279. },
  280. numFmt: 'yyyy/mm/dd hh:mm:ss',
  281. },
  282. },
  283. {
  284. key: 'h',
  285. width: 20,
  286. style: {
  287. font: {
  288. name: TAPi18n.__('excel-font'),
  289. size: '10',
  290. },
  291. numFmt: 'yyyy/mm/dd hh:mm:ss',
  292. },
  293. },
  294. {
  295. key: 'i',
  296. width: 20,
  297. style: {
  298. font: {
  299. name: TAPi18n.__('excel-font'),
  300. size: '10',
  301. },
  302. numFmt: 'yyyy/mm/dd hh:mm:ss',
  303. },
  304. },
  305. {
  306. key: 'j',
  307. width: 20,
  308. style: {
  309. font: {
  310. name: TAPi18n.__('excel-font'),
  311. size: '10',
  312. },
  313. numFmt: 'yyyy/mm/dd hh:mm:ss',
  314. },
  315. },
  316. {
  317. key: 'k',
  318. width: 20,
  319. style: {
  320. font: {
  321. name: TAPi18n.__('excel-font'),
  322. size: '10',
  323. },
  324. numFmt: 'yyyy/mm/dd hh:mm:ss',
  325. },
  326. },
  327. {
  328. key: 'l',
  329. width: 20,
  330. },
  331. {
  332. key: 'm',
  333. width: 20,
  334. },
  335. {
  336. key: 'n',
  337. width: 20,
  338. },
  339. {
  340. key: 'o',
  341. width: 20,
  342. },
  343. {
  344. key: 'p',
  345. width: 20,
  346. },
  347. {
  348. key: 'q',
  349. width: 20,
  350. },
  351. {
  352. key: 'r',
  353. width: 20,
  354. },
  355. ];
  356. //add title line
  357. ws.mergeCells('A1:H1');
  358. ws.getCell('A1').value = result.title;
  359. ws.getCell('A1').style = {
  360. font: {
  361. name: TAPi18n.__('excel-font'),
  362. size: '20',
  363. },
  364. };
  365. ws.getCell('A1').alignment = {
  366. vertical: 'middle',
  367. horizontal: 'center',
  368. };
  369. ws.getRow(1).height = 40;
  370. //get member and assignee info
  371. let jmem = '';
  372. let jassig = '';
  373. const jmeml = {};
  374. const jassigl = {};
  375. for (const i in result.users) {
  376. jmem = `${jmem + result.users[i].username},`;
  377. jmeml[result.users[i]._id] = result.users[i].username;
  378. }
  379. jmem = jmem.substr(0, jmem.length - 1);
  380. for (const ia in result.users) {
  381. jassig = `${jassig + result.users[ia].username},`;
  382. jassigl[result.users[ia]._id] = result.users[ia].username;
  383. }
  384. jassig = jassig.substr(0, jassig.length - 1);
  385. //get kanban list info
  386. const jlist = {};
  387. for (const klist in result.lists) {
  388. jlist[result.lists[klist]._id] = result.lists[klist].title;
  389. }
  390. //get kanban swimlanes info
  391. const jswimlane = {};
  392. for (const kswimlane in result.swimlanes) {
  393. jswimlane[result.swimlanes[kswimlane]._id] =
  394. result.swimlanes[kswimlane].title;
  395. }
  396. //get kanban label info
  397. const jlabel = {};
  398. var isFirst = 1;
  399. for (const klabel in result.labels) {
  400. // console.log(klabel);
  401. if (isFirst == 0) {
  402. jlabel[result.labels[klabel]._id] = `,${result.labels[klabel].name}`;
  403. } else {
  404. isFirst = 0;
  405. jlabel[result.labels[klabel]._id] = result.labels[klabel].name;
  406. }
  407. }
  408. //add data +8 hours
  409. function addTZhours(jdate) {
  410. const curdate = new Date(jdate);
  411. const checkCorrectDate = moment(curdate);
  412. if (checkCorrectDate.isValid()) {
  413. return curdate;
  414. } else {
  415. return ' ';
  416. }
  417. ////Do not add 8 hours to GMT. Use GMT instead.
  418. ////Could not yet figure out how to get localtime.
  419. //return new Date(curdate.setHours(curdate.getHours() + 8));
  420. //return curdate;
  421. }
  422. //add blank row
  423. ws.addRow().values = ['', '', '', '', '', ''];
  424. //add kanban info
  425. ws.addRow().values = [
  426. TAPi18n.__('createdAt'),
  427. addTZhours(result.createdAt),
  428. TAPi18n.__('modifiedAt'),
  429. addTZhours(result.modifiedAt),
  430. TAPi18n.__('members'),
  431. jmem,
  432. ];
  433. ws.getRow(3).font = {
  434. name: TAPi18n.__('excel-font'),
  435. size: 10,
  436. bold: true,
  437. };
  438. ws.mergeCells('F3:R3');
  439. ws.getCell('B3').style = {
  440. font: {
  441. name: TAPi18n.__('excel-font'),
  442. size: '10',
  443. bold: true,
  444. },
  445. numFmt: 'yyyy/mm/dd hh:mm:ss',
  446. };
  447. //cell center
  448. function cellCenter(cellno) {
  449. ws.getCell(cellno).alignment = {
  450. vertical: 'middle',
  451. horizontal: 'center',
  452. wrapText: true,
  453. };
  454. }
  455. function cellLeft(cellno) {
  456. ws.getCell(cellno).alignment = {
  457. vertical: 'middle',
  458. horizontal: 'left',
  459. wrapText: true,
  460. };
  461. }
  462. cellCenter('A3');
  463. cellCenter('B3');
  464. cellCenter('C3');
  465. cellCenter('D3');
  466. cellCenter('E3');
  467. cellLeft('F3');
  468. ws.getRow(3).height = 20;
  469. //all border
  470. function allBorder(cellno) {
  471. ws.getCell(cellno).border = {
  472. top: {
  473. style: 'thin',
  474. },
  475. left: {
  476. style: 'thin',
  477. },
  478. bottom: {
  479. style: 'thin',
  480. },
  481. right: {
  482. style: 'thin',
  483. },
  484. };
  485. }
  486. allBorder('A3');
  487. allBorder('B3');
  488. allBorder('C3');
  489. allBorder('D3');
  490. allBorder('E3');
  491. allBorder('F3');
  492. //add blank row
  493. ws.addRow().values = [
  494. '',
  495. '',
  496. '',
  497. '',
  498. '',
  499. '',
  500. '',
  501. '',
  502. '',
  503. '',
  504. '',
  505. '',
  506. '',
  507. '',
  508. '',
  509. ];
  510. //add card title
  511. //ws.addRow().values = ['编号', '标题', '创建人', '创建时间', '更新时间', '列表', '成员', '描述', '标签'];
  512. //this is where order in which the excel file generates
  513. ws.addRow().values = [
  514. TAPi18n.__('number'),
  515. TAPi18n.__('title'),
  516. TAPi18n.__('description'),
  517. TAPi18n.__('parent-card'),
  518. TAPi18n.__('owner'),
  519. TAPi18n.__('createdAt'),
  520. TAPi18n.__('last-modified-at'),
  521. TAPi18n.__('card-received'),
  522. TAPi18n.__('card-start'),
  523. TAPi18n.__('card-due'),
  524. TAPi18n.__('card-end'),
  525. TAPi18n.__('list'),
  526. TAPi18n.__('swimlane'),
  527. TAPi18n.__('assignee'),
  528. TAPi18n.__('members'),
  529. TAPi18n.__('labels'),
  530. TAPi18n.__('overtime-hours'),
  531. TAPi18n.__('spent-time-hours'),
  532. ];
  533. ws.getRow(5).height = 20;
  534. allBorder('A5');
  535. allBorder('B5');
  536. allBorder('C5');
  537. allBorder('D5');
  538. allBorder('E5');
  539. allBorder('F5');
  540. allBorder('G5');
  541. allBorder('H5');
  542. allBorder('I5');
  543. allBorder('J5');
  544. allBorder('K5');
  545. allBorder('L5');
  546. allBorder('M5');
  547. allBorder('N5');
  548. allBorder('O5');
  549. allBorder('P5');
  550. allBorder('Q5');
  551. allBorder('R5');
  552. cellCenter('A5');
  553. cellCenter('B5');
  554. cellCenter('C5');
  555. cellCenter('D5');
  556. cellCenter('E5');
  557. cellCenter('F5');
  558. cellCenter('G5');
  559. cellCenter('H5');
  560. cellCenter('I5');
  561. cellCenter('J5');
  562. cellCenter('K5');
  563. cellCenter('L5');
  564. cellCenter('M5');
  565. cellCenter('N5');
  566. cellCenter('O5');
  567. cellCenter('P5');
  568. cellCenter('Q5');
  569. cellCenter('R5');
  570. ws.getRow(5).font = {
  571. name: TAPi18n.__('excel-font'),
  572. size: 12,
  573. bold: true,
  574. };
  575. //add blank row
  576. //add card info
  577. for (const i in result.cards) {
  578. const jcard = result.cards[i];
  579. //get member info
  580. let jcmem = '';
  581. for (const j in jcard.members) {
  582. jcmem += jmeml[jcard.members[j]];
  583. jcmem += ' ';
  584. }
  585. //get assignee info
  586. let jcassig = '';
  587. for (const ja in jcard.assignees) {
  588. jcassig += jassigl[jcard.assignees[ja]];
  589. jcassig += ' ';
  590. }
  591. //get card label info
  592. let jclabel = '';
  593. for (const jl in jcard.labelIds) {
  594. jclabel += jlabel[jcard.labelIds[jl]];
  595. jclabel += ' ';
  596. }
  597. //get parent name
  598. if (jcard.parentId) {
  599. const parentCard = result.cards.find(
  600. (card) => card._id === jcard.parentId,
  601. );
  602. jcard.parentCardTitle = parentCard ? parentCard.title : '';
  603. }
  604. //add card detail
  605. const t = Number(i) + 1;
  606. ws.addRow().values = [
  607. t.toString(),
  608. jcard.title,
  609. jcard.description,
  610. jcard.parentCardTitle,
  611. jmeml[jcard.userId],
  612. addTZhours(jcard.createdAt),
  613. addTZhours(jcard.dateLastActivity),
  614. addTZhours(jcard.receivedAt),
  615. addTZhours(jcard.startAt),
  616. addTZhours(jcard.dueAt),
  617. addTZhours(jcard.endAt),
  618. jlist[jcard.listId],
  619. jswimlane[jcard.swimlaneId],
  620. jcassig,
  621. jcmem,
  622. jclabel,
  623. jcard.isOvertime ? 'true' : 'false',
  624. jcard.spentTime,
  625. ];
  626. const y = Number(i) + 6;
  627. //ws.getRow(y).height = 25;
  628. allBorder(`A${y}`);
  629. allBorder(`B${y}`);
  630. allBorder(`C${y}`);
  631. allBorder(`D${y}`);
  632. allBorder(`E${y}`);
  633. allBorder(`F${y}`);
  634. allBorder(`G${y}`);
  635. allBorder(`H${y}`);
  636. allBorder(`I${y}`);
  637. allBorder(`J${y}`);
  638. allBorder(`K${y}`);
  639. allBorder(`L${y}`);
  640. allBorder(`M${y}`);
  641. allBorder(`N${y}`);
  642. allBorder(`O${y}`);
  643. allBorder(`P${y}`);
  644. allBorder(`Q${y}`);
  645. allBorder(`R${y}`);
  646. cellCenter(`A${y}`);
  647. ws.getCell(`B${y}`).alignment = {
  648. wrapText: true,
  649. };
  650. ws.getCell(`C${y}`).alignment = {
  651. wrapText: true,
  652. };
  653. ws.getCell(`M${y}`).alignment = {
  654. wrapText: true,
  655. };
  656. ws.getCell(`N${y}`).alignment = {
  657. wrapText: true,
  658. };
  659. ws.getCell(`O${y}`).alignment = {
  660. wrapText: true,
  661. };
  662. }
  663. workbook.xlsx.write(res).then(function () {});
  664. */
  665. var doc = new PDFDocument({size: 'A4', margin: 50});
  666. doc.fontSize(12);
  667. doc.text('Some test text', 10, 30, {align: 'center', width: 200});
  668. this.response.writeHead(200, {
  669. 'Content-type': 'application/pdf',
  670. 'Content-Disposition': "attachment; filename=test.pdf"
  671. });
  672. this.response.end( doc.outputSync() );
  673. }
  674. canExport(user) {
  675. const board = Boards.findOne(this._boardId);
  676. return board && board.isVisibleBy(user);
  677. }
  678. }