exporter.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. const Papa = require('papaparse');
  2. // exporter maybe is broken since Gridfs introduced, add fs and path
  3. export class Exporter {
  4. constructor(boardId, attachmentId) {
  5. this._boardId = boardId;
  6. this._attachmentId = attachmentId;
  7. }
  8. build() {
  9. const fs = Npm.require('fs');
  10. const os = Npm.require('os');
  11. const path = Npm.require('path');
  12. const byBoard = { boardId: this._boardId };
  13. const byBoardNoLinked = {
  14. boardId: this._boardId,
  15. linkedId: { $in: ['', null] },
  16. };
  17. // we do not want to retrieve boardId in related elements
  18. const noBoardId = {
  19. fields: {
  20. boardId: 0,
  21. },
  22. };
  23. const result = {
  24. _format: 'wekan-board-1.0.0',
  25. };
  26. _.extend(
  27. result,
  28. Boards.findOne(this._boardId, {
  29. fields: {
  30. stars: 0,
  31. },
  32. }),
  33. );
  34. // [Old] for attachments we only export IDs and absolute url to original doc
  35. // [New] Encode attachment to base64
  36. const getBase64Data = function (doc, callback) {
  37. let buffer = Buffer.allocUnsafe(0);
  38. buffer.fill(0);
  39. // callback has the form function (err, res) {}
  40. const tmpFile = path.join(
  41. os.tmpdir(),
  42. `tmpexport${process.pid}${Math.random()}`,
  43. );
  44. const tmpWriteable = fs.createWriteStream(tmpFile);
  45. const readStream = doc.createReadStream();
  46. readStream.on('data', function (chunk) {
  47. buffer = Buffer.concat([buffer, chunk]);
  48. });
  49. readStream.on('error', function () {
  50. callback(null, null);
  51. });
  52. readStream.on('end', function () {
  53. // done
  54. fs.unlink(tmpFile, () => {
  55. //ignored
  56. });
  57. callback(null, buffer.toString('base64'));
  58. });
  59. readStream.pipe(tmpWriteable);
  60. };
  61. const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
  62. const byBoardAndAttachment = this._attachmentId
  63. ? { boardId: this._boardId, _id: this._attachmentId }
  64. : byBoard;
  65. result.attachments = Attachments.find(byBoardAndAttachment)
  66. .fetch()
  67. .map((attachment) => {
  68. let filebase64 = null;
  69. filebase64 = getBase64DataSync(attachment);
  70. return {
  71. _id: attachment._id,
  72. cardId: attachment.cardId,
  73. //url: FlowRouter.url(attachment.url()),
  74. file: filebase64,
  75. name: attachment.original.name,
  76. type: attachment.original.type,
  77. };
  78. });
  79. //When has a especific valid attachment return the single element
  80. if (this._attachmentId) {
  81. return result.attachments.length > 0 ? result.attachments[0] : {};
  82. }
  83. result.lists = Lists.find(byBoard, noBoardId).fetch();
  84. result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
  85. result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
  86. result.customFields = CustomFields.find(
  87. { boardIds: this._boardId },
  88. { fields: { boardIds: 0 } },
  89. ).fetch();
  90. result.comments = CardComments.find(byBoard, noBoardId).fetch();
  91. result.activities = Activities.find(byBoard, noBoardId).fetch();
  92. result.rules = Rules.find(byBoard, noBoardId).fetch();
  93. result.checklists = [];
  94. result.checklistItems = [];
  95. result.subtaskItems = [];
  96. result.triggers = [];
  97. result.actions = [];
  98. result.cards.forEach((card) => {
  99. result.checklists.push(
  100. ...Checklists.find({
  101. cardId: card._id,
  102. }).fetch(),
  103. );
  104. result.checklistItems.push(
  105. ...ChecklistItems.find({
  106. cardId: card._id,
  107. }).fetch(),
  108. );
  109. result.subtaskItems.push(
  110. ...Cards.find({
  111. parentId: card._id,
  112. }).fetch(),
  113. );
  114. });
  115. result.rules.forEach((rule) => {
  116. result.triggers.push(
  117. ...Triggers.find(
  118. {
  119. _id: rule.triggerId,
  120. },
  121. noBoardId,
  122. ).fetch(),
  123. );
  124. result.actions.push(
  125. ...Actions.find(
  126. {
  127. _id: rule.actionId,
  128. },
  129. noBoardId,
  130. ).fetch(),
  131. );
  132. });
  133. // we also have to export some user data - as the other elements only
  134. // include id but we have to be careful:
  135. // 1- only exports users that are linked somehow to that board
  136. // 2- do not export any sensitive information
  137. const users = {};
  138. result.members.forEach((member) => {
  139. users[member.userId] = true;
  140. });
  141. result.lists.forEach((list) => {
  142. users[list.userId] = true;
  143. });
  144. result.cards.forEach((card) => {
  145. users[card.userId] = true;
  146. if (card.members) {
  147. card.members.forEach((memberId) => {
  148. users[memberId] = true;
  149. });
  150. }
  151. });
  152. result.comments.forEach((comment) => {
  153. users[comment.userId] = true;
  154. });
  155. result.activities.forEach((activity) => {
  156. users[activity.userId] = true;
  157. });
  158. result.checklists.forEach((checklist) => {
  159. users[checklist.userId] = true;
  160. });
  161. const byUserIds = {
  162. _id: {
  163. $in: Object.getOwnPropertyNames(users),
  164. },
  165. };
  166. // we use whitelist to be sure we do not expose inadvertently
  167. // some secret fields that gets added to User later.
  168. const userFields = {
  169. fields: {
  170. _id: 1,
  171. username: 1,
  172. 'profile.fullname': 1,
  173. 'profile.initials': 1,
  174. 'profile.avatarUrl': 1,
  175. },
  176. };
  177. result.users = Users.find(byUserIds, userFields)
  178. .fetch()
  179. .map((user) => {
  180. // user avatar is stored as a relative url, we export absolute
  181. if ((user.profile || {}).avatarUrl) {
  182. user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
  183. }
  184. return user;
  185. });
  186. return result;
  187. }
  188. buildCsv(delimiter = ',') {
  189. const result = this.build();
  190. const columnHeaders = [];
  191. const cardRows = [];
  192. const papaconfig = {
  193. delimiter, // get parameter (was: auto-detect)
  194. worker: true,
  195. };
  196. /*
  197. newline: "", // auto-detect
  198. quoteChar: '"',
  199. escapeChar: '"',
  200. header: true,
  201. transformHeader: undefined,
  202. dynamicTyping: false,
  203. preview: 0,
  204. encoding: "",
  205. comments: false,
  206. step: undefined,
  207. complete: undefined,
  208. error: undefined,
  209. download: false,
  210. downloadRequestHeaders: undefined,
  211. downloadRequestBody: undefined,
  212. skipEmptyLines: false,
  213. chunk: undefined,
  214. chunkSize: undefined,
  215. fastMode: undefined,
  216. beforeFirstChunk: undefined,
  217. withCredentials: undefined,
  218. transform: undefined
  219. };
  220. */
  221. //delimitersToGuess: [',', '\t', '|', ';', Papa.RECORD_SEP, Papa.UNIT_SEP]
  222. columnHeaders.push(
  223. 'Title',
  224. 'Description',
  225. 'Status',
  226. 'Swimlane',
  227. 'Owner',
  228. 'Requested by',
  229. 'Assigned by',
  230. 'Members',
  231. 'Assignees',
  232. 'Labels',
  233. 'Start at',
  234. 'Due at',
  235. 'End at',
  236. 'Over time',
  237. 'Spent time (hours)',
  238. 'Created at',
  239. 'Last modified at',
  240. 'Last activity',
  241. 'Vote',
  242. 'Archived',
  243. );
  244. const customFieldMap = {};
  245. let i = 0;
  246. result.customFields.forEach((customField) => {
  247. customFieldMap[customField._id] = {
  248. position: i,
  249. type: customField.type,
  250. };
  251. if (customField.type === 'dropdown') {
  252. let options = '';
  253. customField.settings.dropdownItems.forEach((item) => {
  254. options = options === '' ? item.name : `${`${options}/${item.name}`}`;
  255. });
  256. columnHeaders.push(
  257. `CustomField-${customField.name}-${customField.type}-${options}`,
  258. );
  259. } else if (customField.type === 'currency') {
  260. columnHeaders.push(
  261. `CustomField-${customField.name}-${customField.type}-${customField.settings.currencyCode}`,
  262. );
  263. } else {
  264. columnHeaders.push(
  265. `CustomField-${customField.name}-${customField.type}`,
  266. );
  267. }
  268. i++;
  269. });
  270. cardRows.push([[columnHeaders]]);
  271. /* TODO: Try to get translations working.
  272. These currently only bring English translations.
  273. TAPi18n.__('title'),
  274. TAPi18n.__('description'),
  275. TAPi18n.__('status'),
  276. TAPi18n.__('swimlane'),
  277. TAPi18n.__('owner'),
  278. TAPi18n.__('requested-by'),
  279. TAPi18n.__('assigned-by'),
  280. TAPi18n.__('members'),
  281. TAPi18n.__('assignee'),
  282. TAPi18n.__('labels'),
  283. TAPi18n.__('card-start'),
  284. TAPi18n.__('card-due'),
  285. TAPi18n.__('card-end'),
  286. TAPi18n.__('overtime-hours'),
  287. TAPi18n.__('spent-time-hours'),
  288. TAPi18n.__('createdAt'),
  289. TAPi18n.__('last-modified-at'),
  290. TAPi18n.__('last-activity'),
  291. TAPi18n.__('voting'),
  292. TAPi18n.__('archived'),
  293. */
  294. result.cards.forEach((card) => {
  295. const currentRow = [];
  296. currentRow.push(card.title);
  297. currentRow.push(card.description);
  298. currentRow.push(
  299. result.lists.find(({ _id }) => _id === card.listId).title,
  300. );
  301. currentRow.push(
  302. result.swimlanes.find(({ _id }) => _id === card.swimlaneId).title,
  303. );
  304. currentRow.push(
  305. result.users.find(({ _id }) => _id === card.userId).username,
  306. );
  307. currentRow.push(card.requestedBy ? card.requestedBy : ' ');
  308. currentRow.push(card.assignedBy ? card.assignedBy : ' ');
  309. let usernames = '';
  310. card.members.forEach((memberId) => {
  311. const user = result.users.find(({ _id }) => _id === memberId);
  312. usernames = `${usernames + user.username} `;
  313. });
  314. currentRow.push(usernames.trim());
  315. let assignees = '';
  316. card.assignees.forEach((assigneeId) => {
  317. const user = result.users.find(({ _id }) => _id === assigneeId);
  318. assignees = `${assignees + user.username} `;
  319. });
  320. currentRow.push(assignees.trim());
  321. let labels = '';
  322. card.labelIds.forEach((labelId) => {
  323. const label = result.labels.find(({ _id }) => _id === labelId);
  324. labels = `${labels + label.name}-${label.color} `;
  325. });
  326. currentRow.push(labels.trim());
  327. currentRow.push(card.startAt ? moment(card.startAt).format() : ' ');
  328. currentRow.push(card.dueAt ? moment(card.dueAt).format() : ' ');
  329. currentRow.push(card.endAt ? moment(card.endAt).format() : ' ');
  330. currentRow.push(card.isOvertime ? 'true' : 'false');
  331. currentRow.push(card.spentTime);
  332. currentRow.push(card.createdAt ? moment(card.createdAt).format() : ' ');
  333. currentRow.push(card.modifiedAt ? moment(card.modifiedAt).format() : ' ');
  334. currentRow.push(
  335. card.dateLastActivity ? moment(card.dateLastActivity).format() : ' ',
  336. );
  337. if (card.vote && card.vote.question !== '') {
  338. let positiveVoters = '';
  339. let negativeVoters = '';
  340. card.vote.positive.forEach((userId) => {
  341. const user = result.users.find(({ _id }) => _id === userId);
  342. positiveVoters = `${positiveVoters + user.username} `;
  343. });
  344. card.vote.negative.forEach((userId) => {
  345. const user = result.users.find(({ _id }) => _id === userId);
  346. negativeVoters = `${negativeVoters + user.username} `;
  347. });
  348. const votingResult = `${
  349. card.vote.public
  350. ? `yes-${
  351. card.vote.positive.length
  352. }-${positiveVoters.trimRight()}-no-${
  353. card.vote.negative.length
  354. }-${negativeVoters.trimRight()}`
  355. : `yes-${card.vote.positive.length}-no-${card.vote.negative.length}`
  356. }`;
  357. currentRow.push(`${card.vote.question}-${votingResult}`);
  358. } else {
  359. currentRow.push(' ');
  360. }
  361. currentRow.push(card.archived ? 'true' : 'false');
  362. //Custom fields
  363. const customFieldValuesToPush = new Array(result.customFields.length);
  364. card.customFields.forEach((field) => {
  365. if (field.value !== null) {
  366. if (customFieldMap[field._id].type === 'date') {
  367. customFieldValuesToPush[customFieldMap[field._id].position] =
  368. moment(field.value).format();
  369. } else if (customFieldMap[field._id].type === 'dropdown') {
  370. const dropdownOptions = result.customFields.find(
  371. ({ _id }) => _id === field._id,
  372. ).settings.dropdownItems;
  373. const fieldValue = dropdownOptions.find(
  374. ({ _id }) => _id === field.value,
  375. ).name;
  376. customFieldValuesToPush[customFieldMap[field._id].position] =
  377. fieldValue;
  378. } else {
  379. customFieldValuesToPush[customFieldMap[field._id].position] =
  380. field.value;
  381. }
  382. }
  383. });
  384. for (
  385. let valueIndex = 0;
  386. valueIndex < customFieldValuesToPush.length;
  387. valueIndex++
  388. ) {
  389. if (!(valueIndex in customFieldValuesToPush)) {
  390. currentRow.push(' ');
  391. } else {
  392. currentRow.push(customFieldValuesToPush[valueIndex]);
  393. }
  394. }
  395. cardRows.push([[currentRow]]);
  396. });
  397. return Papa.unparse(cardRows, papaconfig);
  398. }
  399. canExport(user) {
  400. const board = Boards.findOne(this._boardId);
  401. return board && board.isVisibleBy(user);
  402. }
  403. }