diff options
Diffstat (limited to 'models')
-rw-r--r-- | models/attachments.js | 2 | ||||
-rw-r--r-- | models/boards.js | 46 | ||||
-rw-r--r-- | models/cards.js | 5 | ||||
-rw-r--r-- | models/import.js | 364 | ||||
-rw-r--r-- | models/users.js | 57 |
5 files changed, 439 insertions, 35 deletions
diff --git a/models/attachments.js b/models/attachments.js index 8ef0fef0..01e467ff 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -1,4 +1,4 @@ -Attachments = new FS.Collection('attachments', { +Attachments = new FS.Collection('attachments', { // eslint-disable-line meteor/collections stores: [ // XXX Add a new store for cover thumbnails so we don't load big images in diff --git a/models/boards.js b/models/boards.js index 4baec280..98d6ec77 100644 --- a/models/boards.js +++ b/models/boards.js @@ -92,12 +92,16 @@ Boards.helpers({ return _.where(this.members, {isActive: true}); }, + getLabel(name, color) { + return _.findWhere(this.labels, { name, color }); + }, + labelIndex(labelId) { - return _.indexOf(_.pluck(this.labels, '_id'), labelId); + return _.pluck(this.labels, '_id').indexOf(labelId); }, memberIndex(memberId) { - return _.indexOf(_.pluck(this.members, 'userId'), memberId); + return _.pluck(this.members, 'userId').indexOf(memberId); }, absoluteUrl() { @@ -107,6 +111,14 @@ Boards.helpers({ colorClass() { return `board-color-${this.color}`; }, + + // XXX currently mutations return no value so we have an issue when using addLabel in import + // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove... + pushLabel(name, color) { + const _id = Random.id(6); + Boards.direct.update(this._id, { $push: {labels: { _id, name, color }}}); + return _id; + }, }); Boards.mutations({ @@ -131,18 +143,26 @@ Boards.mutations({ }, addLabel(name, color) { - const _id = Random.id(6); - return { $push: {labels: { _id, name, color }}}; + // If label with the same name and color already exists we don't want to + // create another one because they would be indistinguishable in the UI + // (they would still have different `_id` but that is not exposed to the + // user). + if (!this.getLabel(name, color)) { + const _id = Random.id(6); + return { $push: {labels: { _id, name, color }}}; + } }, editLabel(labelId, name, color) { - const labelIndex = this.labelIndex(labelId); - return { - $set: { - [`labels.${labelIndex}.name`]: name, - [`labels.${labelIndex}.color`]: color, - }, - }; + if (!this.getLabel(name, color)) { + const labelIndex = this.labelIndex(labelId); + return { + $set: { + [`labels.${labelIndex}.name`]: name, + [`labels.${labelIndex}.color`]: color, + }, + }; + } }, removeLabel(labelId) { @@ -259,7 +279,7 @@ Boards.before.insert((userId, doc) => { // Handle labels const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; const defaultLabelsColors = _.clone(colors).splice(0, 6); - doc.labels = _.map(defaultLabelsColors, (color) => { + doc.labels = defaultLabelsColors.map((color) => { return { color, _id: Random.id(6), @@ -307,7 +327,7 @@ if (Meteor.isServer) { { boardId: doc._id }, { $pull: { - labels: removedLabelId, + labelIds: removedLabelId, }, }, { multi: true } diff --git a/models/cards.js b/models/cards.js index 95943ae2..2e16583d 100644 --- a/models/cards.js +++ b/models/cards.js @@ -194,8 +194,9 @@ Cards.mutations({ Cards.before.insert((userId, doc) => { doc.createdAt = new Date(); doc.dateLastActivity = new Date(); - doc.archived = false; - + if(!doc.hasOwnProperty('archived')){ + doc.archived = false; + } if (!doc.userId) { doc.userId = userId; } diff --git a/models/import.js b/models/import.js new file mode 100644 index 00000000..a6e9f3d5 --- /dev/null +++ b/models/import.js @@ -0,0 +1,364 @@ +const DateString = Match.Where(function (dateAsString) { + check(dateAsString, String); + return moment(dateAsString, moment.ISO_8601).isValid(); +}); + +class TrelloCreator { + constructor() { + // The object creation dates, indexed by Trello id (so we only parse actions + // once!) + this.createdAt = { + board: null, + cards: {}, + lists: {}, + }; + // Map of labels Trello ID => Wekan ID + this.labels = {}; + // Map of lists Trello ID => Wekan ID + this.lists = {}; + // The comments, indexed by Trello card id (to map when importing cards) + this.comments = {}; + } + + checkActions(trelloActions) { + check(trelloActions, [Match.ObjectIncluding({ + data: Object, + date: DateString, + type: String, + })]); + // XXX we could perform more thorough checks based on action type + } + + checkBoard(trelloBoard) { + check(trelloBoard, Match.ObjectIncluding({ + closed: Boolean, + name: String, + prefs: Match.ObjectIncluding({ + // XXX refine control by validating 'background' against a list of + // allowed values (is it worth the maintenance?) + background: String, + permissionLevel: Match.Where((value) => { + return ['org', 'private', 'public'].indexOf(value)>= 0; + }), + }), + })); + } + + checkCards(trelloCards) { + check(trelloCards, [Match.ObjectIncluding({ + closed: Boolean, + dateLastActivity: DateString, + desc: String, + idLabels: [String], + idMembers: [String], + name: String, + pos: Number, + })]); + } + + checkLabels(trelloLabels) { + check(trelloLabels, [Match.ObjectIncluding({ + // XXX refine control by validating 'color' against a list of allowed + // values (is it worth the maintenance?) + color: String, + name: String, + })]); + } + + checkLists(trelloLists) { + check(trelloLists, [Match.ObjectIncluding({ + closed: Boolean, + name: String, + })]); + } + + // You must call parseActions before calling this one. + createBoardAndLabels(trelloBoard) { + const createdAt = this.createdAt.board; + const boardToCreate = { + archived: trelloBoard.closed, + color: this.getColor(trelloBoard.prefs.background), + createdAt, + labels: [], + members: [{ + userId: Meteor.userId(), + isAdmin: true, + isActive: true, + }], + permission: this.getPermission(trelloBoard.prefs.permissionLevel), + slug: getSlug(trelloBoard.name) || 'board', + stars: 0, + title: trelloBoard.name, + }; + trelloBoard.labels.forEach((label) => { + const labelToCreate = { + _id: Random.id(6), + color: label.color, + name: label.name, + }; + // We need to remember them by Trello ID, as this is the only ref we have + // when importing cards. + this.labels[label.id] = labelToCreate._id; + boardToCreate.labels.push(labelToCreate); + }); + const now = new Date(); + const boardId = Boards.direct.insert(boardToCreate); + Boards.direct.update(boardId, {$set: {modifiedAt: now}}); + // log activity + Activities.direct.insert({ + activityType: 'importBoard', + boardId, + createdAt: now, + source: { + id: trelloBoard.id, + system: 'Trello', + url: trelloBoard.url, + }, + // We attribute the import to current user, not the one from the original + // object. + userId: Meteor.userId(), + }); + return boardId; + } + + // Create labels if they do not exist and load this.labels. + createLabels(trelloLabels, board) { + trelloLabels.forEach((label) => { + const color = label.color; + const name = label.name; + const existingLabel = board.getLabel(name, color); + if (existingLabel) { + this.labels[label.id] = existingLabel._id; + } else { + const idLabelCreated = board.pushLabel(name, color); + this.labels[label.id] = idLabelCreated; + } + }); + } + + createLists(trelloLists, boardId) { + trelloLists.forEach((list) => { + const listToCreate = { + archived: list.closed, + boardId, + // We are being defensing here by providing a default date (now) if the + // creation date wasn't found on the action log. This happen on old + // Trello boards (eg from 2013) that didn't log the 'createList' action + // we require. + createdAt: new Date(this.createdAt.lists[list.id] || Date.now()), + title: list.name, + userId: Meteor.userId(), + }; + const listId = Lists.direct.insert(listToCreate); + const now = new Date(); + Lists.direct.update(listId, {$set: {'updatedAt': now}}); + this.lists[list.id] = listId; + // log activity + Activities.direct.insert({ + activityType: 'importList', + boardId, + createdAt: now, + listId, + source: { + id: list.id, + system: 'Trello', + }, + // We attribute the import to current user, not the one from the + // original object + userId: Meteor.userId(), + }); + }); + } + + createCardsAndComments(trelloCards, boardId) { + const result = []; + trelloCards.forEach((card) => { + const cardToCreate = { + archived: card.closed, + boardId, + createdAt: new Date(this.createdAt.cards[card.id] || Date.now()), + dateLastActivity: new Date(), + description: card.desc, + listId: this.lists[card.idList], + sort: card.pos, + title: card.name, + // XXX use the original user? + userId: Meteor.userId(), + }; + // add labels + if (card.idLabels) { + cardToCreate.labelIds = card.idLabels.map((trelloId) => { + return this.labels[trelloId]; + }); + } + // insert card + const cardId = Cards.direct.insert(cardToCreate); + // log activity + Activities.direct.insert({ + activityType: 'importCard', + boardId, + cardId, + createdAt: new Date(), + listId: cardToCreate.listId, + source: { + id: card.id, + system: 'Trello', + url: card.url, + }, + // we attribute the import to current user, not the one from the + // original card + userId: Meteor.userId(), + }); + // add comments + const comments = this.comments[card.id]; + if (comments) { + comments.forEach((comment) => { + const commentToCreate = { + boardId, + cardId, + createdAt: comment.date, + text: comment.data.text, + // XXX use the original comment user instead + userId: Meteor.userId(), + }; + // dateLastActivity will be set from activity insert, no need to + // update it ourselves + const commentId = CardComments.direct.insert(commentToCreate); + Activities.direct.insert({ + activityType: 'addComment', + boardId: commentToCreate.boardId, + cardId: commentToCreate.cardId, + commentId, + createdAt: commentToCreate.createdAt, + userId: commentToCreate.userId, + }); + }); + } + // XXX add attachments + result.push(cardId); + }); + return result; + } + + getColor(trelloColorCode) { + // trello color name => wekan color + const mapColors = { + 'blue': 'belize', + 'orange': 'pumpkin', + 'green': 'nephritis', + 'red': 'pomegranate', + 'purple': 'wisteria', + 'pink': 'pomegranate', + 'lime': 'nephritis', + 'sky': 'belize', + 'grey': 'midnight', + }; + const wekanColor = mapColors[trelloColorCode]; + return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0]; + } + + getPermission(trelloPermissionCode) { + if (trelloPermissionCode === 'public') { + return 'public'; + } + // Wekan does NOT have organization level, so we default both 'private' and + // 'org' to private. + return 'private'; + } + + parseActions(trelloActions) { + trelloActions.forEach((action) => { + switch (action.type) { + case 'createBoard': + this.createdAt.board = action.date; + break; + case 'createCard': + const cardId = action.data.card.id; + this.createdAt.cards[cardId] = action.date; + break; + case 'createList': + const listId = action.data.list.id; + this.createdAt.lists[listId] = action.date; + break; + case 'commentCard': + const id = action.data.card.id; + if (this.comments[id]) { + this.comments[id].push(action); + } else { + this.comments[id] = [action]; + } + break; + default: + // do nothing + break; + } + }); + } +} + +Meteor.methods({ + importTrelloBoard(trelloBoard, data) { + const trelloCreator = new TrelloCreator(); + + // 1. check all parameters are ok from a syntax point of view + try { + // we don't use additional data - this should be an empty object + check(data, {}); + trelloCreator.checkActions(trelloBoard.actions); + trelloCreator.checkBoard(trelloBoard); + trelloCreator.checkLabels(trelloBoard.labels); + trelloCreator.checkLists(trelloBoard.lists); + trelloCreator.checkCards(trelloBoard.cards); + } catch (e) { + throw new Meteor.Error('error-json-schema'); + } + + // 2. check parameters are ok from a business point of view (exist & + // authorized) nothing to check, everyone can import boards in their account + + // 3. create all elements + trelloCreator.parseActions(trelloBoard.actions); + const boardId = trelloCreator.createBoardAndLabels(trelloBoard); + trelloCreator.createLists(trelloBoard.lists, boardId); + trelloCreator.createCardsAndComments(trelloBoard.cards, boardId); + // XXX add members + return boardId; + }, + + importTrelloCard(trelloCard, data) { + const trelloCreator = new TrelloCreator(); + + // 1. check parameters are ok from a syntax point of view + try { + check(data, { + listId: String, + sortIndex: Number, + }); + trelloCreator.checkCards([trelloCard]); + trelloCreator.checkLabels(trelloCard.labels); + trelloCreator.checkActions(trelloCard.actions); + } catch(e) { + throw new Meteor.Error('error-json-schema'); + } + + // 2. check parameters are ok from a business point of view (exist & + // authorized) + const list = Lists.findOne(data.listId); + if (!list) { + throw new Meteor.Error('error-list-doesNotExist'); + } + if (Meteor.isServer) { + if (!allowIsBoardMember(Meteor.userId(), Boards.findOne(list.boardId))) { + throw new Meteor.Error('error-board-notAMember'); + } + } + + // 3. create all elements + trelloCreator.lists[trelloCard.idList] = data.listId; + trelloCreator.parseActions(trelloCard.actions); + const board = list.board(); + trelloCreator.createLabels(trelloCard.labels, board); + const cardIds = trelloCreator.createCardsAndComments([trelloCard], board._id); + return cardIds[0]; + }, +}); diff --git a/models/users.js b/models/users.js index 4260dc56..1e69564d 100644 --- a/models/users.js +++ b/models/users.js @@ -1,4 +1,4 @@ -Users = Meteor.users; +Users = Meteor.users; // eslint-disable-line meteor/collections // Search a user in the complete server database by its name or username. This // is used for instance to add a new user to a board. @@ -8,31 +8,50 @@ Users.initEasySearch(searchInFields, { returnFields: [...searchInFields, 'profile.avatarUrl'], }); +if (Meteor.isClient) { + Users.helpers({ + isBoardMember() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && + _.contains(_.pluck(board.members, 'userId'), this._id) && + _.where(board.members, {userId: this._id})[0].isActive; + }, + + isBoardAdmin() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && + this.isBoardMember(board) && + _.where(board.members, {userId: this._id})[0].isAdmin; + }, + }); +} + Users.helpers({ boards() { return Boards.find({ userId: this._id }); }, starredBoards() { - const starredBoardIds = this.profile.starredBoards || []; - return Boards.find({archived: false, _id: {$in: starredBoardIds}}); + const {starredBoards = []} = this.profile; + return Boards.find({archived: false, _id: {$in: starredBoards}}); }, hasStarred(boardId) { - const starredBoardIds = this.profile.starredBoards || []; - return _.contains(starredBoardIds, boardId); + const {starredBoards = []} = this.profile; + return _.contains(starredBoards, boardId); }, - isBoardMember() { - const board = Boards.findOne(Session.get('currentBoard')); - return board && _.contains(_.pluck(board.members, 'userId'), this._id) && - _.where(board.members, {userId: this._id})[0].isActive; - }, - - isBoardAdmin() { - const board = Boards.findOne(Session.get('currentBoard')); - return board && this.isBoardMember(board) && - _.where(board.members, {userId: this._id})[0].isAdmin; + getAvatarUrl() { + // Although we put the avatar picture URL in the `profile` object, we need + // to support Sandstorm which put in the `picture` attribute by default. + // XXX Should we move both cases to `picture`? + if (this.picture) { + return this.picture; + } else if (this.profile && this.profile.avatarUrl) { + return this.profile.avatarUrl; + } else { + return null; + } }, getInitials() { @@ -41,9 +60,9 @@ Users.helpers({ return profile.initials; else if (profile.fullname) { - return _.reduce(profile.fullname.split(/\s+/), (memo, word) => { + return profile.fullname.split(/\s+/).reduce((memo = '', word) => { return memo + word[0]; - }, '').toUpperCase(); + }).toUpperCase(); } else { return this.username[0].toUpperCase(); @@ -117,7 +136,7 @@ if (Meteor.isServer) { // b. We use it to find deleted and newly inserted ids by using it in one // direction and then in the other. function incrementBoards(boardsIds, inc) { - _.forEach(boardsIds, (boardId) => { + boardsIds.forEach((boardId) => { Boards.update(boardId, {$inc: {stars: inc}}); }); } @@ -136,7 +155,7 @@ if (Meteor.isServer) { // Insert the Welcome Board Boards.insert(ExampleBoard, (err, boardId) => { - _.forEach(['Basics', 'Advanced'], (title) => { + ['Basics', 'Advanced'].forEach((title) => { const list = { title, boardId, |