Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cfb6eee4b | ||
|
|
6c68867efe | ||
|
|
0ef84a0d5f | ||
|
|
e0f4758667 | ||
|
|
8c149da9e9 | ||
|
|
18eafe2fec | ||
|
|
5ac636fdd4 | ||
|
|
6db3474cdf | ||
|
|
6a1bc167cf | ||
|
|
191071d295 | ||
|
|
3e4a9471ba | ||
|
|
9b68aa919a | ||
|
|
8732e4b18f | ||
|
|
d26786a628 | ||
|
|
54b9035eb2 | ||
|
|
3cf6ed916f | ||
|
|
351d9d0c95 | ||
|
|
c0923ed3cf | ||
|
|
fc2eb2e935 | ||
|
|
88844034fc | ||
|
|
3d33ea911a | ||
|
|
bb08e40ea5 | ||
|
|
c4879de4b1 | ||
|
|
fd0afd7b05 | ||
|
|
5c02850bc1 | ||
|
|
1f450df1af | ||
|
|
e933890730 | ||
|
|
8eafa1ac66 | ||
|
|
858bb35612 | ||
|
|
f9018fc3a8 | ||
|
|
252c4b19f9 | ||
|
|
05349a5deb | ||
|
|
d52affe658 | ||
|
|
23dcd084a4 | ||
|
|
a853189486 | ||
|
|
82d0a3df85 | ||
|
|
ef189477dd | ||
|
|
4bcc795778 | ||
|
|
01346152b5 | ||
|
|
5b7b5b3e7e | ||
|
|
2df29324e6 | ||
|
|
51f52fee23 | ||
|
|
6e088af3d1 | ||
|
|
fc9f0d8392 | ||
|
|
cfa2bbd3d4 | ||
|
|
52d633ca69 | ||
|
|
b2fee6a6c1 | ||
|
|
66d35a1528 | ||
|
|
b7105d7b57 | ||
|
|
d5fbd50b76 | ||
|
|
06515559a6 | ||
|
|
c61a126c8b | ||
|
|
b737adfcdf | ||
|
|
3b44acd87c | ||
|
|
6fad68b9b9 |
No files matched your search
@@ -98,4 +98,3 @@ percolate:synced-cron
|
||||
easylogic:summernote
|
||||
cfs:filesystem
|
||||
ostrio:cookies
|
||||
ostrio:files
|
||||
+1
-2
@@ -109,7 +109,7 @@ mobile-status-bar@1.1.0
|
||||
modern-browsers@0.1.5
|
||||
modules@0.15.0
|
||||
modules-runtime@0.12.0
|
||||
momentjs:moment@2.25.2
|
||||
momentjs:moment@2.26.0
|
||||
mongo@1.10.0
|
||||
mongo-decimal@0.1.1
|
||||
mongo-dev-server@1.1.0
|
||||
@@ -134,7 +134,6 @@ observe-sequence@1.0.16
|
||||
ongoworks:speakingurl@1.1.0
|
||||
ordered-dict@1.1.0
|
||||
ostrio:cookies@2.6.0
|
||||
ostrio:files@1.14.2
|
||||
peerlibrary:assert@0.3.0
|
||||
peerlibrary:base-component@0.16.0
|
||||
peerlibrary:blaze-components@0.15.1
|
||||
|
||||
@@ -1,5 +1,97 @@
|
||||
# v4.09 2020-05-27 Wekan release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix vote export & export/import currency custom field to CSV/TSV](https://github.com/wekan/wekan/pull/3128).
|
||||
Thanks to brymut.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v4.08 2020-05-26 Wekan release
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
- [Add the 'Currency' Custom Field type](https://github.com/wekan/wekan/pull/3123).
|
||||
Thanks to habenamare.
|
||||
|
||||
and adds the following updates:
|
||||
|
||||
- [Add some changes to Modern theme](https://github.com/wekan/wekan/commit/6a1bc167cf10e75d61b3196db9eac2978d70ad8e).
|
||||
Thanks to jeroenstoker and xet7.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Fix typo that caused parse error](https://github.com/wekan/wekan/commit/351d9d0c9577c9d543d543bc12a51388b0141324).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v4.07 2020-05-26 Wekan release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix move selection](https://github.com/wekan/wekan/pull/3120).
|
||||
Thanks to marc1006.
|
||||
- [Fix Python API generation](https://github.com/wekan/wekan/pull/3121).
|
||||
Thanks to marc1006.
|
||||
- [Fix default value of `sort`](https://github.com/wekan/wekan/pull/3122).
|
||||
Thanks to marc1006.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v4.06 2020-05-25 Wekan release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix Card export CSV, check for vote
|
||||
undefined](https://github.com/wekan/wekan/commit/8eafa1ac66fdcf5fb5f0a95aa6cfee454ddad67f).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v4.05 2020-05-25 Wekan release
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
- [Import/Export Custom Fields in CSV/TSV](https://github.com/wekan/wekan/pull/3115).
|
||||
Thanks to brymut.
|
||||
|
||||
and adds the following updates:
|
||||
|
||||
- [Update packages](https://github.com/wekan/wekan/commit/3b44acd87c35340bf9fe5d210f4402f1b1a1dfdf).
|
||||
Thanks to xet7.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- Try to fix Snap [1](https://github.com/wekan/wekan/commit/6fad68b9b9afd8de7074037d73eeac40f6a3f7c1), [2](https://github.com/wekan/wekan/commit/b737adfcdfc9b8084a7eb84420a89c014bbec1fb). Later reverted those like other ostrio-files changes too.
|
||||
Thanks to xet7.
|
||||
- [Add default attachments store path /var/snap/wekan/common/uploads where attachments will be
|
||||
stored](https://github.com/wekan/wekan/commit/c61a126c8bcb25a1eda0203b89c990ae31de7a70).
|
||||
Thanks to xet7.
|
||||
- [Make scrollParentContainer() more robust as it's used in a timeout callback. Example exception: Exception in setTimeout callback: TypeError: Cannot read property 'parentComponent' of null. Probably there is a better fix for this](https://github.com/wekan/wekan/commit/d5fbd50b760b1d3b84b5b4e8af3a8ed7608e2918).
|
||||
Thanks to marc1006.
|
||||
- [Fix error link not available. Fixes: Exception in template helper: TypeError: Cannot read property 'link' of
|
||||
undefined](https://github.com/wekan/wekan/commit/b7105d7b5712dcdbf9dadebfddaba7691810da5c).
|
||||
Thanks to marc1006.
|
||||
- [Fix minicard cover functionality. Otherwise, if `this.coverId` is undefined then `Attachments.findOne()` would return any
|
||||
attachment](https://github.com/wekan/wekan/commit/66d35a15280795b76a81c3e59cebbd2a29e4dff8).
|
||||
Thanks to marc1006.
|
||||
- [Some fixes suggested by deepcode.ai](https://github.com/wekan/wekan/pull/3112).
|
||||
Thanks to marc1006.
|
||||
- [Sorry marc1006, I had to revert deepcode.ai arrow function fixes because Python API docs generator does not work all when code has arrow functions](https://github.com/wekan/wekan/commit/f9018fc3a87080d8d97c371e29a8f3f0a20ca932).
|
||||
Thanks to xet7.
|
||||
- [Move In Progress ostrio-files changes to separate branch, and revert ostrio-files changes, so that:
|
||||
Export to CSV/TSV with custom fields works, Attachments are not exported to disk,
|
||||
It is possible to build arm64/s390x versions
|
||||
again](https://github.com/wekan/wekan/commit/d52affe65893f17bab59bb43aa9f5afbb54993d3).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v4.04 2020-05-24 Wekan release
|
||||
|
||||
Please use v4.05 or newer instead, that works better.
|
||||
|
||||
This release adds the following features:
|
||||
|
||||
- [Found Time Tracking GPLv3 software "Titra" with integration to Wekan](https://github.com/wekan/wekan/wiki/Time-Tracking).
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v4.04.0"
|
||||
appVersion: "v4.09.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -152,18 +152,17 @@ BlazeComponent.extendComponent({
|
||||
|
||||
attachmentLink() {
|
||||
const attachment = this.currentData().activity.attachment();
|
||||
const link = attachment.link('original', '/');
|
||||
// trying to display url before file is stored generates js errors
|
||||
return (
|
||||
(attachment &&
|
||||
link &&
|
||||
attachment.url({ download: true }) &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: link,
|
||||
href: attachment.url({ download: true }),
|
||||
target: '_blank',
|
||||
},
|
||||
attachment.name,
|
||||
attachment.name(),
|
||||
),
|
||||
)) ||
|
||||
this.currentData().activity.attachmentName
|
||||
|
||||
@@ -679,7 +679,7 @@ setBoardClear(color1,color2)
|
||||
color: #333
|
||||
|
||||
.pop-over
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.1)
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.2)
|
||||
border: 0
|
||||
border-radius: 5px
|
||||
|
||||
@@ -687,6 +687,17 @@ setBoardClear(color1,color2)
|
||||
padding: 10px
|
||||
border-bottom: 0
|
||||
border-radius: 5px 5px 0 0
|
||||
background:#eee
|
||||
|
||||
.pop-over .header .header-title
|
||||
font-family: Poppins
|
||||
font-size:16px
|
||||
color:#333
|
||||
|
||||
.pop-over .header .close-btn
|
||||
font-size:20px
|
||||
top:6px
|
||||
right:8px
|
||||
|
||||
.pop-over .content-container .content
|
||||
padding: 5px 20px 20px
|
||||
@@ -708,3 +719,23 @@ setBoardClear(color1,color2)
|
||||
.sidebar .sidebar-content
|
||||
padding: 30px
|
||||
|
||||
/* Notifications */
|
||||
.board-color-modern section#notifications-drawer
|
||||
border-radius:5px
|
||||
|
||||
.board-color-modern section#notifications-drawer .header
|
||||
padding: 18px 16px
|
||||
border-bottom: 0
|
||||
border-radius: 5px 5px 0 0
|
||||
background: #eee
|
||||
|
||||
.board-color-modern section#notifications-drawer .header h5
|
||||
font-family: Poppins
|
||||
font-weight: bold
|
||||
|
||||
.board-color-modern section#notifications-drawer .header .close
|
||||
font-size: 20px
|
||||
top: 14px
|
||||
|
||||
section#notifications-drawer .header .toggle-read
|
||||
top: 18px
|
||||
@@ -18,19 +18,12 @@ template(name="attachmentDeletePopup")
|
||||
p {{_ "attachment-delete-pop"}}
|
||||
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
|
||||
|
||||
template(name="uploadingPopup")
|
||||
.uploading-info
|
||||
span.upload-percentage {{progress}}%
|
||||
.upload-progress-frame
|
||||
.upload-progress-bar(style="width: {{progress}}%;")
|
||||
span.upload-size {{fileSize}}
|
||||
|
||||
template(name="attachmentsGalery")
|
||||
.attachments-galery
|
||||
each attachments
|
||||
.attachment-item
|
||||
a.attachment-thumbnail.swipebox(href="{{url}}" download="{{name}}" title="{{name}}")
|
||||
if isUploaded
|
||||
a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}")
|
||||
if isUploaded
|
||||
if isImage
|
||||
img.attachment-thumbnail-img(src="{{url}}")
|
||||
else
|
||||
@@ -40,7 +33,7 @@ template(name="attachmentsGalery")
|
||||
p.attachment-details
|
||||
= name
|
||||
span.attachment-details-actions
|
||||
a.js-download(href="{{url download=true}}" download="{{name}}")
|
||||
a.js-download(href="{{url download=true}}")
|
||||
i.fa.fa-download
|
||||
| {{_ 'download'}}
|
||||
if currentUser.isBoardMember
|
||||
|
||||
@@ -13,10 +13,10 @@ Template.attachmentsGalery.events({
|
||||
event.stopPropagation();
|
||||
},
|
||||
'click .js-add-cover'() {
|
||||
Cards.findOne(this.meta.cardId).setCover(this._id);
|
||||
Cards.findOne(this.cardId).setCover(this._id);
|
||||
},
|
||||
'click .js-remove-cover'() {
|
||||
Cards.findOne(this.meta.cardId).unsetCover();
|
||||
Cards.findOne(this.cardId).unsetCover();
|
||||
},
|
||||
'click .js-preview-image'(event) {
|
||||
Popup.open('previewAttachedImage').call(this, event);
|
||||
@@ -45,63 +45,22 @@ Template.attachmentsGalery.events({
|
||||
},
|
||||
});
|
||||
|
||||
Template.attachmentsGalery.helpers({
|
||||
url() {
|
||||
return Attachments.link(this, 'original', '/');
|
||||
},
|
||||
isUploaded() {
|
||||
return !this.meta.uploading;
|
||||
},
|
||||
isImage() {
|
||||
return !!this.isImage;
|
||||
},
|
||||
});
|
||||
|
||||
Template.previewAttachedImagePopup.events({
|
||||
'click .js-large-image-clicked'() {
|
||||
Popup.close();
|
||||
},
|
||||
});
|
||||
|
||||
Template.previewAttachedImagePopup.helpers({
|
||||
url() {
|
||||
return Attachments.link(this, 'original', '/');
|
||||
}
|
||||
});
|
||||
|
||||
// For uploading popup
|
||||
|
||||
let uploadFileSize = new ReactiveVar('');
|
||||
let uploadProgress = new ReactiveVar(0);
|
||||
|
||||
Template.cardAttachmentsPopup.events({
|
||||
'change .js-attach-file'(event, instance) {
|
||||
'change .js-attach-file'(event) {
|
||||
const card = this;
|
||||
const callbacks = {
|
||||
onBeforeUpload: (err, fileData) => {
|
||||
Popup.open('uploading')(this.clickEvent);
|
||||
uploadFileSize.set('...');
|
||||
uploadProgress.set(0);
|
||||
return true;
|
||||
},
|
||||
onUploaded: (err, attachment) => {
|
||||
if (attachment && attachment._id && attachment.isImage) {
|
||||
card.setCover(attachment._id);
|
||||
}
|
||||
Popup.close();
|
||||
},
|
||||
onStart: (error, fileData) => {
|
||||
uploadFileSize.set(formatBytes(fileData.size));
|
||||
},
|
||||
onError: (err, fileObj) => {
|
||||
console.log('Error!', err);
|
||||
},
|
||||
onProgress: (progress, fileData) => {
|
||||
uploadProgress.set(progress);
|
||||
}
|
||||
};
|
||||
const processFile = f => {
|
||||
Utils.processUploadedAttachment(card, f, callbacks);
|
||||
Utils.processUploadedAttachment(card, f, attachment => {
|
||||
if (attachment && attachment._id && attachment.isImage()) {
|
||||
card.setCover(attachment._id);
|
||||
}
|
||||
Popup.close();
|
||||
});
|
||||
};
|
||||
|
||||
FS.Utility.eachFile(event, f => {
|
||||
@@ -141,22 +100,12 @@ Template.cardAttachmentsPopup.events({
|
||||
});
|
||||
},
|
||||
'click .js-computer-upload'(event, templateInstance) {
|
||||
this.clickEvent = event;
|
||||
templateInstance.find('.js-attach-file').click();
|
||||
event.preventDefault();
|
||||
},
|
||||
'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
|
||||
});
|
||||
|
||||
Template.uploadingPopup.helpers({
|
||||
fileSize: () => {
|
||||
return uploadFileSize.get();
|
||||
},
|
||||
progress: () => {
|
||||
return uploadProgress.get();
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
|
||||
const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
|
||||
let pastedResults = null;
|
||||
@@ -200,26 +149,20 @@ Template.previewClipboardImagePopup.events({
|
||||
if (results && results.file) {
|
||||
window.oPasted = pastedResults;
|
||||
const card = this;
|
||||
const settings = {
|
||||
file: results.file,
|
||||
streams: 'dynamic',
|
||||
chunkSize: 'dynamic',
|
||||
};
|
||||
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') {
|
||||
settings.fileName =
|
||||
new Date().getTime() + results.file.type.replace('.+/', '');
|
||||
file.name(results.file.type.replace('image/', 'clipboard.'));
|
||||
}
|
||||
}
|
||||
settings.meta = {};
|
||||
settings.meta.updatedAt = new Date().getTime();
|
||||
settings.meta.boardId = card.boardId;
|
||||
settings.meta.cardId = card._id;
|
||||
settings.meta.userId = Meteor.userId();
|
||||
const attachment = Attachments.insert(settings);
|
||||
file.updatedAt(new Date());
|
||||
file.boardId = card.boardId;
|
||||
file.cardId = card._id;
|
||||
file.userId = Meteor.userId();
|
||||
const attachment = Attachments.insert(file);
|
||||
|
||||
if (attachment && attachment._id && attachment.isImage) {
|
||||
if (attachment && attachment._id && attachment.isImage()) {
|
||||
card.setCover(attachment._id);
|
||||
}
|
||||
|
||||
@@ -229,15 +172,3 @@ Template.previewClipboardImagePopup.events({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -64,17 +64,6 @@
|
||||
border: 1px solid black
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2)
|
||||
|
||||
.uploading-info
|
||||
.upload-progress-frame
|
||||
background-color: grey;
|
||||
border: 1px solid;
|
||||
height: 22px;
|
||||
|
||||
.upload-progress-bar
|
||||
background-color: blue;
|
||||
height: 20px;
|
||||
padding: 1px;
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
.attachments-galery
|
||||
flex-direction
|
||||
|
||||
@@ -53,6 +53,23 @@ template(name="cardCustomField-number")
|
||||
if value
|
||||
= value
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
+inlinedForm(classNames="js-card-customfield-currency")
|
||||
input(type="text" value=data.value)
|
||||
.edit-controls.clearfix
|
||||
button.primary(type="submit") {{_ 'save'}}
|
||||
a.fa.fa-times-thin.js-close-inlined-form
|
||||
else
|
||||
a.js-open-inlined-form
|
||||
if value
|
||||
= formattedValue
|
||||
else
|
||||
| {{_ 'edit'}}
|
||||
else
|
||||
if value
|
||||
= formattedValue
|
||||
|
||||
template(name="cardCustomField-date")
|
||||
if canModifyCard
|
||||
a.js-edit-date(title="{{showTitle}}" class="{{classes}}")
|
||||
|
||||
@@ -80,6 +80,36 @@ CardCustomField.register('cardCustomField');
|
||||
}
|
||||
}.register('cardCustomField-number'));
|
||||
|
||||
// cardCustomField-currency
|
||||
(class extends CardCustomField {
|
||||
onCreated() {
|
||||
super.onCreated();
|
||||
|
||||
this.currencyCode = this.data().definition.settings.currencyCode;
|
||||
}
|
||||
|
||||
formattedValue() {
|
||||
const locale = TAPi18n.getLanguage();
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: this.currencyCode,
|
||||
}).format(this.data().value);
|
||||
}
|
||||
|
||||
events() {
|
||||
return [
|
||||
{
|
||||
'submit .js-card-customfield-currency'(event) {
|
||||
event.preventDefault();
|
||||
const value = Number(this.find('input').value, 10);
|
||||
this.card.setCustomField(this.customFieldId, value);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}.register('cardCustomField-currency'));
|
||||
|
||||
// cardCustomField-date
|
||||
(class extends CardCustomField {
|
||||
onCreated() {
|
||||
|
||||
@@ -74,11 +74,18 @@ BlazeComponent.extendComponent({
|
||||
|
||||
scrollParentContainer() {
|
||||
const cardPanelWidth = 510;
|
||||
const bodyBoardComponent = this.parentComponent().parentComponent();
|
||||
const parentComponent = this.parentComponent();
|
||||
// TODO sometimes parentComponent is not available, maybe because it's not
|
||||
// yet created?!
|
||||
if (!parentComponent) return;
|
||||
const bodyBoardComponent = parentComponent.parentComponent();
|
||||
//On Mobile View Parent is Board, Not Board Body. I cant see how this funciton should work then.
|
||||
if (bodyBoardComponent === null) return;
|
||||
const $cardView = this.$(this.firstNode());
|
||||
const $cardContainer = bodyBoardComponent.$('.js-swimlanes');
|
||||
// TODO sometimes cardContainer is not available, maybe because it's not yet
|
||||
// created?!
|
||||
if (!$cardContainer) return;
|
||||
const cardContainerScroll = $cardContainer.scrollLeft();
|
||||
const cardContainerWidth = $cardContainer.width();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ template(name="minicard")
|
||||
.handle
|
||||
.fa.fa-arrows
|
||||
if cover
|
||||
.minicard-cover(style="background-image: url('{{coverUrl}}');")
|
||||
.minicard-cover(style="background-image: url('{{cover.url}}');")
|
||||
if labels
|
||||
.minicard-labels
|
||||
each labels
|
||||
@@ -74,8 +74,12 @@ template(name="minicard")
|
||||
+viewer
|
||||
= definition.name
|
||||
.minicard-custom-field-item
|
||||
+viewer
|
||||
= trueValue
|
||||
if $eq definition.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(definition)
|
||||
else
|
||||
+viewer
|
||||
= trueValue
|
||||
|
||||
if getAssignees
|
||||
.minicard-assignees.js-minicard-assignees
|
||||
|
||||
@@ -9,6 +9,20 @@ BlazeComponent.extendComponent({
|
||||
return 'minicard';
|
||||
},
|
||||
|
||||
formattedCurrencyCustomFieldValue(definition) {
|
||||
const customField = this.data()
|
||||
.customFieldsWD()
|
||||
.find(f => f._id === definition._id);
|
||||
const customFieldTrueValue =
|
||||
customField && customField.trueValue ? customField.trueValue : '';
|
||||
|
||||
const locale = TAPi18n.getLanguage();
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: definition.settings.currencyCode,
|
||||
}).format(customFieldTrueValue);
|
||||
},
|
||||
|
||||
events() {
|
||||
return [
|
||||
{
|
||||
@@ -52,7 +66,4 @@ Template.minicard.helpers({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
coverUrl() {
|
||||
return Attachments.findOne(this.coverId).link('original', '/');
|
||||
},
|
||||
});
|
||||
@@ -152,31 +152,33 @@ Template.editor.onRendered(() => {
|
||||
const processData = function(fileObj) {
|
||||
Utils.processUploadedAttachment(
|
||||
currentCard,
|
||||
fileObj,
|
||||
{ onUploaded:
|
||||
attachment => {
|
||||
if (attachment && attachment._id && attachment.isImage) {
|
||||
attachment.one('uploaded', function() {
|
||||
const maxTry = 3;
|
||||
const checkItvl = 500;
|
||||
let retry = 0;
|
||||
const checkUrl = function() {
|
||||
// even though uploaded event fired, attachment.url() is still null somehow //TODO
|
||||
const url = Attachments.link(attachment, 'original', '/');
|
||||
if (url) {
|
||||
insertImage(
|
||||
`${location.protocol}//${location.host}${url}`,
|
||||
);
|
||||
} else {
|
||||
retry++;
|
||||
if (retry < maxTry) {
|
||||
setTimeout(checkUrl, checkItvl);
|
||||
}
|
||||
fileObj,
|
||||
attachment => {
|
||||
if (
|
||||
attachment &&
|
||||
attachment._id &&
|
||||
attachment.isImage()
|
||||
) {
|
||||
attachment.one('uploaded', function() {
|
||||
const maxTry = 3;
|
||||
const checkItvl = 500;
|
||||
let retry = 0;
|
||||
const checkUrl = function() {
|
||||
// even though uploaded event fired, attachment.url() is still null somehow //TODO
|
||||
const url = attachment.url();
|
||||
if (url) {
|
||||
insertImage(
|
||||
`${location.protocol}//${location.host}${url}`,
|
||||
);
|
||||
} else {
|
||||
retry++;
|
||||
if (retry < maxTry) {
|
||||
setTimeout(checkUrl, checkItvl);
|
||||
}
|
||||
};
|
||||
checkUrl();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
checkUrl();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -164,6 +164,7 @@ BlazeComponent.extendComponent({
|
||||
const boardId = Session.get('currentBoard');
|
||||
const actionId = Actions.insert({
|
||||
actionType: 'removeMember',
|
||||
// deepcode ignore NoHardcodedCredentials: it's no credential
|
||||
username: '*',
|
||||
boardId,
|
||||
desc,
|
||||
|
||||
@@ -467,7 +467,7 @@ Template.exportBoard.events({
|
||||
'click .html-export-board': async event => {
|
||||
event.preventDefault();
|
||||
await ExportHtml(Popup)();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.labelsWidget.events({
|
||||
|
||||
@@ -33,6 +33,17 @@ template(name="createCustomFieldPopup")
|
||||
option(value=value selected="selected") {{name}}
|
||||
else
|
||||
option(value=value) {{name}}
|
||||
|
||||
div.js-field-settings.js-field-settings-currency(class="{{#if isTypeNotSelected 'currency'}}hide{{/if}}")
|
||||
label
|
||||
| {{_ 'custom-field-currency-option'}}
|
||||
select.js-field-currency
|
||||
each getCurrencyCodes
|
||||
if selected
|
||||
option(value=value selected="selected") {{name}}
|
||||
else
|
||||
option(value=value) {{name}}
|
||||
|
||||
div.js-field-settings.js-field-settings-dropdown(class="{{#if isTypeNotSelected 'dropdown'}}hide{{/if}}")
|
||||
label
|
||||
| {{_ 'custom-field-dropdown-options'}}
|
||||
|
||||
@@ -16,12 +16,62 @@ BlazeComponent.extendComponent({
|
||||
}).register('customFieldsSidebar');
|
||||
|
||||
const CreateCustomFieldPopup = BlazeComponent.extendComponent({
|
||||
_types: ['text', 'number', 'date', 'dropdown'],
|
||||
_types: ['text', 'number', 'date', 'dropdown', 'currency'],
|
||||
|
||||
_currencyList: [
|
||||
{
|
||||
name: 'US Dollar',
|
||||
code: 'USD',
|
||||
},
|
||||
{
|
||||
name: 'Euro',
|
||||
code: 'EUR',
|
||||
},
|
||||
{
|
||||
name: 'Yen',
|
||||
code: 'JPY',
|
||||
},
|
||||
{
|
||||
name: 'Pound Sterling',
|
||||
code: 'GBP',
|
||||
},
|
||||
{
|
||||
name: 'Australian Dollar',
|
||||
code: 'AUD',
|
||||
},
|
||||
{
|
||||
name: 'Canadian Dollar',
|
||||
code: 'CAD',
|
||||
},
|
||||
{
|
||||
name: 'Swiss Franc',
|
||||
code: 'CHF',
|
||||
},
|
||||
{
|
||||
name: 'Yuan Renminbi',
|
||||
code: 'CNY',
|
||||
},
|
||||
{
|
||||
name: 'Hong Kong Dollar',
|
||||
code: 'HKD',
|
||||
},
|
||||
{
|
||||
name: 'New Zealand Dollar',
|
||||
code: 'NZD',
|
||||
},
|
||||
],
|
||||
|
||||
onCreated() {
|
||||
this.type = new ReactiveVar(
|
||||
this.data().type ? this.data().type : this._types[0],
|
||||
);
|
||||
|
||||
this.currencyCode = new ReactiveVar(
|
||||
this.data().settings && this.data().settings.currencyCode
|
||||
? this.data().settings.currencyCode
|
||||
: this._currencyList[0].code,
|
||||
);
|
||||
|
||||
this.dropdownItems = new ReactiveVar(
|
||||
this.data().settings && this.data().settings.dropdownItems
|
||||
? this.data().settings.dropdownItems
|
||||
@@ -44,6 +94,18 @@ const CreateCustomFieldPopup = BlazeComponent.extendComponent({
|
||||
return this.type.get() !== type;
|
||||
},
|
||||
|
||||
getCurrencyCodes() {
|
||||
const currentCode = this.currencyCode.get();
|
||||
|
||||
return this._currencyList.map(({ name, code }) => {
|
||||
return {
|
||||
name: `${code} - ${name}`,
|
||||
value: code,
|
||||
selected: code === currentCode,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getDropdownItems() {
|
||||
const items = this.dropdownItems.get();
|
||||
Array.from(this.findAll('.js-field-settings-dropdown input')).forEach(
|
||||
@@ -62,6 +124,11 @@ const CreateCustomFieldPopup = BlazeComponent.extendComponent({
|
||||
getSettings() {
|
||||
const settings = {};
|
||||
switch (this.type.get()) {
|
||||
case 'currency': {
|
||||
const currencyCode = this.currencyCode.get();
|
||||
settings.currencyCode = currencyCode;
|
||||
break;
|
||||
}
|
||||
case 'dropdown': {
|
||||
const dropdownItems = this.getDropdownItems().filter(
|
||||
item => !!item.name.trim(),
|
||||
@@ -80,6 +147,10 @@ const CreateCustomFieldPopup = BlazeComponent.extendComponent({
|
||||
const value = evt.target.value;
|
||||
this.type.set(value);
|
||||
},
|
||||
'change .js-field-currency'(evt) {
|
||||
const value = evt.target.value;
|
||||
this.currencyCode.set(value);
|
||||
},
|
||||
'keydown .js-dropdown-item.last'(evt) {
|
||||
if (evt.target.value.trim() && evt.keyCode === 13) {
|
||||
const items = this.getDropdownItems();
|
||||
|
||||
@@ -157,8 +157,9 @@ Template.disambiguateMultiMemberPopup.events({
|
||||
});
|
||||
|
||||
Template.moveSelectionPopup.events({
|
||||
'click .js-select-list'() {
|
||||
mutateSelectedCards('move', this._id);
|
||||
'click .js-select-list'(event) {
|
||||
// Move the minicard to the end of the target list
|
||||
mutateSelectedCards('moveToEndOfList', { listId: this._id });
|
||||
EscapeActions.executeUpTo('multiselection');
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@ template(name="swimlane")
|
||||
.swimlane
|
||||
+swimlaneHeader
|
||||
unless collapseSwimlane
|
||||
.swimlane.js-lists.js-swimlane
|
||||
.swimlane.js-lists.js-swimlane(id="swimlane-{{_id}}")
|
||||
if isMiniScreen
|
||||
if currentListIsInThisSwimlane _id
|
||||
+list(currentList)
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
};
|
||||
return this.each(function() {
|
||||
const element = this;
|
||||
$(element).bind('dragenter dragover dragleave', stopFn);
|
||||
return $(element).bind('drop', function(event) {
|
||||
$(element).on('dragenter dragover dragleave', stopFn);
|
||||
return $(element).on('drop', function(event) {
|
||||
stopFn(event);
|
||||
const files = event.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
|
||||
+37
-30
@@ -1,6 +1,6 @@
|
||||
const JSZip = require('jszip');
|
||||
|
||||
window.ExportHtml = (Popup) => {
|
||||
window.ExportHtml = Popup => {
|
||||
const saveAs = function(blob, filename) {
|
||||
let dl = document.createElement('a');
|
||||
dl.href = window.URL.createObjectURL(blob);
|
||||
@@ -12,21 +12,22 @@ window.ExportHtml = (Popup) => {
|
||||
dl.click();
|
||||
};
|
||||
|
||||
const asyncForEach = async function (array, callback) {
|
||||
const asyncForEach = async function(array, callback) {
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
await callback(array[index], index, array);
|
||||
}
|
||||
};
|
||||
|
||||
const getPageHtmlString = () => {
|
||||
return `<!doctype html>${
|
||||
window.document.querySelector('html').outerHTML
|
||||
}`;
|
||||
return `<!doctype html>${window.document.querySelector('html').outerHTML}`;
|
||||
};
|
||||
|
||||
const removeAnchors = htmlString => {
|
||||
const replaceOpenAnchor = htmlString.replace(new RegExp('<a ', 'gim'), '<span ');
|
||||
return replaceOpenAnchor.replace(new RegExp('<\/a', 'gim'), '</span');
|
||||
const replaceOpenAnchor = htmlString.replace(
|
||||
new RegExp('<a ', 'gim'),
|
||||
'<span ',
|
||||
);
|
||||
return replaceOpenAnchor.replace(new RegExp('</a', 'gim'), '</span');
|
||||
};
|
||||
|
||||
const ensureSidebarRemoved = () => {
|
||||
@@ -83,9 +84,8 @@ window.ExportHtml = (Popup) => {
|
||||
elem.src = elem.src;
|
||||
});
|
||||
Array.from(document.querySelectorAll('.is-editable')).forEach(elem => {
|
||||
elem.classList.remove('is-editable')
|
||||
})
|
||||
|
||||
elem.classList.remove('is-editable');
|
||||
});
|
||||
};
|
||||
|
||||
const getBoardSlug = () => {
|
||||
@@ -104,7 +104,8 @@ window.ExportHtml = (Popup) => {
|
||||
const responseBody = await response.text();
|
||||
|
||||
const finalResponse = responseBody.replace(
|
||||
new RegExp('packages\/[^\/]+\/upstream\/', 'gim'), '../'
|
||||
new RegExp('packages/[^/]+/upstream/', 'gim'),
|
||||
'../',
|
||||
);
|
||||
|
||||
const filename = elem.href
|
||||
@@ -138,30 +139,33 @@ window.ExportHtml = (Popup) => {
|
||||
};
|
||||
|
||||
const removeCssUrlSurround = url => {
|
||||
const working = url || "";
|
||||
const working = url || '';
|
||||
return working
|
||||
.split("url(")
|
||||
.join("")
|
||||
.split("\")")
|
||||
.join("")
|
||||
.split("\"")
|
||||
.join("")
|
||||
.split('url(')
|
||||
.join('')
|
||||
.split('")')
|
||||
.join('')
|
||||
.split('"')
|
||||
.join('')
|
||||
.split("')")
|
||||
.join("")
|
||||
.join('')
|
||||
.split("'")
|
||||
.join("")
|
||||
.split(")")
|
||||
.join("");
|
||||
.join('')
|
||||
.split(')')
|
||||
.join('');
|
||||
};
|
||||
|
||||
const getCardCovers = () => {
|
||||
return Array.from(document.querySelectorAll('.minicard-cover'))
|
||||
.filter(elem => elem.style['background-image'])
|
||||
}
|
||||
return Array.from(document.querySelectorAll('.minicard-cover')).filter(
|
||||
elem => elem.style['background-image'],
|
||||
);
|
||||
};
|
||||
|
||||
const downloadCardCovers = async (elements, zip, boardSlug) => {
|
||||
await asyncForEach(elements, async elem => {
|
||||
const response = await fetch(removeCssUrlSurround(elem.style['background-image']));
|
||||
const response = await fetch(
|
||||
removeCssUrlSurround(elem.style['background-image']),
|
||||
);
|
||||
const responseBody = await response.blob();
|
||||
const filename = removeCssUrlSurround(elem.style['background-image'])
|
||||
.split('/')
|
||||
@@ -179,9 +183,12 @@ window.ExportHtml = (Popup) => {
|
||||
const addBoardHTMLToZip = (boardSlug, zip) => {
|
||||
ensureSidebarRemoved();
|
||||
const htmlOutputPath = `${boardSlug}/index.html`;
|
||||
zip.file(htmlOutputPath, new Blob([
|
||||
removeAnchors(getPageHtmlString())
|
||||
], { type: 'application/html' }));
|
||||
zip.file(
|
||||
htmlOutputPath,
|
||||
new Blob([removeAnchors(getPageHtmlString())], {
|
||||
type: 'application/html',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return async () => {
|
||||
@@ -202,5 +209,5 @@ window.ExportHtml = (Popup) => {
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(content, `${boardSlug}.zip`);
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -35,7 +35,7 @@
|
||||
options = $.extend({}, defaults, options);
|
||||
return this.each(function() {
|
||||
const element = this;
|
||||
return $(element).bind('paste', function(event) {
|
||||
return $(element).on('paste', function(event) {
|
||||
const types = event.clipboardData.types;
|
||||
const items = event.clipboardData.items;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
|
||||
+22
-30
@@ -61,38 +61,30 @@ Utils = {
|
||||
},
|
||||
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
|
||||
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
|
||||
processUploadedAttachment(card, fileObj, callbacks) {
|
||||
if (!card) {
|
||||
return onUploaded();
|
||||
}
|
||||
let settings = {
|
||||
file: fileObj,
|
||||
streams: 'dynamic',
|
||||
chunkSize: 'dynamic',
|
||||
};
|
||||
settings.meta = {
|
||||
uploading: true
|
||||
};
|
||||
if (card.isLinkedCard()) {
|
||||
settings.meta.boardId = Cards.findOne(card.linkedId).boardId;
|
||||
settings.meta.cardId = card.linkedId;
|
||||
} else {
|
||||
settings.meta.boardId = card.boardId;
|
||||
settings.meta.swimlaneId = card.swimlaneId;
|
||||
settings.meta.listId = card.listId;
|
||||
settings.meta.cardId = card._id;
|
||||
}
|
||||
settings.meta.userId = Meteor.userId();
|
||||
if (typeof callbacks === 'function') {
|
||||
settings.onEnd = callbacks;
|
||||
} else {
|
||||
for (const key in callbacks) {
|
||||
if (key.substring(0, 2) === 'on') {
|
||||
settings[key] = callbacks[key];
|
||||
}
|
||||
processUploadedAttachment(card, fileObj, callback) {
|
||||
const next = attachment => {
|
||||
if (typeof callback === 'function') {
|
||||
callback(attachment);
|
||||
}
|
||||
};
|
||||
if (!card) {
|
||||
return next();
|
||||
}
|
||||
Attachments.insert(settings);
|
||||
const file = new FS.File(fileObj);
|
||||
if (card.isLinkedCard()) {
|
||||
file.boardId = Cards.findOne(card.linkedId).boardId;
|
||||
file.cardId = card.linkedId;
|
||||
} else {
|
||||
file.boardId = card.boardId;
|
||||
file.swimlaneId = card.swimlaneId;
|
||||
file.listId = card.listId;
|
||||
file.cardId = card._id;
|
||||
}
|
||||
file.userId = Meteor.userId();
|
||||
if (file.original) {
|
||||
file.original.name = fileObj.name;
|
||||
}
|
||||
return next(Attachments.insert(file));
|
||||
},
|
||||
shrinkImage(options) {
|
||||
// shrink image to certain size
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "الحالي",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "تاريخ",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "сегашен",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Чекбокс",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Дата",
|
||||
"custom-field-dropdown": "Падащо меню",
|
||||
"custom-field-dropdown-none": "(няма)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "Actual",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "Aktuální",
|
||||
"custom-field-delete-pop": "Nelze vrátit zpět. Toto odebere toto vlastní pole ze všech karet a zničí jeho historii.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Rozbalovací seznam",
|
||||
"custom-field-dropdown-none": "(prázdné)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "nuværende",
|
||||
"custom-field-delete-pop": "Du kan ikke fortryde handlingen. Dette vil fjerne dette brugerdefinerede felt fra alle kort og tilintetgøre dens historik.",
|
||||
"custom-field-checkbox": "Afkrydsningsfelt",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Dato",
|
||||
"custom-field-dropdown": "Rullegardinliste",
|
||||
"custom-field-dropdown-none": "(ingen)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "aktuell",
|
||||
"custom-field-delete-pop": "Dies wird das Feld aus allen Karten entfernen und den dazugehörigen Verlauf löschen. Die Aktion kann nicht rückgängig gemacht werden.",
|
||||
"custom-field-checkbox": "Kontrollkästchen",
|
||||
"custom-field-currency": "Währung",
|
||||
"custom-field-currency-option": "Währungszeichen",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Dropdownliste",
|
||||
"custom-field-dropdown-none": "(keiner)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Ημερομηνία",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Dato",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Fecha",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(ninguno)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "Se eliminará este campo personalizado de todas las tarjetas y se destruirá su historial. Esta acción no puede deshacerse.",
|
||||
"custom-field-checkbox": "Casilla de verificación",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Fecha",
|
||||
"custom-field-dropdown": "Lista desplegable",
|
||||
"custom-field-dropdown-none": "(nada)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "Se eliminará este campo personalizado de todas las tarjetas y se destruirá su historial. Esta acción no puede deshacerse.",
|
||||
"custom-field-checkbox": "Casilla de verificación",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Fecha",
|
||||
"custom-field-dropdown": "Lista desplegable",
|
||||
"custom-field-dropdown-none": "(nada)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "unekoa",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "جاری",
|
||||
"custom-field-delete-pop": "این اقدام فیلدشخصی را بهمراه تمامی تاریخچه آن از کارت ها پاک می کند و برگشت پذیر نمی باشد",
|
||||
"custom-field-checkbox": "جعبه انتخابی",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "تاریخ",
|
||||
"custom-field-dropdown": "لیست افتادنی",
|
||||
"custom-field-dropdown-none": "(هیچ)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "nykyinen",
|
||||
"custom-field-delete-pop": "Tätä ei voi peruuttaa. Tämä poistaa tämän mukautetun kentän kaikista korteista ja poistaa sen historian.",
|
||||
"custom-field-checkbox": "Valintaruutu",
|
||||
"custom-field-currency": "Valuutta",
|
||||
"custom-field-currency-option": "Valuutta koodi",
|
||||
"custom-field-date": "Päivämäärä",
|
||||
"custom-field-dropdown": "Pudotusvalikko",
|
||||
"custom-field-dropdown-none": "(ei mitään)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actuel",
|
||||
"custom-field-delete-pop": "Cette action n'est pas réversible. Elle supprimera ce champ personnalisé de toutes les cartes et détruira son historique.",
|
||||
"custom-field-checkbox": "Case à cocher",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Liste de choix",
|
||||
"custom-field-dropdown-none": "(aucun)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "נוכחי",
|
||||
"custom-field-delete-pop": "אין אפשרות לבטל את הפעולה. הפעולה תסיר את השדה שהותאם אישית מכל הכרטיסים ותשמיד את ההיסטוריה שלו.",
|
||||
"custom-field-checkbox": "תיבת סימון",
|
||||
"custom-field-currency": "מטבע",
|
||||
"custom-field-currency-option": "קוד מטבע",
|
||||
"custom-field-date": "תאריך",
|
||||
"custom-field-dropdown": "רשימה נגללת",
|
||||
"custom-field-dropdown-none": "(ללא)",
|
||||
|
||||
+10
-8
@@ -17,14 +17,14 @@
|
||||
"act-completeChecklist": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-uncompleteChecklist": "uncompleted checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-addComment": "commented on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-editComment": "edited comment on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-deleteComment": "deleted comment on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-editComment": "कार्ड पर संपादित टिप्पणी __card__: __comment__ सूची में __list__ स्विमलेन में __swimlane__ बोर्ड पर __board__",
|
||||
"act-deleteComment": "कार्ड पर हटाई गई टिप्पणी __card__: __comment__ सूची में __list__ स्विमलेन में __swimlane__ बोर्ड पर __board__",
|
||||
"act-createBoard": "created board __board__",
|
||||
"act-createSwimlane": "created swimlane __swimlane__ to board __board__",
|
||||
"act-createCard": "created card __card__ to list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-createCustomField": "created custom field __customField__ at board __board__",
|
||||
"act-deleteCustomField": "deleted custom field __customField__ at board __board__",
|
||||
"act-setCustomField": "edited custom field __customField__: __customFieldValue__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"act-createCustomField": "बोर्ड पर कस्टम फ़ील्ड __customField__ __board__",
|
||||
"act-deleteCustomField": "बोर्ड पर कस्टम फ़ील्ड __customField__ हटा दिया गया __board__",
|
||||
"act-setCustomField": "कस्टम फ़ील्ड __customField__ संपादित करें: __customFieldValue__ कार्ड पर __card__ सूची में __list__ स्विमलेन __swimlane__ पर बोर्ड __board__",
|
||||
"act-createList": "added list __list__ to board __board__",
|
||||
"act-addBoardMember": "added member __member__ to board __board__",
|
||||
"act-archivedBoard": "Board __board__ moved to Archive",
|
||||
@@ -64,7 +64,7 @@
|
||||
"activity-unchecked-item": "अचिह्नित %s अंदर में चिह्नांकन-सूची %s of %s",
|
||||
"activity-checklist-added": "संकलित चिह्नांकन-सूची तक %s",
|
||||
"activity-checklist-removed": "हटा दिया एक चिह्नांकन-सूची से %s",
|
||||
"activity-checklist-completed": "completed checklist %s of %s",
|
||||
"activity-checklist-completed": "पूरी जाँच सूची%sकी %s",
|
||||
"activity-checklist-uncompleted": "अपूर्ण चिह्नांकन-सूची %s of %s",
|
||||
"activity-checklist-item-added": "संकलित चिह्नांकन-सूची विषय तक '%s' अंदर में %s",
|
||||
"activity-checklist-item-removed": "हटा दिया एक चिह्नांकन-सूची विषय से '%s' अंदर में %s",
|
||||
@@ -73,8 +73,8 @@
|
||||
"activity-unchecked-item-card": "अचिह्नित %s अंदर में चिह्नांकन-सूची %s",
|
||||
"activity-checklist-completed-card": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"activity-checklist-uncompleted-card": "अपूर्ण चिह्नांकन-सूची %s",
|
||||
"activity-editComment": "edited comment %s",
|
||||
"activity-deleteComment": "deleted comment %s",
|
||||
"activity-editComment": "संपादित टिप्पणी",
|
||||
"activity-deleteComment": "टिप्पणी हटा दी गई",
|
||||
"add-attachment": "संलग्न करें",
|
||||
"add-board": "बोर्ड जोड़ें",
|
||||
"add-card": "कार्ड जोड़ें",
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "वर्तमान",
|
||||
"custom-field-delete-pop": "कोई पूर्ववत् नहीं है । यह सभी कार्ड से इस कस्टम क्षेत्र को हटा दें और इसके इतिहास को नष्ट कर देगा ।",
|
||||
"custom-field-checkbox": "निशानबक्से",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "दिनांक",
|
||||
"custom-field-dropdown": "ड्रॉपडाउन सूची",
|
||||
"custom-field-dropdown-none": "(कोई नहीं)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "jelenlegi",
|
||||
"custom-field-delete-pop": "Nincs visszavonás. Ez el fogja távolítani az egyéni mezőt az összes kártyáról, és megsemmisíti az előzményeit.",
|
||||
"custom-field-checkbox": "Jelölőnégyzet",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Dátum",
|
||||
"custom-field-dropdown": "Legördülő lista",
|
||||
"custom-field-dropdown-none": "(nincs)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "sekarang",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Tanggal",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "corrente",
|
||||
"custom-field-delete-pop": "Non potrai tornare indietro. Questa azione rimuoverà questo campo personalizzato da tutte le schede ed eliminerà ogni sua traccia.",
|
||||
"custom-field-checkbox": "Casella di scelta",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Lista a discesa",
|
||||
"custom-field-dropdown-none": "(niente)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "現在",
|
||||
"custom-field-delete-pop": "この操作は取り消しできません。このカスタムフィールドはすべてのカードから外され履歴からも見えなくなります。",
|
||||
"custom-field-checkbox": "チェックボックス",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "日付",
|
||||
"custom-field-dropdown": "ドロップダウンリスト",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "მიმდინარე",
|
||||
"custom-field-delete-pop": "ქმედება გამოიწვევს მომხმარებლის ველის წაშლას ყველა ბარათიდან და გაანადგურებს მის ისტორიას, რის შემდეგაც შეუძლებელი იქნება მისი უკან დაბრუნება. ",
|
||||
"custom-field-checkbox": "მოსანიშნი გრაფა",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "თარიღი",
|
||||
"custom-field-dropdown": "ჩამოსაშლელი სია",
|
||||
"custom-field-dropdown-none": "(ცარიელი)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "경향",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "날짜",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "сегашен",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Чекбокс",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Дата",
|
||||
"custom-field-dropdown": "Падащо меню",
|
||||
"custom-field-dropdown-none": "(няма)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Dato",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "Huidige",
|
||||
"custom-field-delete-pop": "Er is geen herstelmogelijkheid. Deze actie zal dit maatwerkveld van alle kaarten verwijderen en de bijbehorende historie wissen.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Dropdown Lijst",
|
||||
"custom-field-dropdown-none": "(geen)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Casa de croiar",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Tièra de causidas",
|
||||
"custom-field-dropdown-none": "(pas res)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "obecny",
|
||||
"custom-field-delete-pop": "Nie ma możliwości wycofania tej operacji. To usunie te niestandardowe pole ze wszystkich kart oraz usunie ich całą historię.",
|
||||
"custom-field-checkbox": "Pole wyboru",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Lista rozwijana",
|
||||
"custom-field-dropdown-none": "(puste)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "atual",
|
||||
"custom-field-delete-pop": "Não existe desfazer. Isso irá excluir o campo customizado de todos os cartões e destruir seu histórico",
|
||||
"custom-field-checkbox": "Caixa de seleção",
|
||||
"custom-field-currency": "Moeda",
|
||||
"custom-field-currency-option": "Código de moeda",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Lista suspensa",
|
||||
"custom-field-dropdown-none": "(nada)",
|
||||
@@ -805,6 +807,6 @@
|
||||
"last-activity": "Última atividade",
|
||||
"voting": "Votação",
|
||||
"archived": "Arquivado",
|
||||
"delete-linked-card-before-this-card": "You can not delete this card before first deleting linked card that has",
|
||||
"delete-linked-cards-before-this-list": "You can not delete this list before first deleting linked cards that are pointing to cards in this list"
|
||||
"delete-linked-card-before-this-card": "Você não pode excluir este cartão antes de excluir primeiro o cartão vinculado que possui",
|
||||
"delete-linked-cards-before-this-list": "Você não pode excluir esta lista antes de excluir primeiro os cartões vinculados que estão apontando para os cartões nesta lista"
|
||||
}
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "actual",
|
||||
"custom-field-delete-pop": "Não existe desfazer. Isto irá remover este campo personalizado de todos os cartões e destruir o seu histórico",
|
||||
"custom-field-checkbox": "Caixa de selecção",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Data",
|
||||
"custom-field-dropdown": "Lista Suspensa",
|
||||
"custom-field-dropdown-none": "(nada)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "текущий",
|
||||
"custom-field-delete-pop": "Отменить нельзя. Это удалит настраиваемое поле со всех карт и уничтожит его историю.",
|
||||
"custom-field-checkbox": "Галочка",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Дата",
|
||||
"custom-field-dropdown": "Выпадающий список",
|
||||
"custom-field-dropdown-none": "(нет)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "trenutno",
|
||||
"custom-field-delete-pop": "Razveljavitve ni. To bo odstranilo to poljubno polje iz vseh kartic in izbrisalo njegovo zgodovino.",
|
||||
"custom-field-checkbox": "Potrditveno polje",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Spustni seznam",
|
||||
"custom-field-dropdown-none": "(nobeno)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Padajuća lista",
|
||||
"custom-field-dropdown-none": "(ništa)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "aktuell",
|
||||
"custom-field-delete-pop": "Det går inte att ångra. Detta tar bort det här anpassade fältet från alla kort och förstör dess historia.",
|
||||
"custom-field-checkbox": "Kryssruta",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Datum",
|
||||
"custom-field-dropdown": "Rullgardingsmeny",
|
||||
"custom-field-dropdown-none": "(inga)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "நாள் ",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "ปัจจุบัน",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "วันที่",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "mevcut",
|
||||
"custom-field-delete-pop": "Bunun geri dönüşü yoktur. Bu özel alan tüm kartlardan kaldırılıp tarihçesi yokedilecektir.",
|
||||
"custom-field-checkbox": "İşaret kutusu",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Tarih",
|
||||
"custom-field-dropdown": "Açılır liste",
|
||||
"custom-field-dropdown-none": "(hiçbiri)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
+27
-25
@@ -164,15 +164,15 @@
|
||||
"cardStartVotingPopup-title": "建立投票",
|
||||
"positiveVoteMembersPopup-title": "支持",
|
||||
"negativeVoteMembersPopup-title": "反对",
|
||||
"card-edit-voting": "Edit voting",
|
||||
"editVoteEndDatePopup-title": "Change vote end date",
|
||||
"allowNonBoardMembers": "Allow all logged in users",
|
||||
"card-edit-voting": "编辑投票",
|
||||
"editVoteEndDatePopup-title": "修改投票截止日期",
|
||||
"allowNonBoardMembers": "允许所有已登入账户",
|
||||
"vote-question": "投票题目",
|
||||
"vote-public": "查看投票结果",
|
||||
"vote-for-it": "同意",
|
||||
"vote-against": "反对",
|
||||
"deleteVotePopup-title": "Delete vote?",
|
||||
"vote-delete-pop": "Deleting is permanent. You will lose all actions associated with this vote.",
|
||||
"deleteVotePopup-title": "删除投票?",
|
||||
"vote-delete-pop": "永久删除。此操作会将所有数据删除掉。",
|
||||
"cardDeletePopup-title": "彻底删除卡片?",
|
||||
"cardDetailsActionsPopup-title": "卡片操作",
|
||||
"cardLabelsPopup-title": "标签",
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "当前",
|
||||
"custom-field-delete-pop": "没有撤销,此动作将从所有卡片中移除自定义字段并销毁历史。",
|
||||
"custom-field-checkbox": "选择框",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "日期",
|
||||
"custom-field-dropdown": "下拉列表",
|
||||
"custom-field-dropdown-none": "(无)",
|
||||
@@ -309,7 +311,7 @@
|
||||
"error-board-notAMember": "需要成为看板成员才能执行此操作",
|
||||
"error-json-malformed": "文本不是合法的 JSON",
|
||||
"error-json-schema": "JSON 数据没有用正确的格式包含合适的信息",
|
||||
"error-csv-schema": "Your CSV(Comma Separated Values)/TSV (Tab Separated Values) does not include the proper information in the correct format",
|
||||
"error-csv-schema": "CSV(逗号分隔值)/TSV(制表符分隔值)数据格式的不正确",
|
||||
"error-list-doesNotExist": "不存在此列表",
|
||||
"error-user-doesNotExist": "该用户不存在",
|
||||
"error-user-notAllowSelf": "无法邀请自己",
|
||||
@@ -317,10 +319,10 @@
|
||||
"error-username-taken": "此用户名已存在",
|
||||
"error-email-taken": "此EMail已存在",
|
||||
"export-board": "导出看板",
|
||||
"export-board-json": "Export board to JSON",
|
||||
"export-board-csv": "Export board to CSV",
|
||||
"export-board-tsv": "Export board to TSV",
|
||||
"export-board-html": "Export board to HTML",
|
||||
"export-board-json": "看板导出为JSON",
|
||||
"export-board-csv": "看板导出为CSV",
|
||||
"export-board-tsv": "看板导出为TSV",
|
||||
"export-board-html": "看板导出为HTML",
|
||||
"exportBoardPopup-title": "导出看板",
|
||||
"sort": "排序",
|
||||
"sort-desc": "点此来将列表排序",
|
||||
@@ -357,16 +359,16 @@
|
||||
"import-board-c": "导入看板",
|
||||
"import-board-title-trello": "从Trello导入看板",
|
||||
"import-board-title-wekan": "从以前的导出数据导入看板",
|
||||
"import-board-title-csv": "Import board from CSV/TSV",
|
||||
"import-board-title-csv": "导入CSV/TSV看板",
|
||||
"from-trello": "自 Trello",
|
||||
"from-wekan": "自以前的导出",
|
||||
"from-csv": "From CSV/TSV",
|
||||
"from-csv": "从CSV/TSV",
|
||||
"import-board-instruction-trello": "在你的Trello看板中,点击“菜单”,然后选择“更多”,“打印与导出”,“导出为 JSON” 并拷贝结果文本",
|
||||
"import-board-instruction-csv": "Paste in your Comma Separated Values(CSV)/ Tab Separated Values (TSV) .",
|
||||
"import-board-instruction-csv": "粘贴逗号分隔值(CSV)/制表符分隔值(TSV)。",
|
||||
"import-board-instruction-wekan": "在您的看板,点击“菜单”,然后“导出看板”,复制下载文件中的文本。",
|
||||
"import-board-instruction-about-errors": "如果在导入看板时出现错误,导入工作可能仍然在进行中,并且看板已经出现在全部看板页。",
|
||||
"import-json-placeholder": "粘贴您有效的 JSON 数据至此",
|
||||
"import-csv-placeholder": "Paste your valid CSV/TSV data here",
|
||||
"import-csv-placeholder": "在此处粘贴有效的CSV/TSV数据",
|
||||
"import-map-members": "映射成员",
|
||||
"import-members-map": "您导入的看板有一些成员,请映射这些成员到您导入的用户。",
|
||||
"import-show-user-mapping": "核对成员映射",
|
||||
@@ -399,7 +401,7 @@
|
||||
"swimlaneActionPopup-title": "泳道图操作",
|
||||
"swimlaneAddPopup-title": "在下面添加一个泳道",
|
||||
"listImportCardPopup-title": "导入 Trello 卡片",
|
||||
"listImportCardsTsvPopup-title": "Import Excel CSV/TSV",
|
||||
"listImportCardsTsvPopup-title": "导入Excel CSV/TSV",
|
||||
"listMorePopup-title": "更多",
|
||||
"link-list": "关联到这个列表",
|
||||
"list-delete-pop": "所有活动将被从活动动态中删除并且你无法恢复他们,此操作无法撤销。",
|
||||
@@ -639,7 +641,7 @@
|
||||
"r-when-a-card": "当一张卡片",
|
||||
"r-is": "是",
|
||||
"r-is-moved": "已经移动",
|
||||
"r-added-to": "Added to",
|
||||
"r-added-to": "添加到",
|
||||
"r-removed-from": "已移除",
|
||||
"r-the-board": "该看板",
|
||||
"r-list": "列表",
|
||||
@@ -798,13 +800,13 @@
|
||||
"friday": "周五",
|
||||
"saturday": "周六",
|
||||
"sunday": "周日",
|
||||
"status": "Status",
|
||||
"swimlane": "Swimlane",
|
||||
"owner": "Owner",
|
||||
"last-modified-at": "Last modified at",
|
||||
"last-activity": "Last activity",
|
||||
"voting": "Voting",
|
||||
"archived": "Archived",
|
||||
"delete-linked-card-before-this-card": "You can not delete this card before first deleting linked card that has",
|
||||
"delete-linked-cards-before-this-list": "You can not delete this list before first deleting linked cards that are pointing to cards in this list"
|
||||
"status": "状态",
|
||||
"swimlane": "泳道",
|
||||
"owner": "所有者",
|
||||
"last-modified-at": "上次修改时间",
|
||||
"last-activity": "上次活动",
|
||||
"voting": "投票",
|
||||
"archived": "存档",
|
||||
"delete-linked-card-before-this-card": "在你首次删除卡片前你无法删除此选项卡片",
|
||||
"delete-linked-cards-before-this-list": "在首先删除指向此列表中的卡的链接卡之前,不能删除此列表"
|
||||
}
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "current",
|
||||
"custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
|
||||
"custom-field-checkbox": "Checkbox",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "Date",
|
||||
"custom-field-dropdown": "Dropdown List",
|
||||
"custom-field-dropdown-none": "(none)",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"current": "目前",
|
||||
"custom-field-delete-pop": "此操作將會從所有卡片中移除自訂欄位以及銷毀歷史紀錄,並且無法撤消。",
|
||||
"custom-field-checkbox": "複選框",
|
||||
"custom-field-currency": "Currency",
|
||||
"custom-field-currency-option": "Currency Code",
|
||||
"custom-field-date": "日期",
|
||||
"custom-field-dropdown": "下拉式選單",
|
||||
"custom-field-dropdown-none": "(無)",
|
||||
|
||||
@@ -217,7 +217,7 @@ if (Meteor.isServer) {
|
||||
}
|
||||
if (activity.attachmentId) {
|
||||
const attachment = activity.attachment();
|
||||
params.attachment = attachment.name;
|
||||
params.attachment = attachment.original.name;
|
||||
params.attachmentId = attachment._id;
|
||||
}
|
||||
if (activity.checklistId) {
|
||||
|
||||
+239
-103
@@ -1,127 +1,263 @@
|
||||
import { FilesCollection } from 'meteor/ostrio:files';
|
||||
const fs = require('fs');
|
||||
const localFSStore = process.env.ATTACHMENTS_STORE_PATH;
|
||||
const storeName = 'attachments';
|
||||
const defaultStoreOptions = {
|
||||
beforeWrite: fileObj => {
|
||||
if (!fileObj.isImage()) {
|
||||
return {
|
||||
type: 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
let store;
|
||||
if (localFSStore) {
|
||||
// have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem
|
||||
const fs = Npm.require('fs');
|
||||
const path = Npm.require('path');
|
||||
const mongodb = Npm.require('mongodb');
|
||||
const Grid = Npm.require('gridfs-stream');
|
||||
// calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :(
|
||||
let pathname = localFSStore;
|
||||
/*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */
|
||||
|
||||
const collectionName = 'attachments2';
|
||||
if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) {
|
||||
pathname = path.join(
|
||||
__meteor_bootstrap__.serverDir,
|
||||
`../../../cfs/files/${storeName}`,
|
||||
);
|
||||
}
|
||||
|
||||
Attachments = new FilesCollection({
|
||||
storagePath: storagePath(),
|
||||
debug: false,
|
||||
// allowClientCode: true,
|
||||
collectionName: 'attachments2',
|
||||
onAfterUpload: onAttachmentUploaded,
|
||||
onBeforeRemove: onAttachmentRemoving
|
||||
if (!pathname)
|
||||
throw new Error('FS.Store.FileSystem unable to determine path');
|
||||
|
||||
// Check if we have '~/foo/bar'
|
||||
if (pathname.split(path.sep)[0] === '~') {
|
||||
const homepath =
|
||||
process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
|
||||
if (homepath) {
|
||||
pathname = pathname.replace('~', homepath);
|
||||
} else {
|
||||
throw new Error('FS.Store.FileSystem unable to resolve "~" in path');
|
||||
}
|
||||
}
|
||||
|
||||
// Set absolute path
|
||||
const absolutePath = path.resolve(pathname);
|
||||
|
||||
const _FStore = new FS.Store.FileSystem(storeName, {
|
||||
path: localFSStore,
|
||||
...defaultStoreOptions,
|
||||
});
|
||||
const GStore = {
|
||||
fileKey(fileObj) {
|
||||
const key = {
|
||||
_id: null,
|
||||
filename: null,
|
||||
};
|
||||
|
||||
// If we're passed a fileObj, we retrieve the _id and filename from it.
|
||||
if (fileObj) {
|
||||
const info = fileObj._getInfo(storeName, {
|
||||
updateFileRecordFirst: false,
|
||||
});
|
||||
key._id = info.key || null;
|
||||
key.filename =
|
||||
info.name ||
|
||||
fileObj.name({ updateFileRecordFirst: false }) ||
|
||||
`${fileObj.collectionName}-${fileObj._id}`;
|
||||
}
|
||||
|
||||
// If key._id is null at this point, createWriteStream will let GridFS generate a new ID
|
||||
return key;
|
||||
},
|
||||
db: undefined,
|
||||
mongoOptions: { useNewUrlParser: true },
|
||||
mongoUrl: process.env.MONGO_URL,
|
||||
init() {
|
||||
this._init(err => {
|
||||
this.inited = !err;
|
||||
});
|
||||
},
|
||||
_init(callback) {
|
||||
const self = this;
|
||||
mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function(
|
||||
err,
|
||||
db,
|
||||
) {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
self.db = db;
|
||||
return callback(null);
|
||||
});
|
||||
return;
|
||||
},
|
||||
createReadStream(fileKey, options) {
|
||||
const self = this;
|
||||
if (!self.inited) {
|
||||
self.init();
|
||||
return undefined;
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
// Init GridFS
|
||||
const gfs = new Grid(self.db, mongodb);
|
||||
|
||||
// Set the default streamning settings
|
||||
const settings = {
|
||||
_id: new mongodb.ObjectID(fileKey._id),
|
||||
root: `cfs_gridfs.${storeName}`,
|
||||
};
|
||||
|
||||
// Check if this should be a partial read
|
||||
if (
|
||||
typeof options.start !== 'undefined' &&
|
||||
typeof options.end !== 'undefined'
|
||||
) {
|
||||
// Add partial info
|
||||
settings.range = {
|
||||
startPos: options.start,
|
||||
endPos: options.end,
|
||||
};
|
||||
}
|
||||
return gfs.createReadStream(settings);
|
||||
},
|
||||
};
|
||||
GStore.init();
|
||||
const CRS = 'createReadStream';
|
||||
const _CRS = `_${CRS}`;
|
||||
const FStore = _FStore._transform;
|
||||
FStore[_CRS] = FStore[CRS].bind(FStore);
|
||||
FStore[CRS] = function(fileObj, options) {
|
||||
let stream;
|
||||
try {
|
||||
const localFile = path.join(
|
||||
absolutePath,
|
||||
FStore.storage.fileKey(fileObj),
|
||||
);
|
||||
const state = fs.statSync(localFile);
|
||||
if (state) {
|
||||
stream = FStore[_CRS](fileObj, options);
|
||||
}
|
||||
} catch (e) {
|
||||
// file is not there, try GridFS ?
|
||||
stream = undefined;
|
||||
}
|
||||
if (stream) return stream;
|
||||
else {
|
||||
try {
|
||||
const stream = GStore[CRS](GStore.fileKey(fileObj), options);
|
||||
return stream;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}.bind(FStore);
|
||||
store = _FStore;
|
||||
} else {
|
||||
store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, {
|
||||
// XXX Add a new store for cover thumbnails so we don't load big images in
|
||||
// the general board view
|
||||
// If the uploaded document is not an image we need to enforce browser
|
||||
// download instead of execution. This is particularly important for HTML
|
||||
// files that the browser will just execute if we don't serve them with the
|
||||
// appropriate `application/octet-stream` MIME header which can lead to user
|
||||
// data leaks. I imagine other formats (like PDF) can also be attack vectors.
|
||||
// See https://github.com/wekan/wekan/issues/99
|
||||
// XXX Should we use `beforeWrite` option of CollectionFS instead of
|
||||
// collection-hooks?
|
||||
// We should use `beforeWrite`.
|
||||
...defaultStoreOptions,
|
||||
});
|
||||
}
|
||||
Attachments = new FS.Collection('attachments', {
|
||||
stores: [store],
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(() => {
|
||||
Attachments.collection._ensureIndex({ cardId: 1 });
|
||||
Attachments.files._ensureIndex({ cardId: 1 });
|
||||
});
|
||||
|
||||
// TODO: Permission related
|
||||
Attachments.allow({
|
||||
insert() {
|
||||
return false;
|
||||
insert(userId, doc) {
|
||||
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
|
||||
},
|
||||
update() {
|
||||
return true;
|
||||
update(userId, doc) {
|
||||
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
|
||||
},
|
||||
remove() {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
cloneAttachment(file, overrides) {
|
||||
check(file, Object);
|
||||
check(overrides, Match.Maybe(Object));
|
||||
const path = file.path;
|
||||
const opts = {
|
||||
fileName: file.name,
|
||||
type: file.type,
|
||||
meta: file.meta,
|
||||
userId: file.userId
|
||||
};
|
||||
for (let key in overrides) {
|
||||
if (key === 'meta') {
|
||||
for (let metaKey in overrides.meta) {
|
||||
opts.meta[metaKey] = overrides.meta[metaKey];
|
||||
}
|
||||
} else {
|
||||
opts[key] = overrides[key];
|
||||
}
|
||||
remove(userId, doc) {
|
||||
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
|
||||
},
|
||||
// We authorize the attachment download either:
|
||||
// - if the board is public, everyone (even unconnected) can download it
|
||||
// - if the board is private, only board members can download it
|
||||
download(userId, doc) {
|
||||
const board = Boards.findOne(doc.boardId);
|
||||
if (board.isPublic()) {
|
||||
return true;
|
||||
} else {
|
||||
return board.hasMember(userId);
|
||||
}
|
||||
const buffer = fs.readFileSync(path);
|
||||
Attachments.write(buffer, opts, (err, fileRef) => {
|
||||
if (err) {
|
||||
console.log('Error when cloning record', err);
|
||||
}
|
||||
},
|
||||
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
}
|
||||
|
||||
// XXX Enforce a schema for the Attachments CollectionFS
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Attachments.files.after.insert((userId, doc) => {
|
||||
// If the attachment doesn't have a source field
|
||||
// or its source is different than import
|
||||
if (!doc.source || doc.source !== 'import') {
|
||||
// Add activity about adding the attachment
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'card',
|
||||
activityType: 'addAttachment',
|
||||
attachmentId: doc._id,
|
||||
// this preserves the name so that notifications can be meaningful after
|
||||
// this file is removed
|
||||
attachmentName: doc.original.name,
|
||||
boardId: doc.boardId,
|
||||
cardId: doc.cardId,
|
||||
listId: doc.listId,
|
||||
swimlaneId: doc.swimlaneId,
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
// Don't add activity about adding the attachment as the activity
|
||||
// be imported and delete source field
|
||||
Attachments.update(
|
||||
{
|
||||
_id: doc._id,
|
||||
},
|
||||
{
|
||||
$unset: {
|
||||
source: '',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.publish(collectionName, function() {
|
||||
return Attachments.find().cursor;
|
||||
});
|
||||
} else {
|
||||
Meteor.subscribe(collectionName);
|
||||
}
|
||||
|
||||
function storagePath(defaultPath) {
|
||||
const storePath = process.env.ATTACHMENTS_STORE_PATH;
|
||||
return storePath ? storePath : defaultPath;
|
||||
}
|
||||
|
||||
function onAttachmentUploaded(fileRef) {
|
||||
Attachments.update({_id:fileRef._id}, {$set: {"meta.uploading": false}});
|
||||
if (!fileRef.meta.source || fileRef.meta.source !== 'import') {
|
||||
// Add activity about adding the attachment
|
||||
Attachments.files.before.remove((userId, doc) => {
|
||||
Activities.insert({
|
||||
userId: fileRef.userId,
|
||||
userId,
|
||||
type: 'card',
|
||||
activityType: 'addAttachment',
|
||||
attachmentId: fileRef._id,
|
||||
activityType: 'deleteAttachment',
|
||||
attachmentId: doc._id,
|
||||
// this preserves the name so that notifications can be meaningful after
|
||||
// this file is removed
|
||||
attachmentName: fileRef.name,
|
||||
boardId: fileRef.meta.boardId,
|
||||
cardId: fileRef.meta.cardId,
|
||||
listId: fileRef.meta.listId,
|
||||
swimlaneId: fileRef.meta.swimlaneId,
|
||||
// this file is removed
|
||||
attachmentName: doc.original.name,
|
||||
boardId: doc.boardId,
|
||||
cardId: doc.cardId,
|
||||
listId: doc.listId,
|
||||
swimlaneId: doc.swimlaneId,
|
||||
});
|
||||
} else {
|
||||
// Don't add activity about adding the attachment as the activity
|
||||
// be imported and delete source field
|
||||
Attachments.collection.update(
|
||||
{
|
||||
_id: fileRef._id,
|
||||
},
|
||||
{
|
||||
$unset: {
|
||||
'meta.source': '',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onAttachmentRemoving(cursor) {
|
||||
const file = cursor.get()[0];
|
||||
const meta = file.meta;
|
||||
Activities.insert({
|
||||
userId: this.userId,
|
||||
type: 'card',
|
||||
activityType: 'deleteAttachment',
|
||||
attachmentId: file._id,
|
||||
// this preserves the name so that notifications can be meaningful after
|
||||
// this file is removed
|
||||
attachmentName: file.name,
|
||||
boardId: meta.boardId,
|
||||
cardId: meta.cardId,
|
||||
listId: meta.listId,
|
||||
swimlaneId: meta.swimlaneId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export default Attachments;
|
||||
+53
-14
@@ -278,7 +278,7 @@ Cards.attachSchema(
|
||||
*/
|
||||
type: Number,
|
||||
decimal: true,
|
||||
defaultValue: '',
|
||||
defaultValue: 0,
|
||||
},
|
||||
subtaskSort: {
|
||||
/**
|
||||
@@ -412,14 +412,10 @@ Cards.helpers({
|
||||
const _id = Cards.insert(this);
|
||||
|
||||
// Copy attachments
|
||||
oldCard.attachments().forEach((file) => {
|
||||
Meteor.call('cloneAttachment', file,
|
||||
{
|
||||
meta: {
|
||||
cardId: _id
|
||||
}
|
||||
}
|
||||
);
|
||||
oldCard.attachments().forEach(att => {
|
||||
att.cardId = _id;
|
||||
delete att._id;
|
||||
return Attachments.insert(att);
|
||||
});
|
||||
|
||||
// copy checklists
|
||||
@@ -522,23 +518,23 @@ Cards.helpers({
|
||||
attachments() {
|
||||
if (this.isLinkedCard()) {
|
||||
return Attachments.find(
|
||||
{ 'meta.cardId': this.linkedId },
|
||||
{ cardId: this.linkedId },
|
||||
{ sort: { uploadedAt: -1 } },
|
||||
);
|
||||
} else {
|
||||
let ret = Attachments.find(
|
||||
{ 'meta.cardId': this._id },
|
||||
return Attachments.find(
|
||||
{ cardId: this._id },
|
||||
{ sort: { uploadedAt: -1 } },
|
||||
);
|
||||
return ret;
|
||||
}
|
||||
},
|
||||
|
||||
cover() {
|
||||
if (!this.coverId) return false;
|
||||
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.link();
|
||||
return cover && cover.url() && cover;
|
||||
},
|
||||
|
||||
checklists() {
|
||||
@@ -1259,6 +1255,49 @@ Cards.mutations({
|
||||
};
|
||||
},
|
||||
|
||||
moveToEndOfList({ listId } = {}) {
|
||||
let swimlaneId = this.swimlaneId;
|
||||
const boardId = this.boardId;
|
||||
let sortIndex = 0;
|
||||
|
||||
// This should never happen, but there was a bug that was fixed in commit
|
||||
// ea0239538a68e225c867411a4f3e0d27c158383.
|
||||
if (!swimlaneId) {
|
||||
const board = Boards.findOne(boardId);
|
||||
swimlaneId = board.getDefaultSwimline()._id;
|
||||
}
|
||||
// Move the minicard to the end of the target list
|
||||
let parentElementDom = $(`#swimlane-${this.swimlaneId}`).get(0);
|
||||
if (!parentElementDom) parentElementDom = $(':root');
|
||||
|
||||
const lastCardDom = $(parentElementDom)
|
||||
.find(`#js-list-${listId} .js-minicard:last`)
|
||||
.get(0);
|
||||
if (lastCardDom) sortIndex = Utils.calculateIndex(lastCardDom, null).base;
|
||||
|
||||
return this.moveOptionalArgs({
|
||||
boardId: boardId,
|
||||
swimlaneId: swimlaneId,
|
||||
listId: listId,
|
||||
sort: sortIndex,
|
||||
});
|
||||
},
|
||||
|
||||
moveOptionalArgs({ boardId, swimlaneId, listId, sort } = {}) {
|
||||
boardId = boardId || this.boardId;
|
||||
swimlaneId = swimlaneId || this.swimlaneId;
|
||||
// This should never happen, but there was a bug that was fixed in commit
|
||||
// ea0239538a68e225c867411a4f3e0d27c158383.
|
||||
if (!swimlaneId) {
|
||||
const board = Boards.findOne(boardId);
|
||||
swimlaneId = board.getDefaultSwimline()._id;
|
||||
}
|
||||
listId = listId || this.listId;
|
||||
if (sort === undefined || sort === null)
|
||||
sort = this.sort;
|
||||
return this.move(boardId, swimlaneId, listId, sort);
|
||||
},
|
||||
|
||||
move(boardId, swimlaneId, listId, sort) {
|
||||
// Copy Custom Fields
|
||||
if (this.boardId !== boardId) {
|
||||
|
||||
@@ -70,7 +70,9 @@ Checklists.helpers({
|
||||
this._id = null;
|
||||
this.cardId = newCardId;
|
||||
const newChecklistId = Checklists.insert(this);
|
||||
ChecklistItems.find({ checklistId: oldChecklistId }).forEach(item => {
|
||||
ChecklistItems.find({ checklistId: oldChecklistId }).forEach(function(
|
||||
item,
|
||||
) {
|
||||
item._id = null;
|
||||
item.checklistId = newChecklistId;
|
||||
item.cardId = newCardId;
|
||||
|
||||
+97
-16
@@ -50,6 +50,7 @@ export class CsvCreator {
|
||||
*/
|
||||
mapHeadertoCardFieldIndex(headerRow) {
|
||||
const index = {};
|
||||
index.customFields = [];
|
||||
for (let i = 0; i < headerRow.length; i++) {
|
||||
switch (headerRow[i].trim().toLowerCase()) {
|
||||
case 'title':
|
||||
@@ -98,9 +99,61 @@ export class CsvCreator {
|
||||
index.modifiedAt = i;
|
||||
break;
|
||||
}
|
||||
if (headerRow[i].toLowerCase().startsWith('customfield')) {
|
||||
if (headerRow[i].split('-')[2] === 'dropdown') {
|
||||
index.customFields.push({
|
||||
name: headerRow[i].split('-')[1],
|
||||
type: headerRow[i].split('-')[2],
|
||||
options: headerRow[i].split('-')[3].split('/'),
|
||||
position: i,
|
||||
});
|
||||
} else if (headerRow[i].split('-')[2] === 'currency') {
|
||||
index.customFields.push({
|
||||
name: headerRow[i].split('-')[1],
|
||||
type: headerRow[i].split('-')[2],
|
||||
currencyCode: headerRow[i].split('-')[3],
|
||||
position: i,
|
||||
});
|
||||
} else {
|
||||
index.customFields.push({
|
||||
name: headerRow[i].split('-')[1],
|
||||
type: headerRow[i].split('-')[2],
|
||||
position: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.fieldIndex = index;
|
||||
}
|
||||
createCustomFields(boardId) {
|
||||
this.fieldIndex.customFields.forEach(customField => {
|
||||
let settings = {};
|
||||
if (customField.type === 'dropdown') {
|
||||
settings = {
|
||||
dropdownItems: customField.options.map(option => {
|
||||
return { _id: Random.id(6), name: option };
|
||||
}),
|
||||
};
|
||||
} else if (customField.type === 'currency') {
|
||||
settings = {
|
||||
currencyCode: customField.currencyCode,
|
||||
};
|
||||
} else {
|
||||
settings = {};
|
||||
}
|
||||
const id = CustomFields.direct.insert({
|
||||
name: customField.name,
|
||||
type: customField.type,
|
||||
settings,
|
||||
showOnCard: false,
|
||||
automaticallyOnCard: false,
|
||||
showLabelOnMiniCard: false,
|
||||
boardIds: [boardId],
|
||||
});
|
||||
customField.id = id;
|
||||
customField.settings = settings;
|
||||
});
|
||||
}
|
||||
|
||||
createBoard(csvData) {
|
||||
const boardToCreate = {
|
||||
@@ -228,9 +281,10 @@ export class CsvCreator {
|
||||
const cardToCreate = {
|
||||
archived: false,
|
||||
boardId,
|
||||
createdAt: csvData[i][this.fieldIndex.createdAt]
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.createdAt]))
|
||||
: null,
|
||||
createdAt:
|
||||
csvData[i][this.fieldIndex.createdAt] !== ' ' || ''
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.createdAt]))
|
||||
: null,
|
||||
dateLastActivity: this._now(),
|
||||
description: csvData[i][this.fieldIndex.description],
|
||||
listId: this.lists[csvData[i][this.fieldIndex.stage]],
|
||||
@@ -238,20 +292,24 @@ export class CsvCreator {
|
||||
sort: -1,
|
||||
title: csvData[i][this.fieldIndex.title],
|
||||
userId: this._user(),
|
||||
startAt: csvData[i][this.fieldIndex.startAt]
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.startAt]))
|
||||
: null,
|
||||
dueAt: csvData[i][this.fieldIndex.dueAt]
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.dueAt]))
|
||||
: null,
|
||||
endAt: csvData[i][this.fieldIndex.endAt]
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.endAt]))
|
||||
: null,
|
||||
startAt:
|
||||
csvData[i][this.fieldIndex.startAt] !== ' ' || ''
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.startAt]))
|
||||
: null,
|
||||
dueAt:
|
||||
csvData[i][this.fieldIndex.dueAt] !== ' ' || ''
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.dueAt]))
|
||||
: null,
|
||||
endAt:
|
||||
csvData[i][this.fieldIndex.endAt] !== ' ' || ''
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.endAt]))
|
||||
: null,
|
||||
spentTime: null,
|
||||
labelIds: [],
|
||||
modifiedAt: csvData[i][this.fieldIndex.modifiedAt]
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.modifiedAt]))
|
||||
: null,
|
||||
modifiedAt:
|
||||
csvData[i][this.fieldIndex.modifiedAt] !== ' ' || ''
|
||||
? this._now(new Date(csvData[i][this.fieldIndex.modifiedAt]))
|
||||
: null,
|
||||
};
|
||||
// add the labels
|
||||
if (csvData[i][this.fieldIndex.labels]) {
|
||||
@@ -290,7 +348,29 @@ export class CsvCreator {
|
||||
cardToCreate.members = wekanMembers;
|
||||
}
|
||||
}
|
||||
Cards.direct.insert(cardToCreate);
|
||||
// add the custom fields
|
||||
if (this.fieldIndex.customFields.length > 0) {
|
||||
const customFields = [];
|
||||
this.fieldIndex.customFields.forEach(customField => {
|
||||
if (csvData[i][customField.position] !== ' ') {
|
||||
if (customField.type === 'dropdown') {
|
||||
customFields.push({
|
||||
_id: customField.id,
|
||||
value: customField.settings.dropdownItems.find(
|
||||
({ name }) => name === csvData[i][customField.position],
|
||||
)._id,
|
||||
});
|
||||
} else {
|
||||
customFields.push({
|
||||
_id: customField.id,
|
||||
value: csvData[i][customField.position],
|
||||
});
|
||||
}
|
||||
}
|
||||
cardToCreate.customFields = customFields;
|
||||
});
|
||||
Cards.direct.insert(cardToCreate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +387,7 @@ export class CsvCreator {
|
||||
const boardId = this.createBoard(board);
|
||||
this.createLists(board, boardId);
|
||||
this.createSwimlanes(boardId);
|
||||
this.createCustomFields(boardId);
|
||||
this.createCards(board, boardId);
|
||||
return boardId;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ CustomFields.attachSchema(
|
||||
* type of the custom field
|
||||
*/
|
||||
type: String,
|
||||
allowedValues: ['text', 'number', 'date', 'dropdown'],
|
||||
allowedValues: ['text', 'number', 'date', 'dropdown', 'currency'],
|
||||
},
|
||||
settings: {
|
||||
/**
|
||||
@@ -30,6 +30,10 @@ CustomFields.attachSchema(
|
||||
*/
|
||||
type: Object,
|
||||
},
|
||||
'settings.currencyCode': {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
'settings.dropdownItems': {
|
||||
/**
|
||||
* list of drop down items objects
|
||||
|
||||
+51
-197
@@ -1,3 +1,4 @@
|
||||
import { Exporter } from './exporter';
|
||||
/* global JsonRoutes */
|
||||
if (Meteor.isServer) {
|
||||
// todo XXX once we have a real API in place, move that route there
|
||||
@@ -7,10 +8,10 @@ if (Meteor.isServer) {
|
||||
// on the client instead of copy/pasting the route path manually between the
|
||||
// client and the server.
|
||||
/**
|
||||
* @operation export
|
||||
* @operation exportJson
|
||||
* @tag Boards
|
||||
*
|
||||
* @summary This route is used to export the board.
|
||||
* @summary This route is used to export the board to a json file format.
|
||||
*
|
||||
* @description If user is already logged-in, pass loginToken as param
|
||||
* "authToken": '/api/boards/:boardId/export?authToken=:token'
|
||||
@@ -46,199 +47,52 @@ if (Meteor.isServer) {
|
||||
JsonRoutes.sendResult(res, 403);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// exporter maybe is broken since Gridfs introduced, add fs and path
|
||||
|
||||
export class Exporter {
|
||||
constructor(boardId) {
|
||||
this._boardId = boardId;
|
||||
}
|
||||
|
||||
build() {
|
||||
const fs = Npm.require('fs');
|
||||
const os = Npm.require('os');
|
||||
const path = Npm.require('path');
|
||||
|
||||
const byBoard = { boardId: this._boardId };
|
||||
const byBoardNoLinked = {
|
||||
boardId: this._boardId,
|
||||
linkedId: { $in: ['', null] },
|
||||
};
|
||||
// we do not want to retrieve boardId in related elements
|
||||
const noBoardId = {
|
||||
fields: {
|
||||
boardId: 0,
|
||||
},
|
||||
};
|
||||
const result = {
|
||||
_format: 'wekan-board-1.0.0',
|
||||
};
|
||||
_.extend(
|
||||
result,
|
||||
Boards.findOne(this._boardId, {
|
||||
fields: {
|
||||
stars: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
result.lists = Lists.find(byBoard, noBoardId).fetch();
|
||||
result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
|
||||
result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
|
||||
result.customFields = CustomFields.find(
|
||||
{ boardIds: { $in: [this.boardId] } },
|
||||
{ fields: { boardId: 0 } },
|
||||
).fetch();
|
||||
result.comments = CardComments.find(byBoard, noBoardId).fetch();
|
||||
result.activities = Activities.find(byBoard, noBoardId).fetch();
|
||||
result.rules = Rules.find(byBoard, noBoardId).fetch();
|
||||
result.checklists = [];
|
||||
result.checklistItems = [];
|
||||
result.subtaskItems = [];
|
||||
result.triggers = [];
|
||||
result.actions = [];
|
||||
result.cards.forEach(card => {
|
||||
result.checklists.push(
|
||||
...Checklists.find({
|
||||
cardId: card._id,
|
||||
}).fetch(),
|
||||
);
|
||||
result.checklistItems.push(
|
||||
...ChecklistItems.find({
|
||||
cardId: card._id,
|
||||
}).fetch(),
|
||||
);
|
||||
result.subtaskItems.push(
|
||||
...Cards.find({
|
||||
parentId: card._id,
|
||||
}).fetch(),
|
||||
);
|
||||
});
|
||||
result.rules.forEach(rule => {
|
||||
result.triggers.push(
|
||||
...Triggers.find(
|
||||
{
|
||||
_id: rule.triggerId,
|
||||
},
|
||||
noBoardId,
|
||||
).fetch(),
|
||||
);
|
||||
result.actions.push(
|
||||
...Actions.find(
|
||||
{
|
||||
_id: rule.actionId,
|
||||
},
|
||||
noBoardId,
|
||||
).fetch(),
|
||||
);
|
||||
});
|
||||
|
||||
// [Old] for attachments we only export IDs and absolute url to original doc
|
||||
// [New] Encode attachment to base64
|
||||
|
||||
const getBase64Data = function(doc, callback) {
|
||||
let buffer = Buffer.allocUnsafe(0);
|
||||
buffer.fill(0);
|
||||
|
||||
// callback has the form function (err, res) {}
|
||||
const tmpFile = path.join(
|
||||
os.tmpdir(),
|
||||
`tmpexport${process.pid}${Math.random()}`,
|
||||
);
|
||||
const tmpWriteable = fs.createWriteStream(tmpFile);
|
||||
const readStream = fs.createReadStream(doc.path);
|
||||
readStream.on('data', function(chunk) {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
});
|
||||
|
||||
readStream.on('error', function(err) {
|
||||
callback(null, null);
|
||||
});
|
||||
readStream.on('end', function() {
|
||||
// done
|
||||
fs.unlink(tmpFile, () => {
|
||||
//ignored
|
||||
});
|
||||
|
||||
callback(null, buffer.toString('base64'));
|
||||
});
|
||||
readStream.pipe(tmpWriteable);
|
||||
};
|
||||
const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
|
||||
result.attachments = Attachments.find({ 'meta.boardId': byBoard.boardId })
|
||||
.fetch()
|
||||
.map(attachment => {
|
||||
let filebase64 = null;
|
||||
filebase64 = getBase64DataSync(attachment);
|
||||
|
||||
return {
|
||||
_id: attachment._id,
|
||||
cardId: attachment.meta.cardId,
|
||||
//url: FlowRouter.url(attachment.url()),
|
||||
file: filebase64,
|
||||
name: attachment.name,
|
||||
type: attachment.type,
|
||||
};
|
||||
});
|
||||
|
||||
// we also have to export some user data - as the other elements only
|
||||
// include id but we have to be careful:
|
||||
// 1- only exports users that are linked somehow to that board
|
||||
// 2- do not export any sensitive information
|
||||
const users = {};
|
||||
result.members.forEach(member => {
|
||||
users[member.userId] = true;
|
||||
});
|
||||
result.lists.forEach(list => {
|
||||
users[list.userId] = true;
|
||||
});
|
||||
result.cards.forEach(card => {
|
||||
users[card.userId] = true;
|
||||
if (card.members) {
|
||||
card.members.forEach(memberId => {
|
||||
users[memberId] = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
result.comments.forEach(comment => {
|
||||
users[comment.userId] = true;
|
||||
});
|
||||
result.activities.forEach(activity => {
|
||||
users[activity.userId] = true;
|
||||
});
|
||||
result.checklists.forEach(checklist => {
|
||||
users[checklist.userId] = true;
|
||||
});
|
||||
const byUserIds = {
|
||||
_id: {
|
||||
$in: Object.getOwnPropertyNames(users),
|
||||
},
|
||||
};
|
||||
// we use whitelist to be sure we do not expose inadvertently
|
||||
// some secret fields that gets added to User later.
|
||||
const userFields = {
|
||||
fields: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.initials': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
},
|
||||
};
|
||||
result.users = Users.find(byUserIds, userFields)
|
||||
.fetch()
|
||||
.map(user => {
|
||||
// user avatar is stored as a relative url, we export absolute
|
||||
if ((user.profile || {}).avatarUrl) {
|
||||
user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
canExport(user) {
|
||||
const board = Boards.findOne(this._boardId);
|
||||
return board && board.isVisibleBy(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @operation exportCSV/TSV
|
||||
* @tag Boards
|
||||
*
|
||||
* @summary This route is used to export the board to a CSV or TSV file format.
|
||||
*
|
||||
* @description If user is already logged-in, pass loginToken as param
|
||||
*
|
||||
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
|
||||
* for detailed explanations
|
||||
*
|
||||
* @param {string} boardId the ID of the board we are exporting
|
||||
* @param {string} authToken the loginToken
|
||||
* @param {string} delimiter delimiter to use while building export. Default is comma ','
|
||||
*/
|
||||
Picker.route('/api/boards/:boardId/export/csv', function(params, req, res) {
|
||||
const boardId = params.boardId;
|
||||
let user = null;
|
||||
const loginToken = params.query.authToken;
|
||||
if (loginToken) {
|
||||
const hashToken = Accounts._hashLoginToken(loginToken);
|
||||
user = Meteor.users.findOne({
|
||||
'services.resume.loginTokens.hashedToken': hashToken,
|
||||
});
|
||||
} else if (!Meteor.settings.public.sandstorm) {
|
||||
Authentication.checkUserId(req.userId);
|
||||
user = Users.findOne({
|
||||
_id: req.userId,
|
||||
isAdmin: true,
|
||||
});
|
||||
}
|
||||
const exporter = new Exporter(boardId);
|
||||
if (exporter.canExport(user)) {
|
||||
body = params.query.delimiter
|
||||
? exporter.buildCsv(params.query.delimiter)
|
||||
: exporter.buildCsv();
|
||||
res.writeHead(200, {
|
||||
'Content-Length': body.length,
|
||||
'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
|
||||
});
|
||||
res.write(body);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(403);
|
||||
res.end('Permission Error');
|
||||
}
|
||||
});
|
||||
}
|
||||
+71
-17
@@ -37,8 +37,8 @@ export class Exporter {
|
||||
result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
|
||||
result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
|
||||
result.customFields = CustomFields.find(
|
||||
{ boardIds: { $in: [this.boardId] } },
|
||||
{ fields: { boardId: 0 } },
|
||||
{ boardIds: this._boardId },
|
||||
{ fields: { boardIds: 0 } },
|
||||
).fetch();
|
||||
result.comments = CardComments.find(byBoard, noBoardId).fetch();
|
||||
result.activities = Activities.find(byBoard, noBoardId).fetch();
|
||||
@@ -214,7 +214,32 @@ export class Exporter {
|
||||
'Vote',
|
||||
'Archived',
|
||||
);
|
||||
|
||||
const customFieldMap = {};
|
||||
let i = 0;
|
||||
result.customFields.forEach(customField => {
|
||||
customFieldMap[customField._id] = {
|
||||
position: i,
|
||||
type: customField.type,
|
||||
};
|
||||
if (customField.type === 'dropdown') {
|
||||
let options = '';
|
||||
customField.settings.dropdownItems.forEach(item => {
|
||||
options = options === '' ? item.name : `${`${options}/${item.name}`}`;
|
||||
});
|
||||
columnHeaders.push(
|
||||
`CustomField-${customField.name}-${customField.type}-${options}`,
|
||||
);
|
||||
} else if (customField.type === 'currency') {
|
||||
columnHeaders.push(
|
||||
`CustomField-${customField.name}-${customField.type}-${customField.settings.currencyCode}`,
|
||||
);
|
||||
} else {
|
||||
columnHeaders.push(
|
||||
`CustomField-${customField.name}-${customField.type}`,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
});
|
||||
/* TODO: Try to get translations working.
|
||||
These currently only bring English translations.
|
||||
TAPi18n.__('title'),
|
||||
@@ -290,23 +315,17 @@ export class Exporter {
|
||||
labels = `${labels + label.name}-${label.color} `;
|
||||
});
|
||||
currentRow.push(labels.trim());
|
||||
currentRow.push(card.startAt ? moment(card.startAt).format('LLLL') : ' ');
|
||||
currentRow.push(card.dueAt ? moment(card.dueAt).format('LLLL') : ' ');
|
||||
currentRow.push(card.endAt ? moment(card.endAt).format('LLLL') : ' ');
|
||||
currentRow.push(card.startAt ? moment(card.startAt).format() : ' ');
|
||||
currentRow.push(card.dueAt ? moment(card.dueAt).format() : ' ');
|
||||
currentRow.push(card.endAt ? moment(card.endAt).format() : ' ');
|
||||
currentRow.push(card.isOvertime ? 'true' : 'false');
|
||||
currentRow.push(card.spentTime);
|
||||
currentRow.push(card.createdAt ? moment(card.createdAt).format() : ' ');
|
||||
currentRow.push(card.modifiedAt ? moment(card.modifiedAt).format() : ' ');
|
||||
currentRow.push(
|
||||
card.createdAt ? moment(card.createdAt).format('LLLL') : ' ',
|
||||
card.dateLastActivity ? moment(card.dateLastActivity).format() : ' ',
|
||||
);
|
||||
currentRow.push(
|
||||
card.modifiedAt ? moment(card.modifiedAt).format('LLLL') : ' ',
|
||||
);
|
||||
currentRow.push(
|
||||
card.dateLastActivity
|
||||
? moment(card.dateLastActivity).format('LLLL')
|
||||
: ' ',
|
||||
);
|
||||
if (card.vote.question) {
|
||||
if (card.vote && card.vote.question !== '') {
|
||||
let positiveVoters = '';
|
||||
let negativeVoters = '';
|
||||
card.vote.positive.forEach(userId => {
|
||||
@@ -331,10 +350,45 @@ export class Exporter {
|
||||
currentRow.push(' ');
|
||||
}
|
||||
currentRow.push(card.archived ? 'true' : 'false');
|
||||
//Custom fields
|
||||
const customFieldValuesToPush = new Array(result.customFields.length);
|
||||
card.customFields.forEach(field => {
|
||||
if (field.value !== null) {
|
||||
if (customFieldMap[field._id].type === 'date') {
|
||||
customFieldValuesToPush[
|
||||
customFieldMap[field._id].position
|
||||
] = moment(field.value).format();
|
||||
} else if (customFieldMap[field._id].type === 'dropdown') {
|
||||
const dropdownOptions = result.customFields.find(
|
||||
({ _id }) => _id === field._id,
|
||||
).settings.dropdownItems;
|
||||
const fieldValue = dropdownOptions.find(
|
||||
({ _id }) => _id === field.value,
|
||||
).name;
|
||||
customFieldValuesToPush[
|
||||
customFieldMap[field._id].position
|
||||
] = fieldValue;
|
||||
} else {
|
||||
customFieldValuesToPush[customFieldMap[field._id].position] =
|
||||
field.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (
|
||||
let valueIndex = 0;
|
||||
valueIndex < customFieldValuesToPush.length;
|
||||
valueIndex++
|
||||
) {
|
||||
if (!(valueIndex in customFieldValuesToPush)) {
|
||||
currentRow.push(' ');
|
||||
} else {
|
||||
currentRow.push(customFieldValuesToPush[valueIndex]);
|
||||
}
|
||||
}
|
||||
stringifier.write(currentRow);
|
||||
});
|
||||
stringifier.end();
|
||||
return cardRows;
|
||||
return cardRows[0];
|
||||
}
|
||||
|
||||
canExport(user) {
|
||||
|
||||
+1
-1
@@ -259,7 +259,7 @@ if (Meteor.isServer) {
|
||||
throw new Meteor.Error('invalid-user');
|
||||
}
|
||||
const user = Meteor.user();
|
||||
if (!user.emails && !user.emails[0] && user.emails[0].address) {
|
||||
if (!user.emails || !user.emails[0] || user.emails[0].address) {
|
||||
throw new Meteor.Error('email-invalid');
|
||||
}
|
||||
this.unblock();
|
||||
|
||||
@@ -369,7 +369,6 @@ export class TrelloCreator {
|
||||
// so we make it server only, and let UI catch up once it is done, forget about latency comp.
|
||||
const self = this;
|
||||
if (Meteor.isServer) {
|
||||
// FIXME: Change to new model
|
||||
file.attachData(att.url, function(error) {
|
||||
file.boardId = boardId;
|
||||
file.cardId = cardId;
|
||||
|
||||
@@ -415,7 +415,6 @@ export class WekanCreator {
|
||||
const self = this;
|
||||
if (Meteor.isServer) {
|
||||
if (att.url) {
|
||||
// FIXME: Change to new file library
|
||||
file.attachData(att.url, function(error) {
|
||||
file.boardId = boardId;
|
||||
file.cardId = cardId;
|
||||
@@ -441,7 +440,6 @@ export class WekanCreator {
|
||||
}
|
||||
});
|
||||
} else if (att.file) {
|
||||
// FIXME: Change to new file library
|
||||
file.attachData(
|
||||
Buffer.from(att.file, 'base64'),
|
||||
{
|
||||
|
||||
Generated
+7
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v4.04.0",
|
||||
"version": "v4.09.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -1744,14 +1744,6 @@
|
||||
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
|
||||
"dev": true
|
||||
},
|
||||
"fibers": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fibers/-/fibers-4.0.2.tgz",
|
||||
"integrity": "sha512-FhICi1K4WZh9D6NC18fh2ODF3EWy1z0gzIdV9P7+s2pRjfRBnCkMDJ6x3bV1DkVymKH8HGrQa/FNOBjYvnJ/tQ==",
|
||||
"requires": {
|
||||
"detect-libc": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"figures": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
|
||||
@@ -4068,9 +4060,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"prettier-eslint": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-9.0.1.tgz",
|
||||
"integrity": "sha512-KZT65QTosSAqBBqmrC+RpXbsMRe7Os2YSR9cAfFbDlyPAopzA/S5bioiZ3rpziNQNSJaOxmtXSx07EQ+o2Dlug==",
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-9.0.2.tgz",
|
||||
"integrity": "sha512-u6EQqxUhaGfra9gy9shcR7MT7r/2twwEfRGy1tfzyaJvLQwSg34M9IU5HuF7FsLW2QUgr5VIUc56EPWibw1pdw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/parser": "^1.10.2",
|
||||
@@ -4979,9 +4971,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
|
||||
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.3.tgz",
|
||||
"integrity": "sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ==",
|
||||
"dev": true
|
||||
},
|
||||
"union-value": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v4.04.0",
|
||||
"version": "v4.09.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -51,7 +51,7 @@
|
||||
"lint-staged": "^7.3.0",
|
||||
"pre-commit": "^1.2.2",
|
||||
"prettier": "^1.19.1",
|
||||
"prettier-eslint": "^9.0.1"
|
||||
"prettier-eslint": "^9.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.9.6",
|
||||
|
||||
+337
-324
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v4.04
|
||||
version: v4.09
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
|
||||
@@ -797,8 +797,8 @@ paths:
|
||||
200 response
|
||||
/api/boards/{board}/export:
|
||||
get:
|
||||
operationId: export
|
||||
summary: This route is used to export the board.
|
||||
operationId: exportJson
|
||||
summary: This route is used to export the board to a json file format.
|
||||
description: |
|
||||
If user is already logged-in, pass loginToken as param
|
||||
"authToken": '/api/boards/:boardId/export?authToken=:token'
|
||||
@@ -2668,6 +2668,7 @@ definitions:
|
||||
- number
|
||||
- date
|
||||
- dropdown
|
||||
- currency
|
||||
settings:
|
||||
description: |
|
||||
settings of the custom field
|
||||
@@ -2701,6 +2702,8 @@ definitions:
|
||||
CustomFieldsSettings:
|
||||
type: object
|
||||
properties:
|
||||
currencyCode:
|
||||
type: string
|
||||
dropdownItems:
|
||||
description: |
|
||||
list of drop down items objects
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 404,
|
||||
appVersion = 409,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "4.04.0~2020-05-24"),
|
||||
appMarketingVersion = (defaultText = "4.09.0~2020-05-27"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
@@ -261,7 +261,6 @@ const myCommand :Spk.Manifest.Command = (
|
||||
(key = "LDAP_ENABLE", value="false"),
|
||||
(key = "PASSWORD_LOGIN_ENABLED", value="true"),
|
||||
(key = "SANDSTORM", value="1"),
|
||||
(key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}"),
|
||||
(key = "ATTACHMENTS_STORE_PATH", value = "/var/attachments/")
|
||||
(key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}")
|
||||
]
|
||||
);
|
||||
+1
-44
@@ -80,7 +80,7 @@ Migrations.add('lowercase-board-permission', () => {
|
||||
Migrations.add('change-attachments-type-for-non-images', () => {
|
||||
const newTypeForNonImage = 'application/octet-stream';
|
||||
Attachments.find().forEach(file => {
|
||||
if (!file.isImage) {
|
||||
if (!file.isImage()) {
|
||||
Attachments.update(
|
||||
file._id,
|
||||
{
|
||||
@@ -1044,46 +1044,3 @@ Migrations.add('add-sort-field-to-boards', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
|
||||
Migrations.add('change-attachment-library', () => {
|
||||
const fs = require('fs');
|
||||
CFSAttachments.find().forEach(file => {
|
||||
const bucket = new MongoInternals.NpmModule.GridFSBucket(MongoInternals.defaultRemoteCollectionDriver().mongo.db, {bucketName: 'cfs_gridfs.attachments'});
|
||||
const gfsId = new MongoInternals.NpmModule.ObjectID(file.copies.attachments.key);
|
||||
const reader = bucket.openDownloadStream(gfsId);
|
||||
let store = Attachments.storagePath();
|
||||
if (store.charAt(store.length - 1) === '/') {
|
||||
store = store.substring(0, store.length - 1);
|
||||
}
|
||||
const path = `${store}/${file.name()}`;
|
||||
const fd = fs.createWriteStream(path);
|
||||
reader.pipe(fd);
|
||||
reader.on('end', () => {
|
||||
let opts = {
|
||||
fileName: file.name(),
|
||||
type: file.type(),
|
||||
size: file.size(),
|
||||
fileId: file._id,
|
||||
meta: {
|
||||
userId: file.userId,
|
||||
boardId: file.boardId,
|
||||
cardId: file.cardId
|
||||
}
|
||||
};
|
||||
if (file.listId) {
|
||||
opts.meta.listId = file.listId;
|
||||
}
|
||||
if (file.swimlaneId) {
|
||||
opts.meta.swimlaneId = file.swimlaneId;
|
||||
}
|
||||
Attachments.addFile(path, opts, (err, fileRef) => {
|
||||
if (err) {
|
||||
console.log('error when migrating', file.name(), err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
const localFSStore = process.env.ATTACHMENTS_STORE_PATH;
|
||||
const storeName = 'attachments';
|
||||
const defaultStoreOptions = {
|
||||
beforeWrite: fileObj => {
|
||||
if (!fileObj.isImage()) {
|
||||
return {
|
||||
type: 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
};
|
||||
let store;
|
||||
if (localFSStore) {
|
||||
// have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem
|
||||
const fs = Npm.require('fs');
|
||||
const path = Npm.require('path');
|
||||
const mongodb = Npm.require('mongodb');
|
||||
const Grid = Npm.require('gridfs-stream');
|
||||
// calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :(
|
||||
let pathname = localFSStore;
|
||||
/*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */
|
||||
|
||||
if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) {
|
||||
pathname = path.join(
|
||||
__meteor_bootstrap__.serverDir,
|
||||
`../../../cfs/files/${storeName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!pathname)
|
||||
throw new Error('FS.Store.FileSystem unable to determine path');
|
||||
|
||||
// Check if we have '~/foo/bar'
|
||||
if (pathname.split(path.sep)[0] === '~') {
|
||||
const homepath =
|
||||
process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
|
||||
if (homepath) {
|
||||
pathname = pathname.replace('~', homepath);
|
||||
} else {
|
||||
throw new Error('FS.Store.FileSystem unable to resolve "~" in path');
|
||||
}
|
||||
}
|
||||
|
||||
// Set absolute path
|
||||
const absolutePath = path.resolve(pathname);
|
||||
|
||||
const _FStore = new FS.Store.FileSystem(storeName, {
|
||||
path: localFSStore,
|
||||
...defaultStoreOptions,
|
||||
});
|
||||
const GStore = {
|
||||
fileKey(fileObj) {
|
||||
const key = {
|
||||
_id: null,
|
||||
filename: null,
|
||||
};
|
||||
|
||||
// If we're passed a fileObj, we retrieve the _id and filename from it.
|
||||
if (fileObj) {
|
||||
const info = fileObj._getInfo(storeName, {
|
||||
updateFileRecordFirst: false,
|
||||
});
|
||||
key._id = info.key || null;
|
||||
key.filename =
|
||||
info.name ||
|
||||
fileObj.name({ updateFileRecordFirst: false }) ||
|
||||
`${fileObj.collectionName}-${fileObj._id}`;
|
||||
}
|
||||
|
||||
// If key._id is null at this point, createWriteStream will let GridFS generate a new ID
|
||||
return key;
|
||||
},
|
||||
db: undefined,
|
||||
mongoOptions: { useNewUrlParser: true },
|
||||
mongoUrl: process.env.MONGO_URL,
|
||||
init() {
|
||||
this._init(err => {
|
||||
this.inited = !err;
|
||||
});
|
||||
},
|
||||
_init(callback) {
|
||||
const self = this;
|
||||
mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function(
|
||||
err,
|
||||
db,
|
||||
) {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
self.db = db;
|
||||
return callback(null);
|
||||
});
|
||||
return;
|
||||
},
|
||||
createReadStream(fileKey, options) {
|
||||
const self = this;
|
||||
if (!self.inited) {
|
||||
self.init();
|
||||
return undefined;
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
// Init GridFS
|
||||
const gfs = new Grid(self.db, mongodb);
|
||||
|
||||
// Set the default streamning settings
|
||||
const settings = {
|
||||
_id: new mongodb.ObjectID(fileKey._id),
|
||||
root: `cfs_gridfs.${storeName}`,
|
||||
};
|
||||
|
||||
// Check if this should be a partial read
|
||||
if (
|
||||
typeof options.start !== 'undefined' &&
|
||||
typeof options.end !== 'undefined'
|
||||
) {
|
||||
// Add partial info
|
||||
settings.range = {
|
||||
startPos: options.start,
|
||||
endPos: options.end,
|
||||
};
|
||||
}
|
||||
return gfs.createReadStream(settings);
|
||||
},
|
||||
};
|
||||
GStore.init();
|
||||
const CRS = 'createReadStream';
|
||||
const _CRS = `_${CRS}`;
|
||||
const FStore = _FStore._transform;
|
||||
FStore[_CRS] = FStore[CRS].bind(FStore);
|
||||
FStore[CRS] = function(fileObj, options) {
|
||||
let stream;
|
||||
try {
|
||||
const localFile = path.join(
|
||||
absolutePath,
|
||||
FStore.storage.fileKey(fileObj),
|
||||
);
|
||||
const state = fs.statSync(localFile);
|
||||
if (state) {
|
||||
stream = FStore[_CRS](fileObj, options);
|
||||
}
|
||||
} catch (e) {
|
||||
// file is not there, try GridFS ?
|
||||
stream = undefined;
|
||||
}
|
||||
if (stream) return stream;
|
||||
else {
|
||||
try {
|
||||
const stream = GStore[CRS](GStore.fileKey(fileObj), options);
|
||||
return stream;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}.bind(FStore);
|
||||
store = _FStore;
|
||||
} else {
|
||||
store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, {
|
||||
// XXX Add a new store for cover thumbnails so we don't load big images in
|
||||
// the general board view
|
||||
// If the uploaded document is not an image we need to enforce browser
|
||||
// download instead of execution. This is particularly important for HTML
|
||||
// files that the browser will just execute if we don't serve them with the
|
||||
// appropriate `application/octet-stream` MIME header which can lead to user
|
||||
// data leaks. I imagine other formats (like PDF) can also be attack vectors.
|
||||
// See https://github.com/wekan/wekan/issues/99
|
||||
// XXX Should we use `beforeWrite` option of CollectionFS instead of
|
||||
// collection-hooks?
|
||||
// We should use `beforeWrite`.
|
||||
...defaultStoreOptions,
|
||||
});
|
||||
}
|
||||
CFSAttachments = new FS.Collection('attachments', {
|
||||
stores: [store],
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(() => {
|
||||
CFSAttachments.files._ensureIndex({ cardId: 1 });
|
||||
});
|
||||
|
||||
CFSAttachments.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));
|
||||
},
|
||||
// We authorize the attachment download either:
|
||||
// - if the board is public, everyone (even unconnected) can download it
|
||||
// - if the board is private, only board members can download it
|
||||
download(userId, doc) {
|
||||
if (Meteor.isServer) {
|
||||
return true;
|
||||
}
|
||||
const board = Boards.findOne(doc.boardId);
|
||||
if (board.isPublic()) {
|
||||
return true;
|
||||
} else {
|
||||
return board.hasMember(userId);
|
||||
}
|
||||
},
|
||||
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
}
|
||||
|
||||
export default CFSAttachments;
|
||||
@@ -131,7 +131,7 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
|
||||
// Gather queries and send in bulk
|
||||
const cardComments = this.join(CardComments);
|
||||
cardComments.selector = _ids => ({ cardId: _ids });
|
||||
const attachments = this.join(Attachments.collection);
|
||||
const attachments = this.join(Attachments);
|
||||
attachments.selector = _ids => ({ cardId: _ids });
|
||||
const checklists = this.join(Checklists);
|
||||
checklists.selector = _ids => ({ cardId: _ids });
|
||||
|
||||
@@ -96,6 +96,8 @@ DESCRIPTION_ATTACHMENTS_STORE_PATH="Allow wekan ower to specify where uploaded f
|
||||
DEFAULT_ATTACHMENTS_STORE_PATH=""
|
||||
KEY_ATTACHMENTS_STORE_PATH="attachments-store-path"
|
||||
|
||||
# Example, not in use: /var/snap/wekan/common/uploads/
|
||||
|
||||
DESCRIPTION_MAX_IMAGE_PIXEL="Max image pixel: Allow to shrink attached/pasted image https://github.com/wekan/wekan/pull/2544"
|
||||
DEFAULT_MAX_IMAGE_PIXEL=""
|
||||
KEY_MAX_IMAGE_PIXEL="max-image-pixel"
|
||||
|
||||
@@ -24,6 +24,12 @@ if test -f "$SNAP_COMMON/mongodb.log"; then
|
||||
rm -f "$SNAP_COMMON/mongodb.log"
|
||||
fi
|
||||
|
||||
# Not in use. If uploads directory does not exist, create it.
|
||||
# Wekan will store attachments there.
|
||||
#if [ ! -d "$SNAP_COMMON/uploads" ]; then
|
||||
# mkdir "$SNAP_COMMON/uploads"
|
||||
#fi
|
||||
|
||||
# Alternative: When starting MongoDB, and using logfile, truncate log to last 1000 lines of text.
|
||||
# 1) If file exists:
|
||||
#if test -f "$SNAP_COMMON/mongodb.log"; then
|
||||
|
||||
@@ -234,6 +234,8 @@ parts:
|
||||
rm -f $SNAPCRAFT_PART_INSTALL/lib/node_modules/node-gyp/node_modules/tar/lib/.mkdir.js.swp
|
||||
# Meteor 1.8.x additional .swp remove
|
||||
rm -f $SNAPCRAFT_PART_INSTALL/programs/server/node_modules/node-pre-gyp/node_modules/tar/lib/.mkdir.js.swp
|
||||
# ostrio tmp remove
|
||||
rm -rf $SNAPCRAFT_PART_INSTALL/programs/server/npm/node_modules/meteor/ostrio_files/node_modules/request-libcurl/.node_modules-garbage*
|
||||
|
||||
organize:
|
||||
README: README.wekan
|
||||
|
||||
Reference in new issue
Block a user