diff --git a/.meteor/versions b/.meteor/versions index 5c4c189b7..a16f56bda 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -12,6 +12,7 @@ babel-runtime@0.1.4 base64@1.0.4 binary-heap@1.0.4 blaze@2.1.3 +blaze-html-templates@1.0.1 blaze-tools@1.0.4 boilerplate-generator@1.0.4 caching-compiler@1.0.0 @@ -63,7 +64,7 @@ idmontie:migrations@1.0.1 jquery@1.11.4 kadira:blaze-layout@2.2.0 kadira:dochead@1.3.2 -kadira:flow-router@2.8.0 +kadira:flow-router@2.9.0 kenton:accounts-sandstorm@0.1.8 launch-screen@1.0.4 livedata@1.0.15 @@ -124,7 +125,7 @@ seriousm:emoji-continued@1.4.0 service-configuration@1.0.5 session@1.1.1 sha@1.0.4 -softwarerero:accounts-t9n@1.1.4 +softwarerero:accounts-t9n@1.1.6 spacebars@1.0.7 spacebars-compiler@1.0.7 srp@1.0.4 diff --git a/History.md b/History.md index 6b622699a..3e4ae42c7 100644 --- a/History.md +++ b/History.md @@ -2,7 +2,8 @@ This release features: -* Card import from Trello +* Trello boards and cards importation, including card history, assigned members, + labels, comments, and attachments; * Autocompletion in the minicard editor. Start with @ to start the a board member autocompletion, or # for a label; * Accelerate the initial page rendering by sending the data on the intial HTTP diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index 64e9865d7..c1465b04b 100644 --- a/client/components/activities/activities.js +++ b/client/components/activities/activities.js @@ -86,7 +86,8 @@ BlazeComponent.extendComponent({ attachmentLink() { const attachment = this.currentData().attachment(); - return attachment && Blaze.toHTML(HTML.A({ + // trying to display url before file is stored generates js errors + return attachment && attachment.url({ download: true }) && Blaze.toHTML(HTML.A({ href: FlowRouter.path(attachment.url({ download: true })), target: '_blank', }, attachment.name())); diff --git a/client/components/import/import.jade b/client/components/import/import.jade index f63661afd..74b6ca13e 100644 --- a/client/components/import/import.jade +++ b/client/components/import/import.jade @@ -4,4 +4,51 @@ template(name="importPopup") form p: label(for='import-textarea') {{_ getLabel}} textarea#import-textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus) + | {{jsonText}} + if membersMapping + div + a.show-mapping + | {{_ 'import-show-user-mapping'}} input.primary.wide(type="submit" value="{{_ 'import'}}") + +template(name="mapMembersPopup") + .map-members + p {{_ 'import-members-map'}} + .mapping-list + each members + .mapping + a.source + div.full-name + = fullName + div.username + | ({{username}}) + .wekan + if wekan + +userAvatar(userId=wekan._id) + else + a.member.add-member.js-add-members + i.fa.fa-plus + form + input.primary.wide(type="submit" value="{{_ 'done'}}") + + template(name="addMemberPopup") + +template(name="mapMembersAddPopup") + .select-member + p + | {{_ 'import-user-select'}} + .js-map-member + +esInput(index="users") + ul.pop-over-list + +esEach(index="users") + li.item.js-member-item + a.name.js-select-import(title="{{profile.name}} ({{username}})" data-id="{{_id}}") + +userAvatar(userId=_id esSearch=true) + span.full-name + = profile.name + | ({{username}}) + +ifEsIsSearching(index='users') + +spinner + +ifEsHasNoResults(index="users") + .manage-member-section + p.quiet {{_ 'no-results'}} diff --git a/client/components/import/import.js b/client/components/import/import.js index c6957fa97..63285e577 100644 --- a/client/components/import/import.js +++ b/client/components/import/import.js @@ -11,48 +11,122 @@ const ImportPopup = BlazeComponent.extendComponent({ return 'importPopup'; }, - events() { - return [{ - 'submit': (evt) => { - evt.preventDefault(); - const dataJson = $(evt.currentTarget).find('.js-import-json').val(); - let dataObject; - try { - dataObject = JSON.parse(dataJson); - } catch (e) { - this.setError('error-json-malformed'); - return; - } - Meteor.call(this.getMethodName(), dataObject, this.getAdditionalData(), - (error, response) => { - if (error) { - this.setError(error.error); - } else { - Filter.addException(response); - this.onFinish(response); - } - } - ); - }, - }]; + jsonText() { + return Session.get('import.text'); + }, + + membersMapping() { + return Session.get('import.membersToMap'); }, onCreated() { this.error = new ReactiveVar(''); + this.dataToImport = ''; + }, + + onFinish() { + Popup.close(); + }, + + onShowMapping(evt) { + this._storeText(evt); + Popup.open('mapMembers')(evt); + }, + + onSubmit(evt){ + evt.preventDefault(); + const dataJson = this._storeText(evt); + let dataObject; + try { + dataObject = JSON.parse(dataJson); + this.setError(''); + } catch (e) { + this.setError('error-json-malformed'); + return; + } + if(this._hasAllNeededData(dataObject)) { + this._import(dataObject); + } else { + this._prepareAdditionalData(dataObject); + Popup.open(this._screenAdditionalData())(evt); + + } + }, + + events() { + return [{ + submit: this.onSubmit, + 'click .show-mapping': this.onShowMapping, + }]; }, setError(error) { this.error.set(error); }, - onFinish() { - Popup.close(); + _import(dataObject) { + const additionalData = this.getAdditionalData(); + const membersMapping = this.membersMapping(); + if (membersMapping) { + const mappingById = {}; + membersMapping.forEach((member) => { + if (member.wekan) { + mappingById[member.id] = member.wekan._id; + } + }); + additionalData.membersMapping = mappingById; + } + Session.set('import.membersToMap', null); + Session.set('import.text', null); + Meteor.call(this.getMethodName(), dataObject, additionalData, + (error, response) => { + if (error) { + this.setError(error.error); + } else { + // ensure will display what we just imported + Filter.addException(response); + this.onFinish(response); + } + } + ); + }, + + _hasAllNeededData(dataObject) { + // import has no members or they are already mapped + return dataObject.members.length === 0 || this.membersMapping(); + }, + + _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.wekan = wekanUser; + } + }); + // store members data and mapping in Session + // (we go deep and 2-way, so storing in data context is not a viable option) + Session.set('import.membersToMap', membersToMap); + return membersToMap; + }, + + _screenAdditionalData() { + return 'mapMembers'; + }, + + _storeText() { + const dataJson = this.$('.js-import-json').val(); + Session.set('import.text', dataJson); + return dataJson; }, }); ImportPopup.extendComponent({ getAdditionalData() { - const listId = this.data()._id; + const listId = this.currentData()._id; const selector = `#js-list-${this.currentData()._id} .js-minicard:first`; const firstCardDom = $(selector).get(0); const sortIndex = Utils.calculateIndex(null, firstCardDom).base; @@ -88,3 +162,110 @@ ImportPopup.extendComponent({ }, }).register('boardImportBoardPopup'); +const ImportMapMembers = BlazeComponent.extendComponent({ + members() { + return Session.get('import.membersToMap'); + }, + _refreshMembers(listOfMembers) { + Session.set('import.membersToMap', listOfMembers); + }, + /** + * Will look into the list of members to import for the specified memberId, + * then set its property to the supplied value. + * If unset is true, it will remove the property from the rest of the list as well. + * + * use: + * - memberId = null to use selected member + * - value = null to unset a property + * - unset = true to ensure property is only set on 1 member at a time + */ + _setPropertyForMember(property, value, memberId, unset = false) { + const listOfMembers = this.members(); + let finder = null; + if(memberId) { + finder = (member) => member.id === memberId; + } else { + finder = (member) => member.selected; + } + listOfMembers.forEach((member) => { + if(finder(member)) { + if(value !== null) { + member[property] = value; + } else { + delete member[property]; + } + if(!unset) { + // we shortcut if we don't care about unsetting the others + return false; + } + } else if(unset) { + delete member[property]; + } + return true; + }); + // Session.get gives us a copy, we have to set it back so it sticks + this._refreshMembers(listOfMembers); + }, + setSelectedMember(memberId) { + return this._setPropertyForMember('selected', true, memberId, true); + }, + /** + * returns the member with specified id, + * or the selected member if memberId is not specified + */ + getMember(memberId = null) { + const allMembers = Session.get('import.membersToMap'); + let finder = null; + if(memberId) { + finder = (user) => user.id === memberId; + } else { + finder = (user) => user.selected; + } + return allMembers.find(finder); + }, + mapSelectedMember(wekan) { + return this._setPropertyForMember('wekan', wekan, null); + }, + unmapMember(memberId){ + return this._setPropertyForMember('wekan', null, memberId); + }, +}); + +ImportMapMembers.extendComponent({ + onMapMember(evt) { + const memberToMap = this.currentData(); + if(memberToMap.wekan) { + // todo xxx ask for confirmation? + this.unmapMember(memberToMap.id); + } else { + this.setSelectedMember(memberToMap.id); + Popup.open('mapMembersAdd')(evt); + } + }, + onSubmit(evt) { + evt.preventDefault(); + Popup.back(); + }, + events() { + return [{ + 'submit': this.onSubmit, + 'click .mapping': this.onMapMember, + }]; + }, +}).register('mapMembersPopup'); + +ImportMapMembers.extendComponent({ + onSelectUser(){ + this.mapSelectedMember(this.currentData()); + Popup.back(); + }, + events() { + return [{ + 'click .js-select-import': this.onSelectUser, + }]; + }, + onRendered() { + // todo XXX why do I not get the focus?? + this.find('.js-map-member input').focus(); + }, +}).register('mapMembersAddPopup'); diff --git a/client/components/import/import.styl b/client/components/import/import.styl new file mode 100644 index 000000000..3c6cfdf3a --- /dev/null +++ b/client/components/import/import.styl @@ -0,0 +1,17 @@ +.map-members + .mapping:first-of-type + border-top: solid 1px #999 + .mapping + padding: 10px 0 + border-bottom: solid 1px #999 + .source + display: inline-block + width: 80% + .wekan + display: inline-block + width: 35px + .member + float: none + +a.show-mapping + text-decoration underline diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 66bd01551..4a6edfe9c 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -115,6 +115,7 @@ "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "Download", "edit": "Edit", "edit-avatar": "Change Avatar", @@ -142,6 +143,9 @@ "import-card": "Import a Trello card", "import-card-trello-instruction": "Go to a Trello card, select 'Share and more...' then 'Export JSON' and copy the resulting text", "import-json-placeholder": "Paste your valid JSON data here", + "import-members-map": "Your imported board has some members. Please map the members you want to import to Wekan users", + "import-show-user-mapping": "Review members mapping", + "import-user-select": "Pick the Wekan user you want to use as this member", "info": "Infos", "initials": "Initials", "joined": "joined", @@ -165,6 +169,8 @@ "lists": "Lists", "log-out": "Log Out", "loginPopup-title": "Log In", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "Members", "menu": "Menu", diff --git a/models/cards.js b/models/cards.js index 2e16583d5..1895fc696 100644 --- a/models/cards.js +++ b/models/cards.js @@ -108,7 +108,10 @@ Cards.helpers({ }, cover() { - return Attachments.findOne(this.coverId); + const cover = Attachments.findOne(this.coverId); + // if we return a cover before it is fully stored, we will get errors when we try to display it + // todo XXX we could return a default "upload pending" image in the meantime? + return cover && cover.url() && cover; }, absoluteUrl() { diff --git a/models/import.js b/models/import.js index a6e9f3d5b..33f56d4bb 100644 --- a/models/import.js +++ b/models/import.js @@ -4,7 +4,7 @@ const DateString = Match.Where(function (dateAsString) { }); class TrelloCreator { - constructor() { + constructor(data) { // The object creation dates, indexed by Trello id (so we only parse actions // once!) this.createdAt = { @@ -18,6 +18,11 @@ class TrelloCreator { this.lists = {}; // 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 = {}; } checkActions(trelloActions) { @@ -90,6 +95,24 @@ class TrelloCreator { 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? + if(!boardToCreate.members.find((wekanMember) => wekanMember.userId === wekanId)) { + boardToCreate.members.push({ + userId: wekanId, + isAdmin: false, + isActive: true, + }); + } + } + }); + } trelloBoard.labels.forEach((label) => { const labelToCreate = { _id: Random.id(6), @@ -121,6 +144,130 @@ class TrelloCreator { 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, + createdAt: new Date(this.createdAt.cards[card.id] || Date.now()), + dateLastActivity: new Date(), + description: card.desc, + listId: this.lists[card.idList], + sort: card.pos, + title: card.name, + // XXX use the original user? + userId: Meteor.userId(), + }; + // add labels + if (card.idLabels) { + cardToCreate.labelIds = card.idLabels.map((trelloId) => { + return this.labels[trelloId]; + }); + } + // 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); + // log activity + Activities.direct.insert({ + activityType: 'importCard', + boardId, + cardId, + createdAt: new Date(), + listId: cardToCreate.listId, + source: { + id: card.id, + system: 'Trello', + url: card.url, + }, + // we attribute the import to current user, not the one from the + // original card + userId: Meteor.userId(), + }); + // add comments + const comments = this.comments[card.id]; + if (comments) { + comments.forEach((comment) => { + const commentToCreate = { + boardId, + cardId, + createdAt: comment.date, + text: comment.data.text, + // XXX use the original comment user instead + userId: Meteor.userId(), + }; + // dateLastActivity will be set from activity insert, no need to + // update it ourselves + const commentId = CardComments.direct.insert(commentToCreate); + Activities.direct.insert({ + activityType: 'addComment', + boardId: commentToCreate.boardId, + cardId: commentToCreate.cardId, + commentId, + createdAt: commentToCreate.createdAt, + userId: commentToCreate.userId, + }); + }); + } + 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) => { @@ -170,75 +317,6 @@ class TrelloCreator { }); } - createCardsAndComments(trelloCards, boardId) { - const result = []; - trelloCards.forEach((card) => { - const cardToCreate = { - archived: card.closed, - boardId, - createdAt: new Date(this.createdAt.cards[card.id] || Date.now()), - dateLastActivity: new Date(), - description: card.desc, - listId: this.lists[card.idList], - sort: card.pos, - title: card.name, - // XXX use the original user? - userId: Meteor.userId(), - }; - // add labels - if (card.idLabels) { - cardToCreate.labelIds = card.idLabels.map((trelloId) => { - return this.labels[trelloId]; - }); - } - // insert card - const cardId = Cards.direct.insert(cardToCreate); - // log activity - Activities.direct.insert({ - activityType: 'importCard', - boardId, - cardId, - createdAt: new Date(), - listId: cardToCreate.listId, - source: { - id: card.id, - system: 'Trello', - url: card.url, - }, - // we attribute the import to current user, not the one from the - // original card - userId: Meteor.userId(), - }); - // add comments - const comments = this.comments[card.id]; - if (comments) { - comments.forEach((comment) => { - const commentToCreate = { - boardId, - cardId, - createdAt: comment.date, - text: comment.data.text, - // XXX use the original comment user instead - userId: Meteor.userId(), - }; - // dateLastActivity will be set from activity insert, no need to - // update it ourselves - const commentId = CardComments.direct.insert(commentToCreate); - Activities.direct.insert({ - activityType: 'addComment', - boardId: commentToCreate.boardId, - cardId: commentToCreate.cardId, - commentId, - createdAt: commentToCreate.createdAt, - userId: commentToCreate.userId, - }); - }); - } - // XXX add attachments - result.push(cardId); - }); - return result; - } getColor(trelloColorCode) { // trello color name => wekan color @@ -269,6 +347,29 @@ class TrelloCreator { parseActions(trelloActions) { trelloActions.forEach((action) => { switch (action.type) { + case '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); + } + break; + case 'commentCard': + const id = action.data.card.id; + if (this.comments[id]) { + this.comments[id].push(action); + } else { + this.comments[id] = [action]; + } + break; case 'createBoard': this.createdAt.board = action.date; break; @@ -280,14 +381,6 @@ class TrelloCreator { const listId = action.data.list.id; this.createdAt.lists[listId] = action.date; break; - case 'commentCard': - const id = action.data.card.id; - if (this.comments[id]) { - this.comments[id].push(action); - } else { - this.comments[id] = [action]; - } - break; default: // do nothing break; @@ -298,12 +391,13 @@ class TrelloCreator { Meteor.methods({ importTrelloBoard(trelloBoard, data) { - const trelloCreator = new TrelloCreator(); + const trelloCreator = new TrelloCreator(data); // 1. check all parameters are ok from a syntax point of view try { - // we don't use additional data - this should be an empty object - check(data, {}); + check(data, { + membersMapping: Match.Optional(Object), + }); trelloCreator.checkActions(trelloBoard.actions); trelloCreator.checkBoard(trelloBoard); trelloCreator.checkLabels(trelloBoard.labels); @@ -320,19 +414,20 @@ Meteor.methods({ trelloCreator.parseActions(trelloBoard.actions); const boardId = trelloCreator.createBoardAndLabels(trelloBoard); trelloCreator.createLists(trelloBoard.lists, boardId); - trelloCreator.createCardsAndComments(trelloBoard.cards, boardId); + trelloCreator.createCards(trelloBoard.cards, boardId); // XXX add members return boardId; }, importTrelloCard(trelloCard, data) { - const trelloCreator = new TrelloCreator(); + const trelloCreator = new TrelloCreator(data); // 1. check parameters are ok from a syntax point of view try { check(data, { listId: String, sortIndex: Number, + membersMapping: Match.Optional(Object), }); trelloCreator.checkCards([trelloCard]); trelloCreator.checkLabels(trelloCard.labels); @@ -358,7 +453,7 @@ Meteor.methods({ trelloCreator.parseActions(trelloCard.actions); const board = list.board(); trelloCreator.createLabels(trelloCard.labels, board); - const cardIds = trelloCreator.createCardsAndComments([trelloCard], board._id); + const cardIds = trelloCreator.createCards([trelloCard], board._id); return cardIds[0]; }, }); diff --git a/models/users.js b/models/users.js index 1e69564de..e85671bca 100644 --- a/models/users.js +++ b/models/users.js @@ -2,7 +2,7 @@ Users = Meteor.users; // eslint-disable-line meteor/collections // Search a user in the complete server database by its name or username. This // is used for instance to add a new user to a board. -const searchInFields = ['username', 'profile.name']; +const searchInFields = ['username', 'profile.fullname']; Users.initEasySearch(searchInFields, { use: 'mongo-db', returnFields: [...searchInFields, 'profile.avatarUrl'],