Browse Source

Merge branch 'add-more-import-export-options' of https://github.com/brymut/wekan into brymut-add-more-import-export-options

Lauri Ojansivu 5 năm trước cách đây
mục cha
commit
851bfa53e6

+ 37 - 0
client/components/import/csvMembersMapper.js

@@ -0,0 +1,37 @@
+export function getMembersToMap(data) {
+  // we will work on the list itself (an ordered array of objects) when a
+  // mapping is done, we add a 'wekan' field to the object representing the
+  // imported member
+
+  const membersToMap = [];
+  const importedMembers = [];
+  let membersIndex;
+
+  for (let i = 0; i < data[0].length; i++) {
+    if (data[0][i].toLowerCase() === 'members') {
+      membersIndex = i;
+    }
+  }
+
+  for (let i = 1; i < data.length; i++) {
+    if (data[i][membersIndex]) {
+      for (const importedMember of data[i][membersIndex].split(' ')) {
+        if (importedMember && importedMembers.indexOf(importedMember) === -1) {
+          importedMembers.push(importedMember);
+        }
+      }
+    }
+  }
+
+  for (let importedMember of importedMembers) {
+    importedMember = {
+      username: importedMember,
+      id: importedMember,
+    };
+    const wekanUser = Users.findOne({ username: importedMember.username });
+    if (wekanUser) importedMember.wekanId = wekanUser._id;
+    membersToMap.push(importedMember);
+  }
+
+  return membersToMap;
+}

+ 1 - 1
client/components/import/import.jade

@@ -13,7 +13,7 @@ template(name="import")
 template(name="importTextarea")
   form
     p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
-    textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus)
+    textarea.js-import-json(placeholder="{{_ importPlaceHolder}}" autofocus)
       | {{jsonText}}
     input.primary.wide(type="submit" value="{{_ 'import'}}")
 

+ 40 - 12
client/components/import/import.js

@@ -1,5 +1,8 @@
 import trelloMembersMapper from './trelloMembersMapper';
 import wekanMembersMapper from './wekanMembersMapper';
+import csvMembersMapper from './csvMembersMapper';
+
+const Papa = require('papaparse');
 
 BlazeComponent.extendComponent({
   title() {
@@ -30,20 +33,30 @@ BlazeComponent.extendComponent({
     }
   },
 
-  importData(evt) {
+  importData(evt, dataSource) {
     evt.preventDefault();
-    const dataJson = this.find('.js-import-json').value;
-    try {
-      const dataObject = JSON.parse(dataJson);
-      this.setError('');
-      this.importedData.set(dataObject);
-      const membersToMap = this._prepareAdditionalData(dataObject);
-      // store members data and mapping in Session
-      // (we go deep and 2-way, so storing in data context is not a viable option)
+    const input = this.find('.js-import-json').value;
+    if (dataSource === 'csv') {
+      const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
+      const ret = Papa.parse(csv);
+      if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
+      else throw new Meteor.Error('error-csv-schema');
+      const membersToMap = this._prepareAdditionalData(ret.data);
       this.membersToMap.set(membersToMap);
       this.nextStep();
-    } catch (e) {
-      this.setError('error-json-malformed');
+    } else {
+      try {
+        const dataObject = JSON.parse(input);
+        this.setError('');
+        this.importedData.set(dataObject);
+        const membersToMap = this._prepareAdditionalData(dataObject);
+        // store members data and mapping in Session
+        // (we go deep and 2-way, so storing in data context is not a viable option)
+        this.membersToMap.set(membersToMap);
+        this.nextStep();
+      } catch (e) {
+        this.setError('error-json-malformed');
+      }
     }
   },
 
@@ -91,6 +104,9 @@ BlazeComponent.extendComponent({
       case 'wekan':
         membersToMap = wekanMembersMapper.getMembersToMap(dataObject);
         break;
+      case 'csv':
+        membersToMap = csvMembersMapper.getMembersToMap(dataObject);
+        break;
     }
     return membersToMap;
   },
@@ -109,11 +125,23 @@ BlazeComponent.extendComponent({
     return `import-board-instruction-${Session.get('importSource')}`;
   },
 
+  importPlaceHolder() {
+    const importSource = Session.get('importSource');
+    if (importSource === 'csv') {
+      return 'import-csv-placeholder';
+    } else {
+      return 'import-json-placeholder';
+    }
+  },
+
   events() {
     return [
       {
         submit(evt) {
-          return this.parentComponent().importData(evt);
+          return this.parentComponent().importData(
+            evt,
+            Session.get('importSource'),
+          );
         },
       },
     ];

+ 18 - 1
client/components/sidebar/sidebar.jade

@@ -230,6 +230,8 @@ template(name="chooseBoardSource")
       a(href="{{pathFor '/import/trello'}}") {{_ 'from-trello'}}
     li
       a(href="{{pathFor '/import/wekan'}}") {{_ 'from-wekan'}}
+    li
+      a(href="{{pathFor '/import/csv'}}") {{_ 'from-csv'}}
 
 template(name="archiveBoardPopup")
   p {{_ 'close-board-pop'}}
@@ -300,7 +302,7 @@ template(name="boardMenuPopup")
       ul.pop-over-list
         if withApi
           li
-            a(href="{{exportUrl}}", download="{{exportFilename}}")
+            a.js-export-board
               i.fa.fa-share-alt
               | {{_ 'export-board'}}
         li
@@ -360,6 +362,21 @@ template(name="boardMenuPopup")
           i.fa.fa-sitemap
           | {{_ 'subtask-settings'}}
 
+template(name="exportBoard")
+  ul.pop-over-list
+    li
+      a(href="{{exportUrl}}", download="{{exportJsonFilename}}")
+        i.fa.fa-share-alt
+        | {{_ 'export-board-json'}}
+    li
+      a(href="{{exportCsvUrl}}", download="{{exportCsvFilename}}")
+        i.fa.fa-share-alt
+        | {{_ 'export-board-csv'}}
+    li
+      a(href="{{exportTsvUrl}}", download="{{exportTsvFilename}}")
+        i.fa.fa-share-alt
+        | {{_ 'export-board-tsv'}}
+
 template(name="labelsWidget")
   .board-widget.board-widget-labels
     h3

+ 59 - 0
client/components/sidebar/sidebar.js

@@ -1,3 +1,4 @@
+sidebar.js;
 import { Cookies } from 'meteor/ostrio:cookies';
 const cookies = new Cookies();
 Sidebar = null;
@@ -213,6 +214,7 @@ Template.boardMenuPopup.events({
   'click .js-import-board': Popup.open('chooseBoardSource'),
   'click .js-subtask-settings': Popup.open('boardSubtaskSettings'),
   'click .js-card-settings': Popup.open('boardCardSettings'),
+  'click .js-export-board': Popup.open('exportBoard'),
 });
 
 Template.boardMenuPopup.onCreated(function() {
@@ -405,6 +407,63 @@ BlazeComponent.extendComponent({
   },
 }).register('chooseBoardSourcePopup');
 
+BlazeComponent.extendComponent({
+  template() {
+    return 'exportBoard';
+  },
+  withApi() {
+    return Template.instance().apiEnabled.get();
+  },
+  exportUrl() {
+    const params = {
+      boardId: Session.get('currentBoard'),
+    };
+    const queryParams = {
+      authToken: Accounts._storedLoginToken(),
+    };
+    return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
+  },
+  exportCsvUrl() {
+    const params = {
+      boardId: Session.get('currentBoard'),
+    };
+    const queryParams = {
+      authToken: Accounts._storedLoginToken(),
+    };
+    return FlowRouter.path(
+      '/api/boards/:boardId/export/csv',
+      params,
+      queryParams,
+    );
+  },
+  exportTsvUrl() {
+    const params = {
+      boardId: Session.get('currentBoard'),
+    };
+    const queryParams = {
+      authToken: Accounts._storedLoginToken(),
+      delimiter: '\t',
+    };
+    return FlowRouter.path(
+      '/api/boards/:boardId/export/csv',
+      params,
+      queryParams,
+    );
+  },
+  exportJsonFilename() {
+    const boardId = Session.get('currentBoard');
+    return `wekan-export-board-${boardId}.json`;
+  },
+  exportCsvFilename() {
+    const boardId = Session.get('currentBoard');
+    return `wekan-export-board-${boardId}.csv`;
+  },
+  exportTsvFilename() {
+    const boardId = Session.get('currentBoard');
+    return `wekan-export-board-${boardId}.tsv`;
+  },
+}).register('exportBoardPopup');
+
 Template.labelsWidget.events({
   'click .js-label': Popup.open('editLabel'),
   'click .js-add-label': Popup.open('createLabel'),

+ 10 - 0
i18n/en.i18n.json

@@ -309,6 +309,7 @@
   "error-board-notAMember": "You need to be a member of this board to do that",
   "error-json-malformed": "Your text is not valid JSON",
   "error-json-schema": "Your JSON data does not include the proper information in the correct format",
+  "error-csv-schema": "Your CSV(Comma Separated Values)/TSV (Tab Separated Values) does not include the proper information in the correct format ",
   "error-list-doesNotExist": "This list does not exist",
   "error-user-doesNotExist": "This user does not exist",
   "error-user-notAllowSelf": "You can not invite yourself",
@@ -316,6 +317,10 @@
   "error-username-taken": "This username is already taken",
   "error-email-taken": "Email has already been taken",
   "export-board": "Export board",
+  "export-board-json": "Export board to JSON",
+  "export-board-csv": "Export board to CSV",
+  "export-board-tsv": "Export board to TSV",
+  "exportBoardPopup-title": "Export board",
   "sort": "Sort",
   "sort-desc": "Click to Sort List",
   "list-sort-by": "Sort the List By:",
@@ -351,12 +356,16 @@
   "import-board-c": "Import board",
   "import-board-title-trello": "Import board from Trello",
   "import-board-title-wekan": "Import board from previous export",
+  "import-board-title-csv": "Import board from CSV/TSV",
   "from-trello": "From Trello",
   "from-wekan": "From previous export",
+  "from-csv": "From CSV/TSV",
   "import-board-instruction-trello": "In your Trello board, go to 'Menu', then 'More', 'Print and Export', 'Export JSON', and copy the resulting text.",
+  "import-board-instruction-csv": "Paste in your Comma Separated Values(CSV)/ Tab Separated Values (TSV) .",
   "import-board-instruction-wekan": "In your board, go to 'Menu', then 'Export board', and copy the text in the downloaded file.",
   "import-board-instruction-about-errors": "If you get errors when importing board, sometimes importing still works, and board is at All Boards page.",
   "import-json-placeholder": "Paste your valid JSON data here",
+  "import-csv-placeholder": "Paste your valid CSV/TSV data here",
   "import-map-members": "Map members",
   "import-members-map": "Your imported board has some members. Please map the members you want to import to your users",
   "import-show-user-mapping": "Review members mapping",
@@ -389,6 +398,7 @@
   "swimlaneActionPopup-title": "Swimlane Actions",
   "swimlaneAddPopup-title": "Add a Swimlane below",
   "listImportCardPopup-title": "Import a Trello card",
+  "listImportCardsTsvPopup-title": "Import Excel CSV/TSV",
   "listMorePopup-title": "More",
   "link-list": "Link to this list",
   "list-delete-pop": "All actions will be removed from the activity feed and you won't be able to recover the list. There is no undo.",

+ 313 - 0
models/csvCreator.js

@@ -0,0 +1,313 @@
+import Boards from './boards';
+
+export class CsvCreator {
+  constructor(data) {
+    // date to be used for timestamps during import
+    this._nowDate = new Date();
+    // index to help keep track of what information a column stores
+    // each row represents a card
+    this.fieldIndex = {};
+    this.lists = {};
+    // Map of members using username => wekanid
+    this.members = data.membersMapping ? data.membersMapping : {};
+    this.swimlane = null;
+  }
+
+  /**
+   * If dateString is provided,
+   * return the Date it represents.
+   * If not, will return the date when it was first called.
+   * This is useful for us, as we want all import operations to
+   * have the exact same date for easier later retrieval.
+   *
+   * @param {String} dateString a properly formatted Date
+   */
+  _now(dateString) {
+    if (dateString) {
+      return new Date(dateString);
+    }
+    if (!this._nowDate) {
+      this._nowDate = new Date();
+    }
+    return this._nowDate;
+  }
+
+  _user(wekanUserId) {
+    if (wekanUserId && this.members[wekanUserId]) {
+      return this.members[wekanUserId];
+    }
+    return Meteor.userId();
+  }
+
+  /**
+   * Map the header row titles to an index to help assign proper values to the cards' fields
+   * Valid headers (name of card fields):
+   * title, description, status, owner, member, label, due date, start date, finish date, created at, updated at
+   * Some header aliases can also be accepted.
+   * Headers are NOT case-sensitive.
+   *
+   * @param {Array} headerRow array from row of headers of imported CSV/TSV for cards
+   */
+  mapHeadertoCardFieldIndex(headerRow) {
+    const index = {};
+    for (let i = 0; i < headerRow.length; i++) {
+      switch (headerRow[i].trim().toLowerCase()) {
+        case 'title':
+          index.title = i;
+          break;
+        case 'description':
+          index.description = i;
+          break;
+        case 'stage':
+        case 'status':
+        case 'state':
+          index.stage = i;
+          break;
+        case 'owner':
+          index.owner = i;
+          break;
+        case 'members':
+        case 'member':
+          index.members = i;
+          break;
+        case 'labels':
+        case 'label':
+          index.labels = i;
+          break;
+        case 'due date':
+        case 'deadline':
+        case 'due at':
+          index.dueAt = i;
+          break;
+        case 'start date':
+        case 'start at':
+          index.startAt = i;
+          break;
+        case 'finish date':
+        case 'end at':
+          index.endAt = i;
+          break;
+        case 'creation date':
+        case 'created at':
+          index.createdAt = i;
+          break;
+        case 'update date':
+        case 'updated at':
+        case 'modified at':
+        case 'modified on':
+          index.modifiedAt = i;
+          break;
+      }
+    }
+    this.fieldIndex = index;
+  }
+
+  createBoard(csvData) {
+    const boardToCreate = {
+      archived: false,
+      color: 'belize',
+      createdAt: this._now(),
+      labels: [],
+      members: [
+        {
+          userId: Meteor.userId(),
+          wekanId: Meteor.userId(),
+          isActive: true,
+          isAdmin: true,
+          isNoComments: false,
+          isCommentOnly: false,
+          swimlaneId: false,
+        },
+      ],
+      modifiedAt: this._now(),
+      //default is private, should inform user.
+      permission: 'private',
+      slug: 'board',
+      stars: 0,
+      title: `Imported Board ${this._now()}`,
+    };
+
+    // create labels
+    const labelsToCreate = new Set();
+    for (let i = 1; i < csvData.length; i++) {
+      if (csvData[i][this.fieldIndex.labels]) {
+        for (const importedLabel of csvData[i][this.fieldIndex.labels].split(
+          ' ',
+        )) {
+          if (importedLabel && importedLabel.length > 0) {
+            labelsToCreate.add(importedLabel);
+          }
+        }
+      }
+    }
+    for (const label of labelsToCreate) {
+      let labelName, labelColor;
+      if (label.indexOf('-') > -1) {
+        labelName = label.split('-')[0];
+        labelColor = label.split('-')[1];
+      } else {
+        labelName = label;
+      }
+      const labelToCreate = {
+        _id: Random.id(6),
+        color: labelColor ? labelColor : 'black',
+        name: labelName,
+      };
+      boardToCreate.labels.push(labelToCreate);
+    }
+
+    const boardId = Boards.direct.insert(boardToCreate);
+    Boards.direct.update(boardId, {
+      $set: {
+        modifiedAt: this._now(),
+      },
+    });
+    // log activity
+    Activities.direct.insert({
+      activityType: 'importBoard',
+      boardId,
+      createdAt: this._now(),
+      source: {
+        id: boardId,
+        system: 'CSV/TSV',
+      },
+      // We attribute the import to current user,
+      // not the author from the original object.
+      userId: this._user(),
+    });
+    return boardId;
+  }
+
+  createSwimlanes(boardId) {
+    const swimlaneToCreate = {
+      archived: false,
+      boardId,
+      createdAt: this._now(),
+      title: 'Default',
+      sort: 1,
+    };
+    const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
+    Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now() } });
+    this.swimlane = swimlaneId;
+  }
+
+  createLists(csvData, boardId) {
+    let numOfCreatedLists = 0;
+    for (let i = 1; i < csvData.length; i++) {
+      const listToCreate = {
+        archived: false,
+        boardId,
+        createdAt: this._now(),
+      };
+      if (csvData[i][this.fieldIndex.stage]) {
+        const existingList = Lists.find({
+          title: csvData[i][this.fieldIndex.stage],
+          boardId,
+        }).fetch();
+        if (existingList.length > 0) {
+          continue;
+        } else {
+          listToCreate.title = csvData[i][this.fieldIndex.stage];
+        }
+      } else listToCreate.title = `Imported List ${this._now()}`;
+
+      const listId = Lists.direct.insert(listToCreate);
+      this.lists[csvData[i][this.fieldIndex.stage]] = listId;
+      numOfCreatedLists++;
+      Lists.direct.update(listId, {
+        $set: {
+          updatedAt: this._now(),
+          sort: numOfCreatedLists,
+        },
+      });
+    }
+  }
+
+  createCards(csvData, boardId) {
+    for (let i = 1; i < csvData.length; i++) {
+      const cardToCreate = {
+        archived: false,
+        boardId,
+        createdAt: csvData[i][this.fieldIndex.createdAt]
+          ? this._now(new Date(csvData[i][this.fieldIndex.createdAt]))
+          : null,
+        dateLastActivity: this._now(),
+        description: csvData[i][this.fieldIndex.description],
+        listId: this.lists[csvData[i][this.fieldIndex.stage]],
+        swimlaneId: this.swimlane,
+        sort: -1,
+        title: csvData[i][this.fieldIndex.title],
+        userId: this._user(),
+        startAt: csvData[i][this.fieldIndex.startAt]
+          ? this._now(new Date(csvData[i][this.fieldIndex.startAt]))
+          : null,
+        dueAt: csvData[i][this.fieldIndex.dueAt]
+          ? this._now(new Date(csvData[i][this.fieldIndex.dueAt]))
+          : null,
+        endAt: csvData[i][this.fieldIndex.endAt]
+          ? this._now(new Date(csvData[i][this.fieldIndex.endAt]))
+          : null,
+        spentTime: null,
+        labelIds: [],
+        modifiedAt: csvData[i][this.fieldIndex.modifiedAt]
+          ? this._now(new Date(csvData[i][this.fieldIndex.modifiedAt]))
+          : null,
+      };
+      // add the labels
+      if (csvData[i][this.fieldIndex.labels]) {
+        const board = Boards.findOne(boardId);
+        for (const importedLabel of csvData[i][this.fieldIndex.labels].split(
+          ' ',
+        )) {
+          if (importedLabel && importedLabel.length > 0) {
+            let labelToApply;
+            if (importedLabel.indexOf('-') === -1) {
+              labelToApply = board.getLabel(importedLabel, 'black');
+            } else {
+              labelToApply = board.getLabel(
+                importedLabel.split('-')[0],
+                importedLabel.split('-')[1],
+              );
+            }
+            cardToCreate.labelIds.push(labelToApply._id);
+          }
+        }
+      }
+      // add the members
+      if (csvData[i][this.fieldIndex.members]) {
+        const wekanMembers = [];
+        for (const importedMember of csvData[i][this.fieldIndex.members].split(
+          ' ',
+        )) {
+          if (this.members[importedMember]) {
+            const wekanId = this.members[importedMember];
+            if (!wekanMembers.find(wId => wId === wekanId)) {
+              wekanMembers.push(wekanId);
+            }
+          }
+        }
+        if (wekanMembers.length > 0) {
+          cardToCreate.members = wekanMembers;
+        }
+      }
+      Cards.direct.insert(cardToCreate);
+    }
+  }
+
+  create(board, currentBoardId) {
+    const isSandstorm =
+      Meteor.settings &&
+      Meteor.settings.public &&
+      Meteor.settings.public.sandstorm;
+    if (isSandstorm && currentBoardId) {
+      const currentBoard = Boards.findOne(currentBoardId);
+      currentBoard.archive();
+    }
+    this.mapHeadertoCardFieldIndex(board[0]);
+    const boardId = this.createBoard(board);
+    this.createLists(board, boardId);
+    this.createSwimlanes(boardId);
+    this.createCards(board, boardId);
+    return boardId;
+  }
+}

+ 47 - 193
models/export.js

@@ -1,3 +1,4 @@
+import { Exporter } from './exporter';
 /* global JsonRoutes */
 if (Meteor.isServer) {
   // todo XXX once we have a real API in place, move that route there
@@ -7,10 +8,10 @@ if (Meteor.isServer) {
   // on the client instead of copy/pasting the route path manually between the
   // client and the server.
   /**
-   * @operation export
+   * @operation exportJson
    * @tag Boards
    *
-   * @summary This route is used to export the board.
+   * @summary This route is used to export the board to a json file format.
    *
    * @description If user is already logged-in, pass loginToken as param
    * "authToken": '/api/boards/:boardId/export?authToken=:token'
@@ -46,199 +47,52 @@ if (Meteor.isServer) {
       JsonRoutes.sendResult(res, 403);
     }
   });
-}
-
-// exporter maybe is broken since Gridfs introduced, add fs and path
-
-export class Exporter {
-  constructor(boardId) {
-    this._boardId = boardId;
-  }
-
-  build() {
-    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(),
-      );
-    });
-
-    // [Old] for attachments we only export IDs and absolute url to original doc
-    // [New] Encode attachment to base64
-
-    const getBase64Data = function(doc, callback) {
-      let buffer = Buffer.allocUnsafe(0);
-      buffer.fill(0);
-
-      // callback has the form function (err, res) {}
-      const tmpFile = path.join(
-        os.tmpdir(),
-        `tmpexport${process.pid}${Math.random()}`,
-      );
-      const tmpWriteable = fs.createWriteStream(tmpFile);
-      const readStream = doc.createReadStream();
-      readStream.on('data', function(chunk) {
-        buffer = Buffer.concat([buffer, chunk]);
-      });
-
-      readStream.on('error', function(err) {
-        callback(null, null);
-      });
-      readStream.on('end', function() {
-        // done
-        fs.unlink(tmpFile, () => {
-          //ignored
-        });
 
-        callback(null, buffer.toString('base64'));
+  /**
+   * @operation exportCSV/TSV
+   * @tag Boards
+   *
+   * @summary This route is used to export the board to a CSV or TSV file format.
+   *
+   * @description If user is already logged-in, pass loginToken as param
+   *
+   * 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
+   * @param {string} delimiter delimiter to use while building export. Default is comma ','
+   */
+  Picker.route('/api/boards/:boardId/export/csv', function(params, req, res) {
+    const boardId = params.boardId;
+    let user = null;
+    const loginToken = params.query.authToken;
+    if (loginToken) {
+      const hashToken = Accounts._hashLoginToken(loginToken);
+      user = Meteor.users.findOne({
+        'services.resume.loginTokens.hashedToken': hashToken,
       });
-      readStream.pipe(tmpWriteable);
-    };
-    const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
-    result.attachments = Attachments.find(byBoard)
-      .fetch()
-      .map(attachment => {
-        let filebase64 = null;
-        filebase64 = getBase64DataSync(attachment);
-
-        return {
-          _id: attachment._id,
-          cardId: attachment.cardId,
-          //url: FlowRouter.url(attachment.url()),
-          file: filebase64,
-          name: attachment.original.name,
-          type: attachment.original.type,
-        };
+    } else if (!Meteor.settings.public.sandstorm) {
+      Authentication.checkUserId(req.userId);
+      user = Users.findOne({
+        _id: req.userId,
+        isAdmin: true,
       });
-
-    // 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;
-        });
-      }
-    });
-    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.fullname': 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;
+    }
+    const exporter = new Exporter(boardId);
+    if (exporter.canExport(user)) {
+      body = params.query.delimiter
+        ? exporter.buildCsv(params.query.delimiter)
+        : exporter.buildCsv();
+      res.writeHead(200, {
+        'Content-Length': body[0].length,
+        'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
       });
-    return result;
-  }
-
-  canExport(user) {
-    const board = Boards.findOne(this._boardId);
-    return board && board.isVisibleBy(user);
-  }
+      res.write(body[0]);
+      res.end();
+    } else {
+      res.writeHead(403);
+      res.end('Permission Error');
+    }
+  });
 }

+ 320 - 0
models/exporter.js

@@ -0,0 +1,320 @@
+models / exporter.js;
+const stringify = require('csv-stringify');
+
+// exporter maybe is broken since Gridfs introduced, add fs and path
+export class Exporter {
+  constructor(boardId) {
+    this._boardId = boardId;
+  }
+
+  build() {
+    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(),
+      );
+    });
+
+    // [Old] for attachments we only export IDs and absolute url to original doc
+    // [New] Encode attachment to base64
+
+    const getBase64Data = function(doc, callback) {
+      let buffer = Buffer.allocUnsafe(0);
+      buffer.fill(0);
+
+      // callback has the form function (err, res) {}
+      const tmpFile = path.join(
+        os.tmpdir(),
+        `tmpexport${process.pid}${Math.random()}`,
+      );
+      const tmpWriteable = fs.createWriteStream(tmpFile);
+      const readStream = doc.createReadStream();
+      readStream.on('data', function(chunk) {
+        buffer = Buffer.concat([buffer, chunk]);
+      });
+
+      readStream.on('error', function() {
+        callback(null, null);
+      });
+      readStream.on('end', function() {
+        // done
+        fs.unlink(tmpFile, () => {
+          //ignored
+        });
+
+        callback(null, buffer.toString('base64'));
+      });
+      readStream.pipe(tmpWriteable);
+    };
+    const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
+    result.attachments = Attachments.find(byBoard)
+      .fetch()
+      .map(attachment => {
+        let filebase64 = null;
+        filebase64 = getBase64DataSync(attachment);
+
+        return {
+          _id: attachment._id,
+          cardId: attachment.cardId,
+          //url: FlowRouter.url(attachment.url()),
+          file: filebase64,
+          name: attachment.original.name,
+          type: attachment.original.type,
+        };
+      });
+
+    // 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;
+        });
+      }
+    });
+    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.fullname': 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;
+      });
+    return result;
+  }
+
+  buildCsv(delimiter = ',') {
+    const result = this.build();
+    const columnHeaders = [];
+    const cardRows = [];
+    columnHeaders.push(
+      'Title',
+      'Description',
+      'Status',
+      'Swimlane',
+      'Owner',
+      'Requested by',
+      'Assigned by',
+      'Members',
+      'Assignees',
+      'Labels',
+      'Start at',
+      'Due at',
+      'End at',
+      'Over time',
+      'Spent time (hours)',
+      'Created at',
+      'Last modified at',
+      'Last activity',
+      'Vote',
+      'Archived',
+    );
+    const stringifier = stringify({
+      header: true,
+      delimiter,
+      columns: columnHeaders,
+    });
+
+    stringifier.on('readable', function() {
+      let row;
+      while ((row = stringifier.read())) {
+        cardRows.push(row);
+      }
+    });
+
+    stringifier.on('error', function(err) {
+      // eslint-disable-next-line no-console
+      console.error(err.message);
+    });
+
+    result.cards.forEach(card => {
+      const currentRow = [];
+      currentRow.push(card.title);
+      currentRow.push(card.description);
+      currentRow.push(
+        result.lists.find(({ _id }) => _id === card.listId).title,
+      );
+      currentRow.push(
+        result.swimlanes.find(({ _id }) => _id === card.swimlaneId).title,
+      );
+      currentRow.push(
+        result.users.find(({ _id }) => _id === card.userId).username,
+      );
+      currentRow.push(card.requestedBy ? card.requestedBy : ' ');
+      currentRow.push(card.assignedBy ? card.assignedBy : ' ');
+      let usernames = '';
+      card.members.forEach(memberId => {
+        const user = result.users.find(({ _id }) => _id === memberId);
+        usernames = `${usernames + user.username} `;
+      });
+      currentRow.push(usernames.trim());
+      let assignees = '';
+      card.assignees.forEach(assigneeId => {
+        const user = result.users.find(({ _id }) => _id === assigneeId);
+        assignees = `${assignees + user.username} `;
+      });
+      currentRow.push(assignees.trim());
+      let labels = '';
+      card.labelIds.forEach(labelId => {
+        const label = result.labels.find(({ _id }) => _id === labelId);
+        labels = `${labels + label.name}-${label.color} `;
+      });
+      currentRow.push(labels.trim());
+      currentRow.push(card.startAt ? moment(card.startAt).format('LLLL') : ' ');
+      currentRow.push(card.dueAt ? moment(card.dueAt).format('LLLL') : ' ');
+      currentRow.push(card.endAt ? moment(card.endAt).format('LLLL') : ' ');
+      currentRow.push(card.isOvertime ? 'true' : 'false');
+      currentRow.push(card.spentTime);
+      currentRow.push(
+        card.createdAt ? moment(card.createdAt).format('LLLL') : ' ',
+      );
+      currentRow.push(
+        card.modifiedAt ? moment(card.modifiedAt).format('LLLL') : ' ',
+      );
+      currentRow.push(
+        card.dateLastActivity
+          ? moment(card.dateLastActivity).format('LLLL')
+          : ' ',
+      );
+      if (card.vote.question) {
+        let positiveVoters = '';
+        let negativeVoters = '';
+        card.vote.positive.forEach(userId => {
+          const user = result.users.find(({ _id }) => _id === userId);
+          positiveVoters = `${positiveVoters + user.username} `;
+        });
+        card.vote.negative.forEach(userId => {
+          const user = result.users.find(({ _id }) => _id === userId);
+          negativeVoters = `${negativeVoters + user.username} `;
+        });
+        const votingResult = `${
+          card.vote.public
+            ? `yes-${
+                card.vote.positive.length
+              }-${positiveVoters.trimRight()}-no-${
+                card.vote.negative.length
+              }-${negativeVoters.trimRight()}`
+            : `yes-${card.vote.positive.length}-no-${card.vote.negative.length}`
+        }`;
+        currentRow.push(`${card.vote.question}-${votingResult}`);
+      } else {
+        currentRow.push(' ');
+      }
+      currentRow.push(card.archived ? 'true' : 'false');
+      stringifier.write(currentRow);
+    });
+    stringifier.end();
+    return cardRows;
+  }
+
+  canExport(user) {
+    const board = Boards.findOne(this._boardId);
+    return board && board.isVisibleBy(user);
+  }
+}

+ 8 - 2
models/import.js

@@ -1,22 +1,28 @@
 import { TrelloCreator } from './trelloCreator';
 import { WekanCreator } from './wekanCreator';
-import { Exporter } from './export';
+import { CsvCreator } from './csvCreator';
+import { Exporter } from './exporter';
 import wekanMembersMapper from './wekanmapper';
 
 Meteor.methods({
   importBoard(board, data, importSource, currentBoard) {
-    check(board, Object);
     check(data, Object);
     check(importSource, String);
     check(currentBoard, Match.Maybe(String));
     let creator;
     switch (importSource) {
       case 'trello':
+        check(board, Object);
         creator = new TrelloCreator(data);
         break;
       case 'wekan':
+        check(board, Object);
         creator = new WekanCreator(data);
         break;
+      case 'csv':
+        check(board, Array);
+        creator = new CsvCreator(data);
+        break;
     }
 
     // 1. check all parameters are ok from a syntax point of view

+ 10 - 0
package-lock.json

@@ -1033,6 +1033,11 @@
       "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
       "integrity": "sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4="
     },
+    "csv-stringify": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.5.0.tgz",
+      "integrity": "sha512-G05575DSO/9vFzQxZN+Srh30cNyHk0SM0ePyiTChMD5WVt7GMTVPBQf4rtgMF6mqhNCJUPw4pN8LDe8MF9EYOA=="
+    },
     "dashdash": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -3864,6 +3869,11 @@
         "path-to-regexp": "~1.2.1"
       }
     },
+    "papaparse": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.2.0.tgz",
+      "integrity": "sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA=="
+    },
     "parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

+ 2 - 0
package.json

@@ -62,6 +62,7 @@
     "bcrypt": "^3.0.7",
     "bson": "^4.0.3",
     "bunyan": "^1.8.12",
+    "csv-stringify": "^5.5.0",
     "es6-promise": "^4.2.4",
     "flatted": "^2.0.1",
     "gridfs-stream": "^0.5.3",
@@ -72,6 +73,7 @@
     "page": "^1.11.5",
     "qs": "^6.9.4",
     "source-map-support": "^0.5.19",
+    "papaparse": "^5.2.0",
     "xss": "^1.0.6"
   }
 }