@basementuniverse/kanbn 1.1.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +3 -0
  2. package/coverage/base.css +224 -0
  3. package/coverage/block-navigation.js +87 -0
  4. package/coverage/coverage-summary.json +31 -0
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +146 -0
  7. package/coverage/kanbn/index.html +116 -0
  8. package/coverage/kanbn/index.js.html +217 -0
  9. package/coverage/kanbn/src/board.js.html +646 -0
  10. package/coverage/kanbn/src/controller/add.js.html +1729 -0
  11. package/coverage/kanbn/src/controller/archive.js.html +277 -0
  12. package/coverage/kanbn/src/controller/board.js.html +217 -0
  13. package/coverage/kanbn/src/controller/burndown.js.html +607 -0
  14. package/coverage/kanbn/src/controller/comment.js.html +397 -0
  15. package/coverage/kanbn/src/controller/edit.js.html +2920 -0
  16. package/coverage/kanbn/src/controller/find.js.html +1597 -0
  17. package/coverage/kanbn/src/controller/gantt.js.html +1342 -0
  18. package/coverage/kanbn/src/controller/help.js.html +115 -0
  19. package/coverage/kanbn/src/controller/history.js.html +400 -0
  20. package/coverage/kanbn/src/controller/index.html +431 -0
  21. package/coverage/kanbn/src/controller/init.js.html +511 -0
  22. package/coverage/kanbn/src/controller/move.js.html +514 -0
  23. package/coverage/kanbn/src/controller/remove-all.js.html +226 -0
  24. package/coverage/kanbn/src/controller/remove.js.html +307 -0
  25. package/coverage/kanbn/src/controller/rename.js.html +397 -0
  26. package/coverage/kanbn/src/controller/restore.js.html +268 -0
  27. package/coverage/kanbn/src/controller/sort.js.html +1318 -0
  28. package/coverage/kanbn/src/controller/sprint.js.html +361 -0
  29. package/coverage/kanbn/src/controller/status.js.html +310 -0
  30. package/coverage/kanbn/src/controller/task.js.html +244 -0
  31. package/coverage/kanbn/src/controller/validate.js.html +367 -0
  32. package/coverage/kanbn/src/controller/version.js.html +100 -0
  33. package/coverage/kanbn/src/index.html +191 -0
  34. package/coverage/kanbn/src/main.js.html +9787 -0
  35. package/coverage/kanbn/src/parse-index.js.html +1063 -0
  36. package/coverage/kanbn/src/parse-markdown.js.html +202 -0
  37. package/coverage/kanbn/src/parse-task.js.html +2191 -0
  38. package/coverage/kanbn/src/utility.js.html +616 -0
  39. package/coverage/lcov.info +11837 -0
  40. package/coverage/prettify.css +1 -0
  41. package/coverage/prettify.js +2 -0
  42. package/coverage/sort-arrow-sprite.png +0 -0
  43. package/coverage/sorter.js +210 -0
  44. package/coverage/tmp/coverage-214292-1787777191526-0.json +1 -0
  45. package/coverage/tmp/coverage-214293-1787777184569-0.json +1 -0
  46. package/docs/advanced-configuration.md +25 -0
  47. package/docs/commands/add.txt +19 -0
  48. package/docs/commands/archive.txt +5 -0
  49. package/docs/commands/board.txt +8 -1
  50. package/docs/commands/boards.txt +34 -0
  51. package/docs/commands/burndown.txt +8 -4
  52. package/docs/commands/comment.txt +5 -0
  53. package/docs/commands/edit.txt +27 -0
  54. package/docs/commands/find.txt +56 -1
  55. package/docs/commands/gantt.txt +5 -0
  56. package/docs/commands/help.txt +2 -0
  57. package/docs/commands/history.txt +5 -0
  58. package/docs/commands/init.txt +13 -0
  59. package/docs/commands/move.txt +10 -1
  60. package/docs/commands/remove.txt +12 -1
  61. package/docs/commands/rename.txt +5 -0
  62. package/docs/commands/restore.txt +6 -0
  63. package/docs/commands/sort.txt +18 -2
  64. package/docs/commands/sprint.txt +9 -0
  65. package/docs/commands/status.txt +10 -1
  66. package/docs/commands/task.txt +5 -0
  67. package/docs/commands/validate.txt +27 -0
  68. package/docs/filtering-and-sorting.md +218 -0
  69. package/docs/index-structure.md +94 -19
  70. package/docs/index.md +28 -2
  71. package/docs/migration-2.0.md +92 -0
  72. package/docs/multiple-boards.md +258 -0
  73. package/docs/quick-start.md +57 -1
  74. package/docs/task-structure.md +33 -3
  75. package/docs/views.md +229 -0
  76. package/example/README.md +93 -0
  77. package/example/advanced/.kanbn/index.md +26 -0
  78. package/example/advanced/.kanbn/tasks/add-rate-limiting.md +18 -0
  79. package/example/advanced/.kanbn/tasks/add-sso-support.md +15 -0
  80. package/example/advanced/.kanbn/tasks/migrate-to-new-cdn.md +14 -0
  81. package/example/advanced/.kanbn/tasks/refresh-marketing-site.md +14 -0
  82. package/example/advanced/.kanbn/tasks/rotate-api-keys.md +15 -0
  83. package/example/advanced/.kanbn/tasks/split-billing-service.md +17 -0
  84. package/example/advanced/.kanbn/tasks/upgrade-node-runtime.md +19 -0
  85. package/example/advanced/kanbn.yml +59 -0
  86. package/example/boards/.kanbn/design.md +31 -0
  87. package/example/boards/.kanbn/index.md +44 -0
  88. package/example/boards/.kanbn/tasks/add-usage-alert-emails.md +19 -0
  89. package/example/boards/.kanbn/tasks/build-tenant-settings-page.md +43 -0
  90. package/example/boards/.kanbn/tasks/create-organization-switcher.md +44 -0
  91. package/example/boards/.kanbn/tasks/design-onboarding-checklist.md +22 -0
  92. package/example/boards/.kanbn/tasks/refresh-marketing-site.md +28 -0
  93. package/example/boards/.kanbn/tasks/ship-billing-portal.md +29 -0
  94. package/example/views/.kanbn/index.md +140 -0
  95. package/example/views/.kanbn/tasks/add-audit-log-export.md +18 -0
  96. package/example/views/.kanbn/tasks/add-dark-mode-toggle.md +16 -0
  97. package/example/views/.kanbn/tasks/build-webhook-retry-queue.md +15 -0
  98. package/example/views/.kanbn/tasks/fix-avatar-upload-crash.md +13 -0
  99. package/example/views/.kanbn/tasks/fix-expired-session-redirect.md +15 -0
  100. package/example/views/.kanbn/tasks/investigate-offline-mode.md +12 -0
  101. package/example/views/.kanbn/tasks/rewrite-search-indexer.md +12 -0
  102. package/example/views/.kanbn/tasks/tidy-up-settings-copy.md +12 -0
  103. package/package.json +12 -9
  104. package/routes/add.json +35 -11
  105. package/routes/archive.json +10 -2
  106. package/routes/board.json +11 -3
  107. package/routes/boards.json +30 -0
  108. package/routes/burndown.json +23 -7
  109. package/routes/comment.json +14 -4
  110. package/routes/edit.json +33 -10
  111. package/routes/find.json +37 -12
  112. package/routes/gantt.json +20 -6
  113. package/routes/history.json +39 -25
  114. package/routes/init.json +3 -1
  115. package/routes/move.json +22 -6
  116. package/routes/remove.json +15 -4
  117. package/routes/rename.json +11 -3
  118. package/routes/restore.json +8 -2
  119. package/routes/sort.json +35 -11
  120. package/routes/sprint.json +14 -4
  121. package/routes/status.json +23 -7
  122. package/routes/task.json +10 -2
  123. package/routes/validate.json +19 -4
  124. package/skills/kanbn-plan/SKILL.md +10 -1
  125. package/skills/kanbn-plan/references/index-structure.md +1 -0
  126. package/skills/kanbn-plan/references/task-structure.md +2 -0
  127. package/skills/kanbn-replan/SKILL.md +6 -1
  128. package/src/board.js +2 -1
  129. package/src/controller/add.js +52 -46
  130. package/src/controller/archive.js +8 -4
  131. package/src/controller/board.js +9 -4
  132. package/src/controller/boards.js +140 -0
  133. package/src/controller/burndown.js +9 -5
  134. package/src/controller/comment.js +9 -5
  135. package/src/controller/edit.js +74 -17
  136. package/src/controller/find.js +12 -9
  137. package/src/controller/gantt.js +8 -4
  138. package/src/controller/history.js +8 -4
  139. package/src/controller/init.js +39 -4
  140. package/src/controller/move.js +69 -15
  141. package/src/controller/remove.js +34 -11
  142. package/src/controller/rename.js +8 -4
  143. package/src/controller/restore.js +23 -8
  144. package/src/controller/sort.js +19 -3
  145. package/src/controller/sprint.js +31 -7
  146. package/src/controller/status.js +20 -4
  147. package/src/controller/task.js +22 -9
  148. package/src/controller/validate.js +139 -23
  149. package/src/git-user-name.js +19 -0
  150. package/src/main.d.ts +184 -6
  151. package/src/main.js +1642 -221
  152. package/src/parse-index.js +17 -0
  153. package/src/parse-markdown.js +3 -1
  154. package/src/parse-task.js +44 -1
  155. package/src/utility.js +166 -1
  156. package/PLANS.md +0 -9
  157. /package/example/{.kanbn → basic/.kanbn}/index.md +0 -0
  158. /package/example/{.kanbn → basic/.kanbn}/tasks/add-basic-activity-feed.md +0 -0
  159. /package/example/{.kanbn → basic/.kanbn}/tasks/add-passwordless-login-option.md +0 -0
  160. /package/example/{.kanbn → basic/.kanbn}/tasks/add-usage-alert-email-thresholds.md +0 -0
  161. /package/example/{.kanbn → basic/.kanbn}/tasks/build-email-template-system.md +0 -0
  162. /package/example/{.kanbn → basic/.kanbn}/tasks/build-invoice-download-endpoint.md +0 -0
  163. /package/example/{.kanbn → basic/.kanbn}/tasks/build-tenant-settings-page.md +0 -0
  164. /package/example/{.kanbn → basic/.kanbn}/tasks/create-organization-switcher.md +0 -0
  165. /package/example/{.kanbn → basic/.kanbn}/tasks/create-sandbox-environment-provisioner.md +0 -0
  166. /package/example/{.kanbn → basic/.kanbn}/tasks/create-self-serve-cancellation-flow.md +0 -0
  167. /package/example/{.kanbn → basic/.kanbn}/tasks/define-product-pricing-strategy.md +0 -0
  168. /package/example/{.kanbn → basic/.kanbn}/tasks/design-onboarding-checklist.md +0 -0
  169. /package/example/{.kanbn → basic/.kanbn}/tasks/design-team-invite-expiry-flow.md +0 -0
  170. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-data-retention-policy-jobs.md +0 -0
  171. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-feature-flags-foundation.md +0 -0
  172. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-project-creation-wizard.md +0 -0
  173. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-stripe-webhook-signature-check.md +0 -0
  174. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-team-permissions-ui.md +0 -0
  175. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-user-signup-and-login.md +0 -0
  176. /package/example/{.kanbn → basic/.kanbn}/tasks/integrate-crm-lead-sync.md +0 -0
  177. /package/example/{.kanbn → basic/.kanbn}/tasks/legal-review-terms-and-privacy.md +0 -0
  178. /package/example/{.kanbn → basic/.kanbn}/tasks/migrate-legacy-events-to-new-schema.md +0 -0
  179. /package/example/{.kanbn → basic/.kanbn}/tasks/optimize-dashboard-first-load.md +0 -0
  180. /package/example/{.kanbn → basic/.kanbn}/tasks/prototype-report-export-scheduler.md +0 -0
  181. /package/example/{.kanbn → basic/.kanbn}/tasks/publish-internal-qa-checklist.md +0 -0
  182. /package/example/{.kanbn → basic/.kanbn}/tasks/setup-ci-pipeline.md +0 -0
package/src/main.js CHANGED
@@ -13,6 +13,39 @@ const DEFAULT_INDEX_FILE_NAME = "index.md";
13
13
  const DEFAULT_TASKS_FOLDER_NAME = "tasks";
14
14
  const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
15
15
 
16
+ // Slugs that always resolve to the main board, whatever the index file is called
17
+ const MAIN_BOARD_ALIASES = ["main", "default"];
18
+
19
+ // Options that describe the workspace rather than a single board. They are only ever read from the
20
+ // config file (or, when there isn't one, from the main board's front matter) - if one of these turns
21
+ // up in a secondary board's front matter it is ignored, and `kanbn validate` reports it
22
+ const WORKSPACE_SCOPED_OPTIONS = [
23
+ "mainFolder",
24
+ "indexFile",
25
+ "taskFolder",
26
+ "archiveFolder",
27
+ "defaultBoard",
28
+ "boards",
29
+ "customFields",
30
+ "dateFormat",
31
+ "defaultTaskWorkload",
32
+ "taskWorkloadTags",
33
+ ];
34
+
35
+ // Options that a secondary board inherits from the main board's front matter. When there is no
36
+ // config file the main board's front matter is doing double duty - it holds the workspace options
37
+ // *and* the main board's own board-scoped ones - so only this allowlist propagates. Anything else
38
+ // there (startedColumns, hiddenColumns, views, custom field column linkages, ...) belongs to the
39
+ // main board alone, and a secondary board that wants it has to say so itself
40
+ const WORKSPACE_INHERITED_OPTIONS = [...WORKSPACE_SCOPED_OPTIONS, "sprints"];
41
+
42
+ // Keys inside the `boards` config option that aren't board slugs
43
+ const RESERVED_BOARDS_CONFIG_KEYS = ["exclude", "order"];
44
+
45
+ // History event types that belong to the task rather than to a board, so they count when replaying
46
+ // for any board. Archiving removes a task from every board, and progress is a property of the task
47
+ const BOARD_AGNOSTIC_EVENT_TYPES = ["progress", "archived"];
48
+
16
49
  // Date normalisation intervals measured in milliseconds
17
50
  const SECOND = 1000;
18
51
  const MINUTE = 60 * SECOND;
@@ -30,6 +63,12 @@ const DEFAULT_TASK_WORKLOAD_TAGS = {
30
63
  Huge: 8,
31
64
  };
32
65
  const DEFAULT_DATE_FORMAT = "d mmm yy, H:MM";
66
+
67
+ // Metadata fields that hold a task's started and completed dates. Boards can override these with the
68
+ // startedField and completedField options, so that a task shared between several boards can carry
69
+ // separate started/completed state for each of them
70
+ const DEFAULT_STARTED_FIELD = "started";
71
+ const DEFAULT_COMPLETED_FIELD = "completed";
33
72
  const DEFAULT_TASK_TEMPLATE = "^+^_${overdue ? '^R' : ''}${name}^: ${created ? ('\\n^-^/' + created) : ''}";
34
73
 
35
74
  /**
@@ -59,6 +98,53 @@ async function exists(path) {
59
98
  return true;
60
99
  }
61
100
 
101
+ /**
102
+ * Convert a board file name into a board slug, i.e. "design.md" -> "design"
103
+ * @param {string} fileName The board file name
104
+ * @return {string} The board slug
105
+ */
106
+ function boardSlugFromFileName(fileName) {
107
+ return path.basename(fileName, path.extname(fileName));
108
+ }
109
+
110
+ /**
111
+ * Copy an object, leaving out the specified keys
112
+ * @param {object} o The object to copy
113
+ * @param {string[]} keys The keys to leave out
114
+ * @return {object} A copy of the object without the specified keys
115
+ */
116
+ function omitKeys(o, keys) {
117
+ return Object.fromEntries(Object.entries(o).filter(([key]) => keys.indexOf(key) === -1));
118
+ }
119
+
120
+ /**
121
+ * Record which options belong to a board's own front matter, so that saving the board writes exactly
122
+ * these rather than everything it inherited from the workspace
123
+ * @param {object} index The index object
124
+ * @param {object} ownOptions The options that belong to this board
125
+ * @return {object} The index object
126
+ */
127
+ function setOwnOptions(index, ownOptions) {
128
+ Object.defineProperty(index, "ownOptions", {
129
+ value: { ...ownOptions },
130
+ enumerable: false,
131
+ writable: true,
132
+ configurable: true,
133
+ });
134
+ return index;
135
+ }
136
+
137
+ /**
138
+ * Compare two option values structurally, so that an option inherited from the workspace can be told
139
+ * apart from one that a board operation has actually changed
140
+ * @param {any} a
141
+ * @param {any} b
142
+ * @return {boolean} True if the two values are equivalent
143
+ */
144
+ function sameOptionValue(a, b) {
145
+ return JSON.stringify(a) === JSON.stringify(b);
146
+ }
147
+
62
148
  /**
63
149
  * Get a list of all tracked task ids
64
150
  * @param {object} index The index object
@@ -221,15 +307,20 @@ function setTaskMetadata(taskData, property, value) {
221
307
  * Append a structured history event to a task
222
308
  * @param {object} taskData The task object
223
309
  * @param {object} historyEvent The history event payload
310
+ * @param {?string} [boardSlug=null] The board the event happened on, or null for the main board
224
311
  * @return {object} The modified task object
225
312
  */
226
- function appendTaskHistory(taskData, historyEvent) {
313
+ function appendTaskHistory(taskData, historyEvent, boardSlug = null) {
227
314
  if (!('history' in taskData) || taskData.history === null) {
228
315
  taskData.history = [];
229
316
  }
230
317
  taskData.history.push({
231
318
  date: new Date(),
232
- ...historyEvent
319
+ ...historyEvent,
320
+
321
+ // Events on the main board carry no board key, so a single-board workspace writes exactly the
322
+ // history it always has
323
+ ...(boardSlug === null ? {} : { board: boardSlug })
233
324
  });
234
325
  return taskData;
235
326
  }
@@ -240,25 +331,38 @@ function appendTaskHistory(taskData, historyEvent) {
240
331
  * @param {object} task
241
332
  * @return {boolean} True if the task is in a completed column or has a completed date
242
333
  */
334
+ /**
335
+ * Get the name of the metadata field that holds a task's started date for this board. Boards can
336
+ * point this at a custom date field so that several boards can track their own started/completed
337
+ * state against the same shared task file
338
+ * @param {object} index The index object
339
+ * @return {string} The metadata field name
340
+ */
341
+ function getStartedField(index) {
342
+ return ("startedField" in index.options && index.options.startedField) || DEFAULT_STARTED_FIELD;
343
+ }
344
+
345
+ /**
346
+ * Get the name of the metadata field that holds a task's completed date for this board
347
+ * @param {object} index The index object
348
+ * @return {string} The metadata field name
349
+ */
350
+ function getCompletedField(index) {
351
+ return ("completedField" in index.options && index.options.completedField) || DEFAULT_COMPLETED_FIELD;
352
+ }
353
+
243
354
  function taskCompleted(index, task) {
244
- return (
245
- "completed" in task.metadata ||
246
- ("completedColumns" in index.options &&
247
- index.options.completedColumns.indexOf(findTaskColumn(index, task.id)) !== -1)
248
- );
355
+ return getCompletedField(index) in task.metadata;
249
356
  }
250
357
 
251
358
  /**
252
- * Sort a column in the index
359
+ * Flatten a task's metadata and computed values into a single object that can be used for sorting
253
360
  * @param {object} index The index object
254
- * @param {object[]} tasks The tasks in the index
255
- * @param {string} columnName The column to sort
256
- * @param {object[]} sorters A list of sorter objects
257
- * @return {object} The modified index object
361
+ * @param {object} task The task object
362
+ * @return {object} An object containing all sortable task fields
258
363
  */
259
- function sortColumnInIndex(index, tasks, columnName, sorters) {
260
- // Get a list of tasks in the target column and add computed fields
261
- tasks = tasks.map((task) => ({
364
+ function taskSortFields(index, task) {
365
+ return {
262
366
  ...task,
263
367
  ...task.metadata,
264
368
  created: "created" in task.metadata ? task.metadata.created : "",
@@ -279,10 +383,38 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
279
383
  comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
280
384
  workload: taskWorkload(index, task),
281
385
  progress: taskProgress(index, task),
282
- }));
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Sort a list of tasks without modifying them, using each task's flattened sort fields
391
+ * @param {object} index The index object
392
+ * @param {object[]} tasks
393
+ * @param {object[]} sorters
394
+ * @return {object[]} The sorted tasks
395
+ */
396
+ function sortTasksWithFields(index, tasks, sorters) {
397
+ if (!sorters.length) {
398
+ return tasks;
399
+ }
400
+ return sortTasks(
401
+ tasks.map((task) => ({ task, fields: taskSortFields(index, task) })),
402
+ sorters,
403
+ (entry, field) => entry.fields[field]
404
+ ).map((entry) => entry.task);
405
+ }
283
406
 
284
- // Sort the list of tasks
285
- tasks = sortTasks(tasks, sorters);
407
+ /**
408
+ * Sort a column in the index
409
+ * @param {object} index The index object
410
+ * @param {object[]} tasks The tasks in the index
411
+ * @param {string} columnName The column to sort
412
+ * @param {object[]} sorters A list of sorter objects
413
+ * @return {object} The modified index object
414
+ */
415
+ function sortColumnInIndex(index, tasks, columnName, sorters) {
416
+ // Sort the tasks in the target column using their flattened sort fields
417
+ tasks = sortTasksWithFields(index, tasks, sorters);
286
418
 
287
419
  // Save the list of tasks back to the index
288
420
  index.columns[columnName] = tasks.map((task) => task.id);
@@ -293,14 +425,16 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
293
425
  * Sort a list of tasks
294
426
  * @param {object[]} tasks
295
427
  * @param {object[]} sorters
428
+ * @param {?function} [getField=null] An optional accessor used to read a sort field from a task
296
429
  * @return {object[]} The sorted tasks
297
430
  */
298
- function sortTasks(tasks, sorters) {
431
+ function sortTasks(tasks, sorters, getField = null) {
432
+ const readField = getField || ((task, field) => task[field]);
299
433
  tasks.sort((a, b) => {
300
434
  let compareA, compareB;
301
435
  for (let sorter of sorters) {
302
- compareA = a[sorter.field];
303
- compareB = b[sorter.field];
436
+ compareA = readField(a, sorter.field);
437
+ compareB = readField(b, sorter.field);
304
438
  if (sorter.filter) {
305
439
  compareA = sortFilter(compareA, sorter.filter);
306
440
  compareB = sortFilter(compareB, sorter.filter);
@@ -436,6 +570,22 @@ function filterTasks(index, tasks, filters) {
436
570
  result = false;
437
571
  }
438
572
 
573
+ // Planned start date
574
+ if (
575
+ "plannedStart" in filters &&
576
+ (!("plannedStart" in task.metadata) || !dateFilter(filters.plannedStart, task.metadata.plannedStart))
577
+ ) {
578
+ result = false;
579
+ }
580
+
581
+ // Planned finish date
582
+ if (
583
+ "plannedFinish" in filters &&
584
+ (!("plannedFinish" in task.metadata) || !dateFilter(filters.plannedFinish, task.metadata.plannedFinish))
585
+ ) {
586
+ result = false;
587
+ }
588
+
439
589
  // Workload
440
590
  if ("workload" in filters && !numberFilter(filters.workload, taskWorkload(index, task))) {
441
591
  result = false;
@@ -471,12 +621,15 @@ function filterTasks(index, tasks, filters) {
471
621
  }
472
622
 
473
623
  // Tag
474
- if ("tag" in filters && !stringFilter(filters.tag, task.metadata.tags.join("\n"))) {
624
+ if ("tag" in filters && !stringFilter(filters.tag, ("tags" in task.metadata ? task.metadata.tags : []).join("\n"))) {
475
625
  result = false;
476
626
  }
477
627
 
478
628
  // Count tags
479
- if ("count-tags" in filters && !numberFilter(filters["count-tags"], task.tags.length)) {
629
+ if (
630
+ "count-tags" in filters &&
631
+ !numberFilter(filters["count-tags"], ("tags" in task.metadata ? task.metadata.tags : []).length)
632
+ ) {
480
633
  result = false;
481
634
  }
482
635
 
@@ -656,75 +809,31 @@ function taskWorkloadInPeriod(tasks, metadataProperty, start, end) {
656
809
  }
657
810
 
658
811
  /**
659
- * Check if a column is configured as a started column
660
- * @param {object} index
661
- * @param {string|null} columnName
662
- * @return {boolean}
663
- */
664
- function startedColumn(index, columnName) {
665
- return (
666
- !!columnName &&
667
- "startedColumns" in index.options &&
668
- index.options.startedColumns.indexOf(columnName) !== -1
669
- );
670
- }
671
-
672
- /**
673
- * Check if a column is configured as a completed column
674
- * @param {object} index
675
- * @param {string|null} columnName
676
- * @return {boolean}
677
- */
678
- function completedColumn(index, columnName) {
679
- return (
680
- !!columnName &&
681
- "completedColumns" in index.options &&
682
- index.options.completedColumns.indexOf(columnName) !== -1
683
- );
684
- }
685
-
686
- /**
687
- * Build a task's state at a specific date from history events
812
+ * Find the date at which a task most recently entered one of the specified columns, using its
813
+ * history. Returns null if the task has no history, or none of its events mention those columns
688
814
  * @param {object} task
689
- * @param {Date} date
690
- * @return {?object}
815
+ * @param {string[]} columnNames
816
+ * @return {?Date}
691
817
  */
692
- function getTaskHistoryStateAtDate(task, date) {
818
+ function findColumnEntryDate(task, columnNames) {
693
819
  if (!("history" in task) || !Array.isArray(task.history) || task.history.length === 0) {
694
820
  return null;
695
821
  }
696
-
697
- const state = {
698
- created: false,
699
- archived: false,
700
- column: null,
701
- };
702
- const history = [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime());
703
- history.forEach((historyEvent) => {
704
- if (!historyEvent.date || historyEvent.date > date) {
705
- return;
706
- }
707
- switch (historyEvent.type) {
708
- case 'created':
709
- state.created = true;
710
- state.archived = false;
711
- state.column = historyEvent.column || state.column;
712
- break;
713
- case 'moved':
714
- state.column = historyEvent.toColumn || state.column;
715
- break;
716
- case 'archived':
717
- state.archived = true;
718
- break;
719
- case 'restored':
720
- state.archived = false;
721
- state.column = historyEvent.toColumn || state.column;
722
- break;
723
- default:
724
- break;
822
+ let result = null;
823
+ for (const historyEvent of [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime())) {
824
+ if (!historyEvent.date) {
825
+ continue;
826
+ }
827
+ const enteredColumn = historyEvent.type === "moved"
828
+ ? historyEvent.toColumn
829
+ : (historyEvent.type === "created" || historyEvent.type === "restored")
830
+ ? (historyEvent.column || historyEvent.toColumn)
831
+ : null;
832
+ if (enteredColumn && columnNames.indexOf(enteredColumn) !== -1) {
833
+ result = historyEvent.date;
725
834
  }
726
- });
727
- return state;
835
+ }
836
+ return result;
728
837
  }
729
838
 
730
839
  /**
@@ -757,17 +866,37 @@ function getTaskProgressAtDate(task, date) {
757
866
  return Math.max(0, Math.min(progress, 1));
758
867
  }
759
868
 
869
+ /**
870
+ * Check whether a history event counts when replaying for a board. An event belongs to a board if it
871
+ * names that board, or if it names none and we're replaying for the main board - which is exactly
872
+ * what makes every task file written before boards existed replay the way it always has
873
+ * @param {object} historyEvent The history event
874
+ * @param {?string} [boardSlug=null] The board being replayed for, or null for the main board
875
+ * @return {boolean} True if the event counts for this board
876
+ */
877
+ function historyEventOnBoard(historyEvent, boardSlug = null) {
878
+ if (BOARD_AGNOSTIC_EVENT_TYPES.indexOf(historyEvent.type) !== -1) {
879
+ return true;
880
+ }
881
+ if ("board" in historyEvent && historyEvent.board) {
882
+ return historyEvent.board === boardSlug;
883
+ }
884
+ return boardSlug === null;
885
+ }
886
+
760
887
  /**
761
888
  * Get timeline dates for a task in a period. For history-enabled tasks this uses all history event dates,
762
889
  * otherwise it falls back to created/started/completed dates.
763
890
  * @param {object} task
764
891
  * @param {Date} from
765
892
  * @param {Date} to
893
+ * @param {?string} [boardSlug=null] The board to replay for, or null for the main board
766
894
  * @return {Date[]}
767
895
  */
768
- function getTaskTimelineDates(task, from, to) {
896
+ function getTaskTimelineDates(task, from, to, boardSlug = null) {
769
897
  if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
770
898
  return task.history
899
+ .filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
771
900
  .map((historyEvent) => historyEvent.date)
772
901
  .filter((date) => date && date >= from && date <= to);
773
902
  }
@@ -1077,31 +1206,13 @@ function buildGanttSchedule(index, tasks, now, trackedTaskIds = new Set()) {
1077
1206
  };
1078
1207
  }
1079
1208
 
1080
- /**
1081
- * Render a gantt bar for a scheduled task
1082
- * @param {object} task
1083
- * @param {Date} from
1084
- * @param {Date} to
1085
- * @return {string}
1086
- */
1087
- function renderGanttBar(task, from, to) {
1088
- const result = [];
1089
- for (let date = new Date(from.getTime()); date <= to; date = new Date(date.getTime() + DAY)) {
1090
- if (date < task.start || date > task.end) {
1091
- result.push(' ');
1092
- } else if (task.completed instanceof Date) {
1093
- result.push('█');
1094
- } else if (task.started instanceof Date) {
1095
- result.push('▓');
1096
- } else {
1097
- result.push('░');
1098
- }
1099
- }
1100
- return result.join('');
1101
- }
1102
-
1103
1209
  /**
1104
1210
  * Get a list of tasks that were started before and/or completed after a date
1211
+ *
1212
+ * A task is active from its started date until its completed date. This used to be derived by
1213
+ * replaying the task's history to work out which column it was in at the time and comparing that
1214
+ * column against the index options, which meant a task's activity depended on column names rather
1215
+ * than on the dates recorded against it
1105
1216
  * @param {object} index
1106
1217
  * @param {object[]} tasks
1107
1218
  * @param {Date} date
@@ -1109,14 +1220,8 @@ function renderGanttBar(task, from, to) {
1109
1220
  */
1110
1221
  function getActiveTasksAtDate(index, tasks, date) {
1111
1222
  return tasks.filter((task) => (
1112
- (() => {
1113
- const historyState = getTaskHistoryStateAtDate(task, date);
1114
- if (historyState !== null) {
1115
- return historyState.created && !historyState.archived && startedColumn(index, historyState.column) && !completedColumn(index, historyState.column);
1116
- }
1117
- return (task.started !== false && task.started <= date) &&
1118
- (task.completed === false || task.completed > date);
1119
- })()
1223
+ (task.started !== false && task.started <= date) &&
1224
+ (task.completed === false || task.completed > date)
1120
1225
  ));
1121
1226
  }
1122
1227
 
@@ -1150,13 +1255,15 @@ function countActiveTasksAtDate(index, tasks, date) {
1150
1255
  * @param {object} index
1151
1256
  * @param {object[]} tasks
1152
1257
  * @param {Date} date
1258
+ * @param {?string} [boardSlug=null] The board to replay for, or null for the main board
1153
1259
  * @return {object[]} A list of event objects, with event type and task id
1154
1260
  */
1155
- function getTaskEventsAtDate(index, tasks, date) {
1261
+ function getTaskEventsAtDate(index, tasks, date, boardSlug = null) {
1156
1262
  return tasks
1157
1263
  .map((task) => {
1158
1264
  if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
1159
1265
  return task.history
1266
+ .filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
1160
1267
  .filter((historyEvent) => historyEvent.date && historyEvent.date.getTime() === date.getTime())
1161
1268
  .map((historyEvent) => ({
1162
1269
  eventType: historyEvent.type,
@@ -1210,12 +1317,19 @@ function normaliseDate(date, resolution = 'minutes') {
1210
1317
  * @param {object} index
1211
1318
  * @param {object} taskData
1212
1319
  * @param {string} columnName
1320
+ * @param {Date} [date=new Date()] The date to stamp
1213
1321
  * @return {object} The updated task data
1214
1322
  */
1215
- function updateColumnLinkedCustomFields(index, taskData, columnName) {
1216
- // Update built-in column-linked metadata properties first (started and completed dates)
1217
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "completed", "once");
1218
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "started", "once");
1323
+ function updateColumnLinkedCustomFields(index, taskData, columnName, date = new Date()) {
1324
+ // Update built-in column-linked metadata properties first (started and completed dates). The
1325
+ // columns are always listed in startedColumns/completedColumns, but the field that gets written
1326
+ // can be pointed elsewhere with startedField/completedField
1327
+ taskData = updateColumnLinkedCustomField(
1328
+ index, taskData, columnName, getCompletedField(index), "once", "completedColumns", date
1329
+ );
1330
+ taskData = updateColumnLinkedCustomField(
1331
+ index, taskData, columnName, getStartedField(index), "once", "startedColumns", date
1332
+ );
1219
1333
 
1220
1334
  // Update column-linked custom fields
1221
1335
  if ("customFields" in index.options) {
@@ -1226,7 +1340,9 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1226
1340
  taskData,
1227
1341
  columnName,
1228
1342
  customField.name,
1229
- customField.updateDate || "none"
1343
+ customField.updateDate || "none",
1344
+ null,
1345
+ date
1230
1346
  );
1231
1347
  }
1232
1348
  }
@@ -1245,17 +1361,23 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1245
1361
  * @param {string} columnName
1246
1362
  * @param {string} fieldName
1247
1363
  * @param {string} [updateCriteria='none']
1364
+ * @param {?string} [columnListName=null] The option holding the linked column names, or null to use
1365
+ * the `${fieldName}Columns` convention
1366
+ * @param {Date} [date=new Date()] The date to stamp
1248
1367
  */
1249
- function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
1250
- const columnList = `${fieldName}Columns`;
1368
+ function updateColumnLinkedCustomField(
1369
+ index, taskData, columnName, fieldName, updateCriteria = "none", columnListName = null,
1370
+ date = new Date()
1371
+ ) {
1372
+ const columnList = columnListName || `${fieldName}Columns`;
1251
1373
  if (columnList in index.options && index.options[columnList].indexOf(columnName) !== -1) {
1252
1374
  switch (updateCriteria) {
1253
1375
  case "always":
1254
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1376
+ taskData = setTaskMetadata(taskData, fieldName, date);
1255
1377
  break;
1256
1378
  case "once":
1257
1379
  if (!(fieldName in taskData.metadata && taskData.metadata[fieldName])) {
1258
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1380
+ taskData = setTaskMetadata(taskData, fieldName, date);
1259
1381
  }
1260
1382
  break;
1261
1383
  default:
@@ -1270,15 +1392,57 @@ class Kanbn {
1270
1392
  CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
1271
1393
  CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
1272
1394
 
1273
- // Memoize config
1274
- configMemo = null;
1395
+ // The board this instance is scoped to, or null for the main board
1396
+ boardSlug = null;
1397
+
1398
+ // Boards named in an archived task's metadata that no longer existed when it was restored, set by
1399
+ // restoreTask() for the caller to report
1400
+ lastRestoreWarnings = [];
1275
1401
 
1276
- constructor(root = null) {
1402
+ /**
1403
+ * @param {?string} [root=null] The workspace root folder
1404
+ * @param {object} [options={}] Instance options: `board` scopes this instance to a board, `caches`
1405
+ * lets a board-scoped clone share its parent's memoized config
1406
+ */
1407
+ constructor(root = null, options = {}) {
1277
1408
  if(root) {
1278
1409
  this.ROOT = root
1279
1410
  this.CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
1280
1411
  this.CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
1281
1412
  }
1413
+ this.caches = options.caches || { config: null, workspaceOptions: null };
1414
+ this.boardSlug = options.board || null;
1415
+ }
1416
+
1417
+ // Memoized config, kept in the shared cache object so that board-scoped clones don't each re-read it
1418
+ get configMemo() {
1419
+ return this.caches.config;
1420
+ }
1421
+
1422
+ set configMemo(value) {
1423
+ this.caches.config = value;
1424
+ }
1425
+
1426
+ /**
1427
+ * Get a copy of this instance scoped to another board. The returned instance shares this one's
1428
+ * cached config, so scoping to a board costs no extra file reads
1429
+ * @param {?string} [slug=null] The board slug, or null/"main"/"default" for the main board
1430
+ * @return {Kanbn} A board-scoped Kanbn instance
1431
+ */
1432
+ board(slug = null) {
1433
+ if (slug === null || slug === undefined || slug === "") {
1434
+ return this.boardSlug === null ? this : new Kanbn(this.ROOT, { caches: this.caches });
1435
+ }
1436
+ return new Kanbn(this.ROOT, { board: String(slug).trim(), caches: this.caches });
1437
+ }
1438
+
1439
+ /**
1440
+ * Alias for board()
1441
+ * @param {?string} [slug=null] The board slug
1442
+ * @return {Kanbn} A board-scoped Kanbn instance
1443
+ */
1444
+ withBoard(slug = null) {
1445
+ return this.board(slug);
1282
1446
  }
1283
1447
 
1284
1448
  /**
@@ -1298,6 +1462,7 @@ class Kanbn {
1298
1462
  } else {
1299
1463
  await fs.promises.writeFile(this.CONFIG_JSON, JSON.stringify(config, null, 4));
1300
1464
  }
1465
+ this.caches.workspaceOptions = null;
1301
1466
  }
1302
1467
 
1303
1468
  /**
@@ -1329,7 +1494,8 @@ class Kanbn {
1329
1494
  * Clear cached config
1330
1495
  */
1331
1496
  clearConfigCache() {
1332
- this.configMemo = null;
1497
+ this.caches.config = null;
1498
+ this.caches.workspaceOptions = null;
1333
1499
  }
1334
1500
 
1335
1501
  /**
@@ -1389,11 +1555,64 @@ class Kanbn {
1389
1555
  }
1390
1556
 
1391
1557
  /**
1392
- * Get the index path
1558
+ * Get the main board's slug. This is the index file name without its extension, so a workspace with
1559
+ * a customised `indexFile` gets a main board slug to match
1560
+ * @return {Promise<string>} The main board slug
1561
+ */
1562
+ async getMainBoardSlug() {
1563
+ return boardSlugFromFileName(await this.getIndexFileName());
1564
+ }
1565
+
1566
+ /**
1567
+ * Resolve a board slug, mapping the reserved aliases and an absent slug onto the main board
1568
+ * @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
1569
+ * @return {Promise<string>} The resolved board slug
1570
+ */
1571
+ async resolveBoardSlug(slug = undefined) {
1572
+ if (slug === undefined) {
1573
+ slug = this.boardSlug;
1574
+ }
1575
+ const mainBoardSlug = await this.getMainBoardSlug();
1576
+ if (slug === null || slug === undefined || slug === "") {
1577
+ return mainBoardSlug;
1578
+ }
1579
+ slug = String(slug).trim();
1580
+ if (MAIN_BOARD_ALIASES.indexOf(slug.toLowerCase()) !== -1) {
1581
+ return mainBoardSlug;
1582
+ }
1583
+ return slug;
1584
+ }
1585
+
1586
+ /**
1587
+ * Check if a slug refers to the main board
1588
+ * @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
1589
+ * @return {Promise<boolean>} True if the slug refers to the main board
1590
+ */
1591
+ async isMainBoard(slug = undefined) {
1592
+ return (await this.resolveBoardSlug(slug)) === (await this.getMainBoardSlug());
1593
+ }
1594
+
1595
+ /**
1596
+ * Get the file path for a board. The main board keeps its configured index file name, every other
1597
+ * board is a sibling markdown file named after its slug
1598
+ * @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
1599
+ * @return {Promise<string>} The board file path
1600
+ */
1601
+ async getBoardPath(slug = undefined) {
1602
+ const resolvedSlug = await this.resolveBoardSlug(slug);
1603
+ const mainFolder = await this.getMainFolder();
1604
+ if (resolvedSlug === (await this.getMainBoardSlug())) {
1605
+ return path.join(mainFolder, await this.getIndexFileName());
1606
+ }
1607
+ return path.join(mainFolder, `${resolvedSlug}.md`);
1608
+ }
1609
+
1610
+ /**
1611
+ * Get the index path. This is the path of the board this instance is scoped to
1393
1612
  * @return {Promise<string>} The kanbn index path
1394
1613
  */
1395
1614
  async getIndexPath() {
1396
- return path.join(await this.getMainFolder(), await this.getIndexFileName());
1615
+ return this.getBoardPath();
1397
1616
  }
1398
1617
 
1399
1618
  /**
@@ -1431,7 +1650,7 @@ class Kanbn {
1431
1650
  * @return {Promise<task>} The task
1432
1651
  */
1433
1652
  async getTask(taskId) {
1434
- this.taskExists(taskId);
1653
+ await this.taskExists(taskId);
1435
1654
  return this.loadTask(taskId);
1436
1655
  }
1437
1656
 
@@ -1456,7 +1675,8 @@ class Kanbn {
1456
1675
 
1457
1676
  // A task is overdue if it's due date is in the past and the task is not in a completed column
1458
1677
  // or doesn't have a completed dates
1459
- const completedDate = "completed" in task.metadata ? task.metadata.completed : null;
1678
+ const completedField = getCompletedField(index);
1679
+ const completedDate = completedField in task.metadata ? task.metadata[completedField] : null;
1460
1680
 
1461
1681
  // Get task due delta - this is the difference between now and the due date, or if the task is completed
1462
1682
  // this is the difference between the completed and due dates
@@ -1498,14 +1718,151 @@ class Kanbn {
1498
1718
  * @return {object[]} A filtered and sorted list of tasks
1499
1719
  */
1500
1720
  filterAndSortTasks(index, tasks, filters, sorters) {
1501
- return sortTasks(filterTasks(index, tasks, filters), sorters);
1721
+ return sortTasksWithFields(index, filterTasks(index, tasks, filters), sorters);
1502
1722
  }
1503
1723
 
1504
1724
  /**
1505
- * Overwrite the index file with the specified data
1506
- * @param {object} indexData Index data to save
1725
+ * Get the workspace-scoped options. These live in the config file if there is one, and in the main
1726
+ * board's front matter if there isn't - which means loading a secondary board also means reading the
1727
+ * main board. The result is memoized so that costs one extra file read per process, not per board
1728
+ * @return {Promise<object>} The workspace options
1507
1729
  */
1508
- async saveIndex(indexData) {
1730
+ async getWorkspaceOptions() {
1731
+ return (await this.loadWorkspaceOptions()).options;
1732
+ }
1733
+
1734
+ /**
1735
+ * Load the workspace options along with where they came from, since that decides how much of them
1736
+ * a secondary board inherits
1737
+ * @return {Promise<{options: object, fromConfig: boolean}>} The workspace options and their source
1738
+ */
1739
+ async loadWorkspaceOptions() {
1740
+ if (this.caches.workspaceOptions === null) {
1741
+ const config = await this.getConfig();
1742
+ if (config !== null) {
1743
+ this.caches.workspaceOptions = { options: { ...config }, fromConfig: true };
1744
+ } else {
1745
+ let options = {};
1746
+ try {
1747
+ const mainBoardData = await fs.promises.readFile(
1748
+ await this.getBoardPath(await this.getMainBoardSlug()),
1749
+ { encoding: "utf-8" }
1750
+ );
1751
+ options = parseIndex.md2json(mainBoardData).options;
1752
+ } catch (error) {
1753
+ // A missing or unparseable main board leaves secondary boards with defaults only. This is
1754
+ // reported by validate rather than thrown, so that one broken file doesn't break every board
1755
+ options = {};
1756
+ }
1757
+ this.caches.workspaceOptions = { options, fromConfig: false };
1758
+ }
1759
+ }
1760
+ return this.caches.workspaceOptions;
1761
+ }
1762
+
1763
+ /**
1764
+ * Get the options a secondary board inherits from the workspace. A config file is workspace-level
1765
+ * by construction, so all of it is inherited; the main board's front matter is that board's own
1766
+ * file, so only the workspace-scoped keys in it are
1767
+ * @return {Promise<object>} The inherited options
1768
+ */
1769
+ async getInheritedBoardOptions() {
1770
+ const { options, fromConfig } = await this.loadWorkspaceOptions();
1771
+ if (fromConfig) {
1772
+ return { ...options };
1773
+ }
1774
+ return Object.fromEntries(
1775
+ Object.entries(options).filter(([key]) => WORKSPACE_INHERITED_OPTIONS.indexOf(key) !== -1)
1776
+ );
1777
+ }
1778
+
1779
+ /**
1780
+ * Get the per-board options declared for a board in the config file's `boards` key, if any
1781
+ * @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
1782
+ * @return {Promise<object>} The board's options from config, or an empty object
1783
+ */
1784
+ async getBoardConfig(slug = undefined) {
1785
+ const resolvedSlug = await this.resolveBoardSlug(slug);
1786
+ const config = await this.getConfig();
1787
+ if (config === null || typeof config.boards !== "object" || config.boards === null) {
1788
+ return {};
1789
+ }
1790
+ if (RESERVED_BOARDS_CONFIG_KEYS.indexOf(resolvedSlug) !== -1) {
1791
+ return {};
1792
+ }
1793
+ const boardConfig = config.boards[resolvedSlug];
1794
+ return typeof boardConfig === "object" && boardConfig !== null && !Array.isArray(boardConfig)
1795
+ ? { ...boardConfig }
1796
+ : {};
1797
+ }
1798
+
1799
+ /**
1800
+ * Layer a board's own front matter options over the workspace options
1801
+ * @param {string} resolvedSlug The resolved board slug
1802
+ * @param {object} ownOptions The options taken from the board file's front matter
1803
+ * @return {Promise<object>} The resolved options
1804
+ */
1805
+ async resolveBoardOptions(resolvedSlug, ownOptions) {
1806
+ // The main board is where the workspace options live, so there is nothing to layer: this is
1807
+ // exactly the behaviour Kanbn has always had
1808
+ if (resolvedSlug === (await this.getMainBoardSlug())) {
1809
+ const config = await this.getConfig();
1810
+ return config !== null ? { ...ownOptions, ...config } : { ...ownOptions };
1811
+ }
1812
+
1813
+ // Workspace-scoped keys in a secondary board's front matter are ignored - one task file has to
1814
+ // parse identically for every board that references it. validate reports them
1815
+ return {
1816
+ ...(await this.getInheritedBoardOptions()),
1817
+ ...(await this.getBoardConfig(resolvedSlug)),
1818
+ ...omitKeys(ownOptions, WORKSPACE_SCOPED_OPTIONS),
1819
+ };
1820
+ }
1821
+
1822
+ /**
1823
+ * Work out which options belong in a secondary board's own front matter, so that options inherited
1824
+ * from the workspace aren't copied into every board file the first time it is saved
1825
+ * @param {string} resolvedSlug The resolved board slug
1826
+ * @param {object} indexData The board data being saved
1827
+ * @return {Promise<object>} The options to write to the board file's front matter
1828
+ */
1829
+ async getOwnBoardOptions(resolvedSlug, indexData) {
1830
+ const previousOwnOptions = indexData.ownOptions ? { ...indexData.ownOptions } : {};
1831
+ const inheritedOptions = {
1832
+ ...(await this.getInheritedBoardOptions()),
1833
+ ...(await this.getBoardConfig(resolvedSlug)),
1834
+ };
1835
+ const options = indexData.options || {};
1836
+ const ownOptions = { ...previousOwnOptions };
1837
+ for (const [key, value] of Object.entries(options)) {
1838
+ if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
1839
+ continue;
1840
+ }
1841
+
1842
+ // Keep anything the board already declared, and pick up anything that differs from what the
1843
+ // workspace provides - i.e. anything this operation actually changed
1844
+ if (key in previousOwnOptions || !(key in inheritedOptions) || !sameOptionValue(inheritedOptions[key], value)) {
1845
+ ownOptions[key] = value;
1846
+ }
1847
+ }
1848
+
1849
+ // Drop anything that has been removed from the board's options entirely
1850
+ for (const key of Object.keys(ownOptions)) {
1851
+ if (!(key in options)) {
1852
+ delete ownOptions[key];
1853
+ }
1854
+ }
1855
+ return ownOptions;
1856
+ }
1857
+
1858
+ /**
1859
+ * Overwrite a board file with the specified data
1860
+ * @param {?string} slug The board slug, or null for the main board
1861
+ * @param {object} indexData Board data to save
1862
+ */
1863
+ async saveBoard(slug, indexData) {
1864
+ const resolvedSlug = await this.resolveBoardSlug(slug);
1865
+
1509
1866
  // Apply column sorting if any sorters are defined in options
1510
1867
  if ("columnSorting" in indexData.options && Object.keys(indexData.options.columnSorting).length) {
1511
1868
  for (let columnName in indexData.options.columnSorting) {
@@ -1518,38 +1875,74 @@ class Kanbn {
1518
1875
  }
1519
1876
  }
1520
1877
 
1521
- // If there is a separate config file, save options to this file
1522
- let ignoreOptions = false;
1523
- if (await this.configExists()) {
1524
- await this.saveConfig(indexData.options);
1525
- ignoreOptions = true;
1878
+ const boardPath = await this.getBoardPath(resolvedSlug);
1879
+ if (resolvedSlug === (await this.getMainBoardSlug())) {
1880
+
1881
+ // If there is a separate config file, save options to this file
1882
+ let ignoreOptions = false;
1883
+ if (await this.configExists()) {
1884
+ await this.saveConfig(indexData.options);
1885
+ ignoreOptions = true;
1886
+ }
1887
+
1888
+ // The main board owns the workspace options, so any cached copy is now stale
1889
+ this.caches.workspaceOptions = null;
1890
+ await fs.promises.writeFile(boardPath, parseIndex.json2md(indexData, ignoreOptions));
1891
+ return;
1526
1892
  }
1527
1893
 
1528
- // Save index
1529
- await fs.promises.writeFile(await this.getIndexPath(), parseIndex.json2md(indexData, ignoreOptions));
1894
+ // A secondary board's options always live in its own front matter, and workspace-scoped options
1895
+ // are never written to the config file as a side effect of a board-local operation
1896
+ const ownOptions = await this.getOwnBoardOptions(resolvedSlug, indexData);
1897
+ await fs.promises.writeFile(
1898
+ boardPath,
1899
+ parseIndex.json2md({ ...indexData, options: ownOptions }, false)
1900
+ );
1530
1901
  }
1531
1902
 
1532
1903
  /**
1533
- * Load the index file and parse it to an object
1534
- * @return {Promise<object>} The index object
1904
+ * Load a board file and parse it to an object
1905
+ * @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
1906
+ * @return {Promise<object>} The board object
1535
1907
  */
1536
- async loadIndex() {
1537
- let indexData = "";
1908
+ async loadBoard(slug = undefined) {
1909
+ const resolvedSlug = await this.resolveBoardSlug(slug);
1910
+ const isMainBoard = resolvedSlug === (await this.getMainBoardSlug());
1911
+ let boardData = "";
1538
1912
  try {
1539
- indexData = await fs.promises.readFile(await this.getIndexPath(), { encoding: "utf-8" });
1913
+ boardData = await fs.promises.readFile(await this.getBoardPath(resolvedSlug), { encoding: "utf-8" });
1540
1914
  } catch (error) {
1541
- throw new Error(`Couldn't access index file: ${error.message}`);
1915
+ throw new Error(
1916
+ isMainBoard
1917
+ ? `Couldn't access index file: ${error.message}`
1918
+ : `Couldn't access board file for board "${resolvedSlug}": ${error.message}`
1919
+ );
1542
1920
  }
1543
- const index = parseIndex.md2json(indexData);
1921
+ const index = parseIndex.md2json(boardData);
1544
1922
 
1545
- // If configuration settings exist in a separate config file, merge them with index options
1546
- const config = await this.getConfig();
1547
- if (config !== null) {
1548
- index.options = { ...index.options, ...config };
1549
- }
1923
+ // Remember which options came from this board's own front matter, so that saving it again doesn't
1924
+ // persist everything it inherited from the workspace
1925
+ setOwnOptions(index, index.options);
1926
+ index.options = await this.resolveBoardOptions(resolvedSlug, index.options);
1550
1927
  return index;
1551
1928
  }
1552
1929
 
1930
+ /**
1931
+ * Overwrite the index file with the specified data
1932
+ * @param {object} indexData Index data to save
1933
+ */
1934
+ async saveIndex(indexData) {
1935
+ return this.saveBoard(this.boardSlug, indexData);
1936
+ }
1937
+
1938
+ /**
1939
+ * Load the index file and parse it to an object
1940
+ * @return {Promise<object>} The index object
1941
+ */
1942
+ async loadIndex() {
1943
+ return this.loadBoard(this.boardSlug);
1944
+ }
1945
+
1553
1946
  /**
1554
1947
  * Overwrite a task file with the specified data
1555
1948
  * @param {string} path The task path
@@ -1633,16 +2026,386 @@ class Kanbn {
1633
2026
  }
1634
2027
 
1635
2028
  /**
1636
- * Initialise a kanbn board in the current working directory
1637
- * @param {object} [options={}] Initial columns and other config options
2029
+ * Check if the workspace has been initialised, regardless of which board this instance is scoped to
2030
+ * @return {Promise<boolean>} True if the main board exists
1638
2031
  */
1639
- async initialise(options = {}) {
1640
- // Check if a main folder is defined in an existing config file
1641
- const mainFolder = await this.getMainFolder();
2032
+ async workspaceInitialised() {
2033
+ return await exists(await this.getBoardPath(await this.getMainBoardSlug()));
2034
+ }
1642
2035
 
1643
- // Create main folder if it doesn't already exist
1644
- if (!(await exists(mainFolder))) {
1645
- await fs.promises.mkdir(mainFolder, { recursive: true });
2036
+ /**
2037
+ * Get the slugs that can't be used for a board, because they would collide with the main board, one
2038
+ * of its aliases, or one of the workspace's folders
2039
+ * @return {Promise<string[]>} The reserved board slugs
2040
+ */
2041
+ async getReservedBoardSlugs() {
2042
+ return [
2043
+ ...MAIN_BOARD_ALIASES,
2044
+ DEFAULT_INDEX_FILE_NAME,
2045
+ boardSlugFromFileName(DEFAULT_INDEX_FILE_NAME),
2046
+ await this.getMainBoardSlug(),
2047
+ DEFAULT_TASKS_FOLDER_NAME,
2048
+ DEFAULT_ARCHIVE_FOLDER_NAME,
2049
+ await this.getTaskFolderName(),
2050
+ await this.getArchiveFolderName(),
2051
+ ];
2052
+ }
2053
+
2054
+ /**
2055
+ * Check that a slug can be used for a new board, throwing a descriptive error if it can't
2056
+ * @param {string} slug The board slug to check
2057
+ * @return {Promise<string>} The validated slug
2058
+ */
2059
+ async validateBoardSlug(slug) {
2060
+ if (!slug || typeof slug !== "string" || !slug.trim()) {
2061
+ throw new Error("Board slug cannot be empty");
2062
+ }
2063
+ slug = slug.trim();
2064
+ if (slug !== utility.paramCase(slug)) {
2065
+ throw new Error(`Board slug "${slug}" is not valid, try "${utility.paramCase(slug)}"`);
2066
+ }
2067
+ if ((await this.getReservedBoardSlugs()).indexOf(slug) !== -1) {
2068
+ throw new Error(`Board slug "${slug}" is reserved`);
2069
+ }
2070
+ return slug;
2071
+ }
2072
+
2073
+ /**
2074
+ * Get the boards config from the config file, i.e. the exclude list and display order
2075
+ * @return {Promise<{exclude: string[], order: string[]}>} The boards config
2076
+ */
2077
+ async getBoardsConfig() {
2078
+ const config = await this.getConfig();
2079
+ const boards =
2080
+ config !== null && typeof config.boards === "object" && config.boards !== null && !Array.isArray(config.boards)
2081
+ ? config.boards
2082
+ : {};
2083
+ return {
2084
+ exclude: (Array.isArray(boards.exclude) ? boards.exclude : []).map(boardSlugFromFileName),
2085
+ order: (Array.isArray(boards.order) ? boards.order : []).map(boardSlugFromFileName),
2086
+ };
2087
+ }
2088
+
2089
+ /**
2090
+ * Find all boards in the workspace. Boards are discovered by globbing markdown files directly inside
2091
+ * the main folder, so the task and archive folders are excluded by construction. A file counts as a
2092
+ * board if it parses as an index; anything that doesn't is ignored here and reported by validate
2093
+ * @return {Promise<object[]>} A list of boards, main board first unless an explicit order says otherwise
2094
+ */
2095
+ async listBoards() {
2096
+ const mainFolder = await this.getMainFolder();
2097
+ if (!(await exists(mainFolder))) {
2098
+ return [];
2099
+ }
2100
+ const mainBoardSlug = await this.getMainBoardSlug();
2101
+ const { exclude, order } = await this.getBoardsConfig();
2102
+ const boardPaths = await glob(`${mainFolder}/*.md`);
2103
+ const boards = [];
2104
+ for (const boardPath of boardPaths) {
2105
+ const slug = boardSlugFromFileName(boardPath);
2106
+ if (exclude.indexOf(slug) !== -1) {
2107
+ continue;
2108
+ }
2109
+ let boardData = null;
2110
+ try {
2111
+ boardData = parseIndex.md2json(await fs.promises.readFile(boardPath, { encoding: "utf-8" }));
2112
+ } catch (error) {
2113
+ continue;
2114
+ }
2115
+ boards.push({
2116
+ slug,
2117
+ path: boardPath,
2118
+ name: boardData.name,
2119
+ description: boardData.description,
2120
+ main: slug === mainBoardSlug,
2121
+ });
2122
+ }
2123
+
2124
+ // Order: anything named in the config order first, in that order, then the main board, then the
2125
+ // rest alphabetically
2126
+ boards.sort((a, b) => {
2127
+ const aOrder = order.indexOf(a.slug), bOrder = order.indexOf(b.slug);
2128
+ if (aOrder !== -1 || bOrder !== -1) {
2129
+ if (aOrder === -1) { return 1; }
2130
+ if (bOrder === -1) { return -1; }
2131
+ return aOrder - bOrder;
2132
+ }
2133
+ if (a.main !== b.main) {
2134
+ return a.main ? -1 : 1;
2135
+ }
2136
+ return a.slug.localeCompare(b.slug);
2137
+ });
2138
+ return boards;
2139
+ }
2140
+
2141
+ /**
2142
+ * Work out which board a command should target: the --board argument, then the KANBN_BOARD
2143
+ * environment variable, then the defaultBoard option, then the main board
2144
+ * @param {?string} [slug=null] The board slug given on the command line, if any
2145
+ * @return {Promise<?string>} The target board slug, or null for the main board
2146
+ */
2147
+ async resolveTargetBoard(slug = null) {
2148
+ if (slug !== null && slug !== undefined && String(slug).trim() !== "") {
2149
+ return String(slug).trim();
2150
+ }
2151
+ if (process.env.KANBN_BOARD && process.env.KANBN_BOARD.trim() !== "") {
2152
+ return process.env.KANBN_BOARD.trim();
2153
+ }
2154
+ const workspaceOptions = await this.getWorkspaceOptions();
2155
+ if (workspaceOptions.defaultBoard && String(workspaceOptions.defaultBoard).trim() !== "") {
2156
+ return String(workspaceOptions.defaultBoard).trim();
2157
+ }
2158
+ return null;
2159
+ }
2160
+
2161
+ /**
2162
+ * Get a Kanbn instance scoped to the board a command's arguments point at
2163
+ * @param {object} [args={}] The parsed command arguments
2164
+ * @return {Promise<Kanbn>} A board-scoped Kanbn instance
2165
+ */
2166
+ async boardFromArgs(args = {}) {
2167
+ return this.board(await this.resolveTargetBoard(utility.strArg(args.board) || null));
2168
+ }
2169
+
2170
+ /**
2171
+ * List boards with the information `kanbn boards` displays: everything listBoards() returns, plus
2172
+ * column and task counts, completion percentage and the file's last modified date
2173
+ * @return {Promise<object[]>} A list of boards with summary information
2174
+ */
2175
+ async getBoardsSummary() {
2176
+ const boards = await this.listBoards();
2177
+ const result = [];
2178
+ for (const board of boards) {
2179
+ const summary = { ...board, columns: 0, tasks: 0, completed: 0, completedPercentage: 0, modified: null };
2180
+ try {
2181
+ const boardData = await this.loadBoard(board.slug);
2182
+ const tasks = await this.loadAllTrackedTasks(boardData);
2183
+ summary.columns = Object.keys(boardData.columns).length;
2184
+ summary.tasks = tasks.length;
2185
+ summary.completed = tasks.filter((task) => taskCompleted(boardData, task)).length;
2186
+ summary.completedPercentage = summary.tasks
2187
+ ? Math.round((summary.completed / summary.tasks) * 100)
2188
+ : 0;
2189
+ } catch (error) {
2190
+ // A board that can't be loaded is still worth listing; validate reports the reason
2191
+ }
2192
+ try {
2193
+ summary.modified = (await fs.promises.stat(board.path)).mtime;
2194
+ } catch (error) {
2195
+ summary.modified = null;
2196
+ }
2197
+ result.push(summary);
2198
+ }
2199
+ return result;
2200
+ }
2201
+
2202
+ /**
2203
+ * Get every task that appears on more than one board, with the column it occupies on each
2204
+ * @param {boolean} [allTasks=false] True to include tasks that only appear on one board
2205
+ * @return {Promise<object[]>} A list of tasks and their board membership
2206
+ */
2207
+ async getCrossBoardTasks(allTasks = false) {
2208
+ const membership = {};
2209
+ for (const board of await this.listBoards()) {
2210
+ let boardData = null;
2211
+ try {
2212
+ boardData = await this.loadBoard(board.slug);
2213
+ } catch (error) {
2214
+ continue;
2215
+ }
2216
+ for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
2217
+ for (const taskId of taskIds) {
2218
+ if (!(taskId in membership)) {
2219
+ membership[taskId] = {};
2220
+ }
2221
+ membership[taskId][board.slug] = columnName;
2222
+ }
2223
+ }
2224
+ }
2225
+ return Object.entries(membership)
2226
+ .filter(([, boards]) => allTasks || Object.keys(boards).length > 1)
2227
+ .map(([id, boards]) => ({ id, boards }))
2228
+ .sort((a, b) => a.id.localeCompare(b.id));
2229
+ }
2230
+
2231
+ /**
2232
+ * Get the value to record in a history event's `board` key for this instance's board. The main
2233
+ * board records nothing, which is what keeps existing task files valid and unchanged
2234
+ * @return {Promise<?string>} The board slug, or null for the main board
2235
+ */
2236
+ async historyBoard() {
2237
+ return (await this.isMainBoard()) ? null : await this.resolveBoardSlug();
2238
+ }
2239
+
2240
+ /**
2241
+ * Check if a board exists
2242
+ * @param {string} slug The board slug
2243
+ * @return {Promise<boolean>} True if the board file exists
2244
+ */
2245
+ async boardExists(slug) {
2246
+ return await exists(await this.getBoardPath(slug));
2247
+ }
2248
+
2249
+ /**
2250
+ * Create a new board
2251
+ * @param {string} slug The board slug
2252
+ * @param {object} [options={}] The board's name, description, columns and options
2253
+ * @return {Promise<string>} The created board's slug
2254
+ */
2255
+ async createBoard(slug, options = {}) {
2256
+ const validatedSlug = await this.validateBoardSlug(slug);
2257
+ if (await this.boardExists(validatedSlug)) {
2258
+ throw new Error(`Board "${validatedSlug}" already exists`);
2259
+ }
2260
+ if (!(await this.workspaceInitialised())) {
2261
+ throw new Error("Not initialised in this folder");
2262
+ }
2263
+ const columns =
2264
+ "columns" in options && options.columns.length ? options.columns : defaultInitialiseOptions.columns;
2265
+
2266
+ // A new board only picks up the default started and completed columns if it actually has columns
2267
+ // by those names - a design board with Ideas/Designing/Signed Off shouldn't silently claim that
2268
+ // "In Progress" started work and "Done" finished it
2269
+ const boardOptions = "options" in options ? { ...options.options } : {};
2270
+ for (const [key, defaultColumns] of Object.entries(defaultInitialiseOptions.options)) {
2271
+ if (key in boardOptions) {
2272
+ continue;
2273
+ }
2274
+ const matchingColumns = defaultColumns.filter((columnName) => columns.indexOf(columnName) !== -1);
2275
+ if (matchingColumns.length) {
2276
+ boardOptions[key] = matchingColumns;
2277
+ }
2278
+ }
2279
+
2280
+ const board = {
2281
+ name: "name" in options && options.name ? options.name : validatedSlug,
2282
+ description: "description" in options ? options.description : "",
2283
+ options: boardOptions,
2284
+ columns: Object.fromEntries(columns.map((columnName) => [columnName, []])),
2285
+ };
2286
+
2287
+ // Options passed to createBoard are deliberate, so they're written to the board file even when
2288
+ // the workspace happens to provide the same value
2289
+ setOwnOptions(board, boardOptions);
2290
+ await this.saveBoard(validatedSlug, board);
2291
+ return validatedSlug;
2292
+ }
2293
+
2294
+ /**
2295
+ * Delete a board file. Tasks referenced only by this board become untracked; their files are left
2296
+ * alone, since boards own membership and tasks are shared
2297
+ * @param {string} slug The board slug
2298
+ * @return {Promise<string[]>} The ids of tasks that are no longer referenced by any board
2299
+ */
2300
+ async deleteBoard(slug) {
2301
+ const resolvedSlug = await this.resolveBoardSlug(slug);
2302
+ if (await this.isMainBoard(resolvedSlug)) {
2303
+ throw new Error("The main board cannot be deleted");
2304
+ }
2305
+ if (!(await this.boardExists(resolvedSlug))) {
2306
+ throw new Error(`Board "${resolvedSlug}" doesn't exist`);
2307
+ }
2308
+ const orphaned = await this.findOrphanedTasks(resolvedSlug);
2309
+ await fs.promises.unlink(await this.getBoardPath(resolvedSlug));
2310
+ return orphaned;
2311
+ }
2312
+
2313
+ /**
2314
+ * Find the tasks that would become untracked if a board were deleted, i.e. the tasks it references
2315
+ * that no other board references
2316
+ * @param {string} slug The board slug
2317
+ * @return {Promise<string[]>} The ids of tasks referenced only by this board
2318
+ */
2319
+ async findOrphanedTasks(slug) {
2320
+ const resolvedSlug = await this.resolveBoardSlug(slug);
2321
+ const taskIds = [...getTrackedTaskIds(await this.loadBoard(resolvedSlug))];
2322
+ const orphaned = [];
2323
+ for (const taskId of taskIds) {
2324
+ const boards = await this.findTaskBoards(taskId);
2325
+ if (Object.keys(boards).filter((boardSlug) => boardSlug !== resolvedSlug).length === 0) {
2326
+ orphaned.push(taskId);
2327
+ }
2328
+ }
2329
+ return orphaned;
2330
+ }
2331
+
2332
+ /**
2333
+ * Rename a board
2334
+ * @param {string} slug The board slug
2335
+ * @param {string} newSlug The new board slug
2336
+ * @param {?string} [newName=null] An optional new display name for the board
2337
+ * @return {Promise<string>} The new slug
2338
+ */
2339
+ async renameBoard(slug, newSlug, newName = null) {
2340
+ const resolvedSlug = await this.resolveBoardSlug(slug);
2341
+ if (await this.isMainBoard(resolvedSlug)) {
2342
+ throw new Error("The main board cannot be renamed");
2343
+ }
2344
+ if (!(await this.boardExists(resolvedSlug))) {
2345
+ throw new Error(`Board "${resolvedSlug}" doesn't exist`);
2346
+ }
2347
+ const validatedSlug = await this.validateBoardSlug(newSlug);
2348
+ if (validatedSlug !== resolvedSlug && (await this.boardExists(validatedSlug))) {
2349
+ throw new Error(`Board "${validatedSlug}" already exists`);
2350
+ }
2351
+ const board = await this.loadBoard(resolvedSlug);
2352
+ if (newName !== null) {
2353
+ board.name = newName;
2354
+ }
2355
+ if (validatedSlug !== resolvedSlug) {
2356
+ await fs.promises.rename(await this.getBoardPath(resolvedSlug), await this.getBoardPath(validatedSlug));
2357
+ }
2358
+ await this.saveBoard(validatedSlug, board);
2359
+ return validatedSlug;
2360
+ }
2361
+
2362
+ /**
2363
+ * Find every board that references a task, and the column it occupies on each
2364
+ * @param {string} taskId The task id
2365
+ * @return {Promise<Record<string, string>>} A map of board slug to column name
2366
+ */
2367
+ async findTaskBoards(taskId) {
2368
+ const result = {};
2369
+ for (const board of await this.listBoards()) {
2370
+ let boardData = null;
2371
+ try {
2372
+ boardData = await this.loadBoard(board.slug);
2373
+ } catch (error) {
2374
+ continue;
2375
+ }
2376
+ const columnName = findTaskColumn(boardData, taskId);
2377
+ if (columnName !== null) {
2378
+ result[board.slug] = columnName;
2379
+ }
2380
+ }
2381
+ return result;
2382
+ }
2383
+
2384
+ /**
2385
+ * Alias for findTaskBoards(), used when displaying a task's board membership
2386
+ * @param {string} taskId The task id
2387
+ * @return {Promise<Record<string, string>>} A map of board slug to column name
2388
+ */
2389
+ async getTaskBoardColumns(taskId) {
2390
+ return this.findTaskBoards(taskId);
2391
+ }
2392
+
2393
+ /**
2394
+ * Initialise a kanbn board in the current working directory
2395
+ * @param {object} [options={}] Initial columns and other config options
2396
+ */
2397
+ async initialise(options = {}) {
2398
+ // A board-scoped instance initialises its own board rather than the workspace
2399
+ if (!(await this.isMainBoard())) {
2400
+ return this.initialiseBoard(this.boardSlug, options);
2401
+ }
2402
+
2403
+ // Check if a main folder is defined in an existing config file
2404
+ const mainFolder = await this.getMainFolder();
2405
+
2406
+ // Create main folder if it doesn't already exist
2407
+ if (!(await exists(mainFolder))) {
2408
+ await fs.promises.mkdir(mainFolder, { recursive: true });
1646
2409
  }
1647
2410
 
1648
2411
  // Create tasks folder if it doesn't already exist
@@ -1687,6 +2450,52 @@ class Kanbn {
1687
2450
  await this.saveIndex(index);
1688
2451
  }
1689
2452
 
2453
+ /**
2454
+ * Create a secondary board, or update an existing one's name, description, columns and options
2455
+ * @param {string} slug The board slug
2456
+ * @param {object} [options={}] The board's name, description, columns and options
2457
+ * @return {Promise<string>} The board slug
2458
+ */
2459
+ async initialiseBoard(slug, options = {}) {
2460
+ const resolvedSlug = await this.resolveBoardSlug(slug);
2461
+ if (!(await this.boardExists(resolvedSlug))) {
2462
+ return this.createBoard(resolvedSlug, options);
2463
+ }
2464
+ if (Object.keys(options).length === 0) {
2465
+ return resolvedSlug;
2466
+ }
2467
+
2468
+ // The board already exists, so update it in the same way initialise() updates the main board
2469
+ const board = await this.loadBoard(resolvedSlug);
2470
+ "name" in options && options.name && (board.name = options.name);
2471
+ "description" in options && (board.description = options.description);
2472
+ if ("options" in options) {
2473
+ board.options = Object.assign(board.options, options.options);
2474
+ setOwnOptions(board, { ...board.ownOptions, ...options.options });
2475
+ }
2476
+ "columns" in options &&
2477
+ (board.columns = Object.assign(
2478
+ board.columns,
2479
+ Object.fromEntries(
2480
+ options.columns.map((columnName) => [
2481
+ columnName,
2482
+ columnName in board.columns ? board.columns[columnName] : [],
2483
+ ])
2484
+ )
2485
+ ));
2486
+ await this.saveBoard(resolvedSlug, board);
2487
+ return resolvedSlug;
2488
+ }
2489
+
2490
+ /**
2491
+ * Check if a task file exists, regardless of whether any board references it
2492
+ * @param {string} taskId The task id to check
2493
+ * @return {Promise<boolean>} True if the task file exists
2494
+ */
2495
+ async taskFileExists(taskId) {
2496
+ return exists(getTaskPath(await this.getTaskFolderPath(), removeFileExtension(taskId)));
2497
+ }
2498
+
1690
2499
  /**
1691
2500
  * Check if a task file exists and is in the index, otherwise throw an error
1692
2501
  * @param {string} taskId The task id to check
@@ -1770,19 +2579,24 @@ class Kanbn {
1770
2579
  throw new Error(`A task with id "${taskId}" is already in the index`);
1771
2580
  }
1772
2581
 
2582
+ // Stamp every date this call writes with the same timestamp, so that the created date and the
2583
+ // column-linked dates can't disagree by a millisecond
2584
+ const now = new Date();
2585
+
1773
2586
  // Set the created date
1774
- taskData = setTaskMetadata(taskData, "created", new Date());
2587
+ taskData = setTaskMetadata(taskData, "created", now);
1775
2588
 
1776
2589
  // Add initial history event
1777
2590
  taskData = appendTaskHistory(taskData, {
2591
+ date: now,
1778
2592
  type: 'created',
1779
2593
  column: columnName,
1780
2594
  fromProgress: 0,
1781
2595
  toProgress: getTaskMetadata(taskData, 'progress') || 0
1782
- });
2596
+ }, await this.historyBoard());
1783
2597
 
1784
2598
  // Update task metadata dates
1785
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
2599
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
1786
2600
  await this.saveTask(taskPath, taskData);
1787
2601
 
1788
2602
  // Add the task to the index
@@ -1823,9 +2637,18 @@ class Kanbn {
1823
2637
  // Load task data
1824
2638
  let taskData = await this.loadTask(taskId);
1825
2639
  const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
2640
+ const now = new Date();
2641
+
2642
+ // Record the task joining this board. Board membership has no other representation in a task
2643
+ // file, so without this event a board's history has no record of the task ever arriving
2644
+ taskData = appendTaskHistory(taskData, {
2645
+ date: now,
2646
+ type: 'added',
2647
+ column: columnName
2648
+ }, await this.historyBoard());
1826
2649
 
1827
2650
  // Update task metadata dates
1828
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
2651
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
1829
2652
  await this.saveTask(taskPath, taskData);
1830
2653
 
1831
2654
  // Add the task to the column and save the index
@@ -1834,6 +2657,17 @@ class Kanbn {
1834
2657
  return taskId;
1835
2658
  }
1836
2659
 
2660
+ /**
2661
+ * Add an existing task to this board. Boards own membership, so the same task file can be added to
2662
+ * any number of boards, in a different column on each
2663
+ * @param {string} taskId The task id
2664
+ * @param {string} columnName The column to add the task to
2665
+ * @return {Promise<string>} The id of the task that was added
2666
+ */
2667
+ async addTaskToBoard(taskId, columnName) {
2668
+ return this.addUntrackedTaskToIndex(taskId, columnName);
2669
+ }
2670
+
1837
2671
  /**
1838
2672
  * Get a list of tracked tasks (i.e. tasks that are listed in the index)
1839
2673
  * @param {?string} [columnName=null] The optional column name to filter tasks by
@@ -1872,14 +2706,76 @@ class Kanbn {
1872
2706
  return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
1873
2707
  }
1874
2708
 
2709
+ /**
2710
+ * Find tasks that no board references at all. "Tracked" is workspace-scoped: a task is tracked if
2711
+ * any board references it, so a task can be missing from this board and still be tracked
2712
+ * @return {Promise<Set<string>>} A set of untracked task ids
2713
+ */
2714
+ async findWorkspaceUntrackedTasks() {
2715
+ if (!(await this.workspaceInitialised())) {
2716
+ throw new Error("Not initialised in this folder");
2717
+ }
2718
+
2719
+ // Collect the tasks referenced by every board
2720
+ const trackedTasks = new Set();
2721
+ for (const board of await this.listBoards()) {
2722
+ try {
2723
+ for (const taskId of getTrackedTaskIds(await this.loadBoard(board.slug))) {
2724
+ trackedTasks.add(taskId);
2725
+ }
2726
+ } catch (error) {
2727
+ continue;
2728
+ }
2729
+ }
2730
+
2731
+ // Get all tasks in the tasks folder and return the set difference
2732
+ const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
2733
+ return new Set(files.map((task) => path.parse(task).name).filter((taskId) => !trackedTasks.has(taskId)));
2734
+ }
2735
+
2736
+ /**
2737
+ * Find tasks that other boards track but this one doesn't - the "what could I pull onto this board"
2738
+ * list
2739
+ * @return {Promise<Record<string, Record<string, string>>>} A map of task id to board slug to column
2740
+ */
2741
+ async findTasksOnOtherBoards() {
2742
+ const thisBoardSlug = await this.resolveBoardSlug();
2743
+ const trackedHere = getTrackedTaskIds(await this.loadIndex());
2744
+ const result = {};
2745
+ for (const board of await this.listBoards()) {
2746
+ if (board.slug === thisBoardSlug) {
2747
+ continue;
2748
+ }
2749
+ let boardData = null;
2750
+ try {
2751
+ boardData = await this.loadBoard(board.slug);
2752
+ } catch (error) {
2753
+ continue;
2754
+ }
2755
+ for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
2756
+ for (const taskId of taskIds) {
2757
+ if (trackedHere.has(taskId)) {
2758
+ continue;
2759
+ }
2760
+ if (!(taskId in result)) {
2761
+ result[taskId] = {};
2762
+ }
2763
+ result[taskId][board.slug] = columnName;
2764
+ }
2765
+ }
2766
+ }
2767
+ return result;
2768
+ }
2769
+
1875
2770
  /**
1876
2771
  * Update an existing task
1877
2772
  * @param {string} taskId The id of the task to update
1878
2773
  * @param {object} taskData The new task data
1879
2774
  * @param {?string} [columnName=null] The column name to move this task to, or null if not moving this task
2775
+ * @param {string[]} [unsetFields=[]] Metadata field names to remove from the task
1880
2776
  * @return {Promise<string>} The id of the task that was updated
1881
2777
  */
1882
- async updateTask(taskId, taskData, columnName = null) {
2778
+ async updateTask(taskId, taskData, columnName = null, unsetFields = []) {
1883
2779
  // Check if this folder has been initialised
1884
2780
  if (!(await this.initialised())) {
1885
2781
  throw new Error("Not initialised in this folder");
@@ -1916,14 +2812,19 @@ class Kanbn {
1916
2812
  throw new Error(`Column "${columnName}" doesn't exist`);
1917
2813
  }
1918
2814
 
2815
+ // Stamp every date this call writes with the same timestamp, so that the updated date and the
2816
+ // history event it records can't disagree by a millisecond
2817
+ const now = new Date();
2818
+
1919
2819
  // Set the updated date
1920
- taskData = setTaskMetadata(taskData, "updated", new Date());
2820
+ taskData = setTaskMetadata(taskData, "updated", now);
1921
2821
 
1922
2822
  // Add history for progress changes only
1923
2823
  const originalProgress = getTaskMetadata(originalTaskData, 'progress') || 0;
1924
2824
  const updatedProgress = getTaskMetadata(taskData, 'progress') || 0;
1925
2825
  if (originalProgress !== updatedProgress) {
1926
2826
  taskData = appendTaskHistory(taskData, {
2827
+ date: now,
1927
2828
  type: 'progress',
1928
2829
  fromProgress: originalProgress,
1929
2830
  toProgress: updatedProgress
@@ -1941,6 +2842,19 @@ class Kanbn {
1941
2842
  } else {
1942
2843
  await this.saveIndex(index);
1943
2844
  }
2845
+
2846
+ // Remove any explicitly unset metadata fields last. This has to happen after the move, because
2847
+ // moving into a started or completed column stamps the linked date fields - an explicit unset
2848
+ // from the user should win over that automatic stamp
2849
+ if (unsetFields.length) {
2850
+ const finalTaskData = await this.loadTask(taskId);
2851
+ if ("metadata" in finalTaskData) {
2852
+ for (const field of unsetFields) {
2853
+ delete finalTaskData.metadata[field];
2854
+ }
2855
+ }
2856
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), finalTaskData);
2857
+ }
1944
2858
  return taskId;
1945
2859
  }
1946
2860
 
@@ -1975,10 +2889,20 @@ class Kanbn {
1975
2889
  throw new Error(`A task with id "${newTaskId}" already exists`);
1976
2890
  }
1977
2891
 
1978
- // Check that a task with the new id isn't already indexed
2892
+ // Check that a task with the new id isn't already indexed, on this board or any other - the id
2893
+ // is the file name, so it has to be free everywhere
1979
2894
  if (taskInIndex(index, newTaskId)) {
1980
2895
  throw new Error(`A task with id "${newTaskId}" is already in the index`);
1981
2896
  }
2897
+ const boardsWithNewId = Object.keys(await this.findTaskBoards(newTaskId));
2898
+ if (boardsWithNewId.length) {
2899
+ throw new Error(
2900
+ `A task with id "${newTaskId}" is already on board "${boardsWithNewId[0]}"`
2901
+ );
2902
+ }
2903
+
2904
+ // Note every board that references this task before anything is written
2905
+ const otherBoards = await this.findTaskBoards(taskId);
1982
2906
 
1983
2907
  // Update the task name and updated date
1984
2908
  let taskData = await this.loadTask(taskId);
@@ -1989,7 +2913,17 @@ class Kanbn {
1989
2913
  // Rename the task file
1990
2914
  await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
1991
2915
 
1992
- // Update the task id in the index
2916
+ // Update the task id in the index, and in every other board that references it - the task file
2917
+ // has moved, so a board still pointing at the old id would have a broken link
2918
+ const thisBoardSlug = await this.resolveBoardSlug();
2919
+ for (const slug of Object.keys(otherBoards)) {
2920
+ if (slug === thisBoardSlug) {
2921
+ continue;
2922
+ }
2923
+ const otherBoard = this.board(slug);
2924
+ const otherIndex = await otherBoard.loadIndex();
2925
+ await otherBoard.saveIndex(renameTaskInIndex(otherIndex, taskId, newTaskId));
2926
+ }
1993
2927
  index = renameTaskInIndex(index, taskId, newTaskId);
1994
2928
  await this.saveIndex(index);
1995
2929
  return newTaskId;
@@ -2001,9 +2935,10 @@ class Kanbn {
2001
2935
  * @param {string} columnName The name of the column that the task will be moved to
2002
2936
  * @param {?number} [position=null] The position to move the task to within the target column
2003
2937
  * @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
2938
+ * @param {boolean} [add=false] Add the task to this board if it isn't on it yet (secondary boards only)
2004
2939
  * @return {Promise<string>} The id of the task that was moved
2005
2940
  */
2006
- async moveTask(taskId, columnName, position = null, relative = false) {
2941
+ async moveTask(taskId, columnName, position = null, relative = false, add = false) {
2007
2942
  // Check if this folder has been initialised
2008
2943
  if (!(await this.initialised())) {
2009
2944
  throw new Error("Not initialised in this folder");
@@ -2018,7 +2953,18 @@ class Kanbn {
2018
2953
  // Get index and make sure the task is indexed
2019
2954
  let index = await this.loadIndex();
2020
2955
  if (!taskInIndex(index, taskId)) {
2021
- throw new Error(`Task "${taskId}" is not in the index`);
2956
+
2957
+ // Moving a task onto a board it isn't on yet adds it, so that membership doesn't have to be a
2958
+ // separate step. Only on a secondary board: on the main board this stays the error it has
2959
+ // always been
2960
+ if (!add || (await this.isMainBoard())) {
2961
+ throw new Error(`Task "${taskId}" is not in the index`);
2962
+ }
2963
+ if (!(columnName in index.columns)) {
2964
+ throw new Error(`Column "${columnName}" doesn't exist`);
2965
+ }
2966
+ await this.addTaskToBoard(taskId, columnName);
2967
+ return taskId;
2022
2968
  }
2023
2969
 
2024
2970
  // Make sure the target column exists
@@ -2029,21 +2975,26 @@ class Kanbn {
2029
2975
  // Find current column before mutating data
2030
2976
  const currentColumnName = findTaskColumn(index, taskId);
2031
2977
 
2978
+ // Stamp every date this move writes with the same timestamp, so that the history event and the
2979
+ // dates it backfills from can't disagree by a millisecond
2980
+ const moveDate = new Date();
2981
+
2032
2982
  // Update the task's updated date
2033
2983
  let taskData = await this.loadTask(taskId);
2034
- taskData = setTaskMetadata(taskData, "updated", new Date());
2984
+ taskData = setTaskMetadata(taskData, "updated", moveDate);
2035
2985
 
2036
2986
  // Add history only when a task changes columns
2037
2987
  if (currentColumnName !== columnName) {
2038
2988
  taskData = appendTaskHistory(taskData, {
2989
+ date: moveDate,
2039
2990
  type: 'moved',
2040
2991
  fromColumn: currentColumnName,
2041
2992
  toColumn: columnName
2042
- });
2993
+ }, await this.historyBoard());
2043
2994
  }
2044
2995
 
2045
2996
  // Update task metadata dates
2046
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
2997
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName, moveDate);
2047
2998
  await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
2048
2999
 
2049
3000
  // If we're moving the task to a new position, calculate the absolute position
@@ -2066,9 +3017,10 @@ class Kanbn {
2066
3017
  * Remove a task from the index and optionally delete the task file as well
2067
3018
  * @param {string} taskId The id of the task to remove
2068
3019
  * @param {boolean} [removeFile=false] True if the task file should be removed
3020
+ * @param {boolean} [allBoards=false] True to remove the task from every board that references it
2069
3021
  * @return {Promise<string>} The id of the task that was deleted
2070
3022
  */
2071
- async deleteTask(taskId, removeFile = false) {
3023
+ async deleteTask(taskId, removeFile = false, allBoards = false) {
2072
3024
  // Check if this folder has been initialised
2073
3025
  if (!(await this.initialised())) {
2074
3026
  throw new Error("Not initialised in this folder");
@@ -2081,10 +3033,40 @@ class Kanbn {
2081
3033
  throw new Error(`Task "${taskId}" is not in the index`);
2082
3034
  }
2083
3035
 
3036
+ // A task file is shared, so deleting it out from under another board would break that board.
3037
+ // Removing the task from this board only is always safe, and is what happens without --all-boards
3038
+ const thisBoardSlug = await this.resolveBoardSlug();
3039
+ const otherBoards = Object.keys(await this.findTaskBoards(taskId)).filter((slug) => slug !== thisBoardSlug);
3040
+ if (removeFile && otherBoards.length && !allBoards) {
3041
+ throw new Error(
3042
+ `Task "${taskId}" is on ${otherBoards.length} other ${otherBoards.length === 1 ? "board" : "boards"} ` +
3043
+ `(${otherBoards.join(", ")})`
3044
+ );
3045
+ }
3046
+
2084
3047
  // Remove the task from whichever column it's in
3048
+ const columnName = findTaskColumn(index, taskId);
2085
3049
  index = removeTaskFromIndex(index, taskId);
2086
3050
 
2087
- // Optionally remove the task file as well
3051
+ // Record the task leaving this board, but only when the task file survives - a removed event on
3052
+ // a file that's about to be deleted records nothing anyone can read
3053
+ if (!removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
3054
+ let taskData = await this.loadTask(taskId);
3055
+ taskData = appendTaskHistory(taskData, {
3056
+ type: 'removed',
3057
+ fromColumn: columnName
3058
+ }, await this.historyBoard());
3059
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
3060
+ }
3061
+
3062
+ // Optionally remove the task from every other board, and remove the task file as well
3063
+ if (allBoards) {
3064
+ for (const slug of otherBoards) {
3065
+ const otherBoard = this.board(slug);
3066
+ const otherIndex = await otherBoard.loadIndex();
3067
+ await otherBoard.saveIndex(removeTaskFromIndex(otherIndex, taskId));
3068
+ }
3069
+ }
2088
3070
  if (removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2089
3071
  await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
2090
3072
  }
@@ -2114,6 +3096,38 @@ class Kanbn {
2114
3096
  });
2115
3097
  }
2116
3098
 
3099
+ /**
3100
+ * Search every board in the workspace. A task on several boards appears once, annotated with the
3101
+ * board and column it occupies on each
3102
+ * @param {object} [filters={}] The filters to apply
3103
+ * @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
3104
+ * @return {Promise<object[]|string[]>} A list of matching tasks, or task ids
3105
+ */
3106
+ async searchAllBoards(filters = {}, quiet = false) {
3107
+ if (!(await this.workspaceInitialised())) {
3108
+ throw new Error("Not initialised in this folder");
3109
+ }
3110
+ const results = new Map();
3111
+ for (const board of await this.listBoards()) {
3112
+ let matches = [];
3113
+ try {
3114
+ matches = await this.board(board.slug).search(filters, quiet);
3115
+ } catch (error) {
3116
+ continue;
3117
+ }
3118
+ for (const match of matches) {
3119
+ const taskId = quiet ? match : match.id;
3120
+ if (!results.has(taskId)) {
3121
+ results.set(taskId, quiet ? taskId : { ...match, boards: {} });
3122
+ }
3123
+ if (!quiet) {
3124
+ results.get(taskId).boards[board.slug] = match.column;
3125
+ }
3126
+ }
3127
+ }
3128
+ return [...results.values()];
3129
+ }
3130
+
2117
3131
  /**
2118
3132
  * Output project status information
2119
3133
  * @param {boolean} [quiet=false] Output full or partial status information
@@ -2132,20 +3146,38 @@ class Kanbn {
2132
3146
  // Get index and column names
2133
3147
  const index = await this.loadIndex();
2134
3148
  const columnNames = Object.keys(index.columns);
3149
+ const startedField = getStartedField(index);
3150
+ const completedField = getCompletedField(index);
2135
3151
 
2136
3152
  // Prepare output
2137
3153
  const result = {
2138
3154
  name: index.name,
2139
3155
  };
2140
3156
 
3157
+ // Name the board this status applies to, but only once there is more than one board - a
3158
+ // single-board workspace produces exactly the output it always has
3159
+ const boards = await this.listBoards();
3160
+ if (boards.length > 1) {
3161
+ result.board = await this.resolveBoardSlug();
3162
+ }
3163
+
2141
3164
  // Get un-tracked tasks if required
2142
3165
  if (untracked) {
2143
- result.untrackedTasks = [...(await this.findUntrackedTasks())].map((taskId) => `${taskId}.md`);
3166
+
3167
+ // Untracked means "on no board at all", which is workspace-scoped
3168
+ result.untrackedTasks = [...(await this.findWorkspaceUntrackedTasks())].map((taskId) => `${taskId}.md`);
2144
3169
 
2145
3170
  // If output is quiet, output a list of untracked task filenames
2146
3171
  if (quiet) {
2147
3172
  return result.untrackedTasks;
2148
3173
  }
3174
+
3175
+ // Tasks that are tracked, but not on this board. This is the useful half of the answer: it's
3176
+ // the list of work that could be pulled onto this board
3177
+ const tasksOnOtherBoards = await this.findTasksOnOtherBoards();
3178
+ if (Object.keys(tasksOnOtherBoards).length) {
3179
+ result.tasksOnOtherBoards = tasksOnOtherBoards;
3180
+ }
2149
3181
  }
2150
3182
 
2151
3183
  // Get basic project status information
@@ -2296,8 +3328,8 @@ class Kanbn {
2296
3328
 
2297
3329
  // Add task workload information for the sprint
2298
3330
  result.sprint.created = taskWorkloadInPeriod(tasks, "created", sprintStartDate, sprintEndDate);
2299
- result.sprint.started = taskWorkloadInPeriod(tasks, "started", sprintStartDate, sprintEndDate);
2300
- result.sprint.completed = taskWorkloadInPeriod(tasks, "completed", sprintStartDate, sprintEndDate);
3331
+ result.sprint.started = taskWorkloadInPeriod(tasks, startedField, sprintStartDate, sprintEndDate);
3332
+ result.sprint.completed = taskWorkloadInPeriod(tasks, completedField, sprintStartDate, sprintEndDate);
2301
3333
  result.sprint.due = taskWorkloadInPeriod(tasks, "due", sprintStartDate, sprintEndDate);
2302
3334
 
2303
3335
  // Add custom date property workload information for the sprint
@@ -2331,8 +3363,8 @@ class Kanbn {
2331
3363
  result.period.end = periodEnd = new Date(Math.max(...dates));
2332
3364
  }
2333
3365
  result.period.created = taskWorkloadInPeriod(tasks, "created", periodStart, periodEnd);
2334
- result.period.started = taskWorkloadInPeriod(tasks, "started", periodStart, periodEnd);
2335
- result.period.completed = taskWorkloadInPeriod(tasks, "completed", periodStart, periodEnd);
3366
+ result.period.started = taskWorkloadInPeriod(tasks, startedField, periodStart, periodEnd);
3367
+ result.period.completed = taskWorkloadInPeriod(tasks, completedField, periodStart, periodEnd);
2336
3368
  result.period.due = taskWorkloadInPeriod(tasks, "due", periodStart, periodEnd);
2337
3369
 
2338
3370
  // Add custom date property workload information for the selected date range
@@ -2406,6 +3438,312 @@ class Kanbn {
2406
3438
  return true;
2407
3439
  }
2408
3440
 
3441
+ /**
3442
+ * Validate every board in the workspace, and the tasks each of them references
3443
+ * @param {boolean} [save=false] Re-save each board and its tasks
3444
+ * @return {Promise<object[]|boolean>} A list of errors, or true if there were none
3445
+ */
3446
+ async validateAllBoards(save = false) {
3447
+ if (!(await this.workspaceInitialised())) {
3448
+ throw new Error("Not initialised in this folder");
3449
+ }
3450
+ const errors = [];
3451
+ for (const board of await this.listBoards()) {
3452
+ const result = await this.board(board.slug).validate(save);
3453
+ if (result !== true) {
3454
+ errors.push(...result.map((error) => ({ board: board.slug, ...error })));
3455
+ }
3456
+ }
3457
+ return errors.length ? errors : true;
3458
+ }
3459
+
3460
+ /**
3461
+ * Check a workspace for multi-board problems. These are reported rather than thrown: every one of
3462
+ * them is a workspace that still works, just not the way its author probably meant it to
3463
+ * @return {Promise<object[]>} A list of warnings, each with a type, a message and the board it applies to
3464
+ */
3465
+ async findBoardWarnings() {
3466
+ if (!(await this.workspaceInitialised())) {
3467
+ throw new Error("Not initialised in this folder");
3468
+ }
3469
+ const warnings = [];
3470
+ const mainBoardSlug = await this.getMainBoardSlug();
3471
+ const boards = await this.listBoards();
3472
+ const boardSlugs = new Set(boards.map((board) => board.slug));
3473
+ const { exclude } = await this.getBoardsConfig();
3474
+
3475
+ // Markdown files beside the boards that don't parse as one. These are ignored during ordinary
3476
+ // commands, which is the right default, but the user should be able to find out why
3477
+ const mainFolder = await this.getMainFolder();
3478
+ for (const filePath of await glob(`${mainFolder}/*.md`)) {
3479
+ const slug = boardSlugFromFileName(filePath);
3480
+ if (boardSlugs.has(slug)) {
3481
+ continue;
3482
+ }
3483
+ if (exclude.indexOf(slug) !== -1) {
3484
+ warnings.push({
3485
+ board: slug,
3486
+ type: "excluded-board",
3487
+ message: `"${path.basename(filePath)}" is excluded by the boards.exclude config and isn't treated as a board`
3488
+ });
3489
+ continue;
3490
+ }
3491
+ try {
3492
+ parseIndex.md2json(await fs.promises.readFile(filePath, { encoding: "utf-8" }));
3493
+ } catch (error) {
3494
+ warnings.push({
3495
+ board: slug,
3496
+ type: "unparseable-board",
3497
+ message: `"${path.basename(filePath)}" doesn't parse as a board and is being ignored: ${error.message}`
3498
+ });
3499
+ }
3500
+ }
3501
+
3502
+ const stampingBoards = [];
3503
+ const knownBoards = new Set(boardSlugs);
3504
+ const taskBoardColumns = {};
3505
+ for (const board of boards) {
3506
+ let boardData = null;
3507
+ try {
3508
+ boardData = await this.loadBoard(board.slug);
3509
+ } catch (error) {
3510
+ continue;
3511
+ }
3512
+
3513
+ // Workspace-scoped options in a secondary board's front matter are ignored, and silently
3514
+ // ignoring them would leave the user wondering why they had no effect
3515
+ if (board.slug !== mainBoardSlug) {
3516
+ const rawOptions = boardData.ownOptions || {};
3517
+ for (const key of Object.keys(rawOptions)) {
3518
+ if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
3519
+ warnings.push({
3520
+ board: board.slug,
3521
+ type: "workspace-scoped-option",
3522
+ message: `"${key}" is a workspace-scoped option and is ignored in a board file`
3523
+ });
3524
+ }
3525
+ }
3526
+ }
3527
+
3528
+ // More than one board writing the same shared completed field means whichever board is touched
3529
+ // first silently owns that date for the whole workspace
3530
+ if ("completedColumns" in boardData.options && boardData.options.completedColumns.length) {
3531
+ stampingBoards.push({ slug: board.slug, field: getCompletedField(boardData) });
3532
+ }
3533
+
3534
+ // Sprints are assumed to be in order: the last one is taken as the current sprint, and each
3535
+ // one runs until the next one starts
3536
+ const sprints = "sprints" in boardData.options ? boardData.options.sprints : [];
3537
+ for (let i = 1; i < sprints.length; i++) {
3538
+ if (new Date(sprints[i].start) < new Date(sprints[i - 1].start)) {
3539
+ warnings.push({
3540
+ board: board.slug,
3541
+ type: "sprints-out-of-order",
3542
+ message: `sprint "${sprints[i].name}" starts before "${sprints[i - 1].name}"`
3543
+ });
3544
+ break;
3545
+ }
3546
+ }
3547
+
3548
+ // A task can only be in one column per board
3549
+ for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
3550
+ for (const taskId of taskIds) {
3551
+ if (!(taskId in taskBoardColumns)) {
3552
+ taskBoardColumns[taskId] = {};
3553
+ }
3554
+ if (board.slug in taskBoardColumns[taskId]) {
3555
+ warnings.push({
3556
+ board: board.slug,
3557
+ type: "duplicate-task",
3558
+ message: `task "${taskId}" appears in both "${taskBoardColumns[taskId][board.slug]}" and "${columnName}"`
3559
+ });
3560
+ } else {
3561
+ taskBoardColumns[taskId][board.slug] = columnName;
3562
+ }
3563
+ }
3564
+ }
3565
+ }
3566
+
3567
+ // Several boards stamping the same shared field
3568
+ const fields = {};
3569
+ for (const { slug, field } of stampingBoards) {
3570
+ if (!(field in fields)) {
3571
+ fields[field] = [];
3572
+ }
3573
+ fields[field].push(slug);
3574
+ }
3575
+ for (const [field, slugs] of Object.entries(fields)) {
3576
+ if (slugs.length > 1) {
3577
+ warnings.push({
3578
+ board: null,
3579
+ type: "multiple-stamping-authorities",
3580
+ message:
3581
+ `boards ${slugs.map((slug) => `"${slug}"`).join(", ")} all stamp "${field}" - ` +
3582
+ 'whichever is touched first owns that date for every board'
3583
+ });
3584
+ }
3585
+ }
3586
+
3587
+ // Tasks referenced by a board with no file behind them
3588
+ for (const [taskId, taskBoards] of Object.entries(taskBoardColumns)) {
3589
+ if (!(await this.taskFileExists(taskId))) {
3590
+ warnings.push({
3591
+ board: Object.keys(taskBoards)[0],
3592
+ type: "missing-task-file",
3593
+ message: `task "${taskId}" is referenced by ${Object.keys(taskBoards).join(", ")} but has no file`
3594
+ });
3595
+ }
3596
+ }
3597
+
3598
+ // Tasks that no board references at all
3599
+ for (const taskId of await this.findWorkspaceUntrackedTasks()) {
3600
+ warnings.push({
3601
+ board: null,
3602
+ type: "untracked-task",
3603
+ message: `task "${taskId}" isn't on any board`
3604
+ });
3605
+ }
3606
+
3607
+ // History events naming a board that has been deleted. These never match a board during replay,
3608
+ // so they're harmless - but they're the only trace a deleted board leaves behind
3609
+ const reportedBoards = new Set();
3610
+ for (const taskId of Object.keys(taskBoardColumns)) {
3611
+ let taskData = null;
3612
+ try {
3613
+ taskData = await this.loadTask(taskId);
3614
+ } catch (error) {
3615
+ continue;
3616
+ }
3617
+ for (const historyEvent of taskData.history || []) {
3618
+ if (historyEvent.board && !knownBoards.has(historyEvent.board) && !reportedBoards.has(historyEvent.board)) {
3619
+ reportedBoards.add(historyEvent.board);
3620
+ warnings.push({
3621
+ board: historyEvent.board,
3622
+ type: "unknown-board-in-history",
3623
+ message: `history events name board "${historyEvent.board}", which doesn't exist`
3624
+ });
3625
+ }
3626
+ }
3627
+ }
3628
+ return warnings;
3629
+ }
3630
+
3631
+ /**
3632
+ * Find tasks whose started/completed dates disagree with the column they're in
3633
+ *
3634
+ * Task state comes from metadata dates only, so a task moved between columns by hand (by editing
3635
+ * the index file, or through a merge) won't have had its dates updated. This reports both
3636
+ * directions: a task sitting in a started or completed column with no date, and a task carrying a
3637
+ * completed date while sitting somewhere else
3638
+ * @return {Promise<object[]>} A list of drift entries
3639
+ */
3640
+ async findDateDrift() {
3641
+ // Check if this folder has been initialised
3642
+ if (!(await this.initialised())) {
3643
+ throw new Error("Not initialised in this folder");
3644
+ }
3645
+ const index = await this.loadIndex();
3646
+ const tasks = [];
3647
+ for (const taskId of getTrackedTaskIds(index)) {
3648
+ try {
3649
+ tasks.push(await this.loadTask(taskId));
3650
+ } catch (error) {
3651
+ // Unreadable tasks are reported by validate(), not here
3652
+ }
3653
+ }
3654
+ return this.getDateDrift(index, tasks);
3655
+ }
3656
+
3657
+ /**
3658
+ * Find date drift in a list of tasks that have already been loaded
3659
+ * @param {object} index The index object
3660
+ * @param {object[]} tasks Loaded task objects
3661
+ * @return {object[]} A list of drift entries
3662
+ */
3663
+ getDateDrift(index, tasks) {
3664
+ const startedField = getStartedField(index);
3665
+ const completedField = getCompletedField(index);
3666
+ const startedColumns = "startedColumns" in index.options ? index.options.startedColumns : [];
3667
+ const completedColumns = "completedColumns" in index.options ? index.options.completedColumns : [];
3668
+ const drift = [];
3669
+
3670
+ for (const task of tasks) {
3671
+ const column = findTaskColumn(index, task.id);
3672
+
3673
+ // A task in a started or completed column with no date to say when that happened. The date is
3674
+ // usually recoverable from history, so these can be backfilled
3675
+ for (const [field, columns] of [[completedField, completedColumns], [startedField, startedColumns]]) {
3676
+ if (columns.indexOf(column) === -1 || field in task.metadata) {
3677
+ continue;
3678
+ }
3679
+ const historyDate = findColumnEntryDate(task, columns);
3680
+ const date = historyDate
3681
+ || ("updated" in task.metadata ? task.metadata.updated : null)
3682
+ || ("created" in task.metadata ? task.metadata.created : null);
3683
+ drift.push({
3684
+ task: task.id,
3685
+ column,
3686
+ field,
3687
+ issue: "missing",
3688
+ message: `task is in column "${column}" but has no ${field} date`,
3689
+ fixable: date !== null,
3690
+ date,
3691
+ source: date === null
3692
+ ? null
3693
+ : historyDate
3694
+ ? "history"
3695
+ : "updated" in task.metadata && task.metadata.updated === date
3696
+ ? "updated"
3697
+ : "created"
3698
+ });
3699
+ }
3700
+
3701
+ // A task carrying a completed date while sitting outside the completed columns. This is left
3702
+ // alone: the date records something that really happened, and removing it would lose it
3703
+ if (
3704
+ completedColumns.length > 0 &&
3705
+ completedField in task.metadata &&
3706
+ completedColumns.indexOf(column) === -1
3707
+ ) {
3708
+ drift.push({
3709
+ task: task.id,
3710
+ column,
3711
+ field: completedField,
3712
+ issue: "unexpected",
3713
+ message: `task has a ${completedField} date but is in column "${column}"`,
3714
+ fixable: false,
3715
+ date: null,
3716
+ source: null
3717
+ });
3718
+ }
3719
+ }
3720
+ return drift;
3721
+ }
3722
+
3723
+ /**
3724
+ * Check whether hints and notices should be shown for this board
3725
+ * @param {object} index The index object
3726
+ * @return {boolean} True if hints should be shown
3727
+ */
3728
+ isVerbose(index) {
3729
+ return !("verbose" in index.options) || index.options.verbose !== false;
3730
+ }
3731
+
3732
+ /**
3733
+ * Backfill missing started/completed dates for tasks that are in a started or completed column
3734
+ * @return {Promise<object[]>} The drift entries that were fixed
3735
+ */
3736
+ async fixDateDrift() {
3737
+ const fixed = [];
3738
+ for (const item of (await this.findDateDrift()).filter((d) => d.fixable && d.issue === "missing")) {
3739
+ const task = await this.loadTask(item.task);
3740
+ setTaskMetadata(task, item.field, item.date);
3741
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), item.task), task);
3742
+ fixed.push(item);
3743
+ }
3744
+ return fixed;
3745
+ }
3746
+
2409
3747
  /**
2410
3748
  * Sort a column in the index
2411
3749
  * @param {string} columnName The column name to sort
@@ -2455,19 +3793,28 @@ class Kanbn {
2455
3793
  throw new Error("Not initialised in this folder");
2456
3794
  }
2457
3795
 
2458
- // Get index and make sure it has a list of sprints in the options
3796
+ // Sprints are workspace-level unless a board declares its own list. Adding a sprint from a
3797
+ // secondary board that hasn't declared one appends to the workspace list rather than quietly
3798
+ // forking it - a fork would be invisible and would freeze that board out of every future
3799
+ // workspace sprint. Creating a board-local list stays a deliberate front-matter edit
2459
3800
  const index = await this.loadIndex();
2460
- if (!("sprints" in index.options)) {
2461
- index.options.sprints = [];
3801
+ const isMainBoard = await this.isMainBoard();
3802
+ const boardOwnsSprints = !isMainBoard && "sprints" in (index.ownOptions || {});
3803
+ const target = isMainBoard || boardOwnsSprints ? this : this.board(await this.getMainBoardSlug());
3804
+ const targetIndex = target === this ? index : await target.loadIndex();
3805
+
3806
+ if (!("sprints" in targetIndex.options)) {
3807
+ targetIndex.options.sprints = [];
2462
3808
  }
2463
- const sprintNumber = index.options.sprints.length + 1;
3809
+ const sprintNumber = targetIndex.options.sprints.length + 1;
2464
3810
  const sprint = {
2465
3811
  start: start,
2466
3812
  };
2467
3813
 
2468
- // If the name is blank, generate a default name
3814
+ // If the name is blank, generate a default name. A board-local list prefixes the board name, so
3815
+ // that two boards generating their own "Sprint 1" can still be told apart
2469
3816
  if (!name) {
2470
- sprint.name = `Sprint ${sprintNumber}`;
3817
+ sprint.name = boardOwnsSprints ? `${index.name} Sprint ${sprintNumber}` : `Sprint ${sprintNumber}`;
2471
3818
  } else {
2472
3819
  sprint.name = name;
2473
3820
  }
@@ -2477,9 +3824,12 @@ class Kanbn {
2477
3824
  sprint.description = description;
2478
3825
  }
2479
3826
 
2480
- // Add sprint and save the index
2481
- index.options.sprints.push(sprint);
2482
- await this.saveIndex(index);
3827
+ // Add sprint and save the board that owns the list
3828
+ targetIndex.options.sprints.push(sprint);
3829
+ await target.saveIndex(targetIndex);
3830
+
3831
+ // Tell the caller which list this went into, so the CLI can say so
3832
+ sprint.board = boardOwnsSprints ? await this.resolveBoardSlug() : null;
2483
3833
  return sprint;
2484
3834
  }
2485
3835
 
@@ -2501,6 +3851,16 @@ class Kanbn {
2501
3851
 
2502
3852
  // Get index and tasks
2503
3853
  const index = await this.loadIndex();
3854
+
3855
+ // Burndown measures work in flight, which a board with no started columns has no notion of. An
3856
+ // empty chart looks like a bug, so say what's actually missing
3857
+ if (!("startedColumns" in index.options) || !index.options.startedColumns.length) {
3858
+ throw new Error(
3859
+ `Board "${await this.resolveBoardSlug()}" declares no startedColumns, so it has no notion of work in progress`
3860
+ );
3861
+ }
3862
+ const startedField = getStartedField(index);
3863
+ const completedField = getCompletedField(index);
2504
3864
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2505
3865
  .map((task) => {
2506
3866
  const taskColumn = findTaskColumn(index, task.id);
@@ -2508,18 +3868,12 @@ class Kanbn {
2508
3868
  return {
2509
3869
  ...task,
2510
3870
  created,
2511
- started:
2512
- "started" in task.metadata
2513
- ? task.metadata.started
2514
- : "startedColumns" in index.options && index.options.startedColumns.indexOf(taskColumn) !== -1
2515
- ? created
2516
- : false,
2517
- completed:
2518
- "completed" in task.metadata
2519
- ? task.metadata.completed
2520
- : "completedColumns" in index.options && index.options.completedColumns.indexOf(taskColumn) !== -1
2521
- ? created
2522
- : false,
3871
+
3872
+ // A task's started and completed dates come from its metadata only. They used to fall back
3873
+ // to the task's created date whenever it sat in a started or completed column, which
3874
+ // invented timings that had nothing to do with when the work actually happened
3875
+ started: startedField in task.metadata ? task.metadata[startedField] : false,
3876
+ completed: completedField in task.metadata ? task.metadata[completedField] : false,
2523
3877
  progress: taskProgress(index, task),
2524
3878
  assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
2525
3879
  workload: taskWorkload(index, task),
@@ -2553,8 +3907,8 @@ class Kanbn {
2553
3907
  .map((t) =>
2554
3908
  [
2555
3909
  "created" in t.metadata && t.metadata.created,
2556
- "started" in t.metadata && t.metadata.started,
2557
- "completed" in t.metadata && (t.metadata.completed || new Date(8640000000000000))
3910
+ startedField in t.metadata && t.metadata[startedField],
3911
+ completedField in t.metadata && (t.metadata[completedField] || new Date(8640000000000000))
2558
3912
  ].filter((d) => d)
2559
3913
  )
2560
3914
  .flat()
@@ -2651,6 +4005,10 @@ class Kanbn {
2651
4005
  });
2652
4006
  }
2653
4007
 
4008
+ // Datapoints are placed and annotated from this board's history, so activity on another board
4009
+ // doesn't put markers on this board's chart
4010
+ const historyBoard = await this.historyBoard();
4011
+
2654
4012
  // Get workload datapoints for each period
2655
4013
  series.forEach((s) => {
2656
4014
  s.dataPoints = [
@@ -2658,23 +4016,23 @@ class Kanbn {
2658
4016
  x: s.from,
2659
4017
  y: getWorkloadAtDate(index, tasks, s.from),
2660
4018
  count: countActiveTasksAtDate(index, tasks, s.from),
2661
- tasks: getTaskEventsAtDate(index, tasks, s.from),
4019
+ tasks: getTaskEventsAtDate(index, tasks, s.from, historyBoard),
2662
4020
  },
2663
4021
  ...tasks
2664
- .map((task) => getTaskTimelineDates(task, s.from, s.to))
4022
+ .map((task) => getTaskTimelineDates(task, s.from, s.to, historyBoard))
2665
4023
  .flat()
2666
4024
  .filter((d) => d)
2667
4025
  .map((x) => ({
2668
4026
  x,
2669
4027
  y: getWorkloadAtDate(index, tasks, x),
2670
4028
  count: countActiveTasksAtDate(index, tasks, x),
2671
- tasks: getTaskEventsAtDate(index, tasks, x),
4029
+ tasks: getTaskEventsAtDate(index, tasks, x, historyBoard),
2672
4030
  })),
2673
4031
  {
2674
4032
  x: s.to,
2675
4033
  y: getWorkloadAtDate(index, tasks, s.to),
2676
4034
  count: countActiveTasksAtDate(index, tasks, s.to),
2677
- tasks: getTaskEventsAtDate(index, tasks, s.to),
4035
+ tasks: getTaskEventsAtDate(index, tasks, s.to, historyBoard),
2678
4036
  },
2679
4037
  ].sort((a, b) => a.x.getTime() - b.x.getTime());
2680
4038
  });
@@ -2700,6 +4058,9 @@ class Kanbn {
2700
4058
  ? null
2701
4059
  : new Set(taskIds.map((taskId) => removeFileExtension(taskId)));
2702
4060
 
4061
+ // Show this board's history, plus the events that aren't board-scoped at all
4062
+ const historyBoard = await this.historyBoard();
4063
+
2703
4064
  // Build date filter periods from sprints and/or dates
2704
4065
  const periods = [];
2705
4066
  const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
@@ -2792,6 +4153,7 @@ class Kanbn {
2792
4153
 
2793
4154
  const historyEvents = ("history" in task && Array.isArray(task.history) ? task.history : [])
2794
4155
  .filter((historyEvent) => historyEvent.date instanceof Date)
4156
+ .filter((historyEvent) => historyEventOnBoard(historyEvent, historyBoard))
2795
4157
  .map((historyEvent) => {
2796
4158
  const event = { ...historyEvent };
2797
4159
  delete event.date;
@@ -2831,6 +4193,8 @@ class Kanbn {
2831
4193
 
2832
4194
  // Get index and tasks
2833
4195
  const index = await this.loadIndex();
4196
+ const startedField = getStartedField(index);
4197
+ const completedField = getCompletedField(index);
2834
4198
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2835
4199
  .map((task) => {
2836
4200
  const taskColumn = findTaskColumn(index, task.id);
@@ -2838,8 +4202,8 @@ class Kanbn {
2838
4202
  return {
2839
4203
  ...task,
2840
4204
  created,
2841
- started: "started" in task.metadata ? task.metadata.started : false,
2842
- completed: "completed" in task.metadata ? task.metadata.completed : false,
4205
+ started: startedField in task.metadata ? task.metadata[startedField] : false,
4206
+ completed: completedField in task.metadata ? task.metadata[completedField] : false,
2843
4207
  plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : false,
2844
4208
  plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : false,
2845
4209
  workload: taskWorkload(index, task),
@@ -2979,7 +4343,15 @@ class Kanbn {
2979
4343
  const taskColumn = findTaskColumn(index, taskId);
2980
4344
  taskData = setTaskMetadata(taskData, "column", taskColumn);
2981
4345
 
2982
- // Add history event
4346
+ // Archiving removes a task from every board, so remember where it was on each of them. The
4347
+ // single `column` key is kept for the main board, so archives written before boards existed -
4348
+ // and archives of tasks that are only on one board - are unchanged
4349
+ const taskBoards = await this.findTaskBoards(taskId);
4350
+ if (Object.keys(taskBoards).length > 1) {
4351
+ taskData = setTaskMetadata(taskData, "columns", taskBoards);
4352
+ }
4353
+
4354
+ // Add history event. Archiving isn't board-scoped, so the event carries no board key
2983
4355
  taskData = appendTaskHistory(taskData, {
2984
4356
  type: 'archived',
2985
4357
  fromColumn: taskColumn
@@ -2988,8 +4360,8 @@ class Kanbn {
2988
4360
  // Save the task inside the archive folder
2989
4361
  await this.saveTask(archivedTaskPath, taskData);
2990
4362
 
2991
- // Remove the original task
2992
- await this.deleteTask(taskId, true);
4363
+ // Remove the original task from every board that references it
4364
+ await this.deleteTask(taskId, true, true);
2993
4365
 
2994
4366
  return taskId;
2995
4367
  }
@@ -2998,9 +4370,11 @@ class Kanbn {
2998
4370
  * Restore a task from the archive
2999
4371
  * @param {string} taskId The task id
3000
4372
  * @param {?string} [columnName=null] The column to restore the task to
4373
+ * @param {boolean} [singleBoard=false] Restore only to this board, rather than to every board the
4374
+ * task was on when it was archived
3001
4375
  * @return {Promise<string>} The task id
3002
4376
  */
3003
- async restoreTask(taskId, columnName = null) {
4377
+ async restoreTask(taskId, columnName = null, singleBoard = false) {
3004
4378
  // Check if this folder has been initialised
3005
4379
  if (!(await this.initialised())) {
3006
4380
  throw new Error("Not initialised in this folder");
@@ -3041,22 +4415,69 @@ class Kanbn {
3041
4415
  // Load the task from the archive
3042
4416
  let taskData = await this.loadArchivedTask(taskId);
3043
4417
  let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
4418
+
4419
+ // Work out which other boards this task was on when it was archived. A board that has since been
4420
+ // deleted is skipped with a warning rather than failing the restore
4421
+ const thisBoardSlug = await this.resolveBoardSlug();
4422
+ const archivedColumns = getTaskMetadata(taskData, "columns") || {};
4423
+ const otherBoards = [];
4424
+ const missingBoards = [];
4425
+ this.lastRestoreWarnings = [];
4426
+ if (!singleBoard) {
4427
+ for (const [slug, archivedColumn] of Object.entries(archivedColumns)) {
4428
+ if (slug === thisBoardSlug) {
4429
+ continue;
4430
+ }
4431
+ if (await this.boardExists(slug)) {
4432
+ otherBoards.push([slug, archivedColumn]);
4433
+ } else {
4434
+ missingBoards.push(slug);
4435
+ }
4436
+ }
4437
+ }
3044
4438
  taskData = setTaskMetadata(taskData, "column", undefined);
4439
+ if ("metadata" in taskData && "columns" in taskData.metadata) {
4440
+ taskData = setTaskMetadata(taskData, "columns", undefined);
4441
+ }
4442
+
4443
+ // Stamp every date this call writes with the same timestamp, so that the history event and the
4444
+ // column-linked dates can't disagree by a millisecond
4445
+ const now = new Date();
3045
4446
 
3046
4447
  // Add history event
3047
4448
  taskData = appendTaskHistory(taskData, {
4449
+ date: now,
3048
4450
  type: 'restored',
3049
4451
  toColumn: actualColumnName
3050
4452
  });
3051
4453
 
3052
4454
  // Update task metadata dates and save task
3053
- taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName);
4455
+ taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName, now);
3054
4456
  await this.saveTask(taskPath, taskData);
3055
4457
 
3056
4458
  // Add the task to the column and save the index
3057
4459
  index = addTaskToIndex(index, taskId, actualColumnName);
3058
4460
  await this.saveIndex(index);
3059
4461
 
4462
+ // Restore the task to every other board it was on, falling back to that board's first column if
4463
+ // the column it used to be in has since gone
4464
+ for (const [slug, archivedColumn] of otherBoards) {
4465
+ const otherBoard = this.board(slug);
4466
+ const otherIndex = await otherBoard.loadIndex();
4467
+ const otherColumns = Object.keys(otherIndex.columns);
4468
+ if (!otherColumns.length) {
4469
+ missingBoards.push(slug);
4470
+ continue;
4471
+ }
4472
+ await otherBoard.addTaskToBoard(
4473
+ taskId,
4474
+ archivedColumn in otherIndex.columns ? archivedColumn : otherColumns[0]
4475
+ );
4476
+ }
4477
+ // Boards the task used to be on that no longer exist, for the caller to report. Restoring is
4478
+ // best-effort: a deleted board shouldn't stop the task coming back to the boards that remain
4479
+ this.lastRestoreWarnings = missingBoards;
4480
+
3060
4481
  // Delete the archived task file
3061
4482
  await fs.promises.unlink(archivedTaskPath);
3062
4483