summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--client/components/import/csvMembersMapper.js37
-rw-r--r--client/components/import/import.jade2
-rw-r--r--client/components/import/import.js52
-rw-r--r--client/components/sidebar/sidebar.jade19
-rw-r--r--client/components/sidebar/sidebar.js59
-rw-r--r--i18n/en.i18n.json10
-rw-r--r--models/csvCreator.js313
-rw-r--r--models/export.js240
-rw-r--r--models/exporter.js320
-rw-r--r--models/import.js10
-rw-r--r--package-lock.json10
-rw-r--r--package.json2
12 files changed, 865 insertions, 209 deletions
diff --git a/client/components/import/csvMembersMapper.js b/client/components/import/csvMembersMapper.js
new file mode 100644
index 00000000..cf8d5837
--- /dev/null
+++ b/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;
+}
diff --git a/client/components/import/import.jade b/client/components/import/import.jade
index 1551a7dd..2bea24ae 100644
--- a/client/components/import/import.jade
+++ b/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'}}")
diff --git a/client/components/import/import.js b/client/components/import/import.js
index 6368885b..673900fd 100644
--- a/client/components/import/import.js
+++ b/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'),
+ );
},
},
];
diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade
index 6bfedc9c..280eaeaf 100644
--- a/client/components/sidebar/sidebar.jade
+++ b/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
diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js
index cbe00797..0541df5e 100644
--- a/client/components/sidebar/sidebar.js
+++ b/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'),
diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json
index ee399f9d..9fe7a521 100644
--- a/i18n/en.i18n.json
+++ b/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.",
diff --git a/models/csvCreator.js b/models/csvCreator.js
new file mode 100644
index 00000000..025a3179
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/models/export.js b/models/export.js
index 339123c8..c3783679 100644
--- a/models/export.js
+++ b/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');
+ }
+ });
}
diff --git a/models/exporter.js b/models/exporter.js
new file mode 100644
index 00000000..910ca9a1
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/models/import.js b/models/import.js
index fbfb1483..f3cbaa9b 100644
--- a/models/import.js
+++ b/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
diff --git a/package-lock.json b/package-lock.json
index 61c32c66..93ab5e39 100644
--- a/package-lock.json
+++ b/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",
diff --git a/package.json b/package.json
index de729564..0f97d09a 100644
--- a/package.json
+++ b/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"
}
}