@basementuniverse/kanbn 2.1.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 +1 -0
- 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 +32 -0
- package/docs/commands/add.txt +8 -1
- package/docs/commands/archive.txt +6 -0
- package/docs/commands/board.txt +4 -0
- package/docs/commands/burndown.txt +1 -0
- package/docs/commands/comment.txt +8 -1
- package/docs/commands/contributors.txt +58 -0
- package/docs/commands/edit.txt +13 -1
- package/docs/commands/find.txt +28 -1
- package/docs/commands/gantt.txt +1 -0
- package/docs/commands/help.txt +1 -0
- package/docs/commands/history.txt +1 -0
- package/docs/commands/move.txt +17 -0
- package/docs/commands/remove.txt +10 -0
- package/docs/commands/restore.txt +6 -0
- package/docs/commands/sort.txt +18 -0
- package/docs/commands/task.txt +4 -0
- package/docs/commands/validate.txt +14 -1
- package/docs/contributors.md +145 -0
- package/docs/filtering-and-sorting.md +60 -3
- package/docs/index-structure.md +119 -11
- package/docs/index.md +3 -1
- package/docs/multiple-boards.md +1 -0
- package/docs/task-structure.md +9 -2
- package/example/advanced/kanbn.yml +65 -0
- package/package.json +1 -1
- package/routes/add.json +5 -1
- package/routes/archive.json +6 -2
- package/routes/comment.json +5 -1
- package/routes/contributors.json +18 -0
- package/routes/edit.json +5 -1
- package/routes/move.json +4 -2
- package/routes/remove.json +6 -2
- package/routes/restore.json +6 -0
- package/routes/sort.json +5 -0
- package/src/actions.js +904 -0
- package/src/board.js +24 -1
- package/src/controller/add.js +21 -10
- package/src/controller/archive.js +1 -0
- package/src/controller/board.js +13 -4
- package/src/controller/burndown.js +5 -2
- package/src/controller/comment.js +5 -2
- package/src/controller/contributors.js +166 -0
- package/src/controller/edit.js +40 -14
- package/src/controller/find.js +75 -4
- package/src/controller/gantt.js +5 -2
- package/src/controller/history.js +5 -2
- package/src/controller/move.js +84 -10
- package/src/controller/remove.js +39 -2
- package/src/controller/restore.js +1 -0
- package/src/controller/sort.js +40 -0
- package/src/controller/task.js +12 -1
- package/src/controller/validate.js +141 -4
- package/src/git-user-name.js +5 -15
- package/src/git-user.js +55 -0
- package/src/main.d.ts +204 -4
- package/src/main.js +1412 -38
- package/src/parse-index.js +205 -17
- package/src/utility.js +135 -1
- package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
package/src/main.js
CHANGED
|
@@ -7,6 +7,8 @@ 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";
|
|
@@ -27,6 +29,7 @@ const WORKSPACE_SCOPED_OPTIONS = [
|
|
|
27
29
|
"defaultBoard",
|
|
28
30
|
"boards",
|
|
29
31
|
"customFields",
|
|
32
|
+
"contributors",
|
|
30
33
|
"dateFormat",
|
|
31
34
|
"defaultTaskWorkload",
|
|
32
35
|
"taskWorkloadTags",
|
|
@@ -161,6 +164,77 @@ function getTrackedTaskIds(index, columnName = null) {
|
|
|
161
164
|
);
|
|
162
165
|
}
|
|
163
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
|
+
|
|
164
238
|
/**
|
|
165
239
|
* Get a task path from the id
|
|
166
240
|
* @param {string} tasksPath The path to the tasks folder
|
|
@@ -303,14 +377,126 @@ function setTaskMetadata(taskData, property, value) {
|
|
|
303
377
|
return taskData;
|
|
304
378
|
}
|
|
305
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
|
+
|
|
306
491
|
/**
|
|
307
492
|
* Append a structured history event to a task
|
|
308
493
|
* @param {object} taskData The task object
|
|
309
494
|
* @param {object} historyEvent The history event payload
|
|
310
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
|
|
311
497
|
* @return {object} The modified task object
|
|
312
498
|
*/
|
|
313
|
-
function appendTaskHistory(taskData, historyEvent, boardSlug = null) {
|
|
499
|
+
function appendTaskHistory(taskData, historyEvent, boardSlug = null, author = null) {
|
|
314
500
|
if (!('history' in taskData) || taskData.history === null) {
|
|
315
501
|
taskData.history = [];
|
|
316
502
|
}
|
|
@@ -320,17 +506,14 @@ function appendTaskHistory(taskData, historyEvent, boardSlug = null) {
|
|
|
320
506
|
|
|
321
507
|
// Events on the main board carry no board key, so a single-board workspace writes exactly the
|
|
322
508
|
// history it always has
|
|
323
|
-
...(boardSlug === null ? {} : { board: boardSlug })
|
|
509
|
+
...(boardSlug === null ? {} : { board: boardSlug }),
|
|
510
|
+
|
|
511
|
+
// Likewise, a machine with no resolvable user writes no author key at all
|
|
512
|
+
...(author ? { author } : {})
|
|
324
513
|
});
|
|
325
514
|
return taskData;
|
|
326
515
|
}
|
|
327
516
|
|
|
328
|
-
/**
|
|
329
|
-
* Check if a task is completed
|
|
330
|
-
* @param {object} index
|
|
331
|
-
* @param {object} task
|
|
332
|
-
* @return {boolean} True if the task is in a completed column or has a completed date
|
|
333
|
-
*/
|
|
334
517
|
/**
|
|
335
518
|
* Get the name of the metadata field that holds a task's started date for this board. Boards can
|
|
336
519
|
* point this at a custom date field so that several boards can track their own started/completed
|
|
@@ -351,10 +534,75 @@ function getCompletedField(index) {
|
|
|
351
534
|
return ("completedField" in index.options && index.options.completedField) || DEFAULT_COMPLETED_FIELD;
|
|
352
535
|
}
|
|
353
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
|
+
*/
|
|
354
563
|
function taskCompleted(index, task) {
|
|
355
564
|
return getCompletedField(index) in task.metadata;
|
|
356
565
|
}
|
|
357
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
|
+
|
|
358
606
|
/**
|
|
359
607
|
* Flatten a task's metadata and computed values into a single object that can be used for sorting
|
|
360
608
|
* @param {object} index The index object
|
|
@@ -383,6 +631,12 @@ function taskSortFields(index, task) {
|
|
|
383
631
|
comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
|
|
384
632
|
workload: taskWorkload(index, task),
|
|
385
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),
|
|
386
640
|
};
|
|
387
641
|
}
|
|
388
642
|
|
|
@@ -416,8 +670,11 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
|
|
|
416
670
|
// Sort the tasks in the target column using their flattened sort fields
|
|
417
671
|
tasks = sortTasksWithFields(index, tasks, sorters);
|
|
418
672
|
|
|
419
|
-
// Save the list of tasks back to the index
|
|
420
|
-
|
|
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];
|
|
421
678
|
return index;
|
|
422
679
|
}
|
|
423
680
|
|
|
@@ -596,6 +853,37 @@ function filterTasks(index, tasks, filters) {
|
|
|
596
853
|
result = false;
|
|
597
854
|
}
|
|
598
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
|
+
|
|
599
887
|
// Assigned
|
|
600
888
|
if (
|
|
601
889
|
"assigned" in filters &&
|
|
@@ -743,6 +1031,17 @@ function numberFilter(filter, input) {
|
|
|
743
1031
|
return input >= Math.min(...filter) && input <= Math.max(...filter);
|
|
744
1032
|
}
|
|
745
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
|
+
|
|
746
1045
|
/**
|
|
747
1046
|
* Calculate task workload
|
|
748
1047
|
* @param {object} index The index object
|
|
@@ -1387,6 +1686,16 @@ function updateColumnLinkedCustomField(
|
|
|
1387
1686
|
return taskData;
|
|
1388
1687
|
}
|
|
1389
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
|
+
|
|
1390
1699
|
class Kanbn {
|
|
1391
1700
|
ROOT = process.cwd();
|
|
1392
1701
|
CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
|
|
@@ -1399,6 +1708,14 @@ class Kanbn {
|
|
|
1399
1708
|
// restoreTask() for the caller to report
|
|
1400
1709
|
lastRestoreWarnings = [];
|
|
1401
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
|
+
|
|
1402
1719
|
/**
|
|
1403
1720
|
* @param {?string} [root=null] The workspace root folder
|
|
1404
1721
|
* @param {object} [options={}] Instance options: `board` scopes this instance to a board, `caches`
|
|
@@ -1412,6 +1729,9 @@ class Kanbn {
|
|
|
1412
1729
|
}
|
|
1413
1730
|
this.caches = options.caches || { config: null, workspaceOptions: null };
|
|
1414
1731
|
this.boardSlug = options.board || null;
|
|
1732
|
+
if (options.actions === false) {
|
|
1733
|
+
this.actionsEnabled = false;
|
|
1734
|
+
}
|
|
1415
1735
|
}
|
|
1416
1736
|
|
|
1417
1737
|
// Memoized config, kept in the shared cache object so that board-scoped clones don't each re-read it
|
|
@@ -1431,9 +1751,29 @@ class Kanbn {
|
|
|
1431
1751
|
*/
|
|
1432
1752
|
board(slug = null) {
|
|
1433
1753
|
if (slug === null || slug === undefined || slug === "") {
|
|
1434
|
-
return this.boardSlug === null
|
|
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;
|
|
1435
1775
|
}
|
|
1436
|
-
return new Kanbn(this.ROOT, { board:
|
|
1776
|
+
return new Kanbn(this.ROOT, { board: this.boardSlug, caches: this.caches, actions: false });
|
|
1437
1777
|
}
|
|
1438
1778
|
|
|
1439
1779
|
/**
|
|
@@ -1673,8 +2013,6 @@ class Kanbn {
|
|
|
1673
2013
|
if ("due" in task.metadata) {
|
|
1674
2014
|
const dueData = {};
|
|
1675
2015
|
|
|
1676
|
-
// A task is overdue if it's due date is in the past and the task is not in a completed column
|
|
1677
|
-
// or doesn't have a completed dates
|
|
1678
2016
|
const completedField = getCompletedField(index);
|
|
1679
2017
|
const completedDate = completedField in task.metadata ? task.metadata[completedField] : null;
|
|
1680
2018
|
|
|
@@ -1691,7 +2029,7 @@ class Kanbn {
|
|
|
1691
2029
|
dueData.completed = completed;
|
|
1692
2030
|
dueData.completedDate = completedDate;
|
|
1693
2031
|
dueData.dueDate = task.metadata.due;
|
|
1694
|
-
dueData.overdue =
|
|
2032
|
+
dueData.overdue = taskOverdue(index, task);
|
|
1695
2033
|
dueData.dueDelta = delta;
|
|
1696
2034
|
|
|
1697
2035
|
// Prepare a due message for the task
|
|
@@ -1760,6 +2098,76 @@ class Kanbn {
|
|
|
1760
2098
|
return this.caches.workspaceOptions;
|
|
1761
2099
|
}
|
|
1762
2100
|
|
|
2101
|
+
/**
|
|
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
|
|
2106
|
+
*/
|
|
2107
|
+
normaliseContributors(contributors) {
|
|
2108
|
+
return normaliseContributors(contributors);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
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);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
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
|
|
2127
|
+
*/
|
|
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
|
+
|
|
1763
2171
|
/**
|
|
1764
2172
|
* Get the options a secondary board inherits from the workspace. A config file is workspace-level
|
|
1765
2173
|
* by construction, so all of it is inherited; the main board's front matter is that board's own
|
|
@@ -1978,11 +2386,262 @@ class Kanbn {
|
|
|
1978
2386
|
const result = [];
|
|
1979
2387
|
const trackedTasks = getTrackedTaskIds(index, columnName);
|
|
1980
2388
|
for (let taskId of trackedTasks) {
|
|
1981
|
-
|
|
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
|
+
}
|
|
1982
2403
|
}
|
|
1983
2404
|
return result;
|
|
1984
2405
|
}
|
|
1985
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
|
+
|
|
1986
2645
|
/**
|
|
1987
2646
|
* Load a task file from the archive and parse it to an object
|
|
1988
2647
|
* @param {string} taskId The task id
|
|
@@ -2237,6 +2896,328 @@ class Kanbn {
|
|
|
2237
2896
|
return (await this.isMainBoard()) ? null : await this.resolveBoardSlug();
|
|
2238
2897
|
}
|
|
2239
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
|
+
|
|
2240
3221
|
/**
|
|
2241
3222
|
* Check if a board exists
|
|
2242
3223
|
* @param {string} slug The board slug
|
|
@@ -2593,15 +3574,37 @@ class Kanbn {
|
|
|
2593
3574
|
column: columnName,
|
|
2594
3575
|
fromProgress: 0,
|
|
2595
3576
|
toProgress: getTaskMetadata(taskData, 'progress') || 0
|
|
2596
|
-
}, await this.historyBoard());
|
|
3577
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
2597
3578
|
|
|
2598
3579
|
// Update task metadata dates
|
|
3580
|
+
const beforeActions = { ...taskData, metadata: {} };
|
|
2599
3581
|
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
2600
|
-
await this.saveTask(taskPath, taskData);
|
|
2601
3582
|
|
|
2602
|
-
// 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
|
|
2603
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);
|
|
2604
3606
|
await this.saveIndex(index);
|
|
3607
|
+
await this.completeActions(result, taskId);
|
|
2605
3608
|
return taskId;
|
|
2606
3609
|
}
|
|
2607
3610
|
|
|
@@ -2645,15 +3648,37 @@ class Kanbn {
|
|
|
2645
3648
|
date: now,
|
|
2646
3649
|
type: 'added',
|
|
2647
3650
|
column: columnName
|
|
2648
|
-
}, await this.historyBoard());
|
|
3651
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
2649
3652
|
|
|
2650
3653
|
// Update task metadata dates
|
|
3654
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
2651
3655
|
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, now);
|
|
2652
|
-
await this.saveTask(taskPath, taskData);
|
|
2653
3656
|
|
|
2654
|
-
// 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
|
|
2655
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);
|
|
2656
3680
|
await this.saveIndex(index);
|
|
3681
|
+
await this.completeActions(result, taskId);
|
|
2657
3682
|
return taskId;
|
|
2658
3683
|
}
|
|
2659
3684
|
|
|
@@ -2828,7 +3853,41 @@ class Kanbn {
|
|
|
2828
3853
|
type: 'progress',
|
|
2829
3854
|
fromProgress: originalProgress,
|
|
2830
3855
|
toProgress: updatedProgress
|
|
2831
|
-
});
|
|
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
|
+
}
|
|
2832
3891
|
}
|
|
2833
3892
|
|
|
2834
3893
|
// Save task
|
|
@@ -2836,12 +3895,17 @@ class Kanbn {
|
|
|
2836
3895
|
|
|
2837
3896
|
// Move the task if we're updating the column
|
|
2838
3897
|
if (columnName) {
|
|
2839
|
-
|
|
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];
|
|
2840
3903
|
|
|
2841
3904
|
// Otherwise save the index
|
|
2842
3905
|
} else {
|
|
2843
3906
|
await this.saveIndex(index);
|
|
2844
3907
|
}
|
|
3908
|
+
await this.completeActions(result, taskId);
|
|
2845
3909
|
|
|
2846
3910
|
// Remove any explicitly unset metadata fields last. This has to happen after the move, because
|
|
2847
3911
|
// moving into a started or completed column stamps the linked date fields - an explicit unset
|
|
@@ -2990,12 +4054,12 @@ class Kanbn {
|
|
|
2990
4054
|
type: 'moved',
|
|
2991
4055
|
fromColumn: currentColumnName,
|
|
2992
4056
|
toColumn: columnName
|
|
2993
|
-
}, await this.historyBoard());
|
|
4057
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
2994
4058
|
}
|
|
2995
4059
|
|
|
2996
4060
|
// Update task metadata dates
|
|
4061
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
2997
4062
|
taskData = updateColumnLinkedCustomFields(index, taskData, columnName, moveDate);
|
|
2998
|
-
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
2999
4063
|
|
|
3000
4064
|
// If we're moving the task to a new position, calculate the absolute position
|
|
3001
4065
|
const currentPosition = index.columns[currentColumnName].indexOf(taskId);
|
|
@@ -3009,7 +4073,39 @@ class Kanbn {
|
|
|
3009
4073
|
// Remove the task from its current column and add it to the new column
|
|
3010
4074
|
index = removeTaskFromIndex(index, taskId);
|
|
3011
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);
|
|
3012
4107
|
await this.saveIndex(index);
|
|
4108
|
+
await this.completeActions(result, taskId);
|
|
3013
4109
|
return taskId;
|
|
3014
4110
|
}
|
|
3015
4111
|
|
|
@@ -3044,8 +4140,30 @@ class Kanbn {
|
|
|
3044
4140
|
);
|
|
3045
4141
|
}
|
|
3046
4142
|
|
|
3047
|
-
//
|
|
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
|
|
3048
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
|
+
|
|
4166
|
+
// Remove the task from whichever column it's in
|
|
3049
4167
|
index = removeTaskFromIndex(index, taskId);
|
|
3050
4168
|
|
|
3051
4169
|
// Record the task leaving this board, but only when the task file survives - a removed event on
|
|
@@ -3055,7 +4173,7 @@ class Kanbn {
|
|
|
3055
4173
|
taskData = appendTaskHistory(taskData, {
|
|
3056
4174
|
type: 'removed',
|
|
3057
4175
|
fromColumn: columnName
|
|
3058
|
-
}, await this.historyBoard());
|
|
4176
|
+
}, await this.historyBoard(), await this.currentUser());
|
|
3059
4177
|
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
3060
4178
|
}
|
|
3061
4179
|
|
|
@@ -3071,6 +4189,7 @@ class Kanbn {
|
|
|
3071
4189
|
await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
|
|
3072
4190
|
}
|
|
3073
4191
|
await this.saveIndex(index);
|
|
4192
|
+
await this.completeActions(result, taskId);
|
|
3074
4193
|
return taskId;
|
|
3075
4194
|
}
|
|
3076
4195
|
|
|
@@ -3413,6 +4532,25 @@ class Kanbn {
|
|
|
3413
4532
|
return errors;
|
|
3414
4533
|
}
|
|
3415
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
|
+
|
|
3416
4554
|
// Load & parse tasks
|
|
3417
4555
|
const trackedTasks = getTrackedTaskIds(index);
|
|
3418
4556
|
for (let taskId of trackedTasks) {
|
|
@@ -3628,6 +4766,160 @@ class Kanbn {
|
|
|
3628
4766
|
return warnings;
|
|
3629
4767
|
}
|
|
3630
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
|
+
|
|
3631
4923
|
/**
|
|
3632
4924
|
* Find tasks whose started/completed dates disagree with the column they're in
|
|
3633
4925
|
*
|
|
@@ -4245,7 +5537,7 @@ class Kanbn {
|
|
|
4245
5537
|
* @param {string} author The comment author
|
|
4246
5538
|
* @return {Promise<string>} The task id
|
|
4247
5539
|
*/
|
|
4248
|
-
async comment(taskId, text, author) {
|
|
5540
|
+
async comment(taskId, text, author = "") {
|
|
4249
5541
|
// Check if this folder has been initialised
|
|
4250
5542
|
if (!(await this.initialised())) {
|
|
4251
5543
|
throw new Error("Not initialised in this folder");
|
|
@@ -4269,16 +5561,54 @@ class Kanbn {
|
|
|
4269
5561
|
}
|
|
4270
5562
|
|
|
4271
5563
|
// Add the comment
|
|
4272
|
-
|
|
5564
|
+
let taskData = await this.loadTask(taskId);
|
|
4273
5565
|
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
5566
|
+
const now = new Date();
|
|
4274
5567
|
taskData.comments.push({
|
|
4275
5568
|
text,
|
|
4276
|
-
|
|
4277
|
-
|
|
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
|
|
4278
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
|
+
}
|
|
4279
5603
|
|
|
4280
5604
|
// Save the task
|
|
4281
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);
|
|
4282
5612
|
return taskId;
|
|
4283
5613
|
}
|
|
4284
5614
|
|
|
@@ -4352,16 +5682,35 @@ class Kanbn {
|
|
|
4352
5682
|
}
|
|
4353
5683
|
|
|
4354
5684
|
// Add history event. Archiving isn't board-scoped, so the event carries no board key
|
|
5685
|
+
const now = new Date();
|
|
4355
5686
|
taskData = appendTaskHistory(taskData, {
|
|
4356
5687
|
type: 'archived',
|
|
4357
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
|
|
4358
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
|
+
}
|
|
4359
5706
|
|
|
4360
5707
|
// Save the task inside the archive folder
|
|
4361
5708
|
await this.saveTask(archivedTaskPath, taskData);
|
|
5709
|
+
await this.completeActions(result, taskId);
|
|
4362
5710
|
|
|
4363
|
-
// Remove the original task from every board that references it
|
|
4364
|
-
|
|
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);
|
|
4365
5714
|
|
|
4366
5715
|
return taskId;
|
|
4367
5716
|
}
|
|
@@ -4449,15 +5798,40 @@ class Kanbn {
|
|
|
4449
5798
|
date: now,
|
|
4450
5799
|
type: 'restored',
|
|
4451
5800
|
toColumn: actualColumnName
|
|
4452
|
-
});
|
|
5801
|
+
}, null, await this.currentUser());
|
|
4453
5802
|
|
|
4454
|
-
// Update task metadata dates
|
|
5803
|
+
// Update task metadata dates
|
|
5804
|
+
const beforeActions = { ...taskData, metadata: { ...taskData.metadata } };
|
|
4455
5805
|
taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName, now);
|
|
4456
|
-
await this.saveTask(taskPath, taskData);
|
|
4457
5806
|
|
|
4458
|
-
// 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
|
|
4459
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);
|
|
4460
5833
|
await this.saveIndex(index);
|
|
5834
|
+
await this.completeActions(result, taskId);
|
|
4461
5835
|
|
|
4462
5836
|
// Restore the task to every other board it was on, falling back to that board's first column if
|
|
4463
5837
|
// the column it used to be in has since gone
|