exportExcel.js 17 KB

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