@basementuniverse/kanbn 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/docs/advanced-configuration.md +23 -0
- package/docs/commands/add.txt +9 -0
- package/docs/commands/archive.txt +5 -0
- package/docs/commands/board.txt +5 -0
- package/docs/commands/boards.txt +34 -0
- package/docs/commands/burndown.txt +6 -0
- package/docs/commands/comment.txt +5 -0
- package/docs/commands/edit.txt +5 -0
- package/docs/commands/find.txt +11 -0
- package/docs/commands/gantt.txt +5 -0
- package/docs/commands/help.txt +1 -0
- package/docs/commands/history.txt +5 -0
- package/docs/commands/init.txt +13 -0
- package/docs/commands/move.txt +9 -0
- package/docs/commands/remove.txt +12 -1
- package/docs/commands/rename.txt +5 -0
- package/docs/commands/restore.txt +6 -0
- package/docs/commands/sort.txt +5 -0
- package/docs/commands/sprint.txt +9 -0
- package/docs/commands/status.txt +10 -1
- package/docs/commands/task.txt +5 -0
- package/docs/commands/validate.txt +15 -0
- package/docs/index-structure.md +26 -0
- package/docs/index.md +3 -1
- package/docs/multiple-boards.md +258 -0
- package/docs/quick-start.md +21 -1
- package/docs/task-structure.md +22 -1
- package/example/README.md +23 -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 +35 -11
- package/routes/archive.json +10 -2
- package/routes/board.json +11 -3
- package/routes/boards.json +30 -0
- package/routes/burndown.json +23 -7
- package/routes/comment.json +14 -4
- package/routes/edit.json +32 -10
- package/routes/find.json +37 -12
- package/routes/gantt.json +20 -6
- package/routes/history.json +39 -25
- package/routes/init.json +3 -1
- package/routes/move.json +22 -6
- package/routes/remove.json +15 -4
- package/routes/rename.json +11 -3
- package/routes/restore.json +8 -2
- package/routes/sort.json +35 -11
- package/routes/sprint.json +14 -4
- package/routes/status.json +23 -7
- package/routes/task.json +10 -2
- package/routes/validate.json +18 -5
- package/skills/kanbn-plan/SKILL.md +10 -1
- package/skills/kanbn-replan/SKILL.md +6 -1
- package/src/board.js +1 -1
- package/src/controller/add.js +52 -46
- package/src/controller/archive.js +8 -4
- package/src/controller/board.js +8 -9
- package/src/controller/boards.js +140 -0
- package/src/controller/burndown.js +9 -5
- package/src/controller/comment.js +9 -5
- package/src/controller/edit.js +9 -5
- package/src/controller/find.js +12 -9
- package/src/controller/gantt.js +8 -4
- package/src/controller/history.js +8 -4
- package/src/controller/init.js +39 -4
- package/src/controller/move.js +69 -15
- package/src/controller/remove.js +34 -11
- package/src/controller/rename.js +8 -4
- package/src/controller/restore.js +23 -8
- package/src/controller/sort.js +19 -3
- package/src/controller/sprint.js +31 -7
- package/src/controller/status.js +8 -4
- package/src/controller/task.js +22 -9
- package/src/controller/validate.js +60 -7
- package/src/git-user-name.js +19 -0
- package/src/main.d.ts +184 -6
- package/src/main.js +1333 -63
- package/src/parse-index.js +14 -0
- package/src/parse-task.js +16 -0
- package/src/utility.js +140 -0
package/src/main.js
CHANGED
|
@@ -13,6 +13,39 @@ const DEFAULT_INDEX_FILE_NAME = "index.md";
|
|
|
13
13
|
const DEFAULT_TASKS_FOLDER_NAME = "tasks";
|
|
14
14
|
const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
|
|
15
15
|
|
|
16
|
+
// Slugs that always resolve to the main board, whatever the index file is called
|
|
17
|
+
const MAIN_BOARD_ALIASES = ["main", "default"];
|
|
18
|
+
|
|
19
|
+
// Options that describe the workspace rather than a single board. They are only ever read from the
|
|
20
|
+
// config file (or, when there isn't one, from the main board's front matter) - if one of these turns
|
|
21
|
+
// up in a secondary board's front matter it is ignored, and `kanbn validate` reports it
|
|
22
|
+
const WORKSPACE_SCOPED_OPTIONS = [
|
|
23
|
+
"mainFolder",
|
|
24
|
+
"indexFile",
|
|
25
|
+
"taskFolder",
|
|
26
|
+
"archiveFolder",
|
|
27
|
+
"defaultBoard",
|
|
28
|
+
"boards",
|
|
29
|
+
"customFields",
|
|
30
|
+
"dateFormat",
|
|
31
|
+
"defaultTaskWorkload",
|
|
32
|
+
"taskWorkloadTags",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Options that a secondary board inherits from the main board's front matter. When there is no
|
|
36
|
+
// config file the main board's front matter is doing double duty - it holds the workspace options
|
|
37
|
+
// *and* the main board's own board-scoped ones - so only this allowlist propagates. Anything else
|
|
38
|
+
// there (startedColumns, hiddenColumns, views, custom field column linkages, ...) belongs to the
|
|
39
|
+
// main board alone, and a secondary board that wants it has to say so itself
|
|
40
|
+
const WORKSPACE_INHERITED_OPTIONS = [...WORKSPACE_SCOPED_OPTIONS, "sprints"];
|
|
41
|
+
|
|
42
|
+
// Keys inside the `boards` config option that aren't board slugs
|
|
43
|
+
const RESERVED_BOARDS_CONFIG_KEYS = ["exclude", "order"];
|
|
44
|
+
|
|
45
|
+
// History event types that belong to the task rather than to a board, so they count when replaying
|
|
46
|
+
// for any board. Archiving removes a task from every board, and progress is a property of the task
|
|
47
|
+
const BOARD_AGNOSTIC_EVENT_TYPES = ["progress", "archived"];
|
|
48
|
+
|
|
16
49
|
// Date normalisation intervals measured in milliseconds
|
|
17
50
|
const SECOND = 1000;
|
|
18
51
|
const MINUTE = 60 * SECOND;
|
|
@@ -65,6 +98,53 @@ async function exists(path) {
|
|
|
65
98
|
return true;
|
|
66
99
|
}
|
|
67
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Convert a board file name into a board slug, i.e. "design.md" -> "design"
|
|
103
|
+
* @param {string} fileName The board file name
|
|
104
|
+
* @return {string} The board slug
|
|
105
|
+
*/
|
|
106
|
+
function boardSlugFromFileName(fileName) {
|
|
107
|
+
return path.basename(fileName, path.extname(fileName));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Copy an object, leaving out the specified keys
|
|
112
|
+
* @param {object} o The object to copy
|
|
113
|
+
* @param {string[]} keys The keys to leave out
|
|
114
|
+
* @return {object} A copy of the object without the specified keys
|
|
115
|
+
*/
|
|
116
|
+
function omitKeys(o, keys) {
|
|
117
|
+
return Object.fromEntries(Object.entries(o).filter(([key]) => keys.indexOf(key) === -1));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record which options belong to a board's own front matter, so that saving the board writes exactly
|
|
122
|
+
* these rather than everything it inherited from the workspace
|
|
123
|
+
* @param {object} index The index object
|
|
124
|
+
* @param {object} ownOptions The options that belong to this board
|
|
125
|
+
* @return {object} The index object
|
|
126
|
+
*/
|
|
127
|
+
function setOwnOptions(index, ownOptions) {
|
|
128
|
+
Object.defineProperty(index, "ownOptions", {
|
|
129
|
+
value: { ...ownOptions },
|
|
130
|
+
enumerable: false,
|
|
131
|
+
writable: true,
|
|
132
|
+
configurable: true,
|
|
133
|
+
});
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compare two option values structurally, so that an option inherited from the workspace can be told
|
|
139
|
+
* apart from one that a board operation has actually changed
|
|
140
|
+
* @param {any} a
|
|
141
|
+
* @param {any} b
|
|
142
|
+
* @return {boolean} True if the two values are equivalent
|
|
143
|
+
*/
|
|
144
|
+
function sameOptionValue(a, b) {
|
|
145
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
146
|
+
}
|
|
147
|
+
|
|
68
148
|
/**
|
|
69
149
|
* Get a list of all tracked task ids
|
|
70
150
|
* @param {object} index The index object
|
|
@@ -227,15 +307,20 @@ function setTaskMetadata(taskData, property, value) {
|
|
|
227
307
|
* Append a structured history event to a task
|
|
228
308
|
* @param {object} taskData The task object
|
|
229
309
|
* @param {object} historyEvent The history event payload
|
|
310
|
+
* @param {?string} [boardSlug=null] The board the event happened on, or null for the main board
|
|
230
311
|
* @return {object} The modified task object
|
|
231
312
|
*/
|
|
232
|
-
function appendTaskHistory(taskData, historyEvent) {
|
|
313
|
+
function appendTaskHistory(taskData, historyEvent, boardSlug = null) {
|
|
233
314
|
if (!('history' in taskData) || taskData.history === null) {
|
|
234
315
|
taskData.history = [];
|
|
235
316
|
}
|
|
236
317
|
taskData.history.push({
|
|
237
318
|
date: new Date(),
|
|
238
|
-
...historyEvent
|
|
319
|
+
...historyEvent,
|
|
320
|
+
|
|
321
|
+
// Events on the main board carry no board key, so a single-board workspace writes exactly the
|
|
322
|
+
// history it always has
|
|
323
|
+
...(boardSlug === null ? {} : { board: boardSlug })
|
|
239
324
|
});
|
|
240
325
|
return taskData;
|
|
241
326
|
}
|
|
@@ -781,17 +866,37 @@ function getTaskProgressAtDate(task, date) {
|
|
|
781
866
|
return Math.max(0, Math.min(progress, 1));
|
|
782
867
|
}
|
|
783
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Check whether a history event counts when replaying for a board. An event belongs to a board if it
|
|
871
|
+
* names that board, or if it names none and we're replaying for the main board - which is exactly
|
|
872
|
+
* what makes every task file written before boards existed replay the way it always has
|
|
873
|
+
* @param {object} historyEvent The history event
|
|
874
|
+
* @param {?string} [boardSlug=null] The board being replayed for, or null for the main board
|
|
875
|
+
* @return {boolean} True if the event counts for this board
|
|
876
|
+
*/
|
|
877
|
+
function historyEventOnBoard(historyEvent, boardSlug = null) {
|
|
878
|
+
if (BOARD_AGNOSTIC_EVENT_TYPES.indexOf(historyEvent.type) !== -1) {
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
if ("board" in historyEvent && historyEvent.board) {
|
|
882
|
+
return historyEvent.board === boardSlug;
|
|
883
|
+
}
|
|
884
|
+
return boardSlug === null;
|
|
885
|
+
}
|
|
886
|
+
|
|
784
887
|
/**
|
|
785
888
|
* Get timeline dates for a task in a period. For history-enabled tasks this uses all history event dates,
|
|
786
889
|
* otherwise it falls back to created/started/completed dates.
|
|
787
890
|
* @param {object} task
|
|
788
891
|
* @param {Date} from
|
|
789
892
|
* @param {Date} to
|
|
893
|
+
* @param {?string} [boardSlug=null] The board to replay for, or null for the main board
|
|
790
894
|
* @return {Date[]}
|
|
791
895
|
*/
|
|
792
|
-
function getTaskTimelineDates(task, from, to) {
|
|
896
|
+
function getTaskTimelineDates(task, from, to, boardSlug = null) {
|
|
793
897
|
if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
|
|
794
898
|
return task.history
|
|
899
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
|
|
795
900
|
.map((historyEvent) => historyEvent.date)
|
|
796
901
|
.filter((date) => date && date >= from && date <= to);
|
|
797
902
|
}
|
|
@@ -1150,13 +1255,15 @@ function countActiveTasksAtDate(index, tasks, date) {
|
|
|
1150
1255
|
* @param {object} index
|
|
1151
1256
|
* @param {object[]} tasks
|
|
1152
1257
|
* @param {Date} date
|
|
1258
|
+
* @param {?string} [boardSlug=null] The board to replay for, or null for the main board
|
|
1153
1259
|
* @return {object[]} A list of event objects, with event type and task id
|
|
1154
1260
|
*/
|
|
1155
|
-
function getTaskEventsAtDate(index, tasks, date) {
|
|
1261
|
+
function getTaskEventsAtDate(index, tasks, date, boardSlug = null) {
|
|
1156
1262
|
return tasks
|
|
1157
1263
|
.map((task) => {
|
|
1158
1264
|
if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
|
|
1159
1265
|
return task.history
|
|
1266
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, boardSlug))
|
|
1160
1267
|
.filter((historyEvent) => historyEvent.date && historyEvent.date.getTime() === date.getTime())
|
|
1161
1268
|
.map((historyEvent) => ({
|
|
1162
1269
|
eventType: historyEvent.type,
|
|
@@ -1285,15 +1392,57 @@ class Kanbn {
|
|
|
1285
1392
|
CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
|
|
1286
1393
|
CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
|
|
1287
1394
|
|
|
1288
|
-
//
|
|
1289
|
-
|
|
1395
|
+
// The board this instance is scoped to, or null for the main board
|
|
1396
|
+
boardSlug = null;
|
|
1290
1397
|
|
|
1291
|
-
|
|
1398
|
+
// Boards named in an archived task's metadata that no longer existed when it was restored, set by
|
|
1399
|
+
// restoreTask() for the caller to report
|
|
1400
|
+
lastRestoreWarnings = [];
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* @param {?string} [root=null] The workspace root folder
|
|
1404
|
+
* @param {object} [options={}] Instance options: `board` scopes this instance to a board, `caches`
|
|
1405
|
+
* lets a board-scoped clone share its parent's memoized config
|
|
1406
|
+
*/
|
|
1407
|
+
constructor(root = null, options = {}) {
|
|
1292
1408
|
if(root) {
|
|
1293
1409
|
this.ROOT = root
|
|
1294
1410
|
this.CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
|
|
1295
1411
|
this.CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
|
|
1296
1412
|
}
|
|
1413
|
+
this.caches = options.caches || { config: null, workspaceOptions: null };
|
|
1414
|
+
this.boardSlug = options.board || null;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Memoized config, kept in the shared cache object so that board-scoped clones don't each re-read it
|
|
1418
|
+
get configMemo() {
|
|
1419
|
+
return this.caches.config;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
set configMemo(value) {
|
|
1423
|
+
this.caches.config = value;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Get a copy of this instance scoped to another board. The returned instance shares this one's
|
|
1428
|
+
* cached config, so scoping to a board costs no extra file reads
|
|
1429
|
+
* @param {?string} [slug=null] The board slug, or null/"main"/"default" for the main board
|
|
1430
|
+
* @return {Kanbn} A board-scoped Kanbn instance
|
|
1431
|
+
*/
|
|
1432
|
+
board(slug = null) {
|
|
1433
|
+
if (slug === null || slug === undefined || slug === "") {
|
|
1434
|
+
return this.boardSlug === null ? this : new Kanbn(this.ROOT, { caches: this.caches });
|
|
1435
|
+
}
|
|
1436
|
+
return new Kanbn(this.ROOT, { board: String(slug).trim(), caches: this.caches });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Alias for board()
|
|
1441
|
+
* @param {?string} [slug=null] The board slug
|
|
1442
|
+
* @return {Kanbn} A board-scoped Kanbn instance
|
|
1443
|
+
*/
|
|
1444
|
+
withBoard(slug = null) {
|
|
1445
|
+
return this.board(slug);
|
|
1297
1446
|
}
|
|
1298
1447
|
|
|
1299
1448
|
/**
|
|
@@ -1313,6 +1462,7 @@ class Kanbn {
|
|
|
1313
1462
|
} else {
|
|
1314
1463
|
await fs.promises.writeFile(this.CONFIG_JSON, JSON.stringify(config, null, 4));
|
|
1315
1464
|
}
|
|
1465
|
+
this.caches.workspaceOptions = null;
|
|
1316
1466
|
}
|
|
1317
1467
|
|
|
1318
1468
|
/**
|
|
@@ -1344,7 +1494,8 @@ class Kanbn {
|
|
|
1344
1494
|
* Clear cached config
|
|
1345
1495
|
*/
|
|
1346
1496
|
clearConfigCache() {
|
|
1347
|
-
this.
|
|
1497
|
+
this.caches.config = null;
|
|
1498
|
+
this.caches.workspaceOptions = null;
|
|
1348
1499
|
}
|
|
1349
1500
|
|
|
1350
1501
|
/**
|
|
@@ -1404,11 +1555,64 @@ class Kanbn {
|
|
|
1404
1555
|
}
|
|
1405
1556
|
|
|
1406
1557
|
/**
|
|
1407
|
-
* Get the index
|
|
1558
|
+
* Get the main board's slug. This is the index file name without its extension, so a workspace with
|
|
1559
|
+
* a customised `indexFile` gets a main board slug to match
|
|
1560
|
+
* @return {Promise<string>} The main board slug
|
|
1561
|
+
*/
|
|
1562
|
+
async getMainBoardSlug() {
|
|
1563
|
+
return boardSlugFromFileName(await this.getIndexFileName());
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Resolve a board slug, mapping the reserved aliases and an absent slug onto the main board
|
|
1568
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1569
|
+
* @return {Promise<string>} The resolved board slug
|
|
1570
|
+
*/
|
|
1571
|
+
async resolveBoardSlug(slug = undefined) {
|
|
1572
|
+
if (slug === undefined) {
|
|
1573
|
+
slug = this.boardSlug;
|
|
1574
|
+
}
|
|
1575
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
1576
|
+
if (slug === null || slug === undefined || slug === "") {
|
|
1577
|
+
return mainBoardSlug;
|
|
1578
|
+
}
|
|
1579
|
+
slug = String(slug).trim();
|
|
1580
|
+
if (MAIN_BOARD_ALIASES.indexOf(slug.toLowerCase()) !== -1) {
|
|
1581
|
+
return mainBoardSlug;
|
|
1582
|
+
}
|
|
1583
|
+
return slug;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Check if a slug refers to the main board
|
|
1588
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1589
|
+
* @return {Promise<boolean>} True if the slug refers to the main board
|
|
1590
|
+
*/
|
|
1591
|
+
async isMainBoard(slug = undefined) {
|
|
1592
|
+
return (await this.resolveBoardSlug(slug)) === (await this.getMainBoardSlug());
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Get the file path for a board. The main board keeps its configured index file name, every other
|
|
1597
|
+
* board is a sibling markdown file named after its slug
|
|
1598
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1599
|
+
* @return {Promise<string>} The board file path
|
|
1600
|
+
*/
|
|
1601
|
+
async getBoardPath(slug = undefined) {
|
|
1602
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
1603
|
+
const mainFolder = await this.getMainFolder();
|
|
1604
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
1605
|
+
return path.join(mainFolder, await this.getIndexFileName());
|
|
1606
|
+
}
|
|
1607
|
+
return path.join(mainFolder, `${resolvedSlug}.md`);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Get the index path. This is the path of the board this instance is scoped to
|
|
1408
1612
|
* @return {Promise<string>} The kanbn index path
|
|
1409
1613
|
*/
|
|
1410
1614
|
async getIndexPath() {
|
|
1411
|
-
return
|
|
1615
|
+
return this.getBoardPath();
|
|
1412
1616
|
}
|
|
1413
1617
|
|
|
1414
1618
|
/**
|
|
@@ -1518,10 +1722,147 @@ class Kanbn {
|
|
|
1518
1722
|
}
|
|
1519
1723
|
|
|
1520
1724
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1725
|
+
* Get the workspace-scoped options. These live in the config file if there is one, and in the main
|
|
1726
|
+
* board's front matter if there isn't - which means loading a secondary board also means reading the
|
|
1727
|
+
* main board. The result is memoized so that costs one extra file read per process, not per board
|
|
1728
|
+
* @return {Promise<object>} The workspace options
|
|
1523
1729
|
*/
|
|
1524
|
-
async
|
|
1730
|
+
async getWorkspaceOptions() {
|
|
1731
|
+
return (await this.loadWorkspaceOptions()).options;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Load the workspace options along with where they came from, since that decides how much of them
|
|
1736
|
+
* a secondary board inherits
|
|
1737
|
+
* @return {Promise<{options: object, fromConfig: boolean}>} The workspace options and their source
|
|
1738
|
+
*/
|
|
1739
|
+
async loadWorkspaceOptions() {
|
|
1740
|
+
if (this.caches.workspaceOptions === null) {
|
|
1741
|
+
const config = await this.getConfig();
|
|
1742
|
+
if (config !== null) {
|
|
1743
|
+
this.caches.workspaceOptions = { options: { ...config }, fromConfig: true };
|
|
1744
|
+
} else {
|
|
1745
|
+
let options = {};
|
|
1746
|
+
try {
|
|
1747
|
+
const mainBoardData = await fs.promises.readFile(
|
|
1748
|
+
await this.getBoardPath(await this.getMainBoardSlug()),
|
|
1749
|
+
{ encoding: "utf-8" }
|
|
1750
|
+
);
|
|
1751
|
+
options = parseIndex.md2json(mainBoardData).options;
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
// A missing or unparseable main board leaves secondary boards with defaults only. This is
|
|
1754
|
+
// reported by validate rather than thrown, so that one broken file doesn't break every board
|
|
1755
|
+
options = {};
|
|
1756
|
+
}
|
|
1757
|
+
this.caches.workspaceOptions = { options, fromConfig: false };
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return this.caches.workspaceOptions;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Get the options a secondary board inherits from the workspace. A config file is workspace-level
|
|
1765
|
+
* by construction, so all of it is inherited; the main board's front matter is that board's own
|
|
1766
|
+
* file, so only the workspace-scoped keys in it are
|
|
1767
|
+
* @return {Promise<object>} The inherited options
|
|
1768
|
+
*/
|
|
1769
|
+
async getInheritedBoardOptions() {
|
|
1770
|
+
const { options, fromConfig } = await this.loadWorkspaceOptions();
|
|
1771
|
+
if (fromConfig) {
|
|
1772
|
+
return { ...options };
|
|
1773
|
+
}
|
|
1774
|
+
return Object.fromEntries(
|
|
1775
|
+
Object.entries(options).filter(([key]) => WORKSPACE_INHERITED_OPTIONS.indexOf(key) !== -1)
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Get the per-board options declared for a board in the config file's `boards` key, if any
|
|
1781
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1782
|
+
* @return {Promise<object>} The board's options from config, or an empty object
|
|
1783
|
+
*/
|
|
1784
|
+
async getBoardConfig(slug = undefined) {
|
|
1785
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
1786
|
+
const config = await this.getConfig();
|
|
1787
|
+
if (config === null || typeof config.boards !== "object" || config.boards === null) {
|
|
1788
|
+
return {};
|
|
1789
|
+
}
|
|
1790
|
+
if (RESERVED_BOARDS_CONFIG_KEYS.indexOf(resolvedSlug) !== -1) {
|
|
1791
|
+
return {};
|
|
1792
|
+
}
|
|
1793
|
+
const boardConfig = config.boards[resolvedSlug];
|
|
1794
|
+
return typeof boardConfig === "object" && boardConfig !== null && !Array.isArray(boardConfig)
|
|
1795
|
+
? { ...boardConfig }
|
|
1796
|
+
: {};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Layer a board's own front matter options over the workspace options
|
|
1801
|
+
* @param {string} resolvedSlug The resolved board slug
|
|
1802
|
+
* @param {object} ownOptions The options taken from the board file's front matter
|
|
1803
|
+
* @return {Promise<object>} The resolved options
|
|
1804
|
+
*/
|
|
1805
|
+
async resolveBoardOptions(resolvedSlug, ownOptions) {
|
|
1806
|
+
// The main board is where the workspace options live, so there is nothing to layer: this is
|
|
1807
|
+
// exactly the behaviour Kanbn has always had
|
|
1808
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
1809
|
+
const config = await this.getConfig();
|
|
1810
|
+
return config !== null ? { ...ownOptions, ...config } : { ...ownOptions };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Workspace-scoped keys in a secondary board's front matter are ignored - one task file has to
|
|
1814
|
+
// parse identically for every board that references it. validate reports them
|
|
1815
|
+
return {
|
|
1816
|
+
...(await this.getInheritedBoardOptions()),
|
|
1817
|
+
...(await this.getBoardConfig(resolvedSlug)),
|
|
1818
|
+
...omitKeys(ownOptions, WORKSPACE_SCOPED_OPTIONS),
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Work out which options belong in a secondary board's own front matter, so that options inherited
|
|
1824
|
+
* from the workspace aren't copied into every board file the first time it is saved
|
|
1825
|
+
* @param {string} resolvedSlug The resolved board slug
|
|
1826
|
+
* @param {object} indexData The board data being saved
|
|
1827
|
+
* @return {Promise<object>} The options to write to the board file's front matter
|
|
1828
|
+
*/
|
|
1829
|
+
async getOwnBoardOptions(resolvedSlug, indexData) {
|
|
1830
|
+
const previousOwnOptions = indexData.ownOptions ? { ...indexData.ownOptions } : {};
|
|
1831
|
+
const inheritedOptions = {
|
|
1832
|
+
...(await this.getInheritedBoardOptions()),
|
|
1833
|
+
...(await this.getBoardConfig(resolvedSlug)),
|
|
1834
|
+
};
|
|
1835
|
+
const options = indexData.options || {};
|
|
1836
|
+
const ownOptions = { ...previousOwnOptions };
|
|
1837
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1838
|
+
if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Keep anything the board already declared, and pick up anything that differs from what the
|
|
1843
|
+
// workspace provides - i.e. anything this operation actually changed
|
|
1844
|
+
if (key in previousOwnOptions || !(key in inheritedOptions) || !sameOptionValue(inheritedOptions[key], value)) {
|
|
1845
|
+
ownOptions[key] = value;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Drop anything that has been removed from the board's options entirely
|
|
1850
|
+
for (const key of Object.keys(ownOptions)) {
|
|
1851
|
+
if (!(key in options)) {
|
|
1852
|
+
delete ownOptions[key];
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return ownOptions;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Overwrite a board file with the specified data
|
|
1860
|
+
* @param {?string} slug The board slug, or null for the main board
|
|
1861
|
+
* @param {object} indexData Board data to save
|
|
1862
|
+
*/
|
|
1863
|
+
async saveBoard(slug, indexData) {
|
|
1864
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
1865
|
+
|
|
1525
1866
|
// Apply column sorting if any sorters are defined in options
|
|
1526
1867
|
if ("columnSorting" in indexData.options && Object.keys(indexData.options.columnSorting).length) {
|
|
1527
1868
|
for (let columnName in indexData.options.columnSorting) {
|
|
@@ -1534,38 +1875,74 @@ class Kanbn {
|
|
|
1534
1875
|
}
|
|
1535
1876
|
}
|
|
1536
1877
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
ignoreOptions =
|
|
1878
|
+
const boardPath = await this.getBoardPath(resolvedSlug);
|
|
1879
|
+
if (resolvedSlug === (await this.getMainBoardSlug())) {
|
|
1880
|
+
|
|
1881
|
+
// If there is a separate config file, save options to this file
|
|
1882
|
+
let ignoreOptions = false;
|
|
1883
|
+
if (await this.configExists()) {
|
|
1884
|
+
await this.saveConfig(indexData.options);
|
|
1885
|
+
ignoreOptions = true;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// The main board owns the workspace options, so any cached copy is now stale
|
|
1889
|
+
this.caches.workspaceOptions = null;
|
|
1890
|
+
await fs.promises.writeFile(boardPath, parseIndex.json2md(indexData, ignoreOptions));
|
|
1891
|
+
return;
|
|
1542
1892
|
}
|
|
1543
1893
|
|
|
1544
|
-
//
|
|
1545
|
-
|
|
1894
|
+
// A secondary board's options always live in its own front matter, and workspace-scoped options
|
|
1895
|
+
// are never written to the config file as a side effect of a board-local operation
|
|
1896
|
+
const ownOptions = await this.getOwnBoardOptions(resolvedSlug, indexData);
|
|
1897
|
+
await fs.promises.writeFile(
|
|
1898
|
+
boardPath,
|
|
1899
|
+
parseIndex.json2md({ ...indexData, options: ownOptions }, false)
|
|
1900
|
+
);
|
|
1546
1901
|
}
|
|
1547
1902
|
|
|
1548
1903
|
/**
|
|
1549
|
-
* Load
|
|
1550
|
-
* @
|
|
1904
|
+
* Load a board file and parse it to an object
|
|
1905
|
+
* @param {?string} [slug=undefined] The board slug, defaulting to this instance's board
|
|
1906
|
+
* @return {Promise<object>} The board object
|
|
1551
1907
|
*/
|
|
1552
|
-
async
|
|
1553
|
-
|
|
1908
|
+
async loadBoard(slug = undefined) {
|
|
1909
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
1910
|
+
const isMainBoard = resolvedSlug === (await this.getMainBoardSlug());
|
|
1911
|
+
let boardData = "";
|
|
1554
1912
|
try {
|
|
1555
|
-
|
|
1913
|
+
boardData = await fs.promises.readFile(await this.getBoardPath(resolvedSlug), { encoding: "utf-8" });
|
|
1556
1914
|
} catch (error) {
|
|
1557
|
-
throw new Error(
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
isMainBoard
|
|
1917
|
+
? `Couldn't access index file: ${error.message}`
|
|
1918
|
+
: `Couldn't access board file for board "${resolvedSlug}": ${error.message}`
|
|
1919
|
+
);
|
|
1558
1920
|
}
|
|
1559
|
-
const index = parseIndex.md2json(
|
|
1921
|
+
const index = parseIndex.md2json(boardData);
|
|
1560
1922
|
|
|
1561
|
-
//
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
}
|
|
1923
|
+
// Remember which options came from this board's own front matter, so that saving it again doesn't
|
|
1924
|
+
// persist everything it inherited from the workspace
|
|
1925
|
+
setOwnOptions(index, index.options);
|
|
1926
|
+
index.options = await this.resolveBoardOptions(resolvedSlug, index.options);
|
|
1566
1927
|
return index;
|
|
1567
1928
|
}
|
|
1568
1929
|
|
|
1930
|
+
/**
|
|
1931
|
+
* Overwrite the index file with the specified data
|
|
1932
|
+
* @param {object} indexData Index data to save
|
|
1933
|
+
*/
|
|
1934
|
+
async saveIndex(indexData) {
|
|
1935
|
+
return this.saveBoard(this.boardSlug, indexData);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Load the index file and parse it to an object
|
|
1940
|
+
* @return {Promise<object>} The index object
|
|
1941
|
+
*/
|
|
1942
|
+
async loadIndex() {
|
|
1943
|
+
return this.loadBoard(this.boardSlug);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1569
1946
|
/**
|
|
1570
1947
|
* Overwrite a task file with the specified data
|
|
1571
1948
|
* @param {string} path The task path
|
|
@@ -1648,11 +2025,381 @@ class Kanbn {
|
|
|
1648
2025
|
return await exists(await this.getIndexPath());
|
|
1649
2026
|
}
|
|
1650
2027
|
|
|
2028
|
+
/**
|
|
2029
|
+
* Check if the workspace has been initialised, regardless of which board this instance is scoped to
|
|
2030
|
+
* @return {Promise<boolean>} True if the main board exists
|
|
2031
|
+
*/
|
|
2032
|
+
async workspaceInitialised() {
|
|
2033
|
+
return await exists(await this.getBoardPath(await this.getMainBoardSlug()));
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Get the slugs that can't be used for a board, because they would collide with the main board, one
|
|
2038
|
+
* of its aliases, or one of the workspace's folders
|
|
2039
|
+
* @return {Promise<string[]>} The reserved board slugs
|
|
2040
|
+
*/
|
|
2041
|
+
async getReservedBoardSlugs() {
|
|
2042
|
+
return [
|
|
2043
|
+
...MAIN_BOARD_ALIASES,
|
|
2044
|
+
DEFAULT_INDEX_FILE_NAME,
|
|
2045
|
+
boardSlugFromFileName(DEFAULT_INDEX_FILE_NAME),
|
|
2046
|
+
await this.getMainBoardSlug(),
|
|
2047
|
+
DEFAULT_TASKS_FOLDER_NAME,
|
|
2048
|
+
DEFAULT_ARCHIVE_FOLDER_NAME,
|
|
2049
|
+
await this.getTaskFolderName(),
|
|
2050
|
+
await this.getArchiveFolderName(),
|
|
2051
|
+
];
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Check that a slug can be used for a new board, throwing a descriptive error if it can't
|
|
2056
|
+
* @param {string} slug The board slug to check
|
|
2057
|
+
* @return {Promise<string>} The validated slug
|
|
2058
|
+
*/
|
|
2059
|
+
async validateBoardSlug(slug) {
|
|
2060
|
+
if (!slug || typeof slug !== "string" || !slug.trim()) {
|
|
2061
|
+
throw new Error("Board slug cannot be empty");
|
|
2062
|
+
}
|
|
2063
|
+
slug = slug.trim();
|
|
2064
|
+
if (slug !== utility.paramCase(slug)) {
|
|
2065
|
+
throw new Error(`Board slug "${slug}" is not valid, try "${utility.paramCase(slug)}"`);
|
|
2066
|
+
}
|
|
2067
|
+
if ((await this.getReservedBoardSlugs()).indexOf(slug) !== -1) {
|
|
2068
|
+
throw new Error(`Board slug "${slug}" is reserved`);
|
|
2069
|
+
}
|
|
2070
|
+
return slug;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Get the boards config from the config file, i.e. the exclude list and display order
|
|
2075
|
+
* @return {Promise<{exclude: string[], order: string[]}>} The boards config
|
|
2076
|
+
*/
|
|
2077
|
+
async getBoardsConfig() {
|
|
2078
|
+
const config = await this.getConfig();
|
|
2079
|
+
const boards =
|
|
2080
|
+
config !== null && typeof config.boards === "object" && config.boards !== null && !Array.isArray(config.boards)
|
|
2081
|
+
? config.boards
|
|
2082
|
+
: {};
|
|
2083
|
+
return {
|
|
2084
|
+
exclude: (Array.isArray(boards.exclude) ? boards.exclude : []).map(boardSlugFromFileName),
|
|
2085
|
+
order: (Array.isArray(boards.order) ? boards.order : []).map(boardSlugFromFileName),
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* Find all boards in the workspace. Boards are discovered by globbing markdown files directly inside
|
|
2091
|
+
* the main folder, so the task and archive folders are excluded by construction. A file counts as a
|
|
2092
|
+
* board if it parses as an index; anything that doesn't is ignored here and reported by validate
|
|
2093
|
+
* @return {Promise<object[]>} A list of boards, main board first unless an explicit order says otherwise
|
|
2094
|
+
*/
|
|
2095
|
+
async listBoards() {
|
|
2096
|
+
const mainFolder = await this.getMainFolder();
|
|
2097
|
+
if (!(await exists(mainFolder))) {
|
|
2098
|
+
return [];
|
|
2099
|
+
}
|
|
2100
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
2101
|
+
const { exclude, order } = await this.getBoardsConfig();
|
|
2102
|
+
const boardPaths = await glob(`${mainFolder}/*.md`);
|
|
2103
|
+
const boards = [];
|
|
2104
|
+
for (const boardPath of boardPaths) {
|
|
2105
|
+
const slug = boardSlugFromFileName(boardPath);
|
|
2106
|
+
if (exclude.indexOf(slug) !== -1) {
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
let boardData = null;
|
|
2110
|
+
try {
|
|
2111
|
+
boardData = parseIndex.md2json(await fs.promises.readFile(boardPath, { encoding: "utf-8" }));
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
boards.push({
|
|
2116
|
+
slug,
|
|
2117
|
+
path: boardPath,
|
|
2118
|
+
name: boardData.name,
|
|
2119
|
+
description: boardData.description,
|
|
2120
|
+
main: slug === mainBoardSlug,
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// Order: anything named in the config order first, in that order, then the main board, then the
|
|
2125
|
+
// rest alphabetically
|
|
2126
|
+
boards.sort((a, b) => {
|
|
2127
|
+
const aOrder = order.indexOf(a.slug), bOrder = order.indexOf(b.slug);
|
|
2128
|
+
if (aOrder !== -1 || bOrder !== -1) {
|
|
2129
|
+
if (aOrder === -1) { return 1; }
|
|
2130
|
+
if (bOrder === -1) { return -1; }
|
|
2131
|
+
return aOrder - bOrder;
|
|
2132
|
+
}
|
|
2133
|
+
if (a.main !== b.main) {
|
|
2134
|
+
return a.main ? -1 : 1;
|
|
2135
|
+
}
|
|
2136
|
+
return a.slug.localeCompare(b.slug);
|
|
2137
|
+
});
|
|
2138
|
+
return boards;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/**
|
|
2142
|
+
* Work out which board a command should target: the --board argument, then the KANBN_BOARD
|
|
2143
|
+
* environment variable, then the defaultBoard option, then the main board
|
|
2144
|
+
* @param {?string} [slug=null] The board slug given on the command line, if any
|
|
2145
|
+
* @return {Promise<?string>} The target board slug, or null for the main board
|
|
2146
|
+
*/
|
|
2147
|
+
async resolveTargetBoard(slug = null) {
|
|
2148
|
+
if (slug !== null && slug !== undefined && String(slug).trim() !== "") {
|
|
2149
|
+
return String(slug).trim();
|
|
2150
|
+
}
|
|
2151
|
+
if (process.env.KANBN_BOARD && process.env.KANBN_BOARD.trim() !== "") {
|
|
2152
|
+
return process.env.KANBN_BOARD.trim();
|
|
2153
|
+
}
|
|
2154
|
+
const workspaceOptions = await this.getWorkspaceOptions();
|
|
2155
|
+
if (workspaceOptions.defaultBoard && String(workspaceOptions.defaultBoard).trim() !== "") {
|
|
2156
|
+
return String(workspaceOptions.defaultBoard).trim();
|
|
2157
|
+
}
|
|
2158
|
+
return null;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* Get a Kanbn instance scoped to the board a command's arguments point at
|
|
2163
|
+
* @param {object} [args={}] The parsed command arguments
|
|
2164
|
+
* @return {Promise<Kanbn>} A board-scoped Kanbn instance
|
|
2165
|
+
*/
|
|
2166
|
+
async boardFromArgs(args = {}) {
|
|
2167
|
+
return this.board(await this.resolveTargetBoard(utility.strArg(args.board) || null));
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* List boards with the information `kanbn boards` displays: everything listBoards() returns, plus
|
|
2172
|
+
* column and task counts, completion percentage and the file's last modified date
|
|
2173
|
+
* @return {Promise<object[]>} A list of boards with summary information
|
|
2174
|
+
*/
|
|
2175
|
+
async getBoardsSummary() {
|
|
2176
|
+
const boards = await this.listBoards();
|
|
2177
|
+
const result = [];
|
|
2178
|
+
for (const board of boards) {
|
|
2179
|
+
const summary = { ...board, columns: 0, tasks: 0, completed: 0, completedPercentage: 0, modified: null };
|
|
2180
|
+
try {
|
|
2181
|
+
const boardData = await this.loadBoard(board.slug);
|
|
2182
|
+
const tasks = await this.loadAllTrackedTasks(boardData);
|
|
2183
|
+
summary.columns = Object.keys(boardData.columns).length;
|
|
2184
|
+
summary.tasks = tasks.length;
|
|
2185
|
+
summary.completed = tasks.filter((task) => taskCompleted(boardData, task)).length;
|
|
2186
|
+
summary.completedPercentage = summary.tasks
|
|
2187
|
+
? Math.round((summary.completed / summary.tasks) * 100)
|
|
2188
|
+
: 0;
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
// A board that can't be loaded is still worth listing; validate reports the reason
|
|
2191
|
+
}
|
|
2192
|
+
try {
|
|
2193
|
+
summary.modified = (await fs.promises.stat(board.path)).mtime;
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
summary.modified = null;
|
|
2196
|
+
}
|
|
2197
|
+
result.push(summary);
|
|
2198
|
+
}
|
|
2199
|
+
return result;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Get every task that appears on more than one board, with the column it occupies on each
|
|
2204
|
+
* @param {boolean} [allTasks=false] True to include tasks that only appear on one board
|
|
2205
|
+
* @return {Promise<object[]>} A list of tasks and their board membership
|
|
2206
|
+
*/
|
|
2207
|
+
async getCrossBoardTasks(allTasks = false) {
|
|
2208
|
+
const membership = {};
|
|
2209
|
+
for (const board of await this.listBoards()) {
|
|
2210
|
+
let boardData = null;
|
|
2211
|
+
try {
|
|
2212
|
+
boardData = await this.loadBoard(board.slug);
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
2217
|
+
for (const taskId of taskIds) {
|
|
2218
|
+
if (!(taskId in membership)) {
|
|
2219
|
+
membership[taskId] = {};
|
|
2220
|
+
}
|
|
2221
|
+
membership[taskId][board.slug] = columnName;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
return Object.entries(membership)
|
|
2226
|
+
.filter(([, boards]) => allTasks || Object.keys(boards).length > 1)
|
|
2227
|
+
.map(([id, boards]) => ({ id, boards }))
|
|
2228
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* Get the value to record in a history event's `board` key for this instance's board. The main
|
|
2233
|
+
* board records nothing, which is what keeps existing task files valid and unchanged
|
|
2234
|
+
* @return {Promise<?string>} The board slug, or null for the main board
|
|
2235
|
+
*/
|
|
2236
|
+
async historyBoard() {
|
|
2237
|
+
return (await this.isMainBoard()) ? null : await this.resolveBoardSlug();
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Check if a board exists
|
|
2242
|
+
* @param {string} slug The board slug
|
|
2243
|
+
* @return {Promise<boolean>} True if the board file exists
|
|
2244
|
+
*/
|
|
2245
|
+
async boardExists(slug) {
|
|
2246
|
+
return await exists(await this.getBoardPath(slug));
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Create a new board
|
|
2251
|
+
* @param {string} slug The board slug
|
|
2252
|
+
* @param {object} [options={}] The board's name, description, columns and options
|
|
2253
|
+
* @return {Promise<string>} The created board's slug
|
|
2254
|
+
*/
|
|
2255
|
+
async createBoard(slug, options = {}) {
|
|
2256
|
+
const validatedSlug = await this.validateBoardSlug(slug);
|
|
2257
|
+
if (await this.boardExists(validatedSlug)) {
|
|
2258
|
+
throw new Error(`Board "${validatedSlug}" already exists`);
|
|
2259
|
+
}
|
|
2260
|
+
if (!(await this.workspaceInitialised())) {
|
|
2261
|
+
throw new Error("Not initialised in this folder");
|
|
2262
|
+
}
|
|
2263
|
+
const columns =
|
|
2264
|
+
"columns" in options && options.columns.length ? options.columns : defaultInitialiseOptions.columns;
|
|
2265
|
+
|
|
2266
|
+
// A new board only picks up the default started and completed columns if it actually has columns
|
|
2267
|
+
// by those names - a design board with Ideas/Designing/Signed Off shouldn't silently claim that
|
|
2268
|
+
// "In Progress" started work and "Done" finished it
|
|
2269
|
+
const boardOptions = "options" in options ? { ...options.options } : {};
|
|
2270
|
+
for (const [key, defaultColumns] of Object.entries(defaultInitialiseOptions.options)) {
|
|
2271
|
+
if (key in boardOptions) {
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
const matchingColumns = defaultColumns.filter((columnName) => columns.indexOf(columnName) !== -1);
|
|
2275
|
+
if (matchingColumns.length) {
|
|
2276
|
+
boardOptions[key] = matchingColumns;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
const board = {
|
|
2281
|
+
name: "name" in options && options.name ? options.name : validatedSlug,
|
|
2282
|
+
description: "description" in options ? options.description : "",
|
|
2283
|
+
options: boardOptions,
|
|
2284
|
+
columns: Object.fromEntries(columns.map((columnName) => [columnName, []])),
|
|
2285
|
+
};
|
|
2286
|
+
|
|
2287
|
+
// Options passed to createBoard are deliberate, so they're written to the board file even when
|
|
2288
|
+
// the workspace happens to provide the same value
|
|
2289
|
+
setOwnOptions(board, boardOptions);
|
|
2290
|
+
await this.saveBoard(validatedSlug, board);
|
|
2291
|
+
return validatedSlug;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Delete a board file. Tasks referenced only by this board become untracked; their files are left
|
|
2296
|
+
* alone, since boards own membership and tasks are shared
|
|
2297
|
+
* @param {string} slug The board slug
|
|
2298
|
+
* @return {Promise<string[]>} The ids of tasks that are no longer referenced by any board
|
|
2299
|
+
*/
|
|
2300
|
+
async deleteBoard(slug) {
|
|
2301
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2302
|
+
if (await this.isMainBoard(resolvedSlug)) {
|
|
2303
|
+
throw new Error("The main board cannot be deleted");
|
|
2304
|
+
}
|
|
2305
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
2306
|
+
throw new Error(`Board "${resolvedSlug}" doesn't exist`);
|
|
2307
|
+
}
|
|
2308
|
+
const orphaned = await this.findOrphanedTasks(resolvedSlug);
|
|
2309
|
+
await fs.promises.unlink(await this.getBoardPath(resolvedSlug));
|
|
2310
|
+
return orphaned;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Find the tasks that would become untracked if a board were deleted, i.e. the tasks it references
|
|
2315
|
+
* that no other board references
|
|
2316
|
+
* @param {string} slug The board slug
|
|
2317
|
+
* @return {Promise<string[]>} The ids of tasks referenced only by this board
|
|
2318
|
+
*/
|
|
2319
|
+
async findOrphanedTasks(slug) {
|
|
2320
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2321
|
+
const taskIds = [...getTrackedTaskIds(await this.loadBoard(resolvedSlug))];
|
|
2322
|
+
const orphaned = [];
|
|
2323
|
+
for (const taskId of taskIds) {
|
|
2324
|
+
const boards = await this.findTaskBoards(taskId);
|
|
2325
|
+
if (Object.keys(boards).filter((boardSlug) => boardSlug !== resolvedSlug).length === 0) {
|
|
2326
|
+
orphaned.push(taskId);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
return orphaned;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/**
|
|
2333
|
+
* Rename a board
|
|
2334
|
+
* @param {string} slug The board slug
|
|
2335
|
+
* @param {string} newSlug The new board slug
|
|
2336
|
+
* @param {?string} [newName=null] An optional new display name for the board
|
|
2337
|
+
* @return {Promise<string>} The new slug
|
|
2338
|
+
*/
|
|
2339
|
+
async renameBoard(slug, newSlug, newName = null) {
|
|
2340
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2341
|
+
if (await this.isMainBoard(resolvedSlug)) {
|
|
2342
|
+
throw new Error("The main board cannot be renamed");
|
|
2343
|
+
}
|
|
2344
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
2345
|
+
throw new Error(`Board "${resolvedSlug}" doesn't exist`);
|
|
2346
|
+
}
|
|
2347
|
+
const validatedSlug = await this.validateBoardSlug(newSlug);
|
|
2348
|
+
if (validatedSlug !== resolvedSlug && (await this.boardExists(validatedSlug))) {
|
|
2349
|
+
throw new Error(`Board "${validatedSlug}" already exists`);
|
|
2350
|
+
}
|
|
2351
|
+
const board = await this.loadBoard(resolvedSlug);
|
|
2352
|
+
if (newName !== null) {
|
|
2353
|
+
board.name = newName;
|
|
2354
|
+
}
|
|
2355
|
+
if (validatedSlug !== resolvedSlug) {
|
|
2356
|
+
await fs.promises.rename(await this.getBoardPath(resolvedSlug), await this.getBoardPath(validatedSlug));
|
|
2357
|
+
}
|
|
2358
|
+
await this.saveBoard(validatedSlug, board);
|
|
2359
|
+
return validatedSlug;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Find every board that references a task, and the column it occupies on each
|
|
2364
|
+
* @param {string} taskId The task id
|
|
2365
|
+
* @return {Promise<Record<string, string>>} A map of board slug to column name
|
|
2366
|
+
*/
|
|
2367
|
+
async findTaskBoards(taskId) {
|
|
2368
|
+
const result = {};
|
|
2369
|
+
for (const board of await this.listBoards()) {
|
|
2370
|
+
let boardData = null;
|
|
2371
|
+
try {
|
|
2372
|
+
boardData = await this.loadBoard(board.slug);
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
const columnName = findTaskColumn(boardData, taskId);
|
|
2377
|
+
if (columnName !== null) {
|
|
2378
|
+
result[board.slug] = columnName;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return result;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* Alias for findTaskBoards(), used when displaying a task's board membership
|
|
2386
|
+
* @param {string} taskId The task id
|
|
2387
|
+
* @return {Promise<Record<string, string>>} A map of board slug to column name
|
|
2388
|
+
*/
|
|
2389
|
+
async getTaskBoardColumns(taskId) {
|
|
2390
|
+
return this.findTaskBoards(taskId);
|
|
2391
|
+
}
|
|
2392
|
+
|
|
1651
2393
|
/**
|
|
1652
2394
|
* Initialise a kanbn board in the current working directory
|
|
1653
2395
|
* @param {object} [options={}] Initial columns and other config options
|
|
1654
2396
|
*/
|
|
1655
2397
|
async initialise(options = {}) {
|
|
2398
|
+
// A board-scoped instance initialises its own board rather than the workspace
|
|
2399
|
+
if (!(await this.isMainBoard())) {
|
|
2400
|
+
return this.initialiseBoard(this.boardSlug, options);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
1656
2403
|
// Check if a main folder is defined in an existing config file
|
|
1657
2404
|
const mainFolder = await this.getMainFolder();
|
|
1658
2405
|
|
|
@@ -1703,6 +2450,52 @@ class Kanbn {
|
|
|
1703
2450
|
await this.saveIndex(index);
|
|
1704
2451
|
}
|
|
1705
2452
|
|
|
2453
|
+
/**
|
|
2454
|
+
* Create a secondary board, or update an existing one's name, description, columns and options
|
|
2455
|
+
* @param {string} slug The board slug
|
|
2456
|
+
* @param {object} [options={}] The board's name, description, columns and options
|
|
2457
|
+
* @return {Promise<string>} The board slug
|
|
2458
|
+
*/
|
|
2459
|
+
async initialiseBoard(slug, options = {}) {
|
|
2460
|
+
const resolvedSlug = await this.resolveBoardSlug(slug);
|
|
2461
|
+
if (!(await this.boardExists(resolvedSlug))) {
|
|
2462
|
+
return this.createBoard(resolvedSlug, options);
|
|
2463
|
+
}
|
|
2464
|
+
if (Object.keys(options).length === 0) {
|
|
2465
|
+
return resolvedSlug;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// The board already exists, so update it in the same way initialise() updates the main board
|
|
2469
|
+
const board = await this.loadBoard(resolvedSlug);
|
|
2470
|
+
"name" in options && options.name && (board.name = options.name);
|
|
2471
|
+
"description" in options && (board.description = options.description);
|
|
2472
|
+
if ("options" in options) {
|
|
2473
|
+
board.options = Object.assign(board.options, options.options);
|
|
2474
|
+
setOwnOptions(board, { ...board.ownOptions, ...options.options });
|
|
2475
|
+
}
|
|
2476
|
+
"columns" in options &&
|
|
2477
|
+
(board.columns = Object.assign(
|
|
2478
|
+
board.columns,
|
|
2479
|
+
Object.fromEntries(
|
|
2480
|
+
options.columns.map((columnName) => [
|
|
2481
|
+
columnName,
|
|
2482
|
+
columnName in board.columns ? board.columns[columnName] : [],
|
|
2483
|
+
])
|
|
2484
|
+
)
|
|
2485
|
+
));
|
|
2486
|
+
await this.saveBoard(resolvedSlug, board);
|
|
2487
|
+
return resolvedSlug;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Check if a task file exists, regardless of whether any board references it
|
|
2492
|
+
* @param {string} taskId The task id to check
|
|
2493
|
+
* @return {Promise<boolean>} True if the task file exists
|
|
2494
|
+
*/
|
|
2495
|
+
async taskFileExists(taskId) {
|
|
2496
|
+
return exists(getTaskPath(await this.getTaskFolderPath(), removeFileExtension(taskId)));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
1706
2499
|
/**
|
|
1707
2500
|
* Check if a task file exists and is in the index, otherwise throw an error
|
|
1708
2501
|
* @param {string} taskId The task id to check
|
|
@@ -1786,19 +2579,24 @@ class Kanbn {
|
|
|
1786
2579
|
throw new Error(`A task with id "${taskId}" is already in the index`);
|
|
1787
2580
|
}
|
|
1788
2581
|
|
|
2582
|
+
// Stamp every date this call writes with the same timestamp, so that the created date and the
|
|
2583
|
+
// column-linked dates can't disagree by a millisecond
|
|
2584
|
+
const now = new Date();
|
|
2585
|
+
|
|
1789
2586
|
// Set the created date
|
|
1790
|
-
taskData = setTaskMetadata(taskData, "created",
|
|
2587
|
+
taskData = setTaskMetadata(taskData, "created", now);
|
|
1791
2588
|
|
|
1792
2589
|
// Add initial history event
|
|
1793
2590
|
taskData = appendTaskHistory(taskData, {
|
|
2591
|
+
date: now,
|
|
1794
2592
|
type: 'created',
|
|
1795
2593
|
column: columnName,
|
|
1796
2594
|
fromProgress: 0,
|
|
1797
2595
|
toProgress: getTaskMetadata(taskData, 'progress') || 0
|
|
1798
|
-
});
|
|
2596
|
+
}, await this.historyBoard());
|
|
1799
2597
|
|
|
1800
2598
|
// Update task metadata dates
|
|
1801
|
-
taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
|
|
2599
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
1802
2600
|
await this.saveTask(taskPath, taskData);
|
|
1803
2601
|
|
|
1804
2602
|
// Add the task to the index
|
|
@@ -1839,9 +2637,18 @@ class Kanbn {
|
|
|
1839
2637
|
// Load task data
|
|
1840
2638
|
let taskData = await this.loadTask(taskId);
|
|
1841
2639
|
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
2640
|
+
const now = new Date();
|
|
2641
|
+
|
|
2642
|
+
// Record the task joining this board. Board membership has no other representation in a task
|
|
2643
|
+
// file, so without this event a board's history has no record of the task ever arriving
|
|
2644
|
+
taskData = appendTaskHistory(taskData, {
|
|
2645
|
+
date: now,
|
|
2646
|
+
type: 'added',
|
|
2647
|
+
column: columnName
|
|
2648
|
+
}, await this.historyBoard());
|
|
1842
2649
|
|
|
1843
2650
|
// Update task metadata dates
|
|
1844
|
-
taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
|
|
2651
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
1845
2652
|
await this.saveTask(taskPath, taskData);
|
|
1846
2653
|
|
|
1847
2654
|
// Add the task to the column and save the index
|
|
@@ -1850,6 +2657,17 @@ class Kanbn {
|
|
|
1850
2657
|
return taskId;
|
|
1851
2658
|
}
|
|
1852
2659
|
|
|
2660
|
+
/**
|
|
2661
|
+
* Add an existing task to this board. Boards own membership, so the same task file can be added to
|
|
2662
|
+
* any number of boards, in a different column on each
|
|
2663
|
+
* @param {string} taskId The task id
|
|
2664
|
+
* @param {string} columnName The column to add the task to
|
|
2665
|
+
* @return {Promise<string>} The id of the task that was added
|
|
2666
|
+
*/
|
|
2667
|
+
async addTaskToBoard(taskId, columnName) {
|
|
2668
|
+
return this.addUntrackedTaskToIndex(taskId, columnName);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
1853
2671
|
/**
|
|
1854
2672
|
* Get a list of tracked tasks (i.e. tasks that are listed in the index)
|
|
1855
2673
|
* @param {?string} [columnName=null] The optional column name to filter tasks by
|
|
@@ -1888,6 +2706,67 @@ class Kanbn {
|
|
|
1888
2706
|
return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
|
|
1889
2707
|
}
|
|
1890
2708
|
|
|
2709
|
+
/**
|
|
2710
|
+
* Find tasks that no board references at all. "Tracked" is workspace-scoped: a task is tracked if
|
|
2711
|
+
* any board references it, so a task can be missing from this board and still be tracked
|
|
2712
|
+
* @return {Promise<Set<string>>} A set of untracked task ids
|
|
2713
|
+
*/
|
|
2714
|
+
async findWorkspaceUntrackedTasks() {
|
|
2715
|
+
if (!(await this.workspaceInitialised())) {
|
|
2716
|
+
throw new Error("Not initialised in this folder");
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// Collect the tasks referenced by every board
|
|
2720
|
+
const trackedTasks = new Set();
|
|
2721
|
+
for (const board of await this.listBoards()) {
|
|
2722
|
+
try {
|
|
2723
|
+
for (const taskId of getTrackedTaskIds(await this.loadBoard(board.slug))) {
|
|
2724
|
+
trackedTasks.add(taskId);
|
|
2725
|
+
}
|
|
2726
|
+
} catch (error) {
|
|
2727
|
+
continue;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Get all tasks in the tasks folder and return the set difference
|
|
2732
|
+
const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
|
|
2733
|
+
return new Set(files.map((task) => path.parse(task).name).filter((taskId) => !trackedTasks.has(taskId)));
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/**
|
|
2737
|
+
* Find tasks that other boards track but this one doesn't - the "what could I pull onto this board"
|
|
2738
|
+
* list
|
|
2739
|
+
* @return {Promise<Record<string, Record<string, string>>>} A map of task id to board slug to column
|
|
2740
|
+
*/
|
|
2741
|
+
async findTasksOnOtherBoards() {
|
|
2742
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
2743
|
+
const trackedHere = getTrackedTaskIds(await this.loadIndex());
|
|
2744
|
+
const result = {};
|
|
2745
|
+
for (const board of await this.listBoards()) {
|
|
2746
|
+
if (board.slug === thisBoardSlug) {
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
let boardData = null;
|
|
2750
|
+
try {
|
|
2751
|
+
boardData = await this.loadBoard(board.slug);
|
|
2752
|
+
} catch (error) {
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
2756
|
+
for (const taskId of taskIds) {
|
|
2757
|
+
if (trackedHere.has(taskId)) {
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
if (!(taskId in result)) {
|
|
2761
|
+
result[taskId] = {};
|
|
2762
|
+
}
|
|
2763
|
+
result[taskId][board.slug] = columnName;
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return result;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
1891
2770
|
/**
|
|
1892
2771
|
* Update an existing task
|
|
1893
2772
|
* @param {string} taskId The id of the task to update
|
|
@@ -1933,14 +2812,19 @@ class Kanbn {
|
|
|
1933
2812
|
throw new Error(`Column "${columnName}" doesn't exist`);
|
|
1934
2813
|
}
|
|
1935
2814
|
|
|
2815
|
+
// Stamp every date this call writes with the same timestamp, so that the updated date and the
|
|
2816
|
+
// history event it records can't disagree by a millisecond
|
|
2817
|
+
const now = new Date();
|
|
2818
|
+
|
|
1936
2819
|
// Set the updated date
|
|
1937
|
-
taskData = setTaskMetadata(taskData, "updated",
|
|
2820
|
+
taskData = setTaskMetadata(taskData, "updated", now);
|
|
1938
2821
|
|
|
1939
2822
|
// Add history for progress changes only
|
|
1940
2823
|
const originalProgress = getTaskMetadata(originalTaskData, 'progress') || 0;
|
|
1941
2824
|
const updatedProgress = getTaskMetadata(taskData, 'progress') || 0;
|
|
1942
2825
|
if (originalProgress !== updatedProgress) {
|
|
1943
2826
|
taskData = appendTaskHistory(taskData, {
|
|
2827
|
+
date: now,
|
|
1944
2828
|
type: 'progress',
|
|
1945
2829
|
fromProgress: originalProgress,
|
|
1946
2830
|
toProgress: updatedProgress
|
|
@@ -2005,10 +2889,20 @@ class Kanbn {
|
|
|
2005
2889
|
throw new Error(`A task with id "${newTaskId}" already exists`);
|
|
2006
2890
|
}
|
|
2007
2891
|
|
|
2008
|
-
// Check that a task with the new id isn't already indexed
|
|
2892
|
+
// Check that a task with the new id isn't already indexed, on this board or any other - the id
|
|
2893
|
+
// is the file name, so it has to be free everywhere
|
|
2009
2894
|
if (taskInIndex(index, newTaskId)) {
|
|
2010
2895
|
throw new Error(`A task with id "${newTaskId}" is already in the index`);
|
|
2011
2896
|
}
|
|
2897
|
+
const boardsWithNewId = Object.keys(await this.findTaskBoards(newTaskId));
|
|
2898
|
+
if (boardsWithNewId.length) {
|
|
2899
|
+
throw new Error(
|
|
2900
|
+
`A task with id "${newTaskId}" is already on board "${boardsWithNewId[0]}"`
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Note every board that references this task before anything is written
|
|
2905
|
+
const otherBoards = await this.findTaskBoards(taskId);
|
|
2012
2906
|
|
|
2013
2907
|
// Update the task name and updated date
|
|
2014
2908
|
let taskData = await this.loadTask(taskId);
|
|
@@ -2019,7 +2913,17 @@ class Kanbn {
|
|
|
2019
2913
|
// Rename the task file
|
|
2020
2914
|
await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
|
|
2021
2915
|
|
|
2022
|
-
// Update the task id in the index
|
|
2916
|
+
// Update the task id in the index, and in every other board that references it - the task file
|
|
2917
|
+
// has moved, so a board still pointing at the old id would have a broken link
|
|
2918
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
2919
|
+
for (const slug of Object.keys(otherBoards)) {
|
|
2920
|
+
if (slug === thisBoardSlug) {
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
const otherBoard = this.board(slug);
|
|
2924
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
2925
|
+
await otherBoard.saveIndex(renameTaskInIndex(otherIndex, taskId, newTaskId));
|
|
2926
|
+
}
|
|
2023
2927
|
index = renameTaskInIndex(index, taskId, newTaskId);
|
|
2024
2928
|
await this.saveIndex(index);
|
|
2025
2929
|
return newTaskId;
|
|
@@ -2031,9 +2935,10 @@ class Kanbn {
|
|
|
2031
2935
|
* @param {string} columnName The name of the column that the task will be moved to
|
|
2032
2936
|
* @param {?number} [position=null] The position to move the task to within the target column
|
|
2033
2937
|
* @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
|
|
2938
|
+
* @param {boolean} [add=false] Add the task to this board if it isn't on it yet (secondary boards only)
|
|
2034
2939
|
* @return {Promise<string>} The id of the task that was moved
|
|
2035
2940
|
*/
|
|
2036
|
-
async moveTask(taskId, columnName, position = null, relative = false) {
|
|
2941
|
+
async moveTask(taskId, columnName, position = null, relative = false, add = false) {
|
|
2037
2942
|
// Check if this folder has been initialised
|
|
2038
2943
|
if (!(await this.initialised())) {
|
|
2039
2944
|
throw new Error("Not initialised in this folder");
|
|
@@ -2048,7 +2953,18 @@ class Kanbn {
|
|
|
2048
2953
|
// Get index and make sure the task is indexed
|
|
2049
2954
|
let index = await this.loadIndex();
|
|
2050
2955
|
if (!taskInIndex(index, taskId)) {
|
|
2051
|
-
|
|
2956
|
+
|
|
2957
|
+
// Moving a task onto a board it isn't on yet adds it, so that membership doesn't have to be a
|
|
2958
|
+
// separate step. Only on a secondary board: on the main board this stays the error it has
|
|
2959
|
+
// always been
|
|
2960
|
+
if (!add || (await this.isMainBoard())) {
|
|
2961
|
+
throw new Error(`Task "${taskId}" is not in the index`);
|
|
2962
|
+
}
|
|
2963
|
+
if (!(columnName in index.columns)) {
|
|
2964
|
+
throw new Error(`Column "${columnName}" doesn't exist`);
|
|
2965
|
+
}
|
|
2966
|
+
await this.addTaskToBoard(taskId, columnName);
|
|
2967
|
+
return taskId;
|
|
2052
2968
|
}
|
|
2053
2969
|
|
|
2054
2970
|
// Make sure the target column exists
|
|
@@ -2074,7 +2990,7 @@ class Kanbn {
|
|
|
2074
2990
|
type: 'moved',
|
|
2075
2991
|
fromColumn: currentColumnName,
|
|
2076
2992
|
toColumn: columnName
|
|
2077
|
-
});
|
|
2993
|
+
}, await this.historyBoard());
|
|
2078
2994
|
}
|
|
2079
2995
|
|
|
2080
2996
|
// Update task metadata dates
|
|
@@ -2101,9 +3017,10 @@ class Kanbn {
|
|
|
2101
3017
|
* Remove a task from the index and optionally delete the task file as well
|
|
2102
3018
|
* @param {string} taskId The id of the task to remove
|
|
2103
3019
|
* @param {boolean} [removeFile=false] True if the task file should be removed
|
|
3020
|
+
* @param {boolean} [allBoards=false] True to remove the task from every board that references it
|
|
2104
3021
|
* @return {Promise<string>} The id of the task that was deleted
|
|
2105
3022
|
*/
|
|
2106
|
-
async deleteTask(taskId, removeFile = false) {
|
|
3023
|
+
async deleteTask(taskId, removeFile = false, allBoards = false) {
|
|
2107
3024
|
// Check if this folder has been initialised
|
|
2108
3025
|
if (!(await this.initialised())) {
|
|
2109
3026
|
throw new Error("Not initialised in this folder");
|
|
@@ -2116,10 +3033,40 @@ class Kanbn {
|
|
|
2116
3033
|
throw new Error(`Task "${taskId}" is not in the index`);
|
|
2117
3034
|
}
|
|
2118
3035
|
|
|
3036
|
+
// A task file is shared, so deleting it out from under another board would break that board.
|
|
3037
|
+
// Removing the task from this board only is always safe, and is what happens without --all-boards
|
|
3038
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
3039
|
+
const otherBoards = Object.keys(await this.findTaskBoards(taskId)).filter((slug) => slug !== thisBoardSlug);
|
|
3040
|
+
if (removeFile && otherBoards.length && !allBoards) {
|
|
3041
|
+
throw new Error(
|
|
3042
|
+
`Task "${taskId}" is on ${otherBoards.length} other ${otherBoards.length === 1 ? "board" : "boards"} ` +
|
|
3043
|
+
`(${otherBoards.join(", ")})`
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
2119
3047
|
// Remove the task from whichever column it's in
|
|
3048
|
+
const columnName = findTaskColumn(index, taskId);
|
|
2120
3049
|
index = removeTaskFromIndex(index, taskId);
|
|
2121
3050
|
|
|
2122
|
-
//
|
|
3051
|
+
// Record the task leaving this board, but only when the task file survives - a removed event on
|
|
3052
|
+
// a file that's about to be deleted records nothing anyone can read
|
|
3053
|
+
if (!removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
|
|
3054
|
+
let taskData = await this.loadTask(taskId);
|
|
3055
|
+
taskData = appendTaskHistory(taskData, {
|
|
3056
|
+
type: 'removed',
|
|
3057
|
+
fromColumn: columnName
|
|
3058
|
+
}, await this.historyBoard());
|
|
3059
|
+
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// Optionally remove the task from every other board, and remove the task file as well
|
|
3063
|
+
if (allBoards) {
|
|
3064
|
+
for (const slug of otherBoards) {
|
|
3065
|
+
const otherBoard = this.board(slug);
|
|
3066
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
3067
|
+
await otherBoard.saveIndex(removeTaskFromIndex(otherIndex, taskId));
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
2123
3070
|
if (removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
|
|
2124
3071
|
await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
|
|
2125
3072
|
}
|
|
@@ -2149,6 +3096,38 @@ class Kanbn {
|
|
|
2149
3096
|
});
|
|
2150
3097
|
}
|
|
2151
3098
|
|
|
3099
|
+
/**
|
|
3100
|
+
* Search every board in the workspace. A task on several boards appears once, annotated with the
|
|
3101
|
+
* board and column it occupies on each
|
|
3102
|
+
* @param {object} [filters={}] The filters to apply
|
|
3103
|
+
* @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
|
|
3104
|
+
* @return {Promise<object[]|string[]>} A list of matching tasks, or task ids
|
|
3105
|
+
*/
|
|
3106
|
+
async searchAllBoards(filters = {}, quiet = false) {
|
|
3107
|
+
if (!(await this.workspaceInitialised())) {
|
|
3108
|
+
throw new Error("Not initialised in this folder");
|
|
3109
|
+
}
|
|
3110
|
+
const results = new Map();
|
|
3111
|
+
for (const board of await this.listBoards()) {
|
|
3112
|
+
let matches = [];
|
|
3113
|
+
try {
|
|
3114
|
+
matches = await this.board(board.slug).search(filters, quiet);
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
continue;
|
|
3117
|
+
}
|
|
3118
|
+
for (const match of matches) {
|
|
3119
|
+
const taskId = quiet ? match : match.id;
|
|
3120
|
+
if (!results.has(taskId)) {
|
|
3121
|
+
results.set(taskId, quiet ? taskId : { ...match, boards: {} });
|
|
3122
|
+
}
|
|
3123
|
+
if (!quiet) {
|
|
3124
|
+
results.get(taskId).boards[board.slug] = match.column;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
return [...results.values()];
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2152
3131
|
/**
|
|
2153
3132
|
* Output project status information
|
|
2154
3133
|
* @param {boolean} [quiet=false] Output full or partial status information
|
|
@@ -2175,14 +3154,30 @@ class Kanbn {
|
|
|
2175
3154
|
name: index.name,
|
|
2176
3155
|
};
|
|
2177
3156
|
|
|
3157
|
+
// Name the board this status applies to, but only once there is more than one board - a
|
|
3158
|
+
// single-board workspace produces exactly the output it always has
|
|
3159
|
+
const boards = await this.listBoards();
|
|
3160
|
+
if (boards.length > 1) {
|
|
3161
|
+
result.board = await this.resolveBoardSlug();
|
|
3162
|
+
}
|
|
3163
|
+
|
|
2178
3164
|
// Get un-tracked tasks if required
|
|
2179
3165
|
if (untracked) {
|
|
2180
|
-
|
|
3166
|
+
|
|
3167
|
+
// Untracked means "on no board at all", which is workspace-scoped
|
|
3168
|
+
result.untrackedTasks = [...(await this.findWorkspaceUntrackedTasks())].map((taskId) => `${taskId}.md`);
|
|
2181
3169
|
|
|
2182
3170
|
// If output is quiet, output a list of untracked task filenames
|
|
2183
3171
|
if (quiet) {
|
|
2184
3172
|
return result.untrackedTasks;
|
|
2185
3173
|
}
|
|
3174
|
+
|
|
3175
|
+
// Tasks that are tracked, but not on this board. This is the useful half of the answer: it's
|
|
3176
|
+
// the list of work that could be pulled onto this board
|
|
3177
|
+
const tasksOnOtherBoards = await this.findTasksOnOtherBoards();
|
|
3178
|
+
if (Object.keys(tasksOnOtherBoards).length) {
|
|
3179
|
+
result.tasksOnOtherBoards = tasksOnOtherBoards;
|
|
3180
|
+
}
|
|
2186
3181
|
}
|
|
2187
3182
|
|
|
2188
3183
|
// Get basic project status information
|
|
@@ -2443,6 +3438,196 @@ class Kanbn {
|
|
|
2443
3438
|
return true;
|
|
2444
3439
|
}
|
|
2445
3440
|
|
|
3441
|
+
/**
|
|
3442
|
+
* Validate every board in the workspace, and the tasks each of them references
|
|
3443
|
+
* @param {boolean} [save=false] Re-save each board and its tasks
|
|
3444
|
+
* @return {Promise<object[]|boolean>} A list of errors, or true if there were none
|
|
3445
|
+
*/
|
|
3446
|
+
async validateAllBoards(save = false) {
|
|
3447
|
+
if (!(await this.workspaceInitialised())) {
|
|
3448
|
+
throw new Error("Not initialised in this folder");
|
|
3449
|
+
}
|
|
3450
|
+
const errors = [];
|
|
3451
|
+
for (const board of await this.listBoards()) {
|
|
3452
|
+
const result = await this.board(board.slug).validate(save);
|
|
3453
|
+
if (result !== true) {
|
|
3454
|
+
errors.push(...result.map((error) => ({ board: board.slug, ...error })));
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
return errors.length ? errors : true;
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
/**
|
|
3461
|
+
* Check a workspace for multi-board problems. These are reported rather than thrown: every one of
|
|
3462
|
+
* them is a workspace that still works, just not the way its author probably meant it to
|
|
3463
|
+
* @return {Promise<object[]>} A list of warnings, each with a type, a message and the board it applies to
|
|
3464
|
+
*/
|
|
3465
|
+
async findBoardWarnings() {
|
|
3466
|
+
if (!(await this.workspaceInitialised())) {
|
|
3467
|
+
throw new Error("Not initialised in this folder");
|
|
3468
|
+
}
|
|
3469
|
+
const warnings = [];
|
|
3470
|
+
const mainBoardSlug = await this.getMainBoardSlug();
|
|
3471
|
+
const boards = await this.listBoards();
|
|
3472
|
+
const boardSlugs = new Set(boards.map((board) => board.slug));
|
|
3473
|
+
const { exclude } = await this.getBoardsConfig();
|
|
3474
|
+
|
|
3475
|
+
// Markdown files beside the boards that don't parse as one. These are ignored during ordinary
|
|
3476
|
+
// commands, which is the right default, but the user should be able to find out why
|
|
3477
|
+
const mainFolder = await this.getMainFolder();
|
|
3478
|
+
for (const filePath of await glob(`${mainFolder}/*.md`)) {
|
|
3479
|
+
const slug = boardSlugFromFileName(filePath);
|
|
3480
|
+
if (boardSlugs.has(slug)) {
|
|
3481
|
+
continue;
|
|
3482
|
+
}
|
|
3483
|
+
if (exclude.indexOf(slug) !== -1) {
|
|
3484
|
+
warnings.push({
|
|
3485
|
+
board: slug,
|
|
3486
|
+
type: "excluded-board",
|
|
3487
|
+
message: `"${path.basename(filePath)}" is excluded by the boards.exclude config and isn't treated as a board`
|
|
3488
|
+
});
|
|
3489
|
+
continue;
|
|
3490
|
+
}
|
|
3491
|
+
try {
|
|
3492
|
+
parseIndex.md2json(await fs.promises.readFile(filePath, { encoding: "utf-8" }));
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
warnings.push({
|
|
3495
|
+
board: slug,
|
|
3496
|
+
type: "unparseable-board",
|
|
3497
|
+
message: `"${path.basename(filePath)}" doesn't parse as a board and is being ignored: ${error.message}`
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
const stampingBoards = [];
|
|
3503
|
+
const knownBoards = new Set(boardSlugs);
|
|
3504
|
+
const taskBoardColumns = {};
|
|
3505
|
+
for (const board of boards) {
|
|
3506
|
+
let boardData = null;
|
|
3507
|
+
try {
|
|
3508
|
+
boardData = await this.loadBoard(board.slug);
|
|
3509
|
+
} catch (error) {
|
|
3510
|
+
continue;
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// Workspace-scoped options in a secondary board's front matter are ignored, and silently
|
|
3514
|
+
// ignoring them would leave the user wondering why they had no effect
|
|
3515
|
+
if (board.slug !== mainBoardSlug) {
|
|
3516
|
+
const rawOptions = boardData.ownOptions || {};
|
|
3517
|
+
for (const key of Object.keys(rawOptions)) {
|
|
3518
|
+
if (WORKSPACE_SCOPED_OPTIONS.indexOf(key) !== -1) {
|
|
3519
|
+
warnings.push({
|
|
3520
|
+
board: board.slug,
|
|
3521
|
+
type: "workspace-scoped-option",
|
|
3522
|
+
message: `"${key}" is a workspace-scoped option and is ignored in a board file`
|
|
3523
|
+
});
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
// More than one board writing the same shared completed field means whichever board is touched
|
|
3529
|
+
// first silently owns that date for the whole workspace
|
|
3530
|
+
if ("completedColumns" in boardData.options && boardData.options.completedColumns.length) {
|
|
3531
|
+
stampingBoards.push({ slug: board.slug, field: getCompletedField(boardData) });
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
// Sprints are assumed to be in order: the last one is taken as the current sprint, and each
|
|
3535
|
+
// one runs until the next one starts
|
|
3536
|
+
const sprints = "sprints" in boardData.options ? boardData.options.sprints : [];
|
|
3537
|
+
for (let i = 1; i < sprints.length; i++) {
|
|
3538
|
+
if (new Date(sprints[i].start) < new Date(sprints[i - 1].start)) {
|
|
3539
|
+
warnings.push({
|
|
3540
|
+
board: board.slug,
|
|
3541
|
+
type: "sprints-out-of-order",
|
|
3542
|
+
message: `sprint "${sprints[i].name}" starts before "${sprints[i - 1].name}"`
|
|
3543
|
+
});
|
|
3544
|
+
break;
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
// A task can only be in one column per board
|
|
3549
|
+
for (const [columnName, taskIds] of Object.entries(boardData.columns)) {
|
|
3550
|
+
for (const taskId of taskIds) {
|
|
3551
|
+
if (!(taskId in taskBoardColumns)) {
|
|
3552
|
+
taskBoardColumns[taskId] = {};
|
|
3553
|
+
}
|
|
3554
|
+
if (board.slug in taskBoardColumns[taskId]) {
|
|
3555
|
+
warnings.push({
|
|
3556
|
+
board: board.slug,
|
|
3557
|
+
type: "duplicate-task",
|
|
3558
|
+
message: `task "${taskId}" appears in both "${taskBoardColumns[taskId][board.slug]}" and "${columnName}"`
|
|
3559
|
+
});
|
|
3560
|
+
} else {
|
|
3561
|
+
taskBoardColumns[taskId][board.slug] = columnName;
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
// Several boards stamping the same shared field
|
|
3568
|
+
const fields = {};
|
|
3569
|
+
for (const { slug, field } of stampingBoards) {
|
|
3570
|
+
if (!(field in fields)) {
|
|
3571
|
+
fields[field] = [];
|
|
3572
|
+
}
|
|
3573
|
+
fields[field].push(slug);
|
|
3574
|
+
}
|
|
3575
|
+
for (const [field, slugs] of Object.entries(fields)) {
|
|
3576
|
+
if (slugs.length > 1) {
|
|
3577
|
+
warnings.push({
|
|
3578
|
+
board: null,
|
|
3579
|
+
type: "multiple-stamping-authorities",
|
|
3580
|
+
message:
|
|
3581
|
+
`boards ${slugs.map((slug) => `"${slug}"`).join(", ")} all stamp "${field}" - ` +
|
|
3582
|
+
'whichever is touched first owns that date for every board'
|
|
3583
|
+
});
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
// Tasks referenced by a board with no file behind them
|
|
3588
|
+
for (const [taskId, taskBoards] of Object.entries(taskBoardColumns)) {
|
|
3589
|
+
if (!(await this.taskFileExists(taskId))) {
|
|
3590
|
+
warnings.push({
|
|
3591
|
+
board: Object.keys(taskBoards)[0],
|
|
3592
|
+
type: "missing-task-file",
|
|
3593
|
+
message: `task "${taskId}" is referenced by ${Object.keys(taskBoards).join(", ")} but has no file`
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
// Tasks that no board references at all
|
|
3599
|
+
for (const taskId of await this.findWorkspaceUntrackedTasks()) {
|
|
3600
|
+
warnings.push({
|
|
3601
|
+
board: null,
|
|
3602
|
+
type: "untracked-task",
|
|
3603
|
+
message: `task "${taskId}" isn't on any board`
|
|
3604
|
+
});
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
// History events naming a board that has been deleted. These never match a board during replay,
|
|
3608
|
+
// so they're harmless - but they're the only trace a deleted board leaves behind
|
|
3609
|
+
const reportedBoards = new Set();
|
|
3610
|
+
for (const taskId of Object.keys(taskBoardColumns)) {
|
|
3611
|
+
let taskData = null;
|
|
3612
|
+
try {
|
|
3613
|
+
taskData = await this.loadTask(taskId);
|
|
3614
|
+
} catch (error) {
|
|
3615
|
+
continue;
|
|
3616
|
+
}
|
|
3617
|
+
for (const historyEvent of taskData.history || []) {
|
|
3618
|
+
if (historyEvent.board && !knownBoards.has(historyEvent.board) && !reportedBoards.has(historyEvent.board)) {
|
|
3619
|
+
reportedBoards.add(historyEvent.board);
|
|
3620
|
+
warnings.push({
|
|
3621
|
+
board: historyEvent.board,
|
|
3622
|
+
type: "unknown-board-in-history",
|
|
3623
|
+
message: `history events name board "${historyEvent.board}", which doesn't exist`
|
|
3624
|
+
});
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
return warnings;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
2446
3631
|
/**
|
|
2447
3632
|
* Find tasks whose started/completed dates disagree with the column they're in
|
|
2448
3633
|
*
|
|
@@ -2608,19 +3793,28 @@ class Kanbn {
|
|
|
2608
3793
|
throw new Error("Not initialised in this folder");
|
|
2609
3794
|
}
|
|
2610
3795
|
|
|
2611
|
-
//
|
|
3796
|
+
// Sprints are workspace-level unless a board declares its own list. Adding a sprint from a
|
|
3797
|
+
// secondary board that hasn't declared one appends to the workspace list rather than quietly
|
|
3798
|
+
// forking it - a fork would be invisible and would freeze that board out of every future
|
|
3799
|
+
// workspace sprint. Creating a board-local list stays a deliberate front-matter edit
|
|
2612
3800
|
const index = await this.loadIndex();
|
|
2613
|
-
|
|
2614
|
-
|
|
3801
|
+
const isMainBoard = await this.isMainBoard();
|
|
3802
|
+
const boardOwnsSprints = !isMainBoard && "sprints" in (index.ownOptions || {});
|
|
3803
|
+
const target = isMainBoard || boardOwnsSprints ? this : this.board(await this.getMainBoardSlug());
|
|
3804
|
+
const targetIndex = target === this ? index : await target.loadIndex();
|
|
3805
|
+
|
|
3806
|
+
if (!("sprints" in targetIndex.options)) {
|
|
3807
|
+
targetIndex.options.sprints = [];
|
|
2615
3808
|
}
|
|
2616
|
-
const sprintNumber =
|
|
3809
|
+
const sprintNumber = targetIndex.options.sprints.length + 1;
|
|
2617
3810
|
const sprint = {
|
|
2618
3811
|
start: start,
|
|
2619
3812
|
};
|
|
2620
3813
|
|
|
2621
|
-
// If the name is blank, generate a default name
|
|
3814
|
+
// If the name is blank, generate a default name. A board-local list prefixes the board name, so
|
|
3815
|
+
// that two boards generating their own "Sprint 1" can still be told apart
|
|
2622
3816
|
if (!name) {
|
|
2623
|
-
sprint.name = `Sprint ${sprintNumber}`;
|
|
3817
|
+
sprint.name = boardOwnsSprints ? `${index.name} Sprint ${sprintNumber}` : `Sprint ${sprintNumber}`;
|
|
2624
3818
|
} else {
|
|
2625
3819
|
sprint.name = name;
|
|
2626
3820
|
}
|
|
@@ -2630,9 +3824,12 @@ class Kanbn {
|
|
|
2630
3824
|
sprint.description = description;
|
|
2631
3825
|
}
|
|
2632
3826
|
|
|
2633
|
-
// Add sprint and save the
|
|
2634
|
-
|
|
2635
|
-
await
|
|
3827
|
+
// Add sprint and save the board that owns the list
|
|
3828
|
+
targetIndex.options.sprints.push(sprint);
|
|
3829
|
+
await target.saveIndex(targetIndex);
|
|
3830
|
+
|
|
3831
|
+
// Tell the caller which list this went into, so the CLI can say so
|
|
3832
|
+
sprint.board = boardOwnsSprints ? await this.resolveBoardSlug() : null;
|
|
2636
3833
|
return sprint;
|
|
2637
3834
|
}
|
|
2638
3835
|
|
|
@@ -2654,6 +3851,14 @@ class Kanbn {
|
|
|
2654
3851
|
|
|
2655
3852
|
// Get index and tasks
|
|
2656
3853
|
const index = await this.loadIndex();
|
|
3854
|
+
|
|
3855
|
+
// Burndown measures work in flight, which a board with no started columns has no notion of. An
|
|
3856
|
+
// empty chart looks like a bug, so say what's actually missing
|
|
3857
|
+
if (!("startedColumns" in index.options) || !index.options.startedColumns.length) {
|
|
3858
|
+
throw new Error(
|
|
3859
|
+
`Board "${await this.resolveBoardSlug()}" declares no startedColumns, so it has no notion of work in progress`
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
2657
3862
|
const startedField = getStartedField(index);
|
|
2658
3863
|
const completedField = getCompletedField(index);
|
|
2659
3864
|
const tasks = [...(await this.loadAllTrackedTasks(index))]
|
|
@@ -2800,6 +4005,10 @@ class Kanbn {
|
|
|
2800
4005
|
});
|
|
2801
4006
|
}
|
|
2802
4007
|
|
|
4008
|
+
// Datapoints are placed and annotated from this board's history, so activity on another board
|
|
4009
|
+
// doesn't put markers on this board's chart
|
|
4010
|
+
const historyBoard = await this.historyBoard();
|
|
4011
|
+
|
|
2803
4012
|
// Get workload datapoints for each period
|
|
2804
4013
|
series.forEach((s) => {
|
|
2805
4014
|
s.dataPoints = [
|
|
@@ -2807,23 +4016,23 @@ class Kanbn {
|
|
|
2807
4016
|
x: s.from,
|
|
2808
4017
|
y: getWorkloadAtDate(index, tasks, s.from),
|
|
2809
4018
|
count: countActiveTasksAtDate(index, tasks, s.from),
|
|
2810
|
-
tasks: getTaskEventsAtDate(index, tasks, s.from),
|
|
4019
|
+
tasks: getTaskEventsAtDate(index, tasks, s.from, historyBoard),
|
|
2811
4020
|
},
|
|
2812
4021
|
...tasks
|
|
2813
|
-
.map((task) => getTaskTimelineDates(task, s.from, s.to))
|
|
4022
|
+
.map((task) => getTaskTimelineDates(task, s.from, s.to, historyBoard))
|
|
2814
4023
|
.flat()
|
|
2815
4024
|
.filter((d) => d)
|
|
2816
4025
|
.map((x) => ({
|
|
2817
4026
|
x,
|
|
2818
4027
|
y: getWorkloadAtDate(index, tasks, x),
|
|
2819
4028
|
count: countActiveTasksAtDate(index, tasks, x),
|
|
2820
|
-
tasks: getTaskEventsAtDate(index, tasks, x),
|
|
4029
|
+
tasks: getTaskEventsAtDate(index, tasks, x, historyBoard),
|
|
2821
4030
|
})),
|
|
2822
4031
|
{
|
|
2823
4032
|
x: s.to,
|
|
2824
4033
|
y: getWorkloadAtDate(index, tasks, s.to),
|
|
2825
4034
|
count: countActiveTasksAtDate(index, tasks, s.to),
|
|
2826
|
-
tasks: getTaskEventsAtDate(index, tasks, s.to),
|
|
4035
|
+
tasks: getTaskEventsAtDate(index, tasks, s.to, historyBoard),
|
|
2827
4036
|
},
|
|
2828
4037
|
].sort((a, b) => a.x.getTime() - b.x.getTime());
|
|
2829
4038
|
});
|
|
@@ -2849,6 +4058,9 @@ class Kanbn {
|
|
|
2849
4058
|
? null
|
|
2850
4059
|
: new Set(taskIds.map((taskId) => removeFileExtension(taskId)));
|
|
2851
4060
|
|
|
4061
|
+
// Show this board's history, plus the events that aren't board-scoped at all
|
|
4062
|
+
const historyBoard = await this.historyBoard();
|
|
4063
|
+
|
|
2852
4064
|
// Build date filter periods from sprints and/or dates
|
|
2853
4065
|
const periods = [];
|
|
2854
4066
|
const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
|
|
@@ -2941,6 +4153,7 @@ class Kanbn {
|
|
|
2941
4153
|
|
|
2942
4154
|
const historyEvents = ("history" in task && Array.isArray(task.history) ? task.history : [])
|
|
2943
4155
|
.filter((historyEvent) => historyEvent.date instanceof Date)
|
|
4156
|
+
.filter((historyEvent) => historyEventOnBoard(historyEvent, historyBoard))
|
|
2944
4157
|
.map((historyEvent) => {
|
|
2945
4158
|
const event = { ...historyEvent };
|
|
2946
4159
|
delete event.date;
|
|
@@ -3130,7 +4343,15 @@ class Kanbn {
|
|
|
3130
4343
|
const taskColumn = findTaskColumn(index, taskId);
|
|
3131
4344
|
taskData = setTaskMetadata(taskData, "column", taskColumn);
|
|
3132
4345
|
|
|
3133
|
-
//
|
|
4346
|
+
// Archiving removes a task from every board, so remember where it was on each of them. The
|
|
4347
|
+
// single `column` key is kept for the main board, so archives written before boards existed -
|
|
4348
|
+
// and archives of tasks that are only on one board - are unchanged
|
|
4349
|
+
const taskBoards = await this.findTaskBoards(taskId);
|
|
4350
|
+
if (Object.keys(taskBoards).length > 1) {
|
|
4351
|
+
taskData = setTaskMetadata(taskData, "columns", taskBoards);
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
// Add history event. Archiving isn't board-scoped, so the event carries no board key
|
|
3134
4355
|
taskData = appendTaskHistory(taskData, {
|
|
3135
4356
|
type: 'archived',
|
|
3136
4357
|
fromColumn: taskColumn
|
|
@@ -3139,8 +4360,8 @@ class Kanbn {
|
|
|
3139
4360
|
// Save the task inside the archive folder
|
|
3140
4361
|
await this.saveTask(archivedTaskPath, taskData);
|
|
3141
4362
|
|
|
3142
|
-
// Remove the original task
|
|
3143
|
-
await this.deleteTask(taskId, true);
|
|
4363
|
+
// Remove the original task from every board that references it
|
|
4364
|
+
await this.deleteTask(taskId, true, true);
|
|
3144
4365
|
|
|
3145
4366
|
return taskId;
|
|
3146
4367
|
}
|
|
@@ -3149,9 +4370,11 @@ class Kanbn {
|
|
|
3149
4370
|
* Restore a task from the archive
|
|
3150
4371
|
* @param {string} taskId The task id
|
|
3151
4372
|
* @param {?string} [columnName=null] The column to restore the task to
|
|
4373
|
+
* @param {boolean} [singleBoard=false] Restore only to this board, rather than to every board the
|
|
4374
|
+
* task was on when it was archived
|
|
3152
4375
|
* @return {Promise<string>} The task id
|
|
3153
4376
|
*/
|
|
3154
|
-
async restoreTask(taskId, columnName = null) {
|
|
4377
|
+
async restoreTask(taskId, columnName = null, singleBoard = false) {
|
|
3155
4378
|
// Check if this folder has been initialised
|
|
3156
4379
|
if (!(await this.initialised())) {
|
|
3157
4380
|
throw new Error("Not initialised in this folder");
|
|
@@ -3192,22 +4415,69 @@ class Kanbn {
|
|
|
3192
4415
|
// Load the task from the archive
|
|
3193
4416
|
let taskData = await this.loadArchivedTask(taskId);
|
|
3194
4417
|
let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
|
|
4418
|
+
|
|
4419
|
+
// Work out which other boards this task was on when it was archived. A board that has since been
|
|
4420
|
+
// deleted is skipped with a warning rather than failing the restore
|
|
4421
|
+
const thisBoardSlug = await this.resolveBoardSlug();
|
|
4422
|
+
const archivedColumns = getTaskMetadata(taskData, "columns") || {};
|
|
4423
|
+
const otherBoards = [];
|
|
4424
|
+
const missingBoards = [];
|
|
4425
|
+
this.lastRestoreWarnings = [];
|
|
4426
|
+
if (!singleBoard) {
|
|
4427
|
+
for (const [slug, archivedColumn] of Object.entries(archivedColumns)) {
|
|
4428
|
+
if (slug === thisBoardSlug) {
|
|
4429
|
+
continue;
|
|
4430
|
+
}
|
|
4431
|
+
if (await this.boardExists(slug)) {
|
|
4432
|
+
otherBoards.push([slug, archivedColumn]);
|
|
4433
|
+
} else {
|
|
4434
|
+
missingBoards.push(slug);
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
3195
4438
|
taskData = setTaskMetadata(taskData, "column", undefined);
|
|
4439
|
+
if ("metadata" in taskData && "columns" in taskData.metadata) {
|
|
4440
|
+
taskData = setTaskMetadata(taskData, "columns", undefined);
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
// Stamp every date this call writes with the same timestamp, so that the history event and the
|
|
4444
|
+
// column-linked dates can't disagree by a millisecond
|
|
4445
|
+
const now = new Date();
|
|
3196
4446
|
|
|
3197
4447
|
// Add history event
|
|
3198
4448
|
taskData = appendTaskHistory(taskData, {
|
|
4449
|
+
date: now,
|
|
3199
4450
|
type: 'restored',
|
|
3200
4451
|
toColumn: actualColumnName
|
|
3201
4452
|
});
|
|
3202
4453
|
|
|
3203
4454
|
// Update task metadata dates and save task
|
|
3204
|
-
taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName);
|
|
4455
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName, now);
|
|
3205
4456
|
await this.saveTask(taskPath, taskData);
|
|
3206
4457
|
|
|
3207
4458
|
// Add the task to the column and save the index
|
|
3208
4459
|
index = addTaskToIndex(index, taskId, actualColumnName);
|
|
3209
4460
|
await this.saveIndex(index);
|
|
3210
4461
|
|
|
4462
|
+
// Restore the task to every other board it was on, falling back to that board's first column if
|
|
4463
|
+
// the column it used to be in has since gone
|
|
4464
|
+
for (const [slug, archivedColumn] of otherBoards) {
|
|
4465
|
+
const otherBoard = this.board(slug);
|
|
4466
|
+
const otherIndex = await otherBoard.loadIndex();
|
|
4467
|
+
const otherColumns = Object.keys(otherIndex.columns);
|
|
4468
|
+
if (!otherColumns.length) {
|
|
4469
|
+
missingBoards.push(slug);
|
|
4470
|
+
continue;
|
|
4471
|
+
}
|
|
4472
|
+
await otherBoard.addTaskToBoard(
|
|
4473
|
+
taskId,
|
|
4474
|
+
archivedColumn in otherIndex.columns ? archivedColumn : otherColumns[0]
|
|
4475
|
+
);
|
|
4476
|
+
}
|
|
4477
|
+
// Boards the task used to be on that no longer exist, for the caller to report. Restoring is
|
|
4478
|
+
// best-effort: a deleted board shouldn't stop the task coming back to the boards that remain
|
|
4479
|
+
this.lastRestoreWarnings = missingBoards;
|
|
4480
|
+
|
|
3211
4481
|
// Delete the archived task file
|
|
3212
4482
|
await fs.promises.unlink(archivedTaskPath);
|
|
3213
4483
|
|