const DateString = Match.Where(function (dateAsString) { check(dateAsString, String); return moment(dateAsString, moment.ISO_8601).isValid(); }); export class TrelloCreator { constructor(data) { // we log current date, to use the same timestamp for all our actions. // this helps to retrieve all elements performed by the same import. this._nowDate = new Date(); // The object creation dates, indexed by Trello id // (so we only parse actions once!) this.createdAt = { board: null, cards: {}, lists: {}, }; // The object creator Trello Id, indexed by the object Trello id // (so we only parse actions once!) this.createdBy = { cards: {}, // only cards have a field for that }; // Map of labels Trello ID => Wekan ID this.labels = {}; // Map of lists Trello ID => Wekan ID this.lists = {}; // Map of cards Trello ID => Wekan ID this.cards = {}; // Map of attachments Wekan ID => Wekan ID this.attachmentIds = {}; // Map of checklists Wekan ID => Wekan ID this.checklists = {}; // The comments, indexed by Trello card id (to map when importing cards) this.comments = {}; // the members, indexed by Trello member id => Wekan user ID this.members = data.membersMapping ? data.membersMapping : {}; // maps a trelloCardId to an array of trelloAttachments this.attachments = {}; } /** * 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; } /** * if trelloUserId is provided and we have a mapping, * return it. * Otherwise return current logged user. * @param trelloUserId * @private */ _user(trelloUserId) { if(trelloUserId && this.members[trelloUserId]) { return this.members[trelloUserId]; } return Meteor.userId(); } 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?) name: String, })]); } checkLists(trelloLists) { check(trelloLists, [Match.ObjectIncluding({ closed: Boolean, name: String, })]); } checkChecklists(trelloChecklists) { check(trelloChecklists, [Match.ObjectIncluding({ idBoard: String, idCard: String, name: String, checkItems: [Match.ObjectIncluding({ state: String, name: String, })], })]); } // You must call parseActions before calling this one. createBoardAndLabels(trelloBoard) { const boardToCreate = { archived: trelloBoard.closed, color: this.getColor(trelloBoard.prefs.background), // very old boards won't have a creation activity so no creation date createdAt: this._now(this.createdAt.board), labels: [], members: [{ userId: Meteor.userId(), isAdmin: true, isActive: true, isCommentOnly: false, }], permission: this.getPermission(trelloBoard.prefs.permissionLevel), slug: getSlug(trelloBoard.name) || 'board', stars: 0, title: trelloBoard.name, }; // now add other members if(trelloBoard.memberships) { trelloBoard.memberships.forEach((trelloMembership) => { const trelloId = trelloMembership.idMember; // do we have a mapping? if(this.members[trelloId]) { const wekanId = this.members[trelloId]; // do we already have it in our list? const wekanMember = boardToCreate.members.find((wekanMember) => wekanMember.userId === wekanId); if(wekanMember) { // we're already mapped, but maybe with lower rights if(!wekanMember.isAdmin) { wekanMember.isAdmin = this.getAdmin(trelloMembership.memberType); } } else { boardToCreate.members.push({ userId: wekanId, isAdmin: this.getAdmin(trelloMembership.memberType), isActive: true, isCommentOnly: false, }); } } }); } trelloBoard.labels.forEach((label) => { const labelToCreate = { _id: Random.id(6), color: label.color ? label.color : 'black', 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 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: trelloBoard.id, system: 'Trello', url: trelloBoard.url, }, // We attribute the import to current user, // not the author from the original object. userId: this._user(), }); return boardId; } /** * Create the Wekan cards corresponding to the supplied Trello cards, * as well as all linked data: activities, comments, and attachments * @param trelloCards * @param boardId * @returns {Array} */ createCards(trelloCards, boardId) { const result = []; trelloCards.forEach((card) => { const cardToCreate = { archived: card.closed, boardId, // very old boards won't have a creation activity so no creation date createdAt: this._now(this.createdAt.cards[card.id]), dateLastActivity: this._now(), description: card.desc, listId: this.lists[card.idList], sort: card.pos, title: card.name, // we attribute the card to its creator if available userId: this._user(this.createdBy.cards[card.id]), dueAt: card.due ? this._now(card.due) : null, }; // add labels if (card.idLabels) { cardToCreate.labelIds = card.idLabels.map((trelloId) => { return this.labels[trelloId]; }); } // add members { if(card.idMembers) { const wekanMembers = []; // we can't just map, as some members may not have been mapped card.idMembers.forEach((trelloId) => { if(this.members[trelloId]) { const wekanId = this.members[trelloId]; // we may map multiple Trello members to the same wekan user // in which case we risk adding the same user multiple times if(!wekanMembers.find((wId) => wId === wekanId)){ wekanMembers.push(wekanId); } } return true; }); if(wekanMembers.length>0) { cardToCreate.members = wekanMembers; } } // insert card const cardId = Cards.direct.insert(cardToCreate); // keep track of Trello id => WeKan id this.cards[card.id] = cardId; // log activity // Activities.direct.insert({ // activityType: 'importCard', // boardId, // cardId, // createdAt: this._now(), // listId: cardToCreate.listId, // source: { // id: card.id, // system: 'Trello', // url: card.url, // }, // // we attribute the import to current user, // // not the author of the original card // userId: this._user(), // }); // add comments const comments = this.comments[card.id]; if (comments) { comments.forEach((comment) => { const commentToCreate = { boardId, cardId, createdAt: this._now(comment.date), text: comment.data.text, // we attribute the comment to the original author, default to current user userId: this._user(comment.idMemberCreator), }; // dateLastActivity will be set from activity insert, no need to // update it ourselves const commentId = CardComments.direct.insert(commentToCreate); // We need to keep adding comment activities this way with Trello // because it doesn't provide a comment ID Activities.direct.insert({ activityType: 'addComment', boardId: commentToCreate.boardId, cardId: commentToCreate.cardId, commentId, createdAt: this._now(comment.date), // we attribute the addComment (not the import) // to the original author - it is needed by some UI elements. userId: commentToCreate.userId, }); }); } const attachments = this.attachments[card.id]; const trelloCoverId = card.idAttachmentCover; if (attachments) { attachments.forEach((att) => { const file = new FS.File(); // Simulating file.attachData on the client generates multiple errors // - HEAD returns null, which causes exception down the line // - the template then tries to display the url to the attachment which causes other errors // so we make it server only, and let UI catch up once it is done, forget about latency comp. const self = this; if(Meteor.isServer) { file.attachData(att.url, function (error) { file.boardId = boardId; file.cardId = cardId; file.userId = self._user(att.idMemberCreator); // The field source will only be used to prevent adding // attachments' related activities automatically file.source = 'import'; if (error) { throw(error); } else { const wekanAtt = Attachments.insert(file, () => { // we do nothing }); self.attachmentIds[att.id] = wekanAtt._id; // if(trelloCoverId === att.id) { Cards.direct.update(cardId, { $set: {coverId: wekanAtt._id}}); } } }); } // todo XXX set cover - if need be }); } result.push(cardId); }); return result; } // 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: this._now(this.createdAt.lists[list.id]), title: list.name, }; const listId = Lists.direct.insert(listToCreate); Lists.direct.update(listId, {$set: {'updatedAt': this._now()}}); this.lists[list.id] = listId; // log activity // Activities.direct.insert({ // activityType: 'importList', // boardId, // createdAt: this._now(), // listId, // source: { // id: list.id, // system: 'Trello', // }, // // We attribute the import to current user, // // not the creator of the original object // userId: this._user(), // }); }); } createChecklists(trelloChecklists) { trelloChecklists.forEach((checklist) => { if (this.cards[checklist.idCard]) { // Create the checklist const checklistToCreate = { cardId: this.cards[checklist.idCard], title: checklist.name, createdAt: this._now(), sort: checklist.pos, }; const checklistId = Checklists.direct.insert(checklistToCreate); // keep track of Trello id => WeKan id this.checklists[checklist.id] = checklistId; // Now add the items to the checklist const itemsToCreate = []; checklist.checkItems.forEach((item) => { itemsToCreate.push({ _id: checklistId + itemsToCreate.length, title: item.name, isFinished: item.state === 'complete', sort: item.pos, }); }); Checklists.direct.update(checklistId, {$set: {items: itemsToCreate}}); } }); } getAdmin(trelloMemberType) { return trelloMemberType === 'admin'; } 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) => { if (action.type === 'addAttachmentToCard') { // We have to be cautious, because the attachment could have been removed later. // In that case Trello still reports its addition, but removes its 'url' field. // So we test for that const trelloAttachment = action.data.attachment; // We need the idMemberCreator trelloAttachment.idMemberCreator = action.idMemberCreator; if(trelloAttachment.url) { // we cannot actually create the Wekan attachment, because we don't yet // have the cards to attach it to, so we store it in the instance variable. const trelloCardId = action.data.card.id; if(!this.attachments[trelloCardId]) { this.attachments[trelloCardId] = []; } this.attachments[trelloCardId].push(trelloAttachment); } } else if (action.type === 'commentCard') { const id = action.data.card.id; if (this.comments[id]) { this.comments[id].push(action); } else { this.comments[id] = [action]; } } else if (action.type === 'createBoard') { this.createdAt.board = action.date; } else if (action.type === 'createCard') { const cardId = action.data.card.id; this.createdAt.cards[cardId] = action.date; this.createdBy.cards[cardId] = action.idMemberCreator; } else if (action.type === 'createList') { const listId = action.data.list.id; this.createdAt.lists[listId] = action.date; } }); } importActions(actions, boardId) { actions.forEach((action) => { switch (action.type) { // Board related actions // TODO: addBoardMember, removeBoardMember case 'createBoard': { Activities.direct.insert({ userId: this._user(action.idMemberCreator), type: 'board', activityTypeId: boardId, activityType: 'createBoard', boardId, createdAt: this._now(action.date), }); break; } // List related activities // TODO: removeList, archivedList case 'createList': { Activities.direct.insert({ userId: this._user(action.idMemberCreator), type: 'list', activityType: 'createList', listId: this.lists[action.data.list.id], boardId, createdAt: this._now(action.date), }); break; } // Card related activities // TODO: archivedCard, restoredCard, joinMember, unjoinMember case 'createCard': { Activities.direct.insert({ userId: this._user(action.idMemberCreator), activityType: 'createCard', listId: this.lists[action.data.list.id], cardId: this.cards[action.data.card.id], boardId, createdAt: this._now(action.date), }); break; } case 'updateCard': { if (action.data.old.idList) { Activities.direct.insert({ userId: this._user(action.idMemberCreator), oldListId: this.lists[action.data.old.idList], activityType: 'moveCard', listId: this.lists[action.data.listAfter.id], cardId: this.cards[action.data.card.id], boardId, createdAt: this._now(action.date), }); } break; } // Comment related activities // Trello doesn't export the comment id // Attachment related activities case 'addAttachmentToCard': { Activities.direct.insert({ userId: this._user(action.idMemberCreator), type: 'card', activityType: 'addAttachment', attachmentId: this.attachmentIds[action.data.attachment.id], cardId: this.cards[action.data.card.id], boardId, createdAt: this._now(action.date), }); break; } // Checklist related activities case 'addChecklistToCard': { Activities.direct.insert({ userId: this._user(action.idMemberCreator), activityType: 'addChecklist', cardId: this.cards[action.data.card.id], checklistId: this.checklists[action.data.checklist.id], boardId, createdAt: this._now(action.date), }); break; }} // Trello doesn't have an add checklist item action }); } check(board) { try { // check(data, { // membersMapping: Match.Optional(Object), // }); this.checkActions(board.actions); this.checkBoard(board); this.checkLabels(board.labels); this.checkLists(board.lists); this.checkCards(board.cards); this.checkChecklists(board.checklists); } catch (e) { throw new Meteor.Error('error-json-schema'); } } create(board, currentBoardId) { // TODO : Make isSandstorm variable global const isSandstorm = Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm; if (isSandstorm && currentBoardId) { const currentBoard = Boards.findOne(currentBoardId); currentBoard.archive(); } this.parseActions(board.actions); const boardId = this.createBoardAndLabels(board); this.createLists(board.lists, boardId); this.createCards(board.cards, boardId); this.createChecklists(board.checklists); this.importActions(board.actions, boardId); // XXX add members return boardId; } }