Просмотр исходного кода

Export Card to PDF. In Progress.

Thanks to xet7 !
Lauri Ojansivu 4 лет назад
Родитель
Сommit
a2f2ce1135

+ 1 - 0
.meteor/packages

@@ -144,3 +144,4 @@ rajit:bootstrap3-datepicker-zh-tw
 staringatlights:fast-render
 spacebars
 easylogic:summernote
+pascoual:pdfkit

+ 1 - 0
.meteor/versions

@@ -114,6 +114,7 @@ oauth2@1.3.0
 observe-sequence@1.0.16
 ongoworks:speakingurl@1.1.0
 ordered-dict@1.1.0
+pascoual:pdfkit@1.0.7
 peerlibrary:assert@0.3.0
 peerlibrary:base-component@0.16.0
 peerlibrary:blaze-components@0.15.1

+ 13 - 0
client/components/cards/cardDetails.jade

@@ -609,6 +609,12 @@ template(name="cardDetailsActionsPopup")
             i.fa.fa-paint-brush
             | {{_ 'setCardColorPopup-title'}}
       hr
+    ul.pop-over-list
+      li
+        a.js-export-card
+          i.fa.fa-share-alt
+          | {{_ 'export-card'}}
+      hr
     ul.pop-over-list
       li
         a.js-move-card-to-top
@@ -653,6 +659,13 @@ template(name="cardDetailsActionsPopup")
             i.fa.fa-link
             | {{_ 'cardMorePopup-title'}}
 
+template(name="exportCard")
+  ul.pop-over-list
+    li
+      a(href="{{exportUrlCardPDF}}",, download="{{exportFilenameCardPDF}}")
+        i.fa.fa-share-alt
+        | {{_ 'export-card-pdf'}}
+
 template(name="moveCardPopup")
   +boardsAndLists
 

+ 31 - 1
client/components/cards/cardDetails.js

@@ -511,9 +511,38 @@ BlazeComponent.extendComponent({
   },
 }).register('cardDetails');
 
+BlazeComponent.extendComponent({
+  template() {
+    return 'exportCard';
+  },
+  withApi() {
+    return Template.instance().apiEnabled.get();
+  },
+  exportUrlCardPDF() {
+    const params = {
+      boardId: Session.get('currentBoard'),
+      listId: this.listId,
+      cardId: this.cardId,
+    };
+    const queryParams = {
+      authToken: Accounts._storedLoginToken(),
+    };
+    return FlowRouter.path(
+      '/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF',
+      params,
+      queryParams,
+    );
+  },
+  exportFilenameCardPDF() {
+    //const boardId = Session.get('currentBoard');
+    //return `export-card-pdf-${boardId}.xlsx`;
+    return `export-card.pdf`;
+  },
+}).register('exportCardPopup');
+
 // only allow number input
 Template.editCardSortOrderForm.onRendered(function() {
-  this.$('input').on("keypress paste", function() {
+  this.$('input').on("keypress paste", function(event) {
     let keyCode = event.keyCode;
     let charCode = String.fromCharCode(keyCode);
     let regex = new RegExp('[-0-9.]');
@@ -583,6 +612,7 @@ Template.cardDetailsActionsPopup.helpers({
 });
 
 Template.cardDetailsActionsPopup.events({
+  'click .js-export-card': Popup.open('exportCard'),
   'click .js-members': Popup.open('cardMembers'),
   'click .js-assignees': Popup.open('cardAssignees'),
   'click .js-labels': Popup.open('cardLabels'),

+ 4 - 0
i18n/en.i18n.json

@@ -358,7 +358,11 @@
   "export-board-excel": "Export board to Excel",
   "user-can-not-export-excel": "User can not export Excel",
   "export-board-html": "Export board to HTML",
+  "export-card": "Export card",
+  "export-card-pdf": "Export card to PDF",
+  "user-can-not-export-card-to-pdf": "User can not export card to PDF",
   "exportBoardPopup-title": "Export board",
+  "exportCardPopup-title": "Export card",
   "sort": "Sort",
   "sort-desc": "Click to Sort List",
   "list-sort-by": "Sort the List By:",

+ 691 - 0
models/exportPDF.js

@@ -0,0 +1,691 @@
+if (Meteor.isServer) {
+  // todo XXX once we have a real API in place, move that route there
+  // todo XXX also  share the route definition between the client and the server
+  // so that we could use something like
+  // `ApiRoutes.path('boards/exportExcel', boardId)``
+  // on the client instead of copy/pasting the route path manually between the
+  // client and the server.
+  /**
+   * @operation exportExcel
+   * @tag Boards
+   *
+   * @summary This route is used to export the board Excel.
+   *
+   * @description If user is already logged-in, pass loginToken as param
+   * "authToken": '/api/boards/:boardId/exportExcel?authToken=:token'
+   *
+   * See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
+   * for detailed explanations
+   *
+   * @param {string} boardId the ID of the board we are exporting
+   * @param {string} authToken the loginToken
+   */
+  const Excel = require('exceljs');
+  Picker.route('/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF', function (params, req, res) {
+    const boardId = params.boardId;
+    const paramListId = req.params.listId;
+    const paramCardId = req.params.cardId;
+    let user = null;
+    let impersonateDone = false;
+    let adminId = null;
+    const loginToken = params.query.authToken;
+    if (loginToken) {
+      const hashToken = Accounts._hashLoginToken(loginToken);
+      user = Meteor.users.findOne({
+        'services.resume.loginTokens.hashedToken': hashToken,
+      });
+      adminId = user._id.toString();
+      impersonateDone = ImpersonatedUsers.findOne({
+        adminId: adminId,
+      });
+    } else if (!Meteor.settings.public.sandstorm) {
+      Authentication.checkUserId(req.userId);
+      user = Users.findOne({
+        _id: req.userId,
+        isAdmin: true,
+      });
+    }
+    const exporterExcel = new ExporterCardPDF(boardId);
+    if (exporterCardPDF.canExport(user) || impersonateDone) {
+      if (impersonateDone) {
+        ImpersonatedUsers.insert({
+          adminId: adminId,
+          boardId: boardId,
+          reason: 'exportCardPDF',
+        });
+      }
+
+      exporterCardPDF.build(res);
+    } else {
+      res.end(TAPi18n.__('user-can-not-export-card-to-pdf'));
+    }
+  });
+}
+
+// exporter maybe is broken since Gridfs introduced, add fs and path
+
+export class ExporterCardPDF {
+  constructor(boardId) {
+    this._boardId = boardId;
+  }
+
+  build(res) {
+
+/*
+    const fs = Npm.require('fs');
+    const os = Npm.require('os');
+    const path = Npm.require('path');
+
+    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,
+      Boards.findOne(this._boardId, {
+        fields: {
+          stars: 0,
+        },
+      }),
+    );
+    result.lists = Lists.find(byBoard, noBoardId).fetch();
+    result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
+    result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
+    result.customFields = CustomFields.find(
+      {
+        boardIds: {
+          $in: [this.boardId],
+        },
+      },
+      {
+        fields: {
+          boardId: 0,
+        },
+      },
+    ).fetch();
+    result.comments = CardComments.find(byBoard, noBoardId).fetch();
+    result.activities = Activities.find(byBoard, noBoardId).fetch();
+    result.rules = Rules.find(byBoard, noBoardId).fetch();
+    result.checklists = [];
+    result.checklistItems = [];
+    result.subtaskItems = [];
+    result.triggers = [];
+    result.actions = [];
+    result.cards.forEach((card) => {
+      result.checklists.push(
+        ...Checklists.find({
+          cardId: card._id,
+        }).fetch(),
+      );
+      result.checklistItems.push(
+        ...ChecklistItems.find({
+          cardId: card._id,
+        }).fetch(),
+      );
+      result.subtaskItems.push(
+        ...Cards.find({
+          parentId: card._id,
+        }).fetch(),
+      );
+    });
+    result.rules.forEach((rule) => {
+      result.triggers.push(
+        ...Triggers.find(
+          {
+            _id: rule.triggerId,
+          },
+          noBoardId,
+        ).fetch(),
+      );
+      result.actions.push(
+        ...Actions.find(
+          {
+            _id: rule.actionId,
+          },
+          noBoardId,
+        ).fetch(),
+      );
+    });
+
+    // 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 = Users.find(byUserIds, userFields)
+      .fetch()
+      .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;
+      });
+
+    //init exceljs workbook
+    const Excel = require('exceljs');
+    const workbook = new Excel.Workbook();
+    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,
+      },
+    ];
+
+    //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 = moment(curdate);
+      if (checkCorrectDate.isValid()) {
+        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 : '';
+      }
+
+      //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 () {});
+    */
+
+    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"
+    });
+    this.response.end( doc.outputSync() );
+
+  }
+
+  canExport(user) {
+    const board = Boards.findOne(this._boardId);
+    return board && board.isVisibleBy(user);
+  }
+}