const DateString = Match.Where(function(dateAsString) { check(dateAsString, String); return moment(dateAsString, moment.ISO_8601).isValid(); }); export class WekanCreator { 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 Wekan id // (so we only parse actions once!) this.createdAt = { board: null, cards: {}, lists: {}, swimlanes: {}, }; // The object creator Wekan Id, indexed by the object Wekan id // (so we only parse actions once!) this.createdBy = { cards: {}, // only cards have a field for that }; // Map of labels Wekan ID => Wekan ID this.labels = {}; // Map of swimlanes Wekan ID => Wekan ID this.swimlanes = {}; // Map of lists Wekan ID => Wekan ID this.lists = {}; // Map of cards Wekan ID => Wekan ID this.cards = {}; // Map of comments Wekan ID => Wekan ID this.commentIds = {}; // Map of attachments Wekan ID => Wekan ID this.attachmentIds = {}; // Map of checklists Wekan ID => Wekan ID this.checklists = {}; // Map of checklistItems Wekan ID => Wekan ID this.checklistItems = {}; // The comments, indexed by Wekan card id (to map when importing cards) this.comments = {}; // Map of rules Wekan ID => Wekan ID this.rules = {}; // the members, indexed by Wekan member id => Wekan user ID this.members = data.membersMapping ? data.membersMapping : {}; // Map of triggers Wekan ID => Wekan ID this.triggers = {}; // Map of actions Wekan ID => Wekan ID this.actions = {}; // maps a wekanCardId to an array of wekanAttachments 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 wekanUserId is provided and we have a mapping, * return it. * Otherwise return current logged user. * @param wekanUserId * @private */ _user(wekanUserId) { if (wekanUserId && this.members[wekanUserId]) { return this.members[wekanUserId]; } return Meteor.userId(); } checkActivities(wekanActivities) { check(wekanActivities, [ Match.ObjectIncluding({ activityType: String, createdAt: DateString, }), ]); // XXX we could perform more thorough checks based on action type } checkBoard(wekanBoard) { check( wekanBoard, Match.ObjectIncluding({ archived: Boolean, title: String, // XXX refine control by validating 'color' against a list of // allowed values (is it worth the maintenance?) color: String, permission: Match.Where(value => { return ['private', 'public'].indexOf(value) >= 0; }), }), ); } checkCards(wekanCards) { check(wekanCards, [ Match.ObjectIncluding({ archived: Boolean, dateLastActivity: DateString, labelIds: [String], title: String, sort: Number, }), ]); } checkLabels(wekanLabels) { check(wekanLabels, [ Match.ObjectIncluding({ // XXX refine control by validating 'color' against a list of allowed // values (is it worth the maintenance?) color: String, }), ]); } checkLists(wekanLists) { check(wekanLists, [ Match.ObjectIncluding({ archived: Boolean, title: String, }), ]); } checkSwimlanes(wekanSwimlanes) { check(wekanSwimlanes, [ Match.ObjectIncluding({ archived: Boolean, title: String, }), ]); } checkChecklists(wekanChecklists) { check(wekanChecklists, [ Match.ObjectIncluding({ cardId: String, title: String, }), ]); } checkChecklistItems(wekanChecklistItems) { check(wekanChecklistItems, [ Match.ObjectIncluding({ cardId: String, title: String, }), ]); } checkRules(wekanRules) { check(wekanRules, [ Match.ObjectIncluding({ triggerId: String, actionId: String, title: String, }), ]); } checkTriggers(wekanTriggers) { // XXX More check based on trigger type check(wekanTriggers, [ Match.ObjectIncluding({ activityType: String, desc: String, }), ]); } 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 = data.members; const users = data.users; // auto-map based on username membersToMap.forEach(importedMember => { importedMember.id = importedMember.userId; delete importedMember.userId; const user = users.filter(user => { return user._id === importedMember.id; })[0]; if (user.profile && user.profile.fullname) { importedMember.fullName = user.profile.fullname; } importedMember.username = user.username; const wekanUser = Users.findOne({ username: importedMember.username }); if (wekanUser) { importedMember.wekanId = wekanUser._id; } }); return membersToMap; } checkActions(wekanActions) { // XXX More check based on action type check(wekanActions, [ Match.ObjectIncluding({ actionType: String, desc: String, }), ]); } // You must call parseActions before calling this one. createBoardAndLabels(boardToImport) { const boardToCreate = { archived: boardToImport.archived, color: boardToImport.color, // very old boards won't have a creation activity so no creation date createdAt: this._now(boardToImport.createdAt), labels: [], members: [ { userId: Meteor.userId(), wekanId: Meteor.userId(), isActive: true, isAdmin: true, isNoComments: false, isCommentOnly: false, swimlaneId: false, }, ], // Standalone Export has modifiedAt missing, adding modifiedAt to fix it modifiedAt: this._now(boardToImport.modifiedAt), permission: boardToImport.permission, slug: getSlug(boardToImport.title) || 'board', stars: 0, title: boardToImport.title, }; // now add other members if (boardToImport.members) { boardToImport.members.forEach(wekanMember => { // do we already have it in our list? if ( !boardToCreate.members.some( member => member.wekanId === wekanMember.wekanId, ) ) boardToCreate.members.push({ ...wekanMember, userId: wekanMember.wekanId, }); }); } boardToImport.labels.forEach(label => { const labelToCreate = { _id: Random.id(6), color: label.color, name: label.name, }; // We need to remember them by Wekan 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: boardToImport.id, system: 'Wekan', }, // 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 Wekan cards, * as well as all linked data: activities, comments, and attachments * @param wekanCards * @param boardId * @returns {Array} */ createCards(wekanCards, boardId) { const result = []; wekanCards.forEach(card => { const cardToCreate = { archived: card.archived, 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.description, listId: this.lists[card.listId], swimlaneId: this.swimlanes[card.swimlaneId], sort: card.sort, title: card.title, // we attribute the card to its creator if available userId: this._user(this.createdBy.cards[card._id]), isOvertime: card.isOvertime || false, startAt: card.startAt ? this._now(card.startAt) : null, dueAt: card.dueAt ? this._now(card.dueAt) : null, spentTime: card.spentTime || null, }; // add labels if (card.labelIds) { cardToCreate.labelIds = card.labelIds.map(wekanId => { return this.labels[wekanId]; }); } // add members { if (card.members) { const wekanMembers = []; // we can't just map, as some members may not have been mapped card.members.forEach(sourceMemberId => { if (this.members[sourceMemberId]) { const wekanId = this.members[sourceMemberId]; // we may map multiple Wekan 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; } } // set color if (card.color) { cardToCreate.color = card.color; } // insert card const cardId = Cards.direct.insert(cardToCreate); // keep track of Wekan 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: 'Wekan', // }, // // 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.createdAt), text: comment.text, // we attribute the comment to the original author, default to current user userId: this._user(comment.userId), }; // dateLastActivity will be set from activity insert, no need to // update it ourselves const commentId = CardComments.direct.insert(commentToCreate); this.commentIds[comment._id] = commentId; // Activities.direct.insert({ // activityType: 'addComment', // boardId: commentToCreate.boardId, // cardId: commentToCreate.cardId, // commentId, // createdAt: this._now(commentToCreate.createdAt), // // 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 wekanCoverId = card.coverId; 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) { if (att.url) { file.attachData(att.url, function(error) { file.boardId = boardId; file.cardId = cardId; file.userId = self._user(att.userId); // 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 (wekanCoverId === att._id) { Cards.direct.update(cardId, { $set: { coverId: wekanAtt._id, }, }); } } }); } else if (att.file) { file.attachData( Buffer.from(att.file, 'base64'), { type: att.type, }, error => { file.name(att.name); file.boardId = boardId; file.cardId = cardId; file.userId = self._user(att.userId); // 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 }); this.attachmentIds[att._id] = wekanAtt._id; // if (wekanCoverId === 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(wekanLabels, board) { wekanLabels.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(wekanLists, boardId) { wekanLists.forEach((list, listIndex) => { const listToCreate = { archived: list.archived, 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 // Wekan boards (eg from 2013) that didn't log the 'createList' action // we require. createdAt: this._now(this.createdAt.lists[list.id]), title: list.title, sort: list.sort ? list.sort : listIndex, }; 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: 'Wekan', // }, // // We attribute the import to current user, // // not the creator of the original object // userId: this._user(), // }); }); } createSwimlanes(wekanSwimlanes, boardId) { wekanSwimlanes.forEach((swimlane, swimlaneIndex) => { const swimlaneToCreate = { archived: swimlane.archived, 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 // Wekan boards (eg from 2013) that didn't log the 'createList' action // we require. createdAt: this._now(this.createdAt.swimlanes[swimlane._id]), title: swimlane.title, sort: swimlane.sort ? swimlane.sort : swimlaneIndex, }; // set color if (swimlane.color) { swimlaneToCreate.color = swimlane.color; } const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate); Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now(), }, }); this.swimlanes[swimlane._id] = swimlaneId; }); } createChecklists(wekanChecklists) { const result = []; wekanChecklists.forEach((checklist, checklistIndex) => { // Create the checklist const checklistToCreate = { cardId: this.cards[checklist.cardId], title: checklist.title, createdAt: checklist.createdAt, sort: checklist.sort ? checklist.sort : checklistIndex, }; const checklistId = Checklists.direct.insert(checklistToCreate); this.checklists[checklist._id] = checklistId; result.push(checklistId); }); return result; } createTriggers(wekanTriggers, boardId) { wekanTriggers.forEach(trigger => { if (trigger.hasOwnProperty('labelId')) { trigger.labelId = this.labels[trigger.labelId]; } if (trigger.hasOwnProperty('memberId')) { trigger.memberId = this.members[trigger.memberId]; } trigger.boardId = boardId; const oldId = trigger._id; delete trigger._id; this.triggers[oldId] = Triggers.direct.insert(trigger); }); } createActions(wekanActions, boardId) { wekanActions.forEach(action => { if (action.hasOwnProperty('labelId')) { action.labelId = this.labels[action.labelId]; } if (action.hasOwnProperty('memberId')) { action.memberId = this.members[action.memberId]; } action.boardId = boardId; const oldId = action._id; delete action._id; this.actions[oldId] = Actions.direct.insert(action); }); } createRules(wekanRules, boardId) { wekanRules.forEach(rule => { // Create the rule rule.boardId = boardId; rule.triggerId = this.triggers[rule.triggerId]; rule.actionId = this.actions[rule.actionId]; delete rule._id; Rules.direct.insert(rule); }); } createChecklistItems(wekanChecklistItems) { wekanChecklistItems.forEach((checklistitem, checklistitemIndex) => { // Create the checklistItem const checklistItemTocreate = { title: checklistitem.title, checklistId: this.checklists[checklistitem.checklistId], cardId: this.cards[checklistitem.cardId], sort: checklistitem.sort ? checklistitem.sort : checklistitemIndex, isFinished: checklistitem.isFinished, }; const checklistItemId = ChecklistItems.direct.insert( checklistItemTocreate, ); this.checklistItems[checklistitem._id] = checklistItemId; }); } parseActivities(wekanBoard) { wekanBoard.activities.forEach(activity => { switch (activity.activityType) { case 'addAttachment': { // We have to be cautious, because the attachment could have been removed later. // In that case Wekan still reports its addition, but removes its 'url' field. // So we test for that const wekanAttachment = wekanBoard.attachments.filter(attachment => { return attachment._id === activity.attachmentId; })[0]; if (typeof wekanAttachment !== 'undefined' && wekanAttachment) { if (wekanAttachment.url || wekanAttachment.file) { // 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 wekanCardId = activity.cardId; if (!this.attachments[wekanCardId]) { this.attachments[wekanCardId] = []; } this.attachments[wekanCardId].push(wekanAttachment); } } break; } case 'addComment': { const wekanComment = wekanBoard.comments.filter(comment => { return comment._id === activity.commentId; })[0]; const id = activity.cardId; if (!this.comments[id]) { this.comments[id] = []; } this.comments[id].push(wekanComment); break; } case 'createBoard': { this.createdAt.board = activity.createdAt; break; } case 'createCard': { const cardId = activity.cardId; this.createdAt.cards[cardId] = activity.createdAt; this.createdBy.cards[cardId] = activity.userId; break; } case 'createList': { const listId = activity.listId; this.createdAt.lists[listId] = activity.createdAt; break; } case 'createSwimlane': { const swimlaneId = activity.swimlaneId; this.createdAt.swimlanes[swimlaneId] = activity.createdAt; break; } } }); } importActivities(activities, boardId) { activities.forEach(activity => { switch (activity.activityType) { // Board related activities // TODO: addBoardMember, removeBoardMember case 'createBoard': { Activities.direct.insert({ userId: this._user(activity.userId), type: 'board', activityTypeId: boardId, activityType: activity.activityType, boardId, createdAt: this._now(activity.createdAt), }); break; } // List related activities // TODO: removeList, archivedList case 'createList': { Activities.direct.insert({ userId: this._user(activity.userId), type: 'list', activityType: activity.activityType, listId: this.lists[activity.listId], boardId, createdAt: this._now(activity.createdAt), }); break; } // Card related activities // TODO: archivedCard, restoredCard, joinMember, unjoinMember case 'createCard': { Activities.direct.insert({ userId: this._user(activity.userId), activityType: activity.activityType, listId: this.lists[activity.listId], cardId: this.cards[activity.cardId], boardId, createdAt: this._now(activity.createdAt), }); break; } case 'moveCard': { Activities.direct.insert({ userId: this._user(activity.userId), oldListId: this.lists[activity.oldListId], activityType: activity.activityType, listId: this.lists[activity.listId], cardId: this.cards[activity.cardId], boardId, createdAt: this._now(activity.createdAt), }); break; } // Comment related activities case 'addComment': { Activities.direct.insert({ userId: this._user(activity.userId), activityType: activity.activityType, cardId: this.cards[activity.cardId], commentId: this.commentIds[activity.commentId], boardId, createdAt: this._now(activity.createdAt), }); break; } // Attachment related activities case 'addAttachment': { Activities.direct.insert({ userId: this._user(activity.userId), type: 'card', activityType: activity.activityType, attachmentId: this.attachmentIds[activity.attachmentId], cardId: this.cards[activity.cardId], boardId, createdAt: this._now(activity.createdAt), }); break; } // Checklist related activities case 'addChecklist': { Activities.direct.insert({ userId: this._user(activity.userId), activityType: activity.activityType, cardId: this.cards[activity.cardId], checklistId: this.checklists[activity.checklistId], boardId, createdAt: this._now(activity.createdAt), }); break; } case 'addChecklistItem': { Activities.direct.insert({ userId: this._user(activity.userId), activityType: activity.activityType, cardId: this.cards[activity.cardId], checklistId: this.checklists[activity.checklistId], checklistItemId: activity.checklistItemId.replace( activity.checklistId, this.checklists[activity.checklistId], ), boardId, createdAt: this._now(activity.createdAt), }); break; } } }); } //check(board) { check() { //try { // check(data, { // membersMapping: Match.Optional(Object), // }); // this.checkActivities(board.activities); // this.checkBoard(board); // this.checkLabels(board.labels); // this.checkLists(board.lists); // this.checkSwimlanes(board.swimlanes); // this.checkCards(board.cards); //this.checkChecklists(board.checklists); // this.checkRules(board.rules); // this.checkActions(board.actions); //this.checkTriggers(board.triggers); //this.checkChecklistItems(board.checklistItems); //} 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.parseActivities(board); const boardId = this.createBoardAndLabels(board); this.createLists(board.lists, boardId); this.createSwimlanes(board.swimlanes, boardId); this.createCards(board.cards, boardId); this.createChecklists(board.checklists); this.createChecklistItems(board.checklistItems); this.importActivities(board.activities, boardId); this.createTriggers(board.triggers, boardId); this.createActions(board.actions, boardId); this.createRules(board.rules, boardId); // XXX add members return boardId; } }