diff options
99 files changed, 4498 insertions, 1139 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4ba559c2 --- /dev/null +++ b/.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 @@ -1,138 +1,113 @@ ecmaFeatures: experimentalObjectRestSpread: true + +plugins: + - meteor + +parser: babel-eslint + rules: - indent: - - 2 - - 2 - semi: - - 2 - - always - comma-dangle: - - 2 - - always-multiline - no-inner-declarations: - - 0 - dot-notation: - - 2 - eqeqeq: - - 2 - no-eval: - - 2 - radix: - - 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] + no-cond-assign: 2 + no-constant-condition: 2 + no-eval: 2 + no-inner-declarations: [0] + 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 - linebreak-style: - - 2 - - unix - quotes: - - 2 - - single - semi-spacing: - - 2 - spaced-comment: - - 2 - - always - - markers: - - '/' - space-unary-ops: - - 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 + space-unary-ops: 2 + spaced-comment: [2, always, markers: ['/']] # 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 -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 + 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 + + # 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 presences: true Ps: true ReactiveTabs: false + Restivus: false SimpleSchema: false SubsManager: false 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 @@ -151,8 +126,10 @@ globals: allowIsBoardAdmin: true allowIsBoardMember: true Emoji: true + env: es6: true node: true browser: true + extends: 'eslint:recommended' @@ -4,3 +4,4 @@ .tx/ *.sublime-workspace tmp/ +node_modules/ diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders index 8a761038..61ee3132 100644 --- a/.meteor/.finished-upgraders +++ b/.meteor/.finished-upgraders @@ -6,3 +6,7 @@ notices-for-0.9.0 notices-for-0.9.1 0.9.4-platform-file notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes diff --git a/.meteor/cordova-plugins b/.meteor/cordova-plugins index 8b137891..e69de29b 100644 --- a/.meteor/cordova-plugins +++ b/.meteor/cordova-plugins @@ -1 +0,0 @@ - diff --git a/.meteor/packages b/.meteor/packages index 0aa0fa68..98c06cc9 100644 --- a/.meteor/packages +++ b/.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 @@ -18,7 +15,6 @@ mquandalle:stylus es5-shim # Collections -mongo aldeed:collection2 cfs:gridfs cfs:standard-packages @@ -26,6 +22,8 @@ dburles:collection-helpers idmontie:migrations matb33:collection-hooks matteodem:easy-search +mongo +mquandalle:collection-mutations reywood:publish-composite # Account system @@ -35,6 +33,7 @@ service-configuration useraccounts:core useraccounts:unstyled useraccounts:flow-routing +email # Utilities check @@ -49,7 +48,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 diff --git a/.meteor/release b/.meteor/release index e1990ae6..3a05e0a2 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.2-rc.12 +METEOR@1.2.1 diff --git a/.meteor/versions b/.meteor/versions index 6c410d0b..9d7fe1b3 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,22 +1,23 @@ -3stack:presence@1.0.3 -accounts-base@1.2.1-rc.2 -accounts-password@1.1.2-rc.1 +3stack:presence@1.0.5 +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-rc.0 -autoupdate@1.2.3-rc.1 -babel-compiler@5.8.22-rc.1 -babel-runtime@0.1.4-rc.0 -base64@1.0.4-rc.0 -binary-heap@1.0.4-rc.0 -blaze@2.1.3-rc.0 -blaze-tools@1.0.4-rc.0 -boilerplate-generator@1.0.4-rc.1 -caching-compiler@1.0.0-rc.0 -caching-html-compiler@1.0.1-rc.0 -callback-hook@1.0.4-rc.0 +arillo:flow-router-helpers@0.4.7 +audit-argument-checks@1.0.4 +autoupdate@1.2.4 +babel-compiler@5.8.24_1 +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 +caching-html-compiler@1.0.2 +callback-hook@1.0.4 cfs:access-point@0.1.49 cfs:base-package@0.0.30 cfs:collection@0.5.5 @@ -24,117 +25,123 @@ cfs:collection-filters@0.2.4 cfs:data-man@0.0.6 cfs:file@0.1.17 cfs:gridfs@0.0.33 -cfs:http-methods@0.0.29 +cfs:http-methods@0.0.30 cfs:http-publish@0.0.13 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-rc.0 -coffeescript@1.0.8-rc.3 -cosmos:browserify@0.5.0 -dburles:collection-helpers@1.0.3 -ddp@1.2.1-rc.0 -ddp-client@1.2.1-rc.1 -ddp-common@1.2.1-rc.0 -ddp-rate-limiter@1.0.0-rc.0 -ddp-server@1.2.1-rc.1 -deps@1.0.8-rc.0 -diff-sequence@1.0.1-rc.0 -ecmascript@0.1.3-rc.2 -ecmascript-collections@0.1.5-rc.1 -ejson@1.0.7-rc.0 -email@1.0.7-rc.0 -es5-shim@0.1.0-rc.0 -fastclick@1.0.7-rc.0 -fortawesome:fontawesome@4.4.0 -geojson-utils@1.0.4-rc.0 -hot-code-push@1.0.0-rc.0 -html-tools@1.0.5-rc.0 -htmljs@1.0.5-rc.1 -http@1.1.1-rc.1 -id-map@1.0.4-rc.0 -idmontie:migrations@1.0.0 -jquery@1.11.4-rc.0 -kadira:blaze-layout@2.1.0 -kadira:flow-router@2.5.0 -kenton:accounts-sandstorm@0.1.4 -launch-screen@1.0.3-rc.1 -less@2.5.0-rc.3_1 -livedata@1.0.14-rc.0 -localstorage@1.0.4-rc.0 -logging@1.0.8-rc.1 -matb33:collection-hooks@0.8.0 -matteodem:easy-search@1.6.3 -meteor@1.1.7-rc.1 -meteor-base@1.0.1-rc.0 -meteor-platform@1.2.3-rc.0 +check@1.1.0 +chuangbo:cookie@1.1.0 +coffeescript@1.0.11 +cosmos:browserify@0.9.2 +dburles:collection-helpers@1.0.4 +ddp@1.2.2 +ddp-client@1.2.1 +ddp-common@1.2.2 +ddp-rate-limiter@1.0.0 +ddp-server@1.2.2 +deps@1.0.9 +diff-sequence@1.0.1 +ecmascript@0.1.6 +ecmascript-runtime@0.2.6 +ejson@1.0.7 +email@1.0.8 +es5-shim@4.1.14 +fastclick@1.0.7 +fortawesome:fontawesome@4.5.0 +geojson-utils@1.0.4 +hot-code-push@1.0.0 +html-tools@1.0.5 +htmljs@1.0.5 +http@1.1.1 +id-map@1.0.4 +idmontie:migrations@1.0.1 +jquery@1.11.4 +kadira:blaze-layout@2.3.0 +kadira:dochead@1.4.0 +kadira:flow-router@2.10.0 +kenton:accounts-sandstorm@0.1.8 +launch-screen@1.0.4 +livedata@1.0.15 +localstorage@1.0.5 +logging@1.0.8 +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.11.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.6-rc.1 -minimongo@1.0.9-rc.0 -mobile-status-bar@1.0.5-rc.1 -mongo@1.1.1-rc.3 -mongo-id@1.0.1-rc.0 -mongo-livedata@1.0.9-rc.0 +minifiers@1.1.7 +minimongo@1.0.10 +mobile-status-bar@1.0.6 +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:jade@0.4.3_1 -mquandalle:jade-compiler@0.4.3 -mquandalle:jquery-textcomplete@0.3.9_1 +mquandalle:collection-mutations@0.1.0 +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:moment@1.0.1 mquandalle:mousetrap-bindglobal@0.0.1 mquandalle:perfect-scrollbar@0.6.5_2 mquandalle:stylus@1.1.1 npm-bcrypt@0.7.8_2 -npm-mongo@1.4.39-rc.0_1 -observe-sequence@1.0.7-rc.0 +npm-mongo@1.4.39_1 +observe-sequence@1.0.7 ongoworks:speakingurl@1.1.0 -ordered-dict@1.0.4-rc.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.1 +peerlibrary:reactive-field@0.1.0 perak:markdown@1.0.5 -promise@0.4.8-rc.0 +promise@0.5.1 raix:eventemitter@0.1.3 -raix:handlebar-helpers@0.2.4 -random@1.0.4-rc.0 -rate-limit@1.0.0-rc.0 -reactive-dict@1.1.1-rc.0 -reactive-var@1.0.6-rc.0 -reload@1.1.4-rc.0 -retry@1.0.4-rc.0 +raix:handlebar-helpers@0.2.5 +random@1.0.5 +rate-limit@1.0.0 +reactive-dict@1.1.3 +reactive-var@1.0.6 +reload@1.1.4 +retry@1.0.4 reywood:publish-composite@1.4.2 -routepolicy@1.0.6-rc.0 +routepolicy@1.0.6 seriousm:emoji-continued@1.4.0 -service-configuration@1.0.5-rc.0 -session@1.1.1-rc.0 -sha@1.0.4-rc.0 -softwarerero:accounts-t9n@1.1.4 -spacebars@1.0.7-rc.0 -spacebars-compiler@1.0.7-rc.0 -srp@1.0.4-rc.0 -standard-minifiers@1.0.0-rc.1 -tap:i18n@1.5.1 +service-configuration@1.0.5 +session@1.1.1 +sha@1.0.4 +softwarerero:accounts-t9n@1.1.7 +spacebars@1.0.7 +spacebars-compiler@1.0.7 +srp@1.0.4 +standard-minifiers@1.0.2 +tap:i18n@1.7.0 templates:tabs@2.2.0 -templating@1.1.2-rc.4 -templating-tools@1.0.0-rc.0 -tracker@1.0.8-rc.0 -ui@1.0.7-rc.0 -underscore@1.0.4-rc.0 -url@1.0.5-rc.0 -useraccounts:core@1.12.3 -useraccounts:flow-routing@1.12.3 -useraccounts:unstyled@1.12.3 +templating@1.1.5 +templating-tools@1.0.0 +tracker@1.0.9 +ui@1.0.8 +underscore@1.0.4 +url@1.0.5 +useraccounts:core@1.13.0 +useraccounts:flow-routing@1.13.0 +useraccounts:unstyled@1.13.0 verron:autosize@3.0.8 -webapp@1.2.2-rc.2 -webapp-hashing@1.0.4-rc.0 +webapp@1.2.3 +webapp-hashing@1.0.5 zimme:active-route@2.3.2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..a8724631 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +sudo: false +language: node_js +node_js: + - "0.10.40" +install: + - "npm install" +script: + - "npm test" diff --git a/History.md b/Changelog.md index ddf7032f..9f7aca32 100644 --- a/History.md +++ b/Changelog.md @@ -1,9 +1,26 @@ -# NEXT — v0.9 +# v0.10 -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 -new user interface and continues to improve the overall performance and -security. It also features the following improvements: +This release features: + +* Trello boards and cards importation, including card history, assigned members, + labels, comments, and attachments; +* Invite new users to a board using a email address; +* Autocompletion in the minicard editor. Start with <kbd>@</kbd> to start a + board member autocompletion, or <kbd>#</kbd> for a label; +* Accelerate the initial page rendering by sending the data on the intial HTTP + response instead of waiting for the DDP connection to open; +* Support images attachments copy pasting. + +New languages supported: Arabic, Catalan, Italian, and Russian. + +Thanks to GitHub users AlexanderS, fisle, floatinghotpot, FuzzyWuzzie, mnutt, +ndarilek, SirCmpwn, and xavierpriour for their contributions. + +# v0.9 + +This release is a large re-write of the previous code base. This release marks +the beginning of our new user interface and continues to improve the overall +performance and security. It also features the following improvements: * A new user account system, including the possibility to reset a forgotten password, to change the password, or to enable email confirmation (all of diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index 85b1276e..28a9f9c9 100644 --- a/client/components/activities/activities.jade +++ b/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}}. diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index 5c5d8370..c1465b04 100644 --- a/client/components/activities/activities.js +++ b/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(), @@ -68,8 +86,9 @@ BlazeComponent.extendComponent({ attachmentLink() { const attachment = this.currentData().attachment(); - return attachment && Blaze.toHTML(HTML.A({ - href: attachment.url({ download: true }), + // 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())); }, @@ -83,9 +102,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, diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 08401caa..18bf9ef0 100644 --- a/client/components/activities/comments.js +++ b/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); } diff --git a/client/components/boards/boardArchive.js b/client/components/boards/boardArchive.js index 9d7ca7f2..35f795f3 100644 --- a/client/components/boards/boardArchive.js +++ b/client/components/boards/boardArchive.js @@ -22,13 +22,9 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-restore-board'() { - const boardId = this.currentData()._id; - Boards.update(boardId, { - $set: { - archived: false, - }, - }); - Utils.goBoardId(boardId); + const board = this.currentData(); + board.restore(); + Utils.goBoardId(board._id); }, }]; }, diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 95590beb..a601bc2e 100644 --- a/client/components/boards/boardBody.js +++ b/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 @@ -45,7 +45,8 @@ BlazeComponent.extendComponent({ }, scrollLeft(position = 0) { - this.$('.js-lists').animate({ + const lists = this.$('.js-lists'); + lists && lists.animate({ scrollLeft: position, }); }, @@ -133,7 +134,7 @@ Template.boardBody.onRendered(function() { if (!Meteor.user() || !Meteor.user().isBoardMember()) return; - self.$(self.listsDom).sortable({ + $(self.listsDom).sortable({ tolerance: 'pointer', helper: 'clone', handle: '.js-list-header', @@ -145,7 +146,7 @@ Template.boardBody.onRendered(function() { Popup.close(); }, stop() { - self.$('.js-lists').find('.js-list:not(.js-list-composer)').each( + $(self.listsDom).find('.js-list:not(.js-list-composer)').each( (i, list) => { const data = Blaze.getData(list); Lists.update(data._id, { @@ -160,7 +161,7 @@ Template.boardBody.onRendered(function() { // Disable drag-dropping while in multi-selection mode self.autorun(() => { - self.$(self.listsDom).sortable('option', 'disabled', + $(self.listsDom).sortable('option', 'disabled', MultiSelection.isActive()); }); @@ -179,22 +180,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(); } }, }]; diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index 94225730..a0160382 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -32,7 +32,7 @@ template(name="headerBoard") title="{{#if MultiSelection.isActive}}{{_ 'filter-on-desc'}}{{/if}}" class="{{#if MultiSelection.isActive}}emphasis{{/if}}") i.fa.fa-check-square-o - span Multi-Selection {{#if MultiSelection.isActive}}is on{{/if}} + span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}} if MultiSelection.isActive a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}") i.fa.fa-times-thin @@ -105,8 +105,11 @@ template(name="createBoardPopup") span.fa.fa-lock.colorful = " " | {{{_ 'board-private-info'}}} - a.js-change-visibility Change. + a.js-change-visibility {{_ 'change'}}. input.primary.wide(type="submit" value="{{_ 'create'}}") + span.quiet + | {{_ 'or'}} + a.js-import {{_ 'import-board'}} template(name="boardChangeTitlePopup") @@ -114,6 +117,9 @@ template(name="boardChangeTitlePopup") label | {{_ 'title'}} input.js-board-name(type="text" value=title autofocus) + label + | {{_ 'description'}} + textarea.js-board-desc= description input.primary.wide(type="submit" value="{{_ 'rename'}}") template(name="archiveBoardPopup") diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index f259b2a6..3dc6d754 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -6,9 +6,9 @@ Template.boardMenuPopup.events({ }, 'click .js-change-board-color': Popup.open('boardChangeColor'), 'click .js-change-language': Popup.open('changeLanguage'), - 'click .js-archive-board ': Popup.afterConfirm('archiveBoard', () => { - const boardId = Session.get('currentBoard'); - Boards.update(boardId, { $set: { archived: true }}); + 'click .js-archive-board ': Popup.afterConfirm('archiveBoard', function() { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + currentBoard.archive(); // XXX We should have some kind of notification on top of the page to // confirm that the board was successfully archived. FlowRouter.go('home'); @@ -17,13 +17,11 @@ Template.boardMenuPopup.events({ Template.boardChangeTitlePopup.events({ submit(evt, tpl) { - const title = tpl.$('.js-board-name').val().trim(); - if (title) { - Boards.update(this._id, { - $set: { - title, - }, - }); + const newTitle = tpl.$('.js-board-name').val().trim(); + const newDesc = tpl.$('.js-board-desc').val().trim(); + if (newTitle) { + this.rename(newTitle); + this.setDesciption(newDesc); Popup.close(); } evt.preventDefault(); @@ -95,12 +93,9 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-select-background'(evt) { - const currentBoardId = Session.get('currentBoard'); - Boards.update(currentBoardId, { - $set: { - color: this.currentData().toString(), - }, - }); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + const newColor = this.currentData().toString(); + currentBoard.setColor(newColor); evt.preventDefault(); }, }]; @@ -152,6 +147,7 @@ BlazeComponent.extendComponent({ this.setVisibility(this.currentData()); }, 'click .js-change-visibility': this.toggleVisibilityMenu, + 'click .js-import': Popup.open('boardImportBoard'), submit: this.onSubmit, }]; }, @@ -168,11 +164,9 @@ BlazeComponent.extendComponent({ }, selectBoardVisibility() { - Boards.update(Session.get('currentBoard'), { - $set: { - permission: this.currentData(), - }, - }); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + const visibility = this.currentData(); + currentBoard.setVisibility(visibility); Popup.close(); }, diff --git a/client/components/boards/boardHeader.styl b/client/components/boards/boardHeader.styl new file mode 100644 index 00000000..adfe4b19 --- /dev/null +++ b/client/components/boards/boardHeader.styl @@ -0,0 +1,2 @@ +a.js-import + text-decoration underline diff --git a/client/components/boards/boardsList.jade b/client/components/boards/boardsList.jade index 11333eee..7099cdc9 100644 --- a/client/components/boards/boardsList.jade +++ b/client/components/boards/boardsList.jade @@ -3,11 +3,23 @@ template(name="boardList") ul.board-list.clearfix each boards li(class="{{#if isStarred}}starred{{/if}}" class=colorClass) - a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}") - span.details - span.board-list-item-name= title - i.fa.js-star-board( - class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" - title="{{_ 'star-board-title'}}") + if isInvited + .board-list-item + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") + p.board-list-item-desc {{_ 'just-invited'}} + button.js-accept-invite.primary {{_ 'accept'}} + button.js-decline-invite {{_ 'decline'}} + else + a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}") + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") + p.board-list-item-desc= description li.js-add-board - a.label {{_ 'add-board'}} + a.board-list-item.label {{_ 'add-board'}} diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js index 1a2d3c9a..131adf9d 100644 --- a/client/components/boards/boardsList.js +++ b/client/components/boards/boardsList.js @@ -17,6 +17,11 @@ BlazeComponent.extendComponent({ return user && user.hasStarred(this.currentData()._id); }, + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(this.currentData()._id); + }, + events() { return [{ 'click .js-add-board': Popup.open('createBoard'), @@ -25,6 +30,19 @@ BlazeComponent.extendComponent({ Meteor.user().toggleBoardStar(boardId); evt.preventDefault(); }, + 'click .js-accept-invite'() { + const boardId = this.currentData()._id; + Meteor.user().removeInvite(boardId); + }, + 'click .js-decline-invite'() { + const boardId = this.currentData()._id; + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }]; }, }).register('boardList'); diff --git a/client/components/boards/boardsList.styl b/client/components/boards/boardsList.styl index 9978fab8..e24940a0 100644 --- a/client/components/boards/boardsList.styl +++ b/client/components/boards/boardsList.styl @@ -14,7 +14,7 @@ $spaceBetweenTiles = 16px .fa-star-o opacity: 1 - a + .board-list-item background-color: #999 color: #f6f6f6 height: 90px @@ -40,6 +40,13 @@ $spaceBetweenTiles = 16px font-weight: 400 line-height: 22px + .board-list-item-desc + color: rgba(255, 255, 255, .5) + display: block + font-size: 10px + font-weight: 400 + line-height: 18px + .js-add-board text-align:center diff --git a/client/components/cards/attachments.jade b/client/components/cards/attachments.jade index 59eaf077..2cb3bb85 100644 --- a/client/components/cards/attachments.jade +++ b/client/components/cards/attachments.jade @@ -3,6 +3,16 @@ template(name="cardAttachmentsPopup") li input.js-attach-file.hide(type="file" name="file" multiple) a.js-computer-upload {{_ 'computer'}} + li + a.js-upload-clipboard-image {{_ 'clipboard'}} + +template(name="previewClipboardImagePopup") + p <kbd>Ctrl</kbd>+<kbd>V</kbd> {{_ "paste-or-dragdrop"}} + img.preview-clipboard-image() + button.primary.js-upload-pasted-image {{_ 'upload'}} + +template(name="previewAttachedImagePopup") + img.preview-large-image.js-large-image-clicked(src="{{pathFor url}}") template(name="attachmentDeletePopup") p {{_ "attachment-delete-pop"}} @@ -15,7 +25,7 @@ template(name="attachmentsGalery") .attachment-thumbnail if isUploaded if isImage - img.attachment-thumbnail-img(src=url) + img.attachment-thumbnail-img.js-preview-image(src="{{pathFor url}}") else span.attachment-thumbnail-ext= extension else diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index ba56aa1a..1e5aa03b 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -1,7 +1,7 @@ Template.attachmentsGalery.events({ 'click .js-add-attachment': Popup.open('cardAttachments'), 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', - () => { + function() { Attachments.remove(this._id); Popup.close(); } @@ -15,10 +15,43 @@ Template.attachmentsGalery.events({ // XXX Not implemented! }, 'click .js-add-cover'() { - Cards.update(this.cardId, { $set: { coverId: this._id } }); + Cards.findOne(this.cardId).setCover(this._id); }, 'click .js-remove-cover'() { - Cards.update(this.cardId, { $unset: { coverId: '' } }); + Cards.findOne(this.cardId).unsetCover(); + }, + 'click .js-preview-image'(evt) { + Popup.open('previewAttachedImage').call(this, evt); + // when multiple thumbnails, if click one then another very fast, + // we might get a wrong width from previous img. + // when popup reused, onRendered() won't be called, so we cannot get there. + // here make sure to get correct size when this img fully loaded. + const img = $('img.preview-large-image')[0]; + if (!img) return; + const rePosPopup = () => { + const w = img.width; + const h = img.height; + // if the image is too large, we resize & center the popup. + if (w > 300) { + $('div.pop-over').css({ + width: (w + 20), + position: 'absolute', + left: (window.innerWidth - w)/2, + top: (window.innerHeight - h)/2, + }); + } + }; + const url = $(evt.currentTarget).attr('src'); + if (img.src === url && img.complete) + rePosPopup(); + else + img.onload = rePosPopup; + }, +}); + +Template.previewAttachedImagePopup.events({ + 'click .js-large-image-clicked'(){ + Popup.close(); }, }); @@ -28,7 +61,7 @@ Template.cardAttachmentsPopup.events({ FS.Utility.eachFile(evt, (f) => { const file = new FS.File(f); file.boardId = card.boardId; - file.cardId = card._id; + file.cardId = card._id; Attachments.insert(file); Popup.close(); @@ -38,4 +71,48 @@ Template.cardAttachmentsPopup.events({ tpl.find('.js-attach-file').click(); evt.preventDefault(); }, + 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'), +}); + +let pastedResults = null; + +Template.previewClipboardImagePopup.onRendered(() => { + // we can paste image from clipboard + $(document.body).pasteImageReader((results) => { + if (results.dataURL.startsWith('data:image/')) { + $('img.preview-clipboard-image').attr('src', results.dataURL); + pastedResults = results; + } + }); + + // we can also drag & drop image file to it + $(document.body).dropImageReader((results) => { + if (results.dataURL.startsWith('data:image/')) { + $('img.preview-clipboard-image').attr('src', results.dataURL); + pastedResults = results; + } + }); +}); + +Template.previewClipboardImagePopup.events({ + 'click .js-upload-pasted-image'() { + const results = pastedResults; + if (results && results.file) { + const card = this; + const file = new FS.File(results.file); + if (!results.name) { + // if no filename, it's from clipboard. then we give it a name, with ext name from MIME type + if (typeof results.file.type === 'string') { + file.name(results.file.type.replace('image/', 'clipboard.')); + } + } + file.updatedAt(new Date()); + file.boardId = card.boardId; + file.cardId = card._id; + Attachments.insert(file); + pastedResults = null; + $(document.body).pasteImageReader(() => {}); + Popup.close(); + } + }, }); diff --git a/client/components/cards/attachments.styl b/client/components/cards/attachments.styl index 5cdf7386..a582f3af 100644 --- a/client/components/cards/attachments.styl +++ b/client/components/cards/attachments.styl @@ -45,3 +45,14 @@ display: block box-shadow: 0 1px 2px rgba(0,0,0,.2) +.preview-large-image + max-width: 1000px + display: block + box-shadow: 0 1px 2px rgba(0,0,0,.2) + +.preview-clipboard-image + width: 280px + height: 200px + display: block + border: 1px solid black + box-shadow: 0 1px 2px rgba(0,0,0,.2) diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 09c99f4e..b4fdca52 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -13,19 +13,19 @@ BlazeComponent.extendComponent({ }, reachNextPeak() { - const activitiesComponent = this.componentChildren('activities')[0]; + const activitiesComponent = this.childComponents('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,13 +52,7 @@ BlazeComponent.extendComponent({ }, onDestroyed() { - this.componentParent().showOverlay.set(false); - }, - - updateCard(modifier) { - Cards.update(this.data()._id, { - $set: modifier, - }); + this.parentComponent().showOverlay.set(false); }, events() { @@ -68,7 +62,8 @@ BlazeComponent.extendComponent({ }, }; - return [_.extend(events, { + return [{ + ...events, 'click .js-close-card-details'() { Utils.goBoardId(this.data().boardId); }, @@ -76,23 +71,23 @@ BlazeComponent.extendComponent({ 'submit .js-card-description'(evt) { evt.preventDefault(); const description = this.currentComponent().getValue(); - this.updateCard({ description }); + this.data().setDescription(description); }, 'submit .js-card-details-title'(evt) { evt.preventDefault(); - const title = this.currentComponent().getValue(); - if ($.trim(title)) { - this.updateCard({ title }); + const title = this.currentComponent().getValue().trim(); + if (title) { + this.data().setTitle(title); } }, 'click .js-member': Popup.open('cardMember'), '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'); @@ -111,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()); } @@ -138,14 +133,9 @@ Template.cardDetailsActionsPopup.events({ 'click .js-labels': Popup.open('cardLabels'), 'click .js-attachments': Popup.open('cardAttachments'), 'click .js-move-card': Popup.open('moveCard'), - // 'click .js-copy': Popup.open(), 'click .js-archive'(evt) { evt.preventDefault(); - Cards.update(this._id, { - $set: { - archived: true, - }, - }); + this.archive(); Popup.close(); }, 'click .js-more': Popup.open('cardMore'), @@ -155,22 +145,18 @@ Template.moveCardPopup.events({ 'click .js-select-list'() { // XXX We should *not* get the currentCard from the global state, but // instead from a “component” state. - const cardId = Session.get('currentCard'); + const card = Cards.findOne(Session.get('currentCard')); const newListId = this._id; - Cards.update(cardId, { - $set: { - listId: newListId, - }, - }); + card.move(newListId); Popup.close(); }, }); Template.cardMorePopup.events({ - 'click .js-delete': Popup.afterConfirm('cardDelete', () => { + 'click .js-delete': Popup.afterConfirm('cardDelete', function() { Popup.close(); Cards.remove(this._id); - Utils.goBoardId(this.board()._id); + Utils.goBoardId(this.boardId); }), }); diff --git a/client/components/cards/labels.jade b/client/components/cards/labels.jade index a868627c..31bd4d06 100644 --- a/client/components/cards/labels.jade +++ b/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"}} diff --git a/client/components/cards/labels.js b/client/components/cards/labels.js index 2da3b80b..4e61a0c6 100644 --- a/client/components/cards/labels.js +++ b/client/components/cards/labels.js @@ -13,7 +13,7 @@ BlazeComponent.extendComponent({ }, labels() { - return _.map(labelColors, (color) => { + return labelColors.map((color) => { return { color, name: '' }; }); }, @@ -45,19 +45,9 @@ Template.createLabelPopup.helpers({ Template.cardLabelsPopup.events({ 'click .js-select-label'(evt) { - const cardId = Template.parentData(2).data._id; + const card = Cards.findOne(Session.get('currentCard')); const labelId = this._id; - let operation; - if (Cards.find({ _id: cardId, labelIds: labelId}).count() === 0) - operation = '$addToSet'; - else - operation = '$pull'; - - Cards.update(cardId, { - [operation]: { - labelIds: labelId, - }, - }); + card.toggleLabel(labelId); evt.preventDefault(); }, 'click .js-edit-label': Popup.open('editLabel'), @@ -79,52 +69,27 @@ 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 boardId = Session.get('currentBoard'); const color = Blaze.getData(tpl.find('.fa-check')).color; - - Boards.update(boardId, { - $push: { - labels: { - name, - color, - _id: Random.id(6), - }, - }, - }); - + board.addLabel(name, color); Popup.back(); - evt.preventDefault(); }, }); Template.editLabelPopup.events({ 'click .js-delete-label': Popup.afterConfirm('deleteLabel', function() { - const boardId = Session.get('currentBoard'); - Boards.update(boardId, { - $pull: { - labels: { - _id: this._id, - }, - }, - }); - + const board = Boards.findOne(Session.get('currentBoard')); + board.removeLabel(this._id); Popup.back(2); }), 'submit .edit-label'(evt, tpl) { evt.preventDefault(); + const board = Boards.findOne(Session.get('currentBoard')); const name = tpl.$('#labelName').val().trim(); - const boardId = Session.get('currentBoard'); - const getLabel = Utils.getLabelIndex(boardId, this._id); const color = Blaze.getData(tpl.find('.fa-check')).color; - - Boards.update(boardId, { - $set: { - [getLabel.key('name')]: name, - [getLabel.key('color')]: color, - }, - }); - + board.editLabel(this._id, name, color); Popup.back(); }, }); diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index 660b0fa5..573b3da1 100644 --- a/client/components/cards/minicard.jade +++ b/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 diff --git a/client/components/forms/forms.styl b/client/components/forms/forms.styl index 83d25370..9ae95140 100644 --- a/client/components/forms/forms.styl +++ b/client/components/forms/forms.styl @@ -617,8 +617,15 @@ button margin-right: 5px vertical-align: middle + .minicard-label + width: 11px + height: @width + border-radius: 2px + margin: 2px 7px -2px -2px + display: inline-block + &.active background: #005377 - a + a, .quiet color: white diff --git a/client/components/import/import.jade b/client/components/import/import.jade new file mode 100644 index 00000000..74b6ca13 --- /dev/null +++ b/client/components/import/import.jade @@ -0,0 +1,54 @@ +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) + | {{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 + | (<span class="username">{{username}}</span>) + +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 new file mode 100644 index 00000000..63285e57 --- /dev/null +++ b/client/components/import/import.js @@ -0,0 +1,271 @@ +/// 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'; + }, + + 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); + }, + + _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.currentData()._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'); + +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 00000000..3c6cfdf3 --- /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/client/components/lists/list.js b/client/components/lists/list.js index cdf30fc2..f5410ed0 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -7,7 +7,7 @@ BlazeComponent.extendComponent({ // Proxy openForm(options) { - this.componentChildren('listBody')[0].openForm(options); + this.childComponents('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({ @@ -73,23 +73,13 @@ BlazeComponent.extendComponent({ $cards.sortable('cancel'); if (MultiSelection.isActive()) { - Cards.find(MultiSelection.getMongoSelector()).forEach((c, i) => { - Cards.update(c._id, { - $set: { - listId, - sort: sortIndex.base + i * sortIndex.increment, - }, - }); + Cards.find(MultiSelection.getMongoSelector()).forEach((card, i) => { + card.move(listId, sortIndex.base + i * sortIndex.increment); }); } else { const cardDomElement = ui.item.get(0); - const cardId = Blaze.getData(cardDomElement)._id; - Cards.update(cardId, { - $set: { - listId, - sort: sortIndex.base, - }, - }); + const card = Blaze.getData(cardDomElement); + card.move(listId, sortIndex.base); } boardComponent.setIsDragging(false); }, @@ -107,16 +97,15 @@ BlazeComponent.extendComponent({ accept: '.js-member,.js-label', drop(event, ui) { const cardId = Blaze.getData(this)._id; - let addToSet; + const card = Cards.findOne(cardId); if (ui.draggable.hasClass('js-member')) { const memberId = Blaze.getData(ui.draggable.get(0)).userId; - addToSet = { members: memberId }; + card.assignMember(memberId); } else { const labelId = Blaze.getData(ui.draggable.get(0))._id; - addToSet = { labelIds: labelId }; + card.addLabel(labelId); } - Cards.update(cardId, { $addToSet: addToSet }); }, }); }); diff --git a/client/components/lists/listBody.jade b/client/components/lists/listBody.jade index b0a374ea..e659b179 100644 --- a/client/components/lists/listBody.jade +++ b/client/components/lists/listBody.jade @@ -22,9 +22,20 @@ template(name="listBody") template(name="addCardForm") .minicard.minicard-composer.js-composer - .minicard-detailss.clearfix - textarea.minicard-composer-textarea.js-card-title(autofocus) + if getLabels + .minicard-labels + each getLabels + .minicard-label(class="card-label-{{color}}" title="{{name}}") + textarea.minicard-composer-textarea.js-card-title(autofocus) + if members.get .minicard-members.js-minicard-composer-members + each members.get + +userAvatar(userId=this) + .add-controls.clearfix button.primary.confirm(type="submit") {{_ 'add'}} a.fa.fa-times-thin.js-close-inlined-form + +template(name="autocompleteLabelLine") + .minicard-label(class="card-label-{{colorName}}" title=labelName) + span(class="{{#if hasNoName}}quiet{{/if}}")= labelName diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index 2e00cb4f..36b60d06 100644 --- a/client/components/lists/listBody.js +++ b/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.childComponents('inlinedForm'); + let form = forms.find((component) => { return component.data().position === options.position; }); if (!form && forms.length > 0) { @@ -26,8 +26,10 @@ BlazeComponent.extendComponent({ const firstCardDom = this.find('.js-minicard:first'); const lastCardDom = this.find('.js-minicard:last'); const textarea = $(evt.currentTarget).find('textarea'); - const title = textarea.val(); - const position = Blaze.getData(evt.currentTarget).position; + const position = this.currentData().position; + const title = textarea.val().trim(); + + const formComponent = this.childComponents('addCardForm')[0]; let sortIndex; if (position === 'top') { sortIndex = Utils.calculateIndex(null, firstCardDom).base; @@ -35,9 +37,14 @@ BlazeComponent.extendComponent({ sortIndex = Utils.calculateIndex(lastCardDom, null).base; } - if ($.trim(title)) { + const members = formComponent.members.get(); + const labelIds = formComponent.labels.get(); + + if (title) { const _id = Cards.insert({ title, + members, + labelIds, listId: this.data()._id, boardId: this.data().board()._id, sort: sortIndex, @@ -53,6 +60,8 @@ BlazeComponent.extendComponent({ if (position === 'bottom') { this.scrollToBottom(); } + + formComponent.reset(); } }, @@ -100,11 +109,39 @@ BlazeComponent.extendComponent({ }, }).register('listBody'); +function toggleValueInReactiveArray(reactiveValue, value) { + const array = reactiveValue.get(); + const valueIndex = array.indexOf(value); + if (valueIndex === -1) { + array.push(value); + } else { + array.splice(valueIndex, 1); + } + reactiveValue.set(array); +} + BlazeComponent.extendComponent({ template() { return 'addCardForm'; }, + onCreated() { + this.labels = new ReactiveVar([]); + this.members = new ReactiveVar([]); + }, + + reset() { + this.labels.set([]); + this.members.set([]); + }, + + getLabels() { + const currentBoardId = Session.get('currentBoard'); + return Boards.findOne(currentBoardId).labels.filter((label) => { + return this.labels.get().indexOf(label._id) > -1; + }); + }, + pressKey(evt) { // Pressing Enter should submit the card if (evt.keyCode === 13) { @@ -140,4 +177,66 @@ BlazeComponent.extendComponent({ keydown: this.pressKey, }]; }, + + onRendered() { + const editor = this; + this.$('textarea').escapeableTextComplete([ + // User mentions + { + match: /\B@(\w*)$/, + search(term, callback) { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + callback($.map(currentBoard.members, (member) => { + const user = Users.findOne(member.userId); + return user.username.indexOf(term) === 0 ? user : null; + })); + }, + template(user) { + return user.username; + }, + replace(user) { + toggleValueInReactiveArray(editor.members, user._id); + return ''; + }, + index: 1, + }, + + // Labels + { + match: /\B#(\w*)$/, + search(term, callback) { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + callback($.map(currentBoard.labels, (label) => { + if (label.name.indexOf(term) > -1 || + label.color.indexOf(term) > -1) { + return label; + } + })); + }, + template(label) { + return Blaze.toHTMLWithData(Template.autocompleteLabelLine, { + hasNoName: !Boolean(label.name), + colorName: label.color, + labelName: label.name || label.color, + }); + }, + replace(label) { + toggleValueInReactiveArray(editor.labels, label._id); + return ''; + }, + index: 1, + }, + ], { + // When the autocomplete menu is shown we want both a press of both `Tab` + // or `Enter` to validation the auto-completion. We also need to stop the + // event propagation to prevent the card from submitting (on `Enter`) or + // going on the next column (on `Tab`). + onKeydown(evt, commands) { + if (evt.keyCode === 9 || evt.keyCode === 13) { + evt.stopPropagation(); + return commands.KEY_ENTER; + } + }, + }); + }, }).register('addCardForm'); diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade index 7d01f1ba..72cd0fe9 100644 --- a/client/components/lists/listHeader.jade +++ b/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") diff --git a/client/components/lists/listHeader.js b/client/components/lists/listHeader.js index 9431b461..d660508a 100644 --- a/client/components/lists/listHeader.js +++ b/client/components/lists/listHeader.js @@ -5,14 +5,10 @@ BlazeComponent.extendComponent({ editTitle(evt) { evt.preventDefault(); - const form = this.componentChildren('inlinedForm')[0]; - const newTitle = form.getValue(); - if ($.trim(newTitle)) { - Lists.update(this.currentData()._id, { - $set: { - title: newTitle, - }, - }); + const newTitle = this.childComponents('inlinedForm')[0].getValue().trim(); + const list = this.currentData(); + if (newTitle) { + list.rename(newTitle.trim()); } }, @@ -33,45 +29,32 @@ Template.listActionPopup.events({ }, 'click .js-list-subscribe'() {}, 'click .js-select-cards'() { - const cardIds = Cards.find( - {listId: this._id}, - {fields: { _id: 1 }} - ).map((card) => card._id); + const cardIds = this.allCards().map((card) => card._id); 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', () => { - Cards.find({listId: this._id}).forEach((card) => { - Cards.update(card._id, { - $set: { - archived: true, - }, - }); + 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() { + this.allCards().forEach((card) => { + card.archive(); }); Popup.close(); }), + 'click .js-close-list'(evt) { evt.preventDefault(); - Lists.update(this._id, { - $set: { - archived: true, - }, - }); + this.archive(); Popup.close(); }, }); Template.listMoveCardsPopup.events({ 'click .js-select-list'() { - const fromList = Template.parentData(2).data._id; + const fromList = Template.parentData(2).data; const toList = this._id; - Cards.find({ listId: fromList }).forEach((card) => { - Cards.update(card._id, { - $set: { - listId: toList, - }, - }); + fromList.allCards().forEach((card) => { + card.move(toList); }); Popup.close(); }, diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 1d88fe74..82fce641 100644 --- a/client/components/main/editor.js +++ b/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; }); diff --git a/client/components/main/header.jade b/client/components/main/header.jade index 4715bfc8..86dfd6a7 100644 --- a/client/components/main/header.jade +++ b/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'}} diff --git a/client/components/main/keyboardShortcuts.styl b/client/components/main/keyboardShortcuts.styl index 42e0637b..f77d387f 100644 --- a/client/components/main/keyboardShortcuts.styl +++ b/client/components/main/keyboardShortcuts.styl @@ -14,11 +14,6 @@ padding: 5px 8px margin: 5px font-size: 18px - font-weight: bold - background: white - border-radius: 3px - border: 1px solid darken(white, 10%) - box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.15) .shortcuts-list-item-action font-size: 1.4em diff --git a/client/components/main/layouts.jade b/client/components/main/layouts.jade index f5a8db59..65b53f04 100644 --- a/client/components/main/layouts.jade +++ b/client/components/main/layouts.jade @@ -2,13 +2,24 @@ 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) + div.at-form-lang + select.select-lang.js-userform-set-language + each languages + if isCurrentLanguage + option(value="{{tag}}" selected="selected") {{name}} + else + option(value="{{tag}}") {{name}} template(name="defaultLayout") +header diff --git a/client/components/main/layouts.js b/client/components/main/layouts.js index ab62e76a..3df17f41 100644 --- a/client/components/main/layouts.js +++ b/client/components/main/layouts.js @@ -2,10 +2,43 @@ Meteor.subscribe('boards'); BlazeLayout.setRoot('body'); +const i18nTagToT9n = (i18nTag) => { + // t9n/i18n tags are same now, see: https://github.com/softwarerero/meteor-accounts-t9n/pull/129 + // but we keep this conversion function here, to be aware that that they are different system. + return i18nTag; +}; + Template.userFormsLayout.onRendered(() => { + const i18nTag = navigator.language; + if (i18nTag) { + T9n.setLanguage(i18nTagToT9n(i18nTag)); + } EscapeActions.executeAll(); }); +Template.userFormsLayout.helpers({ + languages() { + return _.map(TAPi18n.getLanguages(), (lang, tag) => { + const name = lang.name; + return { tag, name }; + }); + }, + + isCurrentLanguage() { + const t9nTag = i18nTagToT9n(this.tag); + const curLang = T9n.getLanguage() || 'en'; + return t9nTag === curLang; + }, +}); + +Template.userFormsLayout.events({ + 'change .js-userform-set-language'(evt) { + const i18nTag = $(evt.currentTarget).val(); + T9n.setLanguage(i18nTagToT9n(i18nTag)); + evt.preventDefault(); + }, +}); + Template.defaultLayout.events({ 'click .js-close-modal': () => { Modal.close(); diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index 1dbefc20..fcc94251 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -172,6 +172,15 @@ dl, dt dd margin: 0 0 16px 24px +kbd + padding: 1px 3px + margin: 3px + font-weight: bold + background: darken(white, 2%) + border-radius: 3px + border: 1px solid darken(white, 10%) + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.15) + .clear clear: both diff --git a/client/components/main/popup.styl b/client/components/main/popup.styl index 3bef4f7d..8a685069 100644 --- a/client/components/main/popup.styl +++ b/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 diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 7f7519c6..3a5c7fdb 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -33,6 +33,13 @@ template(name="membersWidget") a.member.add-member.js-manage-board-members i.fa.fa-plus .clearfix + if isInvited + hr + p + i.fa.fa-exclamation-circle + | {{_ 'just-invited'}} + button.js-member-invite-accept.primary {{_ 'accept'}} + button.js-member-invite-decline {{_ 'decline'}} template(name="labelsWidget") .board-widget.board-widget-labels @@ -56,51 +63,58 @@ template(name="memberPopup") h3 .js-profile= user.profile.fullname p.quiet @#{user.username} + if isInvited + p + i.fa.fa-exclamation-circle + | {{_ 'not-accepted-yet'}} - if currentUser.isBoardMember - ul.pop-over-list - li - a.js-filter-member Filter cards + ul.pop-over-list + li + a.js-filter-member {{_ 'filter-cards'}} + if currentUser.isBoardAdmin unless isSandstorm - if currentUser.isBoardAdmin - li - a.js-change-role - | {{_ 'change-permissions'}} - span.quiet (#{memberType}) - li - if $eq currentUser._id userId - //- - XXX Not implemented! - // a.js-leave-member {{_ 'leave-board'}} - else - a.js-remove-member {{_ 'remove-from-board'}} + li + a.js-change-role + | {{_ 'change-permissions'}} + span.quiet (#{memberType}) + li + if $eq currentUser._id userId + a.js-leave-member {{_ 'leave-board'}} + else + a.js-remove-member {{_ 'remove-from-board'}} template(name="removeMemberPopup") - p {{_ 'remove-member-pop' name=user.profile.name username=user.username boardTitle=board.title}} + p {{_ 'remove-member-pop' name=user.profile.fullname username=user.username boardTitle=board.title}} button.js-confirm.negate.full(type="submit") {{_ 'remove-member'}} template(name="addMemberPopup") .js-search-member +esInput(index="users") - ul.pop-over-list - +esEach(index="users") - li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") - a.name.js-select-member(title="{{profile.name}} ({{username}})") - +userAvatar(userId=_id esSearch=true) - span.full-name - = profile.name - | (<span class="username">{{username}}</span>) - if isBoardMember - .quiet ({{_ 'joined'}}) + if loading.get + +spinner + else if error.get + .warning {{_ error.get}} + else + ul.pop-over-list + +esEach(index="users") + li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") + a.name.js-select-member(title="{{profile.name}} ({{username}})") + +userAvatar(userId=_id esSearch=true) + span.full-name + = profile.fullname + | (<span class="username">{{username}}</span>) + if isBoardMember + .quiet ({{_ 'joined'}}) - +ifEsIsSearching(index='users') - +spinner + +ifEsIsSearching(index='users') + +spinner - +ifEsHasNoResults(index="users") - .manage-member-section - p.quiet {{_ 'no-results'}} + +ifEsHasNoResults(index="users") + .manage-member-section + p.quiet {{_ 'no-results'}} + button.js-email-invite.primary.full {{_ 'email-invite'}} template(name="changePermissionsPopup") ul.pop-over-list diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index eff0ef52..5b58dbd9 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -54,7 +54,7 @@ BlazeComponent.extendComponent({ }, reachNextPeak() { - const activitiesComponent = this.componentChildren('activities')[0]; + const activitiesComponent = this.childComponents('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'); @@ -109,14 +109,6 @@ EscapeActions.register('sidebarView', () => { return Sidebar && Sidebar.getView() !== defaultView; } ); -function getMemberIndex(board, searchId) { - for (let i = 0; i < board.members.length; i++) { - if (board.members[i].userId === searchId) - return i; - } - throw new Meteor.Error('Member not found'); -} - Template.memberPopup.helpers({ user() { return Users.findOne(this.userId); @@ -125,6 +117,9 @@ Template.memberPopup.helpers({ const type = Users.findOne(this.userId).isBoardAdmin() ? 'admin' : 'normal'; return TAPi18n.__(type).toLowerCase(); }, + isInvited() { + return Users.findOne(this.userId).isInvitedTo(Session.get('currentBoard')); + }, }); Template.memberPopup.events({ @@ -135,24 +130,53 @@ Template.memberPopup.events({ 'click .js-change-role': Popup.open('changePermissions'), 'click .js-remove-member': Popup.afterConfirm('removeMember', function() { const currentBoard = Boards.findOne(Session.get('currentBoard')); - const memberIndex = getMemberIndex(currentBoard, this.userId); - - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isActive`]: false, - }, - }); + const memberId = this.userId; + currentBoard.removeMember(memberId); Popup.close(); }), 'click .js-leave-member'() { - // XXX Not implemented - Popup.close(); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + Meteor.call('quitBoard', currentBoard, (err, ret) => { + if (!ret && ret) { + Popup.close(); + FlowRouter.go('home'); + } + }); + }, +}); + +Template.removeMemberPopup.helpers({ + user() { + return Users.findOne(this.userId); + }, + board() { + return Boards.findOne(Session.get('currentBoard')); + }, +}); + +Template.membersWidget.helpers({ + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(Session.get('currentBoard')); }, }); Template.membersWidget.events({ 'click .js-member': Popup.open('member'), 'click .js-manage-board-members': Popup.open('addMember'), + 'click .js-member-invite-accept'() { + const boardId = Session.get('currentBoard'); + Meteor.user().removeInvite(boardId); + }, + 'click .js-member-invite-decline'() { + const boardId = Session.get('currentBoard'); + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }); Template.labelsWidget.events({ @@ -198,56 +222,83 @@ function draggableMembersLabelsWidgets() { Template.membersWidget.onRendered(draggableMembersLabelsWidgets); Template.labelsWidget.onRendered(draggableMembersLabelsWidgets); -Template.addMemberPopup.helpers({ +BlazeComponent.extendComponent({ + template() { + return 'addMemberPopup'; + }, + + onCreated() { + this.error = new ReactiveVar(''); + this.loading = new ReactiveVar(false); + }, + + onRendered() { + this.find('.js-search-member input').focus(); + this.setLoading(false); + }, + isBoardMember() { - const user = Users.findOne(this._id); + const userId = this.currentData()._id; + const user = Users.findOne(userId); return user && user.isBoardMember(); }, -}); -Template.addMemberPopup.events({ - 'click .js-select-member'() { - const userId = this._id; - const currentBoard = Boards.findOne(Session.get('currentBoard')); - const currentMembersIds = _.pluck(currentBoard.members, 'userId'); - if (currentMembersIds.indexOf(userId) === -1) { - Boards.update(currentBoard._id, { - $push: { - members: { - userId, - isAdmin: false, - isActive: true, - }, - }, - }); - } else { - const memberIndex = getMemberIndex(currentBoard, userId); + isValidEmail(email) { + return SimpleSchema.RegEx.Email.test(email); + }, - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isActive`]: true, - }, - }); - } - Popup.close(); + setError(error) { + this.error.set(error); }, -}); -Template.addMemberPopup.onRendered(function() { - this.find('.js-search-member input').focus(); -}); + setLoading(w) { + this.loading.set(w); + }, + + isLoading() { + return this.loading.get(); + }, + + inviteUser(idNameEmail) { + const boardId = Session.get('currentBoard'); + this.setLoading(true); + const self = this; + Meteor.call('inviteUserToBoard', idNameEmail, boardId, (err, ret) => { + self.setLoading(false); + if (err) self.setError(err.error); + else if (ret.email) self.setError('email-sent'); + else Popup.close(); + }); + }, + + events() { + return [{ + 'keyup input'() { + this.setError(''); + }, + 'click .js-select-member'() { + const userId = this.currentData()._id; + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (currentBoard.memberIndex(userId)<0) { + this.inviteUser(userId); + } + }, + 'click .js-email-invite'() { + const idNameEmail = $('.js-search-member input').val(); + if (idNameEmail.indexOf('@')<0 || this.isValidEmail(idNameEmail)) { + this.inviteUser(idNameEmail); + } else this.setError('email-invalid'); + }, + }]; + }, +}).register('addMemberPopup'); Template.changePermissionsPopup.events({ 'click .js-set-admin, click .js-set-normal'(event) { const currentBoard = Boards.findOne(Session.get('currentBoard')); - const memberIndex = getMemberIndex(currentBoard, this.userId); + const memberId = this.userId; const isAdmin = $(event.currentTarget).hasClass('js-set-admin'); - - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isAdmin`]: isAdmin, - }, - }); + currentBoard.setMemberPermission(memberId, isAdmin); Popup.back(1); }, }); diff --git a/client/components/sidebar/sidebarArchives.js b/client/components/sidebar/sidebarArchives.js index f2597c3c..18970267 100644 --- a/client/components/sidebar/sidebarArchives.js +++ b/client/components/sidebar/sidebarArchives.js @@ -11,11 +11,17 @@ BlazeComponent.extendComponent({ }, archivedCards() { - return Cards.find({ archived: true }); + return Cards.find({ + archived: true, + boardId: Session.get('currentBoard'), + }); }, archivedLists() { - return Lists.find({ archived: true }); + return Lists.find({ + archived: true, + boardId: Session.get('currentBoard'), + }); }, cardIsInArchivedList() { @@ -29,8 +35,8 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-restore-card'() { - const cardId = this.currentData()._id; - Cards.update(cardId, {$set: {archived: false}}); + const card = this.currentData(); + card.restore(); }, 'click .js-delete-card': Popup.afterConfirm('cardDelete', function() { const cardId = this._id; @@ -38,8 +44,8 @@ BlazeComponent.extendComponent({ Popup.close(); }), 'click .js-restore-list'() { - const listId = this.currentData()._id; - Lists.update(listId, {$set: {archived: false}}); + const list = this.currentData(); + list.restore(); }, }]; }, diff --git a/client/components/sidebar/sidebarFilters.jade b/client/components/sidebar/sidebarFilters.jade index c894bc8b..ef26ef76 100644 --- a/client/components/sidebar/sidebarFilters.jade +++ b/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 @@ -75,8 +75,8 @@ template(name="multiselectionSidebar") template(name="disambiguateMultiLabelPopup") p {{_ 'what-to-do'}} - button.wide.js-remove-label Remove {{_ 'remove-label'}} - button.wide.js-add-label Add {{_ 'add-label'}} + button.wide.js-remove-label {{_ 'remove-label'}} + button.wide.js-add-label {{_ 'add-label'}} template(name="disambiguateMultiMemberPopup") p {{_ 'what-to-do'}} diff --git a/client/components/sidebar/sidebarFilters.js b/client/components/sidebar/sidebarFilters.js index 335cc7d6..bdecd63e 100644 --- a/client/components/sidebar/sidebarFilters.js +++ b/client/components/sidebar/sidebarFilters.js @@ -30,9 +30,9 @@ BlazeComponent.extendComponent({ }, }).register('filterSidebar'); -function updateSelectedCards(query) { +function mutateSelectedCards(mutationName, ...args) { Cards.find(MultiSelection.getMongoSelector()).forEach((card) => { - Cards.update(card._id, query); + card[mutationName](...args); }); } @@ -67,47 +67,34 @@ BlazeComponent.extendComponent({ 'click .js-toggle-label-multiselection'(evt) { const labelId = this.currentData()._id; const mappedSelection = this.mapSelection('label', labelId); - let operation; - if (_.every(mappedSelection)) - operation = '$pull'; - else if (_.every(mappedSelection, (bool) => !bool)) - operation = '$addToSet'; - else { + + if (_.every(mappedSelection)) { + mutateSelectedCards('removeLabel', labelId); + } else if (_.every(mappedSelection, (bool) => !bool)) { + mutateSelectedCards('addLabel', labelId); + } else { const popup = Popup.open('disambiguateMultiLabel'); // XXX We need to have a better integration between the popup and the // UI components systems. return popup.call(this.currentData(), evt); } - - updateSelectedCards({ - [operation]: { - labelIds: labelId, - }, - }); }, 'click .js-toggle-member-multiselection'(evt) { const memberId = this.currentData()._id; const mappedSelection = this.mapSelection('member', memberId); - let operation; - if (_.every(mappedSelection)) - operation = '$pull'; - else if (_.every(mappedSelection, (bool) => !bool)) - operation = '$addToSet'; - else { + if (_.every(mappedSelection)) { + mutateSelectedCards('unassignMember', memberId); + } else if (_.every(mappedSelection, (bool) => !bool)) { + mutateSelectedCards('assignMember', memberId); + } else { const popup = Popup.open('disambiguateMultiMember'); // XXX We need to have a better integration between the popup and the // UI components systems. return popup.call(this.currentData(), evt); } - - updateSelectedCards({ - [operation]: { - members: memberId, - }, - }); }, 'click .js-archive-selection'() { - updateSelectedCards({$set: {archived: true}}); + mutateSelectedCards('archive'); }, }]; }, @@ -115,22 +102,22 @@ BlazeComponent.extendComponent({ Template.disambiguateMultiLabelPopup.events({ 'click .js-remove-label'() { - updateSelectedCards({$pull: {labelIds: this._id}}); + mutateSelectedCards('removeLabel', this._id); Popup.close(); }, 'click .js-add-label'() { - updateSelectedCards({$addToSet: {labelIds: this._id}}); + mutateSelectedCards('addLabel', this._id); Popup.close(); }, }); Template.disambiguateMultiMemberPopup.events({ 'click .js-unassign-member'() { - updateSelectedCards({$pull: {members: this._id}}); + mutateSelectedCards('assignMember', this._id); Popup.close(); }, 'click .js-assign-member'() { - updateSelectedCards({$addToSet: {members: this._id}}); + mutateSelectedCards('unassignMember', this._id); Popup.close(); }, }); diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade index e08666e5..44e899a7 100644 --- a/client/components/users/userAvatar.jade +++ b/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) diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js index 04add0a6..1e531882 100644 --- a/client/components/users/userAvatar.js +++ b/client/components/users/userAvatar.js @@ -22,8 +22,11 @@ Template.userAvatar.helpers({ }, presenceStatusClassName() { + const user = Users.findOne(this.userId); const userPresence = presences.findOne({ userId: this.userId }); - if (!userPresence) + if (user && user.isInvitedTo(Session.get('currentBoard'))) + return 'pending'; + else if (!userPresence) return 'disconnected'; else if (Session.equals('currentBoard', userPresence.state.currentBoardId)) return 'active'; @@ -82,11 +85,7 @@ BlazeComponent.extendComponent({ }, setAvatar(avatarUrl) { - Meteor.users.update(Meteor.userId(), { - $set: { - 'profile.avatarUrl': avatarUrl, - }, - }); + Meteor.user().setAvatarUrl(avatarUrl); }, setError(error) { @@ -151,19 +150,9 @@ Template.cardMembersPopup.helpers({ Template.cardMembersPopup.events({ 'click .js-select-member'(evt) { - const cardId = Template.parentData(2).data._id; + const card = Cards.findOne(Session.get('currentCard')); const memberId = this.userId; - let operation; - if (Cards.find({ _id: cardId, members: memberId}).count() === 0) - operation = '$addToSet'; - else - operation = '$pull'; - - Cards.update(cardId, { - [operation]: { - members: memberId, - }, - }); + card.toggleMember(memberId); evt.preventDefault(); }, }); @@ -176,7 +165,7 @@ Template.cardMemberPopup.helpers({ Template.cardMemberPopup.events({ 'click .js-remove-member'() { - Cards.update(this.cardId, {$pull: {members: this.userId}}); + Cards.findOne(this.cardId).unassignMember(this.userId); Popup.close(); }, 'click .js-edit-profile': Popup.open('editProfile'), diff --git a/client/components/users/userAvatar.styl b/client/components/users/userAvatar.styl index 83257792..b962b01c 100644 --- a/client/components/users/userAvatar.styl +++ b/client/components/users/userAvatar.styl @@ -56,6 +56,10 @@ avatar-radius = 50% background: #bdbdbd border-color: #ededed + &.pending + background: #e44242 + border-color: #f1dada + .edit-avatar position: absolute top: 0 diff --git a/client/components/users/userForm.styl b/client/components/users/userForm.styl index 9b6e86ce..dbe62b4e 100644 --- a/client/components/users/userForm.styl +++ b/client/components/users/userForm.styl @@ -45,3 +45,13 @@ .at-signUp, .at-signIn font-weight: bold + + .at-form-lang + margin: auto + width: 275px + padding: 25px + padding-bottom: 10px + + .select-lang + width: 275px + font-size: 1.0em diff --git a/client/components/users/userHeader.js b/client/components/users/userHeader.js index 0f91fd15..a478da0c 100644 --- a/client/components/users/userHeader.js +++ b/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, diff --git a/client/config/blazeHelpers.js b/client/config/blazeHelpers.js index 12990ed7..adf5ef6a 100644 --- a/client/config/blazeHelpers.js +++ b/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(''); +}); diff --git a/client/config/router.js b/client/config/router.js index 1cac43a0..0a6958d0 100644 --- a/client/config/router.js +++ b/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(' - ')); + }); +}); diff --git a/client/lib/accessibility.js b/client/lib/accessibility.js new file mode 100644 index 00000000..52b771d4 --- /dev/null +++ b/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); +}; diff --git a/client/lib/dropImage.js b/client/lib/dropImage.js new file mode 100644 index 00000000..592d5c8f --- /dev/null +++ b/client/lib/dropImage.js @@ -0,0 +1,62 @@ +/* eslint-disable */ + +// ------------------------------------------------------------------------ +// Created by STRd6 +// MIT License +// https://github.com/distri/jquery-image_reader/blob/master/drop.coffee.md +// +// Raymond re-write it to javascript + +(function($) { + $.event.fix = (function(originalFix) { + return function(event) { + event = originalFix.apply(this, arguments); + if (event.type.indexOf('drag') === 0 || event.type.indexOf('drop') === 0) { + event.dataTransfer = event.originalEvent.dataTransfer; + } + return event; + }; + })($.event.fix); + + const defaults = { + callback: $.noop, + matchType: /image.*/, + }; + + return $.fn.dropImageReader = function(options) { + if (typeof options === 'function') { + options = { + callback: options, + }; + } + options = $.extend({}, defaults, options); + const stopFn = function(event) { + event.stopPropagation(); + return event.preventDefault(); + }; + return this.each(function() { + const element = this; + $(element).bind('dragenter dragover dragleave', stopFn); + return $(element).bind('drop', function(event) { + stopFn(event); + const files = event.dataTransfer.files; + for(let i=0; i<files.length; i++) { + const f = files[i]; + if(f.type.match(options.matchType)) { + const reader = new FileReader(); + reader.onload = function(evt) { + return options.callback.call(element, { + dataURL: evt.target.result, + event: evt, + file: f, + name: f.name, + }); + }; + reader.readAsDataURL(f); + return; + } + } + }); + }); + }; +})(jQuery); diff --git a/client/lib/filter.js b/client/lib/filter.js index f7baf480..74305284 100644 --- a/client/lib/filter.js +++ b/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(); }); diff --git a/client/lib/keyboard.js b/client/lib/keyboard.js index af5fb7a2..f8212c9b 100644 --- a/client/lib/keyboard.js +++ b/client/lib/keyboard.js @@ -23,6 +23,14 @@ Mousetrap.bind('x', () => { } }); +Mousetrap.bind('f', () => { + if (Sidebar.isOpen() && Sidebar.getView() === 'filter') { + Sidebar.toggle(); + } else { + Sidebar.setView('filter'); + } +}); + Mousetrap.bind(['down', 'up'], (evt, key) => { if (!Session.get('currentCard')) { return; @@ -36,6 +44,26 @@ Mousetrap.bind(['down', 'up'], (evt, key) => { } }); +// XXX This shortcut should also work when hovering over a card in board view +Mousetrap.bind('space', (evt) => { + if (!Session.get('currentCard')) { + return; + } + + const currentUserId = Meteor.userId(); + if (currentUserId === null) { + return; + } + + if (Meteor.user().isBoardMember()) { + const card = Cards.findOne(Session.get('currentCard')); + card.toggleMember(currentUserId); + // We should prevent scrolling in card when spacebar is clicked + // This should do it according to Mousetrap docs, but it doesn't + evt.preventDefault(); + } +}); + Template.keyboardShortcuts.helpers({ mapping: [{ keys: ['W'], @@ -44,6 +72,9 @@ Template.keyboardShortcuts.helpers({ keys: ['Q'], action: 'shortcut-filter-my-cards', }, { + keys: ['F'], + action: 'shortcut-toggle-filterbar', + }, { keys: ['X'], action: 'shortcut-clear-filters', }, { @@ -58,5 +89,8 @@ Template.keyboardShortcuts.helpers({ }, { keys: [':'], action: 'shortcut-autocomplete-emojies', + }, { + keys: ['SPACE'], + action: 'shortcut-assign-self', }], }); diff --git a/client/lib/modal.js b/client/lib/modal.js index 5b3392b2..e6301cb5 100644 --- a/client/lib/modal.js +++ b/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; } }; diff --git a/client/lib/multiSelection.js b/client/lib/multiSelection.js index c2bb2bbc..eeb2015d 100644 --- a/client/lib/multiSelection.js +++ b/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) diff --git a/client/lib/pasteImage.js b/client/lib/pasteImage.js new file mode 100644 index 00000000..264d77ac --- /dev/null +++ b/client/lib/pasteImage.js @@ -0,0 +1,57 @@ +/* eslint-disable */ + +// ------------------------------------------------------------------------ +// Created by STRd6 +// MIT License +// https://github.com/distri/jquery-image_reader/blob/master/paste.coffee.md +// +// Raymond re-write it to javascript + +(function($) { + $.event.fix = (function(originalFix) { + return function(event) { + event = originalFix.apply(this, arguments); + if (event.type.indexOf('copy') === 0 || event.type.indexOf('paste') === 0) { + event.clipboardData = event.originalEvent.clipboardData; + } + return event; + }; + })($.event.fix); + + const defaults = { + callback: $.noop, + matchType: /image.*/, + }; + + return $.fn.pasteImageReader = function(options) { + if (typeof options === 'function') { + options = { + callback: options, + }; + } + options = $.extend({}, defaults, options); + return this.each(function() { + const element = this; + return $(element).bind('paste', function(event) { + const types = event.clipboardData.types; + const items = event.clipboardData.items; + for(let i=0; i<types.length; i++) { + if(types[i].match(options.matchType) || items[i].type.match(options.matchType)) { + const f = items[i].getAsFile(); + const reader = new FileReader(); + reader.onload = function(evt) { + return options.callback.call(element, { + dataURL: evt.target.result, + event: evt, + file: f, + name: f.name, + }); + }; + reader.readAsDataURL(f); + return; + } + } + }); + }); + }; +})(jQuery); diff --git a/client/lib/popup.js b/client/lib/popup.js index 3c39af29..7418d938 100644 --- a/client/lib/popup.js +++ b/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(), diff --git a/client/lib/textComplete.js b/client/lib/textComplete.js new file mode 100644 index 00000000..3e69d07f --- /dev/null +++ b/client/lib/textComplete.js @@ -0,0 +1,54 @@ +// 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(strategies, options, ...otherArgs) { + // When the autocomplete menu is shown we want both a press of both `Tab` + // or `Enter` to validation the auto-completion. We also need to stop the + // event propagation to prevent EscapeActions side effect, for instance the + // minicard submission (on `Enter`) or going on the next column (on `Tab`). + options = { + onKeydown(evt, commands) { + if (evt.keyCode === 9 || evt.keyCode === 13) { + evt.stopPropagation(); + return commands.KEY_ENTER; + } + }, + ...options, + }; + + // Proxy to the vanilla jQuery component + this.textcomplete(strategies, options, ...otherArgs); + + // 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(() => { + // XXX Hack. We unfortunately need to set a setTimeout here to make the + // `noClickEscapeOn` work bellow, otherwise clicking on a autocomplete + // item will close both the autocomplete menu (as expected) but also the + // next item in the stack (for example the minicard editor) which we + // don't want. + setTimeout(() => { + dropdownMenuIsOpened = false; + }, 100); + }); + }, + }); +}; + +EscapeActions.register('textcomplete', + () => {}, + () => dropdownMenuIsOpened, { + noClickEscapeOn: '.textcomplete-dropdown', + } +); diff --git a/client/lib/unsavedEdits.js b/client/lib/unsavedEdits.js index dc267bfb..17bb29b5 100644 --- a/client/lib/unsavedEdits.js +++ b/client/lib/unsavedEdits.js @@ -65,7 +65,7 @@ UnsavedEdits = { }; Blaze.registerHelper('getUnsavedValue', (fieldName, docId, defaultTo) => { - // Workaround some blaze feature that ass a list of keywords arguments as the + // Workaround some blaze feature that pass a list of keywords arguments as the // last parameter (even if the caller didn't specify any). if (!_.isString(defaultTo)) { defaultTo = ''; diff --git a/client/lib/utils.js b/client/lib/utils.js index 0cd93419..6bdd5822 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -22,20 +22,6 @@ Utils = { return string.charAt(0).toUpperCase() + string.slice(1); }, - getLabelIndex(boardId, labelId) { - const board = Boards.findOne(boardId); - const labels = {}; - _.each(board.labels, (a, b) => { - labels[a._id] = b; - }); - return { - index: labels[labelId], - key(key) { - return `labels.${labels[labelId]}.${key}`; - }, - }; - }, - // Determine the new sort index calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) { let base, increment; diff --git a/collections/users.js b/collections/users.js deleted file mode 100644 index fa910c4a..00000000 --- a/collections/users.js +++ /dev/null @@ -1,151 +0,0 @@ -Users = Meteor.users; - -// 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']; -Users.initEasySearch(searchInFields, { - use: 'mongo-db', - returnFields: [...searchInFields, 'profile.avatarUrl'], -}); - -Users.helpers({ - boards() { - return Boards.find({ userId: this._id }); - }, - - starredBoards() { - const starredBoardIds = this.profile.starredBoards || []; - return Boards.find({archived: false, _id: {$in: starredBoardIds}}); - }, - - hasStarred(boardId) { - const starredBoardIds = this.profile.starredBoards || []; - return _.contains(starredBoardIds, 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; - }, - - getInitials() { - const profile = this.profile || {}; - if (profile.initials) - return profile.initials; - - else if (profile.fullname) { - return _.reduce(profile.fullname.split(/\s+/), (memo, word) => { - return memo + word[0]; - }, '').toUpperCase(); - - } else { - return this.username[0].toUpperCase(); - } - }, - - toggleBoardStar(boardId) { - const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet'; - Meteor.users.update(this._id, { - [queryKind]: { - 'profile.starredBoards': boardId, - }, - }); - }, -}); - -Meteor.methods({ - setUsername(username) { - check(username, String); - const nUsersWithUsername = Users.find({ username }).count(); - if (nUsersWithUsername > 0) { - throw new Meteor.Error('username-already-taken'); - } else { - Users.update(this.userId, {$set: { username }}); - } - }, -}); - -Users.before.insert((userId, doc) => { - doc.profile = doc.profile || {}; - - if (!doc.username && doc.profile.name) { - doc.username = doc.profile.name.toLowerCase().replace(/\s/g, ''); - } -}); - -if (Meteor.isServer) { - // Let mongoDB ensure username unicity - Meteor.startup(() => { - Users._collection._ensureIndex({ - username: 1, - }, { unique: true }); - }); - - // Each board document contains the de-normalized number of users that have - // starred it. If the user star or unstar a board, we need to update this - // counter. - // We need to run this code on the server only, otherwise the incrementation - // will be done twice. - Users.after.update(function(userId, user, fieldNames) { - // The `starredBoards` list is hosted on the `profile` field. If this - // field hasn't been modificated we don't need to run this hook. - if (!_.contains(fieldNames, 'profile')) - return; - - // To calculate a diff of board starred ids, we get both the previous - // and the newly board ids list - function getStarredBoardsIds(doc) { - return doc.profile && doc.profile.starredBoards; - } - const oldIds = getStarredBoardsIds(this.previous); - const newIds = getStarredBoardsIds(user); - - // The _.difference(a, b) method returns the values from a that are not in - // 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) => { - Boards.update(boardId, {$inc: {stars: inc}}); - }); - } - incrementBoards(_.difference(oldIds, newIds), -1); - incrementBoards(_.difference(newIds, oldIds), +1); - }); - - // XXX i18n - Users.after.insert((userId, doc) => { - const ExampleBoard = { - title: 'Welcome Board', - userId: doc._id, - permission: 'private', - }; - - // Insert the Welcome Board - Boards.insert(ExampleBoard, (err, boardId) => { - - _.forEach(['Basics', 'Advanced'], (title) => { - const list = { - title, - boardId, - userId: ExampleBoard.userId, - - // XXX Not certain this is a bug, but we except these fields get - // inserted by the Lists.before.insert collection-hook. Since this - // hook is not called in this case, we have to dublicate the logic and - // set them here. - archived: false, - createdAt: new Date(), - }; - - Lists.insert(list); - }); - }); - }); -} diff --git a/client/config/accounts.js b/config/accounts.js index df0935f7..3a6a116e 100644 --- a/client/config/accounts.js +++ b/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 @@ -46,3 +46,16 @@ AccountsTemplates.configureRoute('changePwd', { Popup.back(); }, }); + +if (Meteor.isServer) { + if (process.env.MAIL_FROM) { + Accounts.emailTemplates.from = process.env.MAIL_FROM; + } + + ['resetPassword-subject', 'resetPassword-text', 'verifyEmail-subject', 'verifyEmail-text', 'enrollAccount-subject', 'enrollAccount-text'].forEach((str) => { + const words = str.split('-'); + Accounts.emailTemplates[words[0]][words[1]] = (user, url) => { + return TAPi18n.__(`email-${str}`, { user: user.getName(), url }, user.getLanguage()); + }; + }); +} diff --git a/i18n/ar.i18n.json b/i18n/ar.i18n.json new file mode 100644 index 00000000..0cbbf195 --- /dev/null +++ b/i18n/ar.i18n.json @@ -0,0 +1,229 @@ +{ + "actions": "الإجراءات", + "activities": "الأنشطة", + "activity": "النشاط", + "activity-added": "تمت إضافة %s ل %s", + "activity-archived": "إلى الأرشيف %s", + "activity-attached": "إرفاق %s ل %s", + "activity-created": "أنشأ %s", + "activity-excluded": "استبعاد %s عن %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", + "activity-joined": "انضم %s", + "activity-moved": "تم نقل %s من %s إلى %s", + "activity-on": "على %s", + "activity-removed": "حذف %s إلى %s", + "activity-sent": "إرسال %s إلى %s", + "activity-unjoined": "غادر %s", + "add": "أضف", + "add-attachment": "إرفاق ملف", + "add-board": "إضافة لوحة", + "add-card": "إضافة بطاقة", + "add-cover": "إضافة غلاف", + "add-label": "إضافة علامة", + "add-list": "إضافة قائمة", + "add-members": "تعيين أعضاء", + "added": "أُضيف", + "addMemberPopup-title": "الأعضاء", + "admin": "المدير", + "admin-desc": "إمكانية مشاهدة و تعديل و حذف أعضاء ، و تعديل إعدادات اللوحة أيضا.", + "all-boards": "كل اللوحات", + "and-n-other-card": "And __count__ other بطاقة", + "and-n-other-card_plural": "And __count__ other بطاقات", + "archive": "أرشف", + "archive-all": "أرشف الكل", + "archive-board": "أرشف اللوحة", + "archive-card": "أرشف البطاقة", + "archive-list": "أرشف هذه القائمة", + "archive-selection": "أرشف المُحدّد", + "archiveBoardPopup-title": "غلق اللوحة ?", + "archived-items": "عناصر في الأرشيف", + "archives": "أرشيفات", + "assign-member": "تعيين عضو", + "attached": "أُرفق)", + "attachment": "مرفق", + "attachment-delete-pop": "حذف المرق هو حذف نهائي . لا يمكن التراجع إذا حذف.", + "attachmentDeletePopup-title": "تريد حذف المرفق ?", + "attachments": "المرفقات", + "avatar-too-big": "حجم ملف الصورة الخاصة بك كبير . لا يمكن أن تتجاوز 70 كيلو أكتي", + "back": "رجوع", + "board-change-color": "تغيير اللومr", + "board-nb-stars": "%s نجوم", + "board-not-found": "لوحة مفقودة", + "board-private-info": "سوف تصبح هذه اللوحة <strong>خاصة</strong>", + "board-public-info": "سوف تصبح هذه اللوحة <strong>عامّة</strong>.", + "boardChangeColorPopup-title": "تعديل خلفية الشاشة", + "boardChangeTitlePopup-title": "إعادة تسمية اللوحة", + "boardChangeVisibilityPopup-title": "تعديل وضوح الرؤية", + "boardImportBoardPopup-title": "Import board from Trello", + "boardMenuPopup-title": "قائمة اللوحة", + "boards": "لوحات", + "bucket-example": "مثل « todo list » على سبيل المثال", + "cancel": "إلغاء", + "card-archived": "هذه البطاقة أُرشفت.", + "card-comments-title": "%s تعليقات لهذه البطاقة", + "card-delete-notice": "هذا حذف أبديّ . سوف تفقد كل الإجراءات المنوطة بهذه البطاقة", + "card-delete-pop": "سيتم إزالة جميع الإجراءات من تبعات النشاط، وأنك لن تكون قادرا على إعادة فتح البطاقة. لا يوجد التراجع.", + "card-delete-suggest-archive": "يمكنك أرشفة بطاقة لحذفها من اللوحة والمحافظة على النشاط.", + "card-edit-attachments": "تعديل المرفقات", + "card-edit-labels": "تعديل العلامات", + "card-edit-members": "تعديل الأعضاء", + "card-labels-title": "تعديل علامات البطاقة.", + "card-members-title": "إضافة او حذف أعضاء للبطاقة.", + "cardAttachmentsPopup-title": "إرفاق من", + "cardDeletePopup-title": "حذف البطاقة ?", + "cardDetailsActionsPopup-title": "إجراءات على البطاقة", + "cardLabelsPopup-title": "علامات", + "cardMembersPopup-title": "أعضاء", + "cardMorePopup-title": "المزيد", + "cards": "بطاقات", + "change": "Change", + "change-avatar": "تعديل الصورة الشخصية", + "change-password": "تغيير كلمة المرور", + "change-permissions": "تعديل الصلاحيات", + "changeAvatarPopup-title": "تعديل الصورة الشخصية", + "changeLanguagePopup-title": "تغيير اللغة", + "changePasswordPopup-title": "تغيير كلمة المرور", + "changePermissionsPopup-title": "تعديل الصلاحيات", + "click-to-star": "اضغط لإضافة اللوحة للمفضلة.", + "click-to-unstar": "اضغط لحذف اللوحة من المفضلة.", + "close": "غلق", + "close-board": "غلق اللوحة", + "close-board-pop": "يمكنك إعادة فتح اللوحة بالنقر على عنصر اللوحات من القائمة الفوقية، ثم اختيار -مشاهدة اللوحات المغلقة- ثم ستجد اللوحة و يمكنك إعادة فتحها", + "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-placeholder": "صياغة تعليق", + "computer": "حاسوب", + "create": "إنشاء", + "createBoardPopup-title": "إنشاء لوحة", + "createLabelPopup-title": "إنشاء علامة", + "current": "الحالي", + "default-avatar": "صورة شخصية افتراضية", + "delete": "حذف", + "deleteLabelPopup-title": "حذف العلامة ?", + "description": "وصف", + "disambiguateMultiLabelPopup-title": "تحديد الإجراء على العلامة", + "disambiguateMultiMemberPopup-title": "تحديد الإجراء على العضو", + "discard": "التخلص منها", + "download": "تنزيل", + "edit": "تعديل", + "edit-avatar": "تعديل الصورة الشخصية", + "edit-profile": "تعديل الملف الشخصي", + "editLabelPopup-title": "تعديل العلامة", + "editProfilePopup-title": "تعديل الملف الشخصي", + "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-cards": "تصفية البطاقات", + "filter-clear": "مسح التصفية", + "filter-on": "التصفية تشتغل", + "filter-on-desc": "أنت بصدد تصفية بطاقات هذه اللوحة. اضغط هنا لتعديل التصفية.", + "filter-to-selection": "تصفية بالتحديد", + "fullname": "الإسم الكامل", + "header-logo-title": "الرجوع إلى صفحة اللوحات", + "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": "معلومات", + "initials": "أولية", + "joined": "انضمّ", + "keyboard-shortcuts": "اختصار لوحة المفاتيح", + "label-create": "إنشاء علامة جديدة", + "label-default": "%s علامة (افتراضية)", + "label-delete-pop": "لا يوجد تراجع. سيؤدي هذا إلى إزالة هذه العلامة من جميع بطاقات والقضاء على تأريخها", + "labels": "علامات", + "language": "لغة", + "last-admin-desc": "لا يمكن تعديل الأدوار لأن ذلك يتطلب صلاحيات المدير.", + "leave-board": "مغادرة اللوحة", + "link-card": "ربط هذه البطاقة", + "list-archive-cards": "أرضفت بطاقات هذه القائمة", + "list-archive-cards-pop": "سيقتضي هذا أرشفة جميع بطاقات هذه القائمة. لمشاهدة أرشيف البطاقات أو إعادتها إلى اللوحة، اضغط على -القائمة- ثم - أرشيف العناصر-", + "list-move-cards": "نقل بطاقات هذه القائمة", + "list-select-cards": "تحديد بطاقات هذه القائمة", + "listActionPopup-title": "قائمة الإجراءات", + "listArchiveCardsPopup-title": "أرشفة بطاقات القائمة ?", + "listImportCardPopup-title": "Import a Trello card", + "listMoveCardsPopup-title": "نقل بطاقات القائمة", + "lists": "القائمات", + "log-out": "تسجيل الخروج", + "loginPopup-title": "تسجيل الدخول", + "memberMenuPopup-title": "أفضليات الأعضاء", + "members": "أعضاء", + "menu": "القائمة", + "moveCardPopup-title": "نقل البطاقة", + "multi-selection": "تحديد أكثر من واحدة", + "multi-selection-on": "Multi-Selection is on", + "my-boards": "لوحاتي", + "name": "اسم", + "no-archived-cards": "لا يوجد بطاقة في الأرشيف.", + "no-archived-lists": "لا يوجد قائمة في الأرشيف.", + "no-results": "لا توجد نتائج", + "normal": "عادي", + "normal-desc": "يمكن مشاهدة و تعديل البطاقات. لا يمكن تغيير إعدادات الضبط.", + "optional": "اختياري", + "or": "or", + "page-maybe-private": "قدتكون هذه الصفحة خاصة . قد تستطيع مشاهدتها ب <a href='%s'>تسجيل الدخول</a>.", + "page-not-found": "صفحة غير موجودة", + "password": "كلمة المرور", + "private": "خاص", + "private-desc": "هذه اللوحة خاصة . لا يسمح إلا للأعضاء .", + "profile": "ملف شخصي", + "public": "عامّ", + "public-desc": "هذه اللوحة عامة: مرئية لكلّ من يحصل على الرابط ، و هي مرئية أيضا في محركات البحث مثل جوجل. التعديل مسموح به للأعضاء فقط.", + "quick-access-description": "أضف لوحة إلى المفضلة لإنشاء اختصار في هذا الشريط.", + "remove-cover": "حذف الغلاف", + "remove-from-board": "حذف من اللوحة", + "remove-label": "حذف هذه العلامة", + "remove-member": "حذف العضو", + "remove-member-from-card": "حذف من البطاقة", + "remove-member-pop": "حذف __name__ (__username__) من __boardTitle__ ? سيتم حذف هذا العضو من جميع بطاقة اللوحة مع إرسال إشعار له بذاك.", + "removeMemberPopup-title": "حذف العضو ?", + "rename": "إعادة التسمية", + "rename-board": "إعادة تسمية اللوحة", + "restore": "استعادة", + "save": "حفظ", + "search": "بحث", + "select-color": "اختيار لون", + "shortcut-assign-self": "Assign yourself to current card", + "shortcut-autocomplete-emojies": "الإكمال التلقائي للرموز التعبيرية", + "shortcut-autocomplete-members": "الإكمال التلقائي لأسماء الأعضاء", + "shortcut-clear-filters": "مسح التصفيات", + "shortcut-close-dialog": "غلق النافذة", + "shortcut-filter-my-cards": "تصفية بطاقاتي", + "shortcut-show-shortcuts": "عرض قائمة الإختصارات ،تلك", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "إظهار-إخفاء الشريط الجانبي للوحة", + "signupPopup-title": "إنشاء حساب", + "star-board-title": "اضغط لإضافة هذه اللوحة إلى المفضلة . سوف يتم إظهارها على رأس بقية اللوحات.", + "starred-boards": "اللوحات المفضلة", + "starred-boards-description": "تعرض اللوحات المفضلة على رأس بقية اللوحات.", + "subscribe": "اشتراك و متابعة", + "team": "فريق", + "this-board": "هذه اللوحة", + "this-card": "هذه البطاقة", + "title": "عنوان", + "unassign-member": "إلغاء تعيين العضو", + "unsaved-description": "لديك وصف غير محفوظ", + "upload-avatar": "رفع صورة شخصية", + "uploaded-avatar": "تم رفع الصورة الشخصية", + "username": "اسم المستخدم", + "view-it": "شاهدها", + "warn-list-archived": "انتبه : هذه البطاقة في أرشيف القائمات", + "what-to-do": "ماذا تريد أن تنجز?" +}
\ No newline at end of file diff --git a/i18n/ca.i18n.json b/i18n/ca.i18n.json new file mode 100644 index 00000000..fae76861 --- /dev/null +++ b/i18n/ca.i18n.json @@ -0,0 +1,229 @@ +{ + "actions": "Accions", + "activities": "Activitats", + "activity": "Activitat", + "activity-added": "ha afegit %s a %s", + "activity-archived": "ha arxivat %s", + "activity-attached": "ha adjuntat %s a %s", + "activity-created": "ha creat %s", + "activity-excluded": "ha exclòs %s de %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", + "activity-joined": "s'ha unit a %s", + "activity-moved": "ha mogut %s de %s a %s", + "activity-on": "en %s", + "activity-removed": "ha eliminat %s de %s", + "activity-sent": "ha enviat %s %s", + "activity-unjoined": "unjoined %s", + "add": "Afegeix", + "add-attachment": "Afegeix arxiu adjunt", + "add-board": "Afegeix un nou tauler", + "add-card": "Afegeix fitxa", + "add-cover": "Afegeix coberta", + "add-label": "Afegeix etiqueta", + "add-list": "Afegeix llista", + "add-members": "Afegeix membres", + "added": "Afegit", + "addMemberPopup-title": "Membres", + "admin": "Administrador", + "admin-desc": "Pots veure i editar fitxes, eliminar membres, i canviar la configuració del tauler", + "all-boards": "Tots els taulers", + "and-n-other-card": "And __count__ other card", + "and-n-other-card_plural": "And __count__ other cards", + "archive": "Desa", + "archive-all": "Desa Tot", + "archive-board": "Arxiva tauler", + "archive-card": "Arxiva fitxa", + "archive-list": "Arxiva aquesta llista", + "archive-selection": "Arxiva selecció", + "archiveBoardPopup-title": "Tanca el tauler", + "archived-items": "Elements arxivats", + "archives": "Arxivats", + "assign-member": "Assignar membre", + "attached": "adjuntat", + "attachment": "Adjunt", + "attachment-delete-pop": "L'esborrat d'un arxiu adjunt és permanent. No es pot desfer.", + "attachmentDeletePopup-title": "Esborrar adjunt?", + "attachments": "Adjunts", + "avatar-too-big": "L'avatar és massa gran (70Kb max)", + "back": "Enrere", + "board-change-color": "Canvia el color", + "board-nb-stars": "%s estrelles", + "board-not-found": "No s'ha trobat el tauler", + "board-private-info": "Aquest tauler serà <strong> privat </ strong>.", + "board-public-info": "Aquest tauler serà <strong> públic </ strong>.", + "boardChangeColorPopup-title": "Canvia fons", + "boardChangeTitlePopup-title": "Canvia el nom tauler", + "boardChangeVisibilityPopup-title": "Canvia visibilitat", + "boardImportBoardPopup-title": "Import board from Trello", + "boardMenuPopup-title": "Menú del tauler", + "boards": "Taulers", + "bucket-example": "Igual que “Bucket List”, per exemple", + "cancel": "Cancel·la", + "card-archived": "Aquesta fitxa està arxivada.", + "card-comments-title": "Aquesta fitxa té %s comentaris.", + "card-delete-notice": "L'esborrat és permanent. Perdreu totes les accions associades a aquesta fitxa.", + "card-delete-pop": "Totes les accions s'eliminaran de l'activitat i no podreu tornar a obrir la fitxa. No es pot desfer.", + "card-delete-suggest-archive": "Podeu arxivar una fitxa per extreure-la del tauler i preservar l'activitat.", + "card-edit-attachments": "Edita arxius adjunts", + "card-edit-labels": "Edita etiquetes", + "card-edit-members": "Edita membres", + "card-labels-title": "Canvia les etiquetes de la fitxa", + "card-members-title": "Afegeix o eliminar membres del tauler des de la fitxa.", + "cardAttachmentsPopup-title": "Adjunta des de", + "cardDeletePopup-title": "Esborrar fitxa?", + "cardDetailsActionsPopup-title": "Accions de fitxes", + "cardLabelsPopup-title": "Etiquetes", + "cardMembersPopup-title": "Membres", + "cardMorePopup-title": "Més", + "cards": "Fitxes", + "change": "Change", + "change-avatar": "Canvia Avatar", + "change-password": "Canvia la clau", + "change-permissions": "Canvia permisos", + "changeAvatarPopup-title": "Canvia Avatar", + "changeLanguagePopup-title": "Canvia idioma", + "changePasswordPopup-title": "Canvia la contrasenya", + "changePermissionsPopup-title": "Canvia permisos", + "click-to-star": "Fes clic per destacar aquest tauler.", + "click-to-unstar": "Fes clic per deixar de destacar aquest tauler.", + "close": "Tanca", + "close-board": "Tanca tauler", + "close-board-pop": "Podeu tornar a obrir el tauler fent clic al menú \"Taulers\" de la capçalera, seleccionar \"Veure Taulers Tancats \", cercar el tauler i fer clic a \"Tornar a obrir \".", + "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": "Comentari", + "comment-placeholder": "Escriu un comentari", + "computer": "Ordinador", + "create": "Crea", + "createBoardPopup-title": "Crea tauler", + "createLabelPopup-title": "Crea etiqueta", + "current": "Actual", + "default-avatar": "Avatar per defecte", + "delete": "Esborra", + "deleteLabelPopup-title": "Esborra etiqueta", + "description": "Descripció", + "disambiguateMultiLabelPopup-title": "Desfe l'ambigüitat en les etiquetes", + "disambiguateMultiMemberPopup-title": "Desfe l'ambigüitat en els membres", + "discard": "Descarta", + "download": "Descarrega", + "edit": "Edita", + "edit-avatar": "Canvia Avatar", + "edit-profile": "Edita el teu Perfil", + "editLabelPopup-title": "Canvia etiqueta", + "editProfilePopup-title": "Edita teu Perfil", + "email": "Correu electrònic", + "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": "Filtre", + "filter-cards": "Fitxes de filtre", + "filter-clear": "Elimina filtre", + "filter-on": "Filtra per", + "filter-on-desc": "Estau filtrant fitxes en aquest tauler. Feu clic aquí per editar el filtre.", + "filter-to-selection": "Filtra selecció", + "fullname": "Nom complet", + "header-logo-title": "Torna a la teva pàgina de taulers", + "home": "Inici", + "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": "Informacions", + "initials": "Inicials", + "joined": "s'ha unit", + "keyboard-shortcuts": "Dreceres de teclat", + "label-create": "Crea una etiqueta nova", + "label-default": "%s etiqueta (per defecte)", + "label-delete-pop": "No es pot desfer. Això eliminarà aquesta etiqueta de totes les fitxes i destruirà la seva història.", + "labels": "Etiquetes", + "language": "Idioma", + "last-admin-desc": "No podeu canviar rols perquè ha d'haver-hi almenys un administrador.", + "leave-board": "Abandona tauler", + "link-card": "Enllaç a aquesta fitxa", + "list-archive-cards": "Arxiva totes les fitxes d'aquesta llista", + "list-archive-cards-pop": "Això eliminarà totes les fitxes d'aquesta llista del tauler. Per veure les fitxes arxivades i recuperar-les en el tauler, feu clic a \" Menú \"/ \" Articles Arxivat \".", + "list-move-cards": "Mou totes les fitxes d'aquesta llista", + "list-select-cards": "Selecciona totes les fitxes d'aquesta llista", + "listActionPopup-title": "Accions de la llista", + "listArchiveCardsPopup-title": "Arxivar totes les fitxes d'aquesta llista?", + "listImportCardPopup-title": "Import a Trello card", + "listMoveCardsPopup-title": "Moure totes les fitxes de la llista", + "lists": "Llistes", + "log-out": "Finalitza la sessió", + "loginPopup-title": "Inicia sessió", + "memberMenuPopup-title": "Configura membres", + "members": "Membres", + "menu": "Menú", + "moveCardPopup-title": "Moure fitxa", + "multi-selection": "Multi-Selecció", + "multi-selection-on": "Multi-Selection is on", + "my-boards": "Els meus taulers", + "name": "Nom", + "no-archived-cards": "No hi ha fitxes arxivades.", + "no-archived-lists": "No hi ha llistes arxivades.", + "no-results": "Sense resultats", + "normal": "Normal", + "normal-desc": "Podeu veure i editar fitxes. No podeu canviar la configuració.", + "optional": "opcional", + "or": "or", + "page-maybe-private": "Aquesta pàgina és privada. Per veure-la <a href='%s'> entra </a>.", + "page-not-found": "Pàgina no trobada.", + "password": "Contrasenya", + "private": "Privat", + "private-desc": "Aquest tauler és privat. Només les persones afegides al tauler poden veure´l i editar-lo.", + "profile": "Perfil", + "public": "Públic", + "public-desc": "Aquest tauler és públic. És visible per a qualsevol persona amb l'enllaç i es mostrarà en els motors de cerca com Google. Només persones afegides al tauler poden editar-lo.", + "quick-access-description": "Inicia un tauler per afegir un accés directe en aquest barra", + "remove-cover": "Elimina coberta", + "remove-from-board": "Elimina del tauler", + "remove-label": "Eliminia etiqueta", + "remove-member": "Elimina membre", + "remove-member-from-card": "Elimina de la fitxa", + "remove-member-pop": "Eliminar __name__ (__username__) de __boardTitle__ ? El membre serà eliminat de totes les fitxes d'aquest tauler. Ells rebran una notificació.", + "removeMemberPopup-title": "Vols suprimir el membre?", + "rename": "Canvia el nom", + "rename-board": "Canvia el nom del tauler", + "restore": "Restaura", + "save": "Desa", + "search": "Cerca", + "select-color": "Selecciona un color", + "shortcut-assign-self": "Assign yourself to current card", + "shortcut-autocomplete-emojies": "Autocompleta emojies", + "shortcut-autocomplete-members": "Autocompleta membres", + "shortcut-clear-filters": "Elimina tots els filters", + "shortcut-close-dialog": "Tanca el diàleg", + "shortcut-filter-my-cards": "Filtra les meves fitxes", + "shortcut-show-shortcuts": "Mostra aquesta lista d'accessos directes", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Canvia Sidebar del Tauler", + "signupPopup-title": "Crea un compte", + "star-board-title": "Fes clic per destacar aquest tauler. Es mostrarà a la part superior de la llista de taulers.", + "starred-boards": "Taulers destacats", + "starred-boards-description": "Els taulers destacats es mostraran a la part superior de la llista de taulers.", + "subscribe": "Subscriure", + "team": "Equip", + "this-board": "aquest tauler", + "this-card": "aquesta fitxa", + "title": "Títol", + "unassign-member": "Desassignar membre", + "unsaved-description": "Tens una descripció sense desar.", + "upload-avatar": "Actualitza avatar", + "uploaded-avatar": "Avatar actualitzat", + "username": "Nom d'Usuari", + "view-it": "Vist", + "warn-list-archived": "Avís: aquesta fitxa està en una llista arxivada", + "what-to-do": "Què vols fer?" +}
\ No newline at end of file diff --git a/i18n/de.i18n.json b/i18n/de.i18n.json index 0f017ad0..bb18a335 100644 --- a/i18n/de.i18n.json +++ b/i18n/de.i18n.json @@ -2,17 +2,19 @@ "actions": "Aktionen", "activities": "Aktivitäten", "activity": "Aktivität", - "activity-added": "%s zu %s hinzugefügt", - "activity-archived": "%s archiviert", - "activity-attached": "%s an %s angehängt", - "activity-created": "%s erstellt", - "activity-excluded": "%s von %s ausgeschlossen", - "activity-joined": "%s beigetreten", - "activity-moved": "%s von %s nach %s verschoben", + "activity-added": "hat %s zu %s hinzugefügt", + "activity-archived": "hat %s archiviert", + "activity-attached": "hat %s an %s angehängt", + "activity-created": "hat %s erstellt", + "activity-excluded": "hat %s von %s ausgeschlossen", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", + "activity-joined": "hat %s beigetreten", + "activity-moved": "hat %s von %s nach %s verschoben", "activity-on": "on %s", - "activity-removed": "%s von %s entfernt", - "activity-sent": "%s an %s gesendet", - "activity-unjoined": "%s verlassen", + "activity-removed": "hat %s von %s entfernt", + "activity-sent": "hat %s an %s gesendet", + "activity-unjoined": "unjoined %s", "add": "Hinzufügen", "add-attachment": "Anhang hinzufügen", "add-board": "Neues Board erstellen", @@ -26,8 +28,8 @@ "admin": "Admin", "admin-desc": "Kann Karten anschauen und bearbeiten, Mitglieder entfernen und Boardeinstellungen ändern.", "all-boards": "Alle Boards", - "and-n-other-card": "And __count__ other card", - "and-n-other-card_plural": "And __count__ other cards", + "and-n-other-card": "und eine andere Karte", + "and-n-other-card_plural": "und __count__ andere Karten", "archive": "Archiv", "archive-all": "Alles archivieren", "archive-board": "Board archivieren", @@ -53,27 +55,29 @@ "boardChangeColorPopup-title": "Boardfarbe ändern", "boardChangeTitlePopup-title": "Board umbenennen", "boardChangeVisibilityPopup-title": "Sichtbarkeit ändern", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Boardmenü", "boards": "Boards", - "bucket-example": "Like “Bucket List” for example", + "bucket-example": "Zum Beispiel \"Bucket List\"", "cancel": "Abbrechen", "card-archived": "Diese Karte wurde archiviert.", "card-comments-title": "Diese Karte hat %s Kommentare.", "card-delete-notice": "Löschen ist irreversiebel. Alle Aktionen, die mit dieser Karte zu tun haben, werden ebenfalls gelöscht.", - "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", - "card-delete-suggest-archive": "You can archive a card to remove it from the board and preserve the activity.", + "card-delete-pop": "Alle Aktionen werden vom Aktivitätsfeed entfernt und du kannst die Karte nicht mehr öffnen. Es gibt keine Möglichkeit diese Aktion rückgängig zu machen.", + "card-delete-suggest-archive": "Du kannst die Karte statdessen archivieren, um sie vom Bord zu entfernen und die Aktivitäten zu erhalten.", "card-edit-attachments": "Anhang ändern", "card-edit-labels": "Labels ändern", "card-edit-members": "Nutzer ändern", "card-labels-title": "Label für diese Karte ändern.", "card-members-title": "Füge dem Board Nutzer hinzu oder entferne sie von der Karte.", - "cardAttachmentsPopup-title": "Attach From", + "cardAttachmentsPopup-title": "Anhängen von", "cardDeletePopup-title": "Karte löschen?", "cardDetailsActionsPopup-title": "Kartenaktionen", "cardLabelsPopup-title": "Labels", "cardMembersPopup-title": "Mitglieder", "cardMorePopup-title": "Mehr", "cards": "Karten", + "change": "Change", "change-avatar": "Profilbild ändern", "change-password": "Passwort ändern", "change-permissions": "Ändere Berechtigungen", @@ -86,19 +90,29 @@ "close": "Schließen", "close-board": "Board schließen", "close-board-pop": "Du kannst das Board wiederherstellen, indem du auf den \"Boards\" Menüeintrag im der Kopfleiste klickst, \"Zeige geschlossene Boards an\" auswählst, dein Board suchst und auf \"Wiederherstellen\" klickst.", + "color-green": "grün", + "color-yellow": "gelb", + "color-orange": "orange", + "color-red": "rot", + "color-purple": "lila", + "color-blue": "blau", + "color-sky": "himmelblau", + "color-lime": "hellgrün", + "color-pink": "pink", + "color-black": "schwarz", "comment": "Kommentar", "comment-placeholder": "Kommentar schreiben", "computer": "Computer", "create": "Erstellen", "createBoardPopup-title": "Erstelle ein Board", "createLabelPopup-title": "Label erstellen", - "current": "current", + "current": "aktuell", "default-avatar": "Standard Profilbild", "delete": "Löschen", "deleteLabelPopup-title": "Label löschen?", "description": "Beschreibung", - "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", - "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", + "disambiguateMultiLabelPopup-title": "Labels vereinheitlichen", + "disambiguateMultiMemberPopup-title": "Mitglieder vereinheitlichen", "discard": "Verwerfen", "download": "Download", "edit": "Bearbeiten", @@ -107,15 +121,25 @@ "editLabelPopup-title": "Ändere Label", "editProfilePopup-title": "Profil ändern", "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": "Karten filtern", "filter-clear": "Filter entfernen", "filter-on": "Filter ist aktiv", "filter-on-desc": "Du filterst die Karten auf diesem Board. Klicke hier, um die Filter zu bearbeiten.", - "filter-to-selection": "Filter to selection", + "filter-to-selection": "Ergebnisse auswählen", "fullname": "Voller Name", "header-logo-title": "Zurück zur Board Seite.", "home": "Home", + "import": "Importieren", + "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": "Informationen", "initials": "Initialien", "joined": "beigetreten", @@ -134,6 +158,7 @@ "list-select-cards": "Alle Karten in dieser Liste auswählen", "listActionPopup-title": "Listenaktionen", "listArchiveCardsPopup-title": "Alle Karten in der Liste archivieren?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Verschiebe alle Karten in dieser Liste", "lists": "Listen", "log-out": "Ausloggen", @@ -143,6 +168,7 @@ "menu": "Menü", "moveCardPopup-title": "Karte verschieben", "multi-selection": "Mehrfachauswahl", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Meine Boards", "name": "Name", "no-archived-cards": "Keine archivierten Karten.", @@ -151,6 +177,7 @@ "normal": "Normal", "normal-desc": "Kann Karten anschauen und bearbeiten, aber keine Einstellungen ändern.", "optional": "optional", + "or": "oder", "page-maybe-private": "Diese Seite könnte privat sein. Vielleicht kannst du sie sehen, wenn du dich <a href='%s'>einloggst</a>.", "page-not-found": "Seite nicht gefunden.", "password": "Passwort", @@ -159,7 +186,7 @@ "profile": "Profil", "public": "Öffentlich", "public-desc": "Dieses Board ist öffentlich. Es ist für jeden, der den Link kennt, sichtbar und taucht in Suchmaschinen wie Google auf. Nur Nutzer, die zum Board hinzugefügt wurden, können es bearbeiten.", - "quick-access-description": "Star a board to add a shortcut in this bar.", + "quick-access-description": "Markiere ein Board mit einem Stern um eine Verknüpfung in diese Leise hinzuzufügen.", "remove-cover": "Cover entfernen", "remove-from-board": "Von Board entfernen", "remove-label": "Label entfernen", @@ -173,13 +200,15 @@ "save": "Speichern", "search": "Suchen", "select-color": "Wähle eine Farbe aus", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autovervollständige Emojis", "shortcut-autocomplete-members": "Autovervollständige Nutzer", "shortcut-clear-filters": "Alle Filter entfernen", "shortcut-close-dialog": "Dialog schließen", "shortcut-filter-my-cards": "Meine Karten filtern", - "shortcut-show-shortcuts": "Bring up this shortcuts list", - "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "shortcut-show-shortcuts": "Liste der Tastaturkürzel anzeigen", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Seitenleiste ein-/ausblenden", "signupPopup-title": "Account erstellen", "star-board-title": "Klicke, um das Board mit einem Stern zu kennzeichnen. Es erscheint dann oben in deiner Boardliste.", "starred-boards": "Gekennzeichnete Boards", diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 3486aa93..04c0959f 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "Actions", "activities": "Activities", "activity": "Activity", @@ -7,12 +8,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 +56,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", @@ -74,6 +78,7 @@ "cardMembersPopup-title": "Members", "cardMorePopup-title": "More", "cards": "Cards", + "change": "Change", "change-avatar": "Change Avatar", "change-password": "Change Password", "change-permissions": "Change permissions", @@ -83,9 +88,20 @@ "changePermissionsPopup-title": "Change Permissions", "click-to-star": "Click to star this board.", "click-to-unstar": "Click to unstar this board.", + "clipboard" : "Clipboard or drag & drop", "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", @@ -93,6 +109,7 @@ "createBoardPopup-title": "Create Board", "createLabelPopup-title": "Create Label", "current": "current", + "decline": "Decline", "default-avatar": "Default avatar", "delete": "Delete", "deleteLabelPopup-title": "Delete Label?", @@ -100,6 +117,7 @@ "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "Download", "edit": "Edit", "edit-avatar": "Change Avatar", @@ -108,6 +126,27 @@ "editLabelPopup-title": "Change Label", "editProfilePopup-title": "Edit Profile", "email": "Email", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.\n", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.\n", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.\n", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.\n", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "Filter Cards", "filter-clear": "Clear filter", @@ -117,9 +156,19 @@ "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", + "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", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "Create a new label", "label-default": "%s label (default)", @@ -135,15 +184,19 @@ "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", "loginPopup-title": "Log In", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "Members", "menu": "Menu", "moveCardPopup-title": "Move Card", "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", "my-boards": "My Boards", "name": "Name", "name": "Name", @@ -152,10 +205,16 @@ "no-results": "No results", "normal": "Normal", "normal-desc": "Can view and edit cards. Can't change settings.", + "not-accepted-yet": "Invitation not accepted yet", "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", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "Private", "private-desc": "This board is private. Only people added to the board can view and edit it.", "profile": "Profile", @@ -175,12 +234,14 @@ "save": "Save", "search": "Search", "select-color": "Select a color", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autocomplete emojies", "shortcut-autocomplete-members": "Autocomplete members", "shortcut-clear-filters": "Clear all filters", "shortcut-close-dialog": "Close Dialog", "shortcut-filter-my-cards": "Filter my cards", "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Toggle Board Sidebar", "signupPopup-title": "Create an Account", "star-board-title": "Click to star this board. It will show up at top of your boards list.", @@ -193,6 +254,7 @@ "title": "Title", "unassign-member": "Unassign member", "unsaved-description": "You have an unsaved description.", + "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", "username": "Username", diff --git a/i18n/es.i18n.json b/i18n/es.i18n.json index cd5bef66..466e52cd 100644 --- a/i18n/es.i18n.json +++ b/i18n/es.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "Acciones", "activities": "Activities", "activity": "Actividad", @@ -7,25 +8,27 @@ "activity-attached": "adjuntado %s a %s", "activity-created": "creado %s", "activity-excluded": "excluido %s de %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "se ha unido %s", "activity-moved": "movido %s de %s a %s", "activity-on": "en %s", "activity-removed": "eliminado %s de %s", "activity-sent": "enviado %s a %s", - "activity-unjoined": "ha dejado %s", + "activity-unjoined": "unjoined %s", "add": "Añadir", - "add-attachment": "Add an attachment", + "add-attachment": "Añadir un adjunto", "add-board": "Añadir un nuevo tablero", "add-card": "Add a card", "add-cover": "Añadir cubierta", "add-label": "Add the label", "add-list": "Add a list", - "add-members": "Add Members", + "add-members": "Añadir Miembros", "added": "Añadido", "addMemberPopup-title": "Miembros", "admin": "Administrador", "admin-desc": "Puedes ver y editar fichas, eliminar miembros, y cambiar los ajustes del tablero", - "all-boards": "All boards", + "all-boards": "Tableros", "and-n-other-card": "And __count__ other card", "and-n-other-card_plural": "And __count__ other cards", "archive": "Guardar", @@ -34,7 +37,7 @@ "archive-card": "Archive Card", "archive-list": "Archivar esta lista", "archive-selection": "Archive selection", - "archiveBoardPopup-title": "Cerrar el tablero", + "archiveBoardPopup-title": "¿Cerrar el tablero?", "archived-items": "Items archivados", "archives": "Archives", "assign-member": "Assign member", @@ -45,7 +48,7 @@ "attachments": "Adjuntos", "avatar-too-big": "The avatar is too large (70Kb max)", "back": "Atrás", - "board-change-color": "Change color", + "board-change-color": "Cambiar color", "board-nb-stars": "%s stars", "board-not-found": "Tablero no encontrado", "board-private-info": "This board will be <strong>private</strong>.", @@ -53,6 +56,7 @@ "boardChangeColorPopup-title": "Change Board Background", "boardChangeTitlePopup-title": "Renombrar tablero", "boardChangeVisibilityPopup-title": "Cambiar visibilidad", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Board Menu", "boards": "Tableros", "bucket-example": "Like “Bucket List” for example", @@ -74,32 +78,46 @@ "cardMembersPopup-title": "Miembros", "cardMorePopup-title": "Más", "cards": "Cards", + "change": "Change", "change-avatar": "Cambiar Avatar", "change-password": "Cambiar la clave", - "change-permissions": "Change permissions", + "change-permissions": "Cambiar permisos", "changeAvatarPopup-title": "Cambiar Avatar", "changeLanguagePopup-title": "Cambiar idioma", "changePasswordPopup-title": "Cambiar la clave", "changePermissionsPopup-title": "Cambiar permisos", "click-to-star": "Haz clic para destacar este tablero. ", "click-to-unstar": "Haz clic para dejar de destacar este tablero. ", + "clipboard": "Clipboard or drag & drop", "close": "Cerrar", - "close-board": "Close Board", + "close-board": "Cerrar el tablero", "close-board-pop": "Para reabrir el tablero haz clic en el menú \"Tableros\" de la cabecera, selecciona \"Ver Tableros Cerrados\", busca el tablero y haz clic en \"Reabrir\".", + "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": "Comentario", - "comment-placeholder": "Write a comment", + "comment-placeholder": "Escribe un comentario", "computer": "Ordenador", "create": "Crear", "createBoardPopup-title": "Crear tablero", "createLabelPopup-title": "Crear etiqueta", - "current": "current", - "default-avatar": "Default avatar", + "current": "actual", + "decline": "Decline", + "default-avatar": "Avatar por defecto", "delete": "Borrar", "deleteLabelPopup-title": "Borrar etiqueta", "description": "Descripcion", "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "Descargar", "edit": "Editar", "edit-avatar": "Cambiar Avatar", @@ -107,6 +125,27 @@ "editLabelPopup-title": "Cambiar etiqueta", "editProfilePopup-title": "Edit Profile", "email": "Correo electrónico", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "Fichas de filtro", "filter-clear": "Clear filter", @@ -116,9 +155,19 @@ "fullname": "Nombre Completo", "header-logo-title": "Volver a tu página de tableros", "home": "Inicio", + "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", + "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": "Informaciones", "initials": "Initials", "joined": "se ha unido", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "Crear una etiqueta nueva ", "label-default": "%s etiqueta (por Defecto)", @@ -134,15 +183,19 @@ "list-select-cards": "Select all cards in this list", "listActionPopup-title": "Acciones de la lista", "listArchiveCardsPopup-title": "¿Archivar todas las fichas de esta lista?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Trasladar todas las fichas de la lista", "lists": "Lists", "log-out": "Finalizar la sesion", "loginPopup-title": "Iniciar sesion", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "Miembros", "menu": "Menu", "moveCardPopup-title": "Move Card", "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Mis tableros", "name": "Nombre", "no-archived-cards": "No archived cards.", @@ -150,10 +203,16 @@ "no-results": "Sin resultados", "normal": "Normal", "normal-desc": "Puedes ver y editar fichas. No puedes cambiar la configuración.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "opcional", + "or": "or", "page-maybe-private": "Esta página puede ser privada. Puedes verla por <a href='%s'>logging in</a>.", "page-not-found": "Página no encontrada.", "password": "Clave", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "Privado", "private-desc": "Este tablero es privado. Sólo las personas añadidas al tablero pueden verlo y editarlo.", "profile": "Perfil", @@ -173,12 +232,14 @@ "save": "Guardar", "search": "Buscar", "select-color": "Selecciona un color", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autocomplete emojies", "shortcut-autocomplete-members": "Autocomplete members", "shortcut-clear-filters": "Clear all filters", "shortcut-close-dialog": "Close Dialog", "shortcut-filter-my-cards": "Filter my cards", "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Toggle Board Sidebar", "signupPopup-title": "Crear una Cuenta", "star-board-title": "Haz clic para destacar este tablero. Se mostrará en la parte superior de tu lista de tableros.", @@ -191,6 +252,7 @@ "title": "Título", "unassign-member": "Unassign member", "unsaved-description": "You have an unsaved description.", + "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", "username": "Nombre de Usuario", diff --git a/i18n/fi.i18n.json b/i18n/fi.i18n.json index 2088e98a..1a0cdad5 100644 --- a/i18n/fi.i18n.json +++ b/i18n/fi.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "Toimet", "activities": "Toimet", "activity": "Toiminta", @@ -7,12 +8,14 @@ "activity-attached": "liitetty %s kohteeseen %s", "activity-created": "luotu %s", "activity-excluded": "poistettu %s kohteesta %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "liitytty kohteeseen %s", "activity-moved": "siirretty %s kohteesta %s kohteeseen %s", "activity-on": "kohteessa %s", "activity-removed": "poistettu %s kohteesta %s", "activity-sent": "lähetetty %s kohteeseen %s", - "activity-unjoined": "peruutettu liittyminen kohteeseen %s", + "activity-unjoined": "unjoined %s", "add": "Lisää", "add-attachment": "Lisää liitetiedosto", "add-board": "Lisää uusi taulu", @@ -26,8 +29,8 @@ "admin": "Ylläpitäjä", "admin-desc": "Voi nähfä ja muokata kortteja, poistaa jäseniä, ja muuttaa taulun asetuksia.", "all-boards": "Kaikki taulut", - "and-n-other-card": "And __count__ other card", - "and-n-other-card_plural": "And __count__ other cards", + "and-n-other-card": "Ja __count__ muu kortti", + "and-n-other-card_plural": "Ja __count__ muuta korttia", "archive": "Arkistoi", "archive-all": "Arkistoi kaikki", "archive-board": "Arkistoi taulu", @@ -53,6 +56,7 @@ "boardChangeColorPopup-title": "Vaihda taulun tausta", "boardChangeTitlePopup-title": "Nimeä taulu uudelleen", "boardChangeVisibilityPopup-title": "Vaihda näkyvyyttä", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Taulu valikko", "boards": "Taulut", "bucket-example": "Kuten “Laatikko lista” esimerkiksi", @@ -74,6 +78,7 @@ "cardMembersPopup-title": "Jäsenet", "cardMorePopup-title": "Lisää", "cards": "Kortit", + "change": "Change", "change-avatar": "Vaihda profiilikuva", "change-password": "Vaihda salasana", "change-permissions": "Muuta oikeuksia", @@ -83,9 +88,20 @@ "changePermissionsPopup-title": "Vaihda oikeuksia", "click-to-star": "Klikkaa merkataksesi tämä taulu tähdellä.", "click-to-unstar": "Klikkaa poistaaksesi tähtimerkintä taululta.", + "clipboard": "Clipboard or drag & drop", "close": "Sulje", "close-board": "Sulje taulu", "close-board-pop": "Voit uudelleenavata taulun klikkaamalla “Taulut” valikkoa ylätunnisteesta, valitsemalla “Näytä suljetut taulut”, löytämällä taulu ja klikkaamalla “Uudelleenavaa”.", + "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": "Kommentti", "comment-placeholder": "Kirjoita kommentti", "computer": "Tietokone", @@ -93,6 +109,7 @@ "createBoardPopup-title": "Luo taulu", "createLabelPopup-title": "Luo tunniste", "current": "nykyinen", + "decline": "Decline", "default-avatar": "Oletus profiilikuva", "delete": "Poista", "deleteLabelPopup-title": "Poista tunniste?", @@ -100,6 +117,7 @@ "disambiguateMultiLabelPopup-title": "Yksikäsitteistä tunniste toiminta", "disambiguateMultiMemberPopup-title": "Yksikäsitteistä jäsen toiminta", "discard": "Hylkää", + "done": "Done", "download": "Lataa", "edit": "Muokkaa", "edit-avatar": "Vaihda profiilikuva", @@ -107,6 +125,27 @@ "editLabelPopup-title": "Vaihda tunniste", "editProfilePopup-title": "Muokkaa profiilia", "email": "Sähköposti", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Suodata", "filter-cards": "Suodata kortit", "filter-clear": "Poista suodatin", @@ -116,9 +155,19 @@ "fullname": "Koko nimi", "header-logo-title": "Palaa taulut sivullesi.", "home": "Koti", + "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", + "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": "Tietoja", "initials": "Nimikirjaimet", "joined": "liittyi", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Pikanäppäimet", "label-create": "Luo uusi tunniste", "label-default": "%s tunniste (oletus)", @@ -134,15 +183,19 @@ "list-select-cards": "Valitse kaikki kortit tässä listassa", "listActionPopup-title": "Listaa toimet", "listArchiveCardsPopup-title": "Arkistoi kaikki kortit tässä listassa?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Siirrä kaikki listan kortit", "lists": "Listat", "log-out": "Kirjaudu ulos", "loginPopup-title": "Kirjaudu sisään", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Jäsen asetukset", "members": "Jäsenet", "menu": "Valikko", "moveCardPopup-title": "Siirrä kortti", "multi-selection": "Monivalinta", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Tauluni", "name": "Nimi", "no-archived-cards": "Ei arkistoituja kortteja.", @@ -150,10 +203,16 @@ "no-results": "Ei tuloksia", "normal": "Normaali", "normal-desc": "Voi nähdä ja muokata kortteja. Ei voi muokata asetuksia.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "valinnainen", + "or": "or", "page-maybe-private": "Tämä sivu voi olla yksityinen. Voit ehkä pystyä näkemään sen <a href='%s'>kirjautumalla sisään</a>.", "page-not-found": "Sivua ei löytynyt.", "password": "Salasana", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "Yksityinen", "private-desc": "Tämä taulu on yksityinen. Vain taululle lisätyt henkilöt voivat nähdä ja muokata sitä.", "profile": "Profiili", @@ -173,13 +232,15 @@ "save": "Tallenna", "search": "Etsi", "select-color": "Valitse väri", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Automaattinen täydennys emojille", "shortcut-autocomplete-members": "Automaattinen täydennys jäsenille", "shortcut-clear-filters": "Poista kaikki suodattimet", "shortcut-close-dialog": "Sulje valintaikkuna", "shortcut-filter-my-cards": "Suodata korttini", "shortcut-show-shortcuts": "Tuo esiin tämä pikavalinta lista", - "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Vaihda taulu sivupalkin näkyvyys", "signupPopup-title": "Luo tili", "star-board-title": "Klikkaa merkataksesi taulu tähdellä. Se tulee näkymään ylimpänä taululistallasi.", "starred-boards": "Tähdellä merkatut taulut", @@ -191,6 +252,7 @@ "title": "Otsikko", "unassign-member": "Peru jäsenvalinta", "unsaved-description": "Sinulla on tallentamaton kuvaus.", + "upload": "Upload", "upload-avatar": "Lähetä profiilikuva", "uploaded-avatar": "Profiilikuva lähetetty", "username": "Käyttäjänimi", diff --git a/i18n/fr.i18n.json b/i18n/fr.i18n.json index a63fb0ce..eabd58d2 100644 --- a/i18n/fr.i18n.json +++ b/i18n/fr.i18n.json @@ -7,6 +7,8 @@ "activity-attached": "a attaché %s à %s", "activity-created": "a créé %s", "activity-excluded": "a exclu %s de %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "a rejoint %s", "activity-moved": "a déplacé %s depuis %s vers %s", "activity-on": "sur %s", @@ -33,7 +35,7 @@ "archive-board": "Archiver le tableau", "archive-card": "Archiver la carte", "archive-list": "Archiver cette liste", - "archive-selection": "Archiver la selection ", + "archive-selection": "Archiver la selection", "archiveBoardPopup-title": "Fermer le tableau ?", "archived-items": "Éléments archivés", "archives": "Archives", @@ -53,6 +55,7 @@ "boardChangeColorPopup-title": "Change la fond du tableau", "boardChangeTitlePopup-title": "Renommer le tableau", "boardChangeVisibilityPopup-title": "Changer la visibilité", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Menu du tableau", "boards": "Tableaux", "bucket-example": "Comme « todo list » par exemple", @@ -74,6 +77,7 @@ "cardMembersPopup-title": "Membres", "cardMorePopup-title": "Plus", "cards": "Cartes", + "change": "Change", "change-avatar": "Changer l'avatar", "change-password": "Changer le mot de passe", "change-permissions": "Changer les permissions", @@ -86,6 +90,16 @@ "close": "Fermer", "close-board": "Fermer le tableau", "close-board-pop": "Vous pouvez ré-ouvrir le tableau en cliquant sur le menu « Tableau » dans la barre d'en-tête, puis en sélection « Voir les tableaux fermés », en trouvant le tableau désiré puis en cliquant sur « Ré-ouvrir ».", + "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": "Commentaire", "comment-placeholder": "Rédiger un commentaire", "computer": "Ordinateur", @@ -107,6 +121,10 @@ "editLabelPopup-title": "Changer l'étiquette", "editProfilePopup-title": "Éditer le profil", "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": "Filtrer", "filter-cards": "Filtrer les cartes", "filter-clear": "Retirer les filtres", @@ -116,6 +134,12 @@ "fullname": "Nom complet", "header-logo-title": "Retourner à la page des tableaux", "home": "Accueil", + "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": "Initiales", "joined": "a joint", @@ -124,7 +148,7 @@ "label-default": "%s label (default)", "label-delete-pop": "Cette action est irréversible. Elle supprimera cette étiquette de toutes les cartes ainsi que l'historique associé.", "labels": "Étiquettes", - "language": "Langage ", + "language": "Langage", "last-admin-desc": "Vous ne pouvez pas changer les rôles car il doit y avoir au moins un admin.", "leave-board": "Quitter le tableau", "link-card": "Lier cette carte", @@ -134,6 +158,7 @@ "list-select-cards": "Sélectionner les cartes de cette liste", "listActionPopup-title": "Liste des actions", "listArchiveCardsPopup-title": "Archiver les cartes de la liste ?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Déplacer les cartes de la liste", "lists": "Listes", "log-out": "Déconnexion", @@ -143,6 +168,7 @@ "menu": "Menu", "moveCardPopup-title": "Déplacer la carte", "multi-selection": "Sélection multiple", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Mes tableaux", "name": "Nom", "no-archived-cards": "Pas de carte archivée.", @@ -151,6 +177,7 @@ "normal": "Normal", "normal-desc": "Peut voir et éditer les cartes. Ne peut pas changer les paramètres.", "optional": "optionnel", + "or": "or", "page-maybe-private": "Cette page est peut-être privée. Vous pourrez peut-être la voir en vous <a href='%s'>connectant</a>.", "page-not-found": "Page non trouvée", "password": "Mot de passe", @@ -173,12 +200,14 @@ "save": "Sauvegarder", "search": "Chercher", "select-color": "Choisissez une couleur", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Auto-complétion des emojies", "shortcut-autocomplete-members": "Auto-complétion des membres", "shortcut-clear-filters": "Retirer tous les filtres", "shortcut-close-dialog": "Fermer le dialogue", "shortcut-filter-my-cards": "Filtrer mes cartes", "shortcut-show-shortcuts": "Afficher cette liste de raccourcis", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Afficher/Cacher la barre latérale du tableau", "signupPopup-title": "Créer un compe", "star-board-title": "Cliquer pour ajouter ce tableau aux favoris. Il sera affiché en haut de votre liste de tableaux.", diff --git a/i18n/it.i18n.json b/i18n/it.i18n.json new file mode 100644 index 00000000..1291e0df --- /dev/null +++ b/i18n/it.i18n.json @@ -0,0 +1,229 @@ +{ + "actions": "Azioni", + "activities": "Attività", + "activity": "Attività", + "activity-added": "aggiunto %s a %s", + "activity-archived": "archiviato %s", + "activity-attached": "allegato %s a %s", + "activity-created": "creato %s", + "activity-excluded": "escluso %s da %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", + "activity-joined": "è stato unito %s", + "activity-moved": "spostato %s da %s a %s", + "activity-on": "su %s", + "activity-removed": "rimosso %s da %s", + "activity-sent": "inviato %s a %s", + "activity-unjoined": "unjoined %s", + "add": "Aggiungere", + "add-attachment": "Aggiungi allegato", + "add-board": "Aggiungi una nuova bachecha", + "add-card": "Aggiungi una scheda", + "add-cover": "Aggiungi copertina", + "add-label": "Aggiungi l'etichetta", + "add-list": "Aggiungi una lista", + "add-members": "Aggiungi membri", + "added": "Aggiunto", + "addMemberPopup-title": "Membri", + "admin": "Amministratore", + "admin-desc": "Può vedere e modificare schede, rimuovere membri e modificare le impostazioni della bacheca.", + "all-boards": "Tutte le bacheche", + "and-n-other-card": "E __count__ altra scheda", + "and-n-other-card_plural": "E __count__ altre schede", + "archive": "Archivia", + "archive-all": "Archivia tutto", + "archive-board": "Archivia bacheca", + "archive-card": "Archivia scheda", + "archive-list": "Archivia questa lista", + "archive-selection": "Archivia selezione", + "archiveBoardPopup-title": "Chiudere la bacheca?", + "archived-items": "Elementi archiviati", + "archives": "Archives", + "assign-member": "Assegna membri", + "attached": "allegato", + "attachment": "Allegato", + "attachment-delete-pop": "L'eliminazione di un allegato è permanente. Non è possibile annullare.", + "attachmentDeletePopup-title": "Eliminare l'allegato?", + "attachments": "Allegati", + "avatar-too-big": "L'avatar è troppo grande (max 70Kb)", + "back": "Indietro", + "board-change-color": "Cambia colore", + "board-nb-stars": "%s stelle", + "board-not-found": "Bacheca non trovata", + "board-private-info": "Questa bacheca sarà <strong>privata</strong>.", + "board-public-info": "Questa bacheca sarà <strong>pubblica</strong>.", + "boardChangeColorPopup-title": "Cambia sfondo della bacheca", + "boardChangeTitlePopup-title": "Rinomina bacheca", + "boardChangeVisibilityPopup-title": "Cambia visibilità", + "boardImportBoardPopup-title": "Import board from Trello", + "boardMenuPopup-title": "Menu bacheca", + "boards": "Bacheche", + "bucket-example": "Like “Bucket List” for example", + "cancel": "Cancella", + "card-archived": "Questa scheda è archiviata.", + "card-comments-title": "Questa scheda ha %s commenti.", + "card-delete-notice": "L'eliminazione è permanente. Tutte le azioni associate a questa scheda andranno perse.", + "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", + "card-delete-suggest-archive": "You can archive a card to remove it from the board and preserve the activity.", + "card-edit-attachments": "Edit attachments", + "card-edit-labels": "Edit labels", + "card-edit-members": "Edit members", + "card-labels-title": "Change the labels for the card.", + "card-members-title": "Add or remove members of the board from the card.", + "cardAttachmentsPopup-title": "Attach From", + "cardDeletePopup-title": "Delete Card?", + "cardDetailsActionsPopup-title": "Card Actions", + "cardLabelsPopup-title": "Labels", + "cardMembersPopup-title": "Membr", + "cardMorePopup-title": "More", + "cards": "Cards", + "change": "Change", + "change-avatar": "Change Avatar", + "change-password": "Change Password", + "change-permissions": "Change permissions", + "changeAvatarPopup-title": "Change Avatar", + "changeLanguagePopup-title": "Change Language", + "changePasswordPopup-title": "Change Password", + "changePermissionsPopup-title": "Change Permissions", + "click-to-star": "Click to star this board.", + "click-to-unstar": "Click to unstar this board.", + "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", + "create": "Create", + "createBoardPopup-title": "Create Board", + "createLabelPopup-title": "Create Label", + "current": "current", + "default-avatar": "Default avatar", + "delete": "Delete", + "deleteLabelPopup-title": "Delete Label?", + "description": "Description", + "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", + "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", + "discard": "Discard", + "download": "Download", + "edit": "Edit", + "edit-avatar": "Change Avatar", + "edit-profile": "Edit Profile", + "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", + "filter-on": "Filter is on", + "filter-on-desc": "You are filtering cards on this board. Click here to edit filter.", + "filter-to-selection": "Filter to selection", + "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", + "keyboard-shortcuts": "Keyboard shortcuts", + "label-create": "Create a new label", + "label-default": "%s label (default)", + "label-delete-pop": "There is no undo. This will remove this label from all cards and destroy its history.", + "labels": "Labels", + "language": "Language", + "last-admin-desc": "You can’t change roles because there must be at least one admin.", + "leave-board": "Leave Board", + "link-card": "Link to this card", + "list-archive-cards": "Archive all cards in this list", + "list-archive-cards-pop": "This will remove all the cards in this list from the board. To view archived cards and bring them back to the board, click “Menu” > “Archived Items”.", + "list-move-cards": "Move all cards in this list", + "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", + "loginPopup-title": "Log In", + "memberMenuPopup-title": "Member Settings", + "members": "Membr", + "menu": "Menu", + "moveCardPopup-title": "Move Card", + "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", + "my-boards": "My Boards", + "name": "Name", + "no-archived-cards": "No archived cards.", + "no-archived-lists": "No archived lists.", + "no-results": "No results", + "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", + "private": "Private", + "private-desc": "This board is private. Only people added to the board can view and edit it.", + "profile": "Profile", + "public": "Public", + "public-desc": "This board is public. It's visible to anyone with the link and will show up in search engines like Google. Only people added to the board can edit.", + "quick-access-description": "Star a board to add a shortcut in this bar.", + "remove-cover": "Remove Cover", + "remove-from-board": "Remove from Board", + "remove-label": "Remove the label", + "remove-member": "Remove Member", + "remove-member-from-card": "Remove from Card", + "remove-member-pop": "Remove __name__ (__username__) from __boardTitle__? The member will be removed from all cards on this board. They will receive a notification.", + "removeMemberPopup-title": "Remove Member?", + "rename": "Rename", + "rename-board": "Rinomina bacheca", + "restore": "Restore", + "save": "Save", + "search": "Search", + "select-color": "Select a color", + "shortcut-assign-self": "Assign yourself to current card", + "shortcut-autocomplete-emojies": "Autocomplete emojies", + "shortcut-autocomplete-members": "Autocomplete members", + "shortcut-clear-filters": "Clear all filters", + "shortcut-close-dialog": "Close Dialog", + "shortcut-filter-my-cards": "Filter my cards", + "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "signupPopup-title": "Create an Account", + "star-board-title": "Click to star this board. It will show up at top of your boards list.", + "starred-boards": "Starred Boards", + "starred-boards-description": "Starred boards show up at the top of your boards list.", + "subscribe": "Subscribe", + "team": "Team", + "this-board": "this board", + "this-card": "this card", + "title": "Title", + "unassign-member": "Unassign member", + "unsaved-description": "You have an unsaved description.", + "upload-avatar": "Upload an avatar", + "uploaded-avatar": "Uploaded an avatar", + "username": "Username", + "view-it": "View it", + "warn-list-archived": "warning: this card is in an archived list", + "what-to-do": "What do you want to do?" +}
\ No newline at end of file diff --git a/i18n/ja.i18n.json b/i18n/ja.i18n.json index a6594f41..c346adfa 100644 --- a/i18n/ja.i18n.json +++ b/i18n/ja.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "操作", "activities": "Activities", "activity": "アクティビティ", @@ -7,12 +8,14 @@ "activity-attached": "%s を %s に添付しました", "activity-created": "%s を作成しました", "activity-excluded": "%s を %s から除外しました", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "%s にジョインしました", "activity-moved": "%s を %s から %s に移動しました", "activity-on": "%s", "activity-removed": "%s を %s から削除しました", "activity-sent": "%s を %s に送りました", - "activity-unjoined": "%s から脱退しました", + "activity-unjoined": "unjoined %s", "add": "追加", "add-attachment": "Add an attachment", "add-board": "ボード追加", @@ -53,6 +56,7 @@ "boardChangeColorPopup-title": "Change Board Background", "boardChangeTitlePopup-title": "ボード名の変更", "boardChangeVisibilityPopup-title": "公開範囲の変更", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Board Menu", "boards": "ボード", "bucket-example": "Like “Bucket List” for example", @@ -74,6 +78,7 @@ "cardMembersPopup-title": "メンバー", "cardMorePopup-title": "さらに見る", "cards": "Cards", + "change": "Change", "change-avatar": "アバターの変更", "change-password": "パスワードの変更", "change-permissions": "Change permissions", @@ -83,9 +88,20 @@ "changePermissionsPopup-title": "パーミッションの変更", "click-to-star": "ボードにスターをつける", "click-to-unstar": "ボードからスターを外す", + "clipboard": "Clipboard or drag & drop", "close": "閉じる", "close-board": "Close Board", "close-board-pop": "ヘッダーの\"ボード\"メニューから\"閉じたボードを見る\"を選択し、そこでボードを選択して、\"ボードの再開\"をクリックすると、ボードを再度利用できるようになります。", + "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-placeholder": "Write a comment", "computer": "コンピューター", @@ -93,6 +109,7 @@ "createBoardPopup-title": "ボードの作成", "createLabelPopup-title": "ラベルの作成", "current": "current", + "decline": "Decline", "default-avatar": "Default avatar", "delete": "削除", "deleteLabelPopup-title": "ラベルを削除しますか?", @@ -100,6 +117,7 @@ "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "ダウンロード", "edit": "編集", "edit-avatar": "アバターの変更", @@ -107,6 +125,27 @@ "editLabelPopup-title": "ラベルの変更", "editProfilePopup-title": "Edit Profile", "email": "メールアドレス", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "カードをフィルターする", "filter-clear": "Clear filter", @@ -116,9 +155,19 @@ "fullname": "フルネーム", "header-logo-title": "自分のボードページに戻る。", "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", + "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": "情報", "initials": "Initials", "joined": "参加しました", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "ラベル作成", "label-default": "%s ラベル(デフォルト)", @@ -134,15 +183,19 @@ "list-select-cards": "Select all cards in this list", "listActionPopup-title": "操作一覧", "listArchiveCardsPopup-title": "このリスト内の善カードをアーカイブしますか?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "リスト内のすべてのカードを移動する", "lists": "Lists", "log-out": "ログアウト", "loginPopup-title": "ログイン", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "メンバー", "menu": "メニュー", "moveCardPopup-title": "Move Card", "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", "my-boards": "自分のボード", "name": "名前", "no-archived-cards": "No archived cards.", @@ -150,10 +203,16 @@ "no-results": "該当するものはありません", "normal": "通常", "normal-desc": "カードの閲覧と編集が可能。設定変更不可。", + "not-accepted-yet": "Invitation not accepted yet", "optional": "任意", + "or": "or", "page-maybe-private": "このページはプライベートです。<a href='%s'>ログイン</a>して見てください。", "page-not-found": "ページが見つかりません。", "password": "パスワード", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "プライベート", "private-desc": "このボードはプライベートです。ボードメンバーのみが閲覧・編集可能です。", "profile": "プロフィール", @@ -173,12 +232,14 @@ "save": "保存", "search": "検索", "select-color": "色を選択", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autocomplete emojies", "shortcut-autocomplete-members": "Autocomplete members", "shortcut-clear-filters": "Clear all filters", "shortcut-close-dialog": "Close Dialog", "shortcut-filter-my-cards": "Filter my cards", "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Toggle Board Sidebar", "signupPopup-title": "アカウント作成", "star-board-title": "ボードにスターをつけると自分のボード一覧のトップに表示されます。", @@ -191,6 +252,7 @@ "title": "タイトル", "unassign-member": "Unassign member", "unsaved-description": "You have an unsaved description.", + "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", "username": "ユーザー名", diff --git a/i18n/ko.i18n.json b/i18n/ko.i18n.json index c43dd0e6..3baf3096 100644 --- a/i18n/ko.i18n.json +++ b/i18n/ko.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "동작", "activities": "Activities", "activity": "활동 상태", @@ -7,12 +8,14 @@ "activity-attached": "%s를 %s에 첨부함", "activity-created": "%s 생성됨", "activity-excluded": "%s를 %s에서 제외함", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "%s에 참여", "activity-moved": "%s를 %s에서 %s로 옮김", "activity-on": "%s에", "activity-removed": "%s를 %s에서 삭제함", "activity-sent": "%s를 %s로 보냄", - "activity-unjoined": "%s에 참여할 수 없음", + "activity-unjoined": "unjoined %s", "add": "추가", "add-attachment": "Add an attachment", "add-board": "새로운 보드를 추가", @@ -53,6 +56,7 @@ "boardChangeColorPopup-title": "Change Board Background", "boardChangeTitlePopup-title": "보드 이름 바꾸기", "boardChangeVisibilityPopup-title": "표시 여부 변경", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Board Menu", "boards": "보드", "bucket-example": "Like “Bucket List” for example", @@ -74,6 +78,7 @@ "cardMembersPopup-title": "멤버", "cardMorePopup-title": "더보기", "cards": "Cards", + "change": "Change", "change-avatar": "아바타 변경", "change-password": "암호 변경", "change-permissions": "Change permissions", @@ -83,9 +88,20 @@ "changePermissionsPopup-title": "권한 변경", "click-to-star": "보드 별 추가.", "click-to-unstar": "보드 별 삭제.", + "clipboard": "Clipboard or drag & drop", "close": "닫기", "close-board": "Close Board", "close-board-pop": "보드를 다시 열 수 있습니다. 상단 \"보드\" 메뉴를 클릭해 \"닫힌 보드 보기\"를 선택하여, 보드를 찾아 \"다시 열기\" 버튼을 클릭합니다.", + "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-placeholder": "Write a comment", "computer": "내 컴퓨터", @@ -93,6 +109,7 @@ "createBoardPopup-title": "보드 생성", "createLabelPopup-title": "라벨 생성", "current": "current", + "decline": "Decline", "default-avatar": "Default avatar", "delete": "삭제", "deleteLabelPopup-title": "라벨을 삭제합니까?", @@ -100,6 +117,7 @@ "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "다운로드", "edit": "수정", "edit-avatar": "아바타 변경", @@ -107,6 +125,27 @@ "editLabelPopup-title": "라벨 변경", "editProfilePopup-title": "Edit Profile", "email": "이메일", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "카드 필터", "filter-clear": "Clear filter", @@ -116,9 +155,19 @@ "fullname": "전체 이름", "header-logo-title": "보드 페이지로 돌아가기.", "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", + "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": "정보", "initials": "Initials", "joined": "참가함", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "새로운 라벨 생성", "label-default": "%s 라벨 (기본)", @@ -134,15 +183,19 @@ "list-select-cards": "Select all cards in this list", "listActionPopup-title": "동작 목록", "listArchiveCardsPopup-title": "목록에서 모든 카드를 보관합니까?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "목록에서 모든 카드 이동", "lists": "Lists", "log-out": "로그아웃", "loginPopup-title": "로그인", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "멤버", "menu": "메뉴", "moveCardPopup-title": "Move Card", "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", "my-boards": "내 보드", "name": "이름", "no-archived-cards": "No archived cards.", @@ -150,10 +203,16 @@ "no-results": "결과 값 없음", "normal": "표준", "normal-desc": "카드를 보거나 수정할 수 있습니다. 설정값은 변경할 수 없습니다.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "옴션", + "or": "or", "page-maybe-private": "이 페이지를 비공개일 수 있습니다. 이것을 보고 싶으면 <a href='%s'>로그인</a>을 하십시오.", "page-not-found": "페이지를 찾지 못 했습니다", "password": "암호", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "비공개", "private-desc": "비공개된 보드입니다. 오직 보드에 추가된 사람들만 보고 수정할 수 있습니다", "profile": "프로파일", @@ -173,12 +232,14 @@ "save": "저장", "search": "검색", "select-color": "색 선택", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autocomplete emojies", "shortcut-autocomplete-members": "Autocomplete members", "shortcut-clear-filters": "Clear all filters", "shortcut-close-dialog": "Close Dialog", "shortcut-filter-my-cards": "Filter my cards", "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Toggle Board Sidebar", "signupPopup-title": "계정 생성", "star-board-title": "보드에 별을 클릭합니다. 보드 목록에서 최상위로 둘 수 있습니다.", @@ -191,6 +252,7 @@ "title": "제목", "unassign-member": "Unassign member", "unsaved-description": "You have an unsaved description.", + "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", "username": "사용자 이름", diff --git a/i18n/pt-BR.i18n.json b/i18n/pt-BR.i18n.json index 381dc9b5..0a9e46e1 100644 --- a/i18n/pt-BR.i18n.json +++ b/i18n/pt-BR.i18n.json @@ -1,4 +1,5 @@ { + "accept": "Accept", "actions": "Ações", "activities": "Atividades", "activity": "Atividade", @@ -7,12 +8,14 @@ "activity-attached": "anexou %s a %s", "activity-created": "criou %s", "activity-excluded": "excluiu %s de %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "juntou-se a %s", "activity-moved": "moveu %s de %s para %s", "activity-on": "em %s", "activity-removed": "removeu %s de %s", "activity-sent": "enviou %s de %s", - "activity-unjoined": "deixou %s", + "activity-unjoined": "unjoined %s", "add": "Novo", "add-attachment": "Adicionar um anexo", "add-board": "Criar um quadro novo", @@ -26,8 +29,8 @@ "admin": "Administrador", "admin-desc": "Pode ver e editar cartões, remover membros e alterar configurações do quadro.", "all-boards": "Todos os quadros", - "and-n-other-card": "And __count__ other card", - "and-n-other-card_plural": "And __count__ other cards", + "and-n-other-card": "E __count__ outro cartão", + "and-n-other-card_plural": "E __count__ outros cartões", "archive": "Arquivar", "archive-all": "Arquivar Tudo", "archive-board": "Arquivar Quadro", @@ -43,25 +46,26 @@ "attachment-delete-pop": "Excluir um anexo é permanente. Não será possível recuperá-lo.", "attachmentDeletePopup-title": "Excluir Anexo?", "attachments": "Anexos", - "avatar-too-big": "The avatar is too large (70Kb max)", + "avatar-too-big": "Imagem de avatar muito grande (máx 70KB)", "back": "Voltar", "board-change-color": "Alterar cor", - "board-nb-stars": "%s stars", + "board-nb-stars": "%s estrelas", "board-not-found": "Quadro não encontrado", "board-private-info": "Este quadro será <strong>privado</strong>.", "board-public-info": "Este quadro será <strong>público</strong>.", "boardChangeColorPopup-title": "Alterar Tela de Fundo", "boardChangeTitlePopup-title": "Renomear Quadro", "boardChangeVisibilityPopup-title": "Alterar Visibilidade", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Menu do Quadro", "boards": "Quadros", - "bucket-example": "Like “Bucket List” for example", + "bucket-example": "\"Bucket List\", por exemplo", "cancel": "Cancelar", "card-archived": "Este cartão está arquivado.", "card-comments-title": "Este cartão possui %s comentários.", "card-delete-notice": "A exclusão será permanente. Você perderá todas as ações associadas a este cartão.", - "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", - "card-delete-suggest-archive": "You can archive a card to remove it from the board and preserve the activity.", + "card-delete-pop": "Todas as ações serão removidas da lista de Atividades e vocês não poderá re-abrir o cartão. Não há como desfazer.", + "card-delete-suggest-archive": "Você pode arquivar um cartão para removê-lo do quadro e preservar suas atividades.", "card-edit-attachments": "Editar anexos", "card-edit-labels": "Editar etiquetas", "card-edit-members": "Editar membros", @@ -69,11 +73,12 @@ "card-members-title": "Acrescentar ou remover membros do quadro deste cartão.", "cardAttachmentsPopup-title": "Anexar a partir de", "cardDeletePopup-title": "Excluir Cartão?", - "cardDetailsActionsPopup-title": "Card Actions", + "cardDetailsActionsPopup-title": "Ações do cartão", "cardLabelsPopup-title": "Etiquetas", "cardMembersPopup-title": "Membros", "cardMorePopup-title": "Mais", "cards": "Cartões", + "change": "Change", "change-avatar": "Alterar Avatar", "change-password": "Alterar Senha", "change-permissions": "Alterar permissões", @@ -83,23 +88,36 @@ "changePermissionsPopup-title": "Alterar Permissões", "click-to-star": "Marcar quadro como favorito.", "click-to-unstar": "Remover quadro dos favoritos.", + "clipboard": "Clipboard or drag & drop", "close": "Fechar", "close-board": "Fechar Quadro", "close-board-pop": "Você pode reabrir um quadro clicando em “Quadros” no menu no cabeçalho, selecionando “Exibir Quadros Fechados”, encontrando-o e clicando em “Reabrir”.", + "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": "Comentário", "comment-placeholder": "Escrever um comentário", "computer": "Computador", "create": "Criar", "createBoardPopup-title": "Criar Quadro", "createLabelPopup-title": "Criar Etiqueta", - "current": "current", - "default-avatar": "Default avatar", + "current": "atual", + "decline": "Decline", + "default-avatar": "Avatar padrão", "delete": "Excluir", "deleteLabelPopup-title": "Excluir Etiqueta?", "description": "Descrição", - "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", - "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", - "discard": "Discard", + "disambiguateMultiLabelPopup-title": "Desambiguar ações de etiquetas", + "disambiguateMultiMemberPopup-title": "Desambiguar ações de membros", + "discard": "Descartar", + "done": "Done", "download": "Baixar", "edit": "Editar", "edit-avatar": "Alterar Avatar", @@ -107,19 +125,50 @@ "editLabelPopup-title": "Alterar Etiqueta", "editProfilePopup-title": "Editar Perfil", "email": "E-mail", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filtrar", "filter-cards": "Filtrar Cartões", "filter-clear": "Limpar filtro", "filter-on": "Filtro está ativo", "filter-on-desc": "Você está filtrando cartões neste quadro. Clique aqui para editar o filtro.", - "filter-to-selection": "Filter to selection", + "filter-to-selection": "Filtrar esta seleção", "fullname": "Nome Completo", "header-logo-title": "Voltar para a lista de quadros.", "home": "Início", + "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", + "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": "Informações", "initials": "Iniciais", "joined": "juntou-se", - "keyboard-shortcuts": "Keyboard shortcuts", + "just-invited": "You are just invited to this board", + "keyboard-shortcuts": "Atalhos do teclado", "label-create": "Criar uma nova etiqueta", "label-default": "%s etiqueta (padrão)", "label-delete-pop": "Não será possível recuperá-la. A etiqueta será removida de todos os cartões e seu histórico será destruído.", @@ -128,41 +177,51 @@ "last-admin-desc": "Você não pode alterar funções porque deve existir pelo menos um administrador.", "leave-board": "Sair do Quadro", "link-card": "Vincular a este cartão", - "list-archive-cards": "Archive all cards in this list", + "list-archive-cards": "Arquivar todos os cartões nesta lista", "list-archive-cards-pop": "Isto removerá todos os cartões desta lista do quadro. Para visualizar os cartões arquivados e trazê-los de volta para o quadro, clique em “Menu” > “Itens Arquivados”.", - "list-move-cards": "Move all cards in this list", - "list-select-cards": "Select all cards in this list", + "list-move-cards": "Mover todos os cartões desta lista", + "list-select-cards": "Selecionar todos os cartões nesta lista", "listActionPopup-title": "Listar Ações", "listArchiveCardsPopup-title": "Arquivar Todos Os Cartões Nesta Lista?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Mover Todos Os Cartões Nesta Lista", "lists": "Listas", "log-out": "Sair", "loginPopup-title": "Entrar", - "memberMenuPopup-title": "Member Settings", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", + "memberMenuPopup-title": "Configuração de Membros", "members": "Membros", "menu": "Menu", "moveCardPopup-title": "Mover Cartão", - "multi-selection": "Multi-Selection", + "multi-selection": "Multi-Seleção", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Meus Quadros", "name": "Nome", - "no-archived-cards": "No archived cards.", - "no-archived-lists": "No archived lists.", + "no-archived-cards": "Nenhum cartão arquivado", + "no-archived-lists": "Sem listas arquivadas", "no-results": "Nenhum resultado.", "normal": "Normal", "normal-desc": "Pode ver e editar cartões. Não pode alterar configurações.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "opcional", + "or": "or", "page-maybe-private": "Esta página pode ser privada. Você poderá vê-la se estiver <a href='%s'>logado</a>.", "page-not-found": "Página não encontrada.", "password": "Senha", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "Privado", "private-desc": "Este quadro é privado. Apenas seus membros podem acessar e editá-lo.", "profile": "Perfil", "public": "Público", "public-desc": "Este quadro é público. Ele é visível a qualquer pessoa com o link e será exibido em mecanismos de busca como o Google. Apenas seus membros podem editá-lo.", - "quick-access-description": "Star a board to add a shortcut in this bar.", + "quick-access-description": "Clique na estrela para adicionar um atalho nesta barra.", "remove-cover": "Remover Capa", - "remove-from-board": "Remove from Board", - "remove-label": "Remove the label", + "remove-from-board": "Remover do Quadro", + "remove-label": "Remover Etiqueta", "remove-member": "Remover Membro", "remove-member-from-card": "Remover do Cartão", "remove-member-pop": "Remover __name__ (__username__) de __boardTitle__? O membro será removido de todos os cartões neste quadro e será notificado.", @@ -173,13 +232,15 @@ "save": "Salvar", "search": "Buscar", "select-color": "Selecione uma cor", - "shortcut-autocomplete-emojies": "Autocomplete emojies", - "shortcut-autocomplete-members": "Autocomplete members", - "shortcut-clear-filters": "Clear all filters", - "shortcut-close-dialog": "Close Dialog", - "shortcut-filter-my-cards": "Filter my cards", - "shortcut-show-shortcuts": "Bring up this shortcuts list", - "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "shortcut-assign-self": "Assign yourself to current card", + "shortcut-autocomplete-emojies": "Preenchimento automático de emojies", + "shortcut-autocomplete-members": "Preenchimento automático de membros", + "shortcut-clear-filters": "Limpar todos filtros", + "shortcut-close-dialog": "Fechar dialogo", + "shortcut-filter-my-cards": "Filtrar meus cartões", + "shortcut-show-shortcuts": "Mostrar lista de atalhos", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Fechar barra lateral.", "signupPopup-title": "Criar uma Conta", "star-board-title": "Clique para marcar este quadro como favorito. Ele aparecerá no topo na lista dos seus quadros.", "starred-boards": "Quadros Favoritos", @@ -189,12 +250,13 @@ "this-board": "este quadro", "this-card": "este cartão", "title": "Título", - "unassign-member": "Unassign member", - "unsaved-description": "You have an unsaved description.", - "upload-avatar": "Upload an avatar", - "uploaded-avatar": "Uploaded an avatar", + "unassign-member": "Membro não associado", + "unsaved-description": "Você possui uma descrição não salva", + "upload": "Upload", + "upload-avatar": "Carregar um avatar", + "uploaded-avatar": "Avatar carregado", "username": "Nome de usuário", - "view-it": "View it", - "warn-list-archived": "warning: this card is in an archived list", - "what-to-do": "What do you want to do?" + "view-it": "Visualizar", + "warn-list-archived": "aviso: este cartão está em uma lista arquivada", + "what-to-do": "O que você gostaria de fazer?" }
\ No newline at end of file diff --git a/i18n/ru.i18n.json b/i18n/ru.i18n.json new file mode 100644 index 00000000..9528a3c9 --- /dev/null +++ b/i18n/ru.i18n.json @@ -0,0 +1,229 @@ +{ + "actions": "Действия", + "activities": "Activities", + "activity": "Активность", + "activity-added": "добавил %s на %s", + "activity-archived": "отправил в архив %s", + "activity-attached": "прикрепил %s к %s", + "activity-created": "создал %s", + "activity-excluded": "исключено %s из %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", + "activity-joined": "присоединились %s", + "activity-moved": "переместил %s из %s на %s", + "activity-on": "%s", + "activity-removed": "удалено %s из %s", + "activity-sent": "отправлено %s в %s", + "activity-unjoined": "unjoined %s", + "add": "Создать", + "add-attachment": "Add an attachment", + "add-board": "Создать новую доску", + "add-card": "Add a card", + "add-cover": "Прикрепить", + "add-label": "Add the label", + "add-list": "Add a list", + "add-members": "Add Members", + "added": "Добавлено", + "addMemberPopup-title": "Участники", + "admin": "Администратор", + "admin-desc": "Может просматривать и редактировать карточки, удалять участников и управлять настройками доски.", + "all-boards": "All boards", + "and-n-other-card": "And __count__ other card", + "and-n-other-card_plural": "And __count__ other cards", + "archive": "Архивировать", + "archive-all": "Архивировать все", + "archive-board": "Archive Board", + "archive-card": "Archive Card", + "archive-list": "Архивировать список", + "archive-selection": "Archive selection", + "archiveBoardPopup-title": "Закрыть доску?", + "archived-items": "Объекты в архиве", + "archives": "Archives", + "assign-member": "Assign member", + "attached": "прикреплено", + "attachment": "Вложение", + "attachment-delete-pop": "Если удалить вложение, его нельзя будет восстановить.", + "attachmentDeletePopup-title": "Удалить вложение?", + "attachments": "Вложения", + "avatar-too-big": "The avatar is too large (70Kb max)", + "back": "Назад", + "board-change-color": "Change color", + "board-nb-stars": "%s stars", + "board-not-found": "Доска не найдена", + "board-private-info": "This board will be <strong>private</strong>.", + "board-public-info": "Эта доска будет <strong>доступной всем</strong>.", + "boardChangeColorPopup-title": "Change Board Background", + "boardChangeTitlePopup-title": "Переименовать доску", + "boardChangeVisibilityPopup-title": "Изменить настройки видимости", + "boardImportBoardPopup-title": "Import board from Trello", + "boardMenuPopup-title": "Board Menu", + "boards": "Доски", + "bucket-example": "Like “Bucket List” for example", + "cancel": "Отмена", + "card-archived": "Эта карточка помещена в архив.", + "card-comments-title": "Комментарии (%s)", + "card-delete-notice": "Это действие невозможно будет отменить. Все изменения, которые вы вносили в карточку будут потеряны.", + "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", + "card-delete-suggest-archive": "You can archive a card to remove it from the board and preserve the activity.", + "card-edit-attachments": "Edit attachments", + "card-edit-labels": "Edit labels", + "card-edit-members": "Edit members", + "card-labels-title": "Редактировать метки.", + "card-members-title": "Добавить или удалить участника.", + "cardAttachmentsPopup-title": "Attach From", + "cardDeletePopup-title": "Удалить карточку?", + "cardDetailsActionsPopup-title": "Card Actions", + "cardLabelsPopup-title": "Метки", + "cardMembersPopup-title": "Участники", + "cardMorePopup-title": "Поделиться", + "cards": "Cards", + "change": "Change", + "change-avatar": "Изменить аватар", + "change-password": "Изменить пароль", + "change-permissions": "Change permissions", + "changeAvatarPopup-title": "Изменить аватар", + "changeLanguagePopup-title": "Сменить язык", + "changePasswordPopup-title": "Изменить пароль", + "changePermissionsPopup-title": "Изменить настройки доступа", + "click-to-star": "Отметить как «Избранное»", + "click-to-unstar": "Снять отметку", + "close": "Закрыть", + "close-board": "Close Board", + "close-board-pop": "Вы сможете снова открыть доску нажав кнопку \"Доски\" в верхнем меню и выбрав \"Показать скрытые доски\".", + "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-placeholder": "Write a comment", + "computer": "Загрузить с компьютера", + "create": "Создать", + "createBoardPopup-title": "Создать доску", + "createLabelPopup-title": "Создать метку", + "current": "current", + "default-avatar": "Default avatar", + "delete": "Удалить", + "deleteLabelPopup-title": "Удалить метку?", + "description": "Описание", + "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", + "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", + "discard": "Discard", + "download": "Скачать", + "edit": "Редактировать", + "edit-avatar": "Изменить аватар", + "edit-profile": "Edit Profile", + "editLabelPopup-title": "Редактирование метки", + "editProfilePopup-title": "Edit Profile", + "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-clear": "Clear filter", + "filter-on": "Filter is on", + "filter-on-desc": "Показываются карточки, соответствующие настройкам фильтра. Нажмите для редактирования.", + "filter-to-selection": "Filter to selection", + "fullname": "Полное имя", + "header-logo-title": "Вернуться к доскам.", + "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": "Информация", + "initials": "Initials", + "joined": "вступил", + "keyboard-shortcuts": "Keyboard shortcuts", + "label-create": "Создать метку", + "label-default": "%s", + "label-delete-pop": "Это действие невозможно будет отменить. Метка будет удалена во всех карточках.", + "labels": "Метки", + "language": "Язык", + "last-admin-desc": "Вы не можете изменять роли, для этого требуются права администратора.", + "leave-board": "Leave Board", + "link-card": "Доступна по ссылке", + "list-archive-cards": "Archive all cards in this list", + "list-archive-cards-pop": "Это действие переместит все карточки в архив и они перестанут быть видимым на доске. Для просмотра карточек в архиве нажмите “Меню” > “Объекты в архиве”.", + "list-move-cards": "Move all cards in this list", + "list-select-cards": "Select all cards in this list", + "listActionPopup-title": "Список действий", + "listArchiveCardsPopup-title": "Архивировать все карточки в списке?", + "listImportCardPopup-title": "Import a Trello card", + "listMoveCardsPopup-title": "Перенос карточек", + "lists": "Lists", + "log-out": "Выйти", + "loginPopup-title": "Войти", + "memberMenuPopup-title": "Member Settings", + "members": "Участники", + "menu": "Меню", + "moveCardPopup-title": "Move Card", + "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", + "my-boards": "Мои доски", + "name": "Имя", + "no-archived-cards": "No archived cards.", + "no-archived-lists": "No archived lists.", + "no-results": "Ничего не найдено", + "normal": "Обычный", + "normal-desc": "Может редактировать карточки. Не может управлять настройками.", + "optional": "не обязательно", + "or": "or", + "page-maybe-private": "Возможно, эта страница скрыта от незарегистрированных пользователей. Попробуйте <a href='%s'>войти на сайт</a>.", + "page-not-found": "Страница не найдена.", + "password": "Пароль", + "private": "Закрытая", + "private-desc": "Эта доска с ограниченным доступом. Только участники могут работать с ней.", + "profile": "Профиль", + "public": "Открытая", + "public-desc": "Эта доска может быть видна всем у кого есть ссылка. Также может быть проиндексирована поисковыми системами. Вносить изменения могут только участники.", + "quick-access-description": "Star a board to add a shortcut in this bar.", + "remove-cover": "Открепить", + "remove-from-board": "Remove from Board", + "remove-label": "Remove the label", + "remove-member": "Удалить участника", + "remove-member-from-card": "Удалить из карточки", + "remove-member-pop": "Удалить участника __name__ (__username__) из доски __boardTitle__? Участник будет удален из всех карточек. Также он получит уведомление о совершаемом действии.", + "removeMemberPopup-title": "Удалить участника?", + "rename": "Переименовать", + "rename-board": "Переименовать доску", + "restore": "Restore", + "save": "Сохранить", + "search": "Поиск", + "select-color": "Выбрать цвет", + "shortcut-assign-self": "Assign yourself to current card", + "shortcut-autocomplete-emojies": "Autocomplete emojies", + "shortcut-autocomplete-members": "Autocomplete members", + "shortcut-clear-filters": "Clear all filters", + "shortcut-close-dialog": "Close Dialog", + "shortcut-filter-my-cards": "Filter my cards", + "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", + "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "signupPopup-title": "Создать учетную запись", + "star-board-title": "Отметьте как «Избранное». Эта доска будет всегда на виду.", + "starred-boards": "Помеченные как «Избранное»", + "starred-boards-description": "Доска будет всегда на видном месте.", + "subscribe": "Подписаться", + "team": "Участники", + "this-board": "эту доску", + "this-card": "активная карточка", + "title": "Название", + "unassign-member": "Unassign member", + "unsaved-description": "You have an unsaved description.", + "upload-avatar": "Upload an avatar", + "uploaded-avatar": "Uploaded an avatar", + "username": "Имя пользователя", + "view-it": "View it", + "warn-list-archived": "warning: this card is in an archived list", + "what-to-do": "What do you want to do?" +}
\ No newline at end of file diff --git a/i18n/tr.i18n.json b/i18n/tr.i18n.json index 812efcbf..d4158947 100644 --- a/i18n/tr.i18n.json +++ b/i18n/tr.i18n.json @@ -1,18 +1,21 @@ { + "accept": "Accept", "actions": "İşlemler", - "activities": "Activities", + "activities": "Aktiviteler", "activity": "Etkinlik", "activity-added": "added %s to %s", "activity-archived": "%s arşivledi", "activity-attached": "attached %s to %s", "activity-created": "%s oluşturdu", "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": "Ekle", "add-attachment": "Add an attachment", "add-board": "Yeni bir pano ekle", @@ -36,7 +39,7 @@ "archive-selection": "Archive selection", "archiveBoardPopup-title": "Pano Kapatılsın mı?", "archived-items": "Arşivlenmiş Öğeler", - "archives": "Archives", + "archives": "Arşiv", "assign-member": "Assign member", "attached": "dosya eklendi", "attachment": "Ek Dosya", @@ -53,6 +56,7 @@ "boardChangeColorPopup-title": "Change Board Background", "boardChangeTitlePopup-title": "Pano Adı Değiştirme", "boardChangeVisibilityPopup-title": "Görünebilirliği Değiştir", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Board Menu", "boards": "Panolar", "bucket-example": "Like “Bucket List” for example", @@ -74,6 +78,7 @@ "cardMembersPopup-title": "Üyeler", "cardMorePopup-title": "More", "cards": "Cards", + "change": "Change", "change-avatar": "Avatar Değiştir", "change-password": "Parola Değiştir", "change-permissions": "Change permissions", @@ -83,9 +88,20 @@ "changePermissionsPopup-title": "Yetkileri Değiştirme", "click-to-star": "Bu panoyu yıldızlamak için tıkla.", "click-to-unstar": "Bu panunun yıldızını kaldırmak için tıkla.", + "clipboard": "Clipboard or drag & drop", "close": "Kapat", "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": "Yorum Gönder", "comment-placeholder": "Write a comment", "computer": "Bilgisayar", @@ -93,6 +109,7 @@ "createBoardPopup-title": "Pano Oluşturma", "createLabelPopup-title": "Etiket Oluşturma", "current": "current", + "decline": "Decline", "default-avatar": "Default avatar", "delete": "Sil", "deleteLabelPopup-title": "Etiket Silinsin mi?", @@ -100,13 +117,35 @@ "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", "discard": "Discard", + "done": "Done", "download": "İndir", "edit": "Düzenle", "edit-avatar": "Avatar Değiştir", - "edit-profile": "Edit Profile", + "edit-profile": "Profili Düzenle", "editLabelPopup-title": "Etiket Değiştirme", - "editProfilePopup-title": "Edit Profile", + "editProfilePopup-title": "Profili Düzenle", "email": "E-posta", + "email-enrollAccount-subject": "An account created for you on __url__", + "email-enrollAccount-text": "Hello __user__,\n\nTo start using the service, simply click the link below.\n\n__url__\n\nThanks.", + "email-fail": "Sending email failed", + "email-invalid": "Invalid email", + "email-invite": "Invite via Email", + "email-invite-subject": "__inviter__ sent you an invitation", + "email-invite-text": "Dear __user__,\n\n__inviter__ invites you to join board \"__board__\" for collaborations.\n\nPlease follow the link below:\n\n__url__\n\nThanks.", + "email-resetPassword-subject": "Reset your password on __url__", + "email-resetPassword-text": "Hello __user__,\n\nTo reset your password, simply click the link below.\n\n__url__\n\nThanks.", + "email-verifyEmail-subject": "Verify your email address on __url__", + "email-verifyEmail-text": "Hello __user__,\n\nTo verify your account email, simply click the link below.\n\n__url__\n\nThanks.", + "email-sent": "Email sent", + "error-board-doesNotExist": "This board does not exist", + "error-board-notAdmin": "You need to be admin of this board to do that", + "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", + "error-user-doesNotExist": "This user does not exist", + "error-user-notAllowSelf": "This action on self is not allowed", + "error-user-notCreated": "This user is not created", "filter": "Filter", "filter-cards": "Kartları Süz", "filter-clear": "Clear filter", @@ -116,9 +155,19 @@ "fullname": "Ad Soyad", "header-logo-title": "Panolar sayfanıza geri dön.", "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", + "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", + "just-invited": "You are just invited to this board", "keyboard-shortcuts": "Keyboard shortcuts", "label-create": "Yeni bir etiket oluştur", "label-default": "%s etiket (varsayılan)", @@ -134,15 +183,19 @@ "list-select-cards": "Select all cards in this list", "listActionPopup-title": "Liste İşlemleri", "listArchiveCardsPopup-title": "Bu Listedeki Tüm Kartlar Taşınsın mı?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Listedeki Tüm Kartları Taşıma", "lists": "Lists", "log-out": "Oturum Kapat", "loginPopup-title": "Oturum Aç", + "mapMembersPopup-title": "Map members", + "mapMembersAddPopup-title": "Select Wekan member", "memberMenuPopup-title": "Member Settings", "members": "Üyeler", "menu": "Menü", "moveCardPopup-title": "Move Card", "multi-selection": "Multi-Selection", + "multi-selection-on": "Multi-Selection is on", "my-boards": "Panolarım", "name": "Adı", "no-archived-cards": "No archived cards.", @@ -150,10 +203,16 @@ "no-results": "Sonuç yok", "normal": "Normal", "normal-desc": "Kartları görüntüler ve düzenler. Ayarları değiştiremez.", + "not-accepted-yet": "Invitation not accepted yet", "optional": "isteğe bağlı", + "or": "or", "page-maybe-private": "Bu sayfa özel olabilir. <a href='%s'>Oturum açarak</a> görülebilir.", "page-not-found": "Sayda bulunamadı.", "password": "Parola", + "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)", + "preview": "Preview", + "previewClipboardImagePopup-title": "Preview", + "previewAttachedImagePopup-title": "Preview", "private": "Özel", "private-desc": "Bu pano özel. Sadece panoya ekli kişiler görüntüleyebilir ve düzenleyebilir.", "profile": "Kullanıcı Sayfası", @@ -173,12 +232,14 @@ "save": "Kaydet", "search": "Search", "select-color": "Bir renk seç", + "shortcut-assign-self": "Assign yourself to current card", "shortcut-autocomplete-emojies": "Autocomplete emojies", "shortcut-autocomplete-members": "Autocomplete members", "shortcut-clear-filters": "Clear all filters", "shortcut-close-dialog": "Close Dialog", "shortcut-filter-my-cards": "Filter my cards", "shortcut-show-shortcuts": "Bring up this shortcuts list", + "shortcut-toggle-filterbar": "Toggle Filter Sidebar", "shortcut-toggle-sidebar": "Toggle Board Sidebar", "signupPopup-title": "Bir Hesap Oluştur", "star-board-title": "Bu panoyu yıldızlamak için tıkla. Pano listesinin en üstünde gösterilir.", @@ -191,6 +252,7 @@ "title": "Başlık", "unassign-member": "Unassign member", "unsaved-description": "You have an unsaved description.", + "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", "username": "Kullanıcı adı", diff --git a/i18n/zh-CN.i18n.json b/i18n/zh-CN.i18n.json index 49b6bba5..d26cd6b1 100644 --- a/i18n/zh-CN.i18n.json +++ b/i18n/zh-CN.i18n.json @@ -1,12 +1,14 @@ { "actions": "动作", - "activities": "Activities", + "activities": "活动", "activity": "活动", "activity-added": "添加 %s 至 %s", "activity-archived": "归档 %s", "activity-attached": "附加 %s 至 %s", "activity-created": "创建 %s", "activity-excluded": "排除 %s 从 %s", + "activity-imported": "导入 %s 至 %s 从 %s 中", + "activity-imported-board": "已导入 %s 从 %s 中", "activity-joined": "关联 %s", "activity-moved": "将 %s 从 %s 移动到 %s", "activity-on": "在 %s", @@ -14,69 +16,71 @@ "activity-sent": "发送 %s 至 %s", "activity-unjoined": "解除关联 %s", "add": "添加", - "add-attachment": "Add an attachment", - "add-board": "添加一个新的看板", - "add-card": "Add a card", + "add-attachment": "添加附件", + "add-board": "添加新看板", + "add-card": "添加卡片", "add-cover": "添加封面", - "add-label": "Add the label", - "add-list": "Add a list", - "add-members": "Add Members", + "add-label": "添加标签", + "add-list": "添加列表", + "add-members": "添加成员", "added": "添加", "addMemberPopup-title": "成员", "admin": "管理员", "admin-desc": "可以浏览并编辑卡片,移除成员,并且更改该看板的设置", - "all-boards": "All boards", - "and-n-other-card": "And __count__ other card", - "and-n-other-card_plural": "And __count__ other cards", + "all-boards": "全部看板", + "and-n-other-card": "和另外 __count__ 个卡片", + "and-n-other-card_plural": "和另外 __count__ 个卡片", "archive": "归档", - "archive-all": "归档所有", - "archive-board": "Archive Board", - "archive-card": "Archive Card", + "archive-all": "全部归档", + "archive-board": "归档看板", + "archive-card": "归档卡片", "archive-list": "归档该列表", - "archive-selection": "Archive selection", + "archive-selection": "归档选中内容", "archiveBoardPopup-title": "关闭看板?", - "archived-items": "归档项", - "archives": "Archives", - "assign-member": "Assign member", + "archived-items": "归档条目", + "archives": "归档", + "assign-member": "分配人员", "attached": "附加", "attachment": "附件", "attachment-delete-pop": "删除附件操作不可逆。", "attachmentDeletePopup-title": "删除附件?", "attachments": "附件", - "avatar-too-big": "The avatar is too large (70Kb max)", + "avatar-too-big": "头像太大 (最大 70 Kb)", "back": "返回", - "board-change-color": "Change color", - "board-nb-stars": "%s stars", + "board-change-color": "更改颜色", + "board-nb-stars": "%s 星标", "board-not-found": "看板不存在", - "board-private-info": "This board will be <strong>private</strong>.", + "board-private-info": "该看板将被 <strong>私有化</strong>.", "board-public-info": "该看板将 <strong>公开</strong>.", - "boardChangeColorPopup-title": "Change Board Background", + "boardChangeColorPopup-title": "修改看板背景", "boardChangeTitlePopup-title": "重命名看板", "boardChangeVisibilityPopup-title": "更改可视级别", - "boardMenuPopup-title": "Board Menu", + "boardImportBoardPopup-title": "从 Trello 导入看板", + "boardMenuPopup-title": "看板菜单", "boards": "看板", - "bucket-example": "Like “Bucket List” for example", + "bucket-example": "例如 “目标清单”", "cancel": "取消", "card-archived": "该卡片已被归档", "card-comments-title": "该卡片拥有 %s 条评论", "card-delete-notice": "删除操作不可恢复,你将会丢失该卡片的所有相关动作。", - "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.", - "card-delete-suggest-archive": "You can archive a card to remove it from the board and preserve the activity.", - "card-edit-attachments": "Edit attachments", - "card-edit-labels": "Edit labels", - "card-edit-members": "Edit members", + "card-delete-pop": "所有的动作将从活动动态中被移除且您将无法重新打开该卡片。此操作无法撤销。", + "card-delete-suggest-archive": "你可以通过归档一个卡片来将它从看板中移除且保留活动。", + "card-edit-attachments": "编辑附件", + "card-edit-labels": "编辑标签", + "card-edit-members": "编辑成员", "card-labels-title": "更改该卡片上的标签", "card-members-title": "在该卡片中添加或移除看板成员", - "cardAttachmentsPopup-title": "Attach From", + "cardAttachmentsPopup-title": "附件位置", "cardDeletePopup-title": "删除卡片?", - "cardDetailsActionsPopup-title": "Card Actions", + "cardDetailsActionsPopup-title": "卡片动作", "cardLabelsPopup-title": "标签", "cardMembersPopup-title": "成员", "cardMorePopup-title": "更多", - "cards": "Cards", + "cards": "卡片", + "change": "变更", "change-avatar": "更改头像", "change-password": "更改密码", - "change-permissions": "Change permissions", + "change-permissions": "更改权限", "changeAvatarPopup-title": "更改头像", "changeLanguagePopup-title": "更改语言", "changePasswordPopup-title": "更改密码", @@ -84,73 +88,96 @@ "click-to-star": "点此来标记该看板", "click-to-unstar": "点此来去除该看板的标记", "close": "关闭", - "close-board": "Close Board", + "close-board": "关闭看板", "close-board-pop": "你可以通过点击头部的\"看板\"菜单,选择\"浏览已关闭看板\",查找看板并且点击\"重开\"来重开看板。", + "color-green": "绿色", + "color-yellow": "黄色", + "color-orange": "橙色", + "color-red": "红色", + "color-purple": "紫色", + "color-blue": "蓝色", + "color-sky": "天蓝", + "color-lime": "绿黄", + "color-pink": "粉红", + "color-black": "黑色", "comment": "评论", - "comment-placeholder": "Write a comment", + "comment-placeholder": "添加评论", "computer": "从本机上传", "create": "创建", "createBoardPopup-title": "创建看板", "createLabelPopup-title": "创建标签", - "current": "current", - "default-avatar": "Default avatar", + "current": "当前", + "default-avatar": "默认头像", "delete": "删除", "deleteLabelPopup-title": "删除标签?", "description": "描述", - "disambiguateMultiLabelPopup-title": "Disambiguate Label Action", - "disambiguateMultiMemberPopup-title": "Disambiguate Member Action", - "discard": "Discard", + "disambiguateMultiLabelPopup-title": "消除标签动作歧义", + "disambiguateMultiMemberPopup-title": "消除会员动作歧义", + "discard": "放弃", "download": "下载", "edit": "编辑", "edit-avatar": "更改头像", - "edit-profile": "Edit Profile", + "edit-profile": "编辑资料", "editLabelPopup-title": "更改标签", - "editProfilePopup-title": "Edit Profile", + "editProfilePopup-title": "编辑资料", "email": "邮箱", - "filter": "Filter", + "error-board-notAMember": "需要成为看板成员才能执行此动作", + "error-json-malformed": "文本不是合法的 JSON", + "error-json-schema": "JSON 数据没有用正确的格式包含合适的信息", + "error-list-doesNotExist": "不存在此列表", + "filter": "过滤", "filter-cards": "过滤卡片", - "filter-clear": "Clear filter", - "filter-on": "Filter is on", + "filter-clear": "清空过滤器", + "filter-on": "过滤器启用", "filter-on-desc": "你正在过滤该看板上的卡片,点此编辑过滤。", - "filter-to-selection": "Filter to selection", + "filter-to-selection": "要选择的过滤器", "fullname": "全称", "header-logo-title": "返回您的看板页", "home": "首页", + "import": "导入", + "import-board": "从 Trello 导入", + "import-board-trello-instruction": "在你的Trello看板中,点击“菜单”,然后选择“更多”,“打印与导出”,“导出为 JSON” 并拷贝结果文本", + "import-card": "导入 Trello 卡片", + "import-card-trello-instruction": "进入一个 Trello 卡片,选择“分享与更多”,然后选择 “导出为 JSON” 并且拷贝结果文本", + "import-json-placeholder": "粘贴您有效的 JSON 数据至此", "info": "信息", - "initials": "Initials", + "initials": "首字母", "joined": "关联", - "keyboard-shortcuts": "Keyboard shortcuts", + "keyboard-shortcuts": "键盘快捷键", "label-create": "创建新标签", "label-default": "%s 标签 (默认)", "label-delete-pop": "此操作不可逆,这将会删除该标签并清除它的历史记录。", "labels": "标签", "language": "语言", "last-admin-desc": "你不能更改角色,因为至少需要一名管理员。", - "leave-board": "Leave Board", + "leave-board": "离开面板", "link-card": "关联至该卡片", - "list-archive-cards": "Archive all cards in this list", + "list-archive-cards": "归档列表中的所有卡片", "list-archive-cards-pop": "这将会从本看板中移除该列表中的所有卡片。如果需要浏览已归档的卡片并且将其恢复至看板,请点击\"菜单\">\"归档项\"", - "list-move-cards": "Move all cards in this list", - "list-select-cards": "Select all cards in this list", + "list-move-cards": "移动列表中的所有卡片", + "list-select-cards": "选择列表中的所有卡片", "listActionPopup-title": "列出动作", "listArchiveCardsPopup-title": "归档该列表中的所有卡片?", + "listImportCardPopup-title": "导入 Trello 卡片", "listMoveCardsPopup-title": "移动该列表的所有卡片", - "lists": "Lists", + "lists": "列表", "log-out": "登出", "loginPopup-title": "登录", - "memberMenuPopup-title": "Member Settings", + "memberMenuPopup-title": "成员设置", "members": "成员", "menu": "菜单", - "moveCardPopup-title": "Move Card", - "multi-selection": "Multi-Selection", + "moveCardPopup-title": "移动卡片", + "multi-selection": "多选", + "multi-selection-on": "多选启用", "my-boards": "我的看板", "name": "名称", - "no-archived-cards": "No archived cards.", - "no-archived-lists": "No archived lists.", + "no-archived-cards": "无归档的卡片", + "no-archived-lists": "无归档的列表", "no-results": "无结果", "normal": "普通", "normal-desc": "可以创建以及编辑卡片,无法更改设置。", "optional": "可选", + "or": "或", "page-maybe-private": "本页面被设为私有. 您必须 <a href='%s'>登录</a>以浏览其中内容。", "page-not-found": "页面不存在。", "password": "密码", @@ -159,27 +186,29 @@ "profile": "资料", "public": "公共", "public-desc": "该看板将被公共。任何人均可通过链接查看,并且将对Google和其他搜索引擎开放,只有添加至该看板的成员才可进行编辑。", - "quick-access-description": "Star a board to add a shortcut in this bar.", + "quick-access-description": "星标一个看板来在该导航条中添加一个快捷方式", "remove-cover": "移除封面", - "remove-from-board": "Remove from Board", - "remove-label": "Remove the label", + "remove-from-board": "从看板中删除", + "remove-label": "移除标签", "remove-member": "移除成员", "remove-member-from-card": "从该卡片中移除", "remove-member-pop": "欲从 __boardTitle__ 中移除 __name__ (__username__) ? 该成员将会从该看板的所有卡片中被移除,他将会收到一条提醒。", "removeMemberPopup-title": "删除成员?", "rename": "重命名", "rename-board": "重命名看板", - "restore": "Restore", + "restore": "还原", "save": "保存", "search": "搜索", "select-color": "选择颜色", - "shortcut-autocomplete-emojies": "Autocomplete emojies", - "shortcut-autocomplete-members": "Autocomplete members", - "shortcut-clear-filters": "Clear all filters", - "shortcut-close-dialog": "Close Dialog", - "shortcut-filter-my-cards": "Filter my cards", - "shortcut-show-shortcuts": "Bring up this shortcuts list", - "shortcut-toggle-sidebar": "Toggle Board Sidebar", + "shortcut-assign-self": "分配当前卡片给自己", + "shortcut-autocomplete-emojies": "自动补全表情", + "shortcut-autocomplete-members": "自动补全成员", + "shortcut-clear-filters": "清空全部过滤器", + "shortcut-close-dialog": "关闭对话框", + "shortcut-filter-my-cards": "过滤我的卡片", + "shortcut-show-shortcuts": "显示此快捷键列表", + "shortcut-toggle-filterbar": "切换过滤器边栏", + "shortcut-toggle-sidebar": "切换面板边栏", "signupPopup-title": " 创建账户", "star-board-title": "点此来标记该看板,它将会出现在您的看板列表顶部。", "starred-boards": "已标记看板", @@ -189,12 +218,12 @@ "this-board": "该看板", "this-card": "该卡片", "title": "标题", - "unassign-member": "Unassign member", - "unsaved-description": "You have an unsaved description.", - "upload-avatar": "Upload an avatar", - "uploaded-avatar": "Uploaded an avatar", + "unassign-member": "取消分配成员", + "unsaved-description": "存在未保存的描述", + "upload-avatar": "上传头像", + "uploaded-avatar": "头像已经上传", "username": "用户名", - "view-it": "View it", - "warn-list-archived": "warning: this card is in an archived list", - "what-to-do": "What do you want to do?" + "view-it": "查看", + "warn-list-archived": "警告: 卡片位于一个归档列表", + "what-to-do": "要做什么?" }
\ No newline at end of file diff --git a/collections/activities.js b/models/activities.js index 5de07ee5..5de07ee5 100644 --- a/collections/activities.js +++ b/models/activities.js diff --git a/collections/attachments.js b/models/attachments.js index 8ef0fef0..01e467ff 100644 --- a/collections/attachments.js +++ b/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 diff --git a/collections/avatars.js b/models/avatars.js index 53924ffb..53924ffb 100644 --- a/collections/avatars.js +++ b/models/avatars.js diff --git a/collections/boards.js b/models/boards.js index fcd04153..6aba0b1e 100644 --- a/collections/boards.js +++ b/models/boards.js @@ -71,8 +71,190 @@ Boards.attachSchema(new SimpleSchema({ 'midnight', ], }, + description: { + type: String, + optional: true, + }, })); + +Boards.helpers({ + isPublic() { + return this.permission === 'public'; + }, + + lists() { + return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 }}); + }, + + activities() { + return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }}); + }, + + activeMembers() { + return _.where(this.members, {isActive: true}); + }, + + activeAdmins() { + return _.where(this.members, {isActive: true, isAdmin: true}); + }, + + memberUsers() { + return Users.find({ _id: {$in: _.pluck(this.members, 'userId')} }); + }, + + getLabel(name, color) { + return _.findWhere(this.labels, { name, color }); + }, + + labelIndex(labelId) { + return _.pluck(this.labels, '_id').indexOf(labelId); + }, + + memberIndex(memberId) { + return _.pluck(this.members, 'userId').indexOf(memberId); + }, + + absoluteUrl() { + return FlowRouter.path('board', { id: this._id, slug: this.slug }); + }, + + 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({ + archive() { + return { $set: { archived: true }}; + }, + + restore() { + return { $set: { archived: false }}; + }, + + rename(title) { + return { $set: { title }}; + }, + + setDesciption(description) { + return { $set: {description} }; + }, + + setColor(color) { + return { $set: { color }}; + }, + + setVisibility(visibility) { + return { $set: { permission: visibility }}; + }, + + addLabel(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) { + if (!this.getLabel(name, color)) { + const labelIndex = this.labelIndex(labelId); + return { + $set: { + [`labels.${labelIndex}.name`]: name, + [`labels.${labelIndex}.color`]: color, + }, + }; + } + }, + + removeLabel(labelId) { + return { $pull: { labels: { _id: labelId }}}; + }, + + addMember(memberId) { + const memberIndex = this.memberIndex(memberId); + if (memberIndex === -1) { + const xIndex = this.memberIndex('x'); + if (xIndex === -1) { + return { + $push: { + members: { + userId: memberId, + isAdmin: false, + isActive: true, + }, + }, + }; + } else { + return { + $set: { + [`members.${xIndex}.userId`]: memberId, + [`members.${xIndex}.isActive`]: true, + [`members.${xIndex}.isAdmin`]: false, + }, + }; + } + } else { + return { + $set: { + [`members.${memberIndex}.isActive`]: true, + }, + }; + } + }, + + removeMember(memberId) { + const memberIndex = this.memberIndex(memberId); + + // we do not allow the only one admin to be removed + const allowRemove = (!this.members[memberIndex].isAdmin) || (this.activeAdmins().length > 1); + + if (allowRemove) { + return { + $set: { + [`members.${memberIndex}.userId`]: 'x', + [`members.${memberIndex}.isActive`]: false, + [`members.${memberIndex}.isAdmin`]: false, + }, + }; + } else { + return { + $set: { + [`members.${memberIndex}.isActive`]: true, + }, + }; + } + }, + + setMemberPermission(memberId, isAdmin) { + const memberIndex = this.memberIndex(memberId); + + // do not allow change permission of self + if (memberId === Meteor.userId()) { + isAdmin = this.members[memberIndex].isAdmin; + } + + return { + $set: { + [`members.${memberIndex}.isAdmin`]: isAdmin, + }, + }; + }, +}); + if (Meteor.isServer) { Boards.allow({ insert: Meteor.userId, @@ -101,9 +283,7 @@ if (Meteor.isServer) { return false; // If there is more than one admin, it's ok to remove anyone - const nbAdmins = _.filter(doc.members, (member) => { - return member.isAdmin; - }).length; + const nbAdmins = _.where(doc.members, {isActive: true, isAdmin: true}).length; if (nbAdmins > 1) return false; @@ -117,34 +297,22 @@ if (Meteor.isServer) { }, fetch: ['members'], }); -} -Boards.helpers({ - isPublic() { - return this.permission === 'public'; - }, - - lists() { - return Lists.find({ boardId: this._id, archived: false }, - { sort: { sort: 1 }}); - }, - - activities() { - return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }}); - }, - - activeMembers() { - return _.where(this.members, {isActive: true}); - }, - - absoluteUrl() { - return FlowRouter.path('board', { id: this._id, slug: this.slug }); - }, - - colorClass() { - return `board-color-${this.color}`; - }, -}); + Meteor.methods({ + quitBoard(boardId) { + check(boardId, String); + const board = Boards.findOne(boardId); + if (board) { + const userId = Meteor.userId(); + const index = board.memberIndex(userId); + if (index>=0) { + board.removeMember(userId); + return true; + } else throw new Meteor.Error('error-board-notAMember'); + } else throw new Meteor.Error('error-board-doesNotExist'); + }, + }); +} Boards.before.insert((userId, doc) => { // XXX We need to improve slug management. Only the id should be necessary @@ -167,7 +335,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), @@ -215,7 +383,7 @@ if (Meteor.isServer) { { boardId: doc._id }, { $pull: { - labels: removedLabelId, + labelIds: removedLabelId, }, }, { multi: true } diff --git a/models/cardComments.js b/models/cardComments.js new file mode 100644 index 00000000..224deb03 --- /dev/null +++ b/models/cardComments.js @@ -0,0 +1,69 @@ +CardComments = new Mongo.Collection('card_comments'); + +CardComments.attachSchema(new SimpleSchema({ + boardId: { + type: String, + }, + cardId: { + type: String, + }, + // XXX Rename in `content`? `text` is a bit vague... + text: { + type: String, + }, + // XXX We probably don't need this information here, since we already have it + // in the associated comment creation activity + createdAt: { + type: Date, + denyUpdate: false, + }, + // XXX Should probably be called `authorId` + userId: { + type: String, + }, +})); + +CardComments.allow({ + insert(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update(userId, doc) { + return userId === doc.userId; + }, + remove(userId, doc) { + return userId === doc.userId; + }, + fetch: ['userId', 'boardId'], +}); + +CardComments.helpers({ + user() { + return Users.findOne(this.userId); + }, +}); + +CardComments.hookOptions.after.update = { fetchPrevious: false }; + +CardComments.before.insert((userId, doc) => { + doc.createdAt = new Date(); + doc.userId = userId; +}); + +if (Meteor.isServer) { + CardComments.after.insert((userId, doc) => { + Activities.insert({ + userId, + activityType: 'addComment', + boardId: doc.boardId, + cardId: doc.cardId, + commentId: doc._id, + }); + }); + + CardComments.after.remove((userId, doc) => { + const activity = Activities.findOne({ commentId: doc._id }); + if (activity) { + Activities.remove(activity._id); + } + }); +} diff --git a/collections/cards.js b/models/cards.js index 97ba4e3c..1895fc69 100644 --- a/collections/cards.js +++ b/models/cards.js @@ -1,5 +1,4 @@ Cards = new Mongo.Collection('cards'); -CardComments = new Mongo.Collection('card_comments'); // XXX To improve pub/sub performances a card document should include a // de-normalized number of comments so we don't have to publish the whole list @@ -54,64 +53,28 @@ Cards.attachSchema(new SimpleSchema({ }, })); -CardComments.attachSchema(new SimpleSchema({ - boardId: { - type: String, +Cards.allow({ + insert(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); }, - cardId: { - type: String, - }, - // XXX Rename in `content`? `text` is a bit vague... - text: { - type: String, + update(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); }, - // XXX We probably don't need this information here, since we already have it - // in the associated comment creation activity - createdAt: { - type: Date, - denyUpdate: false, - }, - // XXX Should probably be called `authorId` - userId: { - type: String, + remove(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); }, -})); - -if (Meteor.isServer) { - Cards.allow({ - insert(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - update(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - remove(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - fetch: ['boardId'], - }); - - CardComments.allow({ - insert(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - update(userId, doc) { - return userId === doc.userId; - }, - remove(userId, doc) { - return userId === doc.userId; - }, - fetch: ['userId', 'boardId'], - }); -} + fetch: ['boardId'], +}); Cards.helpers({ list() { return Lists.findOne(this.listId); }, + board() { return Boards.findOne(this.boardId); }, + labels() { const boardLabels = this.board().labels; const cardLabels = _.filter(boardLabels, (label) => { @@ -119,27 +82,38 @@ Cards.helpers({ }); return cardLabels; }, + hasLabel(labelId) { return _.contains(this.labelIds, labelId); }, + user() { return Users.findOne(this.userId); }, + isAssigned(memberId) { return _.contains(this.members, memberId); }, + activities() { return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 }}); }, + comments() { return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }}); }, + attachments() { return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }}); }, + 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() { const board = this.board(); return FlowRouter.path('card', { @@ -148,33 +122,87 @@ Cards.helpers({ cardId: this._id, }); }, + rootUrl() { return Meteor.absoluteUrl(this.absoluteUrl().replace('/', '')); }, }); -CardComments.helpers({ - user() { - return Users.findOne(this.userId); +Cards.mutations({ + archive() { + return { $set: { archived: true }}; }, -}); -CardComments.hookOptions.after.update = { fetchPrevious: false }; -Cards.before.insert((userId, doc) => { - doc.createdAt = new Date(); - doc.dateLastActivity = new Date(); + restore() { + return { $set: { archived: false }}; + }, - // defaults - doc.archived = false; + setTitle(title) { + return { $set: { title }}; + }, - // userId native set. - if (!doc.userId) - doc.userId = userId; + setDescription(description) { + return { $set: { description }}; + }, + + move(listId, sortIndex) { + const mutatedFields = { listId }; + if (sortIndex) { + mutatedFields.sort = sortIndex; + } + return { $set: mutatedFields }; + }, + + addLabel(labelId) { + return { $addToSet: { labelIds: labelId }}; + }, + + removeLabel(labelId) { + return { $pull: { labelIds: labelId }}; + }, + + toggleLabel(labelId) { + if (this.labelIds && this.labelIds.indexOf(labelId) > -1) { + return this.removeLabel(labelId); + } else { + return this.addLabel(labelId); + } + }, + + assignMember(memberId) { + return { $addToSet: { members: memberId }}; + }, + + unassignMember(memberId) { + return { $pull: { members: memberId }}; + }, + + toggleMember(memberId) { + if (this.members && this.members.indexOf(memberId) > -1) { + return this.unassignMember(memberId); + } else { + return this.assignMember(memberId); + } + }, + + setCover(coverId) { + return { $set: { coverId }}; + }, + + unsetCover() { + return { $unset: { coverId: '' }}; + }, }); -CardComments.before.insert((userId, doc) => { +Cards.before.insert((userId, doc) => { doc.createdAt = new Date(); - doc.userId = userId; + doc.dateLastActivity = new Date(); + if(!doc.hasOwnProperty('archived')){ + doc.archived = false; + } + if (!doc.userId) { + doc.userId = userId; + } }); if (Meteor.isServer) { @@ -264,21 +292,4 @@ if (Meteor.isServer) { cardId: doc._id, }); }); - - CardComments.after.insert((userId, doc) => { - Activities.insert({ - userId, - activityType: 'addComment', - boardId: doc.boardId, - cardId: doc.cardId, - commentId: doc._id, - }); - }); - - CardComments.after.remove((userId, doc) => { - const activity = Activities.findOne({ commentId: doc._id }); - if (activity) { - Activities.remove(activity._id); - } - }); } diff --git a/models/import.js b/models/import.js new file mode 100644 index 00000000..4be1273c --- /dev/null +++ b/models/import.js @@ -0,0 +1,511 @@ +const DateString = Match.Where(function (dateAsString) { + check(dateAsString, String); + return moment(dateAsString, moment.ISO_8601).isValid(); +}); + +class TrelloCreator { + constructor(data) { + // we log current date, to use the same timestamp for all our actions. + // this helps to retrieve all elements performed by the same import. + this._nowDate = new Date(); + // The object creation dates, indexed by Trello id + // (so we only parse actions once!) + this.createdAt = { + board: null, + cards: {}, + lists: {}, + }; + // The object creator Trello Id, indexed by the object Trello id + // (so we only parse actions once!) + this.createdBy = { + cards: {}, // only cards have a field for that + }; + + // Map of labels Trello ID => Wekan ID + this.labels = {}; + // Map of lists Trello ID => Wekan ID + this.lists = {}; + // The comments, indexed by Trello card id (to map when importing cards) + this.comments = {}; + // the members, indexed by Trello member id => Wekan user ID + this.members = data.membersMapping ? data.membersMapping : {}; + + // maps a trelloCardId to an array of trelloAttachments + this.attachments = {}; + } + + /** + * If dateString is provided, + * return the Date it represents. + * If not, will return the date when it was first called. + * This is useful for us, as we want all import operations to + * have the exact same date for easier later retrieval. + * + * @param {String} dateString a properly formatted Date + */ + _now(dateString) { + if(dateString) { + return new Date(dateString); + } + if(!this._nowDate) { + this._nowDate = new Date(); + } + return this._nowDate; + } + + /** + * if trelloUserId is provided and we have a mapping, + * return it. + * Otherwise return current logged user. + * @param trelloUserId + * @private + */ + _user(trelloUserId) { + if(trelloUserId && this.members[trelloUserId]) { + return this.members[trelloUserId]; + } + return Meteor.userId(); + } + + checkActions(trelloActions) { + check(trelloActions, [Match.ObjectIncluding({ + data: Object, + date: DateString, + type: String, + })]); + // XXX we could perform more thorough checks based on action type + } + + checkBoard(trelloBoard) { + check(trelloBoard, Match.ObjectIncluding({ + closed: Boolean, + name: String, + prefs: Match.ObjectIncluding({ + // XXX refine control by validating 'background' against a list of + // allowed values (is it worth the maintenance?) + background: String, + permissionLevel: Match.Where((value) => { + return ['org', 'private', 'public'].indexOf(value)>= 0; + }), + }), + })); + } + + checkCards(trelloCards) { + check(trelloCards, [Match.ObjectIncluding({ + closed: Boolean, + dateLastActivity: DateString, + desc: String, + idLabels: [String], + idMembers: [String], + name: String, + pos: Number, + })]); + } + + checkLabels(trelloLabels) { + check(trelloLabels, [Match.ObjectIncluding({ + // XXX refine control by validating 'color' against a list of allowed + // values (is it worth the maintenance?) + color: String, + name: String, + })]); + } + + checkLists(trelloLists) { + check(trelloLists, [Match.ObjectIncluding({ + closed: Boolean, + name: String, + })]); + } + + // You must call parseActions before calling this one. + createBoardAndLabels(trelloBoard) { + const boardToCreate = { + archived: trelloBoard.closed, + color: this.getColor(trelloBoard.prefs.background), + // very old boards won't have a creation activity so no creation date + createdAt: this._now(this.createdAt.board), + labels: [], + members: [{ + userId: Meteor.userId(), + isAdmin: true, + isActive: true, + }], + permission: this.getPermission(trelloBoard.prefs.permissionLevel), + slug: getSlug(trelloBoard.name) || 'board', + stars: 0, + title: trelloBoard.name, + }; + // now add other members + if(trelloBoard.memberships) { + trelloBoard.memberships.forEach((trelloMembership) => { + const trelloId = trelloMembership.idMember; + // do we have a mapping? + if(this.members[trelloId]) { + const wekanId = this.members[trelloId]; + // do we already have it in our list? + const wekanMember = boardToCreate.members.find((wekanMember) => wekanMember.userId === wekanId); + if(wekanMember) { + // we're already mapped, but maybe with lower rights + if(!wekanMember.isAdmin) { + wekanMember.isAdmin = this.getAdmin(trelloMembership.memberType); + } + } else { + boardToCreate.members.push({ + userId: wekanId, + isAdmin: this.getAdmin(trelloMembership.memberType), + isActive: true, + }); + } + } + }); + } + trelloBoard.labels.forEach((label) => { + const labelToCreate = { + _id: Random.id(6), + color: label.color, + name: label.name, + }; + // We need to remember them by Trello ID, as this is the only ref we have + // when importing cards. + this.labels[label.id] = labelToCreate._id; + boardToCreate.labels.push(labelToCreate); + }); + const boardId = Boards.direct.insert(boardToCreate); + Boards.direct.update(boardId, {$set: {modifiedAt: this._now()}}); + // log activity + Activities.direct.insert({ + activityType: 'importBoard', + boardId, + createdAt: this._now(), + source: { + id: trelloBoard.id, + system: 'Trello', + url: trelloBoard.url, + }, + // We attribute the import to current user, + // not the author from the original object. + userId: this._user(), + }); + return boardId; + } + + /** + * Create the Wekan cards corresponding to the supplied Trello cards, + * as well as all linked data: activities, comments, and attachments + * @param trelloCards + * @param boardId + * @returns {Array} + */ + createCards(trelloCards, boardId) { + const result = []; + trelloCards.forEach((card) => { + const cardToCreate = { + archived: card.closed, + boardId, + // very old boards won't have a creation activity so no creation date + createdAt: this._now(this.createdAt.cards[card.id]), + dateLastActivity: this._now(), + description: card.desc, + listId: this.lists[card.idList], + sort: card.pos, + title: card.name, + // we attribute the card to its creator if available + userId: this._user(this.createdBy.cards[card.id]), + }; + // 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: this._now(), + listId: cardToCreate.listId, + source: { + id: card.id, + system: 'Trello', + url: card.url, + }, + // we attribute the import to current user, + // not the author of the original card + userId: this._user(), + }); + // add comments + const comments = this.comments[card.id]; + if (comments) { + comments.forEach((comment) => { + const commentToCreate = { + boardId, + cardId, + createdAt: this._now(comment.date), + text: comment.data.text, + // we attribute the comment to the original author, default to current user + userId: this._user(comment.memberCreator.id), + }; + // dateLastActivity will be set from activity insert, no need to + // update it ourselves + const commentId = CardComments.direct.insert(commentToCreate); + Activities.direct.insert({ + activityType: 'addComment', + boardId: commentToCreate.boardId, + cardId: commentToCreate.cardId, + commentId, + createdAt: this._now(commentToCreate.createdAt), + // we attribute the addComment (not the import) + // to the original author - it is needed by some UI elements. + userId: commentToCreate.userId, + }); + }); + } + const attachments = this.attachments[card.id]; + const trelloCoverId = card.idAttachmentCover; + if (attachments) { + attachments.forEach((att) => { + const file = new FS.File(); + // Simulating file.attachData on the client generates multiple errors + // - HEAD returns null, which causes exception down the line + // - the template then tries to display the url to the attachment which causes other errors + // so we make it server only, and let UI catch up once it is done, forget about latency comp. + if(Meteor.isServer) { + file.attachData(att.url, function (error) { + file.boardId = boardId; + file.cardId = cardId; + if (error) { + throw(error); + } else { + const wekanAtt = Attachments.insert(file, () => { + // we do nothing + }); + // + if(trelloCoverId === att.id) { + Cards.direct.update(cardId, { $set: {coverId: wekanAtt._id}}); + } + } + }); + } + // todo XXX set cover - if need be + }); + } + result.push(cardId); + }); + return result; + } + + // Create labels if they do not exist and load this.labels. + createLabels(trelloLabels, board) { + trelloLabels.forEach((label) => { + const color = label.color; + const name = label.name; + const existingLabel = board.getLabel(name, color); + if (existingLabel) { + this.labels[label.id] = existingLabel._id; + } else { + const idLabelCreated = board.pushLabel(name, color); + this.labels[label.id] = idLabelCreated; + } + }); + } + + createLists(trelloLists, boardId) { + trelloLists.forEach((list) => { + const listToCreate = { + archived: list.closed, + boardId, + // We are being defensing here by providing a default date (now) if the + // creation date wasn't found on the action log. This happen on old + // Trello boards (eg from 2013) that didn't log the 'createList' action + // we require. + createdAt: this._now(this.createdAt.lists[list.id]), + title: list.name, + }; + const listId = Lists.direct.insert(listToCreate); + Lists.direct.update(listId, {$set: {'updatedAt': this._now()}}); + this.lists[list.id] = listId; + // log activity + Activities.direct.insert({ + activityType: 'importList', + boardId, + createdAt: this._now(), + listId, + source: { + id: list.id, + system: 'Trello', + }, + // We attribute the import to current user, + // not the creator of the original object + userId: this._user(), + }); + }); + } + + getAdmin(trelloMemberType) { + return trelloMemberType === 'admin'; + } + + getColor(trelloColorCode) { + // trello color name => wekan color + const mapColors = { + 'blue': 'belize', + 'orange': 'pumpkin', + 'green': 'nephritis', + 'red': 'pomegranate', + 'purple': 'wisteria', + 'pink': 'pomegranate', + 'lime': 'nephritis', + 'sky': 'belize', + 'grey': 'midnight', + }; + const wekanColor = mapColors[trelloColorCode]; + return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0]; + } + + getPermission(trelloPermissionCode) { + if (trelloPermissionCode === 'public') { + return 'public'; + } + // Wekan does NOT have organization level, so we default both 'private' and + // 'org' to private. + return 'private'; + } + + parseActions(trelloActions) { + trelloActions.forEach((action) => { + 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; + case 'createCard': + const cardId = action.data.card.id; + this.createdAt.cards[cardId] = action.date; + this.createdBy.cards[cardId] = action.idMemberCreator; + break; + case 'createList': + const listId = action.data.list.id; + this.createdAt.lists[listId] = action.date; + break; + default: + // do nothing + break; + } + }); + } +} + +Meteor.methods({ + importTrelloBoard(trelloBoard, data) { + const trelloCreator = new TrelloCreator(data); + + // 1. check all parameters are ok from a syntax point of view + try { + check(data, { + membersMapping: Match.Optional(Object), + }); + trelloCreator.checkActions(trelloBoard.actions); + trelloCreator.checkBoard(trelloBoard); + trelloCreator.checkLabels(trelloBoard.labels); + trelloCreator.checkLists(trelloBoard.lists); + trelloCreator.checkCards(trelloBoard.cards); + } 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.createCards(trelloBoard.cards, boardId); + // XXX add members + return boardId; + }, + + importTrelloCard(trelloCard, data) { + 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); + 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.createCards([trelloCard], board._id); + return cardIds[0]; + }, +}); diff --git a/collections/lists.js b/models/lists.js index 0c6ba407..4e4a1134 100644 --- a/collections/lists.js +++ b/models/lists.js @@ -27,20 +27,18 @@ Lists.attachSchema(new SimpleSchema({ }, })); -if (Meteor.isServer) { - Lists.allow({ - insert(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - update(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - remove(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); - }, - fetch: ['boardId'], - }); -} +Lists.allow({ + insert(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'], +}); Lists.helpers({ cards() { @@ -49,12 +47,30 @@ Lists.helpers({ archived: false, }), { sort: ['sort'] }); }, + + allCards() { + return Cards.find({ listId: this._id }); + }, + board() { return Boards.findOne(this.boardId); }, }); -// HOOKS +Lists.mutations({ + rename(title) { + return { $set: { title }}; + }, + + archive() { + return { $set: { archived: true }}; + }, + + restore() { + return { $set: { archived: false }}; + }, +}); + Lists.hookOptions.after.update = { fetchPrevious: false }; Lists.before.insert((userId, doc) => { diff --git a/collections/unsavedEdits.js b/models/unsavedEdits.js index 87a70e22..87a70e22 100644 --- a/collections/unsavedEdits.js +++ b/models/unsavedEdits.js diff --git a/models/users.js b/models/users.js new file mode 100644 index 00000000..2c9ae380 --- /dev/null +++ b/models/users.js @@ -0,0 +1,291 @@ +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.fullname']; +Users.initEasySearch(searchInFields, { + use: 'mongo-db', + 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 {starredBoards = []} = this.profile; + return Boards.find({archived: false, _id: {$in: starredBoards}}); + }, + + hasStarred(boardId) { + const {starredBoards = []} = this.profile; + return _.contains(starredBoards, boardId); + }, + + invitedBoards() { + const {invitedBoards = []} = this.profile; + return Boards.find({archived: false, _id: {$in: invitedBoards}}); + }, + + isInvitedTo(boardId) { + const {invitedBoards = []} = this.profile; + return _.contains(invitedBoards, boardId); + }, + + 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() { + const profile = this.profile || {}; + if (profile.initials) + return profile.initials; + + else if (profile.fullname) { + return profile.fullname.split(/\s+/).reduce((memo = '', word) => { + return memo + word[0]; + }).toUpperCase(); + + } else { + return this.username[0].toUpperCase(); + } + }, + + getName() { + const profile = this.profile || {}; + return profile.fullname || this.username; + }, + + getLanguage() { + const profile = this.profile || {}; + return profile.language || 'en'; + }, +}); + +Users.mutations({ + toggleBoardStar(boardId) { + const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet'; + return { + [queryKind]: { + 'profile.starredBoards': boardId, + }, + }; + }, + + addInvite(boardId) { + return { + $addToSet: { + 'profile.invitedBoards': boardId, + }, + }; + }, + + removeInvite(boardId) { + return { + $pull: { + 'profile.invitedBoards': boardId, + }, + }; + }, + + setAvatarUrl(avatarUrl) { + return { $set: { 'profile.avatarUrl': avatarUrl }}; + }, +}); + +Meteor.methods({ + setUsername(username) { + check(username, String); + const nUsersWithUsername = Users.find({ username }).count(); + if (nUsersWithUsername > 0) { + throw new Meteor.Error('username-already-taken'); + } else { + Users.update(this.userId, {$set: { username }}); + } + }, +}); + +if (Meteor.isServer) { + Meteor.methods({ + // we accept userId, username, email + inviteUserToBoard(username, boardId) { + check(username, String); + check(boardId, String); + + const inviter = Meteor.user(); + const board = Boards.findOne(boardId); + const allowInvite = inviter && + board && + board.members && + _.contains(_.pluck(board.members, 'userId'), inviter._id) && + _.where(board.members, {userId: inviter._id})[0].isActive && + _.where(board.members, {userId: inviter._id})[0].isAdmin; + if (!allowInvite) throw new Meteor.Error('error-board-notAMember'); + + this.unblock(); + + const posAt = username.indexOf('@'); + let user = null; + if (posAt>=0) { + user = Users.findOne({emails: {$elemMatch: {address: username}}}); + } else { + user = Users.findOne(username) || Users.findOne({ username }); + } + if (user) { + if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf'); + } else { + if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist'); + + const email = username; + username = email.substring(0, posAt); + const newUserId = Accounts.createUser({ username, email }); + if (!newUserId) throw new Meteor.Error('error-user-notCreated'); + // assume new user speak same language with inviter + if (inviter.profile && inviter.profile.language) { + Users.update(newUserId, { + $set: { + 'profile.language': inviter.profile.language, + }, + }); + } + Accounts.sendEnrollmentEmail(newUserId); + user = Users.findOne(newUserId); + } + + board.addMember(user._id); + user.addInvite(boardId); + + if (!process.env.MAIL_URL || (!Email)) return { username: user.username }; + + try { + let rootUrl = Meteor.absoluteUrl.defaultOptions.rootUrl || ''; + if (!rootUrl.endsWith('/')) rootUrl = `${rootUrl}/`; + const boardUrl = `${rootUrl}b/${board._id}/${board.slug}`; + + const vars = { + user: user.username, + inviter: inviter.username, + board: board.title, + url: boardUrl, + }; + const lang = user.getLanguage(); + Email.send({ + to: user.emails[0].address, + from: Accounts.emailTemplates.from, + subject: TAPi18n.__('email-invite-subject', vars, lang), + text: TAPi18n.__('email-invite-text', vars, lang), + }); + } catch (e) { + throw new Meteor.Error('email-fail', e.message); + } + + return { username: user.username, email: user.emails[0].address }; + }, + }); +} + +Users.before.insert((userId, doc) => { + doc.profile = doc.profile || {}; + + if (!doc.username && doc.profile.name) { + doc.username = doc.profile.name.toLowerCase().replace(/\s/g, ''); + } +}); + +if (Meteor.isServer) { + // Let mongoDB ensure username unicity + Meteor.startup(() => { + Users._collection._ensureIndex({ + username: 1, + }, { unique: true }); + }); + + // Each board document contains the de-normalized number of users that have + // starred it. If the user star or unstar a board, we need to update this + // counter. + // We need to run this code on the server only, otherwise the incrementation + // will be done twice. + Users.after.update(function(userId, user, fieldNames) { + // The `starredBoards` list is hosted on the `profile` field. If this + // field hasn't been modificated we don't need to run this hook. + if (!_.contains(fieldNames, 'profile')) + return; + + // To calculate a diff of board starred ids, we get both the previous + // and the newly board ids list + function getStarredBoardsIds(doc) { + return doc.profile && doc.profile.starredBoards; + } + const oldIds = getStarredBoardsIds(this.previous); + const newIds = getStarredBoardsIds(user); + + // The _.difference(a, b) method returns the values from a that are not in + // 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) { + boardsIds.forEach((boardId) => { + Boards.update(boardId, {$inc: {stars: inc}}); + }); + } + incrementBoards(_.difference(oldIds, newIds), -1); + incrementBoards(_.difference(newIds, oldIds), +1); + }); + + // XXX i18n + Users.after.insert((userId, doc) => { + const ExampleBoard = { + title: 'Welcome Board', + userId: doc._id, + permission: 'private', + }; + + // Insert the Welcome Board + Boards.insert(ExampleBoard, (err, boardId) => { + + ['Basics', 'Advanced'].forEach((title) => { + const list = { + title, + boardId, + userId: ExampleBoard.userId, + + // XXX Not certain this is a bug, but we except these fields get + // inserted by the Lists.before.insert collection-hook. Since this + // hook is not called in this case, we have to dublicate the logic and + // set them here. + archived: false, + createdAt: new Date(), + }; + + Lists.insert(list); + }); + }); + }); +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..77a9fb74 --- /dev/null +++ b/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" + } +} diff --git a/sandstorm-pkgdef.capnp b/sandstorm-pkgdef.capnp index 0e41b5a1..586d2d71 100644 --- a/sandstorm-pkgdef.capnp +++ b/sandstorm-pkgdef.capnp @@ -22,7 +22,7 @@ const pkgdef :Spk.PackageDefinition = ( appTitle = (defaultText = "Wekan"), # The name of the app as it is displayed to the user. - appVersion = 5, + appVersion = 6, # Increment this for every release. appMarketingVersion = (defaultText = "0.9.0"), diff --git a/sandstorm.js b/sandstorm.js index c430c3a8..a711a960 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -21,24 +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.update(userId, { - $set: { - 'profile.avatarUrl': avatarUrl, - }, - }); - } - function updateUserPermissions(userId, permissions) { const isActive = permissions.indexOf('participate') > -1; const isAdmin = permissions.indexOf('configure') > -1; 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) @@ -59,33 +48,28 @@ 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 // it is only accessible on the client. - const path = `/boards/${sandstormBoard._id}/${sandstormBoard.slug}`; + const boardPath = `/b/${sandstormBoard._id}/${sandstormBoard.slug}`; res.writeHead(301, { - Location: base + path, + Location: base + boardPath, }); 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); } }); @@ -96,25 +80,75 @@ if (isSandstorm && Meteor.isServer) { // despite the appearances `userId` is null in this block. Users.after.insert((userId, doc) => { if (!Boards.findOne(sandstormBoard._id)) { - Boards.insert(sandstormBoard, {validate: false}); + Boards.insert(sandstormBoard, { validate: false }); Activities.update( { activityTypeId: sandstormBoard._id }, { $set: { userId: doc._id }} ); } + // We rely on username uniqueness for the user mention feature, but + // Sandstorm doesn't enforce this property -- see #352. Our strategy to + // generate unique usernames from the Sandstorm `preferredHandle` is to + // append a number that we increment until we generate a username that no + // one already uses (eg, 'max', 'max1', 'max2'). + function generateUniqueUsername(username, appendNumber) { + return username + String(appendNumber === 0 ? '' : appendNumber); + } + + const username = doc.services.sandstorm.preferredHandle; + let appendNumber = 0; + while (Users.findOne({ + _id: { $ne: doc._id }, + username: generateUniqueUsername(username, appendNumber), + })) { + appendNumber += 1; + } + + Users.update(doc._id, { + $set: { + username: generateUniqueUsername(username, appendNumber), + 'profile.fullname': doc.services.sandstorm.name, + }, + }); + 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) { + // Since the Sandstorm grain is displayed in an iframe of the Sandstorm shell, + // we need to explicitly expose meta data like the page title or the URL path + // so that they could appear in the browser window. + // See https://docs.sandstorm.io/en/latest/developing/path/ + function updateSandstormMetaData(msg) { + return window.parent.postMessage(msg, '*'); + } + + FlowRouter.triggers.enter([({ path }) => { + updateSandstormMetaData({ setPath: path }); + }]); + + Tracker.autorun(() => { + updateSandstormMetaData({ setTitle: DocHead.getTitle() }); + }); + // XXX Hack. `Meteor.absoluteUrl` doesn't work in Sandstorm, since every // session has a different URL whereas Meteor computes absoluteUrl based on // the ROOT_URL environment variable. So we overwrite this function on a // 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}/, ''); }; diff --git a/server/migrations.js b/server/migrations.js index 05f5ff7d..99125976 100644 --- a/server/migrations.js +++ b/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, diff --git a/server/publications/boards.js b/server/publications/boards.js index 403d0084..814d1df8 100644 --- a/server/publications/boards.js +++ b/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({ @@ -25,6 +25,7 @@ Meteor.publish('boards', function() { archived: 1, slug: 1, title: 1, + description: 1, color: 1, members: 1, permission: 1, diff --git a/server/publications/fast-render.js b/server/publications/fast-render.js new file mode 100644 index 00000000..e28b6f2e --- /dev/null +++ b/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); +}); |