diff options
-rw-r--r-- | client/components/import/csvMembersMapper.js | 37 | ||||
-rw-r--r-- | client/components/import/import.jade | 2 | ||||
-rw-r--r-- | client/components/import/import.js | 52 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.jade | 19 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.js | 59 | ||||
-rw-r--r-- | i18n/en.i18n.json | 10 | ||||
-rw-r--r-- | models/csvCreator.js | 313 | ||||
-rw-r--r-- | models/export.js | 240 | ||||
-rw-r--r-- | models/exporter.js | 320 | ||||
-rw-r--r-- | models/import.js | 10 | ||||
-rw-r--r-- | package-lock.json | 10 | ||||
-rw-r--r-- | package.json | 2 |
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" } } |