@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.
- package/README.md +3 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-summary.json +31 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/kanbn/index.html +116 -0
- package/coverage/kanbn/index.js.html +217 -0
- package/coverage/kanbn/src/board.js.html +646 -0
- package/coverage/kanbn/src/controller/add.js.html +1729 -0
- package/coverage/kanbn/src/controller/archive.js.html +277 -0
- package/coverage/kanbn/src/controller/board.js.html +217 -0
- package/coverage/kanbn/src/controller/burndown.js.html +607 -0
- package/coverage/kanbn/src/controller/comment.js.html +397 -0
- package/coverage/kanbn/src/controller/edit.js.html +2920 -0
- package/coverage/kanbn/src/controller/find.js.html +1597 -0
- package/coverage/kanbn/src/controller/gantt.js.html +1342 -0
- package/coverage/kanbn/src/controller/help.js.html +115 -0
- package/coverage/kanbn/src/controller/history.js.html +400 -0
- package/coverage/kanbn/src/controller/index.html +431 -0
- package/coverage/kanbn/src/controller/init.js.html +511 -0
- package/coverage/kanbn/src/controller/move.js.html +514 -0
- package/coverage/kanbn/src/controller/remove-all.js.html +226 -0
- package/coverage/kanbn/src/controller/remove.js.html +307 -0
- package/coverage/kanbn/src/controller/rename.js.html +397 -0
- package/coverage/kanbn/src/controller/restore.js.html +268 -0
- package/coverage/kanbn/src/controller/sort.js.html +1318 -0
- package/coverage/kanbn/src/controller/sprint.js.html +361 -0
- package/coverage/kanbn/src/controller/status.js.html +310 -0
- package/coverage/kanbn/src/controller/task.js.html +244 -0
- package/coverage/kanbn/src/controller/validate.js.html +367 -0
- package/coverage/kanbn/src/controller/version.js.html +100 -0
- package/coverage/kanbn/src/index.html +191 -0
- package/coverage/kanbn/src/main.js.html +9787 -0
- package/coverage/kanbn/src/parse-index.js.html +1063 -0
- package/coverage/kanbn/src/parse-markdown.js.html +202 -0
- package/coverage/kanbn/src/parse-task.js.html +2191 -0
- package/coverage/kanbn/src/utility.js.html +616 -0
- package/coverage/lcov.info +11837 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/tmp/coverage-214292-1787777191526-0.json +1 -0
- package/coverage/tmp/coverage-214293-1787777184569-0.json +1 -0
- package/docs/advanced-configuration.md +25 -0
- package/docs/commands/add.txt +19 -0
- package/docs/commands/archive.txt +5 -0
- package/docs/commands/board.txt +8 -1
- package/docs/commands/boards.txt +34 -0
- package/docs/commands/burndown.txt +8 -4
- package/docs/commands/comment.txt +5 -0
- package/docs/commands/edit.txt +27 -0
- package/docs/commands/find.txt +56 -1
- package/docs/commands/gantt.txt +5 -0
- package/docs/commands/help.txt +2 -0
- package/docs/commands/history.txt +5 -0
- package/docs/commands/init.txt +13 -0
- package/docs/commands/move.txt +10 -1
- package/docs/commands/remove.txt +12 -1
- package/docs/commands/rename.txt +5 -0
- package/docs/commands/restore.txt +6 -0
- package/docs/commands/sort.txt +18 -2
- package/docs/commands/sprint.txt +9 -0
- package/docs/commands/status.txt +10 -1
- package/docs/commands/task.txt +5 -0
- package/docs/commands/validate.txt +27 -0
- package/docs/filtering-and-sorting.md +218 -0
- package/docs/index-structure.md +94 -19
- package/docs/index.md +28 -2
- package/docs/migration-2.0.md +92 -0
- package/docs/multiple-boards.md +258 -0
- package/docs/quick-start.md +57 -1
- package/docs/task-structure.md +33 -3
- package/docs/views.md +229 -0
- package/example/README.md +93 -0
- package/example/advanced/.kanbn/index.md +26 -0
- package/example/advanced/.kanbn/tasks/add-rate-limiting.md +18 -0
- package/example/advanced/.kanbn/tasks/add-sso-support.md +15 -0
- package/example/advanced/.kanbn/tasks/migrate-to-new-cdn.md +14 -0
- package/example/advanced/.kanbn/tasks/refresh-marketing-site.md +14 -0
- package/example/advanced/.kanbn/tasks/rotate-api-keys.md +15 -0
- package/example/advanced/.kanbn/tasks/split-billing-service.md +17 -0
- package/example/advanced/.kanbn/tasks/upgrade-node-runtime.md +19 -0
- package/example/advanced/kanbn.yml +59 -0
- package/example/boards/.kanbn/design.md +31 -0
- package/example/boards/.kanbn/index.md +44 -0
- package/example/boards/.kanbn/tasks/add-usage-alert-emails.md +19 -0
- package/example/boards/.kanbn/tasks/build-tenant-settings-page.md +43 -0
- package/example/boards/.kanbn/tasks/create-organization-switcher.md +44 -0
- package/example/boards/.kanbn/tasks/design-onboarding-checklist.md +22 -0
- package/example/boards/.kanbn/tasks/refresh-marketing-site.md +28 -0
- package/example/boards/.kanbn/tasks/ship-billing-portal.md +29 -0
- package/example/views/.kanbn/index.md +140 -0
- package/example/views/.kanbn/tasks/add-audit-log-export.md +18 -0
- package/example/views/.kanbn/tasks/add-dark-mode-toggle.md +16 -0
- package/example/views/.kanbn/tasks/build-webhook-retry-queue.md +15 -0
- package/example/views/.kanbn/tasks/fix-avatar-upload-crash.md +13 -0
- package/example/views/.kanbn/tasks/fix-expired-session-redirect.md +15 -0
- package/example/views/.kanbn/tasks/investigate-offline-mode.md +12 -0
- package/example/views/.kanbn/tasks/rewrite-search-indexer.md +12 -0
- package/example/views/.kanbn/tasks/tidy-up-settings-copy.md +12 -0
- package/package.json +12 -9
- package/routes/add.json +35 -11
- package/routes/archive.json +10 -2
- package/routes/board.json +11 -3
- package/routes/boards.json +30 -0
- package/routes/burndown.json +23 -7
- package/routes/comment.json +14 -4
- package/routes/edit.json +33 -10
- package/routes/find.json +37 -12
- package/routes/gantt.json +20 -6
- package/routes/history.json +39 -25
- package/routes/init.json +3 -1
- package/routes/move.json +22 -6
- package/routes/remove.json +15 -4
- package/routes/rename.json +11 -3
- package/routes/restore.json +8 -2
- package/routes/sort.json +35 -11
- package/routes/sprint.json +14 -4
- package/routes/status.json +23 -7
- package/routes/task.json +10 -2
- package/routes/validate.json +19 -4
- package/skills/kanbn-plan/SKILL.md +10 -1
- package/skills/kanbn-plan/references/index-structure.md +1 -0
- package/skills/kanbn-plan/references/task-structure.md +2 -0
- package/skills/kanbn-replan/SKILL.md +6 -1
- package/src/board.js +2 -1
- package/src/controller/add.js +52 -46
- package/src/controller/archive.js +8 -4
- package/src/controller/board.js +9 -4
- package/src/controller/boards.js +140 -0
- package/src/controller/burndown.js +9 -5
- package/src/controller/comment.js +9 -5
- package/src/controller/edit.js +74 -17
- package/src/controller/find.js +12 -9
- package/src/controller/gantt.js +8 -4
- package/src/controller/history.js +8 -4
- package/src/controller/init.js +39 -4
- package/src/controller/move.js +69 -15
- package/src/controller/remove.js +34 -11
- package/src/controller/rename.js +8 -4
- package/src/controller/restore.js +23 -8
- package/src/controller/sort.js +19 -3
- package/src/controller/sprint.js +31 -7
- package/src/controller/status.js +20 -4
- package/src/controller/task.js +22 -9
- package/src/controller/validate.js +139 -23
- package/src/git-user-name.js +19 -0
- package/src/main.d.ts +184 -6
- package/src/main.js +1642 -221
- package/src/parse-index.js +17 -0
- package/src/parse-markdown.js +3 -1
- package/src/parse-task.js +44 -1
- package/src/utility.js +166 -1
- package/PLANS.md +0 -9
- /package/example/{.kanbn → basic/.kanbn}/index.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-basic-activity-feed.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-passwordless-login-option.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-usage-alert-email-thresholds.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-email-template-system.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-invoice-download-endpoint.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-tenant-settings-page.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-organization-switcher.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-sandbox-environment-provisioner.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-self-serve-cancellation-flow.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/define-product-pricing-strategy.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/design-onboarding-checklist.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/design-team-invite-expiry-flow.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-data-retention-policy-jobs.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-feature-flags-foundation.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-project-creation-wizard.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-stripe-webhook-signature-check.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-team-permissions-ui.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-user-signup-and-login.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/integrate-crm-lead-sync.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/legal-review-terms-and-privacy.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/migrate-legacy-events-to-new-schema.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/optimize-dashboard-first-load.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/prototype-report-export-scheduler.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/publish-internal-qa-checklist.md +0 -0
- /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
|
-
*
|
|
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
|
|
255
|
-
* @
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
303
|
-
compareB = b
|
|
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 (
|
|
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
|
-
*
|
|
660
|
-
*
|
|
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 {
|
|
690
|
-
* @return {?
|
|
815
|
+
* @param {string[]} columnNames
|
|
816
|
+
* @return {?Date}
|
|
691
817
|
*/
|
|
692
|
-
function
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
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(
|
|
1250
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
1274
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1721
|
+
return sortTasksWithFields(index, filterTasks(index, tasks, filters), sorters);
|
|
1502
1722
|
}
|
|
1503
1723
|
|
|
1504
1724
|
/**
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
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
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
ignoreOptions =
|
|
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
|
-
//
|
|
1529
|
-
|
|
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
|
|
1534
|
-
* @
|
|
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
|
|
1537
|
-
|
|
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
|
-
|
|
1913
|
+
boardData = await fs.promises.readFile(await this.getBoardPath(resolvedSlug), { encoding: "utf-8" });
|
|
1540
1914
|
} catch (error) {
|
|
1541
|
-
throw new Error(
|
|
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(
|
|
1921
|
+
const index = parseIndex.md2json(boardData);
|
|
1544
1922
|
|
|
1545
|
-
//
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
*
|
|
1637
|
-
* @
|
|
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
|
|
1640
|
-
|
|
1641
|
-
|
|
2032
|
+
async workspaceInitialised() {
|
|
2033
|
+
return await exists(await this.getBoardPath(await this.getMainBoardSlug()));
|
|
2034
|
+
}
|
|
1642
2035
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
2300
|
-
result.sprint.completed = taskWorkloadInPeriod(tasks,
|
|
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,
|
|
2335
|
-
result.period.completed = taskWorkloadInPeriod(tasks,
|
|
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
|
-
//
|
|
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
|
-
|
|
2461
|
-
|
|
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 =
|
|
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
|
|
2481
|
-
|
|
2482
|
-
await
|
|
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
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
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
|
-
|
|
2557
|
-
|
|
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:
|
|
2842
|
-
completed:
|
|
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
|
-
//
|
|
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
|
|