Bladeren bron

Add import Wekan board feature

Ghassen Rjab 8 jaren geleden
bovenliggende
commit
3f4c285551

+ 7 - 1
client/components/boards/boardHeader.jade

@@ -191,8 +191,14 @@ template(name="createBoard")
     input.primary.wide(type="submit" value="{{_ 'create'}}")
     input.primary.wide(type="submit" value="{{_ 'create'}}")
     span.quiet
     span.quiet
       | {{_ 'or'}}
       | {{_ 'or'}}
-      a(href="{{pathFor 'import'}}") {{_ 'import-board'}}
+      a.js-import-board {{_ 'import-board'}}
 
 
+template(name="chooseBoardSource")
+  ul
+    li
+      a(href="{{pathFor 'import/trello'}}") {{_ 'from-trello'}}
+    li
+      a(href="{{pathFor 'import/wekan'}}") {{_ 'from-wekan'}}
 
 
 template(name="boardChangeTitlePopup")
 template(name="boardChangeTitlePopup")
   form
   form

+ 7 - 0
client/components/boards/boardHeader.js

@@ -174,10 +174,17 @@ const CreateBoard = BlazeComponent.extendComponent({
       'click .js-change-visibility': this.toggleVisibilityMenu,
       'click .js-change-visibility': this.toggleVisibilityMenu,
       'click .js-import': Popup.open('boardImportBoard'),
       'click .js-import': Popup.open('boardImportBoard'),
       submit: this.onSubmit,
       submit: this.onSubmit,
+      'click .js-import-board': Popup.open('chooseBoardSource'),
     }];
     }];
   },
   },
 }).register('createBoardPopup');
 }).register('createBoardPopup');
 
 
+BlazeComponent.extendComponent({
+  template() {
+    return 'chooseBoardSource';
+  },
+}).register('chooseBoardSourcePopup');
+
 (class HeaderBarCreateBoard extends CreateBoard {
 (class HeaderBarCreateBoard extends CreateBoard {
   onSubmit(evt) {
   onSubmit(evt) {
     super.onSubmit(evt);
     super.onSubmit(evt);

+ 2 - 2
client/components/import/import.jade

@@ -2,7 +2,7 @@ template(name="importHeaderBar")
   h1
   h1
     a.back-btn(href="{{pathFor 'home'}}")
     a.back-btn(href="{{pathFor 'home'}}")
       i.fa.fa-chevron-left
       i.fa.fa-chevron-left
-    | {{_ 'import-board-title'}}
+    | {{_ title}}
 
 
 template(name="import")
 template(name="import")
   .wrapper
   .wrapper
@@ -12,7 +12,7 @@ template(name="import")
 
 
 template(name="importTextarea")
 template(name="importTextarea")
   form
   form
-    p: label(for='import-textarea') {{_ 'import-board-trello-instruction'}}
+    p: label(for='import-textarea') {{_ instruction}}
     textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus)
     textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus)
       | {{jsonText}}
       | {{jsonText}}
     input.primary.wide(type="submit" value="{{_ 'import'}}")
     input.primary.wide(type="submit" value="{{_ 'import'}}")

+ 32 - 16
client/components/import/import.js

@@ -1,3 +1,12 @@
+import trelloMembersMapper from './trelloMembersMapper';
+import wekanMembersMapper from './wekanMembersMapper';
+
+BlazeComponent.extendComponent({
+  title() {
+    return `import-board-title-${Session.get('importSource')}!`;
+  },
+}).register('importHeaderBar');
+
 BlazeComponent.extendComponent({
 BlazeComponent.extendComponent({
   onCreated() {
   onCreated() {
     this.error = new ReactiveVar('');
     this.error = new ReactiveVar('');
@@ -5,6 +14,7 @@ BlazeComponent.extendComponent({
     this._currentStepIndex = new ReactiveVar(0);
     this._currentStepIndex = new ReactiveVar(0);
     this.importedData = new ReactiveVar();
     this.importedData = new ReactiveVar();
     this.membersToMap = new ReactiveVar([]);
     this.membersToMap = new ReactiveVar([]);
+    this.importSource = Session.get('importSource');
   },
   },
 
 
   currentTemplate() {
   currentTemplate() {
@@ -27,7 +37,10 @@ BlazeComponent.extendComponent({
       const dataObject = JSON.parse(dataJson);
       const dataObject = JSON.parse(dataJson);
       this.setError('');
       this.setError('');
       this.importedData.set(dataObject);
       this.importedData.set(dataObject);
-      this._prepareAdditionalData(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();
       this.nextStep();
     } catch (e) {
     } catch (e) {
       this.setError('error-json-malformed');
       this.setError('error-json-malformed');
@@ -51,7 +64,10 @@ BlazeComponent.extendComponent({
       additionalData.membersMapping = mappingById;
       additionalData.membersMapping = mappingById;
     }
     }
     this.membersToMap.set([]);
     this.membersToMap.set([]);
-    Meteor.call('importTrelloBoard', this.importedData.get(), additionalData,
+    Meteor.call('importBoard',
+      this.importedData.get(),
+      additionalData,
+      this.importSource,
       (err, res) => {
       (err, res) => {
         if (err) {
         if (err) {
           this.setError(err.error);
           this.setError(err.error);
@@ -63,20 +79,16 @@ BlazeComponent.extendComponent({
   },
   },
 
 
   _prepareAdditionalData(dataObject) {
   _prepareAdditionalData(dataObject) {
-    // 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 = dataObject.members;
-    // auto-map based on username
-    membersToMap.forEach((importedMember) => {
-      const wekanUser = Users.findOne({ username: importedMember.username });
-      if (wekanUser) {
-        importedMember.wekanId = wekanUser._id;
-      }
-    });
-    // 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);
+    const importSource = Session.get('importSource');
+    let membersToMap;
+    switch (importSource) {
+    case 'trello':
+      membersToMap = trelloMembersMapper.getMembersToMap(dataObject);
+      break;
+    case 'wekan':
+      membersToMap = wekanMembersMapper.getMembersToMap(dataObject);
+      break;
+    }
     return membersToMap;
     return membersToMap;
   },
   },
 
 
@@ -90,6 +102,10 @@ BlazeComponent.extendComponent({
     return 'importTextarea';
     return 'importTextarea';
   },
   },
 
 
+  instruction() {
+    return `import-board-instruction-${Session.get('importSource')}!`;
+  },
+
   events() {
   events() {
     return [{
     return [{
       submit(evt) {
       submit(evt) {

+ 14 - 0
client/components/import/trelloMembersMapper.js

@@ -0,0 +1,14 @@
+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 = data.members;
+  // auto-map based on username
+  membersToMap.forEach((importedMember) => {
+    const wekanUser = Users.findOne({ username: importedMember.username });
+    if (wekanUser) {
+      importedMember.wekanId = wekanUser._id;
+    }
+  });
+  return membersToMap;
+}

+ 24 - 0
client/components/import/wekanMembersMapper.js

@@ -0,0 +1,24 @@
+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 = 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;
+}

+ 9 - 11
config/router.js

@@ -80,19 +80,16 @@ FlowRouter.route('/shortcuts', {
   },
   },
 });
 });
 
 
-FlowRouter.route('/import', {
+FlowRouter.route('/import/:source', {
   name: 'import',
   name: 'import',
-  triggersEnter: [
-    AccountsTemplates.ensureSignedIn,
-    () => {
-      Session.set('currentBoard', null);
-      Session.set('currentCard', null);
+  triggersEnter: [AccountsTemplates.ensureSignedIn],
+  action(params) {
+    Session.set('currentBoard', null);
+    Session.set('currentCard', null);
+    Session.set('importSource', params.source);
 
 
-      Filter.reset();
-      EscapeActions.executeAll();
-    },
-  ],
-  action() {
+    Filter.reset();
+    EscapeActions.executeAll();
     BlazeLayout.render('defaultLayout', {
     BlazeLayout.render('defaultLayout', {
       headerBar: 'importHeaderBar',
       headerBar: 'importHeaderBar',
       content: 'import',
       content: 'import',
@@ -132,6 +129,7 @@ const redirections = {
   '/boards': '/',
   '/boards': '/',
   '/boards/:id/:slug': '/b/:id/:slug',
   '/boards/:id/:slug': '/b/:id/:slug',
   '/boards/:id/:slug/:cardId': '/b/:id/:slug/:cardId',
   '/boards/:id/:slug/:cardId': '/b/:id/:slug/:cardId',
+  '/import': '/import/trello',
 };
 };
 
 
 _.each(redirections, (newPath, oldPath) => {
 _.each(redirections, (newPath, oldPath) => {

+ 8 - 3
i18n/en.i18n.json

@@ -145,6 +145,7 @@
     "computer": "Computer",
     "computer": "Computer",
     "create": "Create",
     "create": "Create",
     "createBoardPopup-title": "Create Board",
     "createBoardPopup-title": "Create Board",
+    "chooseBoardSourcePopup-title": "Import board",
     "createLabelPopup-title": "Create Label",
     "createLabelPopup-title": "Create Label",
     "current": "current",
     "current": "current",
     "date": "Date",
     "date": "Date",
@@ -204,9 +205,13 @@
     "headerBarCreateBoardPopup-title": "Create Board",
     "headerBarCreateBoardPopup-title": "Create Board",
     "home": "Home",
     "home": "Home",
     "import": "Import",
     "import": "Import",
-    "import-board": "import from Trello",
-    "import-board-title": "Import board from Trello",
-    "import-board-trello-instruction": "In your Trello board, go to 'Menu', then 'More', 'Print and Export', 'Export JSON', and copy the resulting text.",
+    "import-board": "import board",
+    "import-board-title-trello": "Import board from Trello",
+    "import-board-title-wekan": "Import board from Wekan",
+    "from-trello": "From Trello",
+    "from-wekan": "From Wekan",
+    "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-wekan": "In your Wekan board, go to 'Menu', then 'Export board', and copy the text in the downloaded file.",
     "import-json-placeholder": "Paste your valid JSON data here",
     "import-json-placeholder": "Paste your valid JSON data here",
     "import-map-members": "Map members",
     "import-map-members": "Map members",
     "import-members-map": "Your imported board has some members. Please map the members you want to import to Wekan users",
     "import-members-map": "Your imported board has some members. Please map the members you want to import to Wekan users",

+ 17 - 496
models/import.js

@@ -1,507 +1,28 @@
-const DateString = Match.Where(function (dateAsString) {
-  check(dateAsString, String);
-  return moment(dateAsString, moment.ISO_8601).isValid();
-});
-
-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 = {};
-    // 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?)
-      color: String,
-      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,
-        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);
-          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 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.
-          if(Meteor.isServer) {
-            file.attachData(att.url, function (error) {
-              file.boardId = boardId;
-              file.cardId = cardId;
-              if (error) {
-                throw(error);
-              } else {
-                const wekanAtt = Attachments.insert(file, () => {
-                  // we do nothing
-                });
-                //
-                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) => {
-      // Create the checklist
-      const checklistToCreate = {
-        cardId: this.cards[checklist.idCard],
-        title: checklist.name,
-        createdAt: this._now(),
-      };
-      const checklistId = Checklists.direct.insert(checklistToCreate);
-      // 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',
-        });
-      });
-      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;
-        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;
-      }
-    });
-  }
-}
+import { TrelloCreator } from './trelloCreator';
+import { WekanCreator } from './wekanCreator';
 
 
 Meteor.methods({
 Meteor.methods({
-  importTrelloBoard(trelloBoard, data) {
-    const trelloCreator = new TrelloCreator(data);
+  importBoard(board, data, importSource) {
+    check(board, Object);
+    check(data, Object);
+    check(importSource, String);
+    let creator;
+    switch (importSource) {
+    case 'trello':
+      creator = new TrelloCreator(data);
+      break;
+    case 'wekan':
+      creator = new WekanCreator(data);
+      break;
+    }
 
 
     // 1. check all parameters are ok from a syntax point of view
     // 1. check all parameters are ok from a syntax point of view
-    try {
-      check(data, {
-        membersMapping: Match.Optional(Object),
-      });
-      trelloCreator.checkActions(trelloBoard.actions);
-      trelloCreator.checkBoard(trelloBoard);
-      trelloCreator.checkLabels(trelloBoard.labels);
-      trelloCreator.checkLists(trelloBoard.lists);
-      trelloCreator.checkCards(trelloBoard.cards);
-      trelloCreator.checkChecklists(trelloBoard.checklists);
-    } catch (e) {
-      throw new Meteor.Error('error-json-schema');
-    }
+    creator.check(board);
 
 
     // 2. check parameters are ok from a business point of view (exist &
     // 2. check parameters are ok from a business point of view (exist &
     // authorized) nothing to check, everyone can import boards in their account
     // authorized) nothing to check, everyone can import boards in their account
 
 
     // 3. create all elements
     // 3. create all elements
-    trelloCreator.parseActions(trelloBoard.actions);
-    const boardId = trelloCreator.createBoardAndLabels(trelloBoard);
-    trelloCreator.createLists(trelloBoard.lists, boardId);
-    trelloCreator.createCards(trelloBoard.cards, boardId);
-    trelloCreator.createChecklists(trelloBoard.checklists);
-    // XXX add members
-    return boardId;
+    return creator.create(board);
   },
   },
 });
 });

+ 500 - 0
models/trelloCreator.js

@@ -0,0 +1,500 @@
+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 = {};
+    // 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?)
+      color: String,
+      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,
+        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);
+          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 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.
+          if(Meteor.isServer) {
+            file.attachData(att.url, function (error) {
+              file.boardId = boardId;
+              file.cardId = cardId;
+              if (error) {
+                throw(error);
+              } else {
+                const wekanAtt = Attachments.insert(file, () => {
+                  // we do nothing
+                });
+                //
+                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) => {
+      // Create the checklist
+      const checklistToCreate = {
+        cardId: this.cards[checklist.idCard],
+        title: checklist.name,
+        createdAt: this._now(),
+      };
+      const checklistId = Checklists.direct.insert(checklistToCreate);
+      // 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',
+        });
+      });
+      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;
+        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;
+      }
+    });
+  }
+
+  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) {
+    this.parseActions(board.actions);
+    const boardId = this.createBoardAndLabels(board);
+    this.createLists(board.lists, boardId);
+    this.createCards(board.cards, boardId);
+    this.createChecklists(board.checklists);
+    // XXX add members
+    return boardId;
+  }
+}

+ 476 - 0
models/wekanCreator.js

@@ -0,0 +1,476 @@
+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: {},
+    };
+    // 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 lists Wekan ID => Wekan ID
+    this.lists = {};
+    // Map of cards Wekan ID => Wekan ID
+    this.cards = {};
+    // The comments, indexed by Wekan card id (to map when importing cards)
+    this.comments = {};
+    // the members, indexed by Wekan member id => Wekan user ID
+    this.members = data.membersMapping ? data.membersMapping : {};
+
+    // 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({
+      userId: String,
+      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],
+      members: [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,
+      name: String,
+    })]);
+  }
+
+  checkLists(wekanLists) {
+    check(wekanLists, [Match.ObjectIncluding({
+      archived: Boolean,
+      title: String,
+    })]);
+  }
+
+  // checkChecklists(wekanChecklists) {
+  //   check(wekanChecklists, [Match.ObjectIncluding({
+  //     idBoard: String,
+  //     idCard: String,
+  //     name: String,
+  //     checkItems: [Match.ObjectIncluding({
+  //       state: String,
+  //       name: String,
+  //     })],
+  //   })]);
+  // }
+
+  // You must call parseActions before calling this one.
+  createBoardAndLabels(wekanBoard) {
+    const boardToCreate = {
+      archived: wekanBoard.archived,
+      color: wekanBoard.color,
+      // very old boards won't have a creation activity so no creation date
+      createdAt: this._now(wekanBoard.createdAt),
+      labels: [],
+      members: [{
+        userId: Meteor.userId(),
+        isAdmin: true,
+        isActive: true,
+        isCommentOnly: false,
+      }],
+      permission: wekanBoard.permission,
+      slug: getSlug(wekanBoard.title) || 'board',
+      stars: 0,
+      title: wekanBoard.title,
+    };
+    // now add other members
+    if(wekanBoard.members) {
+      wekanBoard.members.forEach((wekanMember) => {
+        const wekanId = wekanMember.userId;
+        // do we have a mapping?
+        if(this.members[wekanId]) {
+          const wekanId = this.members[wekanId];
+          // do we already have it in our list?
+          const wekanMember = boardToCreate.members.find((wekanMember) => wekanMember.userId === wekanId);
+          if(!wekanMember) {
+            boardToCreate.members.push({
+              userId: wekanId,
+              isAdmin: wekanMember.isAdmin,
+              isActive: true,
+              isCommentOnly: false,
+            });
+          }
+        }
+      });
+    }
+    wekanBoard.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: wekanBoard.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],
+        sort: card.sort,
+        title: card.title,
+        // we attribute the card to its creator if available
+        userId: this._user(this.createdBy.cards[card._id]),
+        dueAt: card.dueAt ? this._now(card.dueAt) : 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;
+        }
+      }
+      // 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.date),
+            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);
+          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.
+          if(Meteor.isServer) {
+            file.attachData(att.url, function (error) {
+              file.boardId = boardId;
+              file.cardId = cardId;
+              if (error) {
+                throw(error);
+              } else {
+                const wekanAtt = Attachments.insert(file, () => {
+                  // we do nothing
+                });
+                //
+                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) => {
+      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,
+      };
+      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(),
+      });
+    });
+  }
+
+  // createChecklists(wekanChecklists) {
+  //   wekanChecklists.forEach((checklist) => {
+  //     // Create the checklist
+  //     const checklistToCreate = {
+  //       cardId: this.cards[checklist.cardId],
+  //       title: checklist.title,
+  //       createdAt: this._now(),
+  //     };
+  //     const checklistId = Checklists.direct.insert(checklistToCreate);
+  //     // Now add the items to the checklist
+  //     const itemsToCreate = [];
+  //     checklist.checkItems.forEach((item) => {
+  //       itemsToCreate.push({
+  //         _id: checklistId + itemsToCreate.length,
+  //         title: item.title,
+  //         isFinished: item.isFinished,
+  //       });
+  //     });
+  //     Checklists.direct.update(checklistId, {$set: {items: itemsToCreate}});
+  //   });
+  // }
+
+  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(wekanAttachment.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 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;
+      }}
+    });
+  }
+
+  check(board) {
+    try {
+      // check(data, {
+      //   membersMapping: Match.Optional(Object),
+      // });
+      this.checkActivities(board.activities);
+      this.checkBoard(board);
+      this.checkLabels(board.labels);
+      this.checkLists(board.lists);
+      this.checkCards(board.cards);
+      // Checklists are not exported yet
+      // this.checkChecklists(board.checklists);
+    } catch (e) {
+      throw new Meteor.Error('error-json-schema');
+    }
+  }
+
+  create(board) {
+    this.parseActivities(board);
+    const boardId = this.createBoardAndLabels(board);
+    this.createLists(board.lists, boardId);
+    this.createCards(board.cards, boardId);
+    // Checklists are not exported yet
+    // this.createChecklists(board.checklists);
+    // XXX add members
+    return boardId;
+  }
+}