Explorar o código

Merge branch 'devel' into minicard-editor

Conflicts:
	client/components/lists/listBody.js
Maxime Quandalle %!s(int64=9) %!d(string=hai) anos
pai
achega
2b134ff7a9
Modificáronse 54 ficheiros con 1014 adicións e 297 borrados
  1. 10 0
      .editorconfig
  2. 72 65
      .eslintrc
  3. 1 0
      .gitignore
  4. 2 3
      .meteor/packages
  5. 1 1
      .meteor/release
  6. 48 43
      .meteor/versions
  7. 2 2
      .travis.yml
  8. 12 1
      History.md
  9. 35 24
      client/components/activities/activities.jade
  10. 23 5
      client/components/activities/activities.js
  11. 6 4
      client/components/activities/comments.js
  12. 8 6
      client/components/boards/boardBody.js
  13. 3 0
      client/components/boards/boardHeader.jade
  14. 1 0
      client/components/boards/boardHeader.js
  15. 2 0
      client/components/boards/boardHeader.styl
  16. 1 1
      client/components/cards/attachments.jade
  17. 13 12
      client/components/cards/cardDetails.js
  18. 1 1
      client/components/cards/labels.jade
  19. 2 2
      client/components/cards/labels.js
  20. 1 1
      client/components/cards/minicard.jade
  21. 7 0
      client/components/import/import.jade
  22. 90 0
      client/components/import/import.js
  23. 2 2
      client/components/lists/list.js
  24. 5 5
      client/components/lists/listBody.js
  25. 1 0
      client/components/lists/listHeader.jade
  26. 5 3
      client/components/lists/listHeader.js
  27. 6 30
      client/components/main/editor.js
  28. 2 2
      client/components/main/header.jade
  29. 5 1
      client/components/main/layouts.jade
  30. 3 3
      client/components/main/popup.styl
  31. 3 3
      client/components/sidebar/sidebar.js
  32. 1 1
      client/components/sidebar/sidebarFilters.jade
  33. 2 2
      client/components/users/userAvatar.jade
  34. 3 3
      client/components/users/userHeader.js
  35. 1 1
      client/config/accounts.js
  36. 4 0
      client/config/blazeHelpers.js
  37. 23 0
      client/config/router.js
  38. 41 0
      client/lib/accessibility.js
  39. 2 2
      client/lib/filter.js
  40. 2 2
      client/lib/modal.js
  41. 5 4
      client/lib/multiSelection.js
  42. 2 2
      client/lib/popup.js
  43. 30 0
      client/lib/textComplete.js
  44. 26 1
      i18n/en.i18n.json
  45. 1 1
      models/attachments.js
  46. 33 13
      models/boards.js
  47. 3 2
      models/cards.js
  48. 364 0
      models/import.js
  49. 38 19
      models/users.js
  50. 24 0
      package.json
  51. 19 20
      sandstorm.js
  52. 9 3
      server/migrations.js
  53. 1 1
      server/publications/boards.js
  54. 7 0
      server/publications/fast-render.js

+ 10 - 0
.editorconfig

@@ -0,0 +1,10 @@
+# EditorConfig is awesome: http://EditorConfig.org
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2

+ 72 - 65
.eslintrc

@@ -1,83 +1,101 @@
 ecmaFeatures:
   experimentalObjectRestSpread: true
 
+plugins:
+  - meteor
+
+parser: babel-eslint
+
 rules:
-  accessor-pairs: [2]
-  consistent-return: [2]
+  strict: 0
+  no-undef: 2
+  accessor-pairs: 2
+  comma-dangle: [2, 'always-multiline']
+  consistent-return: 2
+  dot-notation: 2
+  eqeqeq: 2
   indent: [2, 2]
-  semi: [2, always]
-  comma-dangle: [2, always-multiline]
+  no-cond-assign: 2
+  no-constant-condition: 2
+  no-eval: 2
   no-inner-declarations: [0]
-  dot-notation: [2]
-  eqeqeq: [2]
-  no-eval: [2]
-  radix: [2]
+  no-unneeded-ternary: 2
+  radix: 2
+  semi: [2, always]
 
   # Stylistic Issues
-  camelcase: [2]
-  comma-spacing: [2]
-  comma-style: [2]
-  new-parens: [2]
-  no-lonely-if: [2]
-  no-multiple-empty-lines: [2]
-  no-nested-ternary: [2]
+  camelcase: 2
+  comma-spacing: 2
+  comma-style: 2
   linebreak-style: [2, unix]
+  new-parens: 2
+  no-lonely-if: 2
+  no-multiple-empty-lines: 2
+  no-nested-ternary: 2
+  no-spaced-func: 2
+  operator-linebreak: 2
   quotes: [2, single]
-  semi-spacing: [2]
+  semi-spacing: 2
+  space-unary-ops: 2
   spaced-comment: [2, always, markers: ['/']]
-  space-unary-ops: [2]
 
   # ECMAScript 6
-  arrow-parens: [2]
-  arrow-spacing: [2]
-  no-class-assign: [2]
-  no-dupe-class-members: [2]
-  no-var: [2]
-  object-shorthand: [2]
-  prefer-const: [2]
-  prefer-template: [2]
-  prefer-spread: [2]
+  arrow-parens: 2
+  arrow-spacing: 2
+  no-class-assign: 2
+  no-dupe-class-members: 2
+  no-var: 2
+  object-shorthand: 2
+  prefer-const: 2
+  prefer-spread: 2
+  prefer-template: 2
 
-globals:
-  # Meteor globals
-  Meteor: false
-  DDP: false
-  Mongo: false
-  Session: false
-  Accounts: false
-  Template: false
-  Blaze: false
-  UI: false
-  Match: false
-  check: false
-  Tracker: false
-  Deps: false
-  ReactiveVar: false
-  EJSON: false
-  HTTP: false
-  Email: false
-  Assets: false
-  Handlebars: false
-  Package: false
-  App: false
-  Npm: false
-  Tinytest: false
-  Random: false
-  HTML: false
+  # eslint-plugin-meteor
+  ## Meteor API
+  meteor/globals: 2
+  meteor/core: 2
+  meteor/pubsub: 2
+  meteor/methods: 2
+  meteor/check: 2
+  meteor/connections: 2
+  meteor/collections: 2
+  meteor/session: [2, 'no-equal']
 
+  ## Best practices
+  meteor/no-session: 0
+  meteor/no-zero-timeout: 2
+  meteor/no-blaze-lifecycle-assignment: 2
+
+settings:
+  meteor:
+
+    # Our collections
+    collections:
+      - AccountsTemplates
+      - Activities
+      - Attachments
+      - Boards
+      - CardComments
+      - Cards
+      - Lists
+      - UnsavedEditCollection
+      - Users
+
+globals:
   # Exported by packages we use
-  '$': false
-  _: false
   autosize: false
   Avatar: true
   Avatars: true
   BlazeComponent: false
   BlazeLayout: false
+  DocHead: false
   ESSearchResults: false
+  FastRender: false
   FlowRouter: false
   FS: false
   getSlug: false
   Migrations: false
+  moment: false
   Mousetrap: false
   Picker: false
   Presence: true
@@ -90,17 +108,6 @@ globals:
   T9n: false
   TAPi18n: false
 
-  # Our collections
-  AccountsTemplates: true
-  Activities: true
-  Attachments: true
-  Boards: true
-  CardComments: true
-  Cards: true
-  Lists: true
-  UnsavedEditCollection: true
-  Users: true
-
   # Our objects
   CSSEvents: true
   EscapeActions: true

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@
 .tx/
 *.sublime-workspace
 tmp/
+node_modules/

+ 2 - 3
.meteor/packages

@@ -2,9 +2,6 @@
 #
 # 'meteor add' and 'meteor remove' will edit this file for you,
 # but you can also edit it by hand.
-#
-# XXX Should we replace tmeasday:presence by 3stack:presence? Or maybe the
-# packages will merge in the future?
 
 meteor-base
 
@@ -50,7 +47,9 @@ alethes:pages
 arillo:flow-router-helpers
 audit-argument-checks
 kadira:blaze-layout
+kadira:dochead
 kadira:flow-router
+meteorhacks:fast-render
 meteorhacks:picker
 meteorhacks:subs-manager
 mquandalle:autofocus

+ 1 - 1
.meteor/release

@@ -1 +1 @@
-METEOR@1.2.0.1
+METEOR@1.2.1

+ 48 - 43
.meteor/versions

@@ -1,12 +1,12 @@
-3stack:presence@1.0.3
-accounts-base@1.2.1
-accounts-password@1.1.3
+3stack:presence@1.0.4
+accounts-base@1.2.2
+accounts-password@1.1.4
 aldeed:collection2@2.5.0
 aldeed:simple-schema@1.3.3
 alethes:pages@1.8.4
 arillo:flow-router-helpers@0.4.5
 audit-argument-checks@1.0.4
-autoupdate@1.2.3
+autoupdate@1.2.4
 babel-compiler@5.8.24_1
 babel-runtime@0.1.4
 base64@1.0.4
@@ -15,7 +15,7 @@ blaze@2.1.3
 blaze-tools@1.0.4
 boilerplate-generator@1.0.4
 caching-compiler@1.0.0
-caching-html-compiler@1.0.1
+caching-html-compiler@1.0.2
 callback-hook@1.0.4
 cfs:access-point@0.1.49
 cfs:base-package@0.0.30
@@ -30,26 +30,27 @@ cfs:power-queue@0.9.11
 cfs:reactive-list@0.0.9
 cfs:reactive-property@0.0.4
 cfs:standard-packages@0.5.9
-cfs:storage-adapter@0.2.2
+cfs:storage-adapter@0.2.3
 cfs:tempstore@0.1.5
 cfs:upload-http@0.0.20
 cfs:worker@0.1.4
-check@1.0.6
-coffeescript@1.0.9
-cosmos:browserify@0.5.1
-dburles:collection-helpers@1.0.3
+check@1.1.0
+chuangbo:cookie@1.1.0
+coffeescript@1.0.11
+cosmos:browserify@0.8.1
+dburles:collection-helpers@1.0.4
 ddp@1.2.2
 ddp-client@1.2.1
-ddp-common@1.2.1
+ddp-common@1.2.2
 ddp-rate-limiter@1.0.0
-ddp-server@1.2.1
+ddp-server@1.2.2
 deps@1.0.9
 diff-sequence@1.0.1
-ecmascript@0.1.4
-ecmascript-collections@0.1.6
+ecmascript@0.1.6
+ecmascript-runtime@0.2.6
 ejson@1.0.7
-email@1.0.7
-es5-shim@4.1.13
+email@1.0.8
+es5-shim@4.1.14
 fastclick@1.0.7
 fortawesome:fontawesome@4.4.0
 geojson-utils@1.0.4
@@ -58,38 +59,40 @@ html-tools@1.0.5
 htmljs@1.0.5
 http@1.1.1
 id-map@1.0.4
-idmontie:migrations@1.0.0
+idmontie:migrations@1.0.1
 jquery@1.11.4
-kadira:blaze-layout@2.1.0
-kadira:flow-router@2.6.1
-kenton:accounts-sandstorm@0.1.4
+kadira:blaze-layout@2.2.0
+kadira:dochead@1.3.2
+kadira:flow-router@2.7.0
+kenton:accounts-sandstorm@0.1.7
 launch-screen@1.0.4
-less@2.5.0_2
 livedata@1.0.15
 localstorage@1.0.5
 logging@1.0.8
-matb33:collection-hooks@0.8.0
-matteodem:easy-search@1.6.3
-meteor@1.1.7
+matb33:collection-hooks@0.8.1
+matteodem:easy-search@1.6.4
+meteor@1.1.10
 meteor-base@1.0.1
 meteor-platform@1.2.3
 meteorhacks:aggregate@1.3.0
 meteorhacks:collection-utils@1.2.0
+meteorhacks:fast-render@2.10.0
+meteorhacks:inject-data@1.4.1
 meteorhacks:picker@1.0.3
 meteorhacks:subs-manager@1.6.2
 meteorspark:util@0.2.0
 minifiers@1.1.7
-minimongo@1.0.9
+minimongo@1.0.10
 mobile-status-bar@1.0.6
-mongo@1.1.1
+mongo@1.1.3
 mongo-id@1.0.1
 mongo-livedata@1.0.9
 mousetrap:mousetrap@1.4.6_1
 mquandalle:autofocus@1.0.0
 mquandalle:collection-mutations@0.1.0
-mquandalle:jade@0.4.3_1
-mquandalle:jade-compiler@0.4.3
-mquandalle:jquery-textcomplete@0.3.9_1
+mquandalle:jade@0.4.5
+mquandalle:jade-compiler@0.4.5
+mquandalle:jquery-textcomplete@0.8.0_1
 mquandalle:jquery-ui-drag-drop-sort@0.1.0
 mquandalle:moment@1.0.0
 mquandalle:mousetrap-bindglobal@0.0.1
@@ -101,15 +104,17 @@ observe-sequence@1.0.7
 ongoworks:speakingurl@1.1.0
 ordered-dict@1.0.4
 peerlibrary:assert@0.2.5
-peerlibrary:base-component@0.10.0
-peerlibrary:blaze-components@0.13.0
+peerlibrary:base-component@0.14.0
+peerlibrary:blaze-components@0.15.1
+peerlibrary:computed-field@0.3.0
+peerlibrary:reactive-field@0.1.0
 perak:markdown@1.0.5
-promise@0.4.8
+promise@0.5.1
 raix:eventemitter@0.1.3
-raix:handlebar-helpers@0.2.4
-random@1.0.4
+raix:handlebar-helpers@0.2.5
+random@1.0.5
 rate-limit@1.0.0
-reactive-dict@1.1.1
+reactive-dict@1.1.3
 reactive-var@1.0.6
 reload@1.1.4
 retry@1.0.4
@@ -123,19 +128,19 @@ softwarerero:accounts-t9n@1.1.4
 spacebars@1.0.7
 spacebars-compiler@1.0.7
 srp@1.0.4
-standard-minifiers@1.0.0
-tap:i18n@1.6.1
+standard-minifiers@1.0.2
+tap:i18n@1.7.0
 templates:tabs@2.2.0
-templating@1.1.3
+templating@1.1.5
 templating-tools@1.0.0
-tracker@1.0.8
+tracker@1.0.9
 ui@1.0.8
 underscore@1.0.4
 url@1.0.5
-useraccounts:core@1.12.3
-useraccounts:flow-routing@1.12.3
-useraccounts:unstyled@1.12.3
+useraccounts:core@1.12.4
+useraccounts:flow-routing@1.12.4
+useraccounts:unstyled@1.12.4
 verron:autosize@3.0.8
-webapp@1.2.2
+webapp@1.2.3
 webapp-hashing@1.0.5
 zimme:active-route@2.3.2

+ 2 - 2
.travis.yml

@@ -3,6 +3,6 @@ language: node_js
 node_js:
   - "0.10.40"
 install:
-  - "npm install -g eslint"
+  - "npm install"
 script:
-  - "eslint ./"
+  - "npm test"

+ 12 - 1
History.md

@@ -1,4 +1,15 @@
-# NEXT — v0.9
+# v0.10
+
+This release features:
+
+* Card import from Trello
+* Accelerate the initial page rendering by sending the data on the intial HTTP
+  response instead of waiting for the DDP connection to open.
+
+Thanks to GitHub users AlexanderS, fisle, ndarilek, and xavierpriour for their
+contributions.
+
+# v0.9
 
 This release is a large re-write of the previous code base. Despite being
 relatively similar to v0.8 feature-wise, this release marks the beginning of our

+ 35 - 24
client/components/activities/activities.jade

@@ -14,32 +14,41 @@ template(name="boardActivities")
       p.activity-desc
         +memberName(user=user)
 
-        if($eq activityType 'createBoard')
-          | {{_ 'activity-created' boardLabel}}.
+        if($eq activityType 'addAttachment')
+          | {{{_ 'activity-attached' attachmentLink cardLink}}}.
 
-        if($eq activityType 'createList')
-          | {{_ 'activity-added' list.title boardLabel}}.
+        if($eq activityType 'addBoardMember')
+          | {{{_ 'activity-added' memberLink boardLabel}}}.
+
+        if($eq activityType 'addComment')
+          | {{{_ 'activity-on' cardLink}}}
+          a.activity-comment(href="{{ card.absoluteUrl }}")
+            +viewer
+              = comment.text
+
+        if($eq activityType 'archivedCard')
+          | {{{_ 'activity-archived' cardLink}}}.
 
         if($eq activityType 'archivedList')
           | {{_ 'activity-archived' list.title}}.
 
+        if($eq activityType 'createBoard')
+          | {{_ 'activity-created' boardLabel}}.
+
         if($eq activityType 'createCard')
           | {{{_ 'activity-added' cardLink boardLabel}}}.
 
-        if($eq activityType 'archivedCard')
-          | {{{_ 'activity-archived' cardLink}}}.
-
-        if($eq activityType 'restoredCard')
-          | {{{_ 'activity-sent' cardLink boardLabel}}}.
+        if($eq activityType 'createList')
+          | {{_ 'activity-added' list.title boardLabel}}.
 
-        if($eq activityType 'moveCard')
-          | {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
+        if($eq activityType 'importBoard')
+          | {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
 
-        if($eq activityType 'addBoardMember')
-          | {{{_ 'activity-added' memberLink boardLabel}}}.
+        if($eq activityType 'importCard')
+          | {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
 
-        if($eq activityType 'removeBoardMember')
-          | {{{_ 'activity-excluded' memberLink boardLabel}}}.
+        if($eq activityType 'importList')
+          | {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
 
         if($eq activityType 'joinMember')
           if($eq currentUser._id member._id)
@@ -47,21 +56,21 @@ template(name="boardActivities")
           else
             | {{{_ 'activity-added' memberLink cardLink}}}.
 
+        if($eq activityType 'moveCard')
+          | {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
+
+        if($eq activityType 'removeBoardMember')
+          | {{{_ 'activity-excluded' memberLink boardLabel}}}.
+
+        if($eq activityType 'restoredCard')
+          | {{{_ 'activity-sent' cardLink boardLabel}}}.
+
         if($eq activityType 'unjoinMember')
           if($eq currentUser._id member._id)
             | {{{_ 'activity-unjoined' cardLink}}}.
           else
             | {{{_ 'activity-removed' memberLink cardLink}}}.
 
-        if($eq activityType 'addComment')
-          | {{{_ 'activity-on' cardLink}}}
-          a.activity-comment(href="{{ card.absoluteUrl }}")
-            +viewer
-              = comment.text
-
-        if($eq activityType 'addAttachment')
-          | {{{_ 'activity-attached' attachmentLink cardLink}}}.
-
         span.activity-meta {{ moment createdAt }}
 
 template(name="cardActivities")
@@ -72,6 +81,8 @@ template(name="cardActivities")
         +memberName(user=user)
         if($eq activityType 'createCard')
           | {{_ 'activity-added' cardLabel list.title}}.
+        if($eq activityType 'importCard')
+          | {{{_ 'activity-imported' cardLabel list.title sourceLink}}}.
         if($eq activityType 'joinMember')
           if($eq currentUser._id member._id)
             | {{_ 'activity-joined' cardLabel}}.

+ 23 - 5
client/components/activities/activities.js

@@ -9,7 +9,7 @@ BlazeComponent.extendComponent({
     // XXX Should we use ReactiveNumber?
     this.page = new ReactiveVar(1);
     this.loadNextPageLocked = false;
-    const sidebar = this.componentParent(); // XXX for some reason not working
+    const sidebar = this.parentComponent(); // XXX for some reason not working
     sidebar.callFirstWith(null, 'resetNextPeak');
     this.autorun(() => {
       const mode = this.data().mode;
@@ -55,11 +55,29 @@ BlazeComponent.extendComponent({
   cardLink() {
     const card = this.currentData().card();
     return card && Blaze.toHTML(HTML.A({
-      href: card.absoluteUrl(),
+      href: FlowRouter.path(card.absoluteUrl()),
       'class': 'action-card',
     }, card.title));
   },
 
+  listLabel() {
+    return this.currentData().list().title;
+  },
+
+  sourceLink() {
+    const source = this.currentData().source;
+    if(source) {
+      if(source.url) {
+        return Blaze.toHTML(HTML.A({
+          href: source.url,
+        }, source.system));
+      } else {
+        return source.system;
+      }
+    }
+    return null;
+  },
+
   memberLink() {
     return Blaze.toHTMLWithData(Template.memberName, {
       user: this.currentData().member(),
@@ -69,7 +87,7 @@ BlazeComponent.extendComponent({
   attachmentLink() {
     const attachment = this.currentData().attachment();
     return attachment && Blaze.toHTML(HTML.A({
-      href: attachment.url({ download: true }),
+      href: FlowRouter.path(attachment.url({ download: true })),
       target: '_blank',
     }, attachment.name()));
   },
@@ -83,9 +101,9 @@ BlazeComponent.extendComponent({
       },
       'submit .js-edit-comment'(evt) {
         evt.preventDefault();
-        const commentText = this.currentComponent().getValue();
+        const commentText = this.currentComponent().getValue().trim();
         const commentId = Template.parentData().commentId;
-        if ($.trim(commentText)) {
+        if (commentText) {
           CardComments.update(commentId, {
             $set: {
               text: commentText,

+ 6 - 4
client/components/activities/comments.js

@@ -24,11 +24,12 @@ BlazeComponent.extendComponent({
       },
       'submit .js-new-comment-form'(evt) {
         const input = this.getInput();
-        if ($.trim(input.val())) {
+        const text = input.val().trim();
+        if (text) {
           CardComments.insert({
+            text,
             boardId: this.currentData().boardId,
             cardId: this.currentData()._id,
-            text: input.val(),
           });
           resetCommentInput(input);
           Tracker.flush();
@@ -72,8 +73,9 @@ EscapeActions.register('inlinedForm',
       docId: Session.get('currentCard'),
     };
     const commentInput = $('.js-new-comment-input');
-    if ($.trim(commentInput.val())) {
-      UnsavedEdits.set(draftKey, commentInput.val());
+    const draft = commentInput.val().trim();
+    if (draft) {
+      UnsavedEdits.set(draftKey, draft);
     } else {
       UnsavedEdits.reset(draftKey);
     }

+ 8 - 6
client/components/boards/boardBody.js

@@ -34,7 +34,7 @@ BlazeComponent.extendComponent({
   },
 
   openNewListForm() {
-    this.componentChildren('addListForm')[0].open();
+    this.childComponents('addListForm')[0].open();
   },
 
   // XXX Flow components allow us to avoid creating these two setter methods by
@@ -179,22 +179,24 @@ BlazeComponent.extendComponent({
 
   // Proxy
   open() {
-    this.componentChildren('inlinedForm')[0].open();
+    this.childComponents('inlinedForm')[0].open();
   },
 
   events() {
     return [{
       submit(evt) {
         evt.preventDefault();
-        const title = this.find('.list-name-input');
-        if ($.trim(title.value)) {
+        const titleInput = this.find('.list-name-input');
+        const title = titleInput.value.trim();
+        if (title) {
           Lists.insert({
-            title: title.value,
+            title,
             boardId: Session.get('currentBoard'),
             sort: $('.list').length,
           });
 
-          title.value = '';
+          titleInput.value = '';
+          titleInput.focus();
         }
       },
     }];

+ 3 - 0
client/components/boards/boardHeader.jade

@@ -107,6 +107,9 @@ template(name="createBoardPopup")
           | {{{_ 'board-private-info'}}}
         a.js-change-visibility {{_ 'change'}}.
     input.primary.wide(type="submit" value="{{_ 'create'}}")
+    span.quiet
+      | {{_ 'or'}}
+      a.js-import {{_ 'import-board'}}
 
 
 template(name="boardChangeTitlePopup")

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

@@ -145,6 +145,7 @@ BlazeComponent.extendComponent({
         this.setVisibility(this.currentData());
       },
       'click .js-change-visibility': this.toggleVisibilityMenu,
+      'click .js-import': Popup.open('boardImportBoard'),
       submit: this.onSubmit,
     }];
   },

+ 2 - 0
client/components/boards/boardHeader.styl

@@ -0,0 +1,2 @@
+a.js-import
+  text-decoration underline

+ 1 - 1
client/components/cards/attachments.jade

@@ -15,7 +15,7 @@ template(name="attachmentsGalery")
         .attachment-thumbnail
           if isUploaded
             if isImage
-              img.attachment-thumbnail-img(src=url)
+              img.attachment-thumbnail-img(src="{{pathFor url}}")
             else
               span.attachment-thumbnail-ext= extension
           else

+ 13 - 12
client/components/cards/cardDetails.js

@@ -13,19 +13,19 @@ BlazeComponent.extendComponent({
   },
 
   reachNextPeak() {
-    const activitiesComponent = this.componentChildren('activities')[0];
+    const activitiesComponent = this.childrenComponents('activities')[0];
     activitiesComponent.loadNextPage();
   },
 
   onCreated() {
     this.isLoaded = new ReactiveVar(false);
-    this.componentParent().showOverlay.set(true);
-    this.componentParent().mouseHasEnterCardDetails = false;
+    this.parentComponent().showOverlay.set(true);
+    this.parentComponent().mouseHasEnterCardDetails = false;
   },
 
   scrollParentContainer() {
     const cardPanelWidth = 510;
-    const bodyBoardComponent = this.componentParent();
+    const bodyBoardComponent = this.parentComponent();
 
     const $cardContainer = bodyBoardComponent.$('.js-lists');
     const $cardView = this.$(this.firstNode());
@@ -52,7 +52,7 @@ BlazeComponent.extendComponent({
   },
 
   onDestroyed() {
-    this.componentParent().showOverlay.set(false);
+    this.parentComponent().showOverlay.set(false);
   },
 
   events() {
@@ -62,7 +62,8 @@ BlazeComponent.extendComponent({
       },
     };
 
-    return [_.extend(events, {
+    return [{
+      ...events,
       'click .js-close-card-details'() {
         Utils.goBoardId(this.data().boardId);
       },
@@ -74,8 +75,8 @@ BlazeComponent.extendComponent({
       },
       'submit .js-card-details-title'(evt) {
         evt.preventDefault();
-        const title = this.currentComponent().getValue();
-        if ($.trim(title)) {
+        const title = this.currentComponent().getValue().trim();
+        if (title) {
           this.data().setTitle(title);
         }
       },
@@ -83,10 +84,10 @@ BlazeComponent.extendComponent({
       'click .js-add-members': Popup.open('cardMembers'),
       'click .js-add-labels': Popup.open('cardLabels'),
       'mouseenter .js-card-details'() {
-        this.componentParent().showOverlay.set(true);
-        this.componentParent().mouseHasEnterCardDetails = true;
+        this.parentComponent().showOverlay.set(true);
+        this.parentComponent().mouseHasEnterCardDetails = true;
       },
-    })];
+    }];
   },
 }).register('cardDetails');
 
@@ -105,7 +106,7 @@ BlazeComponent.extendComponent({
 
   close(isReset = false) {
     if (this.isOpen.get() && !isReset) {
-      const draft = $.trim(this.getValue());
+      const draft = this.getValue().trim();
       if (draft !== Cards.findOne(Session.get('currentCard')).description) {
         UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue());
       }

+ 1 - 1
client/components/cards/labels.jade

@@ -18,7 +18,7 @@ template(name="editLabelPopup")
   form.edit-label
     +formLabel
     button.primary.wide.left(type="submit") {{_ 'save'}}
-    span.right
+    button.js-delete-label.negate.wide.right {{_ 'delete'}}
 
 template(name="deleteLabelPopup")
   p {{_ "label-delete-pop"}}

+ 2 - 2
client/components/cards/labels.js

@@ -13,7 +13,7 @@ BlazeComponent.extendComponent({
   },
 
   labels() {
-    return _.map(labelColors, (color) => {
+    return labelColors.map((color) => {
       return { color, name: '' };
     });
   },
@@ -69,12 +69,12 @@ Template.formLabel.events({
 Template.createLabelPopup.events({
   // Create the new label
   'submit .create-label'(evt, tpl) {
+    evt.preventDefault();
     const board = Boards.findOne(Session.get('currentBoard'));
     const name = tpl.$('#labelName').val().trim();
     const color = Blaze.getData(tpl.find('.fa-check')).color;
     board.addLabel(name, color);
     Popup.back();
-    evt.preventDefault();
   },
 });
 

+ 1 - 1
client/components/cards/minicard.jade

@@ -2,7 +2,7 @@ template(name="minicard")
   .minicard
     if cover
       .minicard-cover
-        img(src=cover.url)
+        img(src="{{pathFor cover.url}}")
     if labels
       .minicard-labels
         each labels

+ 7 - 0
client/components/import/import.jade

@@ -0,0 +1,7 @@
+template(name="importPopup")
+  if error.get
+    .warning {{_ error.get}}
+  form
+    p: label(for='import-textarea') {{_ getLabel}}
+    textarea#import-textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus)
+    input.primary.wide(type="submit" value="{{_ 'import'}}")

+ 90 - 0
client/components/import/import.js

@@ -0,0 +1,90 @@
+/// Abstract root for all import popup screens.
+/// Descendants must define:
+/// - getMethodName(): return the Meteor method to call for import, passing json
+/// data decoded as object and additional data (see below);
+/// - getAdditionalData(): return object containing additional data passed to
+/// Meteor method (like list ID and position for a card import);
+/// - getLabel(): i18n key for the text displayed in the popup, usually to
+/// explain how to get the data out of the source system.
+const ImportPopup = BlazeComponent.extendComponent({
+  template() {
+    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);
+            }
+          }
+        );
+      },
+    }];
+  },
+
+  onCreated() {
+    this.error = new ReactiveVar('');
+  },
+
+  setError(error) {
+    this.error.set(error);
+  },
+
+  onFinish() {
+    Popup.close();
+  },
+});
+
+ImportPopup.extendComponent({
+  getAdditionalData() {
+    const listId = this.data()._id;
+    const selector = `#js-list-${this.currentData()._id} .js-minicard:first`;
+    const firstCardDom = $(selector).get(0);
+    const sortIndex = Utils.calculateIndex(null, firstCardDom).base;
+    const result = {listId, sortIndex};
+    return result;
+  },
+
+  getMethodName() {
+    return 'importTrelloCard';
+  },
+
+  getLabel() {
+    return 'import-card-trello-instruction';
+  },
+}).register('listImportCardPopup');
+
+ImportPopup.extendComponent({
+  getAdditionalData() {
+    const result = {};
+    return result;
+  },
+
+  getMethodName() {
+    return 'importTrelloBoard';
+  },
+
+  getLabel() {
+    return 'import-board-trello-instruction';
+  },
+
+  onFinish(response) {
+    Utils.goBoardId(response);
+  },
+}).register('boardImportBoardPopup');
+

+ 2 - 2
client/components/lists/list.js

@@ -7,7 +7,7 @@ BlazeComponent.extendComponent({
 
   // Proxy
   openForm(options) {
-    this.componentChildren('listBody')[0].openForm(options);
+    this.childrenComponents('listBody')[0].openForm(options);
   },
 
   onCreated() {
@@ -25,7 +25,7 @@ BlazeComponent.extendComponent({
     if (!Meteor.user() || !Meteor.user().isBoardMember())
       return;
 
-    const boardComponent = this.componentParent();
+    const boardComponent = this.parentComponent();
     const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
     const $cards = this.$('.js-minicards');
     $cards.sortable({

+ 5 - 5
client/components/lists/listBody.js

@@ -11,8 +11,8 @@ BlazeComponent.extendComponent({
     options = options || {};
     options.position = options.position || 'top';
 
-    const forms = this.componentChildren('inlinedForm');
-    let form = _.find(forms, (component) => {
+    const forms = this.childrenComponents('inlinedForm');
+    let form = forms.find((component) => {
       return component.data().position === options.position;
     });
     if (!form && forms.length > 0) {
@@ -26,8 +26,8 @@ BlazeComponent.extendComponent({
     const firstCardDom = this.find('.js-minicard:first');
     const lastCardDom = this.find('.js-minicard:last');
     const textarea = $(evt.currentTarget).find('textarea');
-    let title = textarea.val();
-    const position = Blaze.getData(evt.currentTarget).position;
+    const position = this.currentData().position;
+    let title = textarea.val().trim();
     let sortIndex;
     if (position === 'top') {
       sortIndex = Utils.calculateIndex(null, firstCardDom).base;
@@ -62,7 +62,7 @@ BlazeComponent.extendComponent({
       }
     });
 
-    if ($.trim(title)) {
+    if (title) {
       const _id = Cards.insert({
         title,
         listId: this.data()._id,

+ 1 - 0
client/components/lists/listHeader.jade

@@ -25,6 +25,7 @@ template(name="listActionPopup")
       li: a.js-archive-cards {{_ 'list-archive-cards'}}
   hr
   ul.pop-over-list
+    li: a.js-import-card {{_ 'import-card'}}
     li: a.js-close-list {{_ 'archive-list'}}
 
 template(name="listMoveCardsPopup")

+ 5 - 3
client/components/lists/listHeader.js

@@ -5,10 +5,10 @@ BlazeComponent.extendComponent({
 
   editTitle(evt) {
     evt.preventDefault();
-    const newTitle = this.componentChildren('inlinedForm')[0].getValue();
+    const newTitle = this.childrenComponents('inlinedForm')[0].getValue().trim();
     const list = this.currentData();
-    if ($.trim(newTitle)) {
-      list.rename(newTitle);
+    if (newTitle) {
+      list.rename(newTitle.trim());
     }
   },
 
@@ -33,6 +33,7 @@ Template.listActionPopup.events({
     MultiSelection.add(cardIds);
     Popup.close();
   },
+  'click .js-import-card': Popup.open('listImportCard'),
   'click .js-move-cards': Popup.open('listMoveCards'),
   'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() {
     this.allCards().forEach((card) => {
@@ -40,6 +41,7 @@ Template.listActionPopup.events({
     });
     Popup.close();
   }),
+
   'click .js-close-list'(evt) {
     evt.preventDefault();
     this.archive();

+ 6 - 30
client/components/main/editor.js

@@ -1,17 +1,15 @@
-let dropdownMenuIsOpened = false;
-
 Template.editor.onRendered(() => {
   const $textarea = this.$('textarea');
 
   autosize($textarea);
 
-  $textarea.textcomplete([
+  $textarea.escapeableTextComplete([
     // Emojies
     {
       match: /\B:([\-+\w]*)$/,
       search(term, callback) {
-        callback($.map(Emoji.values, (emoji) => {
-          return emoji.indexOf(term) === 0 ? emoji : null;
+        callback(Emoji.values.map((emoji) => {
+          return emoji.includes(term) ? emoji : null;
         }));
       },
       template(value) {
@@ -30,9 +28,9 @@ Template.editor.onRendered(() => {
       match: /\B@(\w*)$/,
       search(term, callback) {
         const currentBoard = Boards.findOne(Session.get('currentBoard'));
-        callback($.map(currentBoard.members, (member) => {
+        callback(currentBoard.members.map((member) => {
           const username = Users.findOne(member.userId).username;
-          return username.indexOf(term) === 0 ? username : null;
+          return username.includes(term) ? username : null;
         }));
       },
       template(value) {
@@ -44,30 +42,8 @@ Template.editor.onRendered(() => {
       index: 1,
     },
   ]);
-
-  // Since commit d474017 jquery-textComplete automatically closes a potential
-  // opened dropdown menu when the user press Escape. This behavior conflicts
-  // with our EscapeActions system, but it's too complicated and hacky to
-  // monkey-pach textComplete to disable it -- I tried. Instead we listen to
-  // 'open' and 'hide' events, and create a ghost escapeAction when the dropdown
-  // is opened (and rely on textComplete to execute the actual action).
-  $textarea.on({
-    'textComplete:show'() {
-      dropdownMenuIsOpened = true;
-    },
-    'textComplete:hide'() {
-      Tracker.afterFlush(() => {
-        dropdownMenuIsOpened = false;
-      });
-    },
-  });
 });
 
-EscapeActions.register('textcomplete',
-  () => {},
-  () => dropdownMenuIsOpened
-);
-
 // XXX I believe we should compute a HTML rendered field on the server that
 // would handle markdown, emojies and user mentions. We can simply have two
 // fields, one source, and one compiled version (in HTML) and send only the
@@ -78,7 +54,7 @@ const at = HTML.CharRef({html: '@', str: '@'});
 Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
   const view = this;
   const currentBoard = Boards.findOne(Session.get('currentBoard'));
-  const knowedUsers = _.map(currentBoard.members, (member) => {
+  const knowedUsers = currentBoard.members.map((member) => {
     member.username = Users.findOne(member.userId).username;
     return member;
   });

+ 2 - 2
client/components/main/header.jade

@@ -43,10 +43,10 @@ template(name="header")
         the list of all boards.
       if isSandstorm
         .wekan-logo
-          img(src="/wekan-logo-header.png" alt="Wekan")
+          img(src="{{pathFor '/wekan-logo-header.png'}}" alt="Wekan")
       else
         a.wekan-logo(href="{{pathFor 'home'}}" title="{{_ 'header-logo-title'}}")
-          img(src="/wekan-logo-header.png" alt="Wekan")
+          img(src="{{pathFor '/wekan-logo-header.png'}}" alt="Wekan")
 
 template(name="headerTitle")
   h1 {{_ 'my-boards'}}

+ 5 - 1
client/components/main/layouts.jade

@@ -2,12 +2,16 @@ head
   title Wekan
   meta(name="viewport"
    content="maximum-scale=1.0,width=device-width,initial-scale=1.0,user-scalable=0")
+  //- XXX We should use pathFor in the following `href` to support the case
+    where the application is deployed with a path prefix, but it seems to be
+    difficult to do that cleanly with Blaze -- at least without adding extra
+    packages.
   link(rel="shortcut icon" href="/wekan-favicon.png")
 
 template(name="userFormsLayout")
   section.auth-layout
     h1.at-form-landing-logo
-      img(src="/wekan-logo.png" alt="Wekan")
+      img(src="{{pathFor '/wekan-logo.png'}}" alt="Wekan")
     +Template.dynamic(template=content)
 
 template(name="defaultLayout")

+ 3 - 3
client/components/main/popup.styl

@@ -17,9 +17,11 @@ $popupWidth = 300px
     margin: 4px -10px
     width: $popupWidth
 
+  p,
+  textarea,
   input[type="text"],
   input[type="email"],
-  input[type="password"]
+  input[type="password"],
   input[type="file"]
     margin: 4px 0 12px
     width: 100%
@@ -30,8 +32,6 @@ $popupWidth = 300px
 
   textarea
     height: 72px
-    margin: 4px 0 12px
-    width: 100%
 
   .header
     height: 36px

+ 3 - 3
client/components/sidebar/sidebar.js

@@ -54,7 +54,7 @@ BlazeComponent.extendComponent({
   },
 
   reachNextPeak() {
-    const activitiesComponent = this.componentChildren('activities')[0];
+    const activitiesComponent = this.childrenComponents('activities')[0];
     activitiesComponent.loadNextPage();
   },
 
@@ -95,10 +95,10 @@ BlazeComponent.extendComponent({
   events() {
     // XXX Hacky, we need some kind of `super`
     const mixinEvents = this.getMixin(Mixins.InfiniteScrolling).events();
-    return mixinEvents.concat([{
+    return [...mixinEvents, {
       'click .js-toggle-sidebar': this.toggle,
       'click .js-back-home': this.setView,
-    }]);
+    }];
   },
 }).register('sidebar');
 

+ 1 - 1
client/components/sidebar/sidebarFilters.jade

@@ -13,7 +13,7 @@ template(name="filterSidebar")
             if name
               = name
             else
-              span.quiet {{_ "label-default" color}}
+              span.quiet {{_ "label-default" (_ (concat "color-" color))}}
           if Filter.labelIds.isSelected _id
             i.fa.fa-check
   hr

+ 2 - 2
client/components/users/userAvatar.jade

@@ -1,7 +1,7 @@
 template(name="userAvatar")
   a.member.js-member(title="{{userData.profile.fullname}} ({{userData.username}})")
-    if userData.profile.avatarUrl
-      img.avatar.avatar-image(src=userData.profile.avatarUrl)
+    if userData.getAvatarUrl
+      img.avatar.avatar-image(src=userData.getAvatarUrl)
     else
       +userAvatarInitials(userId=userData._id)
 

+ 3 - 3
client/components/users/userHeader.js

@@ -18,9 +18,9 @@ Template.memberMenuPopup.events({
 Template.editProfilePopup.events({
   submit(evt, tpl) {
     evt.preventDefault();
-    const fullname = $.trim(tpl.find('.js-profile-fullname').value);
-    const username = $.trim(tpl.find('.js-profile-username').value);
-    const initials = $.trim(tpl.find('.js-profile-initials').value);
+    const fullname = tpl.find('.js-profile-fullname').value.trim();
+    const username = tpl.find('.js-profile-username').value.trim();
+    const initials = tpl.find('.js-profile-initials').value.trim();
     Users.update(Meteor.userId(), {$set: {
       'profile.fullname': fullname,
       'profile.initials': initials,

+ 1 - 1
client/config/accounts.js

@@ -25,7 +25,7 @@ AccountsTemplates.configure({
   },
 });
 
-_.each(['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'],
+['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'].forEach(
   (routeName) => AccountsTemplates.configureRoute(routeName));
 
 // We display the form to change the password in a popup window that already

+ 4 - 0
client/config/blazeHelpers.js

@@ -13,3 +13,7 @@ Blaze.registerHelper('currentCard', () => {
 });
 
 Blaze.registerHelper('getUser', (userId) => Users.findOne(userId));
+
+UI.registerHelper('concat', function (...args) {
+  return Array.prototype.slice.call(args, 0, -1).join('');
+});

+ 23 - 0
client/config/router.js

@@ -88,3 +88,26 @@ _.each(redirections, (newPath, oldPath) => {
     }],
   });
 });
+
+// As it is not possible to use template helpers in the page <head> we create a
+// reactive function whose role is to set any page-specific tag in the <head>
+// using the `kadira:dochead` package. Currently we only use it to display the
+// board title if we are in a board page (see #364) but we may want to support
+// some <meta> tags in the future.
+const appTitle = 'Wekan';
+
+// XXX The `Meteor.startup` should not be necessary -- we don't need to wait for
+// the complete DOM to be ready to call `DocHead.setTitle`. But the problem is
+// that the global variable `Boards` is undefined when this file loads so we
+// wait a bit until hopefully all files are loaded. This will be fixed in a
+// clean way once Meteor will support ES6 modules -- hopefully in Meteor 1.3.
+Meteor.startup(() => {
+  Tracker.autorun(() => {
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    const titleStack = [appTitle];
+    if (currentBoard) {
+      titleStack.push(currentBoard.title);
+    }
+    DocHead.setTitle(titleStack.reverse().join(' - '));
+  });
+});

+ 41 - 0
client/lib/accessibility.js

@@ -0,0 +1,41 @@
+// In this file we define a set of DOM transformations that are specifically
+// intended for blind screen readers.
+//
+// See https://github.com/wekan/wekan/issues/337 for the general accessibility
+// considerations.
+
+// Without an href, links are non-keyboard-focusable and are not presented on
+// blind screen readers. We default to the empty anchor `#` href.
+function enforceHref(attributes) {
+  if (!_.has(attributes, 'href')) {
+    attributes.href = '#';
+  }
+  return attributes;
+}
+
+// `title` is inconsistently used on the web, and is thus inconsistently
+// presented by screen readers. `aria-label`, on the other hand, is specific to
+// accessibility and is presented in ways that title shouldn't be.
+function copyTitleInAriaLabel(attributes) {
+  if (!_.has(attributes, 'aria-label') && _.has(attributes, 'title')) {
+    attributes['aria-label'] = attributes.title;
+  }
+  return attributes;
+}
+
+// XXX Our implementation relies on overwriting Blaze virtual DOM functions,
+// which is a little bit hacky -- but still reasonable with our ES6 usage. If we
+// end up switching to React we will probably create lower level small
+// components to handle that without overwriting any build-in function.
+const {
+  A: superA,
+  I: superI,
+} = HTML;
+
+HTML.A = (attributes, ...others) => {
+  return superA(copyTitleInAriaLabel(enforceHref(attributes)), ...others);
+};
+
+HTML.I = (attributes, ...others) => {
+  return superI(copyTitleInAriaLabel(attributes), ...others);
+};

+ 2 - 2
client/lib/filter.js

@@ -95,7 +95,7 @@ Filter = {
       return {};
 
     const filterSelector = {};
-    _.forEach(this._fields, (fieldName) => {
+    this._fields.forEach((fieldName) => {
       const filter = this[fieldName];
       if (filter._isActive())
         filterSelector[fieldName] = filter._getMongoSelector();
@@ -116,7 +116,7 @@ Filter = {
   },
 
   reset() {
-    _.forEach(this._fields, (fieldName) => {
+    this._fields.forEach((fieldName) => {
       const filter = this[fieldName];
       filter.reset();
     });

+ 2 - 2
client/lib/modal.js

@@ -21,9 +21,9 @@ window.Modal = new class {
     }
   }
 
-  open(modalName, options) {
+  open(modalName, { onCloseGoTo = ''} = {}) {
     this._currentModal.set(modalName);
-    this._onCloseGoTo = options && options.onCloseGoTo || '';
+    this._onCloseGoTo = onCloseGoTo;
   }
 };
 

+ 5 - 4
client/lib/multiSelection.js

@@ -119,12 +119,13 @@ MultiSelection = {
     }
   },
 
-  toggle(cardIds, options) {
+  toggle(cardIds, options = {}) {
     cardIds = _.isString(cardIds) ? [cardIds] : cardIds;
-    options = _.extend({
+    options = {
       add: true,
       remove: true,
-    }, options || {});
+      ...options,
+    };
 
     if (!this.isActive()) {
       this.reset();
@@ -133,7 +134,7 @@ MultiSelection = {
 
     const selectedCards = this._selectedCards.get();
 
-    _.each(cardIds, (cardId) => {
+    cardIds.forEach((cardId) => {
       const indexOfCard = selectedCards.indexOf(cardId);
 
       if (options.remove && indexOfCard > -1)

+ 2 - 2
client/lib/popup.js

@@ -91,7 +91,7 @@ window.Popup = new class {
       if (!self.isOpen()) {
         self.current = Blaze.renderWithData(self.template, () => {
           self._dep.depend();
-          return _.extend(self._getTopStack(), { stack: self._stack });
+          return { ...self._getTopStack(), stack: self._stack };
         }, document.body);
 
       } else {
@@ -191,7 +191,7 @@ window.Popup = new class {
 // We close a potential opened popup on any left click on the document, or go
 // one step back by pressing escape.
 const escapeActions = ['back', 'close'];
-_.each(escapeActions, (actionName) => {
+escapeActions.forEach((actionName) => {
   EscapeActions.register(`popup-${actionName}`,
     () => Popup[actionName](),
     () => Popup.isOpen(),

+ 30 - 0
client/lib/textComplete.js

@@ -0,0 +1,30 @@
+// We “inherit” the jquery-textcomplete plugin to integrate with our
+// EscapeActions system. You should always use `escapeableTextComplete` instead
+// of the vanilla `textcomplete`.
+let dropdownMenuIsOpened = false;
+
+$.fn.escapeableTextComplete = function(...args) {
+  this.textcomplete(...args);
+
+  // Since commit d474017 jquery-textComplete automatically closes a potential
+  // opened dropdown menu when the user press Escape. This behavior conflicts
+  // with our EscapeActions system, but it's too complicated and hacky to
+  // monkey-pach textComplete to disable it -- I tried. Instead we listen to
+  // 'open' and 'hide' events, and create a ghost escapeAction when the dropdown
+  // is opened (and rely on textComplete to execute the actual action).
+  this.on({
+    'textComplete:show'() {
+      dropdownMenuIsOpened = true;
+    },
+    'textComplete:hide'() {
+      Tracker.afterFlush(() => {
+        dropdownMenuIsOpened = false;
+      });
+    },
+  });
+};
+
+EscapeActions.register('textcomplete',
+  () => {},
+  () => dropdownMenuIsOpened
+);

+ 26 - 1
i18n/en.i18n.json

@@ -7,12 +7,14 @@
     "activity-attached": "attached %s to %s",
     "activity-created": "created %s",
     "activity-excluded": "excluded %s from %s",
+    "activity-imported": "imported %s into %s from %s",
+    "activity-imported-board": "imported %s from %s",
     "activity-joined": "joined %s",
     "activity-moved": "moved %s from %s to %s",
     "activity-on": "on %s",
     "activity-removed": "removed %s from %s",
     "activity-sent": "sent %s to %s",
-    "activity-unjoined": "unjoinded %s",
+    "activity-unjoined": "unjoined %s",
     "add": "Add",
     "add-attachment": "Add an attachment",
     "add-board": "Add a new board",
@@ -53,6 +55,7 @@
     "boardChangeColorPopup-title": "Change Board Background",
     "boardChangeTitlePopup-title": "Rename Board",
     "boardChangeVisibilityPopup-title": "Change Visibility",
+    "boardImportBoardPopup-title": "Import board from Trello",
     "boardMenuPopup-title": "Board Menu",
     "boards": "Boards",
     "bucket-example": "Like “Bucket List” for example",
@@ -87,6 +90,16 @@
     "close": "Close",
     "close-board": "Close Board",
     "close-board-pop": "You can re-open the board by clicking the “Boards” menu from the header, selecting “View Closed Boards”, finding the board and clicking “Re-open”.",
+    "color-green": "green",
+    "color-yellow": "yellow",
+    "color-orange": "orange",
+    "color-red": "red",
+    "color-purple": "purple",
+    "color-blue": "blue",
+    "color-sky": "sky",
+    "color-lime": "lime",
+    "color-pink": "pink",
+    "color-black": "black",
     "comment": "Comment",
     "comment-placeholder": "Write a comment",
     "computer": "Computer",
@@ -109,6 +122,10 @@
     "editLabelPopup-title": "Change Label",
     "editProfilePopup-title": "Edit Profile",
     "email": "Email",
+    "error-board-notAMember": "You need to be a member of this board to do that",
+    "error-json-malformed": "Your text is not valid JSON",
+    "error-json-schema": "Your JSON data does not include the proper information in the correct format",
+    "error-list-doesNotExist": "This list does not exist",
     "filter": "Filter",
     "filter-cards": "Filter Cards",
     "filter-clear": "Clear filter",
@@ -118,6 +135,12 @@
     "fullname": "Full Name",
     "header-logo-title": "Go back to your boards page.",
     "home": "Home",
+    "import": "Import",
+    "import-board": "import 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-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",
     "info": "Infos",
     "initials": "Initials",
     "joined": "joined",
@@ -136,6 +159,7 @@
     "list-select-cards": "Select all cards in this list",
     "listActionPopup-title": "List Actions",
     "listArchiveCardsPopup-title": "Archive All Cards in this List?",
+    "listImportCardPopup-title": "Import a Trello card",
     "listMoveCardsPopup-title": "Move All Cards in List",
     "lists": "Lists",
     "log-out": "Log Out",
@@ -155,6 +179,7 @@
     "normal": "Normal",
     "normal-desc": "Can view and edit cards. Can't change settings.",
     "optional": "optional",
+    "or": "or",
     "page-maybe-private": "This page may be private. You may be able to view it by <a href='%s'>logging in</a>.",
     "page-not-found": "Page not found.",
     "password": "Password",

+ 1 - 1
models/attachments.js

@@ -1,4 +1,4 @@
-Attachments = new FS.Collection('attachments', {
+Attachments = new FS.Collection('attachments', { // eslint-disable-line meteor/collections
   stores: [
 
     // XXX Add a new store for cover thumbnails so we don't load big images in

+ 33 - 13
models/boards.js

@@ -92,12 +92,16 @@ Boards.helpers({
     return _.where(this.members, {isActive: true});
   },
 
+  getLabel(name, color) {
+    return _.findWhere(this.labels, { name, color });
+  },
+
   labelIndex(labelId) {
-    return _.indexOf(_.pluck(this.labels, '_id'), labelId);
+    return _.pluck(this.labels, '_id').indexOf(labelId);
   },
 
   memberIndex(memberId) {
-    return _.indexOf(_.pluck(this.members, 'userId'), memberId);
+    return _.pluck(this.members, 'userId').indexOf(memberId);
   },
 
   absoluteUrl() {
@@ -107,6 +111,14 @@ Boards.helpers({
   colorClass() {
     return `board-color-${this.color}`;
   },
+
+  // XXX currently mutations return no value so we have an issue when using addLabel in import
+  // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove...
+  pushLabel(name, color) {
+    const _id = Random.id(6);
+    Boards.direct.update(this._id, { $push: {labels: { _id, name, color }}});
+    return _id;
+  },
 });
 
 Boards.mutations({
@@ -131,18 +143,26 @@ Boards.mutations({
   },
 
   addLabel(name, color) {
-    const _id = Random.id(6);
-    return { $push: {labels: { _id, name, color }}};
+    // If label with the same name and color already exists we don't want to
+    // create another one because they would be indistinguishable in the UI
+    // (they would still have different `_id` but that is not exposed to the
+    // user).
+    if (!this.getLabel(name, color)) {
+      const _id = Random.id(6);
+      return { $push: {labels: { _id, name, color }}};
+    }
   },
 
   editLabel(labelId, name, color) {
-    const labelIndex = this.labelIndex(labelId);
-    return {
-      $set: {
-        [`labels.${labelIndex}.name`]: name,
-        [`labels.${labelIndex}.color`]: color,
-      },
-    };
+    if (!this.getLabel(name, color)) {
+      const labelIndex = this.labelIndex(labelId);
+      return {
+        $set: {
+          [`labels.${labelIndex}.name`]: name,
+          [`labels.${labelIndex}.color`]: color,
+        },
+      };
+    }
   },
 
   removeLabel(labelId) {
@@ -259,7 +279,7 @@ Boards.before.insert((userId, doc) => {
   // Handle labels
   const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues;
   const defaultLabelsColors = _.clone(colors).splice(0, 6);
-  doc.labels = _.map(defaultLabelsColors, (color) => {
+  doc.labels = defaultLabelsColors.map((color) => {
     return {
       color,
       _id: Random.id(6),
@@ -307,7 +327,7 @@ if (Meteor.isServer) {
       { boardId: doc._id },
       {
         $pull: {
-          labels: removedLabelId,
+          labelIds: removedLabelId,
         },
       },
       { multi: true }

+ 3 - 2
models/cards.js

@@ -194,8 +194,9 @@ Cards.mutations({
 Cards.before.insert((userId, doc) => {
   doc.createdAt = new Date();
   doc.dateLastActivity = new Date();
-  doc.archived = false;
-
+  if(!doc.hasOwnProperty('archived')){
+    doc.archived = false;
+  }
   if (!doc.userId) {
     doc.userId = userId;
   }

+ 364 - 0
models/import.js

@@ -0,0 +1,364 @@
+const DateString = Match.Where(function (dateAsString) {
+  check(dateAsString, String);
+  return moment(dateAsString, moment.ISO_8601).isValid();
+});
+
+class TrelloCreator {
+  constructor() {
+    // The object creation dates, indexed by Trello id (so we only parse actions
+    // once!)
+    this.createdAt = {
+      board: null,
+      cards: {},
+      lists: {},
+    };
+    // Map of labels Trello ID => Wekan ID
+    this.labels = {};
+    // Map of lists Trello ID => Wekan ID
+    this.lists = {};
+    // The comments, indexed by Trello card id (to map when importing cards)
+    this.comments = {};
+  }
+
+  checkActions(trelloActions) {
+    check(trelloActions, [Match.ObjectIncluding({
+      data: Object,
+      date: DateString,
+      type: String,
+    })]);
+    // XXX we could perform more thorough checks based on action type
+  }
+
+  checkBoard(trelloBoard) {
+    check(trelloBoard, Match.ObjectIncluding({
+      closed: Boolean,
+      name: String,
+      prefs: Match.ObjectIncluding({
+        // XXX refine control by validating 'background' against a list of
+        // allowed values (is it worth the maintenance?)
+        background: String,
+        permissionLevel: Match.Where((value) => {
+          return ['org', 'private', 'public'].indexOf(value)>= 0;
+        }),
+      }),
+    }));
+  }
+
+  checkCards(trelloCards) {
+    check(trelloCards, [Match.ObjectIncluding({
+      closed: Boolean,
+      dateLastActivity: DateString,
+      desc: String,
+      idLabels: [String],
+      idMembers: [String],
+      name: String,
+      pos: Number,
+    })]);
+  }
+
+  checkLabels(trelloLabels) {
+    check(trelloLabels, [Match.ObjectIncluding({
+      // XXX refine control by validating 'color' against a list of allowed
+      // values (is it worth the maintenance?)
+      color: String,
+      name: String,
+    })]);
+  }
+
+  checkLists(trelloLists) {
+    check(trelloLists, [Match.ObjectIncluding({
+      closed: Boolean,
+      name: String,
+    })]);
+  }
+
+  // You must call parseActions before calling this one.
+  createBoardAndLabels(trelloBoard) {
+    const createdAt = this.createdAt.board;
+    const boardToCreate = {
+      archived: trelloBoard.closed,
+      color: this.getColor(trelloBoard.prefs.background),
+      createdAt,
+      labels: [],
+      members: [{
+        userId: Meteor.userId(),
+        isAdmin: true,
+        isActive: true,
+      }],
+      permission: this.getPermission(trelloBoard.prefs.permissionLevel),
+      slug: getSlug(trelloBoard.name) || 'board',
+      stars: 0,
+      title: trelloBoard.name,
+    };
+    trelloBoard.labels.forEach((label) => {
+      const labelToCreate = {
+        _id: Random.id(6),
+        color: label.color,
+        name: label.name,
+      };
+      // We need to remember them by Trello ID, as this is the only ref we have
+      // when importing cards.
+      this.labels[label.id] = labelToCreate._id;
+      boardToCreate.labels.push(labelToCreate);
+    });
+    const now = new Date();
+    const boardId = Boards.direct.insert(boardToCreate);
+    Boards.direct.update(boardId, {$set: {modifiedAt: now}});
+    // log activity
+    Activities.direct.insert({
+      activityType: 'importBoard',
+      boardId,
+      createdAt: now,
+      source: {
+        id: trelloBoard.id,
+        system: 'Trello',
+        url: trelloBoard.url,
+      },
+      // We attribute the import to current user, not the one from the original
+      // object.
+      userId: Meteor.userId(),
+    });
+    return boardId;
+  }
+
+  // Create labels if they do not exist and load this.labels.
+  createLabels(trelloLabels, board) {
+    trelloLabels.forEach((label) => {
+      const color = label.color;
+      const name = label.name;
+      const existingLabel = board.getLabel(name, color);
+      if (existingLabel) {
+        this.labels[label.id] = existingLabel._id;
+      } else {
+        const idLabelCreated = board.pushLabel(name, color);
+        this.labels[label.id] = idLabelCreated;
+      }
+    });
+  }
+
+  createLists(trelloLists, boardId) {
+    trelloLists.forEach((list) => {
+      const listToCreate = {
+        archived: list.closed,
+        boardId,
+        // We are being defensing here by providing a default date (now) if the
+        // creation date wasn't found on the action log. This happen on old
+        // Trello boards (eg from 2013) that didn't log the 'createList' action
+        // we require.
+        createdAt: new Date(this.createdAt.lists[list.id] || Date.now()),
+        title: list.name,
+        userId: Meteor.userId(),
+      };
+      const listId = Lists.direct.insert(listToCreate);
+      const now = new Date();
+      Lists.direct.update(listId, {$set: {'updatedAt': now}});
+      this.lists[list.id] = listId;
+      // log activity
+      Activities.direct.insert({
+        activityType: 'importList',
+        boardId,
+        createdAt: now,
+        listId,
+        source: {
+          id: list.id,
+          system: 'Trello',
+        },
+        // We attribute the import to current user, not the one from the
+        // original object
+        userId: Meteor.userId(),
+      });
+    });
+  }
+
+  createCardsAndComments(trelloCards, boardId) {
+    const result = [];
+    trelloCards.forEach((card) => {
+      const cardToCreate = {
+        archived: card.closed,
+        boardId,
+        createdAt: new Date(this.createdAt.cards[card.id]  || Date.now()),
+        dateLastActivity: new Date(),
+        description: card.desc,
+        listId: this.lists[card.idList],
+        sort: card.pos,
+        title: card.name,
+        // XXX use the original user?
+        userId: Meteor.userId(),
+      };
+      // add labels
+      if (card.idLabels) {
+        cardToCreate.labelIds = card.idLabels.map((trelloId) => {
+          return this.labels[trelloId];
+        });
+      }
+      // insert card
+      const cardId = Cards.direct.insert(cardToCreate);
+      // log activity
+      Activities.direct.insert({
+        activityType: 'importCard',
+        boardId,
+        cardId,
+        createdAt: new Date(),
+        listId: cardToCreate.listId,
+        source: {
+          id: card.id,
+          system: 'Trello',
+          url: card.url,
+        },
+        // we attribute the import to current user, not the one from the
+        // original card
+        userId: Meteor.userId(),
+      });
+      // add comments
+      const comments = this.comments[card.id];
+      if (comments) {
+        comments.forEach((comment) => {
+          const commentToCreate = {
+            boardId,
+            cardId,
+            createdAt: comment.date,
+            text: comment.data.text,
+            // XXX use the original comment user instead
+            userId: Meteor.userId(),
+          };
+          // dateLastActivity will be set from activity insert, no need to
+          // update it ourselves
+          const commentId = CardComments.direct.insert(commentToCreate);
+          Activities.direct.insert({
+            activityType: 'addComment',
+            boardId: commentToCreate.boardId,
+            cardId: commentToCreate.cardId,
+            commentId,
+            createdAt: commentToCreate.createdAt,
+            userId: commentToCreate.userId,
+          });
+        });
+      }
+      // XXX add attachments
+      result.push(cardId);
+    });
+    return result;
+  }
+
+  getColor(trelloColorCode) {
+    // trello color name => wekan color
+    const mapColors = {
+      'blue': 'belize',
+      'orange': 'pumpkin',
+      'green': 'nephritis',
+      'red': 'pomegranate',
+      'purple': 'wisteria',
+      'pink': 'pomegranate',
+      'lime': 'nephritis',
+      'sky': 'belize',
+      'grey': 'midnight',
+    };
+    const wekanColor = mapColors[trelloColorCode];
+    return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0];
+  }
+
+  getPermission(trelloPermissionCode) {
+    if (trelloPermissionCode === 'public') {
+      return 'public';
+    }
+    // Wekan does NOT have organization level, so we default both 'private' and
+    // 'org' to private.
+    return 'private';
+  }
+
+  parseActions(trelloActions) {
+    trelloActions.forEach((action) => {
+      switch (action.type) {
+      case 'createBoard':
+        this.createdAt.board = action.date;
+        break;
+      case 'createCard':
+        const cardId = action.data.card.id;
+        this.createdAt.cards[cardId] = action.date;
+        break;
+      case 'createList':
+        const listId = action.data.list.id;
+        this.createdAt.lists[listId] = action.date;
+        break;
+      case 'commentCard':
+        const id = action.data.card.id;
+        if (this.comments[id]) {
+          this.comments[id].push(action);
+        } else {
+          this.comments[id] = [action];
+        }
+        break;
+      default:
+        // do nothing
+        break;
+      }
+    });
+  }
+}
+
+Meteor.methods({
+  importTrelloBoard(trelloBoard, data) {
+    const trelloCreator = new TrelloCreator();
+
+    // 1. check all parameters are ok from a syntax point of view
+    try {
+      // we don't use additional data - this should be an empty object
+      check(data, {});
+      trelloCreator.checkActions(trelloBoard.actions);
+      trelloCreator.checkBoard(trelloBoard);
+      trelloCreator.checkLabels(trelloBoard.labels);
+      trelloCreator.checkLists(trelloBoard.lists);
+      trelloCreator.checkCards(trelloBoard.cards);
+    } catch (e) {
+      throw new Meteor.Error('error-json-schema');
+    }
+
+    // 2. check parameters are ok from a business point of view (exist &
+    // authorized) nothing to check, everyone can import boards in their account
+
+    // 3. create all elements
+    trelloCreator.parseActions(trelloBoard.actions);
+    const boardId = trelloCreator.createBoardAndLabels(trelloBoard);
+    trelloCreator.createLists(trelloBoard.lists, boardId);
+    trelloCreator.createCardsAndComments(trelloBoard.cards, boardId);
+    // XXX add members
+    return boardId;
+  },
+
+  importTrelloCard(trelloCard, data) {
+    const trelloCreator = new TrelloCreator();
+
+    // 1. check parameters are ok from a syntax point of view
+    try {
+      check(data, {
+        listId: String,
+        sortIndex: Number,
+      });
+      trelloCreator.checkCards([trelloCard]);
+      trelloCreator.checkLabels(trelloCard.labels);
+      trelloCreator.checkActions(trelloCard.actions);
+    } catch(e) {
+      throw new Meteor.Error('error-json-schema');
+    }
+
+    // 2. check parameters are ok from a business point of view (exist &
+    // authorized)
+    const list = Lists.findOne(data.listId);
+    if (!list) {
+      throw new Meteor.Error('error-list-doesNotExist');
+    }
+    if (Meteor.isServer) {
+      if (!allowIsBoardMember(Meteor.userId(), Boards.findOne(list.boardId))) {
+        throw new Meteor.Error('error-board-notAMember');
+      }
+    }
+
+    // 3. create all elements
+    trelloCreator.lists[trelloCard.idList] = data.listId;
+    trelloCreator.parseActions(trelloCard.actions);
+    const board = list.board();
+    trelloCreator.createLabels(trelloCard.labels, board);
+    const cardIds = trelloCreator.createCardsAndComments([trelloCard], board._id);
+    return cardIds[0];
+  },
+});

+ 38 - 19
models/users.js

@@ -1,4 +1,4 @@
-Users = Meteor.users;
+Users = Meteor.users; // eslint-disable-line meteor/collections
 
 // Search a user in the complete server database by its name or username. This
 // is used for instance to add a new user to a board.
@@ -8,31 +8,50 @@ Users.initEasySearch(searchInFields, {
   returnFields: [...searchInFields, 'profile.avatarUrl'],
 });
 
+if (Meteor.isClient) {
+  Users.helpers({
+    isBoardMember() {
+      const board = Boards.findOne(Session.get('currentBoard'));
+      return board &&
+        _.contains(_.pluck(board.members, 'userId'), this._id) &&
+        _.where(board.members, {userId: this._id})[0].isActive;
+    },
+
+    isBoardAdmin() {
+      const board = Boards.findOne(Session.get('currentBoard'));
+      return board &&
+        this.isBoardMember(board) &&
+        _.where(board.members, {userId: this._id})[0].isAdmin;
+    },
+  });
+}
+
 Users.helpers({
   boards() {
     return Boards.find({ userId: this._id });
   },
 
   starredBoards() {
-    const starredBoardIds = this.profile.starredBoards || [];
-    return Boards.find({archived: false, _id: {$in: starredBoardIds}});
+    const {starredBoards = []} = this.profile;
+    return Boards.find({archived: false, _id: {$in: starredBoards}});
   },
 
   hasStarred(boardId) {
-    const starredBoardIds = this.profile.starredBoards || [];
-    return _.contains(starredBoardIds, boardId);
+    const {starredBoards = []} = this.profile;
+    return _.contains(starredBoards, boardId);
   },
 
-  isBoardMember() {
-    const board = Boards.findOne(Session.get('currentBoard'));
-    return board && _.contains(_.pluck(board.members, 'userId'), this._id) &&
-                         _.where(board.members, {userId: this._id})[0].isActive;
-  },
-
-  isBoardAdmin() {
-    const board = Boards.findOne(Session.get('currentBoard'));
-    return board && this.isBoardMember(board) &&
-                          _.where(board.members, {userId: this._id})[0].isAdmin;
+  getAvatarUrl() {
+    // Although we put the avatar picture URL in the `profile` object, we need
+    // to support Sandstorm which put in the `picture` attribute by default.
+    // XXX Should we move both cases to `picture`?
+    if (this.picture) {
+      return this.picture;
+    } else if (this.profile && this.profile.avatarUrl) {
+      return this.profile.avatarUrl;
+    } else {
+      return null;
+    }
   },
 
   getInitials() {
@@ -41,9 +60,9 @@ Users.helpers({
       return profile.initials;
 
     else if (profile.fullname) {
-      return _.reduce(profile.fullname.split(/\s+/), (memo, word) => {
+      return profile.fullname.split(/\s+/).reduce((memo = '', word) => {
         return memo + word[0];
-      }, '').toUpperCase();
+      }).toUpperCase();
 
     } else {
       return this.username[0].toUpperCase();
@@ -117,7 +136,7 @@ if (Meteor.isServer) {
     // b. We use it to find deleted and newly inserted ids by using it in one
     // direction and then in the other.
     function incrementBoards(boardsIds, inc) {
-      _.forEach(boardsIds, (boardId) => {
+      boardsIds.forEach((boardId) => {
         Boards.update(boardId, {$inc: {stars: inc}});
       });
     }
@@ -136,7 +155,7 @@ if (Meteor.isServer) {
     // Insert the Welcome Board
     Boards.insert(ExampleBoard, (err, boardId) => {
 
-      _.forEach(['Basics', 'Advanced'], (title) => {
+      ['Basics', 'Advanced'].forEach((title) => {
         const list = {
           title,
           boardId,

+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "wekan",
+  "version": "1.0.0",
+  "description": "The open-source Trello-like kanban",
+  "private": true,
+  "scripts": {
+    "lint": "eslint .",
+    "test": "npm run --silent lint"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/wekan/wekan.git"
+  },
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/wekan/wekan/issues"
+  },
+  "homepage": "http://wekan.io",
+  "devDependencies": {
+    "babel-eslint": "4.1.3",
+    "eslint": "1.7.3",
+    "eslint-plugin-meteor": "1.7.0"
+  }
+}

+ 19 - 20
sandstorm.js

@@ -21,20 +21,13 @@ if (isSandstorm && Meteor.isServer) {
     permission: 'public',
   };
 
-  // This function should probably be handled by `accounts-sandstorm` but
-  // apparently meteor-core misses an API to handle that cleanly, cf.
-  // https://github.com/meteor/meteor/blob/ff783e9a12ffa04af6fd163843a563c9f4bbe8c1/packages/accounts-base/accounts_server.js#L1143
-  function updateUserAvatar(userId, avatarUrl) {
-    Users.findOne(userId).setAvatarUrl(avatarUrl);
-  }
-
   function updateUserPermissions(userId, permissions) {
-    const isActive = permissions.indexOf('participate') > -1;
-    const isAdmin = permissions.indexOf('configure') > -1;
+    const isActive = permissions.includes('participate');
+    const isAdmin = permissions.includes('configure');
     const permissionDoc = { userId, isActive, isAdmin };
 
     const boardMembers = Boards.findOne(sandstormBoard._id).members;
-    const memberIndex = _.indexOf(_.pluck(boardMembers, 'userId'), userId);
+    const memberIndex = _.pluck(boardMembers, 'userId').indexOf(userId);
 
     let modifier;
     if (memberIndex > -1)
@@ -55,7 +48,8 @@ if (isSandstorm && Meteor.isServer) {
     // and the home page was accessible by pressing the back button of the
     // browser, a server-side redirection solves both of these issues.
     //
-    // XXX Maybe sandstorm manifest could provide some kind of "home URL"?
+    // XXX Maybe the sandstorm http-bridge could provide some kind of "home URL"
+    // in the manifest?
     const base = req.headers['x-sandstorm-base-path'];
     // XXX If this routing scheme changes, this will break. We should generate
     // the location URL using the router, but at the time of writing, the
@@ -68,20 +62,14 @@ if (isSandstorm && Meteor.isServer) {
     res.end();
 
     // `accounts-sandstorm` populate the Users collection when new users
-    // accesses the document, but in case a already known user come back, we
+    // accesses the document, but in case a already known user comes back, we
     // need to update his associated document to match the request HTTP headers
     // informations.
     const user = Users.findOne({
       'services.sandstorm.id': req.headers['x-sandstorm-user-id'],
     });
     if (user) {
-      const userId = user._id;
-      const avatarUrl = req.headers['x-sandstorm-user-picture'];
-      const permissions = req.headers['x-sandstorm-permissions'].split(',') || [];
-
-      // XXX The user may also change his name, we should handle it.
-      updateUserAvatar(userId, avatarUrl);
-      updateUserPermissions(userId, permissions);
+      updateUserPermissions(user._id, user.permissions);
     }
   });
 
@@ -90,6 +78,8 @@ if (isSandstorm && Meteor.isServer) {
   // unique board document. Note that when the `Users.after.insert` hook is
   // called, the user is inserted into the database but not connected. So
   // despite the appearances `userId` is null in this block.
+  //
+  // XXX We should support the `preferredHandle` exposed by Sandstorm
   Users.after.insert((userId, doc) => {
     if (!Boards.findOne(sandstormBoard._id)) {
       Boards.insert(sandstormBoard, {validate: false});
@@ -101,6 +91,15 @@ if (isSandstorm && Meteor.isServer) {
 
     updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
   });
+
+  // LibreBoard v0.8 didn’t implement the Sandstorm sharing model and instead
+  // kept the visibility setting (“public” or “private”) in the UI as does the
+  // main Meteor application. We need to enforce “public” visibility as the
+  // sharing is now handled by Sandstorm.
+  // See https://github.com/wekan/wekan/issues/346
+  Migrations.add('enforce-public-visibility-for-sandstorm', () => {
+    Boards.update('sandstorm', { $set: { permission: 'public' }});
+  });
 }
 
 if (isSandstorm && Meteor.isClient) {
@@ -110,7 +109,7 @@ if (isSandstorm && Meteor.isClient) {
   // sandstorm client to return relative paths instead of absolutes.
   const _absoluteUrl = Meteor.absoluteUrl;
   const _defaultOptions = Meteor.absoluteUrl.defaultOptions;
-  Meteor.absoluteUrl = (path, options) => {
+  Meteor.absoluteUrl = (path, options) => { // eslint-disable-line meteor/core
     const url = _absoluteUrl(path, options);
     return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, '');
   };

+ 9 - 3
server/migrations.js

@@ -4,6 +4,12 @@
 //
 //   Migrations.add(name, migrationCallback, optionalOrder);
 
+// Note that we have extra migrations defined in `sandstorm.js` that are
+// exclusive to Sandstorm and shouldn’t be executed in the general case.
+// XXX I guess if we had ES6 modules we could
+// `import { isSandstorm } from sandstorm.js` and define the migration here as
+// well, but for now I want to avoid definied too many globals.
+
 // In the context of migration functions we don't want to validate database
 // mutation queries against the current (ie, latest) collection schema. Doing
 // that would work at the time we write the migration but would break in the
@@ -37,7 +43,7 @@ Migrations.add('board-background-color', () => {
 });
 
 Migrations.add('lowercase-board-permission', () => {
-  _.forEach(['Public', 'Private'], (permission) => {
+  ['Public', 'Private'].forEach((permission) => {
     Boards.update(
       { permission },
       { $set: { permission: permission.toLowerCase() } },
@@ -110,11 +116,11 @@ Migrations.add('add-member-isactive-field', () => {
     const formerUsers = _.difference(allUsersWithSomeActivity, currentUsers);
 
     const newMemberSet = [];
-    _.forEach(board.members, (member) => {
+    board.members.forEach((member) => {
       member.isActive = true;
       newMemberSet.push(member);
     });
-    _.forEach(formerUsers, (userId) => {
+    formerUsers.forEach((userId) => {
       newMemberSet.push({
         userId,
         isAdmin: false,

+ 1 - 1
server/publications/boards.js

@@ -10,7 +10,7 @@ Meteor.publish('boards', function() {
 
   // Defensive programming to verify that starredBoards has the expected
   // format -- since the field is in the `profile` a user can modify it.
-  const starredBoards = Users.findOne(this.userId).profile.starredBoards || [];
+  const {starredBoards = []} = Users.findOne(this.userId).profile;
   check(starredBoards, [String]);
 
   return Boards.find({

+ 7 - 0
server/publications/fast-render.js

@@ -0,0 +1,7 @@
+FastRender.onAllRoutes(function() {
+  this.subscribe('boards');
+});
+
+FastRender.route('/b/:id/:slug', function({ id }) {
+  this.subscribe('board', id);
+});