exporter.js 12 KB

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