@basementuniverse/kanbn 2.0.0 → 2.5.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 -1
- package/coverage/tmp/coverage-916017-1788028598821-0.json +1 -0
- package/coverage/tmp/{coverage-214293-1787777184569-0.json → coverage-916018-1788028597179-0.json} +1 -1
- package/coverage/tmp/coverage-916036-1788028598796-0.json +1 -0
- package/docs/actions.md +337 -0
- package/docs/advanced-configuration.md +55 -0
- package/docs/commands/add.txt +17 -1
- package/docs/commands/archive.txt +11 -0
- package/docs/commands/board.txt +9 -0
- package/docs/commands/boards.txt +34 -0
- package/docs/commands/burndown.txt +7 -0
- package/docs/commands/comment.txt +13 -1
- package/docs/commands/contributors.txt +58 -0
- package/docs/commands/edit.txt +18 -1
- package/docs/commands/find.txt +39 -1
- package/docs/commands/gantt.txt +6 -0
- package/docs/commands/help.txt +2 -0
- package/docs/commands/history.txt +6 -0
- package/docs/commands/init.txt +13 -0
- package/docs/commands/move.txt +26 -0
- package/docs/commands/remove.txt +22 -1
- package/docs/commands/rename.txt +5 -0
- package/docs/commands/restore.txt +12 -0
- package/docs/commands/sort.txt +23 -0
- package/docs/commands/sprint.txt +9 -0
- package/docs/commands/status.txt +10 -1
- package/docs/commands/task.txt +9 -0
- package/docs/commands/validate.txt +29 -1
- package/docs/contributors.md +145 -0
- package/docs/filtering-and-sorting.md +60 -3
- package/docs/index-structure.md +145 -11
- package/docs/index.md +6 -2
- package/docs/multiple-boards.md +259 -0
- package/docs/quick-start.md +21 -1
- package/docs/task-structure.md +31 -3
- package/example/README.md +23 -0
- package/example/advanced/kanbn.yml +65 -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/package.json +9 -7
- package/routes/add.json +40 -12
- package/routes/archive.json +14 -2
- package/routes/board.json +11 -3
- package/routes/boards.json +30 -0
- package/routes/burndown.json +23 -7
- package/routes/comment.json +19 -5
- package/routes/contributors.json +18 -0
- package/routes/edit.json +37 -11
- 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 +24 -6
- package/routes/remove.json +18 -3
- package/routes/rename.json +11 -3
- package/routes/restore.json +14 -2
- package/routes/sort.json +40 -11
- package/routes/sprint.json +14 -4
- package/routes/status.json +23 -7
- package/routes/task.json +10 -2
- package/routes/validate.json +18 -5
- package/skills/kanbn-plan/SKILL.md +10 -1
- package/skills/kanbn-replan/SKILL.md +6 -1
- package/src/actions.js +904 -0
- package/src/board.js +25 -2
- package/src/controller/add.js +72 -55
- package/src/controller/archive.js +9 -4
- package/src/controller/board.js +21 -13
- package/src/controller/boards.js +140 -0
- package/src/controller/burndown.js +14 -7
- package/src/controller/comment.js +13 -6
- package/src/controller/contributors.js +166 -0
- package/src/controller/edit.js +48 -18
- package/src/controller/find.js +87 -13
- package/src/controller/gantt.js +13 -6
- package/src/controller/history.js +13 -6
- package/src/controller/init.js +39 -4
- package/src/controller/move.js +153 -25
- package/src/controller/remove.js +73 -13
- package/src/controller/rename.js +8 -4
- package/src/controller/restore.js +24 -8
- package/src/controller/sort.js +59 -3
- package/src/controller/sprint.js +31 -7
- package/src/controller/status.js +8 -4
- package/src/controller/task.js +34 -10
- package/src/controller/validate.js +197 -7
- package/src/git-user-name.js +9 -0
- package/src/git-user.js +55 -0
- package/src/main.d.ts +387 -9
- package/src/main.js +2757 -113
- package/src/parse-index.js +219 -17
- package/src/parse-task.js +16 -0
- package/src/utility.js +274 -0
- package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
package/src/main.js
CHANGED
|
@@ -7,12 +7,48 @@ const utility = require("./utility");
|
|
|
7
7
|
const yaml = require("yamljs");
|
|
8
8
|
const humanizeDuration = require("humanize-duration");
|
|
9
9
|
const rimraf = require("rimraf");
|
|
10
|
+
const gitUser = require("./git-user");
|
|
11
|
+
const actions = require("./actions");
|
|
10
12
|
|
|
11
13
|
const DEFAULT_FOLDER_NAME = ".kanbn";
|
|
12
14
|
const DEFAULT_INDEX_FILE_NAME = "index.md";
|
|
13
15
|
const DEFAULT_TASKS_FOLDER_NAME = "tasks";
|
|
14
16
|
const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
|
|
15
17
|
|
|
18
|
+
// Slugs that always resolve to the main board, whatever the index file is called
|
|
19
|
+
const MAIN_BOARD_ALIASES = ["main", "default"];
|
|
20
|
+
|
|
21
|
+
// Options that describe the workspace rather than a single board. They are only ever read from the
|
|
22
|
+
// config file (or, when there isn't one, from the main board's front matter) - if one of these turns
|
|
23
|
+
// up in a secondary board's front matter it is ignored, and `kanbn validate` reports it
|
|
24
|
+
const WORKSPACE_SCOPED_OPTIONS = [
|
|
25
|
+
"mainFolder",
|
|
26
|
+
"indexFile",
|
|
27
|
+
"taskFolder",
|
|
28
|
+
"archiveFolder",
|
|
29
|
+
"defaultBoard",
|
|
30
|
+
"boards",
|
|
31
|
+
"customFields",
|
|
32
|
+
"contributors",
|
|
33
|
+
"dateFormat",
|
|
34
|
+
"defaultTaskWorkload",
|
|
35
|
+
"taskWorkloadTags",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Options that a secondary board inherits from the main board's front matter. When there is no
|
|
39
|
+
// config file the main board's front matter is doing double duty - it holds the workspace options
|
|
40
|
+
// *and* the main board's own board-scoped ones - so only this allowlist propagates. Anything else
|
|
41
|
+
// there (startedColumns, hiddenColumns, views, custom field column linkages, ...) belongs to the
|
|
42
|
+
// main board alone, and a secondary board that wants it has to say so itself
|
|
43
|
+
const WORKSPACE_INHERITED_OPTIONS = [...WORKSPACE_SCOPED_OPTIONS, "sprints"];
|
|
44
|
+
|
|
45
|
+
// Keys inside the `boards` config option that aren't board slugs
|
|
46
|
+
const RESERVED_BOARDS_CONFIG_KEYS = ["exclude", "order"];
|
|
47
|
+
|
|
48
|
+
// History event types that belong to the task rather than to a board, so they count when replaying
|
|
49
|
+
// for any board. Archiving removes a task from every board, and progress is a property of the task
|
|
50
|
+
const BOARD_AGNOSTIC_EVENT_TYPES = ["progress", "archived"];
|
|
51
|
+
|
|
16
52
|
// Date normalisation intervals measured in milliseconds
|
|
17
53
|
const SECOND = 1000;
|
|
18
54
|
const MINUTE = 60 * SECOND;
|
|
@@ -65,6 +101,53 @@ async function exists(path) {
|
|
|
65
101
|
return true;
|
|
66
102
|
}
|
|
67
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Convert a board file name into a board slug, i.e. "design.md" -> "design"
|
|
106
|
+
* @param {string} fileName The board file name
|
|
107
|
+
* @return {string} The board slug
|
|
108
|
+
*/
|
|
109
|
+
function boardSlugFromFileName(fileName) {
|
|
110
|
+
return path.basename(fileName, path.extname(fileName));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Copy an object, leaving out the specified keys
|
|
115
|
+
* @param {object} o The object to copy
|
|
116
|
+
* @param {string[]} keys The keys to leave out
|
|
117
|
+
* @return {object} A copy of the object without the specified keys
|
|
118
|
+
*/
|
|
119
|
+
function omitKeys(o, keys) {
|
|
120
|
+
return Object.fromEntries(Object.entries(o).filter(([key]) => keys.indexOf(key) === -1));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Record which options belong to a board's own front matter, so that saving the board writes exactly
|
|
125
|
+
* these rather than everything it inherited from the workspace
|
|
126
|
+
* @param {object} index The index object
|
|
127
|
+
* @param {object} ownOptions The options that belong to this board
|
|
128
|
+
* @return {object} The index object
|
|
129
|
+
*/
|
|
130
|
+
function setOwnOptions(index, ownOptions) {
|
|
131
|
+
Object.defineProperty(index, "ownOptions", {
|
|
132
|
+
value: { ...ownOptions },
|
|
133
|
+
enumerable: false,
|
|
134
|
+
writable: true,
|
|
135
|
+
configurable: true,
|
|
136
|
+
});
|
|
137
|
+
return index;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compare two option values structurally, so that an option inherited from the workspace can be told
|
|
142
|
+
* apart from one that a board operation has actually changed
|
|
143
|
+
* @param {any} a
|
|
144
|
+
* @param {any} b
|
|
145
|
+
* @return {boolean} True if the two values are equivalent
|
|
146
|
+
*/
|
|
147
|
+
function sameOptionValue(a, b) {
|
|
148
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
149
|
+
}
|
|
150
|
+
|
|
68
151
|
/**
|
|
69
152
|
* Get a list of all tracked task ids
|
|
70
153
|
* @param {object} index The index object
|
|
@@ -81,6 +164,77 @@ function getTrackedTaskIds(index, columnName = null) {
|
|
|
81
164
|
);
|
|
82
165
|
}
|
|
83
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Check whether a preserved column content entry is a simple task: a single-line list item, rather
|
|
169
|
+
* than a block of prose or an empty line. Only these can be addressed from the CLI
|
|
170
|
+
* @param {object} entry A column content entry
|
|
171
|
+
* @return {boolean} True if the entry is a simple task
|
|
172
|
+
*/
|
|
173
|
+
function isSimpleTask(entry) {
|
|
174
|
+
return !entry.block && !!entry.text && entry.text.indexOf("\n") === -1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Remove a preserved column content entry from a column. Positions are unique within a column, so
|
|
179
|
+
* they identify an entry
|
|
180
|
+
* @param {object} index The index object
|
|
181
|
+
* @param {string} columnName The column to remove from
|
|
182
|
+
* @param {number} position The entry's position
|
|
183
|
+
* @return {object} The modified index object
|
|
184
|
+
*/
|
|
185
|
+
function removeColumnContent(index, columnName, position) {
|
|
186
|
+
if (!index.columnContent || !(columnName in index.columnContent)) {
|
|
187
|
+
return index;
|
|
188
|
+
}
|
|
189
|
+
index.columnContent[columnName] = index.columnContent[columnName].filter(
|
|
190
|
+
(entry) => entry.position !== position
|
|
191
|
+
);
|
|
192
|
+
if (!index.columnContent[columnName].length) {
|
|
193
|
+
delete index.columnContent[columnName];
|
|
194
|
+
}
|
|
195
|
+
if (!Object.keys(index.columnContent).length) {
|
|
196
|
+
delete index.columnContent;
|
|
197
|
+
}
|
|
198
|
+
return index;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Add a preserved column content entry to a column
|
|
203
|
+
* @param {object} index The index object
|
|
204
|
+
* @param {string} columnName The column to add to
|
|
205
|
+
* @param {object} entry The entry to add
|
|
206
|
+
* @param {?number} [position=null] The position to add it at, or the end of the column if null
|
|
207
|
+
* @return {object} The modified index object
|
|
208
|
+
*/
|
|
209
|
+
function addColumnContent(index, columnName, entry, position = null) {
|
|
210
|
+
if (!index.columnContent) {
|
|
211
|
+
index.columnContent = {};
|
|
212
|
+
}
|
|
213
|
+
if (!(columnName in index.columnContent)) {
|
|
214
|
+
index.columnContent[columnName] = [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A position counts every entry in the column, tasks and content alike, so the end of the column
|
|
218
|
+
// is past both of them
|
|
219
|
+
const columnLength = index.columns[columnName].length + index.columnContent[columnName].length;
|
|
220
|
+
index.columnContent[columnName].push({
|
|
221
|
+
...entry,
|
|
222
|
+
position: position === null ? columnLength : Math.max(Math.min(position, columnLength), 0),
|
|
223
|
+
});
|
|
224
|
+
return index;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check whether a line in a column looks like a task link with a typo in it, e.g. a missing closing
|
|
229
|
+
* bracket. A checkbox item (`- [ ] ...`) is an ordinary line rather than a broken link
|
|
230
|
+
* @param {string} raw The raw line
|
|
231
|
+
* @return {boolean} True if the line looks like a malformed task link
|
|
232
|
+
*/
|
|
233
|
+
function looksLikeMalformedTaskLink(raw) {
|
|
234
|
+
const text = String(raw).replace(/^\s*(?:[-*+]|\d+\.)\s+/, "");
|
|
235
|
+
return /^\[/.test(text) && !/^\[[ xX]\]/.test(text);
|
|
236
|
+
}
|
|
237
|
+
|
|
84
238
|
/**
|
|
85
239
|
* Get a task path from the id
|
|
86
240
|
* @param {string} tasksPath The path to the tasks folder
|
|
@@ -223,29 +377,143 @@ function setTaskMetadata(taskData, property, value) {
|
|
|
223
377
|
return taskData;
|
|
224
378
|
}
|
|
225
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Work out which fields an update actually changed
|
|
382
|
+
*
|
|
383
|
+
* Reserved fields are left out: `updated` changes on every update, so including it would make the
|
|
384
|
+
* payload say nothing
|
|
385
|
+
* @param {object} before The task before the update
|
|
386
|
+
* @param {object} after The task after it
|
|
387
|
+
* @return {string[]} The names of the fields that changed
|
|
388
|
+
*/
|
|
389
|
+
function changedFields(before, after) {
|
|
390
|
+
const fields = new Set();
|
|
391
|
+
if (before.name !== after.name) {
|
|
392
|
+
fields.add("name");
|
|
393
|
+
}
|
|
394
|
+
if (before.description !== after.description) {
|
|
395
|
+
fields.add("description");
|
|
396
|
+
}
|
|
397
|
+
const beforeMetadata = before.metadata || {};
|
|
398
|
+
const afterMetadata = after.metadata || {};
|
|
399
|
+
for (const field of new Set([...Object.keys(beforeMetadata), ...Object.keys(afterMetadata)])) {
|
|
400
|
+
if (actions.RESERVED_FIELDS.indexOf(field) !== -1) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (JSON.stringify(beforeMetadata[field]) !== JSON.stringify(afterMetadata[field])) {
|
|
404
|
+
fields.add(field);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return [...fields];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Normalise a contributors option into a consistent object form
|
|
412
|
+
*
|
|
413
|
+
* A contributor can be written as a bare name or as an object, and both mean the same thing - the
|
|
414
|
+
* shorthand is what most workspaces will ever need, and requiring `- name: gordon` for a bare name
|
|
415
|
+
* is the kind of ceremony that stops an optional feature being adopted. Everything downstream sees
|
|
416
|
+
* the object form
|
|
417
|
+
* @param {any} contributors The raw contributors option
|
|
418
|
+
* @return {object[]} The normalised contributors
|
|
419
|
+
*/
|
|
420
|
+
function normaliseContributors(contributors) {
|
|
421
|
+
if (!Array.isArray(contributors)) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const result = [];
|
|
425
|
+
for (const contributor of contributors) {
|
|
426
|
+
const source = typeof contributor === "string" ? { name: contributor } : contributor;
|
|
427
|
+
if (source === null || typeof source !== "object" || typeof source.name !== "string") {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const name = source.name.trim();
|
|
431
|
+
if (!name) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const normalised = {
|
|
435
|
+
name,
|
|
436
|
+
displayName: typeof source.displayName === "string" && source.displayName ? source.displayName : name,
|
|
437
|
+
aliases: Array.isArray(source.aliases)
|
|
438
|
+
? source.aliases.filter((alias) => typeof alias === "string" && alias.trim()).map((alias) => alias.trim())
|
|
439
|
+
: [],
|
|
440
|
+
};
|
|
441
|
+
if (typeof source.email === "string" && source.email) {
|
|
442
|
+
normalised.email = source.email;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Purely presentational, for avatar chips in a UI. Kanbn stores and serves it, and never
|
|
446
|
+
// interprets it
|
|
447
|
+
if (typeof source.colour === "string" && source.colour) {
|
|
448
|
+
normalised.colour = source.colour;
|
|
449
|
+
}
|
|
450
|
+
result.push(normalised);
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Find the contributor a value refers to, matching against the canonical name, the display name and
|
|
457
|
+
* any aliases, case-insensitively
|
|
458
|
+
* @param {object[]} contributors Normalised contributors
|
|
459
|
+
* @param {?string} value The value to look up
|
|
460
|
+
* @return {?object} The matching contributor, or null if there isn't one
|
|
461
|
+
*/
|
|
462
|
+
function matchContributor(contributors, value) {
|
|
463
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
const needle = value.trim().toLowerCase();
|
|
467
|
+
return (
|
|
468
|
+
contributors.find(
|
|
469
|
+
(contributor) =>
|
|
470
|
+
contributor.name.toLowerCase() === needle ||
|
|
471
|
+
contributor.displayName.toLowerCase() === needle ||
|
|
472
|
+
contributor.aliases.some((alias) => alias.toLowerCase() === needle)
|
|
473
|
+
) || null
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Find the contributor with a given email address, case-insensitively
|
|
479
|
+
* @param {object[]} contributors Normalised contributors
|
|
480
|
+
* @param {?string} email The email address to look up
|
|
481
|
+
* @return {?object} The matching contributor, or null if there isn't one
|
|
482
|
+
*/
|
|
483
|
+
function matchContributorEmail(contributors, email) {
|
|
484
|
+
if (typeof email !== "string" || !email.trim()) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const needle = email.trim().toLowerCase();
|
|
488
|
+
return contributors.find((contributor) => (contributor.email || "").toLowerCase() === needle) || null;
|
|
489
|
+
}
|
|
490
|
+
|
|
226
491
|
/**
|
|
227
492
|
* Append a structured history event to a task
|
|
228
493
|
* @param {object} taskData The task object
|
|
229
494
|
* @param {object} historyEvent The history event payload
|
|
495
|
+
* @param {?string} [boardSlug=null] The board the event happened on, or null for the main board
|
|
496
|
+
* @param {?string} [author=null] The user the event is attributed to, or null for no attribution
|
|
230
497
|
* @return {object} The modified task object
|
|
231
498
|
*/
|
|
232
|
-
function appendTaskHistory(taskData, historyEvent) {
|
|
499
|
+
function appendTaskHistory(taskData, historyEvent, boardSlug = null, author = null) {
|
|
233
500
|
if (!('history' in taskData) || taskData.history === null) {
|
|
234
501
|
taskData.history = [];
|
|
235
502
|
}
|
|
236
503
|
taskData.history.push({
|
|
237
504
|
date: new Date(),
|
|
238
|
-
...historyEvent
|
|
505
|
+
...historyEvent,
|
|
506
|
+
|
|
507
|
+
// Events on the main board carry no board key, so a single-board workspace writes exactly the
|
|
508
|
+
// history it always has
|
|
509
|
+
...(boardSlug === null ? {} : { board: boardSlug }),
|
|
510
|
+
|
|
511
|
+
// Likewise, a machine with no resolvable user writes no author key at all
|
|
512
|
+
...(author ? { author } : {})
|
|
239
513
|
});
|
|
240
514
|
return taskData;
|
|
241
515
|
}
|
|
242
516
|
|
|
243
|
-
/**
|
|
244
|
-
* Check if a task is completed
|
|
245
|
-
* @param {object} index
|
|
246
|
-
* @param {object} task
|
|
247
|
-
* @return {boolean} True if the task is in a completed column or has a completed date
|
|
248
|
-
*/
|
|
249
517
|
/**
|
|
250
518
|
* Get the name of the metadata field that holds a task's started date for this board. Boards can
|
|
251
519
|
* point this at a custom date field so that several boards can track their own started/completed
|
|
@@ -266,10 +534,75 @@ function getCompletedField(index) {
|
|
|
266
534
|
return ("completedField" in index.options && index.options.completedField) || DEFAULT_COMPLETED_FIELD;
|
|
267
535
|
}
|
|
268
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Find the column a task is currently in
|
|
539
|
+
* @param {object} index The index object
|
|
540
|
+
* @param {object} task The task object
|
|
541
|
+
* @return {?string} The column name, or null if the task isn't in the index
|
|
542
|
+
*/
|
|
543
|
+
function getTaskColumn(index, task) {
|
|
544
|
+
return findTaskColumn(index, task.id || utility.getTaskId(task.name));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Check if a task has been started, based on its metadata
|
|
549
|
+
* @param {object} index The index object
|
|
550
|
+
* @param {object} task The task object
|
|
551
|
+
* @return {boolean} True if the task has a started date
|
|
552
|
+
*/
|
|
553
|
+
function taskStarted(index, task) {
|
|
554
|
+
return getStartedField(index) in task.metadata;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Check if a task is completed, based on its metadata
|
|
559
|
+
* @param {object} index The index object
|
|
560
|
+
* @param {object} task The task object
|
|
561
|
+
* @return {boolean} True if the task has a completed date
|
|
562
|
+
*/
|
|
269
563
|
function taskCompleted(index, task) {
|
|
270
564
|
return getCompletedField(index) in task.metadata;
|
|
271
565
|
}
|
|
272
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Check if a task is in one of this board's started columns. A board that declares no
|
|
569
|
+
* startedColumns has no notion of work in progress, so nothing is in a started column
|
|
570
|
+
* @param {object} index The index object
|
|
571
|
+
* @param {object} task The task object
|
|
572
|
+
* @return {boolean} True if the task is in a started column
|
|
573
|
+
*/
|
|
574
|
+
function taskInStartedColumn(index, task) {
|
|
575
|
+
const startedColumns = "startedColumns" in index.options ? index.options.startedColumns : [];
|
|
576
|
+
const column = getTaskColumn(index, task);
|
|
577
|
+
return column !== null && startedColumns.indexOf(column) !== -1;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Check if a task is in one of this board's completed columns
|
|
582
|
+
* @param {object} index The index object
|
|
583
|
+
* @param {object} task The task object
|
|
584
|
+
* @return {boolean} True if the task is in a completed column
|
|
585
|
+
*/
|
|
586
|
+
function taskInCompletedColumn(index, task) {
|
|
587
|
+
const completedColumns = "completedColumns" in index.options ? index.options.completedColumns : [];
|
|
588
|
+
const column = getTaskColumn(index, task);
|
|
589
|
+
return column !== null && completedColumns.indexOf(column) !== -1;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Check if a task is overdue - i.e. it has a due date in the past and hasn't been completed. A task
|
|
594
|
+
* with no due date is never overdue, and neither is a completed task, however late it was
|
|
595
|
+
* @param {object} index The index object
|
|
596
|
+
* @param {object} task The task object
|
|
597
|
+
* @return {boolean} True if the task is overdue
|
|
598
|
+
*/
|
|
599
|
+
function taskOverdue(index, task) {
|
|
600
|
+
if (!("due" in task.metadata) || taskCompleted(index, task)) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
return new Date() - task.metadata.due > 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
273
606
|
/**
|
|
274
607
|
* Flatten a task's metadata and computed values into a single object that can be used for sorting
|
|
275
608
|
* @param {object} index The index object
|
|
@@ -298,6 +631,12 @@ function taskSortFields(index, task) {
|
|
|
298
631
|
comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
|
|
299
632
|
workload: taskWorkload(index, task),
|
|
300
633
|
progress: taskProgress(index, task),
|
|
634
|
+
column: getTaskColumn(index, task) || "",
|
|
635
|
+
overdue: taskOverdue(index, task),
|
|
636
|
+
isStarted: taskStarted(index, task),
|
|
637
|
+
isCompleted: taskCompleted(index, task),
|
|
638
|
+
inStartedColumn: taskInStartedColumn(index, task),
|
|
639
|
+
inCompletedColumn: taskInCompletedColumn(index, task),
|
|
301
640
|
};
|
|
302
641
|
}
|
|
303
642
|
|
|
@@ -331,8 +670,11 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
|
|
|
331
670
|
// Sort the tasks in the target column using their flattened sort fields
|
|
332
671
|
tasks = sortTasksWithFields(index, tasks, sorters);
|
|
333
672
|
|
|
334
|
-
// Save the list of tasks back to the index
|
|
335
|
-
|
|
673
|
+
// Save the list of tasks back to the index. A task whose file is missing can't be loaded, so it
|
|
674
|
+
// isn't in the sorted list - keep it in the column rather than dropping it from the board
|
|
675
|
+
const sortedTaskIds = tasks.map((task) => task.id);
|
|
676
|
+
const unsortedTaskIds = index.columns[columnName].filter((taskId) => sortedTaskIds.indexOf(taskId) === -1);
|
|
677
|
+
index.columns[columnName] = [...sortedTaskIds, ...unsortedTaskIds];
|
|
336
678
|
return index;
|
|
337
679
|
}
|
|
338
680
|
|
|
@@ -511,6 +853,37 @@ function filterTasks(index, tasks, filters) {
|
|
|
511
853
|
result = false;
|
|
512
854
|
}
|
|
513
855
|
|
|
856
|
+
// Overdue
|
|
857
|
+
if ("overdue" in filters && !booleanFilter(filters.overdue, taskOverdue(index, task))) {
|
|
858
|
+
result = false;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Started, from the task's metadata
|
|
862
|
+
if ("is-started" in filters && !booleanFilter(filters["is-started"], taskStarted(index, task))) {
|
|
863
|
+
result = false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Completed, from the task's metadata
|
|
867
|
+
if ("is-completed" in filters && !booleanFilter(filters["is-completed"], taskCompleted(index, task))) {
|
|
868
|
+
result = false;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// In a started column on this board
|
|
872
|
+
if (
|
|
873
|
+
"in-started-column" in filters &&
|
|
874
|
+
!booleanFilter(filters["in-started-column"], taskInStartedColumn(index, task))
|
|
875
|
+
) {
|
|
876
|
+
result = false;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// In a completed column on this board
|
|
880
|
+
if (
|
|
881
|
+
"in-completed-column" in filters &&
|
|
882
|
+
!booleanFilter(filters["in-completed-column"], taskInCompletedColumn(index, task))
|
|
883
|
+
) {
|
|
884
|
+
result = false;
|
|
885
|
+
}
|
|
886
|
+
|
|
514
887
|
// Assigned
|
|
515
888
|
if (
|
|
516
889
|
"assigned" in filters &&
|
|
@@ -658,6 +1031,17 @@ function numberFilter(filter, input) {
|
|
|
658
1031
|
return input >= Math.min(...filter) && input <= Math.max(...filter);
|
|
659
1032
|
}
|
|
660
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Check if the input matches a boolean, or if multiple booleans are passed in, check if the input
|
|
1036
|
+
* matches any of them
|
|
1037
|
+
* @param {boolean|boolean[]} filter A filter boolean or array of filter booleans
|
|
1038
|
+
* @param {boolean} input The value to match against
|
|
1039
|
+
* @return {boolean} True if the input matches the boolean filter
|
|
1040
|
+
*/
|
|
1041
|
+
function booleanFilter(filter, input) {
|
|
1042
|
+
return utility.arrayArg(filter).some((value) => !!value === !!input);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
661
1045
|
/**
|
|
662
1046
|
* Calculate task workload
|
|
663
1047
|
* @param {object} index The index object
|
|
@@ -781,17 +1165,37 @@ function getTaskProgressAtDate(task, date) {
|
|
|
781
1165
|
return Math.max(0, Math.min(progress, 1));
|
|
782
1166
|
}
|
|
783
1167
|
|
|
1168
|
+
/**
|
|
1169
|
+
* Check whether a history event counts when replaying for a board. An event belongs to a board if it
|
|
1170
|
+
* names that board, or if it names none and we're replaying for the main board - which is exactly
|
|
1171
|
+
* what makes every task file written before boards existed replay the way it always has
|
|
1172
|
+
* @param {object} historyEvent The history event
|
|
1173
|
+
* @param {?string} [boardSlug=null] The board being replayed for, or null for the main board
|
|
1174
|
+
* @return {boolean} True if the event counts for this board
|
|
1175
|
+
*/
|
|
1176
|
+
function historyEventOnBoard(historyEvent, boardSlug = null) {
|
|
1177
|
+
if (BOARD_AGNOSTIC_EVENT_TYPES.indexOf(historyEvent.type) !== -1) {
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
if ("board" in historyEvent && historyEvent.board) {
|
|
1181
|
+
return historyEvent.board === boardSlug;
|
|
1182
|
+
}
|
|
1183
|
+
return boardSlug === null;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
784
1186
|
/**
|
|
785
1187
|
* Get timeline dates for a task in a period. For history-enabled tasks this uses all history event dates,
|
|
786
1188
|
* otherwise it falls back to created/started/completed dates.
|
|
787
1189
|
* @param {object} task
|
|
788
1190
|
* @param {Date} from
|
|
789
1191
|
* @param {Date} to
|
|
1192
|
+
* @param {?string} [boardSlug=null] The board to replay for, or null for the main board
|
|
790
1193
|
* @return {Date[]}
|
|
791
1194
|
*/
|
|
792
|
-
function getTaskTimelineDates(task, from, to) {
|
|
1195
|
+
function getTaskTimelineDates(task, from, to, boardSlug = null) {
|
|
793
1196
|
if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
|
|
794
1197
|
return task.history
|
|
1198
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
|
|
795
1199
|
.map((historyEvent) => historyEvent.date)
|
|
796
1200
|
.filter((date) => date && date >= from && date <= to);
|
|
797
1201
|
}
|
|
@@ -1150,13 +1554,15 @@ function countActiveTasksAtDate(index, tasks, date) {
|
|
|
1150
1554
|
* @param {object} index
|
|
1151
1555
|
* @param {object[]} tasks
|
|
1152
1556
|
* @param {Date} date
|
|
1557
|
+
* @param {?string} [boardSlug=null] The board to replay for, or null for the main board
|
|
1153
1558
|
* @return {object[]} A list of event objects, with event type and task id
|
|
1154
1559
|
*/
|
|
1155
|
-
function getTaskEventsAtDate(index, tasks, date) {
|
|
1560
|
+
function getTaskEventsAtDate(index, tasks, date, boardSlug = null) {
|
|
1156
1561
|
return tasks
|
|
1157
1562
|
.map((task) => {
|
|
1158
1563
|
if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
|
|
1159
1564
|
return task.history
|
|
1565
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
|
|
1160
1566
|
.filter((historyEvent) => historyEvent.date && historyEvent.date.getTime() === date.getTime())
|
|
1161
1567
|
.map((historyEvent) => ({
|
|
1162
1568
|
eventType: historyEvent.type,
|
|
@@ -1280,20 +1686,103 @@ function updateColumnLinkedCustomField(
|
|
|
1280
1686
|
return taskData;
|
|
1281
1687
|
}
|
|
1282
1688
|
|
|
1689
|
+
// The parts of this module the actions engine needs. Passing them in rather than requiring main.js
|
|
1690
|
+
// from actions.js keeps the engine free of filesystem and workspace knowledge, and free of a
|
|
1691
|
+
// circular require
|
|
1692
|
+
const ACTION_HELPERS = {
|
|
1693
|
+
filterTasks,
|
|
1694
|
+
getTaskMetadata,
|
|
1695
|
+
setTaskMetadata,
|
|
1696
|
+
appendTaskHistory
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1283
1699
|
class Kanbn {
|
|
1284
1700
|
ROOT = process.cwd();
|
|
1285
1701
|
CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
|
|
1286
1702
|
CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
|
|
1287
1703
|
|
|
1288
|
-
//
|
|
1289
|
-
|
|
1704
|
+
// The board this instance is scoped to, or null for the main board
|
|
1705
|
+
boardSlug = null;
|
|
1290
1706
|
|
|
1291
|
-
|
|
1707
|
+
// Boards named in an archived task's metadata that no longer existed when it was restored, set by
|
|
1708
|
+
// restoreTask() for the caller to report
|
|
1709
|
+
lastRestoreWarnings = [];
|
|
1710
|
+
|
|
1711
|
+
// Rules that were skipped during the last operation, set by runActions() for the caller to report
|
|
1712
|
+
lastActionWarnings = [];
|
|
1713
|
+
|
|
1714
|
+
// Whether scripted actions run for operations on this instance. Actions are ordinary declarative
|
|
1715
|
+
// rules with no code execution, so this is a convenience for stepping around a misbehaving rule
|
|
1716
|
+
// rather than a safety control
|
|
1717
|
+
actionsEnabled = true;
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* @param {?string} [root=null] The workspace root folder
|
|
1721
|
+
* @param {object} [options={}] Instance options: `board` scopes this instance to a board, `caches`
|
|
1722
|
+
* lets a board-scoped clone share its parent's memoized config
|
|
1723
|
+
*/
|
|
1724
|
+
constructor(root = null, options = {}) {
|
|
1292
1725
|
if(root) {
|
|
1293
1726
|
this.ROOT = root
|
|
1294
1727
|
this.CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
|
|
1295
1728
|
this.CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
|
|
1296
1729
|
}
|
|
1730
|
+
this.caches = options.caches || { config: null, workspaceOptions: null };
|
|
1731
|
+
this.boardSlug = options.board || null;
|
|
1732
|
+
if (options.actions === false) {
|
|
1733
|
+
this.actionsEnabled = false;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Memoized config, kept in the shared cache object so that board-scoped clones don't each re-read it
|
|
1738
|
+
get configMemo() {
|
|
1739
|
+
return this.caches.config;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
set configMemo(value) {
|
|
1743
|
+
this.caches.config = value;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Get a copy of this instance scoped to another board. The returned instance shares this one's
|
|
1748
|
+
* cached config, so scoping to a board costs no extra file reads
|
|
1749
|
+
* @param {?string} [slug=null] The board slug, or null/"main"/"default" for the main board
|
|
1750
|
+
* @return {Kanbn} A board-scoped Kanbn instance
|
|
1751
|
+
*/
|
|
1752
|
+
board(slug = null) {
|
|
1753
|
+
if (slug === null || slug === undefined || slug === "") {
|
|
1754
|
+
return this.boardSlug === null
|
|
1755
|
+
? this
|
|
1756
|
+
: new Kanbn(this.ROOT, { caches: this.caches, actions: this.actionsEnabled });
|
|
1757
|
+
}
|
|
1758
|
+
return new Kanbn(this.ROOT, {
|
|
1759
|
+
board: String(slug).trim(),
|
|
1760
|
+
caches: this.caches,
|
|
1761
|
+
actions: this.actionsEnabled
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Get an instance that runs no actions
|
|
1767
|
+
*
|
|
1768
|
+
* Used for the writes one operation makes on another's behalf - archiving removes the task from
|
|
1769
|
+
* every board, and that removal is part of the archive, not a deletion anyone wrote a rule for
|
|
1770
|
+
* @return {Kanbn} An instance with actions disabled
|
|
1771
|
+
*/
|
|
1772
|
+
withoutActions() {
|
|
1773
|
+
if (!this.actionsEnabled) {
|
|
1774
|
+
return this;
|
|
1775
|
+
}
|
|
1776
|
+
return new Kanbn(this.ROOT, { board: this.boardSlug, caches: this.caches, actions: false });
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Alias for board()
|
|
1781
|
+
* @param {?string} [slug=null] The board slug
|
|
1782
|
+
* @return {Kanbn} A board-scoped Kanbn instance
|
|
1783
|
+
*/
|
|
1784
|
+
withBoard(slug = null) {
|
|
1785
|
+
return this.board(slug);
|
|
1297
1786
|
}
|
|
1298
1787
|
|
|
1299
1788
|
/**
|
|
@@ -1313,6 +1802,7 @@ class Kanbn {
|
|
|
1313
1802
|
} else {
|
|
1314
1803
|
await fs.promises.writeFile(this.CONFIG_JSON, JSON.stringify(config, null, 4));
|
|
1315
1804
|
}
|
|
1805
|
+
this.caches.workspaceOptions = null;
|
|
1316
1806
|
}
|
|
1317
1807
|
|
|
1318
1808
|
/**
|
|
@@ -1344,7 +1834,8 @@ class Kanbn {
|
|
|
1344
1834
|
* Clear cached config
|
|
1345
1835
|
*/
|
|
1346
1836
|
clearConfigCache() {
|
|
1347
|
-
this.
|
|
1837
|
+
this.caches.config = null;
|
|
1838
|
+
this.caches.workspaceOptions = null;
|
|
1348
1839
|
}
|
|
1349
1840
|
|
|
1350
1841
|
/**
|
|
@@ -1404,11 +1895,64 @@ class Kanbn {
|
|
|
1404
1895
|
}
|
|
1405
1896
|
|
|
1406
1897
|
/**
|
|
1407
|
-
* Get the index
|
|
1898
|
+
* Get the main board's slug. This is the index file name without its extension, so a workspace with
|
|
1899
|
+
* a customised `indexFile` gets a main board slug to match
|
|
1900
|
+
* @return {Promise<string>} The main board slug
|
|
1901
|
+
*/
|
|
1902
|
+
async getMainBoardSlug() {
|
|
1903
|
+
return boardSlugFromFileName(await this.getIndexFileName());
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Resolve a board slug, mapping the reserved aliases and an absent slug onto the main board
|
|
1908
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1909
|
+
* @return {Promise<string>} The resolved board slug
|
|
1910
|
+
*/
|
|
1911
|
+
async resolveBoardSlug(slug = undefined) {
|
|
1912
|
+
if (slug === undefined) {
|
|
1913
|
+
slug = this.boardSlug;
|
|
1914
|
+
}
|
|
1915
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
1916
|
+
if (slug === null || slug === undefined || slug === "") {
|
|
1917
|
+
return mainBoardSlug;
|
|
1918
|
+
}
|
|
1919
|
+
slug = String(slug).trim();
|
|
1920
|
+
if (MAIN_BOARD_ALIASES.indexOf(slug.toLowerCase()) !== -1) {
|
|
1921
|
+
return mainBoardSlug;
|
|
1922
|
+
}
|
|
1923
|
+
return slug;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Check if a slug refers to the main board
|
|
1928
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1929
|
+
* @return {Promise<boolean>} True if the slug refers to the main board
|
|
1930
|
+
*/
|
|
1931
|
+
async isMainBoard(slug = undefined) {
|
|
1932
|
+
return (await this.resolveBoardSlug(slug)) === (await this.getMainBoardSlug());
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Get the file path for a board. The main board keeps its configured index file name, every other
|
|
1937
|
+
* board is a sibling markdown file named after its slug
|
|
1938
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1939
|
+
* @return {Promise<string>} The board file path
|
|
1940
|
+
*/
|
|
1941
|
+
async getBoardPath(slug = undefined) {
|
|
1942
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
1943
|
+
const mainFolder = await this.getMainFolder();
|
|
1944
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
1945
|
+
return path.join(mainFolder, await this.getIndexFileName());
|
|
1946
|
+
}
|
|
1947
|
+
return path.join(mainFolder, `${resolvedSlug}.md`);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Get the index path. This is the path of the board this instance is scoped to
|
|
1408
1952
|
* @return {Promise<string>} The kanbn index path
|
|
1409
1953
|
*/
|
|
1410
1954
|
async getIndexPath() {
|
|
1411
|
-
return
|
|
1955
|
+
return this.getBoardPath();
|
|
1412
1956
|
}
|
|
1413
1957
|
|
|
1414
1958
|
/**
|
|
@@ -1469,8 +2013,6 @@ class Kanbn {
|
|
|
1469
2013
|
if ("due" in task.metadata) {
|
|
1470
2014
|
const dueData = {};
|
|
1471
2015
|
|
|
1472
|
-
// A task is overdue if it's due date is in the past and the task is not in a completed column
|
|
1473
|
-
// or doesn't have a completed dates
|
|
1474
2016
|
const completedField = getCompletedField(index);
|
|
1475
2017
|
const completedDate = completedField in task.metadata ? task.metadata[completedField] : null;
|
|
1476
2018
|
|
|
@@ -1487,7 +2029,7 @@ class Kanbn {
|
|
|
1487
2029
|
dueData.completed = completed;
|
|
1488
2030
|
dueData.completedDate = completedDate;
|
|
1489
2031
|
dueData.dueDate = task.metadata.due;
|
|
1490
|
-
dueData.overdue =
|
|
2032
|
+
dueData.overdue = taskOverdue(index, task);
|
|
1491
2033
|
dueData.dueDelta = delta;
|
|
1492
2034
|
|
|
1493
2035
|
// Prepare a due message for the task
|
|
@@ -1518,61 +2060,304 @@ class Kanbn {
|
|
|
1518
2060
|
}
|
|
1519
2061
|
|
|
1520
2062
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
2063
|
+
* Get the workspace-scoped options. These live in the config file if there is one, and in the main
|
|
2064
|
+
* board's front matter if there isn't - which means loading a secondary board also means reading the
|
|
2065
|
+
* main board. The result is memoized so that costs one extra file read per process, not per board
|
|
2066
|
+
* @return {Promise<object>} The workspace options
|
|
1523
2067
|
*/
|
|
1524
|
-
async
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
for (let columnName in indexData.options.columnSorting) {
|
|
1528
|
-
indexData = sortColumnInIndex(
|
|
1529
|
-
indexData,
|
|
1530
|
-
await this.loadAllTrackedTasks(indexData, columnName),
|
|
1531
|
-
columnName,
|
|
1532
|
-
indexData.options.columnSorting[columnName]
|
|
1533
|
-
);
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
2068
|
+
async getWorkspaceOptions() {
|
|
2069
|
+
return (await this.loadWorkspaceOptions()).options;
|
|
2070
|
+
}
|
|
1536
2071
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
2072
|
+
/**
|
|
2073
|
+
* Load the workspace options along with where they came from, since that decides how much of them
|
|
2074
|
+
* a secondary board inherits
|
|
2075
|
+
* @return {Promise<{options: object, fromConfig: boolean}>} The workspace options and their source
|
|
2076
|
+
*/
|
|
2077
|
+
async loadWorkspaceOptions() {
|
|
2078
|
+
if (this.caches.workspaceOptions === null) {
|
|
2079
|
+
const config = await this.getConfig();
|
|
2080
|
+
if (config !== null) {
|
|
2081
|
+
this.caches.workspaceOptions = { options: { ...config }, fromConfig: true };
|
|
2082
|
+
} else {
|
|
2083
|
+
let options = {};
|
|
2084
|
+
try {
|
|
2085
|
+
const mainBoardData = await fs.promises.readFile(
|
|
2086
|
+
await this.getBoardPath(await this.getMainBoardSlug()),
|
|
2087
|
+
{ encoding: "utf-8" }
|
|
2088
|
+
);
|
|
2089
|
+
options = parseIndex.md2json(mainBoardData).options;
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
// A missing or unparseable main board leaves secondary boards with defaults only. This is
|
|
2092
|
+
// reported by validate rather than thrown, so that one broken file doesn't break every board
|
|
2093
|
+
options = {};
|
|
2094
|
+
}
|
|
2095
|
+
this.caches.workspaceOptions = { options, fromConfig: false };
|
|
2096
|
+
}
|
|
1542
2097
|
}
|
|
1543
|
-
|
|
1544
|
-
// Save index
|
|
1545
|
-
await fs.promises.writeFile(await this.getIndexPath(), parseIndex.json2md(indexData, ignoreOptions));
|
|
2098
|
+
return this.caches.workspaceOptions;
|
|
1546
2099
|
}
|
|
1547
2100
|
|
|
1548
2101
|
/**
|
|
1549
|
-
*
|
|
1550
|
-
*
|
|
2102
|
+
* Normalise a contributors option into a consistent object form. Exposed so that callers holding
|
|
2103
|
+
* an index object (the VSCode extension, for one) don't have to reimplement the shorthand rules
|
|
2104
|
+
* @param {any} contributors The raw contributors option
|
|
2105
|
+
* @return {object[]} The normalised contributors
|
|
1551
2106
|
*/
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
indexData = await fs.promises.readFile(await this.getIndexPath(), { encoding: "utf-8" });
|
|
1556
|
-
} catch (error) {
|
|
1557
|
-
throw new Error(`Couldn't access index file: ${error.message}`);
|
|
1558
|
-
}
|
|
1559
|
-
const index = parseIndex.md2json(indexData);
|
|
2107
|
+
normaliseContributors(contributors) {
|
|
2108
|
+
return normaliseContributors(contributors);
|
|
2109
|
+
}
|
|
1560
2110
|
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2111
|
+
/**
|
|
2112
|
+
* Get the workspace's contributors, normalised to the object form
|
|
2113
|
+
*
|
|
2114
|
+
* Contributors are advisory: they're a convenience list, not an access control list, and nothing
|
|
2115
|
+
* anywhere validates `assigned` or a comment `author` against them
|
|
2116
|
+
* @return {Promise<object[]>} The normalised contributors, or an empty array if none are declared
|
|
2117
|
+
*/
|
|
2118
|
+
async getContributors() {
|
|
2119
|
+
return normaliseContributors((await this.getWorkspaceOptions()).contributors);
|
|
1567
2120
|
}
|
|
1568
2121
|
|
|
1569
2122
|
/**
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
* @param {
|
|
2123
|
+
* Find the contributor a value refers to, matching against the canonical name, the display name
|
|
2124
|
+
* and any aliases, case-insensitively
|
|
2125
|
+
* @param {?string} value The value to look up
|
|
2126
|
+
* @return {Promise<?object>} The matching contributor, or null if there isn't one
|
|
1573
2127
|
*/
|
|
1574
|
-
async
|
|
1575
|
-
await
|
|
2128
|
+
async findContributor(value) {
|
|
2129
|
+
return matchContributor(await this.getContributors(), value);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Work out who the current user is, as the value that would be written into `assigned` or a
|
|
2134
|
+
* comment `author`
|
|
2135
|
+
*
|
|
2136
|
+
* In order, first match wins:
|
|
2137
|
+
* 1. the KANBN_USER environment variable, used verbatim
|
|
2138
|
+
* 2. `git config user.email` matched against a contributor's email
|
|
2139
|
+
* 3. `git config user.name` matched against a contributor's name, display name or aliases
|
|
2140
|
+
* 4. `git config user.name` as-is
|
|
2141
|
+
* 5. null
|
|
2142
|
+
*
|
|
2143
|
+
* With no contributors declared this is exactly what the git username has always been, so a
|
|
2144
|
+
* workspace that ignores contributors sees no change. With contributors declared it canonicalises:
|
|
2145
|
+
* a machine whose git says "Gordon Larrigan" writes "gordon", because that's what the workspace has
|
|
2146
|
+
* agreed to call him
|
|
2147
|
+
* @return {Promise<?string>} The current user, or null if there's nothing to go on
|
|
2148
|
+
*/
|
|
2149
|
+
async currentUser() {
|
|
2150
|
+
// An explicitly set user is used exactly as given - it's the escape hatch for a machine whose
|
|
2151
|
+
// git identity is wrong, or which has none
|
|
2152
|
+
const envUser = (process.env.KANBN_USER || "").trim();
|
|
2153
|
+
if (envUser) {
|
|
2154
|
+
return envUser;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
const contributors = await this.getContributors();
|
|
2158
|
+
if (contributors.length) {
|
|
2159
|
+
const byEmail = matchContributorEmail(contributors, gitUser.email());
|
|
2160
|
+
if (byEmail !== null) {
|
|
2161
|
+
return byEmail.name;
|
|
2162
|
+
}
|
|
2163
|
+
const byName = matchContributor(contributors, gitUser.name());
|
|
2164
|
+
if (byName !== null) {
|
|
2165
|
+
return byName.name;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
return gitUser.name();
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Get the options a secondary board inherits from the workspace. A config file is workspace-level
|
|
2173
|
+
* by construction, so all of it is inherited; the main board's front matter is that board's own
|
|
2174
|
+
* file, so only the workspace-scoped keys in it are
|
|
2175
|
+
* @return {Promise<object>} The inherited options
|
|
2176
|
+
*/
|
|
2177
|
+
async getInheritedBoardOptions() {
|
|
2178
|
+
const { options, fromConfig } = await this.loadWorkspaceOptions();
|
|
2179
|
+
if (fromConfig) {
|
|
2180
|
+
return { ...options };
|
|
2181
|
+
}
|
|
2182
|
+
return Object.fromEntries(
|
|
2183
|
+
Object.entries(options).filter(([key]) => WORKSPACE_INHERITED_OPTIONS.indexOf(key) !== -1)
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* Get the per-board options declared for a board in the config file's `boards` key, if any
|
|
2189
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
2190
|
+
* @return {Promise<object>} The board's options from config, or an empty object
|
|
2191
|
+
*/
|
|
2192
|
+
async getBoardConfig(slug = undefined) {
|
|
2193
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2194
|
+
const config = await this.getConfig();
|
|
2195
|
+
if (config === null || typeof config.boards !== "object" || config.boards === null) {
|
|
2196
|
+
return {};
|
|
2197
|
+
}
|
|
2198
|
+
if (RESERVED_BOARDS_CONFIG_KEYS.indexOf(resolvedSlug) !== -1) {
|
|
2199
|
+
return {};
|
|
2200
|
+
}
|
|
2201
|
+
const boardConfig = config.boards[resolvedSlug];
|
|
2202
|
+
return typeof boardConfig === "object" && boardConfig !== null && !Array.isArray(boardConfig)
|
|
2203
|
+
? { ...boardConfig }
|
|
2204
|
+
: {};
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Layer a board's own front matter options over the workspace options
|
|
2209
|
+
* @param {string} resolvedSlug The resolved board slug
|
|
2210
|
+
* @param {object} ownOptions The options taken from the board file's front matter
|
|
2211
|
+
* @return {Promise<object>} The resolved options
|
|
2212
|
+
*/
|
|
2213
|
+
async resolveBoardOptions(resolvedSlug, ownOptions) {
|
|
2214
|
+
// The main board is where the workspace options live, so there is nothing to layer: this is
|
|
2215
|
+
// exactly the behaviour Kanbn has always had
|
|
2216
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
2217
|
+
const config = await this.getConfig();
|
|
2218
|
+
return config !== null ? { ...ownOptions, ...config } : { ...ownOptions };
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// Workspace-scoped keys in a secondary board's front matter are ignored - one task file has to
|
|
2222
|
+
// parse identically for every board that references it. validate reports them
|
|
2223
|
+
return {
|
|
2224
|
+
...(await this.getInheritedBoardOptions()),
|
|
2225
|
+
...(await this.getBoardConfig(resolvedSlug)),
|
|
2226
|
+
...omitKeys(ownOptions, WORKSPACE_SCOPED_OPTIONS),
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* Work out which options belong in a secondary board's own front matter, so that options inherited
|
|
2232
|
+
* from the workspace aren't copied into every board file the first time it is saved
|
|
2233
|
+
* @param {string} resolvedSlug The resolved board slug
|
|
2234
|
+
* @param {object} indexData The board data being saved
|
|
2235
|
+
* @return {Promise<object>} The options to write to the board file's front matter
|
|
2236
|
+
*/
|
|
2237
|
+
async getOwnBoardOptions(resolvedSlug, indexData) {
|
|
2238
|
+
const previousOwnOptions = indexData.ownOptions ? { ...indexData.ownOptions } : {};
|
|
2239
|
+
const inheritedOptions = {
|
|
2240
|
+
...(await this.getInheritedBoardOptions()),
|
|
2241
|
+
...(await this.getBoardConfig(resolvedSlug)),
|
|
2242
|
+
};
|
|
2243
|
+
const options = indexData.options || {};
|
|
2244
|
+
const ownOptions = { ...previousOwnOptions };
|
|
2245
|
+
for (const [key, value] of Object.entries(options)) {
|
|
2246
|
+
if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// Keep anything the board already declared, and pick up anything that differs from what the
|
|
2251
|
+
// workspace provides - i.e. anything this operation actually changed
|
|
2252
|
+
if (key in previousOwnOptions || !(key in inheritedOptions) || !sameOptionValue(inheritedOptions[key], value)) {
|
|
2253
|
+
ownOptions[key] = value;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// Drop anything that has been removed from the board's options entirely
|
|
2258
|
+
for (const key of Object.keys(ownOptions)) {
|
|
2259
|
+
if (!(key in options)) {
|
|
2260
|
+
delete ownOptions[key];
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return ownOptions;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Overwrite a board file with the specified data
|
|
2268
|
+
* @param {?string} slug The board slug, or null for the main board
|
|
2269
|
+
* @param {object} indexData Board data to save
|
|
2270
|
+
*/
|
|
2271
|
+
async saveBoard(slug, indexData) {
|
|
2272
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2273
|
+
|
|
2274
|
+
// Apply column sorting if any sorters are defined in options
|
|
2275
|
+
if ("columnSorting" in indexData.options && Object.keys(indexData.options.columnSorting).length) {
|
|
2276
|
+
for (let columnName in indexData.options.columnSorting) {
|
|
2277
|
+
indexData = sortColumnInIndex(
|
|
2278
|
+
indexData,
|
|
2279
|
+
await this.loadAllTrackedTasks(indexData, columnName),
|
|
2280
|
+
columnName,
|
|
2281
|
+
indexData.options.columnSorting[columnName]
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const boardPath = await this.getBoardPath(resolvedSlug);
|
|
2287
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
2288
|
+
|
|
2289
|
+
// If there is a separate config file, save options to this file
|
|
2290
|
+
let ignoreOptions = false;
|
|
2291
|
+
if (await this.configExists()) {
|
|
2292
|
+
await this.saveConfig(indexData.options);
|
|
2293
|
+
ignoreOptions = true;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// The main board owns the workspace options, so any cached copy is now stale
|
|
2297
|
+
this.caches.workspaceOptions = null;
|
|
2298
|
+
await fs.promises.writeFile(boardPath, parseIndex.json2md(indexData, ignoreOptions));
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// A secondary board's options always live in its own front matter, and workspace-scoped options
|
|
2303
|
+
// are never written to the config file as a side effect of a board-local operation
|
|
2304
|
+
const ownOptions = await this.getOwnBoardOptions(resolvedSlug, indexData);
|
|
2305
|
+
await fs.promises.writeFile(
|
|
2306
|
+
boardPath,
|
|
2307
|
+
parseIndex.json2md({ ...indexData, options: ownOptions }, false)
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* Load a board file and parse it to an object
|
|
2313
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
2314
|
+
* @return {Promise<object>} The board object
|
|
2315
|
+
*/
|
|
2316
|
+
async loadBoard(slug = undefined) {
|
|
2317
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2318
|
+
const isMainBoard = resolvedSlug === (await this.getMainBoardSlug());
|
|
2319
|
+
let boardData = "";
|
|
2320
|
+
try {
|
|
2321
|
+
boardData = await fs.promises.readFile(await this.getBoardPath(resolvedSlug), { encoding: "utf-8" });
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
throw new Error(
|
|
2324
|
+
isMainBoard
|
|
2325
|
+
? `Couldn't access index file: ${error.message}`
|
|
2326
|
+
: `Couldn't access board file for board "${resolvedSlug}": ${error.message}`
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
const index = parseIndex.md2json(boardData);
|
|
2330
|
+
|
|
2331
|
+
// Remember which options came from this board's own front matter, so that saving it again doesn't
|
|
2332
|
+
// persist everything it inherited from the workspace
|
|
2333
|
+
setOwnOptions(index, index.options);
|
|
2334
|
+
index.options = await this.resolveBoardOptions(resolvedSlug, index.options);
|
|
2335
|
+
return index;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Overwrite the index file with the specified data
|
|
2340
|
+
* @param {object} indexData Index data to save
|
|
2341
|
+
*/
|
|
2342
|
+
async saveIndex(indexData) {
|
|
2343
|
+
return this.saveBoard(this.boardSlug, indexData);
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* Load the index file and parse it to an object
|
|
2348
|
+
* @return {Promise<object>} The index object
|
|
2349
|
+
*/
|
|
2350
|
+
async loadIndex() {
|
|
2351
|
+
return this.loadBoard(this.boardSlug);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* Overwrite a task file with the specified data
|
|
2356
|
+
* @param {string} path The task path
|
|
2357
|
+
* @param {object} taskData The task data
|
|
2358
|
+
*/
|
|
2359
|
+
async saveTask(path, taskData) {
|
|
2360
|
+
await fs.promises.writeFile(path, parseTask.json2md(taskData));
|
|
1576
2361
|
}
|
|
1577
2362
|
|
|
1578
2363
|
/**
|
|
@@ -1601,11 +2386,262 @@ class Kanbn {
|
|
|
1601
2386
|
const result = [];
|
|
1602
2387
|
const trackedTasks = getTrackedTaskIds(index, columnName);
|
|
1603
2388
|
for (let taskId of trackedTasks) {
|
|
1604
|
-
|
|
2389
|
+
try {
|
|
2390
|
+
result.push(await this.loadTask(taskId));
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
|
|
2393
|
+
// A board referencing a task file that doesn't exist is a broken reference, not a reason to
|
|
2394
|
+
// fail: it happens whenever a board file and a task file arrive in different commits. Skip
|
|
2395
|
+
// it and carry on - findMissingTaskFiles() and validate report it. Anything else (an
|
|
2396
|
+
// unreadable or unparseable file) still throws, because that is a file with contents that
|
|
2397
|
+
// can't be trusted rather than a file that isn't there
|
|
2398
|
+
if (!(await this.taskFileExists(taskId))) {
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
throw error;
|
|
2402
|
+
}
|
|
1605
2403
|
}
|
|
1606
2404
|
return result;
|
|
1607
2405
|
}
|
|
1608
2406
|
|
|
2407
|
+
/**
|
|
2408
|
+
* Find tasks that this board references but which have no task file
|
|
2409
|
+
* @param {?object} [index=null] The index object, or null to load it
|
|
2410
|
+
* @return {Promise<object[]>} A list of {task, column} for each missing task file
|
|
2411
|
+
*/
|
|
2412
|
+
async findMissingTaskFiles(index = null) {
|
|
2413
|
+
if (index === null) {
|
|
2414
|
+
index = await this.getIndex();
|
|
2415
|
+
}
|
|
2416
|
+
const missing = [];
|
|
2417
|
+
for (const [columnName, taskIds] of Object.entries(index.columns)) {
|
|
2418
|
+
for (const taskId of taskIds) {
|
|
2419
|
+
if (!(await this.taskFileExists(taskId))) {
|
|
2420
|
+
missing.push({ task: taskId, column: columnName });
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
return missing;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Get this board's simple tasks - lines in a column that aren't task links. A simple task has a
|
|
2429
|
+
* title and a column and nothing else: no id, no metadata, no dates, and no presence in any of
|
|
2430
|
+
* kanbn's reporting. It can be moved, removed, or promoted into a real task
|
|
2431
|
+
*
|
|
2432
|
+
* When an input string is given, this returns the simple tasks matching it. Real tasks always win,
|
|
2433
|
+
* so callers should resolve a task id first and only fall back to this when nothing matched
|
|
2434
|
+
* @param {?string} [input=null] A title to match, or null for every simple task on this board
|
|
2435
|
+
* @param {?object} [index=null] The index object, or null to load it
|
|
2436
|
+
* @return {Promise<object[]>} The matching simple tasks, each with a column, position and text
|
|
2437
|
+
*/
|
|
2438
|
+
async findSimpleTasks(input = null, index = null) {
|
|
2439
|
+
if (index === null) {
|
|
2440
|
+
index = await this.getIndex();
|
|
2441
|
+
}
|
|
2442
|
+
const simpleTasks = [];
|
|
2443
|
+
for (const [columnName, entries] of Object.entries(index.columnContent || {})) {
|
|
2444
|
+
for (const entry of entries) {
|
|
2445
|
+
if (isSimpleTask(entry)) {
|
|
2446
|
+
simpleTasks.push({ column: columnName, position: entry.position, text: entry.text, raw: entry.raw });
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
if (input === null) {
|
|
2451
|
+
return simpleTasks;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Match on the title exactly first, then ignoring case, then on the slugified title, so that
|
|
2455
|
+
// `kanbn move "Buy milk"` and `kanbn move buy-milk` both find the same line
|
|
2456
|
+
const matchers = [
|
|
2457
|
+
(simpleTask) => simpleTask.text === input,
|
|
2458
|
+
(simpleTask) => simpleTask.text.toLowerCase() === String(input).toLowerCase(),
|
|
2459
|
+
(simpleTask) => utility.getTaskId(simpleTask.text) === utility.getTaskId(String(input)),
|
|
2460
|
+
];
|
|
2461
|
+
for (const matcher of matchers) {
|
|
2462
|
+
const matches = simpleTasks.filter(matcher);
|
|
2463
|
+
if (matches.length) {
|
|
2464
|
+
return matches;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
return [];
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Resolve a string to exactly one simple task on this board, or throw
|
|
2472
|
+
* @param {string} input The title to match
|
|
2473
|
+
* @param {?object} [index=null] The index object, or null to load it
|
|
2474
|
+
* @return {Promise<object>} The matching simple task
|
|
2475
|
+
*/
|
|
2476
|
+
async getSimpleTask(input, index = null) {
|
|
2477
|
+
const matches = await this.findSimpleTasks(input, index);
|
|
2478
|
+
if (!matches.length) {
|
|
2479
|
+
throw new Error(`No simple task found matching "${input}"`);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// Two lines with the same title are two different lines, and picking one of them silently would
|
|
2483
|
+
// eventually move or delete the wrong one
|
|
2484
|
+
if (matches.length > 1) {
|
|
2485
|
+
throw new Error(
|
|
2486
|
+
`"${input}" matches ${matches.length} simple tasks (${matches
|
|
2487
|
+
.map((match) => `"${match.text}" in ${match.column}`)
|
|
2488
|
+
.join(", ")})`
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
return matches[0];
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/**
|
|
2495
|
+
* Move a simple task to another column on this board
|
|
2496
|
+
* @param {string} input The title to match
|
|
2497
|
+
* @param {string} columnName The column to move it to
|
|
2498
|
+
* @param {?number} [position=null] The position in the target column, or the end of it if null
|
|
2499
|
+
* @return {Promise<object>} The simple task that was moved, with the column it came from
|
|
2500
|
+
*/
|
|
2501
|
+
async moveSimpleTask(input, columnName, position = null) {
|
|
2502
|
+
let index = await this.getIndex();
|
|
2503
|
+
if (!(columnName in index.columns)) {
|
|
2504
|
+
throw new Error(`Column "${columnName}" doesn't exist`);
|
|
2505
|
+
}
|
|
2506
|
+
const simpleTask = await this.getSimpleTask(input, index);
|
|
2507
|
+
index = removeColumnContent(index, simpleTask.column, simpleTask.position);
|
|
2508
|
+
index = addColumnContent(index, columnName, { text: simpleTask.text, raw: simpleTask.raw }, position);
|
|
2509
|
+
await this.saveIndex(index);
|
|
2510
|
+
return { ...simpleTask, toColumn: columnName };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Move a simple task from this board onto another one. A simple task is content in a board file
|
|
2515
|
+
* rather than a shared task file, so this moves the line: it leaves this board and joins the other
|
|
2516
|
+
* @param {string} input The title to match
|
|
2517
|
+
* @param {string} targetSlug The board to move it to
|
|
2518
|
+
* @param {?string} [columnName=null] The column on the target board, or its first column if null
|
|
2519
|
+
* @param {?number} [position=null] The position in the target column, or the end of it if null
|
|
2520
|
+
* @return {Promise<object>} The simple task that was moved, with the board and column it went to
|
|
2521
|
+
*/
|
|
2522
|
+
async moveSimpleTaskToBoard(input, targetSlug, columnName = null, position = null) {
|
|
2523
|
+
const target = this.board(targetSlug);
|
|
2524
|
+
const targetBoardSlug = await target.resolveBoardSlug();
|
|
2525
|
+
if (!(await target.initialised())) {
|
|
2526
|
+
throw new Error(`Board "${targetBoardSlug}" doesn't exist`);
|
|
2527
|
+
}
|
|
2528
|
+
if (targetBoardSlug === (await this.resolveBoardSlug())) {
|
|
2529
|
+
throw new Error(`Simple task "${input}" is already on board "${targetBoardSlug}"`);
|
|
2530
|
+
}
|
|
2531
|
+
let index = await this.getIndex();
|
|
2532
|
+
const simpleTask = await this.getSimpleTask(input, index);
|
|
2533
|
+
let targetIndex = await target.getIndex();
|
|
2534
|
+
const targetColumnNames = Object.keys(targetIndex.columns);
|
|
2535
|
+
if (!targetColumnNames.length) {
|
|
2536
|
+
throw new Error(`Board "${targetBoardSlug}" has no columns`);
|
|
2537
|
+
}
|
|
2538
|
+
const targetColumn = columnName === null ? targetColumnNames[0] : columnName;
|
|
2539
|
+
if (!(targetColumn in targetIndex.columns)) {
|
|
2540
|
+
throw new Error(`Column "${targetColumn}" doesn't exist on board "${targetBoardSlug}"`);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// Write the target board first: a line that ends up on both boards is visible and easy to fix,
|
|
2544
|
+
// where a line removed from one board and never added to the other is gone
|
|
2545
|
+
targetIndex = addColumnContent(
|
|
2546
|
+
targetIndex,
|
|
2547
|
+
targetColumn,
|
|
2548
|
+
{ text: simpleTask.text, raw: simpleTask.raw },
|
|
2549
|
+
position
|
|
2550
|
+
);
|
|
2551
|
+
await target.saveIndex(targetIndex);
|
|
2552
|
+
index = removeColumnContent(index, simpleTask.column, simpleTask.position);
|
|
2553
|
+
await this.saveIndex(index);
|
|
2554
|
+
return { ...simpleTask, toBoard: targetBoardSlug, toColumn: targetColumn };
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* Remove a simple task from this board. There is no file to delete and nothing to archive - the
|
|
2559
|
+
* line is the whole of it
|
|
2560
|
+
* @param {string} input The title to match
|
|
2561
|
+
* @return {Promise<object>} The simple task that was removed
|
|
2562
|
+
*/
|
|
2563
|
+
async deleteSimpleTask(input) {
|
|
2564
|
+
let index = await this.getIndex();
|
|
2565
|
+
const simpleTask = await this.getSimpleTask(input, index);
|
|
2566
|
+
index = removeColumnContent(index, simpleTask.column, simpleTask.position);
|
|
2567
|
+
await this.saveIndex(index);
|
|
2568
|
+
return simpleTask;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* Turn a simple task into a real task file, in the column it was already in. The created date is
|
|
2573
|
+
* the moment of promotion: a line carries no history, so there is no earlier date to know
|
|
2574
|
+
* @param {string} input The title to match
|
|
2575
|
+
* @param {?string} [columnName=null] The column to create the task in, or its own column if null
|
|
2576
|
+
* @return {Promise<string>} The id of the task that was created
|
|
2577
|
+
*/
|
|
2578
|
+
async promoteSimpleTask(input, columnName = null) {
|
|
2579
|
+
const index = await this.getIndex();
|
|
2580
|
+
const simpleTask = await this.getSimpleTask(input, index);
|
|
2581
|
+
const targetColumn = columnName === null ? simpleTask.column : columnName;
|
|
2582
|
+
if (!(targetColumn in index.columns)) {
|
|
2583
|
+
throw new Error(`Column "${targetColumn}" doesn't exist`);
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Check for a clash before writing anything, so that a promotion that can't happen doesn't take
|
|
2587
|
+
// the line with it
|
|
2588
|
+
const taskId = utility.getTaskId(simpleTask.text);
|
|
2589
|
+
if (await this.taskFileExists(taskId)) {
|
|
2590
|
+
throw new Error(`A task with id "${taskId}" already exists`);
|
|
2591
|
+
}
|
|
2592
|
+
if (taskInIndex(index, taskId)) {
|
|
2593
|
+
throw new Error(`A task with id "${taskId}" is already in the index`);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// Create the task first: a leftover line beside a real task is reported by validate and is easy
|
|
2597
|
+
// to fix, where a line deleted for a task that was never created is gone
|
|
2598
|
+
await this.createTask({ name: simpleTask.text }, targetColumn);
|
|
2599
|
+
await this.saveIndex(removeColumnContent(await this.getIndex(), simpleTask.column, simpleTask.position));
|
|
2600
|
+
return taskId;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/**
|
|
2604
|
+
* Find lines in this board's columns that aren't task links. These are preserved verbatim and
|
|
2605
|
+
* ignored by every command, but a line that looks like it was meant to be a task link is worth
|
|
2606
|
+
* pointing out - it's the one way a task can silently stop being tracked
|
|
2607
|
+
* @param {?object} [index=null] The index object, or null to load it
|
|
2608
|
+
* @return {Promise<object[]>} A list of warnings, each with a board, column, type and message
|
|
2609
|
+
*/
|
|
2610
|
+
async findColumnContentWarnings(index = null) {
|
|
2611
|
+
if (index === null) {
|
|
2612
|
+
index = await this.getIndex();
|
|
2613
|
+
}
|
|
2614
|
+
const boardSlug = await this.resolveBoardSlug();
|
|
2615
|
+
const warnings = [];
|
|
2616
|
+
for (const [columnName, entries] of Object.entries(index.columnContent || {})) {
|
|
2617
|
+
for (const entry of entries) {
|
|
2618
|
+
|
|
2619
|
+
// A line that starts with link punctuation was probably meant to be a task link and has a
|
|
2620
|
+
// typo in it, which would otherwise look exactly like a task that has vanished from the
|
|
2621
|
+
// board. A checkbox item is an ordinary line, not a broken link
|
|
2622
|
+
let type = "non-task-line";
|
|
2623
|
+
let message = `column "${columnName}" contains a line that isn't a task link: ${entry.raw}`;
|
|
2624
|
+
if (looksLikeMalformedTaskLink(entry.raw)) {
|
|
2625
|
+
type = "malformed-task-link";
|
|
2626
|
+
message = `column "${columnName}" contains a line that looks like a malformed task link: ${entry.raw}`;
|
|
2627
|
+
|
|
2628
|
+
// A line naming a task file that exists is almost certainly a task the user expects to be
|
|
2629
|
+
// tracked, written without the link. It isn't, and this is the only warning that says so
|
|
2630
|
+
} else if (
|
|
2631
|
+
(await this.taskFileExists(entry.text)) ||
|
|
2632
|
+
(await this.taskFileExists(utility.getTaskId(entry.text)))
|
|
2633
|
+
) {
|
|
2634
|
+
type = "untracked-task-line";
|
|
2635
|
+
message =
|
|
2636
|
+
`column "${columnName}" contains a line naming a task file that exists, but it isn't a ` +
|
|
2637
|
+
`link so the task isn't tracked: ${entry.raw}`;
|
|
2638
|
+
}
|
|
2639
|
+
warnings.push({ board: boardSlug, column: columnName, type, text: entry.text, message });
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return warnings;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
1609
2645
|
/**
|
|
1610
2646
|
* Load a task file from the archive and parse it to an object
|
|
1611
2647
|
* @param {string} taskId The task id
|
|
@@ -1648,11 +2684,703 @@ class Kanbn {
|
|
|
1648
2684
|
return await exists(await this.getIndexPath());
|
|
1649
2685
|
}
|
|
1650
2686
|
|
|
2687
|
+
/**
|
|
2688
|
+
* Check if the workspace has been initialised, regardless of which board this instance is scoped to
|
|
2689
|
+
* @return {Promise<boolean>} True if the main board exists
|
|
2690
|
+
*/
|
|
2691
|
+
async workspaceInitialised() {
|
|
2692
|
+
return await exists(await this.getBoardPath(await this.getMainBoardSlug()));
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* Get the slugs that can't be used for a board, because they would collide with the main board, one
|
|
2697
|
+
* of its aliases, or one of the workspace's folders
|
|
2698
|
+
* @return {Promise<string[]>} The reserved board slugs
|
|
2699
|
+
*/
|
|
2700
|
+
async getReservedBoardSlugs() {
|
|
2701
|
+
return [
|
|
2702
|
+
...MAIN_BOARD_ALIASES,
|
|
2703
|
+
DEFAULT_INDEX_FILE_NAME,
|
|
2704
|
+
boardSlugFromFileName(DEFAULT_INDEX_FILE_NAME),
|
|
2705
|
+
await this.getMainBoardSlug(),
|
|
2706
|
+
DEFAULT_TASKS_FOLDER_NAME,
|
|
2707
|
+
DEFAULT_ARCHIVE_FOLDER_NAME,
|
|
2708
|
+
await this.getTaskFolderName(),
|
|
2709
|
+
await this.getArchiveFolderName(),
|
|
2710
|
+
];
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/**
|
|
2714
|
+
* Check that a slug can be used for a new board, throwing a descriptive error if it can't
|
|
2715
|
+
* @param {string} slug The board slug to check
|
|
2716
|
+
* @return {Promise<string>} The validated slug
|
|
2717
|
+
*/
|
|
2718
|
+
async validateBoardSlug(slug) {
|
|
2719
|
+
if (!slug || typeof slug !== "string" || !slug.trim()) {
|
|
2720
|
+
throw new Error("Board slug cannot be empty");
|
|
2721
|
+
}
|
|
2722
|
+
slug = slug.trim();
|
|
2723
|
+
if (slug !== utility.paramCase(slug)) {
|
|
2724
|
+
throw new Error(`Board slug "${slug}" is not valid, try "${utility.paramCase(slug)}"`);
|
|
2725
|
+
}
|
|
2726
|
+
if ((await this.getReservedBoardSlugs()).indexOf(slug) !== -1) {
|
|
2727
|
+
throw new Error(`Board slug "${slug}" is reserved`);
|
|
2728
|
+
}
|
|
2729
|
+
return slug;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* Get the boards config from the config file, i.e. the exclude list and display order
|
|
2734
|
+
* @return {Promise<{exclude: string[], order: string[]}>} The boards config
|
|
2735
|
+
*/
|
|
2736
|
+
async getBoardsConfig() {
|
|
2737
|
+
const config = await this.getConfig();
|
|
2738
|
+
const boards =
|
|
2739
|
+
config !== null && typeof config.boards === "object" && config.boards !== null && !Array.isArray(config.boards)
|
|
2740
|
+
? config.boards
|
|
2741
|
+
: {};
|
|
2742
|
+
return {
|
|
2743
|
+
exclude: (Array.isArray(boards.exclude) ? boards.exclude : []).map(boardSlugFromFileName),
|
|
2744
|
+
order: (Array.isArray(boards.order) ? boards.order : []).map(boardSlugFromFileName),
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* Find all boards in the workspace. Boards are discovered by globbing markdown files directly inside
|
|
2750
|
+
* the main folder, so the task and archive folders are excluded by construction. A file counts as a
|
|
2751
|
+
* board if it parses as an index; anything that doesn't is ignored here and reported by validate
|
|
2752
|
+
* @return {Promise<object[]>} A list of boards, main board first unless an explicit order says otherwise
|
|
2753
|
+
*/
|
|
2754
|
+
async listBoards() {
|
|
2755
|
+
const mainFolder = await this.getMainFolder();
|
|
2756
|
+
if (!(await exists(mainFolder))) {
|
|
2757
|
+
return [];
|
|
2758
|
+
}
|
|
2759
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
2760
|
+
const { exclude, order } = await this.getBoardsConfig();
|
|
2761
|
+
const boardPaths = await glob(`${mainFolder}/*.md`);
|
|
2762
|
+
const boards = [];
|
|
2763
|
+
for (const boardPath of boardPaths) {
|
|
2764
|
+
const slug = boardSlugFromFileName(boardPath);
|
|
2765
|
+
if (exclude.indexOf(slug) !== -1) {
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
let boardData = null;
|
|
2769
|
+
try {
|
|
2770
|
+
boardData = parseIndex.md2json(await fs.promises.readFile(boardPath, { encoding: "utf-8" }));
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
continue;
|
|
2773
|
+
}
|
|
2774
|
+
boards.push({
|
|
2775
|
+
slug,
|
|
2776
|
+
path: boardPath,
|
|
2777
|
+
name: boardData.name,
|
|
2778
|
+
description: boardData.description,
|
|
2779
|
+
main: slug === mainBoardSlug,
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
// Order: anything named in the config order first, in that order, then the main board, then the
|
|
2784
|
+
// rest alphabetically
|
|
2785
|
+
boards.sort((a, b) => {
|
|
2786
|
+
const aOrder = order.indexOf(a.slug), bOrder = order.indexOf(b.slug);
|
|
2787
|
+
if (aOrder !== -1 || bOrder !== -1) {
|
|
2788
|
+
if (aOrder === -1) { return 1; }
|
|
2789
|
+
if (bOrder === -1) { return -1; }
|
|
2790
|
+
return aOrder - bOrder;
|
|
2791
|
+
}
|
|
2792
|
+
if (a.main !== b.main) {
|
|
2793
|
+
return a.main ? -1 : 1;
|
|
2794
|
+
}
|
|
2795
|
+
return a.slug.localeCompare(b.slug);
|
|
2796
|
+
});
|
|
2797
|
+
return boards;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/**
|
|
2801
|
+
* Work out which board a command should target: the --board argument, then the KANBN_BOARD
|
|
2802
|
+
* environment variable, then the defaultBoard option, then the main board
|
|
2803
|
+
* @param {?string} [slug=null] The board slug given on the command line, if any
|
|
2804
|
+
* @return {Promise<?string>} The target board slug, or null for the main board
|
|
2805
|
+
*/
|
|
2806
|
+
async resolveTargetBoard(slug = null) {
|
|
2807
|
+
if (slug !== null && slug !== undefined && String(slug).trim() !== "") {
|
|
2808
|
+
return String(slug).trim();
|
|
2809
|
+
}
|
|
2810
|
+
if (process.env.KANBN_BOARD && process.env.KANBN_BOARD.trim() !== "") {
|
|
2811
|
+
return process.env.KANBN_BOARD.trim();
|
|
2812
|
+
}
|
|
2813
|
+
const workspaceOptions = await this.getWorkspaceOptions();
|
|
2814
|
+
if (workspaceOptions.defaultBoard && String(workspaceOptions.defaultBoard).trim() !== "") {
|
|
2815
|
+
return String(workspaceOptions.defaultBoard).trim();
|
|
2816
|
+
}
|
|
2817
|
+
return null;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/**
|
|
2821
|
+
* Get a Kanbn instance scoped to the board a command's arguments point at
|
|
2822
|
+
* @param {object} [args={}] The parsed command arguments
|
|
2823
|
+
* @return {Promise<Kanbn>} A board-scoped Kanbn instance
|
|
2824
|
+
*/
|
|
2825
|
+
async boardFromArgs(args = {}) {
|
|
2826
|
+
return this.board(await this.resolveTargetBoard(utility.strArg(args.board) || null));
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/**
|
|
2830
|
+
* List boards with the information `kanbn boards` displays: everything listBoards() returns, plus
|
|
2831
|
+
* column and task counts, completion percentage and the file's last modified date
|
|
2832
|
+
* @return {Promise<object[]>} A list of boards with summary information
|
|
2833
|
+
*/
|
|
2834
|
+
async getBoardsSummary() {
|
|
2835
|
+
const boards = await this.listBoards();
|
|
2836
|
+
const result = [];
|
|
2837
|
+
for (const board of boards) {
|
|
2838
|
+
const summary = { ...board, columns: 0, tasks: 0, completed: 0, completedPercentage: 0, modified: null };
|
|
2839
|
+
try {
|
|
2840
|
+
const boardData = await this.loadBoard(board.slug);
|
|
2841
|
+
const tasks = await this.loadAllTrackedTasks(boardData);
|
|
2842
|
+
summary.columns = Object.keys(boardData.columns).length;
|
|
2843
|
+
summary.tasks = tasks.length;
|
|
2844
|
+
summary.completed = tasks.filter((task) => taskCompleted(boardData, task)).length;
|
|
2845
|
+
summary.completedPercentage = summary.tasks
|
|
2846
|
+
? Math.round((summary.completed / summary.tasks) * 100)
|
|
2847
|
+
: 0;
|
|
2848
|
+
} catch (error) {
|
|
2849
|
+
// A board that can't be loaded is still worth listing; validate reports the reason
|
|
2850
|
+
}
|
|
2851
|
+
try {
|
|
2852
|
+
summary.modified = (await fs.promises.stat(board.path)).mtime;
|
|
2853
|
+
} catch (error) {
|
|
2854
|
+
summary.modified = null;
|
|
2855
|
+
}
|
|
2856
|
+
result.push(summary);
|
|
2857
|
+
}
|
|
2858
|
+
return result;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* Get every task that appears on more than one board, with the column it occupies on each
|
|
2863
|
+
* @param {boolean} [allTasks=false] True to include tasks that only appear on one board
|
|
2864
|
+
* @return {Promise<object[]>} A list of tasks and their board membership
|
|
2865
|
+
*/
|
|
2866
|
+
async getCrossBoardTasks(allTasks = false) {
|
|
2867
|
+
const membership = {};
|
|
2868
|
+
for (const board of await this.listBoards()) {
|
|
2869
|
+
let boardData = null;
|
|
2870
|
+
try {
|
|
2871
|
+
boardData = await this.loadBoard(board.slug);
|
|
2872
|
+
} catch (error) {
|
|
2873
|
+
continue;
|
|
2874
|
+
}
|
|
2875
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
2876
|
+
for (const taskId of taskIds) {
|
|
2877
|
+
if (!(taskId in membership)) {
|
|
2878
|
+
membership[taskId] = {};
|
|
2879
|
+
}
|
|
2880
|
+
membership[taskId][board.slug] = columnName;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
return Object.entries(membership)
|
|
2885
|
+
.filter(([, boards]) => allTasks || Object.keys(boards).length > 1)
|
|
2886
|
+
.map(([id, boards]) => ({ id, boards }))
|
|
2887
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* Get the value to record in a history event's `board` key for this instance's board. The main
|
|
2892
|
+
* board records nothing, which is what keeps existing task files valid and unchanged
|
|
2893
|
+
* @return {Promise<?string>} The board slug, or null for the main board
|
|
2894
|
+
*/
|
|
2895
|
+
async historyBoard() {
|
|
2896
|
+
return (await this.isMainBoard()) ? null : await this.resolveBoardSlug();
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/**
|
|
2900
|
+
* Get the action rules that apply to this board
|
|
2901
|
+
*
|
|
2902
|
+
* Rules layer like any other option: a config file value applies workspace-wide, a board's own
|
|
2903
|
+
* front matter applies to that board. `actionsFile` points at a file holding the same list, for
|
|
2904
|
+
* workspaces whose rule sets have outgrown their front matter
|
|
2905
|
+
* @param {?object} [index=null] The index object, or null to load it
|
|
2906
|
+
* @return {Promise<object[]>} The rules
|
|
2907
|
+
*/
|
|
2908
|
+
async getActionRules(index = null) {
|
|
2909
|
+
if (index === null) {
|
|
2910
|
+
index = await this.getIndex();
|
|
2911
|
+
}
|
|
2912
|
+
const options = index.options || {};
|
|
2913
|
+
const inlineActions = "actions" in options && options.actions !== null;
|
|
2914
|
+
const actionsFile = "actionsFile" in options && options.actionsFile ? String(options.actionsFile) : null;
|
|
2915
|
+
|
|
2916
|
+
// One overriding or extending the other would invent a second merge rule for the sake of a case
|
|
2917
|
+
// nobody needs, so having both is an error the author has to resolve
|
|
2918
|
+
if (inlineActions && actionsFile !== null) {
|
|
2919
|
+
throw new Error('"actions" and "actionsFile" can\'t both be set - use one or the other');
|
|
2920
|
+
}
|
|
2921
|
+
if (actionsFile !== null) {
|
|
2922
|
+
const filePath = path.join(await this.getMainFolder(), actionsFile);
|
|
2923
|
+
if (!(await exists(filePath))) {
|
|
2924
|
+
throw new Error(`actionsFile "${actionsFile}" doesn't exist`);
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// A rule set that quietly stops existing is worse than one that fails loudly, so an
|
|
2928
|
+
// unreadable or unparseable file is an error rather than "no rules"
|
|
2929
|
+
return actions.parseActionsFile(await fs.promises.readFile(filePath, { encoding: "utf-8" }), actionsFile);
|
|
2930
|
+
}
|
|
2931
|
+
return actions.normaliseRules(options.actions || []);
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
/**
|
|
2935
|
+
* Check whether actions should run at all
|
|
2936
|
+
* @return {boolean} True if actions are enabled
|
|
2937
|
+
*/
|
|
2938
|
+
actionsAllowed() {
|
|
2939
|
+
if (!this.actionsEnabled) {
|
|
2940
|
+
return false;
|
|
2941
|
+
}
|
|
2942
|
+
const disabled = process.env.KANBN_NO_ACTIONS;
|
|
2943
|
+
return !(disabled && disabled !== "0" && disabled !== "false");
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/**
|
|
2947
|
+
* Get the value to record in a history event's `board` key for another board
|
|
2948
|
+
* @param {string} slug The board slug
|
|
2949
|
+
* @return {Promise<?string>} The board slug, or null for the main board
|
|
2950
|
+
*/
|
|
2951
|
+
async historyBoardFor(slug) {
|
|
2952
|
+
return slug === (await this.getMainBoardSlug()) ? null : slug;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* Run the rules that match an event and fold everything they produce into the in-memory state
|
|
2957
|
+
*
|
|
2958
|
+
* Nothing is written here. Every verb becomes a patch to a task or to the index that the operation
|
|
2959
|
+
* was already going to write, which is what makes a rule that fails leave the workspace alone, and
|
|
2960
|
+
* what makes "actions never fire actions" structural: the writes this produces are performed with
|
|
2961
|
+
* no event assembly at all
|
|
2962
|
+
* @param {object} params The event, the in-memory index and task, and the operation's timestamp
|
|
2963
|
+
* @return {Promise<?object>} The action result, or null if no rules ran
|
|
2964
|
+
*/
|
|
2965
|
+
async runActions({ eventTypes, index, taskId, taskData, payload, date, taskBoards = null }) {
|
|
2966
|
+
this.lastActionWarnings = [];
|
|
2967
|
+
if (!this.actionsAllowed()) {
|
|
2968
|
+
return null;
|
|
2969
|
+
}
|
|
2970
|
+
const boardSlug = await this.resolveBoardSlug();
|
|
2971
|
+
|
|
2972
|
+
// A task on its way to being created hasn't been through the parser yet, so it has no id. The
|
|
2973
|
+
// filter vocabulary and the relation graph are both keyed on it
|
|
2974
|
+
if (!taskData.id) {
|
|
2975
|
+
taskData.id = taskId;
|
|
2976
|
+
}
|
|
2977
|
+
const rules = await this.getActionRules(index);
|
|
2978
|
+
|
|
2979
|
+
// Archiving, restoring and deleting affect every board, so the board a command happened to be
|
|
2980
|
+
// run from is an arbitrary choice of whose rules to fire. A rule elsewhere can opt in to hearing
|
|
2981
|
+
// about them whoever triggered them - which is why this is collected before the early return:
|
|
2982
|
+
// a board with no rules of its own can still be the one an archive was run from
|
|
2983
|
+
let borrowed = [];
|
|
2984
|
+
if (
|
|
2985
|
+
taskBoards !== false &&
|
|
2986
|
+
eventTypes.some((eventType) => actions.WORKSPACE_WIDE_EVENTS.indexOf(eventType) !== -1) &&
|
|
2987
|
+
(await this.listBoards()).length > 1
|
|
2988
|
+
) {
|
|
2989
|
+
borrowed = await this.borrowedActionRules(taskId, boardSlug, eventTypes, taskBoards);
|
|
2990
|
+
}
|
|
2991
|
+
if (!rules.length && !borrowed.length) {
|
|
2992
|
+
return null;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// Configuration errors fail the operation before anything is written. Checking the whole rule
|
|
2996
|
+
// set rather than just the matching rules means a typo is reported the first time any command
|
|
2997
|
+
// runs, not the first time that one rule would have fired
|
|
2998
|
+
if (rules.length) {
|
|
2999
|
+
const context = { columns: Object.keys(index.columns) };
|
|
3000
|
+
if (
|
|
3001
|
+
rules.some(
|
|
3002
|
+
(rule) =>
|
|
3003
|
+
Array.isArray(rule.then) &&
|
|
3004
|
+
rule.then.some((verb) => verb !== null && typeof verb === "object" && "addToBoard" in verb)
|
|
3005
|
+
)
|
|
3006
|
+
) {
|
|
3007
|
+
context.boards = (await this.listBoards()).map((board) => board.slug);
|
|
3008
|
+
}
|
|
3009
|
+
const errors = actions.findRuleErrors(rules, context);
|
|
3010
|
+
if (errors.length) {
|
|
3011
|
+
throw new Error(`Invalid actions:${errors.map((error) => `\n ${error}`).join("")}`);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// This board's rules run first, then the ones that asked to listen from elsewhere
|
|
3016
|
+
const relevant = [...rules.filter((rule) => eventTypes.indexOf(rule.on) !== -1), ...borrowed];
|
|
3017
|
+
if (!relevant.length) {
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
const result = await actions.run({
|
|
3021
|
+
rules: relevant,
|
|
3022
|
+
eventTypes,
|
|
3023
|
+
payload: { ...payload, board: boardSlug, boardSlug, isMainBoard: await this.isMainBoard() },
|
|
3024
|
+
index,
|
|
3025
|
+
task: taskData,
|
|
3026
|
+
date,
|
|
3027
|
+
user: await this.currentUser(),
|
|
3028
|
+
boardSlug,
|
|
3029
|
+
helpers: ACTION_HELPERS,
|
|
3030
|
+
loadTask: async (id) => {
|
|
3031
|
+
try {
|
|
3032
|
+
return await this.loadTask(id);
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
return null;
|
|
3035
|
+
}
|
|
3036
|
+
},
|
|
3037
|
+
loadAllTasks: async () => await this.loadAllTrackedTasks(index)
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
// Appended rather than replaced: collecting the rules can warn too, and those warnings matter
|
|
3041
|
+
this.lastActionWarnings.push(...result.warnings);
|
|
3042
|
+
|
|
3043
|
+
// Board memberships and cross-task moves are recorded in the task's history the same way the
|
|
3044
|
+
// equivalent manual operations are, attributed to the rule rather than to a person
|
|
3045
|
+
for (const add of result.patch.boardAdds) {
|
|
3046
|
+
taskData = appendTaskHistory(
|
|
3047
|
+
taskData,
|
|
3048
|
+
{ date, type: "added", column: add.column },
|
|
3049
|
+
await this.historyBoardFor(add.board),
|
|
3050
|
+
add.author
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
for (const target of result.targets) {
|
|
3054
|
+
for (const add of target.boardAdds) {
|
|
3055
|
+
target.task = appendTaskHistory(
|
|
3056
|
+
target.task,
|
|
3057
|
+
{ date, type: "added", column: add.column },
|
|
3058
|
+
await this.historyBoardFor(add.board),
|
|
3059
|
+
add.author
|
|
3060
|
+
);
|
|
3061
|
+
}
|
|
3062
|
+
if (target.moveTo === null) {
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// A target that isn't on this board has no row in this index to move, and a target already in
|
|
3067
|
+
// the column it would be moved to is a no-op rather than an event
|
|
3068
|
+
if (!taskInIndex(index, target.task.id)) {
|
|
3069
|
+
this.lastActionWarnings.push(
|
|
3070
|
+
`skipped moving "${target.task.id}": it isn't on board "${boardSlug}"`
|
|
3071
|
+
);
|
|
3072
|
+
target.moveTo = null;
|
|
3073
|
+
continue;
|
|
3074
|
+
}
|
|
3075
|
+
const fromColumn = findTaskColumn(index, target.task.id);
|
|
3076
|
+
if (fromColumn === target.moveTo.column) {
|
|
3077
|
+
target.moveTo = null;
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
target.task = appendTaskHistory(
|
|
3081
|
+
target.task,
|
|
3082
|
+
{ date, type: "moved", fromColumn, toColumn: target.moveTo.column },
|
|
3083
|
+
await this.historyBoard(),
|
|
3084
|
+
target.moveTo.author
|
|
3085
|
+
);
|
|
3086
|
+
target.task = setTaskMetadata(target.task, "updated", date);
|
|
3087
|
+
index = removeTaskFromIndex(index, target.task.id);
|
|
3088
|
+
index = addTaskToIndex(index, target.task.id, target.moveTo.column, target.moveTo.position);
|
|
3089
|
+
}
|
|
3090
|
+
result.index = index;
|
|
3091
|
+
result.taskData = taskData;
|
|
3092
|
+
return result;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
/**
|
|
3096
|
+
* Collect the rules on other boards that have opted in to hearing about a workspace-wide event
|
|
3097
|
+
*
|
|
3098
|
+
* Board membership is per-board data and can't conflict, but rule verbs write the single shared
|
|
3099
|
+
* task file, where two boards' rules genuinely can. So the acting board decides what gets written
|
|
3100
|
+
* by default, and a rule elsewhere has to say that it wants to listen
|
|
3101
|
+
* @param {string} taskId The task the event fired for
|
|
3102
|
+
* @param {string} boardSlug The acting board
|
|
3103
|
+
* @param {string[]} eventTypes The events that fired
|
|
3104
|
+
* @param {?string[]} [knownBoards=null] The boards this operation affects, when the index can't say
|
|
3105
|
+
* @return {Promise<object[]>} The rules to run after this board's own
|
|
3106
|
+
*/
|
|
3107
|
+
async borrowedActionRules(taskId, boardSlug, eventTypes, knownBoards = null) {
|
|
3108
|
+
// A task being restored isn't on any board yet, so the caller has to say which boards it is
|
|
3109
|
+
// about to rejoin. Everywhere else the index is the authority
|
|
3110
|
+
const taskBoards = (knownBoards === null
|
|
3111
|
+
? Object.keys(await this.findTaskBoards(taskId))
|
|
3112
|
+
: knownBoards
|
|
3113
|
+
).filter((slug) => slug !== boardSlug);
|
|
3114
|
+
if (!taskBoards.length) {
|
|
3115
|
+
return [];
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Board order is whatever `kanbn boards` shows, so which rule wins when two of them write the
|
|
3119
|
+
// same field is at least stable and inspectable
|
|
3120
|
+
const ordered = (await this.listBoards()).map((board) => board.slug).filter((slug) => taskBoards.indexOf(slug) !== -1);
|
|
3121
|
+
const borrowed = [];
|
|
3122
|
+
for (const slug of ordered) {
|
|
3123
|
+
const otherBoard = this.board(slug);
|
|
3124
|
+
let otherRules = [];
|
|
3125
|
+
try {
|
|
3126
|
+
otherRules = await otherBoard.getActionRules();
|
|
3127
|
+
} catch (error) {
|
|
3128
|
+
this.lastActionWarnings.push(`skipped board "${slug}" rules: ${error.message}`);
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
const listening = otherRules.filter((rule) => rule.anyBoard === true && eventTypes.indexOf(rule.on) !== -1);
|
|
3132
|
+
if (!listening.length) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// Another board's mistake shouldn't fail a command that didn't ask for that board. Its rules
|
|
3137
|
+
// are skipped instead, and kanbn validate --all-boards reports why
|
|
3138
|
+
const errors = actions.findRuleErrors(listening, {
|
|
3139
|
+
columns: Object.keys((await otherBoard.getIndex()).columns)
|
|
3140
|
+
});
|
|
3141
|
+
if (errors.length) {
|
|
3142
|
+
this.lastActionWarnings.push(`skipped board "${slug}" rules: ${errors[0]}`);
|
|
3143
|
+
continue;
|
|
3144
|
+
}
|
|
3145
|
+
borrowed.push(...listening);
|
|
3146
|
+
}
|
|
3147
|
+
return borrowed;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/**
|
|
3151
|
+
* Write the task files and board memberships a rule asked for
|
|
3152
|
+
*
|
|
3153
|
+
* These come after the operation's own writes because they are separate files: the patches were
|
|
3154
|
+
* all computed before anything was written, so a failure here can't leave a half-applied rule
|
|
3155
|
+
* @param {?object} result The action result, or null if no rules ran
|
|
3156
|
+
* @param {string} taskId The id of the task the event fired for
|
|
3157
|
+
*/
|
|
3158
|
+
async completeActions(result, taskId) {
|
|
3159
|
+
if (result === null) {
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
const taskFolder = await this.getTaskFolderPath();
|
|
3163
|
+
for (const target of result.targets) {
|
|
3164
|
+
await this.saveTask(getTaskPath(taskFolder, target.task.id), target.task);
|
|
3165
|
+
}
|
|
3166
|
+
const boardAdds = [
|
|
3167
|
+
...result.patch.boardAdds.map((add) => ({ ...add, taskId })),
|
|
3168
|
+
...result.targets.flatMap((target) => target.boardAdds.map((add) => ({ ...add, taskId: target.task.id })))
|
|
3169
|
+
];
|
|
3170
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
3171
|
+
for (const add of boardAdds) {
|
|
3172
|
+
const slug = await this.resolveBoardSlug(add.board);
|
|
3173
|
+
if (slug === thisBoardSlug) {
|
|
3174
|
+
continue;
|
|
3175
|
+
}
|
|
3176
|
+
if (!(await this.boardExists(slug))) {
|
|
3177
|
+
this.lastActionWarnings.push(`skipped adding "${add.taskId}" to board "${add.board}": no such board`);
|
|
3178
|
+
continue;
|
|
3179
|
+
}
|
|
3180
|
+
const otherBoard = this.board(slug);
|
|
3181
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
3182
|
+
if (!(add.column in otherIndex.columns)) {
|
|
3183
|
+
this.lastActionWarnings.push(
|
|
3184
|
+
`skipped adding "${add.taskId}" to board "${add.board}": no column "${add.column}"`
|
|
3185
|
+
);
|
|
3186
|
+
continue;
|
|
3187
|
+
}
|
|
3188
|
+
if (taskInIndex(otherIndex, add.taskId)) {
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3191
|
+
await otherBoard.saveIndex(addTaskToIndex(otherIndex, add.taskId, add.column));
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* Work out which derived events an operation fired
|
|
3197
|
+
*
|
|
3198
|
+
* `task.started` and `task.completed` aren't operations - they're transitions, computed from the
|
|
3199
|
+
* state before the operation and the state it is about to write, so a rule meaning "when this is
|
|
3200
|
+
* done" doesn't have to duplicate the board's column list. They're computed before any rule runs,
|
|
3201
|
+
* so a rule that sets a completed date doesn't fire task.completed
|
|
3202
|
+
* @param {object} index The index object
|
|
3203
|
+
* @param {?object} before The task as it was, or null if it didn't exist
|
|
3204
|
+
* @param {object} after The task the operation is about to write
|
|
3205
|
+
* @return {string[]} The derived event types that fired
|
|
3206
|
+
*/
|
|
3207
|
+
derivedEvents(index, before, after) {
|
|
3208
|
+
const events = [];
|
|
3209
|
+
if (!taskStarted(index, { ...after, metadata: before === null ? {} : before.metadata }) && taskStarted(index, after)) {
|
|
3210
|
+
events.push("task.started");
|
|
3211
|
+
}
|
|
3212
|
+
if (
|
|
3213
|
+
!taskCompleted(index, { ...after, metadata: before === null ? {} : before.metadata }) &&
|
|
3214
|
+
taskCompleted(index, after)
|
|
3215
|
+
) {
|
|
3216
|
+
events.push("task.completed");
|
|
3217
|
+
}
|
|
3218
|
+
return events;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
/**
|
|
3222
|
+
* Check if a board exists
|
|
3223
|
+
* @param {string} slug The board slug
|
|
3224
|
+
* @return {Promise<boolean>} True if the board file exists
|
|
3225
|
+
*/
|
|
3226
|
+
async boardExists(slug) {
|
|
3227
|
+
return await exists(await this.getBoardPath(slug));
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* Create a new board
|
|
3232
|
+
* @param {string} slug The board slug
|
|
3233
|
+
* @param {object} [options={}] The board's name, description, columns and options
|
|
3234
|
+
* @return {Promise<string>} The created board's slug
|
|
3235
|
+
*/
|
|
3236
|
+
async createBoard(slug, options = {}) {
|
|
3237
|
+
const validatedSlug = await this.validateBoardSlug(slug);
|
|
3238
|
+
if (await this.boardExists(validatedSlug)) {
|
|
3239
|
+
throw new Error(`Board "${validatedSlug}" already exists`);
|
|
3240
|
+
}
|
|
3241
|
+
if (!(await this.workspaceInitialised())) {
|
|
3242
|
+
throw new Error("Not initialised in this folder");
|
|
3243
|
+
}
|
|
3244
|
+
const columns =
|
|
3245
|
+
"columns" in options && options.columns.length ? options.columns : defaultInitialiseOptions.columns;
|
|
3246
|
+
|
|
3247
|
+
// A new board only picks up the default started and completed columns if it actually has columns
|
|
3248
|
+
// by those names - a design board with Ideas/Designing/Signed Off shouldn't silently claim that
|
|
3249
|
+
// "In Progress" started work and "Done" finished it
|
|
3250
|
+
const boardOptions = "options" in options ? { ...options.options } : {};
|
|
3251
|
+
for (const [key, defaultColumns] of Object.entries(defaultInitialiseOptions.options)) {
|
|
3252
|
+
if (key in boardOptions) {
|
|
3253
|
+
continue;
|
|
3254
|
+
}
|
|
3255
|
+
const matchingColumns = defaultColumns.filter((columnName) => columns.indexOf(columnName) !== -1);
|
|
3256
|
+
if (matchingColumns.length) {
|
|
3257
|
+
boardOptions[key] = matchingColumns;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
const board = {
|
|
3262
|
+
name: "name" in options && options.name ? options.name : validatedSlug,
|
|
3263
|
+
description: "description" in options ? options.description : "",
|
|
3264
|
+
options: boardOptions,
|
|
3265
|
+
columns: Object.fromEntries(columns.map((columnName) => [columnName, []])),
|
|
3266
|
+
};
|
|
3267
|
+
|
|
3268
|
+
// Options passed to createBoard are deliberate, so they're written to the board file even when
|
|
3269
|
+
// the workspace happens to provide the same value
|
|
3270
|
+
setOwnOptions(board, boardOptions);
|
|
3271
|
+
await this.saveBoard(validatedSlug, board);
|
|
3272
|
+
return validatedSlug;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
/**
|
|
3276
|
+
* Delete a board file. Tasks referenced only by this board become untracked; their files are left
|
|
3277
|
+
* alone, since boards own membership and tasks are shared
|
|
3278
|
+
* @param {string} slug The board slug
|
|
3279
|
+
* @return {Promise<string[]>} The ids of tasks that are no longer referenced by any board
|
|
3280
|
+
*/
|
|
3281
|
+
async deleteBoard(slug) {
|
|
3282
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
3283
|
+
if (await this.isMainBoard(resolvedSlug)) {
|
|
3284
|
+
throw new Error("The main board cannot be deleted");
|
|
3285
|
+
}
|
|
3286
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
3287
|
+
throw new Error(`Board "${resolvedSlug}" doesn't exist`);
|
|
3288
|
+
}
|
|
3289
|
+
const orphaned = await this.findOrphanedTasks(resolvedSlug);
|
|
3290
|
+
await fs.promises.unlink(await this.getBoardPath(resolvedSlug));
|
|
3291
|
+
return orphaned;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
/**
|
|
3295
|
+
* Find the tasks that would become untracked if a board were deleted, i.e. the tasks it references
|
|
3296
|
+
* that no other board references
|
|
3297
|
+
* @param {string} slug The board slug
|
|
3298
|
+
* @return {Promise<string[]>} The ids of tasks referenced only by this board
|
|
3299
|
+
*/
|
|
3300
|
+
async findOrphanedTasks(slug) {
|
|
3301
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
3302
|
+
const taskIds = [...getTrackedTaskIds(await this.loadBoard(resolvedSlug))];
|
|
3303
|
+
const orphaned = [];
|
|
3304
|
+
for (const taskId of taskIds) {
|
|
3305
|
+
const boards = await this.findTaskBoards(taskId);
|
|
3306
|
+
if (Object.keys(boards).filter((boardSlug) => boardSlug !== resolvedSlug).length === 0) {
|
|
3307
|
+
orphaned.push(taskId);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
return orphaned;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
/**
|
|
3314
|
+
* Rename a board
|
|
3315
|
+
* @param {string} slug The board slug
|
|
3316
|
+
* @param {string} newSlug The new board slug
|
|
3317
|
+
* @param {?string} [newName=null] An optional new display name for the board
|
|
3318
|
+
* @return {Promise<string>} The new slug
|
|
3319
|
+
*/
|
|
3320
|
+
async renameBoard(slug, newSlug, newName = null) {
|
|
3321
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
3322
|
+
if (await this.isMainBoard(resolvedSlug)) {
|
|
3323
|
+
throw new Error("The main board cannot be renamed");
|
|
3324
|
+
}
|
|
3325
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
3326
|
+
throw new Error(`Board "${resolvedSlug}" doesn't exist`);
|
|
3327
|
+
}
|
|
3328
|
+
const validatedSlug = await this.validateBoardSlug(newSlug);
|
|
3329
|
+
if (validatedSlug !== resolvedSlug && (await this.boardExists(validatedSlug))) {
|
|
3330
|
+
throw new Error(`Board "${validatedSlug}" already exists`);
|
|
3331
|
+
}
|
|
3332
|
+
const board = await this.loadBoard(resolvedSlug);
|
|
3333
|
+
if (newName !== null) {
|
|
3334
|
+
board.name = newName;
|
|
3335
|
+
}
|
|
3336
|
+
if (validatedSlug !== resolvedSlug) {
|
|
3337
|
+
await fs.promises.rename(await this.getBoardPath(resolvedSlug), await this.getBoardPath(validatedSlug));
|
|
3338
|
+
}
|
|
3339
|
+
await this.saveBoard(validatedSlug, board);
|
|
3340
|
+
return validatedSlug;
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
/**
|
|
3344
|
+
* Find every board that references a task, and the column it occupies on each
|
|
3345
|
+
* @param {string} taskId The task id
|
|
3346
|
+
* @return {Promise<Record<string, string>>} A map of board slug to column name
|
|
3347
|
+
*/
|
|
3348
|
+
async findTaskBoards(taskId) {
|
|
3349
|
+
const result = {};
|
|
3350
|
+
for (const board of await this.listBoards()) {
|
|
3351
|
+
let boardData = null;
|
|
3352
|
+
try {
|
|
3353
|
+
boardData = await this.loadBoard(board.slug);
|
|
3354
|
+
} catch (error) {
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
const columnName = findTaskColumn(boardData, taskId);
|
|
3358
|
+
if (columnName !== null) {
|
|
3359
|
+
result[board.slug] = columnName;
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
return result;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
/**
|
|
3366
|
+
* Alias for findTaskBoards(), used when displaying a task's board membership
|
|
3367
|
+
* @param {string} taskId The task id
|
|
3368
|
+
* @return {Promise<Record<string, string>>} A map of board slug to column name
|
|
3369
|
+
*/
|
|
3370
|
+
async getTaskBoardColumns(taskId) {
|
|
3371
|
+
return this.findTaskBoards(taskId);
|
|
3372
|
+
}
|
|
3373
|
+
|
|
1651
3374
|
/**
|
|
1652
3375
|
* Initialise a kanbn board in the current working directory
|
|
1653
3376
|
* @param {object} [options={}] Initial columns and other config options
|
|
1654
3377
|
*/
|
|
1655
3378
|
async initialise(options = {}) {
|
|
3379
|
+
// A board-scoped instance initialises its own board rather than the workspace
|
|
3380
|
+
if (!(await this.isMainBoard())) {
|
|
3381
|
+
return this.initialiseBoard(this.boardSlug, options);
|
|
3382
|
+
}
|
|
3383
|
+
|
|
1656
3384
|
// Check if a main folder is defined in an existing config file
|
|
1657
3385
|
const mainFolder = await this.getMainFolder();
|
|
1658
3386
|
|
|
@@ -1703,6 +3431,52 @@ class Kanbn {
|
|
|
1703
3431
|
await this.saveIndex(index);
|
|
1704
3432
|
}
|
|
1705
3433
|
|
|
3434
|
+
/**
|
|
3435
|
+
* Create a secondary board, or update an existing one's name, description, columns and options
|
|
3436
|
+
* @param {string} slug The board slug
|
|
3437
|
+
* @param {object} [options={}] The board's name, description, columns and options
|
|
3438
|
+
* @return {Promise<string>} The board slug
|
|
3439
|
+
*/
|
|
3440
|
+
async initialiseBoard(slug, options = {}) {
|
|
3441
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
3442
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
3443
|
+
return this.createBoard(resolvedSlug, options);
|
|
3444
|
+
}
|
|
3445
|
+
if (Object.keys(options).length === 0) {
|
|
3446
|
+
return resolvedSlug;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// The board already exists, so update it in the same way initialise() updates the main board
|
|
3450
|
+
const board = await this.loadBoard(resolvedSlug);
|
|
3451
|
+
"name" in options && options.name && (board.name = options.name);
|
|
3452
|
+
"description" in options && (board.description = options.description);
|
|
3453
|
+
if ("options" in options) {
|
|
3454
|
+
board.options = Object.assign(board.options, options.options);
|
|
3455
|
+
setOwnOptions(board, { ...board.ownOptions, ...options.options });
|
|
3456
|
+
}
|
|
3457
|
+
"columns" in options &&
|
|
3458
|
+
(board.columns = Object.assign(
|
|
3459
|
+
board.columns,
|
|
3460
|
+
Object.fromEntries(
|
|
3461
|
+
options.columns.map((columnName) => [
|
|
3462
|
+
columnName,
|
|
3463
|
+
columnName in board.columns ? board.columns[columnName] : [],
|
|
3464
|
+
])
|
|
3465
|
+
)
|
|
3466
|
+
));
|
|
3467
|
+
await this.saveBoard(resolvedSlug, board);
|
|
3468
|
+
return resolvedSlug;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/**
|
|
3472
|
+
* Check if a task file exists, regardless of whether any board references it
|
|
3473
|
+
* @param {string} taskId The task id to check
|
|
3474
|
+
* @return {Promise<boolean>} True if the task file exists
|
|
3475
|
+
*/
|
|
3476
|
+
async taskFileExists(taskId) {
|
|
3477
|
+
return exists(getTaskPath(await this.getTaskFolderPath(), removeFileExtension(taskId)));
|
|
3478
|
+
}
|
|
3479
|
+
|
|
1706
3480
|
/**
|
|
1707
3481
|
* Check if a task file exists and is in the index, otherwise throw an error
|
|
1708
3482
|
* @param {string} taskId The task id to check
|
|
@@ -1786,24 +3560,51 @@ class Kanbn {
|
|
|
1786
3560
|
throw new Error(`A task with id "${taskId}" is already in the index`);
|
|
1787
3561
|
}
|
|
1788
3562
|
|
|
3563
|
+
// Stamp every date this call writes with the same timestamp, so that the created date and the
|
|
3564
|
+
// column-linked dates can't disagree by a millisecond
|
|
3565
|
+
const now = new Date();
|
|
3566
|
+
|
|
1789
3567
|
// Set the created date
|
|
1790
|
-
taskData = setTaskMetadata(taskData, "created",
|
|
3568
|
+
taskData = setTaskMetadata(taskData, "created", now);
|
|
1791
3569
|
|
|
1792
3570
|
// Add initial history event
|
|
1793
3571
|
taskData = appendTaskHistory(taskData, {
|
|
3572
|
+
date: now,
|
|
1794
3573
|
type: 'created',
|
|
1795
3574
|
column: columnName,
|
|
1796
3575
|
fromProgress: 0,
|
|
1797
3576
|
toProgress: getTaskMetadata(taskData, 'progress') || 0
|
|
1798
|
-
});
|
|
3577
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
1799
3578
|
|
|
1800
3579
|
// Update task metadata dates
|
|
1801
|
-
|
|
1802
|
-
|
|
3580
|
+
const beforeActions = { ...taskData, metadata: {} };
|
|
3581
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
1803
3582
|
|
|
1804
|
-
// Add the task to the index
|
|
3583
|
+
// Add the task to the index before running rules, so that a rule filtering on the task's column
|
|
3584
|
+
// sees the column it is being created in
|
|
1805
3585
|
index = addTaskToIndex(index, taskId, columnName);
|
|
3586
|
+
|
|
3587
|
+
// Run actions and fold whatever they produce into the writes below
|
|
3588
|
+
const result = await this.runActions({
|
|
3589
|
+
eventTypes: ["task.created", ...this.derivedEvents(index, beforeActions, taskData)],
|
|
3590
|
+
index,
|
|
3591
|
+
taskId,
|
|
3592
|
+
taskData,
|
|
3593
|
+
payload: { column: columnName },
|
|
3594
|
+
date: now
|
|
3595
|
+
});
|
|
3596
|
+
if (result !== null) {
|
|
3597
|
+
({ index, taskData } = result);
|
|
3598
|
+
if (result.patch.moveTo !== null && result.patch.moveTo.column !== columnName) {
|
|
3599
|
+
columnName = result.patch.moveTo.column;
|
|
3600
|
+
index = removeTaskFromIndex(index, taskId);
|
|
3601
|
+
index = addTaskToIndex(index, taskId, columnName, result.patch.moveTo.position);
|
|
3602
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
await this.saveTask(taskPath, taskData);
|
|
1806
3606
|
await this.saveIndex(index);
|
|
3607
|
+
await this.completeActions(result, taskId);
|
|
1807
3608
|
return taskId;
|
|
1808
3609
|
}
|
|
1809
3610
|
|
|
@@ -1839,17 +3640,59 @@ class Kanbn {
|
|
|
1839
3640
|
// Load task data
|
|
1840
3641
|
let taskData = await this.loadTask(taskId);
|
|
1841
3642
|
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
3643
|
+
const now = new Date();
|
|
3644
|
+
|
|
3645
|
+
// Record the task joining this board. Board membership has no other representation in a task
|
|
3646
|
+
// file, so without this event a board's history has no record of the task ever arriving
|
|
3647
|
+
taskData = appendTaskHistory(taskData, {
|
|
3648
|
+
date: now,
|
|
3649
|
+
type: 'added',
|
|
3650
|
+
column: columnName
|
|
3651
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
1842
3652
|
|
|
1843
3653
|
// Update task metadata dates
|
|
1844
|
-
|
|
1845
|
-
|
|
3654
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
3655
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
1846
3656
|
|
|
1847
|
-
// Add the task to the column
|
|
3657
|
+
// Add the task to the column before running rules, so that a rule filtering on the task's
|
|
3658
|
+
// column sees the column it is joining
|
|
1848
3659
|
index = addTaskToIndex(index, taskId, columnName);
|
|
3660
|
+
|
|
3661
|
+
// Run actions and fold whatever they produce into the writes below
|
|
3662
|
+
const result = await this.runActions({
|
|
3663
|
+
eventTypes: ["task.addedToBoard", ...this.derivedEvents(index, beforeActions, taskData)],
|
|
3664
|
+
index,
|
|
3665
|
+
taskId,
|
|
3666
|
+
taskData,
|
|
3667
|
+
payload: { column: columnName, board: await this.resolveBoardSlug() },
|
|
3668
|
+
date: now
|
|
3669
|
+
});
|
|
3670
|
+
if (result !== null) {
|
|
3671
|
+
({ index, taskData } = result);
|
|
3672
|
+
if (result.patch.moveTo !== null && result.patch.moveTo.column !== columnName) {
|
|
3673
|
+
columnName = result.patch.moveTo.column;
|
|
3674
|
+
index = removeTaskFromIndex(index, taskId);
|
|
3675
|
+
index = addTaskToIndex(index, taskId, columnName, result.patch.moveTo.position);
|
|
3676
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
await this.saveTask(taskPath, taskData);
|
|
1849
3680
|
await this.saveIndex(index);
|
|
3681
|
+
await this.completeActions(result, taskId);
|
|
1850
3682
|
return taskId;
|
|
1851
3683
|
}
|
|
1852
3684
|
|
|
3685
|
+
/**
|
|
3686
|
+
* Add an existing task to this board. Boards own membership, so the same task file can be added to
|
|
3687
|
+
* any number of boards, in a different column on each
|
|
3688
|
+
* @param {string} taskId The task id
|
|
3689
|
+
* @param {string} columnName The column to add the task to
|
|
3690
|
+
* @return {Promise<string>} The id of the task that was added
|
|
3691
|
+
*/
|
|
3692
|
+
async addTaskToBoard(taskId, columnName) {
|
|
3693
|
+
return this.addUntrackedTaskToIndex(taskId, columnName);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
1853
3696
|
/**
|
|
1854
3697
|
* Get a list of tracked tasks (i.e. tasks that are listed in the index)
|
|
1855
3698
|
* @param {?string} [columnName=null] The optional column name to filter tasks by
|
|
@@ -1884,8 +3727,69 @@ class Kanbn {
|
|
|
1884
3727
|
const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
|
|
1885
3728
|
const untrackedTasks = new Set(files.map((task) => path.parse(task).name));
|
|
1886
3729
|
|
|
1887
|
-
// Return the set difference
|
|
1888
|
-
return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
|
|
3730
|
+
// Return the set difference
|
|
3731
|
+
return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
/**
|
|
3735
|
+
* Find tasks that no board references at all. "Tracked" is workspace-scoped: a task is tracked if
|
|
3736
|
+
* any board references it, so a task can be missing from this board and still be tracked
|
|
3737
|
+
* @return {Promise<Set<string>>} A set of untracked task ids
|
|
3738
|
+
*/
|
|
3739
|
+
async findWorkspaceUntrackedTasks() {
|
|
3740
|
+
if (!(await this.workspaceInitialised())) {
|
|
3741
|
+
throw new Error("Not initialised in this folder");
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
// Collect the tasks referenced by every board
|
|
3745
|
+
const trackedTasks = new Set();
|
|
3746
|
+
for (const board of await this.listBoards()) {
|
|
3747
|
+
try {
|
|
3748
|
+
for (const taskId of getTrackedTaskIds(await this.loadBoard(board.slug))) {
|
|
3749
|
+
trackedTasks.add(taskId);
|
|
3750
|
+
}
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
continue;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// Get all tasks in the tasks folder and return the set difference
|
|
3757
|
+
const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
|
|
3758
|
+
return new Set(files.map((task) => path.parse(task).name).filter((taskId) => !trackedTasks.has(taskId)));
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
/**
|
|
3762
|
+
* Find tasks that other boards track but this one doesn't - the "what could I pull onto this board"
|
|
3763
|
+
* list
|
|
3764
|
+
* @return {Promise<Record<string, Record<string, string>>>} A map of task id to board slug to column
|
|
3765
|
+
*/
|
|
3766
|
+
async findTasksOnOtherBoards() {
|
|
3767
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
3768
|
+
const trackedHere = getTrackedTaskIds(await this.loadIndex());
|
|
3769
|
+
const result = {};
|
|
3770
|
+
for (const board of await this.listBoards()) {
|
|
3771
|
+
if (board.slug === thisBoardSlug) {
|
|
3772
|
+
continue;
|
|
3773
|
+
}
|
|
3774
|
+
let boardData = null;
|
|
3775
|
+
try {
|
|
3776
|
+
boardData = await this.loadBoard(board.slug);
|
|
3777
|
+
} catch (error) {
|
|
3778
|
+
continue;
|
|
3779
|
+
}
|
|
3780
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
3781
|
+
for (const taskId of taskIds) {
|
|
3782
|
+
if (trackedHere.has(taskId)) {
|
|
3783
|
+
continue;
|
|
3784
|
+
}
|
|
3785
|
+
if (!(taskId in result)) {
|
|
3786
|
+
result[taskId] = {};
|
|
3787
|
+
}
|
|
3788
|
+
result[taskId][board.slug] = columnName;
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
return result;
|
|
1889
3793
|
}
|
|
1890
3794
|
|
|
1891
3795
|
/**
|
|
@@ -1933,18 +3837,57 @@ class Kanbn {
|
|
|
1933
3837
|
throw new Error(`Column "${columnName}" doesn't exist`);
|
|
1934
3838
|
}
|
|
1935
3839
|
|
|
3840
|
+
// Stamp every date this call writes with the same timestamp, so that the updated date and the
|
|
3841
|
+
// history event it records can't disagree by a millisecond
|
|
3842
|
+
const now = new Date();
|
|
3843
|
+
|
|
1936
3844
|
// Set the updated date
|
|
1937
|
-
taskData = setTaskMetadata(taskData, "updated",
|
|
3845
|
+
taskData = setTaskMetadata(taskData, "updated", now);
|
|
1938
3846
|
|
|
1939
3847
|
// Add history for progress changes only
|
|
1940
3848
|
const originalProgress = getTaskMetadata(originalTaskData, 'progress') || 0;
|
|
1941
3849
|
const updatedProgress = getTaskMetadata(taskData, 'progress') || 0;
|
|
1942
3850
|
if (originalProgress !== updatedProgress) {
|
|
1943
3851
|
taskData = appendTaskHistory(taskData, {
|
|
3852
|
+
date: now,
|
|
1944
3853
|
type: 'progress',
|
|
1945
3854
|
fromProgress: originalProgress,
|
|
1946
3855
|
toProgress: updatedProgress
|
|
1947
|
-
});
|
|
3856
|
+
}, null, await this.currentUser());
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// Run actions. A task.updated rule that moves the task redirects the move this call was going
|
|
3860
|
+
// to make, or makes one of its own if the caller didn't ask for a column change
|
|
3861
|
+
const result = await this.runActions({
|
|
3862
|
+
eventTypes: ["task.updated", ...this.derivedEvents(index, originalTaskData, taskData)],
|
|
3863
|
+
index,
|
|
3864
|
+
taskId,
|
|
3865
|
+
taskData,
|
|
3866
|
+
payload: { changedFields: changedFields(originalTaskData, taskData), unsetFields: [...unsetFields] },
|
|
3867
|
+
date: now
|
|
3868
|
+
});
|
|
3869
|
+
let rulePosition = null;
|
|
3870
|
+
if (result !== null) {
|
|
3871
|
+
({ index, taskData } = result);
|
|
3872
|
+
if (result.patch.moveTo !== null) {
|
|
3873
|
+
rulePosition = result.patch.moveTo.position;
|
|
3874
|
+
if (columnName === null) {
|
|
3875
|
+
const fromColumn = findTaskColumn(index, taskId);
|
|
3876
|
+
if (fromColumn !== result.patch.moveTo.column) {
|
|
3877
|
+
taskData = appendTaskHistory(
|
|
3878
|
+
taskData,
|
|
3879
|
+
{ date: now, type: "moved", fromColumn, toColumn: result.patch.moveTo.column },
|
|
3880
|
+
await this.historyBoard(),
|
|
3881
|
+
result.patch.moveTo.author
|
|
3882
|
+
);
|
|
3883
|
+
index = removeTaskFromIndex(index, taskId);
|
|
3884
|
+
index = addTaskToIndex(index, taskId, result.patch.moveTo.column, rulePosition);
|
|
3885
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, result.patch.moveTo.column, now);
|
|
3886
|
+
}
|
|
3887
|
+
} else {
|
|
3888
|
+
columnName = result.patch.moveTo.column;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
1948
3891
|
}
|
|
1949
3892
|
|
|
1950
3893
|
// Save task
|
|
@@ -1952,12 +3895,17 @@ class Kanbn {
|
|
|
1952
3895
|
|
|
1953
3896
|
// Move the task if we're updating the column
|
|
1954
3897
|
if (columnName) {
|
|
1955
|
-
|
|
3898
|
+
// The move is a nested operation that fires its own rules, and reports its own skips. Keep
|
|
3899
|
+
// this call's warnings in front of them rather than letting the move discard them
|
|
3900
|
+
const updateWarnings = [...this.lastActionWarnings];
|
|
3901
|
+
await this.moveTask(taskId, columnName, rulePosition);
|
|
3902
|
+
this.lastActionWarnings = [...updateWarnings, ...this.lastActionWarnings];
|
|
1956
3903
|
|
|
1957
3904
|
// Otherwise save the index
|
|
1958
3905
|
} else {
|
|
1959
3906
|
await this.saveIndex(index);
|
|
1960
3907
|
}
|
|
3908
|
+
await this.completeActions(result, taskId);
|
|
1961
3909
|
|
|
1962
3910
|
// Remove any explicitly unset metadata fields last. This has to happen after the move, because
|
|
1963
3911
|
// moving into a started or completed column stamps the linked date fields - an explicit unset
|
|
@@ -2005,10 +3953,20 @@ class Kanbn {
|
|
|
2005
3953
|
throw new Error(`A task with id "${newTaskId}" already exists`);
|
|
2006
3954
|
}
|
|
2007
3955
|
|
|
2008
|
-
// Check that a task with the new id isn't already indexed
|
|
3956
|
+
// Check that a task with the new id isn't already indexed, on this board or any other - the id
|
|
3957
|
+
// is the file name, so it has to be free everywhere
|
|
2009
3958
|
if (taskInIndex(index, newTaskId)) {
|
|
2010
3959
|
throw new Error(`A task with id "${newTaskId}" is already in the index`);
|
|
2011
3960
|
}
|
|
3961
|
+
const boardsWithNewId = Object.keys(await this.findTaskBoards(newTaskId));
|
|
3962
|
+
if (boardsWithNewId.length) {
|
|
3963
|
+
throw new Error(
|
|
3964
|
+
`A task with id "${newTaskId}" is already on board "${boardsWithNewId[0]}"`
|
|
3965
|
+
);
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
// Note every board that references this task before anything is written
|
|
3969
|
+
const otherBoards = await this.findTaskBoards(taskId);
|
|
2012
3970
|
|
|
2013
3971
|
// Update the task name and updated date
|
|
2014
3972
|
let taskData = await this.loadTask(taskId);
|
|
@@ -2019,7 +3977,17 @@ class Kanbn {
|
|
|
2019
3977
|
// Rename the task file
|
|
2020
3978
|
await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
|
|
2021
3979
|
|
|
2022
|
-
// Update the task id in the index
|
|
3980
|
+
// Update the task id in the index, and in every other board that references it - the task file
|
|
3981
|
+
// has moved, so a board still pointing at the old id would have a broken link
|
|
3982
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
3983
|
+
for (const slug of Object.keys(otherBoards)) {
|
|
3984
|
+
if (slug === thisBoardSlug) {
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
const otherBoard = this.board(slug);
|
|
3988
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
3989
|
+
await otherBoard.saveIndex(renameTaskInIndex(otherIndex, taskId, newTaskId));
|
|
3990
|
+
}
|
|
2023
3991
|
index = renameTaskInIndex(index, taskId, newTaskId);
|
|
2024
3992
|
await this.saveIndex(index);
|
|
2025
3993
|
return newTaskId;
|
|
@@ -2031,9 +3999,10 @@ class Kanbn {
|
|
|
2031
3999
|
* @param {string} columnName The name of the column that the task will be moved to
|
|
2032
4000
|
* @param {?number} [position=null] The position to move the task to within the target column
|
|
2033
4001
|
* @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
|
|
4002
|
+
* @param {boolean} [add=false] Add the task to this board if it isn't on it yet (secondary boards only)
|
|
2034
4003
|
* @return {Promise<string>} The id of the task that was moved
|
|
2035
4004
|
*/
|
|
2036
|
-
async moveTask(taskId, columnName, position = null, relative = false) {
|
|
4005
|
+
async moveTask(taskId, columnName, position = null, relative = false, add = false) {
|
|
2037
4006
|
// Check if this folder has been initialised
|
|
2038
4007
|
if (!(await this.initialised())) {
|
|
2039
4008
|
throw new Error("Not initialised in this folder");
|
|
@@ -2048,7 +4017,18 @@ class Kanbn {
|
|
|
2048
4017
|
// Get index and make sure the task is indexed
|
|
2049
4018
|
let index = await this.loadIndex();
|
|
2050
4019
|
if (!taskInIndex(index, taskId)) {
|
|
2051
|
-
|
|
4020
|
+
|
|
4021
|
+
// Moving a task onto a board it isn't on yet adds it, so that membership doesn't have to be a
|
|
4022
|
+
// separate step. Only on a secondary board: on the main board this stays the error it has
|
|
4023
|
+
// always been
|
|
4024
|
+
if (!add || (await this.isMainBoard())) {
|
|
4025
|
+
throw new Error(`Task "${taskId}" is not in the index`);
|
|
4026
|
+
}
|
|
4027
|
+
if (!(columnName in index.columns)) {
|
|
4028
|
+
throw new Error(`Column "${columnName}" doesn't exist`);
|
|
4029
|
+
}
|
|
4030
|
+
await this.addTaskToBoard(taskId, columnName);
|
|
4031
|
+
return taskId;
|
|
2052
4032
|
}
|
|
2053
4033
|
|
|
2054
4034
|
// Make sure the target column exists
|
|
@@ -2074,12 +4054,12 @@ class Kanbn {
|
|
|
2074
4054
|
type: 'moved',
|
|
2075
4055
|
fromColumn: currentColumnName,
|
|
2076
4056
|
toColumn: columnName
|
|
2077
|
-
});
|
|
4057
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
2078
4058
|
}
|
|
2079
4059
|
|
|
2080
4060
|
// Update task metadata dates
|
|
4061
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
2081
4062
|
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, moveDate);
|
|
2082
|
-
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
2083
4063
|
|
|
2084
4064
|
// If we're moving the task to a new position, calculate the absolute position
|
|
2085
4065
|
const currentPosition = index.columns[currentColumnName].indexOf(taskId);
|
|
@@ -2093,7 +4073,39 @@ class Kanbn {
|
|
|
2093
4073
|
// Remove the task from its current column and add it to the new column
|
|
2094
4074
|
index = removeTaskFromIndex(index, taskId);
|
|
2095
4075
|
index = addTaskToIndex(index, taskId, columnName, position);
|
|
4076
|
+
|
|
4077
|
+
// Run actions. A rule that moves the task changes the destination of the write that was already
|
|
4078
|
+
// about to happen, rather than performing a second move
|
|
4079
|
+
const eventTypes = this.derivedEvents(index, beforeActions, taskData);
|
|
4080
|
+
if (currentColumnName !== columnName) {
|
|
4081
|
+
eventTypes.unshift("task.moved");
|
|
4082
|
+
}
|
|
4083
|
+
const result = await this.runActions({
|
|
4084
|
+
eventTypes,
|
|
4085
|
+
index,
|
|
4086
|
+
taskId,
|
|
4087
|
+
taskData,
|
|
4088
|
+
payload: { fromColumn: currentColumnName, toColumn: columnName },
|
|
4089
|
+
date: moveDate
|
|
4090
|
+
});
|
|
4091
|
+
if (result !== null) {
|
|
4092
|
+
({ index, taskData } = result);
|
|
4093
|
+
if (result.patch.moveTo !== null && result.patch.moveTo.column !== columnName) {
|
|
4094
|
+
const ruleColumn = result.patch.moveTo.column;
|
|
4095
|
+
taskData = appendTaskHistory(
|
|
4096
|
+
taskData,
|
|
4097
|
+
{ date: moveDate, type: "moved", fromColumn: columnName, toColumn: ruleColumn },
|
|
4098
|
+
await this.historyBoard(),
|
|
4099
|
+
result.patch.moveTo.author
|
|
4100
|
+
);
|
|
4101
|
+
index = removeTaskFromIndex(index, taskId);
|
|
4102
|
+
index = addTaskToIndex(index, taskId, ruleColumn, result.patch.moveTo.position);
|
|
4103
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, ruleColumn, moveDate);
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
2096
4107
|
await this.saveIndex(index);
|
|
4108
|
+
await this.completeActions(result, taskId);
|
|
2097
4109
|
return taskId;
|
|
2098
4110
|
}
|
|
2099
4111
|
|
|
@@ -2101,9 +4113,10 @@ class Kanbn {
|
|
|
2101
4113
|
* Remove a task from the index and optionally delete the task file as well
|
|
2102
4114
|
* @param {string} taskId The id of the task to remove
|
|
2103
4115
|
* @param {boolean} [removeFile=false] True if the task file should be removed
|
|
4116
|
+
* @param {boolean} [allBoards=false] True to remove the task from every board that references it
|
|
2104
4117
|
* @return {Promise<string>} The id of the task that was deleted
|
|
2105
4118
|
*/
|
|
2106
|
-
async deleteTask(taskId, removeFile = false) {
|
|
4119
|
+
async deleteTask(taskId, removeFile = false, allBoards = false) {
|
|
2107
4120
|
// Check if this folder has been initialised
|
|
2108
4121
|
if (!(await this.initialised())) {
|
|
2109
4122
|
throw new Error("Not initialised in this folder");
|
|
@@ -2116,14 +4129,67 @@ class Kanbn {
|
|
|
2116
4129
|
throw new Error(`Task "${taskId}" is not in the index`);
|
|
2117
4130
|
}
|
|
2118
4131
|
|
|
4132
|
+
// A task file is shared, so deleting it out from under another board would break that board.
|
|
4133
|
+
// Removing the task from this board only is always safe, and is what happens without --all-boards
|
|
4134
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
4135
|
+
const otherBoards = Object.keys(await this.findTaskBoards(taskId)).filter((slug) => slug !== thisBoardSlug);
|
|
4136
|
+
if (removeFile && otherBoards.length && !allBoards) {
|
|
4137
|
+
throw new Error(
|
|
4138
|
+
`Task "${taskId}" is on ${otherBoards.length} other ${otherBoards.length === 1 ? "board" : "boards"} ` +
|
|
4139
|
+
`(${otherBoards.join(", ")})`
|
|
4140
|
+
);
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
// Run actions before anything is removed, so that a rule's targets are selected from the task's
|
|
4144
|
+
// relations while the task is still there to have them
|
|
4145
|
+
const columnName = findTaskColumn(index, taskId);
|
|
4146
|
+
const taskFileExists = await exists(getTaskPath(await this.getTaskFolderPath(), taskId));
|
|
4147
|
+
let result = null;
|
|
4148
|
+
if (taskFileExists) {
|
|
4149
|
+
result = await this.runActions({
|
|
4150
|
+
eventTypes: ["task.deleted"],
|
|
4151
|
+
index,
|
|
4152
|
+
taskId,
|
|
4153
|
+
taskData: await this.loadTask(taskId),
|
|
4154
|
+
payload: { fromColumn: columnName, removeFile, allBoards },
|
|
4155
|
+
|
|
4156
|
+
// Removing a task from one board isn't a workspace-wide operation, whatever the event is
|
|
4157
|
+
// called, so the other boards' rules have nothing to hear about
|
|
4158
|
+
taskBoards: allBoards ? null : false,
|
|
4159
|
+
date: new Date()
|
|
4160
|
+
});
|
|
4161
|
+
if (result !== null) {
|
|
4162
|
+
index = result.index;
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
|
|
2119
4166
|
// Remove the task from whichever column it's in
|
|
2120
4167
|
index = removeTaskFromIndex(index, taskId);
|
|
2121
4168
|
|
|
2122
|
-
//
|
|
4169
|
+
// Record the task leaving this board, but only when the task file survives - a removed event on
|
|
4170
|
+
// a file that's about to be deleted records nothing anyone can read
|
|
4171
|
+
if (!removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
|
|
4172
|
+
let taskData = await this.loadTask(taskId);
|
|
4173
|
+
taskData = appendTaskHistory(taskData, {
|
|
4174
|
+
type: 'removed',
|
|
4175
|
+
fromColumn: columnName
|
|
4176
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
4177
|
+
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
// Optionally remove the task from every other board, and remove the task file as well
|
|
4181
|
+
if (allBoards) {
|
|
4182
|
+
for (const slug of otherBoards) {
|
|
4183
|
+
const otherBoard = this.board(slug);
|
|
4184
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
4185
|
+
await otherBoard.saveIndex(removeTaskFromIndex(otherIndex, taskId));
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
2123
4188
|
if (removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
|
|
2124
4189
|
await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
|
|
2125
4190
|
}
|
|
2126
4191
|
await this.saveIndex(index);
|
|
4192
|
+
await this.completeActions(result, taskId);
|
|
2127
4193
|
return taskId;
|
|
2128
4194
|
}
|
|
2129
4195
|
|
|
@@ -2149,6 +4215,38 @@ class Kanbn {
|
|
|
2149
4215
|
});
|
|
2150
4216
|
}
|
|
2151
4217
|
|
|
4218
|
+
/**
|
|
4219
|
+
* Search every board in the workspace. A task on several boards appears once, annotated with the
|
|
4220
|
+
* board and column it occupies on each
|
|
4221
|
+
* @param {object} [filters={}] The filters to apply
|
|
4222
|
+
* @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
|
|
4223
|
+
* @return {Promise<object[]|string[]>} A list of matching tasks, or task ids
|
|
4224
|
+
*/
|
|
4225
|
+
async searchAllBoards(filters = {}, quiet = false) {
|
|
4226
|
+
if (!(await this.workspaceInitialised())) {
|
|
4227
|
+
throw new Error("Not initialised in this folder");
|
|
4228
|
+
}
|
|
4229
|
+
const results = new Map();
|
|
4230
|
+
for (const board of await this.listBoards()) {
|
|
4231
|
+
let matches = [];
|
|
4232
|
+
try {
|
|
4233
|
+
matches = await this.board(board.slug).search(filters, quiet);
|
|
4234
|
+
} catch (error) {
|
|
4235
|
+
continue;
|
|
4236
|
+
}
|
|
4237
|
+
for (const match of matches) {
|
|
4238
|
+
const taskId = quiet ? match : match.id;
|
|
4239
|
+
if (!results.has(taskId)) {
|
|
4240
|
+
results.set(taskId, quiet ? taskId : { ...match, boards: {} });
|
|
4241
|
+
}
|
|
4242
|
+
if (!quiet) {
|
|
4243
|
+
results.get(taskId).boards[board.slug] = match.column;
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
return [...results.values()];
|
|
4248
|
+
}
|
|
4249
|
+
|
|
2152
4250
|
/**
|
|
2153
4251
|
* Output project status information
|
|
2154
4252
|
* @param {boolean} [quiet=false] Output full or partial status information
|
|
@@ -2175,14 +4273,30 @@ class Kanbn {
|
|
|
2175
4273
|
name: index.name,
|
|
2176
4274
|
};
|
|
2177
4275
|
|
|
4276
|
+
// Name the board this status applies to, but only once there is more than one board - a
|
|
4277
|
+
// single-board workspace produces exactly the output it always has
|
|
4278
|
+
const boards = await this.listBoards();
|
|
4279
|
+
if (boards.length > 1) {
|
|
4280
|
+
result.board = await this.resolveBoardSlug();
|
|
4281
|
+
}
|
|
4282
|
+
|
|
2178
4283
|
// Get un-tracked tasks if required
|
|
2179
4284
|
if (untracked) {
|
|
2180
|
-
|
|
4285
|
+
|
|
4286
|
+
// Untracked means "on no board at all", which is workspace-scoped
|
|
4287
|
+
result.untrackedTasks = [...(await this.findWorkspaceUntrackedTasks())].map((taskId) => `${taskId}.md`);
|
|
2181
4288
|
|
|
2182
4289
|
// If output is quiet, output a list of untracked task filenames
|
|
2183
4290
|
if (quiet) {
|
|
2184
4291
|
return result.untrackedTasks;
|
|
2185
4292
|
}
|
|
4293
|
+
|
|
4294
|
+
// Tasks that are tracked, but not on this board. This is the useful half of the answer: it's
|
|
4295
|
+
// the list of work that could be pulled onto this board
|
|
4296
|
+
const tasksOnOtherBoards = await this.findTasksOnOtherBoards();
|
|
4297
|
+
if (Object.keys(tasksOnOtherBoards).length) {
|
|
4298
|
+
result.tasksOnOtherBoards = tasksOnOtherBoards;
|
|
4299
|
+
}
|
|
2186
4300
|
}
|
|
2187
4301
|
|
|
2188
4302
|
// Get basic project status information
|
|
@@ -2418,6 +4532,25 @@ class Kanbn {
|
|
|
2418
4532
|
return errors;
|
|
2419
4533
|
}
|
|
2420
4534
|
|
|
4535
|
+
// Action rules are configuration: a rule that names an unknown event or verb is wrong in the
|
|
4536
|
+
// file, and an operation that meets one fails before writing anything, so it belongs here
|
|
4537
|
+
// rather than in the warnings
|
|
4538
|
+
try {
|
|
4539
|
+
const rules = await this.getActionRules(index);
|
|
4540
|
+
const context = { columns: Object.keys(index.columns) };
|
|
4541
|
+
if (await this.workspaceInitialised()) {
|
|
4542
|
+
context.boards = (await this.listBoards()).map((board) => board.slug);
|
|
4543
|
+
}
|
|
4544
|
+
for (const error of actions.findRuleErrors(rules, context)) {
|
|
4545
|
+
errors.push({ task: null, errors: `actions: ${error}` });
|
|
4546
|
+
}
|
|
4547
|
+
} catch (error) {
|
|
4548
|
+
errors.push({ task: null, errors: `actions: ${error.message}` });
|
|
4549
|
+
}
|
|
4550
|
+
if (errors.length) {
|
|
4551
|
+
return errors;
|
|
4552
|
+
}
|
|
4553
|
+
|
|
2421
4554
|
// Load & parse tasks
|
|
2422
4555
|
const trackedTasks = getTrackedTaskIds(index);
|
|
2423
4556
|
for (let taskId of trackedTasks) {
|
|
@@ -2443,6 +4576,350 @@ class Kanbn {
|
|
|
2443
4576
|
return true;
|
|
2444
4577
|
}
|
|
2445
4578
|
|
|
4579
|
+
/**
|
|
4580
|
+
* Validate every board in the workspace, and the tasks each of them references
|
|
4581
|
+
* @param {boolean} [save=false] Re-save each board and its tasks
|
|
4582
|
+
* @return {Promise<object[]|boolean>} A list of errors, or true if there were none
|
|
4583
|
+
*/
|
|
4584
|
+
async validateAllBoards(save = false) {
|
|
4585
|
+
if (!(await this.workspaceInitialised())) {
|
|
4586
|
+
throw new Error("Not initialised in this folder");
|
|
4587
|
+
}
|
|
4588
|
+
const errors = [];
|
|
4589
|
+
for (const board of await this.listBoards()) {
|
|
4590
|
+
const result = await this.board(board.slug).validate(save);
|
|
4591
|
+
if (result !== true) {
|
|
4592
|
+
errors.push(...result.map((error) => ({ board: board.slug, ...error })));
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
return errors.length ? errors : true;
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
/**
|
|
4599
|
+
* Check a workspace for multi-board problems. These are reported rather than thrown: every one of
|
|
4600
|
+
* them is a workspace that still works, just not the way its author probably meant it to
|
|
4601
|
+
* @return {Promise<object[]>} A list of warnings, each with a type, a message and the board it applies to
|
|
4602
|
+
*/
|
|
4603
|
+
async findBoardWarnings() {
|
|
4604
|
+
if (!(await this.workspaceInitialised())) {
|
|
4605
|
+
throw new Error("Not initialised in this folder");
|
|
4606
|
+
}
|
|
4607
|
+
const warnings = [];
|
|
4608
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
4609
|
+
const boards = await this.listBoards();
|
|
4610
|
+
const boardSlugs = new Set(boards.map((board) => board.slug));
|
|
4611
|
+
const { exclude } = await this.getBoardsConfig();
|
|
4612
|
+
|
|
4613
|
+
// Markdown files beside the boards that don't parse as one. These are ignored during ordinary
|
|
4614
|
+
// commands, which is the right default, but the user should be able to find out why
|
|
4615
|
+
const mainFolder = await this.getMainFolder();
|
|
4616
|
+
for (const filePath of await glob(`${mainFolder}/*.md`)) {
|
|
4617
|
+
const slug = boardSlugFromFileName(filePath);
|
|
4618
|
+
if (boardSlugs.has(slug)) {
|
|
4619
|
+
continue;
|
|
4620
|
+
}
|
|
4621
|
+
if (exclude.indexOf(slug) !== -1) {
|
|
4622
|
+
warnings.push({
|
|
4623
|
+
board: slug,
|
|
4624
|
+
type: "excluded-board",
|
|
4625
|
+
message: `"${path.basename(filePath)}" is excluded by the boards.exclude config and isn't treated as a board`
|
|
4626
|
+
});
|
|
4627
|
+
continue;
|
|
4628
|
+
}
|
|
4629
|
+
try {
|
|
4630
|
+
parseIndex.md2json(await fs.promises.readFile(filePath, { encoding: "utf-8" }));
|
|
4631
|
+
} catch (error) {
|
|
4632
|
+
warnings.push({
|
|
4633
|
+
board: slug,
|
|
4634
|
+
type: "unparseable-board",
|
|
4635
|
+
message: `"${path.basename(filePath)}" doesn't parse as a board and is being ignored: ${error.message}`
|
|
4636
|
+
});
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
|
|
4640
|
+
const stampingBoards = [];
|
|
4641
|
+
const knownBoards = new Set(boardSlugs);
|
|
4642
|
+
const taskBoardColumns = {};
|
|
4643
|
+
for (const board of boards) {
|
|
4644
|
+
let boardData = null;
|
|
4645
|
+
try {
|
|
4646
|
+
boardData = await this.loadBoard(board.slug);
|
|
4647
|
+
} catch (error) {
|
|
4648
|
+
continue;
|
|
4649
|
+
}
|
|
4650
|
+
|
|
4651
|
+
// Workspace-scoped options in a secondary board's front matter are ignored, and silently
|
|
4652
|
+
// ignoring them would leave the user wondering why they had no effect
|
|
4653
|
+
if (board.slug !== mainBoardSlug) {
|
|
4654
|
+
const rawOptions = boardData.ownOptions || {};
|
|
4655
|
+
for (const key of Object.keys(rawOptions)) {
|
|
4656
|
+
if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
|
|
4657
|
+
warnings.push({
|
|
4658
|
+
board: board.slug,
|
|
4659
|
+
type: "workspace-scoped-option",
|
|
4660
|
+
message: `"${key}" is a workspace-scoped option and is ignored in a board file`
|
|
4661
|
+
});
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
// More than one board writing the same shared completed field means whichever board is touched
|
|
4667
|
+
// first silently owns that date for the whole workspace
|
|
4668
|
+
if ("completedColumns" in boardData.options && boardData.options.completedColumns.length) {
|
|
4669
|
+
stampingBoards.push({ slug: board.slug, field: getCompletedField(boardData) });
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
// Sprints are assumed to be in order: the last one is taken as the current sprint, and each
|
|
4673
|
+
// one runs until the next one starts
|
|
4674
|
+
const sprints = "sprints" in boardData.options ? boardData.options.sprints : [];
|
|
4675
|
+
for (let i = 1; i < sprints.length; i++) {
|
|
4676
|
+
if (new Date(sprints[i].start) < new Date(sprints[i - 1].start)) {
|
|
4677
|
+
warnings.push({
|
|
4678
|
+
board: board.slug,
|
|
4679
|
+
type: "sprints-out-of-order",
|
|
4680
|
+
message: `sprint "${sprints[i].name}" starts before "${sprints[i - 1].name}"`
|
|
4681
|
+
});
|
|
4682
|
+
break;
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
// A task can only be in one column per board
|
|
4687
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
4688
|
+
for (const taskId of taskIds) {
|
|
4689
|
+
if (!(taskId in taskBoardColumns)) {
|
|
4690
|
+
taskBoardColumns[taskId] = {};
|
|
4691
|
+
}
|
|
4692
|
+
if (board.slug in taskBoardColumns[taskId]) {
|
|
4693
|
+
warnings.push({
|
|
4694
|
+
board: board.slug,
|
|
4695
|
+
type: "duplicate-task",
|
|
4696
|
+
message: `task "${taskId}" appears in both "${taskBoardColumns[taskId][board.slug]}" and "${columnName}"`
|
|
4697
|
+
});
|
|
4698
|
+
} else {
|
|
4699
|
+
taskBoardColumns[taskId][board.slug] = columnName;
|
|
4700
|
+
}
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4705
|
+
// Several boards stamping the same shared field
|
|
4706
|
+
const fields = {};
|
|
4707
|
+
for (const { slug, field } of stampingBoards) {
|
|
4708
|
+
if (!(field in fields)) {
|
|
4709
|
+
fields[field] = [];
|
|
4710
|
+
}
|
|
4711
|
+
fields[field].push(slug);
|
|
4712
|
+
}
|
|
4713
|
+
for (const [field, slugs] of Object.entries(fields)) {
|
|
4714
|
+
if (slugs.length > 1) {
|
|
4715
|
+
warnings.push({
|
|
4716
|
+
board: null,
|
|
4717
|
+
type: "multiple-stamping-authorities",
|
|
4718
|
+
message:
|
|
4719
|
+
`boards ${slugs.map((slug) => `"${slug}"`).join(", ")} all stamp "${field}" - ` +
|
|
4720
|
+
'whichever is touched first owns that date for every board'
|
|
4721
|
+
});
|
|
4722
|
+
}
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
// Tasks referenced by a board with no file behind them
|
|
4726
|
+
for (const [taskId, taskBoards] of Object.entries(taskBoardColumns)) {
|
|
4727
|
+
if (!(await this.taskFileExists(taskId))) {
|
|
4728
|
+
warnings.push({
|
|
4729
|
+
board: Object.keys(taskBoards)[0],
|
|
4730
|
+
type: "missing-task-file",
|
|
4731
|
+
message: `task "${taskId}" is referenced by ${Object.keys(taskBoards).join(", ")} but has no file`
|
|
4732
|
+
});
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
// Tasks that no board references at all
|
|
4737
|
+
for (const taskId of await this.findWorkspaceUntrackedTasks()) {
|
|
4738
|
+
warnings.push({
|
|
4739
|
+
board: null,
|
|
4740
|
+
type: "untracked-task",
|
|
4741
|
+
message: `task "${taskId}" isn't on any board`
|
|
4742
|
+
});
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
// History events naming a board that has been deleted. These never match a board during replay,
|
|
4746
|
+
// so they're harmless - but they're the only trace a deleted board leaves behind
|
|
4747
|
+
const reportedBoards = new Set();
|
|
4748
|
+
for (const taskId of Object.keys(taskBoardColumns)) {
|
|
4749
|
+
let taskData = null;
|
|
4750
|
+
try {
|
|
4751
|
+
taskData = await this.loadTask(taskId);
|
|
4752
|
+
} catch (error) {
|
|
4753
|
+
continue;
|
|
4754
|
+
}
|
|
4755
|
+
for (const historyEvent of taskData.history || []) {
|
|
4756
|
+
if (historyEvent.board && !knownBoards.has(historyEvent.board) && !reportedBoards.has(historyEvent.board)) {
|
|
4757
|
+
reportedBoards.add(historyEvent.board);
|
|
4758
|
+
warnings.push({
|
|
4759
|
+
board: historyEvent.board,
|
|
4760
|
+
type: "unknown-board-in-history",
|
|
4761
|
+
message: `history events name board "${historyEvent.board}", which doesn't exist`
|
|
4762
|
+
});
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
return warnings;
|
|
4767
|
+
}
|
|
4768
|
+
|
|
4769
|
+
/**
|
|
4770
|
+
* Collect every distinct value used in an `assigned` field or a comment `author` across the
|
|
4771
|
+
* workspace's task files, along with where each one is used
|
|
4772
|
+
*
|
|
4773
|
+
* Tasks are workspace-scoped - a task file can be referenced by several boards - so this reads the
|
|
4774
|
+
* task folder rather than any one board. Task files that don't parse are skipped: this is a
|
|
4775
|
+
* reporting helper, and `kanbn validate` is where a broken file gets reported
|
|
4776
|
+
* @return {Promise<Map<string, object>>} A map of value to usage entry
|
|
4777
|
+
*/
|
|
4778
|
+
async collectContributorValues() {
|
|
4779
|
+
if (!(await this.workspaceInitialised())) {
|
|
4780
|
+
throw new Error("Not initialised in this folder");
|
|
4781
|
+
}
|
|
4782
|
+
const values = new Map();
|
|
4783
|
+
const record = (value, taskId, field) => {
|
|
4784
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
const key = value.trim();
|
|
4788
|
+
if (!values.has(key)) {
|
|
4789
|
+
values.set(key, { value: key, assigned: 0, comments: 0, tasks: new Set() });
|
|
4790
|
+
}
|
|
4791
|
+
const entry = values.get(key);
|
|
4792
|
+
entry[field]++;
|
|
4793
|
+
entry.tasks.add(taskId);
|
|
4794
|
+
};
|
|
4795
|
+
for (const taskPath of await glob(`${await this.getTaskFolderPath()}/*.md`)) {
|
|
4796
|
+
const taskId = path.parse(taskPath).name;
|
|
4797
|
+
let taskData = null;
|
|
4798
|
+
try {
|
|
4799
|
+
taskData = await this.loadTask(taskId);
|
|
4800
|
+
} catch (error) {
|
|
4801
|
+
continue;
|
|
4802
|
+
}
|
|
4803
|
+
record(getTaskMetadata(taskData, "assigned"), taskId, "assigned");
|
|
4804
|
+
for (const comment of taskData.comments || []) {
|
|
4805
|
+
record(comment.author, taskId, "comments");
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
return values;
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
/**
|
|
4812
|
+
* Report how the workspace's contributors are actually used, and which names are in use that the
|
|
4813
|
+
* contributors list doesn't know about
|
|
4814
|
+
*
|
|
4815
|
+
* The second half is the point: adopting contributors in a workspace with 200 existing tasks
|
|
4816
|
+
* otherwise means grepping. This is read-only - rewriting "Gordon" to "gordon" across every task
|
|
4817
|
+
* file is a bulk mutation and belongs to its own command, with its own dry run
|
|
4818
|
+
* @return {Promise<{contributors: object[], unknown: object[]}>} Usage per known contributor, and
|
|
4819
|
+
* every value in use that isn't one
|
|
4820
|
+
*/
|
|
4821
|
+
async getContributorUsage() {
|
|
4822
|
+
const contributors = await this.getContributors();
|
|
4823
|
+
const values = await this.collectContributorValues();
|
|
4824
|
+
const usage = contributors.map((contributor) => ({
|
|
4825
|
+
...contributor,
|
|
4826
|
+
assigned: 0,
|
|
4827
|
+
comments: 0,
|
|
4828
|
+
tasks: new Set(),
|
|
4829
|
+
spellings: []
|
|
4830
|
+
}));
|
|
4831
|
+
const unknown = [];
|
|
4832
|
+
for (const entry of [...values.values()].sort((a, b) => a.value.localeCompare(b.value))) {
|
|
4833
|
+
const result = {
|
|
4834
|
+
value: entry.value,
|
|
4835
|
+
assigned: entry.assigned,
|
|
4836
|
+
comments: entry.comments,
|
|
4837
|
+
tasks: [...entry.tasks].sort()
|
|
4838
|
+
};
|
|
4839
|
+
const contributor = matchContributor(contributors, entry.value);
|
|
4840
|
+
if (contributor === null) {
|
|
4841
|
+
unknown.push(result);
|
|
4842
|
+
continue;
|
|
4843
|
+
}
|
|
4844
|
+
const target = usage.find((u) => u.name === contributor.name);
|
|
4845
|
+
target.assigned += entry.assigned;
|
|
4846
|
+
target.comments += entry.comments;
|
|
4847
|
+
|
|
4848
|
+
// Counted as a set, so a task naming the same person twice under two spellings is one task
|
|
4849
|
+
for (const taskId of entry.tasks) {
|
|
4850
|
+
target.tasks.add(taskId);
|
|
4851
|
+
}
|
|
4852
|
+
target.spellings.push(result);
|
|
4853
|
+
}
|
|
4854
|
+
return {
|
|
4855
|
+
contributors: usage.map((contributor) => ({ ...contributor, tasks: contributor.tasks.size })),
|
|
4856
|
+
unknown
|
|
4857
|
+
};
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
/**
|
|
4861
|
+
* Find things about this board's action rules that are legal but probably not what the author meant
|
|
4862
|
+
*
|
|
4863
|
+
* Rules that are wrong in the file are errors, reported by validate(). These are the ones that
|
|
4864
|
+
* work: two rules writing the same field on the same event, or a rule using @me where no user can
|
|
4865
|
+
* be resolved
|
|
4866
|
+
* @return {Promise<object[]>} A list of warnings
|
|
4867
|
+
*/
|
|
4868
|
+
async findActionWarnings() {
|
|
4869
|
+
if (!(await this.initialised())) {
|
|
4870
|
+
throw new Error("Not initialised in this folder");
|
|
4871
|
+
}
|
|
4872
|
+
let rules = [];
|
|
4873
|
+
try {
|
|
4874
|
+
rules = await this.getActionRules();
|
|
4875
|
+
} catch (error) {
|
|
4876
|
+
// A rule set that can't be read is an error rather than a warning, and validate reports it
|
|
4877
|
+
return [];
|
|
4878
|
+
}
|
|
4879
|
+
if (!rules.length) {
|
|
4880
|
+
return [];
|
|
4881
|
+
}
|
|
4882
|
+
return actions.findRuleWarnings(rules, { hasUser: (await this.currentUser()) !== null });
|
|
4883
|
+
}
|
|
4884
|
+
|
|
4885
|
+
/**
|
|
4886
|
+
* Find tasks whose assigned user or comment author isn't a known contributor
|
|
4887
|
+
*
|
|
4888
|
+
* Contributors are advisory, so this is a warning and never an error: `assigned` and `author` stay
|
|
4889
|
+
* free text, and a name that isn't in the list is written, read and filtered exactly as before.
|
|
4890
|
+
* Nothing is reported at all when the workspace declares no contributors
|
|
4891
|
+
* @return {Promise<object[]>} A list of warnings
|
|
4892
|
+
*/
|
|
4893
|
+
async findContributorWarnings() {
|
|
4894
|
+
const contributors = await this.getContributors();
|
|
4895
|
+
if (!contributors.length) {
|
|
4896
|
+
return [];
|
|
4897
|
+
}
|
|
4898
|
+
const warnings = [];
|
|
4899
|
+
for (const entry of [...(await this.collectContributorValues()).values()].sort((a, b) =>
|
|
4900
|
+
a.value.localeCompare(b.value)
|
|
4901
|
+
)) {
|
|
4902
|
+
if (matchContributor(contributors, entry.value) !== null) {
|
|
4903
|
+
continue;
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
// A comment written by a rule is authored by the rule, not by a person, so it is never a
|
|
4907
|
+
// missing contributor
|
|
4908
|
+
if (entry.value === actions.ACTION_AUTHOR_PREFIX || entry.value.startsWith(`${actions.ACTION_AUTHOR_PREFIX}/`)) {
|
|
4909
|
+
continue;
|
|
4910
|
+
}
|
|
4911
|
+
for (const taskId of [...entry.tasks].sort()) {
|
|
4912
|
+
warnings.push({
|
|
4913
|
+
task: taskId,
|
|
4914
|
+
type: "unknown-contributor",
|
|
4915
|
+
value: entry.value,
|
|
4916
|
+
message: `"${entry.value}" isn't a known contributor`
|
|
4917
|
+
});
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
return warnings;
|
|
4921
|
+
}
|
|
4922
|
+
|
|
2446
4923
|
/**
|
|
2447
4924
|
* Find tasks whose started/completed dates disagree with the column they're in
|
|
2448
4925
|
*
|
|
@@ -2608,19 +5085,28 @@ class Kanbn {
|
|
|
2608
5085
|
throw new Error("Not initialised in this folder");
|
|
2609
5086
|
}
|
|
2610
5087
|
|
|
2611
|
-
//
|
|
5088
|
+
// Sprints are workspace-level unless a board declares its own list. Adding a sprint from a
|
|
5089
|
+
// secondary board that hasn't declared one appends to the workspace list rather than quietly
|
|
5090
|
+
// forking it - a fork would be invisible and would freeze that board out of every future
|
|
5091
|
+
// workspace sprint. Creating a board-local list stays a deliberate front-matter edit
|
|
2612
5092
|
const index = await this.loadIndex();
|
|
2613
|
-
|
|
2614
|
-
|
|
5093
|
+
const isMainBoard = await this.isMainBoard();
|
|
5094
|
+
const boardOwnsSprints = !isMainBoard && "sprints" in (index.ownOptions || {});
|
|
5095
|
+
const target = isMainBoard || boardOwnsSprints ? this : this.board(await this.getMainBoardSlug());
|
|
5096
|
+
const targetIndex = target === this ? index : await target.loadIndex();
|
|
5097
|
+
|
|
5098
|
+
if (!("sprints" in targetIndex.options)) {
|
|
5099
|
+
targetIndex.options.sprints = [];
|
|
2615
5100
|
}
|
|
2616
|
-
const sprintNumber =
|
|
5101
|
+
const sprintNumber = targetIndex.options.sprints.length + 1;
|
|
2617
5102
|
const sprint = {
|
|
2618
5103
|
start: start,
|
|
2619
5104
|
};
|
|
2620
5105
|
|
|
2621
|
-
// If the name is blank, generate a default name
|
|
5106
|
+
// If the name is blank, generate a default name. A board-local list prefixes the board name, so
|
|
5107
|
+
// that two boards generating their own "Sprint 1" can still be told apart
|
|
2622
5108
|
if (!name) {
|
|
2623
|
-
sprint.name = `Sprint ${sprintNumber}`;
|
|
5109
|
+
sprint.name = boardOwnsSprints ? `${index.name} Sprint ${sprintNumber}` : `Sprint ${sprintNumber}`;
|
|
2624
5110
|
} else {
|
|
2625
5111
|
sprint.name = name;
|
|
2626
5112
|
}
|
|
@@ -2630,9 +5116,12 @@ class Kanbn {
|
|
|
2630
5116
|
sprint.description = description;
|
|
2631
5117
|
}
|
|
2632
5118
|
|
|
2633
|
-
// Add sprint and save the
|
|
2634
|
-
|
|
2635
|
-
await
|
|
5119
|
+
// Add sprint and save the board that owns the list
|
|
5120
|
+
targetIndex.options.sprints.push(sprint);
|
|
5121
|
+
await target.saveIndex(targetIndex);
|
|
5122
|
+
|
|
5123
|
+
// Tell the caller which list this went into, so the CLI can say so
|
|
5124
|
+
sprint.board = boardOwnsSprints ? await this.resolveBoardSlug() : null;
|
|
2636
5125
|
return sprint;
|
|
2637
5126
|
}
|
|
2638
5127
|
|
|
@@ -2654,6 +5143,14 @@ class Kanbn {
|
|
|
2654
5143
|
|
|
2655
5144
|
// Get index and tasks
|
|
2656
5145
|
const index = await this.loadIndex();
|
|
5146
|
+
|
|
5147
|
+
// Burndown measures work in flight, which a board with no started columns has no notion of. An
|
|
5148
|
+
// empty chart looks like a bug, so say what's actually missing
|
|
5149
|
+
if (!("startedColumns" in index.options) || !index.options.startedColumns.length) {
|
|
5150
|
+
throw new Error(
|
|
5151
|
+
`Board "${await this.resolveBoardSlug()}" declares no startedColumns, so it has no notion of work in progress`
|
|
5152
|
+
);
|
|
5153
|
+
}
|
|
2657
5154
|
const startedField = getStartedField(index);
|
|
2658
5155
|
const completedField = getCompletedField(index);
|
|
2659
5156
|
const tasks = [...(await this.loadAllTrackedTasks(index))]
|
|
@@ -2800,6 +5297,10 @@ class Kanbn {
|
|
|
2800
5297
|
});
|
|
2801
5298
|
}
|
|
2802
5299
|
|
|
5300
|
+
// Datapoints are placed and annotated from this board's history, so activity on another board
|
|
5301
|
+
// doesn't put markers on this board's chart
|
|
5302
|
+
const historyBoard = await this.historyBoard();
|
|
5303
|
+
|
|
2803
5304
|
// Get workload datapoints for each period
|
|
2804
5305
|
series.forEach((s) => {
|
|
2805
5306
|
s.dataPoints = [
|
|
@@ -2807,23 +5308,23 @@ class Kanbn {
|
|
|
2807
5308
|
x: s.from,
|
|
2808
5309
|
y: getWorkloadAtDate(index, tasks, s.from),
|
|
2809
5310
|
count: countActiveTasksAtDate(index, tasks, s.from),
|
|
2810
|
-
tasks: getTaskEventsAtDate(index, tasks, s.from),
|
|
5311
|
+
tasks: getTaskEventsAtDate(index, tasks, s.from, historyBoard),
|
|
2811
5312
|
},
|
|
2812
5313
|
...tasks
|
|
2813
|
-
.map((task) => getTaskTimelineDates(task, s.from, s.to))
|
|
5314
|
+
.map((task) => getTaskTimelineDates(task, s.from, s.to, historyBoard))
|
|
2814
5315
|
.flat()
|
|
2815
5316
|
.filter((d) => d)
|
|
2816
5317
|
.map((x) => ({
|
|
2817
5318
|
x,
|
|
2818
5319
|
y: getWorkloadAtDate(index, tasks, x),
|
|
2819
5320
|
count: countActiveTasksAtDate(index, tasks, x),
|
|
2820
|
-
tasks: getTaskEventsAtDate(index, tasks, x),
|
|
5321
|
+
tasks: getTaskEventsAtDate(index, tasks, x, historyBoard),
|
|
2821
5322
|
})),
|
|
2822
5323
|
{
|
|
2823
5324
|
x: s.to,
|
|
2824
5325
|
y: getWorkloadAtDate(index, tasks, s.to),
|
|
2825
5326
|
count: countActiveTasksAtDate(index, tasks, s.to),
|
|
2826
|
-
tasks: getTaskEventsAtDate(index, tasks, s.to),
|
|
5327
|
+
tasks: getTaskEventsAtDate(index, tasks, s.to, historyBoard),
|
|
2827
5328
|
},
|
|
2828
5329
|
].sort((a, b) => a.x.getTime() - b.x.getTime());
|
|
2829
5330
|
});
|
|
@@ -2849,6 +5350,9 @@ class Kanbn {
|
|
|
2849
5350
|
? null
|
|
2850
5351
|
: new Set(taskIds.map((taskId) => removeFileExtension(taskId)));
|
|
2851
5352
|
|
|
5353
|
+
// Show this board's history, plus the events that aren't board-scoped at all
|
|
5354
|
+
const historyBoard = await this.historyBoard();
|
|
5355
|
+
|
|
2852
5356
|
// Build date filter periods from sprints and/or dates
|
|
2853
5357
|
const periods = [];
|
|
2854
5358
|
const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
|
|
@@ -2941,6 +5445,7 @@ class Kanbn {
|
|
|
2941
5445
|
|
|
2942
5446
|
const historyEvents = ("history" in task && Array.isArray(task.history) ? task.history : [])
|
|
2943
5447
|
.filter((historyEvent) => historyEvent.date instanceof Date)
|
|
5448
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, historyBoard))
|
|
2944
5449
|
.map((historyEvent) => {
|
|
2945
5450
|
const event = { ...historyEvent };
|
|
2946
5451
|
delete event.date;
|
|
@@ -3032,7 +5537,7 @@ class Kanbn {
|
|
|
3032
5537
|
* @param {string} author The comment author
|
|
3033
5538
|
* @return {Promise<string>} The task id
|
|
3034
5539
|
*/
|
|
3035
|
-
async comment(taskId, text, author) {
|
|
5540
|
+
async comment(taskId, text, author = "") {
|
|
3036
5541
|
// Check if this folder has been initialised
|
|
3037
5542
|
if (!(await this.initialised())) {
|
|
3038
5543
|
throw new Error("Not initialised in this folder");
|
|
@@ -3056,16 +5561,54 @@ class Kanbn {
|
|
|
3056
5561
|
}
|
|
3057
5562
|
|
|
3058
5563
|
// Add the comment
|
|
3059
|
-
|
|
5564
|
+
let taskData = await this.loadTask(taskId);
|
|
3060
5565
|
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
5566
|
+
const now = new Date();
|
|
3061
5567
|
taskData.comments.push({
|
|
3062
5568
|
text,
|
|
3063
|
-
|
|
3064
|
-
|
|
5569
|
+
|
|
5570
|
+
// An author is optional, and a machine with no resolvable user has none. Serialising null
|
|
5571
|
+
// fails schema validation, so the task could never be written back
|
|
5572
|
+
author: author || "",
|
|
5573
|
+
date: now,
|
|
5574
|
+
});
|
|
5575
|
+
|
|
5576
|
+
// Run actions
|
|
5577
|
+
const result = await this.runActions({
|
|
5578
|
+
eventTypes: ["task.commented"],
|
|
5579
|
+
index,
|
|
5580
|
+
taskId,
|
|
5581
|
+
taskData,
|
|
5582
|
+
payload: { comment: text },
|
|
5583
|
+
date: now
|
|
3065
5584
|
});
|
|
5585
|
+
if (result !== null) {
|
|
5586
|
+
({ taskData } = result);
|
|
5587
|
+
index = result.index;
|
|
5588
|
+
if (result.patch.moveTo !== null) {
|
|
5589
|
+
const fromColumn = findTaskColumn(index, taskId);
|
|
5590
|
+
if (fromColumn !== result.patch.moveTo.column) {
|
|
5591
|
+
taskData = appendTaskHistory(
|
|
5592
|
+
taskData,
|
|
5593
|
+
{ date: now, type: "moved", fromColumn, toColumn: result.patch.moveTo.column },
|
|
5594
|
+
await this.historyBoard(),
|
|
5595
|
+
result.patch.moveTo.author
|
|
5596
|
+
);
|
|
5597
|
+
index = removeTaskFromIndex(index, taskId);
|
|
5598
|
+
index = addTaskToIndex(index, taskId, result.patch.moveTo.column, result.patch.moveTo.position);
|
|
5599
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, result.patch.moveTo.column, now);
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
3066
5603
|
|
|
3067
5604
|
// Save the task
|
|
3068
5605
|
await this.saveTask(taskPath, taskData);
|
|
5606
|
+
|
|
5607
|
+
// Commenting doesn't touch the index, so it is only written when a rule moved something
|
|
5608
|
+
if (result !== null && (result.patch.moveTo !== null || result.targets.some((target) => target.moveTo !== null))) {
|
|
5609
|
+
await this.saveIndex(index);
|
|
5610
|
+
}
|
|
5611
|
+
await this.completeActions(result, taskId);
|
|
3069
5612
|
return taskId;
|
|
3070
5613
|
}
|
|
3071
5614
|
|
|
@@ -3130,17 +5673,44 @@ class Kanbn {
|
|
|
3130
5673
|
const taskColumn = findTaskColumn(index, taskId);
|
|
3131
5674
|
taskData = setTaskMetadata(taskData, "column", taskColumn);
|
|
3132
5675
|
|
|
3133
|
-
//
|
|
5676
|
+
// Archiving removes a task from every board, so remember where it was on each of them. The
|
|
5677
|
+
// single `column` key is kept for the main board, so archives written before boards existed -
|
|
5678
|
+
// and archives of tasks that are only on one board - are unchanged
|
|
5679
|
+
const taskBoards = await this.findTaskBoards(taskId);
|
|
5680
|
+
if (Object.keys(taskBoards).length > 1) {
|
|
5681
|
+
taskData = setTaskMetadata(taskData, "columns", taskBoards);
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5684
|
+
// Add history event. Archiving isn't board-scoped, so the event carries no board key
|
|
5685
|
+
const now = new Date();
|
|
3134
5686
|
taskData = appendTaskHistory(taskData, {
|
|
3135
5687
|
type: 'archived',
|
|
3136
5688
|
fromColumn: taskColumn
|
|
5689
|
+
}, null, await this.currentUser());
|
|
5690
|
+
|
|
5691
|
+
// Run actions
|
|
5692
|
+
const result = await this.runActions({
|
|
5693
|
+
eventTypes: ["task.archived"],
|
|
5694
|
+
index,
|
|
5695
|
+
taskId,
|
|
5696
|
+
taskData,
|
|
5697
|
+
payload: { fromColumn: taskColumn },
|
|
5698
|
+
date: now
|
|
3137
5699
|
});
|
|
5700
|
+
if (result !== null) {
|
|
5701
|
+
({ index, taskData } = result);
|
|
5702
|
+
if (result.targets.some((target) => target.moveTo !== null)) {
|
|
5703
|
+
await this.saveIndex(index);
|
|
5704
|
+
}
|
|
5705
|
+
}
|
|
3138
5706
|
|
|
3139
5707
|
// Save the task inside the archive folder
|
|
3140
5708
|
await this.saveTask(archivedTaskPath, taskData);
|
|
5709
|
+
await this.completeActions(result, taskId);
|
|
3141
5710
|
|
|
3142
|
-
// Remove the original task
|
|
3143
|
-
|
|
5711
|
+
// Remove the original task from every board that references it. This removal is part of the
|
|
5712
|
+
// archive rather than a deletion anyone wrote a rule for, so it fires nothing
|
|
5713
|
+
await this.withoutActions().deleteTask(taskId, true, true);
|
|
3144
5714
|
|
|
3145
5715
|
return taskId;
|
|
3146
5716
|
}
|
|
@@ -3149,9 +5719,11 @@ class Kanbn {
|
|
|
3149
5719
|
* Restore a task from the archive
|
|
3150
5720
|
* @param {string} taskId The task id
|
|
3151
5721
|
* @param {?string} [columnName=null] The column to restore the task to
|
|
5722
|
+
* @param {boolean} [singleBoard=false] Restore only to this board, rather than to every board the
|
|
5723
|
+
* task was on when it was archived
|
|
3152
5724
|
* @return {Promise<string>} The task id
|
|
3153
5725
|
*/
|
|
3154
|
-
async restoreTask(taskId, columnName = null) {
|
|
5726
|
+
async restoreTask(taskId, columnName = null, singleBoard = false) {
|
|
3155
5727
|
// Check if this folder has been initialised
|
|
3156
5728
|
if (!(await this.initialised())) {
|
|
3157
5729
|
throw new Error("Not initialised in this folder");
|
|
@@ -3192,21 +5764,93 @@ class Kanbn {
|
|
|
3192
5764
|
// Load the task from the archive
|
|
3193
5765
|
let taskData = await this.loadArchivedTask(taskId);
|
|
3194
5766
|
let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
|
|
5767
|
+
|
|
5768
|
+
// Work out which other boards this task was on when it was archived. A board that has since been
|
|
5769
|
+
// deleted is skipped with a warning rather than failing the restore
|
|
5770
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
5771
|
+
const archivedColumns = getTaskMetadata(taskData, "columns") || {};
|
|
5772
|
+
const otherBoards = [];
|
|
5773
|
+
const missingBoards = [];
|
|
5774
|
+
this.lastRestoreWarnings = [];
|
|
5775
|
+
if (!singleBoard) {
|
|
5776
|
+
for (const [slug, archivedColumn] of Object.entries(archivedColumns)) {
|
|
5777
|
+
if (slug === thisBoardSlug) {
|
|
5778
|
+
continue;
|
|
5779
|
+
}
|
|
5780
|
+
if (await this.boardExists(slug)) {
|
|
5781
|
+
otherBoards.push([slug, archivedColumn]);
|
|
5782
|
+
} else {
|
|
5783
|
+
missingBoards.push(slug);
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
3195
5787
|
taskData = setTaskMetadata(taskData, "column", undefined);
|
|
5788
|
+
if ("metadata" in taskData && "columns" in taskData.metadata) {
|
|
5789
|
+
taskData = setTaskMetadata(taskData, "columns", undefined);
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
// Stamp every date this call writes with the same timestamp, so that the history event and the
|
|
5793
|
+
// column-linked dates can't disagree by a millisecond
|
|
5794
|
+
const now = new Date();
|
|
3196
5795
|
|
|
3197
5796
|
// Add history event
|
|
3198
5797
|
taskData = appendTaskHistory(taskData, {
|
|
5798
|
+
date: now,
|
|
3199
5799
|
type: 'restored',
|
|
3200
5800
|
toColumn: actualColumnName
|
|
3201
|
-
});
|
|
5801
|
+
}, null, await this.currentUser());
|
|
3202
5802
|
|
|
3203
|
-
// Update task metadata dates
|
|
3204
|
-
|
|
3205
|
-
|
|
5803
|
+
// Update task metadata dates
|
|
5804
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
5805
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName, now);
|
|
3206
5806
|
|
|
3207
|
-
// Add the task to the column
|
|
5807
|
+
// Add the task to the column before running rules, so that a rule filtering on the task's
|
|
5808
|
+
// column sees the column it is being restored to
|
|
3208
5809
|
index = addTaskToIndex(index, taskId, actualColumnName);
|
|
5810
|
+
|
|
5811
|
+
// Run actions
|
|
5812
|
+
const result = await this.runActions({
|
|
5813
|
+
eventTypes: ["task.restored", ...this.derivedEvents(index, beforeActions, taskData)],
|
|
5814
|
+
index,
|
|
5815
|
+
taskId,
|
|
5816
|
+
taskData,
|
|
5817
|
+
payload: { toColumn: actualColumnName },
|
|
5818
|
+
|
|
5819
|
+
// The task isn't on any board yet, so the index can't say which boards this affects
|
|
5820
|
+
taskBoards: [thisBoardSlug, ...otherBoards.map(([slug]) => slug)],
|
|
5821
|
+
date: now
|
|
5822
|
+
});
|
|
5823
|
+
if (result !== null) {
|
|
5824
|
+
({ index, taskData } = result);
|
|
5825
|
+
if (result.patch.moveTo !== null && result.patch.moveTo.column !== actualColumnName) {
|
|
5826
|
+
actualColumnName = result.patch.moveTo.column;
|
|
5827
|
+
index = removeTaskFromIndex(index, taskId);
|
|
5828
|
+
index = addTaskToIndex(index, taskId, actualColumnName, result.patch.moveTo.position);
|
|
5829
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName, now);
|
|
5830
|
+
}
|
|
5831
|
+
}
|
|
5832
|
+
await this.saveTask(taskPath, taskData);
|
|
3209
5833
|
await this.saveIndex(index);
|
|
5834
|
+
await this.completeActions(result, taskId);
|
|
5835
|
+
|
|
5836
|
+
// Restore the task to every other board it was on, falling back to that board's first column if
|
|
5837
|
+
// the column it used to be in has since gone
|
|
5838
|
+
for (const [slug, archivedColumn] of otherBoards) {
|
|
5839
|
+
const otherBoard = this.board(slug);
|
|
5840
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
5841
|
+
const otherColumns = Object.keys(otherIndex.columns);
|
|
5842
|
+
if (!otherColumns.length) {
|
|
5843
|
+
missingBoards.push(slug);
|
|
5844
|
+
continue;
|
|
5845
|
+
}
|
|
5846
|
+
await otherBoard.addTaskToBoard(
|
|
5847
|
+
taskId,
|
|
5848
|
+
archivedColumn in otherIndex.columns ? archivedColumn : otherColumns[0]
|
|
5849
|
+
);
|
|
5850
|
+
}
|
|
5851
|
+
// Boards the task used to be on that no longer exist, for the caller to report. Restoring is
|
|
5852
|
+
// best-effort: a deleted board shouldn't stop the task coming back to the boards that remain
|
|
5853
|
+
this.lastRestoreWarnings = missingBoards;
|
|
3210
5854
|
|
|
3211
5855
|
// Delete the archived task file
|
|
3212
5856
|
await fs.promises.unlink(archivedTaskPath);
|