Compare commits

...
30 Commits
Author SHA1 Message Date
Lauri Ojansivu 1f450df1af v4.06 2020-05-25 23:22:35 +03:00
Lauri Ojansivu e933890730 Update translations. 2020-05-25 23:19:25 +03:00
Lauri Ojansivu 8eafa1ac66 Fix Card export CSV, check for vote undefined.
Thanks to xet7 !
2020-05-25 22:59:52 +03:00
Lauri Ojansivu 858bb35612 v4.05 2020-05-25 21:46:08 +03:00
Lauri Ojansivu f9018fc3a8 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.

Thanks to xet7 !
2020-05-25 21:33:38 +03:00
Lauri Ojansivu 252c4b19f9 Update ChangeLog. 2020-05-25 18:04:51 +03:00
Lauri Ojansivu 05349a5deb Prettifier fixes. 2020-05-25 18:04:32 +03:00
Lauri Ojansivu d52affe658 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.

Thanks to xet7 !

Related #3110
2020-05-25 17:54:51 +03:00
Lauri Ojansivu 23dcd084a4 Update translations. 2020-05-24 19:29:38 +03:00
Lauri Ojansivu a853189486 Update ChangeLog. 2020-05-24 19:10:29 +03:00
Lauri Ojansivu 82d0a3df85 Merge branch 'brymut-import-export-custom-fields' 2020-05-24 19:06:38 +03:00
Lauri Ojansivu ef189477dd Merge branch 'import-export-custom-fields' of https://github.com/brymut/wekan into
brymut-import-export-custom-fields
2020-05-24 18:59:46 +03:00
Lauri Ojansivu 4bcc795778 Update ChangeLog. 2020-05-24 18:57:43 +03:00
Lauri Ojansivu 01346152b5 Merge pull request #3112 from marc1006/deepcode
Some fixes suggested by deepcode.ai
2020-05-24 18:56:36 +03:00
Lauri Ojansivu 5b7b5b3e7e Update ChangeLog. 2020-05-24 18:51:24 +03:00
Lauri Ojansivu 2df29324e6 Merge branch 'marc1006-minor_fixes' 2020-05-24 18:51:12 +03:00
Bryan Mutai 51f52fee23 enable exporting and importing custom fields. 2020-05-24 17:48:49 +03:00
Marc Hartmayer 6e088af3d1 Fix email verification in sendSMTPTestEmail
Found by deepcode.ai.
2020-05-24 13:19:24 +02:00
Marc Hartmayer fc9f0d8392 Sort callback should return 0 if values are equal
Fixes:
"The callback [:381] provided to sort [:381] should return 0 if the compared values are equal."
2020-05-24 13:19:24 +02:00
Marc Hartmayer cfa2bbd3d4 Fix false positive found by deepcode.ai 2020-05-24 13:19:24 +02:00
Marc Hartmayer 52d633ca69 Use on instead of bind
Suggested by deepcode.ai.
2020-05-24 12:46:39 +02:00
Marc Hartmayer b2fee6a6c1 Use an arrow function inside forEach() instead of an anonymous function
Suggested by deepcode.ai.
2020-05-24 12:44:14 +02:00
Marc Hartmayer 66d35a1528 Fix minicard cover functionality
Otherwise, if `this.coverId` is undefined then `Attachments.findOne()` would
return any attachment.
2020-05-24 11:55:44 +02:00
Marc Hartmayer b7105d7b57 Fix error link not available
Fixes:

Exception in template helper: TypeError: Cannot read property 'link' of undefined
    at constructor.attachmentLink (http://192.168.178.2:8080/app/app.js?hash=c0f961362c1318d650abaafcaaa6f6a77c8d11c8:3935:29)
    at http://192.168.178.2:8080/packages/blaze.js?hash=a20deb597f76789e171a9ee2b2e37d73fbb7ecda:1566:16
    at http://192.168.178.2:8080/packages/peerlibrary_blaze-components.js?hash=4049f7e3116e3d9e865392b9546e70dc479b9add:395:66
    at Function.Template._withTemplateInstanceFunc (http://192.168.178.2:8080/packages/blaze.js?hash=a20deb597f76789e171a9ee2b2e37d73fbb7ecda:3575:14)
    at http://192.168.178.2:8080/packages/peerlibrary_blaze-components.js?hash=4049f7e3116e3d9e865392b9546e70dc479b9add:394:27
    at Spacebars.call (http://192.168.178.2:8080/packages/spacebars.js?hash=6f2be25813c793c0b363a6a91ebb029723f294ec:169:51)
    at Spacebars.mustacheImpl (http://192.168.178.2:8080/packages/spacebars.js?hash=6f2be25813c793c0b363a6a91ebb029723f294ec:106:25)
    at Object.Spacebars.mustache (http://192.168.178.2:8080/packages/spacebars.js?hash=6f2be25813c793c0b363a6a91ebb029723f294ec:110:39)
    at Blaze.View._render (http://192.168.178.2:8080/app/app.js?hash=c0f961362c1318d650abaafcaaa6f6a77c8d11c8:3328:42)
    at Blaze.View.doRender (http://192.168.178.2:8080/packages/blaze.js?hash=a20deb597f76789e171a9ee2b2e37d73fbb7ecda:1925:25)
2020-05-24 11:31:25 +02:00
Marc Hartmayer d5fbd50b76 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
at constructor.scrollParentContainer (cardDetails.js:77)
at cardDetails.js:190
at Meteor.EnvironmentVariable.EVp.withValue (meteor.js?hash=857dafb4b9dff17e29ed8498a22ea5b1a3d6b41d:1207)
at meteor.js?hash=857dafb4b9dff17e29ed8498a22ea5b1a3d6b41d:588
at meteor.js?hash=857dafb4b9dff17e29ed8498a22ea5b1a3d6b41d:1234

Probably there is a better fix for this.
2020-05-24 11:28:05 +02:00
Lauri Ojansivu 06515559a6 Update translations. 2020-05-24 09:12:01 +03:00
Lauri Ojansivu c61a126c8b Add default attachments store path /var/snap/wekan/common/uploads
where attachments will be stored.
2020-05-24 09:03:50 +03:00
Lauri Ojansivu b737adfcdf Try to fix Snap. 2020-05-24 07:24:44 +03:00
Lauri Ojansivu 3b44acd87c Update packages. 2020-05-24 06:54:48 +03:00
Lauri Ojansivu 6fad68b9b9 Try to fix Snap. 2020-05-24 05:43:50 +03:00
40 changed files with 1006 additions and 1189 deletions

No files matched your search

-1
View File
@@ -98,4 +98,3 @@ percolate:synced-cron
easylogic:summernote
cfs:filesystem
ostrio:cookies
ostrio:files
+1 -2
View File
@@ -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
+51
View File
@@ -1,5 +1,56 @@
# 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
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v4.04.0"
appVersion: "v4.06.0"
files:
userUploads:
- README.md
+3 -4
View File
@@ -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
+3 -10
View File
@@ -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
+17 -86
View File
@@ -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];
}
-11
View File
@@ -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
+8 -1
View File
@@ -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();
+1 -1
View File
@@ -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
-3
View File
@@ -52,7 +52,4 @@ Template.minicard.helpers({
return false;
}
},
coverUrl() {
return Attachments.findOne(this.coverId).link('original', '/');
},
});
+26 -24
View File
@@ -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,
+1 -1
View File
@@ -467,7 +467,7 @@ Template.exportBoard.events({
'click .html-export-board': async event => {
event.preventDefault();
await ExportHtml(Popup)();
}
},
});
Template.labelsWidget.events({
+2 -2
View File
@@ -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
View File
@@ -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();
}
};
};
+1 -1
View File
@@ -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
View File
@@ -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
+25 -25
View File
@@ -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": "标签",
@@ -309,7 +309,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 +317,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 +357,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 +399,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 +639,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 +798,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": "在首先删除指向此列表中的卡的链接卡之前,不能删除此列表"
}
+1 -1
View File
@@ -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
View File
@@ -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;
+9 -13
View File
@@ -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() {
+3 -1
View File
@@ -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;
+86 -16
View File
@@ -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,50 @@ 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 {
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 {
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 +270,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 +281,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 +337,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 +376,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;
}
+51 -197
View File
@@ -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[0].length,
'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
});
res.write(body[0]);
res.end();
} else {
res.writeHead(403);
res.end('Permission Error');
}
});
}
+65 -16
View File
@@ -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,29 @@ export class Exporter {
'Vote',
'Archived',
);
const customFieldMap = {};
let i = 0;
result.customFields.forEach(customField => {
customFieldMap[customField._id] = i;
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 {
columnHeaders.push(
`CustomField-${customField.name}-${customField.type}`,
);
}
i++;
});
/* TODO: Try to get translations working.
These currently only bring English translations.
TAPi18n.__('title'),
@@ -290,23 +312,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.question !== undefined) {
let positiveVoters = '';
let negativeVoters = '';
card.vote.positive.forEach(userId => {
@@ -331,6 +347,39 @@ 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 (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();
+1 -1
View File
@@ -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();
-1
View File
@@ -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;
-2
View File
@@ -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'),
{
+7 -15
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v4.04.0",
"version": "v4.06.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
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v4.04.0",
"version": "v4.06.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",
+324 -324
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v4.04
version: v4.06
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'
+3 -4
View File
@@ -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 = 406,
# Increment this for every release.
appMarketingVersion = (defaultText = "4.04.0~2020-05-24"),
appMarketingVersion = (defaultText = "4.06.0~2020-05-25"),
# 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
View File
@@ -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);
}
});
});
});
});
-212
View File
@@ -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;
+1 -1
View File
@@ -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 });
+2
View File
@@ -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"
+6
View File
@@ -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
+2
View File
@@ -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