exportExcel.js 15 KB

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