diff options
-rw-r--r-- | client/components/sidebar/sidebar.jade | 17 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.js | 59 | ||||
-rw-r--r-- | i18n/en.i18n.json | 4 | ||||
-rw-r--r-- | models/csvCreator.js | 33 | ||||
-rw-r--r-- | models/export.js | 240 | ||||
-rw-r--r-- | models/exporter.js | 320 | ||||
-rw-r--r-- | models/import.js | 4 | ||||
-rw-r--r-- | package-lock.json | 5 | ||||
-rw-r--r-- | package.json | 1 |
9 files changed, 470 insertions, 213 deletions
diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 89622ac1..280eaeaf 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -302,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 @@ -362,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 a1bff774..67afccad 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -315,6 +315,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:", diff --git a/models/csvCreator.js b/models/csvCreator.js index 346d2201..025a3179 100644 --- a/models/csvCreator.js +++ b/models/csvCreator.js @@ -128,10 +128,9 @@ export class CsvCreator { }; // create labels + const labelsToCreate = new Set(); for (let i = 1; i < csvData.length; i++) { - //get the label column if (csvData[i][this.fieldIndex.labels]) { - const labelsToCreate = new Set(); for (const importedLabel of csvData[i][this.fieldIndex.labels].split( ' ', )) { @@ -139,23 +138,23 @@ export class CsvCreator { 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); - } } } + 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, { 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 ea18c14f..f3cbaa9b 100644 --- a/models/import.js +++ b/models/import.js @@ -1,8 +1,8 @@ import { TrelloCreator } from './trelloCreator'; import { WekanCreator } from './wekanCreator'; -import { Exporter } from './export'; -import wekanMembersMapper from './wekanmapper'; import { CsvCreator } from './csvCreator'; +import { Exporter } from './exporter'; +import wekanMembersMapper from './wekanmapper'; Meteor.methods({ importBoard(board, data, importSource, currentBoard) { diff --git a/package-lock.json b/package-lock.json index ced4a945..ad8f79c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -894,6 +894,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", diff --git a/package.json b/package.json index 871f1171..94b0f5a0 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,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", |