@basementuniverse/kanbn 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/docs/commands/burndown.txt +7 -1
  2. package/docs/commands/gantt.txt +36 -0
  3. package/docs/commands/help.txt +1 -0
  4. package/docs/index-structure.md +20 -1
  5. package/docs/quick-start.md +1 -1
  6. package/docs/task-structure.md +47 -0
  7. package/example/.kanbn/index.md +63 -0
  8. package/example/.kanbn/tasks/add-basic-activity-feed.md +53 -0
  9. package/example/.kanbn/tasks/add-passwordless-login-option.md +34 -0
  10. package/example/.kanbn/tasks/add-usage-alert-email-thresholds.md +35 -0
  11. package/example/.kanbn/tasks/build-email-template-system.md +30 -0
  12. package/example/.kanbn/tasks/build-invoice-download-endpoint.md +43 -0
  13. package/example/.kanbn/tasks/build-tenant-settings-page.md +48 -0
  14. package/example/.kanbn/tasks/create-organization-switcher.md +53 -0
  15. package/example/.kanbn/tasks/create-sandbox-environment-provisioner.md +36 -0
  16. package/example/.kanbn/tasks/create-self-serve-cancellation-flow.md +38 -0
  17. package/example/.kanbn/tasks/define-product-pricing-strategy.md +37 -0
  18. package/example/.kanbn/tasks/design-onboarding-checklist.md +30 -0
  19. package/example/.kanbn/tasks/design-team-invite-expiry-flow.md +33 -0
  20. package/example/.kanbn/tasks/implement-data-retention-policy-jobs.md +30 -0
  21. package/example/.kanbn/tasks/implement-feature-flags-foundation.md +36 -0
  22. package/example/.kanbn/tasks/implement-project-creation-wizard.md +44 -0
  23. package/example/.kanbn/tasks/implement-stripe-webhook-signature-check.md +54 -0
  24. package/example/.kanbn/tasks/implement-team-permissions-ui.md +57 -0
  25. package/example/.kanbn/tasks/implement-user-signup-and-login.md +62 -0
  26. package/example/.kanbn/tasks/integrate-crm-lead-sync.md +35 -0
  27. package/example/.kanbn/tasks/legal-review-terms-and-privacy.md +30 -0
  28. package/example/.kanbn/tasks/migrate-legacy-events-to-new-schema.md +48 -0
  29. package/example/.kanbn/tasks/optimize-dashboard-first-load.md +52 -0
  30. package/example/.kanbn/tasks/prototype-report-export-scheduler.md +34 -0
  31. package/example/.kanbn/tasks/publish-internal-qa-checklist.md +53 -0
  32. package/example/.kanbn/tasks/setup-ci-pipeline.md +53 -0
  33. package/package.json +3 -2
  34. package/routes/gantt.json +27 -0
  35. package/skills/kanbn-plan/SKILL.md +0 -0
  36. package/src/board.js +21 -2
  37. package/src/controller/add.js +33 -1
  38. package/src/controller/burndown.js +72 -5
  39. package/src/controller/edit.js +68 -1
  40. package/src/controller/find.js +5 -0
  41. package/src/controller/gantt.js +375 -0
  42. package/src/controller/init.js +2 -1
  43. package/src/controller/sort.js +8 -0
  44. package/src/main.d.ts +295 -340
  45. package/src/main.js +2591 -2050
  46. package/src/parse-task.js +211 -3
package/src/main.js CHANGED
@@ -8,2352 +8,2893 @@ const yaml = require("yamljs");
8
8
  const humanizeDuration = require("humanize-duration");
9
9
  const rimraf = require("rimraf");
10
10
 
11
- module.exports = (() => {
12
- const ROOT = process.cwd();
13
- const CONFIG_YAML = path.join(ROOT, "kanbn.yml");
14
- const CONFIG_JSON = path.join(ROOT, "kanbn.json");
15
- const DEFAULT_FOLDER_NAME = ".kanbn";
16
- const DEFAULT_INDEX_FILE_NAME = "index.md";
17
- const DEFAULT_TASKS_FOLDER_NAME = "tasks";
18
- const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
19
-
20
- // Date normalisation intervals measured in milliseconds
21
- const SECOND = 1000;
22
- const MINUTE = 60 * SECOND;
23
- const HOUR = 60 * MINUTE;
24
- const DAY = 24 * HOUR;
25
-
26
- // Default fallback values for index options
27
- const DEFAULT_TASK_WORKLOAD = 2;
28
- const DEFAULT_TASK_WORKLOAD_TAGS = {
29
- Nothing: 0,
30
- Tiny: 1,
31
- Small: 2,
32
- Medium: 3,
33
- Large: 5,
34
- Huge: 8,
11
+ const DEFAULT_FOLDER_NAME = ".kanbn";
12
+ const DEFAULT_INDEX_FILE_NAME = "index.md";
13
+ const DEFAULT_TASKS_FOLDER_NAME = "tasks";
14
+ const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
15
+
16
+ // Date normalisation intervals measured in milliseconds
17
+ const SECOND = 1000;
18
+ const MINUTE = 60 * SECOND;
19
+ const HOUR = 60 * MINUTE;
20
+ const DAY = 24 * HOUR;
21
+
22
+ // Default fallback values for index options
23
+ const DEFAULT_TASK_WORKLOAD = 2;
24
+ const DEFAULT_TASK_WORKLOAD_TAGS = {
25
+ Nothing: 0,
26
+ Tiny: 1,
27
+ Small: 2,
28
+ Medium: 3,
29
+ Large: 5,
30
+ Huge: 8,
31
+ };
32
+ const DEFAULT_DATE_FORMAT = "d mmm yy, H:MM";
33
+ const DEFAULT_TASK_TEMPLATE = "^+^_${overdue ? '^R' : ''}${name}^: ${created ? ('\\n^-^/' + created) : ''}";
34
+
35
+ /**
36
+ * Default options for the initialise command
37
+ */
38
+ const defaultInitialiseOptions = {
39
+ name: "Project Name",
40
+ description: "",
41
+ options: {
42
+ startedColumns: ["In Progress"],
43
+ completedColumns: ["Done"],
44
+ },
45
+ columns: ["Backlog", "Todo", "In Progress", "Done"],
46
+ };
47
+
48
+ /**
49
+ * Check if a file or folder exists
50
+ * @param {string} path
51
+ * @return {Promise<boolean>} True if the file or folder exists
52
+ */
53
+ async function exists(path) {
54
+ try {
55
+ await fs.promises.access(path, fs.constants.R_OK | fs.constants.W_OK);
56
+ } catch (error) {
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Get a list of all tracked task ids
64
+ * @param {object} index The index object
65
+ * @param {?string} [columnName=null] The optional column name to filter tasks by
66
+ * @return {Set} A set of task ids appearing in the index
67
+ */
68
+ function getTrackedTaskIds(index, columnName = null) {
69
+ return new Set(
70
+ columnName
71
+ ? index.columns[columnName]
72
+ : Object.keys(index.columns)
73
+ .map((columnName) => index.columns[columnName])
74
+ .flat()
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Get a task path from the id
80
+ * @param {string} tasksPath The path to the tasks folder
81
+ * @param {string} taskId The task id
82
+ * @return {string} The task path
83
+ */
84
+ function getTaskPath(tasksPath, taskId) {
85
+ return path.join(tasksPath, addFileExtension(taskId));
86
+ }
87
+
88
+ /**
89
+ * Add the file extension to an id if it doesn't already have one
90
+ * @param {string} taskId The task id
91
+ * @return {string} The task id with .md extension
92
+ */
93
+ function addFileExtension(taskId) {
94
+ if (!/\.md$/.test(taskId)) {
95
+ return `${taskId}.md`;
96
+ }
97
+ return taskId;
98
+ }
99
+
100
+ /**
101
+ * Remove the file extension from an id if it has one
102
+ * @param {string} taskId The task id
103
+ * @return {string} The task id without .md extension
104
+ */
105
+ function removeFileExtension(taskId) {
106
+ if (/\.md$/.test(taskId)) {
107
+ return taskId.slice(0, taskId.length - ".md".length);
108
+ }
109
+ return taskId;
110
+ }
111
+
112
+
113
+ /**
114
+ * Check if a task exists in the index
115
+ * @param {object} index The index object
116
+ * @param {string} taskId The task id to search for
117
+ * @return {boolean} True if the task exists in the index
118
+ */
119
+ function taskInIndex(index, taskId) {
120
+ for (let columnName in index.columns) {
121
+ if (index.columns[columnName].indexOf(taskId) !== -1) {
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Find a task in the index and returns the column that it's in
130
+ * @param {object} index The index data
131
+ * @param {string} taskId The task id to search for
132
+ * @return {?string} The column name for the specified task, or null if it wasn't found
133
+ */
134
+ function findTaskColumn(index, taskId) {
135
+ for (let columnName in index.columns) {
136
+ if (index.columns[columnName].indexOf(taskId) !== -1) {
137
+ return columnName;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Add a task id to the specified column in the index
145
+ * @param {object} index The index object
146
+ * @param {string} taskId The task id to add
147
+ * @param {string} columnName The column to add the task to
148
+ * @param {?number} [position=null] The position in the column to move the task to, or last position if null
149
+ * @return {object} The modified index object
150
+ */
151
+ function addTaskToIndex(index, taskId, columnName, position = null) {
152
+ if (position === null) {
153
+ index.columns[columnName].push(taskId);
154
+ } else {
155
+ index.columns[columnName].splice(position, 0, taskId);
156
+ }
157
+ return index;
158
+ }
159
+
160
+ /**
161
+ * Remove all instances of a task id from the index
162
+ * @param {object} index The index object
163
+ * @param {string} taskId The task id to remove
164
+ * @return {object} The modified index object
165
+ */
166
+ function removeTaskFromIndex(index, taskId) {
167
+ for (let columnName in index.columns) {
168
+ index.columns[columnName] = index.columns[columnName].filter((t) => t !== taskId);
169
+ }
170
+ return index;
171
+ }
172
+
173
+ /**
174
+ * Rename all instances of a task id in the index
175
+ * @param {object} index The index object
176
+ * @param {string} taskId The task id to rename
177
+ * @param {string} newTaskId The new task id
178
+ * @return {object} The modified index object
179
+ */
180
+ function renameTaskInIndex(index, taskId, newTaskId) {
181
+ for (let columnName in index.columns) {
182
+ index.columns[columnName] = index.columns[columnName].map((t) => (t === taskId ? newTaskId : t));
183
+ }
184
+ return index;
185
+ }
186
+
187
+ /**
188
+ * Get a metadata property from a task, or undefined if the metadata property doesn't exist or
189
+ * if the task has no metadata
190
+ * @param {object} taskData The task object
191
+ * @param {string} property The metadata property to check
192
+ * @return {any} The metadata property value
193
+ */
194
+ function getTaskMetadata(taskData, property) {
195
+ if ("metadata" in taskData && property in taskData.metadata) {
196
+ return taskData.metadata[property];
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /**
202
+ * Set a metadata value in a task. If the value is undefined, remove the metadata property instead
203
+ * @param {object} taskData The task object
204
+ * @param {string} property The metadata property to update
205
+ * @param {string} value The value to set
206
+ * @return {object} The modified task object
207
+ */
208
+ function setTaskMetadata(taskData, property, value) {
209
+ if (!("metadata" in taskData)) {
210
+ taskData.metadata = {};
211
+ }
212
+ if (property in taskData.metadata && value === undefined) {
213
+ delete taskData.metadata[property];
214
+ } else {
215
+ taskData.metadata[property] = value;
216
+ }
217
+ return taskData;
218
+ }
219
+
220
+ /**
221
+ * Append a structured history event to a task
222
+ * @param {object} taskData The task object
223
+ * @param {object} historyEvent The history event payload
224
+ * @return {object} The modified task object
225
+ */
226
+ function appendTaskHistory(taskData, historyEvent) {
227
+ if (!('history' in taskData) || taskData.history === null) {
228
+ taskData.history = [];
229
+ }
230
+ taskData.history.push({
231
+ date: new Date(),
232
+ ...historyEvent
233
+ });
234
+ return taskData;
235
+ }
236
+
237
+ /**
238
+ * Check if a task is completed
239
+ * @param {object} index
240
+ * @param {object} task
241
+ * @return {boolean} True if the task is in a completed column or has a completed date
242
+ */
243
+ function taskCompleted(index, task) {
244
+ return (
245
+ "completed" in task.metadata ||
246
+ ("completedColumns" in index.options &&
247
+ index.options.completedColumns.indexOf(findTaskColumn(index, task.id)) !== -1)
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Sort a column in the index
253
+ * @param {object} index The index object
254
+ * @param {object[]} tasks The tasks in the index
255
+ * @param {string} columnName The column to sort
256
+ * @param {object[]} sorters A list of sorter objects
257
+ * @return {object} The modified index object
258
+ */
259
+ function sortColumnInIndex(index, tasks, columnName, sorters) {
260
+ // Get a list of tasks in the target column and add computed fields
261
+ tasks = tasks.map((task) => ({
262
+ ...task,
263
+ ...task.metadata,
264
+ created: "created" in task.metadata ? task.metadata.created : "",
265
+ updated: "updated" in task.metadata ? task.metadata.updated : "",
266
+ started: "started" in task.metadata ? task.metadata.started : "",
267
+ completed: "completed" in task.metadata ? task.metadata.completed : "",
268
+ due: "due" in task.metadata ? task.metadata.due : "",
269
+ postponed: "postponed" in task.metadata ? task.metadata.postponed : "",
270
+ assigned: "assigned" in task.metadata ? task.metadata.assigned : "",
271
+ countSubTasks: task.subTasks.length,
272
+ subTasks: task.subTasks.map((subTask) => `[${subTask.completed ? "x" : ""}] ${subTask.text}`).join("\n"),
273
+ countTags: "tags" in task.metadata ? task.metadata.tags.length : 0,
274
+ tags: "tags" in task.metadata ? task.metadata.tags.join("\n") : "",
275
+ countRelations: task.relations.length,
276
+ relations: task.relations.map((relation) => `${relation.type} ${relation.task}`).join("\n"),
277
+ countComments: task.comments.length,
278
+ comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
279
+ workload: taskWorkload(index, task),
280
+ progress: taskProgress(index, task),
281
+ }));
282
+
283
+ // Sort the list of tasks
284
+ tasks = sortTasks(tasks, sorters);
285
+
286
+ // Save the list of tasks back to the index
287
+ index.columns[columnName] = tasks.map((task) => task.id);
288
+ return index;
289
+ }
290
+
291
+ /**
292
+ * Sort a list of tasks
293
+ * @param {object[]} tasks
294
+ * @param {object[]} sorters
295
+ * @return {object[]} The sorted tasks
296
+ */
297
+ function sortTasks(tasks, sorters) {
298
+ tasks.sort((a, b) => {
299
+ let compareA, compareB;
300
+ for (let sorter of sorters) {
301
+ compareA = a[sorter.field];
302
+ compareB = b[sorter.field];
303
+ if (sorter.filter) {
304
+ compareA = sortFilter(compareA, sorter.filter);
305
+ compareB = sortFilter(compareB, sorter.filter);
306
+ }
307
+ if (compareA === compareB) {
308
+ continue;
309
+ }
310
+ return sorter.order === "descending" ? compareValues(compareB, compareA) : compareValues(compareA, compareB);
311
+ }
312
+ return 0;
313
+ });
314
+ return tasks;
315
+ }
316
+
317
+ /**
318
+ * Transform a value using a sort filter regular expression
319
+ * @param {string} value
320
+ * @param {string} filter
321
+ * @return {string} The transformed value
322
+ */
323
+ function sortFilter(value, filter) {
324
+ // Filter regex is global and case-insensitive
325
+ const matches = [...value.matchAll(new RegExp(filter, "gi"))];
326
+ const result = matches.map((match) => {
327
+ // If the matched string has named capturing groups, concatenate their contents
328
+ if (match.groups) {
329
+ return Object.values(match.groups).join("");
330
+ }
331
+
332
+ // If the matched string has non-named capturing groups, use the contents of the first group
333
+ if (match[1]) {
334
+ return match[1];
335
+ }
336
+
337
+ // Otherwise use the matched string
338
+ return match[0];
339
+ });
340
+ return result.join("");
341
+ }
342
+
343
+ /**
344
+ * Compare two values (supports string, date and number values)
345
+ * @param {any} a
346
+ * @param {any} b
347
+ * @return {number} A positive value if a > b, negative if a < b, otherwise 0
348
+ */
349
+ function compareValues(a, b) {
350
+ if (a === undefined && b === undefined) {
351
+ return 0;
352
+ }
353
+ a = utility.coerceUndefined(a, typeof b);
354
+ b = utility.coerceUndefined(b, typeof a);
355
+ if (typeof a === "string" && typeof b === "string") {
356
+ return a.localeCompare(b, undefined, { sensitivity: "accent" });
357
+ }
358
+ return a - b;
359
+ }
360
+
361
+ /**
362
+ * Filter a list of tasks using a filters object containing field names and filter values
363
+ * @param {object} index
364
+ * @param {object[]}} tasks
365
+ * @param {object} filters
366
+ */
367
+ function filterTasks(index, tasks, filters) {
368
+ return tasks.filter((task) => {
369
+ // Get task id and column
370
+ const taskId = utility.getTaskId(task.name);
371
+ const column = findTaskColumn(index, taskId);
372
+
373
+ // If no filters are defined, return all tasks
374
+ if (Object.keys(filters).length === 0) {
375
+ return true;
376
+ }
377
+
378
+ // Apply filters
379
+ let result = true;
380
+
381
+ // Id
382
+ if ("id" in filters && !stringFilter(filters.id, task.id)) {
383
+ result = false;
384
+ }
385
+
386
+ // Name
387
+ if ("name" in filters && !stringFilter(filters.name, task.name)) {
388
+ result = false;
389
+ }
390
+
391
+ // Description
392
+ if ("description" in filters && !stringFilter(filters.description, task.description)) {
393
+ result = false;
394
+ }
395
+
396
+ // Column
397
+ if ("column" in filters && !stringFilter(filters.column, column)) {
398
+ result = false;
399
+ }
400
+
401
+ // Created date
402
+ if (
403
+ "created" in filters &&
404
+ (!("created" in task.metadata) || !dateFilter(filters.created, task.metadata.created))
405
+ ) {
406
+ result = false;
407
+ }
408
+
409
+ // Updated date
410
+ if (
411
+ "updated" in filters &&
412
+ (!("updated" in task.metadata) || !dateFilter(filters.updated, task.metadata.updated))
413
+ ) {
414
+ result = false;
415
+ }
416
+
417
+ // Started date
418
+ if (
419
+ "started" in filters &&
420
+ (!("started" in task.metadata) || !dateFilter(filters.started, task.metadata.started))
421
+ ) {
422
+ result = false;
423
+ }
424
+
425
+ // Completed date
426
+ if (
427
+ "completed" in filters &&
428
+ (!("completed" in task.metadata) || !dateFilter(filters.completed, task.metadata.completed))
429
+ ) {
430
+ result = false;
431
+ }
432
+
433
+ // Due
434
+ if ("due" in filters && (!("due" in task.metadata) || !dateFilter(filters.due, task.metadata.due))) {
435
+ result = false;
436
+ }
437
+
438
+ // Workload
439
+ if ("workload" in filters && !numberFilter(filters.workload, taskWorkload(index, task))) {
440
+ result = false;
441
+ }
442
+
443
+ // Progress
444
+ if ("progress" in filters && !numberFilter(filters.progress, taskProgress(index, task))) {
445
+ result = false;
446
+ }
447
+
448
+ // Assigned
449
+ if (
450
+ "assigned" in filters &&
451
+ !stringFilter(filters.assigned, "assigned" in task.metadata ? task.metadata.assigned : "")
452
+ ) {
453
+ result = false;
454
+ }
455
+
456
+ // Sub-tasks
457
+ if (
458
+ "sub-task" in filters &&
459
+ !stringFilter(
460
+ filters["sub-task"],
461
+ task.subTasks.map((subTask) => `[${subTask.completed ? "x" : " "}] ${subTask.text}`).join("\n")
462
+ )
463
+ ) {
464
+ result = false;
465
+ }
466
+
467
+ // Count sub-tasks
468
+ if ("count-sub-tasks" in filters && !numberFilter(filters["count-sub-tasks"], task.subTasks.length)) {
469
+ result = false;
470
+ }
471
+
472
+ // Tag
473
+ if ("tag" in filters && !stringFilter(filters.tag, task.metadata.tags.join("\n"))) {
474
+ result = false;
475
+ }
476
+
477
+ // Count tags
478
+ if ("count-tags" in filters && !numberFilter(filters["count-tags"], task.tags.length)) {
479
+ result = false;
480
+ }
481
+
482
+ // Relation
483
+ if (
484
+ "relation" in filters &&
485
+ !stringFilter(
486
+ filters.relation,
487
+ task.relations.map((relation) => `${relation.type} ${relation.task}`).join("\n")
488
+ )
489
+ ) {
490
+ result = false;
491
+ }
492
+
493
+ // Count relations
494
+ if ("count-relations" in filters && !numberFilter(filters["count-relations"], task.relations.length)) {
495
+ result = false;
496
+ }
497
+
498
+ // Comments
499
+ if (
500
+ "comment" in filters &&
501
+ !stringFilter(filters.comment, task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"))
502
+ ) {
503
+ result = false;
504
+ }
505
+
506
+ // Count comments
507
+ if ("count-comments" in filters && !numberFilter(filters["count-comments"], task.comments.length)) {
508
+ result = false;
509
+ }
510
+
511
+ // Custom metadata properties
512
+ if ("customFields" in index.options) {
513
+ for (let customField of index.options.customFields) {
514
+ if (customField.name in filters) {
515
+ if (!(customField.name in task.metadata)) {
516
+ result = false;
517
+ } else {
518
+ switch (customField.type) {
519
+ case "boolean":
520
+ if (task.metadata[customField.name] !== filters[customField.name]) {
521
+ result = false;
522
+ }
523
+ break;
524
+ case "number":
525
+ if (!numberFilter(filters[customField.name], task.metadata[customField.name])) {
526
+ result = false;
527
+ }
528
+ break;
529
+ case "string":
530
+ if (!stringFilter(filters[customField.name], task.metadata[customField.name])) {
531
+ result = false;
532
+ }
533
+ break;
534
+ case "date":
535
+ if (!dateFilter(filters[customField.name], task.metadata[customField.name])) {
536
+ result = false;
537
+ }
538
+ break;
539
+ default:
540
+ break;
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ return result;
547
+ });
548
+ }
549
+
550
+ /**
551
+ * Check if the input string matches the filter regex
552
+ * @param {string|string[]} filter A regular expression or array of regular expressions
553
+ * @param {string} input The string to match against
554
+ * @return {boolean} True if the input matches the string filter
555
+ */
556
+ function stringFilter(filter, input) {
557
+ if (Array.isArray(filter)) {
558
+ filter = filter.join("|");
559
+ }
560
+ return new RegExp(filter, "i").test(input);
561
+ }
562
+
563
+ /**
564
+ * Check if the input date matches a date (ignore time part), or if multiple dates are passed in, check if the
565
+ * input date is between the earliest and latest dates
566
+ * @param {Date|Date[]} dates A date or list of dates to check against
567
+ * @param {Date} input The input date to match against
568
+ * @return {boolean} True if the input matches the date filter
569
+ */
570
+ function dateFilter(dates, input) {
571
+ dates = utility.arrayArg(dates);
572
+ if (dates.length === 1) {
573
+ return utility.compareDates(input, dates[0]);
574
+ }
575
+ const earliest = Math.min(...dates);
576
+ const latest = Math.max(...dates);
577
+ return input >= earliest && input <= latest;
578
+ }
579
+
580
+ /**
581
+ * Check if the input matches a number, or if multiple numbers are passed in, check if the input is between the
582
+ * minimum and maximum numbers
583
+ * @param {number|number[]} filter A filter number or array of filter numbers
584
+ * @param {number} input The number to match against
585
+ * @return {boolean} True if the input matches the number filter
586
+ */
587
+ function numberFilter(filter, input) {
588
+ filter = utility.arrayArg(filter);
589
+ return input >= Math.min(...filter) && input <= Math.max(...filter);
590
+ }
591
+
592
+ /**
593
+ * Calculate task workload
594
+ * @param {object} index The index object
595
+ * @param {object} task The task object
596
+ * @return {number} The task workload
597
+ */
598
+ function taskWorkload(index, task) {
599
+ const defaultTaskWorkload =
600
+ "defaultTaskWorkload" in index.options ? index.options.defaultTaskWorkload : DEFAULT_TASK_WORKLOAD;
601
+ const taskWorkloadTags =
602
+ "taskWorkloadTags" in index.options ? index.options.taskWorkloadTags : DEFAULT_TASK_WORKLOAD_TAGS;
603
+ let workload = 0;
604
+ let hasWorkloadTags = false;
605
+ if ("tags" in task.metadata) {
606
+ for (let workloadTag of Object.keys(taskWorkloadTags)) {
607
+ if (task.metadata.tags.indexOf(workloadTag) !== -1) {
608
+ workload += taskWorkloadTags[workloadTag];
609
+ hasWorkloadTags = true;
610
+ }
611
+ }
612
+ }
613
+ if (!hasWorkloadTags) {
614
+ workload = defaultTaskWorkload;
615
+ }
616
+ return workload;
617
+ }
618
+
619
+ /**
620
+ * Get task progress amount
621
+ * @param {object} index
622
+ * @param {object} task
623
+ * @return {number} Task progress
624
+ */
625
+ function taskProgress(index, task) {
626
+ if (taskCompleted(index, task)) {
627
+ return 1;
628
+ }
629
+ return "progress" in task.metadata ? task.metadata.progress : 0;
630
+ }
631
+
632
+ /**
633
+ * Calculate task workload statistics between a start and end date
634
+ * @param {object[]} tasks
635
+ * @param {string} metadataProperty
636
+ * @param {Date} start
637
+ * @param {Date} end
638
+ * @return {object} A statistics object
639
+ */
640
+ function taskWorkloadInPeriod(tasks, metadataProperty, start, end) {
641
+ const filteredTasks = tasks.filter(
642
+ (task) =>
643
+ metadataProperty in task.metadata &&
644
+ task.metadata[metadataProperty] >= start &&
645
+ task.metadata[metadataProperty] <= end
646
+ );
647
+ return {
648
+ tasks: filteredTasks.map((task) => ({
649
+ id: task.id,
650
+ column: task.column,
651
+ workload: task.workload,
652
+ })),
653
+ workload: filteredTasks.reduce((a, task) => a + task.workload, 0),
35
654
  };
36
- const DEFAULT_DATE_FORMAT = "d mmm yy, H:MM";
37
- const DEFAULT_TASK_TEMPLATE = "^+^_${overdue ? '^R' : ''}${name}^: ${created ? ('\\n^-^/' + created) : ''}";
655
+ }
656
+
657
+ /**
658
+ * Check if a column is configured as a started column
659
+ * @param {object} index
660
+ * @param {string|null} columnName
661
+ * @return {boolean}
662
+ */
663
+ function startedColumn(index, columnName) {
664
+ return (
665
+ !!columnName &&
666
+ "startedColumns" in index.options &&
667
+ index.options.startedColumns.indexOf(columnName) !== -1
668
+ );
669
+ }
670
+
671
+ /**
672
+ * Check if a column is configured as a completed column
673
+ * @param {object} index
674
+ * @param {string|null} columnName
675
+ * @return {boolean}
676
+ */
677
+ function completedColumn(index, columnName) {
678
+ return (
679
+ !!columnName &&
680
+ "completedColumns" in index.options &&
681
+ index.options.completedColumns.indexOf(columnName) !== -1
682
+ );
683
+ }
684
+
685
+ /**
686
+ * Build a task's state at a specific date from history events
687
+ * @param {object} task
688
+ * @param {Date} date
689
+ * @return {?object}
690
+ */
691
+ function getTaskHistoryStateAtDate(task, date) {
692
+ if (!("history" in task) || !Array.isArray(task.history) || task.history.length === 0) {
693
+ return null;
694
+ }
38
695
 
39
- // Memoize config
40
- let configMemo = null;
696
+ const state = {
697
+ created: false,
698
+ archived: false,
699
+ column: null,
700
+ };
701
+ const history = [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime());
702
+ history.forEach((historyEvent) => {
703
+ if (!historyEvent.date || historyEvent.date > date) {
704
+ return;
705
+ }
706
+ switch (historyEvent.type) {
707
+ case 'created':
708
+ state.created = true;
709
+ state.archived = false;
710
+ state.column = historyEvent.column || state.column;
711
+ break;
712
+ case 'moved':
713
+ state.column = historyEvent.toColumn || state.column;
714
+ break;
715
+ case 'archived':
716
+ state.archived = true;
717
+ break;
718
+ case 'restored':
719
+ state.archived = false;
720
+ state.column = historyEvent.toColumn || state.column;
721
+ break;
722
+ default:
723
+ break;
724
+ }
725
+ });
726
+ return state;
727
+ }
728
+
729
+ /**
730
+ * Get task progress at a specific date from history when available
731
+ * @param {object} task
732
+ * @param {Date} date
733
+ * @return {number}
734
+ */
735
+ function getTaskProgressAtDate(task, date) {
736
+ if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
737
+ let progress = 0;
738
+ const history = [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime());
739
+ history.forEach((historyEvent) => {
740
+ if (!historyEvent.date || historyEvent.date > date) {
741
+ return;
742
+ }
743
+ if (historyEvent.type === 'progress' && historyEvent.toProgress !== undefined) {
744
+ progress = historyEvent.toProgress;
745
+ }
746
+ if (historyEvent.type === 'created' && historyEvent.toProgress !== undefined) {
747
+ progress = historyEvent.toProgress;
748
+ }
749
+ });
750
+ return Math.max(0, Math.min(progress, 1));
751
+ }
752
+ if (task.completed && task.completed <= date) {
753
+ return 1;
754
+ }
755
+ const progress = "progress" in task.metadata ? task.metadata.progress : 0;
756
+ return Math.max(0, Math.min(progress, 1));
757
+ }
758
+
759
+ /**
760
+ * Get timeline dates for a task in a period. For history-enabled tasks this uses all history event dates,
761
+ * otherwise it falls back to created/started/completed dates.
762
+ * @param {object} task
763
+ * @param {Date} from
764
+ * @param {Date} to
765
+ * @return {Date[]}
766
+ */
767
+ function getTaskTimelineDates(task, from, to) {
768
+ if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
769
+ return task.history
770
+ .map((historyEvent) => historyEvent.date)
771
+ .filter((date) => date && date >= from && date <= to);
772
+ }
773
+ return [task.created, task.started, task.completed].filter((date) => date && date >= from && date <= to);
774
+ }
775
+
776
+ /**
777
+ * Normalise relation type text to kebab-case
778
+ * @param {string} relationType
779
+ * @return {string}
780
+ */
781
+ function normaliseRelationType(relationType) {
782
+ return String(relationType || '')
783
+ .trim()
784
+ .toLowerCase()
785
+ .replace(/\s+/g, '-');
786
+ }
787
+
788
+ /**
789
+ * Get the earliest date in a list of dates
790
+ * @param {Date[]} dates
791
+ * @return {?Date}
792
+ */
793
+ function minDate(dates) {
794
+ const filteredDates = dates.filter((date) => date instanceof Date);
795
+ if (filteredDates.length === 0) {
796
+ return null;
797
+ }
798
+ return new Date(Math.min(...filteredDates.map((date) => date.getTime())));
799
+ }
800
+
801
+ /**
802
+ * Get the latest date in a list of dates
803
+ * @param {Date[]} dates
804
+ * @return {?Date}
805
+ */
806
+ function maxDate(dates) {
807
+ const filteredDates = dates.filter((date) => date instanceof Date);
808
+ if (filteredDates.length === 0) {
809
+ return null;
810
+ }
811
+ return new Date(Math.max(...filteredDates.map((date) => date.getTime())));
812
+ }
813
+
814
+ /**
815
+ * Set a date to the start of the day
816
+ * @param {Date} date
817
+ * @return {Date}
818
+ */
819
+ function startOfDay(date) {
820
+ const result = new Date(date.getTime());
821
+ result.setHours(0, 0, 0, 0);
822
+ return result;
823
+ }
824
+
825
+ /**
826
+ * Set a date to the end of the day
827
+ * @param {Date} date
828
+ * @return {Date}
829
+ */
830
+ function endOfDay(date) {
831
+ const result = new Date(date.getTime());
832
+ result.setHours(23, 59, 59, 999);
833
+ return result;
834
+ }
835
+
836
+ /**
837
+ * Get the task anchor date used for gantt ordering
838
+ * @param {object} task
839
+ * @return {Date}
840
+ */
841
+ function getTaskGanttAnchor(task) {
842
+ return minDate([
843
+ task.metadata.postponed,
844
+ task.created,
845
+ task.started,
846
+ task.completed
847
+ ]) || new Date(0);
848
+ }
849
+
850
+ /**
851
+ * Find one concrete dependency cycle in the graph, if present
852
+ * @param {Map<string, Set<string>>} dependencyMap
853
+ * @return {string[]}
854
+ */
855
+ function findDependencyCycle(dependencyMap) {
856
+ const visiting = new Set();
857
+ const visited = new Set();
858
+ const stack = [];
859
+ let cycleTaskIds = [];
860
+
861
+ const visit = (taskId) => {
862
+ if (cycleTaskIds.length) {
863
+ return;
864
+ }
41
865
 
42
- /**
43
- * Default options for the initialise command
44
- */
45
- const defaultInitialiseOptions = {
46
- name: "Project Name",
47
- description: "",
48
- options: {
49
- startedColumns: ["In Progress"],
50
- completedColumns: ["Done"],
51
- },
52
- columns: ["Backlog", "Todo", "In Progress", "Done"],
866
+ visiting.add(taskId);
867
+ stack.push(taskId);
868
+
869
+ for (let dependencyId of dependencyMap.get(taskId) || []) {
870
+ if (!visited.has(dependencyId) && !visiting.has(dependencyId)) {
871
+ visit(dependencyId);
872
+ if (cycleTaskIds.length) {
873
+ return;
874
+ }
875
+ } else if (visiting.has(dependencyId)) {
876
+ const cycleStartIndex = stack.indexOf(dependencyId);
877
+ cycleTaskIds = stack.slice(cycleStartIndex).concat(dependencyId);
878
+ return;
879
+ }
880
+ }
881
+
882
+ stack.pop();
883
+ visiting.delete(taskId);
884
+ visited.add(taskId);
53
885
  };
54
886
 
55
- /**
56
- * Check if a file or folder exists
57
- * @param {string} path
58
- * @return {boolean} True if the file or folder exists
59
- */
60
- async function exists(path) {
61
- try {
62
- await fs.promises.access(path, fs.constants.R_OK | fs.constants.W_OK);
63
- } catch (error) {
64
- return false;
887
+ for (let taskId of dependencyMap.keys()) {
888
+ if (!visited.has(taskId)) {
889
+ visit(taskId);
890
+ if (cycleTaskIds.length) {
891
+ break;
892
+ }
893
+ }
894
+ }
895
+
896
+ return cycleTaskIds;
897
+ }
898
+
899
+ /**
900
+ * Build gantt schedule data from tracked tasks
901
+ * @param {object} index
902
+ * @param {object[]} tasks
903
+ * @param {Date} now
904
+ * @return {object}
905
+ */
906
+ function buildGanttSchedule(index, tasks, now) {
907
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
908
+ const dependencyMap = new Map(tasks.map((task) => [task.id, new Set()]));
909
+ const dependentsMap = new Map();
910
+ const indegree = new Map(tasks.map((task) => [task.id, 0]));
911
+
912
+ const addDependencyEdge = (dependencyId, dependentId) => {
913
+ if (!taskById.has(dependencyId)) {
914
+ throw new Error(`Task "${dependentId}" depends on missing task "${dependencyId}"`);
915
+ }
916
+
917
+ const dependencies = dependencyMap.get(dependentId);
918
+ if (dependencies.has(dependencyId)) {
919
+ return;
920
+ }
921
+
922
+ dependencies.add(dependencyId);
923
+ indegree.set(dependentId, indegree.get(dependentId) + 1);
924
+ if (!dependentsMap.has(dependencyId)) {
925
+ dependentsMap.set(dependencyId, []);
926
+ }
927
+ dependentsMap.get(dependencyId).push(dependentId);
928
+ };
929
+
930
+ tasks.forEach((task) => {
931
+ (Array.isArray(task.relations) ? task.relations : []).forEach((relation) => {
932
+ if (!relation || !relation.task) {
933
+ return;
934
+ }
935
+
936
+ const relationType = normaliseRelationType(relation.type);
937
+ if (relationType === 'depends-on') {
938
+ addDependencyEdge(relation.task, task.id);
939
+ } else if (relationType === 'blocks') {
940
+ addDependencyEdge(task.id, relation.task);
941
+ }
942
+ });
943
+ });
944
+
945
+ const queue = tasks
946
+ .filter((task) => indegree.get(task.id) === 0)
947
+ .slice()
948
+ .sort((a, b) => compareValues(getTaskGanttAnchor(a), getTaskGanttAnchor(b)) || compareValues(a.id, b.id));
949
+ const orderedTasks = [];
950
+
951
+ while (queue.length > 0) {
952
+ const nextTask = queue.shift();
953
+ orderedTasks.push(nextTask);
954
+
955
+ for (let dependentId of dependentsMap.get(nextTask.id) || []) {
956
+ indegree.set(dependentId, indegree.get(dependentId) - 1);
957
+ if (indegree.get(dependentId) === 0) {
958
+ queue.push(taskById.get(dependentId));
959
+ queue.sort((a, b) => compareValues(getTaskGanttAnchor(a), getTaskGanttAnchor(b)) || compareValues(a.id, b.id));
960
+ }
961
+ }
962
+ }
963
+
964
+ let dependencyCycleDetected = false;
965
+ let cycleFallbackTaskIds = [];
966
+ let dependencyCycleTaskIds = [];
967
+ if (orderedTasks.length !== tasks.length) {
968
+ dependencyCycleDetected = true;
969
+ dependencyCycleTaskIds = findDependencyCycle(dependencyMap);
970
+ const orderedTaskIds = new Set(orderedTasks.map((task) => task.id));
971
+ const remainingTasks = tasks
972
+ .filter((task) => !orderedTaskIds.has(task.id))
973
+ .slice()
974
+ .sort((a, b) => compareValues(getTaskGanttAnchor(a), getTaskGanttAnchor(b)) || compareValues(a.id, b.id));
975
+ cycleFallbackTaskIds = remainingTasks.map((task) => task.id);
976
+ orderedTasks.push(...remainingTasks);
977
+ }
978
+
979
+ const scheduledTasks = [];
980
+ const scheduleById = new Map();
981
+ for (let task of orderedTasks) {
982
+ const dependencies = [...(dependencyMap.get(task.id) || [])];
983
+ const dependencyEnd = maxDate(
984
+ dependencies
985
+ .map((dependencyId) => scheduleById.get(dependencyId))
986
+ .filter((scheduledDependency) => scheduledDependency)
987
+ .map((scheduledDependency) => scheduledDependency.end)
988
+ );
989
+ const taskBaseDate = maxDate([
990
+ task.created,
991
+ task.started,
992
+ task.metadata.postponed,
993
+ ]);
994
+ const scheduleAnchor = maxDate([
995
+ taskBaseDate,
996
+ dependencyEnd
997
+ ]) || new Date(0);
998
+ const duration = task.completed instanceof Date && task.started instanceof Date
999
+ ? Math.max(DAY, task.completed.getTime() - task.started.getTime())
1000
+ : Math.max(DAY, Math.ceil(Math.max(1, task.workload)) * DAY);
1001
+ let start = new Date(scheduleAnchor.getTime());
1002
+ let end = task.completed instanceof Date ? new Date(task.completed.getTime()) : new Date(start.getTime() + duration);
1003
+ if (end.getTime() < start.getTime()) {
1004
+ end = new Date(start.getTime() + duration);
1005
+ }
1006
+
1007
+ const scheduledTask = {
1008
+ ...task,
1009
+ dependencies,
1010
+ start,
1011
+ end,
1012
+ blocked: dependencyEnd !== null && taskBaseDate !== null && dependencyEnd.getTime() > taskBaseDate.getTime()
1013
+ };
1014
+ scheduleById.set(task.id, scheduledTask);
1015
+ scheduledTasks.push(scheduledTask);
1016
+ }
1017
+
1018
+ const from = startOfDay(minDate(scheduledTasks.map((task) => task.start)) || now);
1019
+ const to = endOfDay(maxDate(scheduledTasks.map((task) => task.end)) || now);
1020
+
1021
+ return {
1022
+ from,
1023
+ to,
1024
+ dependencyCycleDetected,
1025
+ dependencyCycleTaskIds,
1026
+ cycleFallbackTaskIds,
1027
+ tasks: scheduledTasks
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * Render a gantt bar for a scheduled task
1033
+ * @param {object} task
1034
+ * @param {Date} from
1035
+ * @param {Date} to
1036
+ * @return {string}
1037
+ */
1038
+ function renderGanttBar(task, from, to) {
1039
+ const result = [];
1040
+ for (let date = new Date(from.getTime()); date <= to; date = new Date(date.getTime() + DAY)) {
1041
+ if (date < task.start || date > task.end) {
1042
+ result.push(' ');
1043
+ } else if (task.completed instanceof Date) {
1044
+ result.push('█');
1045
+ } else if (task.started instanceof Date) {
1046
+ result.push('▓');
1047
+ } else {
1048
+ result.push('░');
1049
+ }
1050
+ }
1051
+ return result.join('');
1052
+ }
1053
+
1054
+ /**
1055
+ * Get a list of tasks that were started before and/or completed after a date
1056
+ * @param {object} index
1057
+ * @param {object[]} tasks
1058
+ * @param {Date} date
1059
+ * @return {object[]} A filtered list of tasks
1060
+ */
1061
+ function getActiveTasksAtDate(index, tasks, date) {
1062
+ return tasks.filter((task) => (
1063
+ (() => {
1064
+ const historyState = getTaskHistoryStateAtDate(task, date);
1065
+ if (historyState !== null) {
1066
+ return historyState.created && !historyState.archived && startedColumn(index, historyState.column) && !completedColumn(index, historyState.column);
1067
+ }
1068
+ return (task.started !== false && task.started <= date) &&
1069
+ (task.completed === false || task.completed > date);
1070
+ })()
1071
+ ));
1072
+ }
1073
+
1074
+ /**
1075
+ * Calculate the total workload at a specific date
1076
+ * @param {object} index
1077
+ * @param {object[]} tasks
1078
+ * @param {Date} date
1079
+ * @return {number} The total workload at the specified date
1080
+ */
1081
+ function getWorkloadAtDate(index, tasks, date) {
1082
+ return getActiveTasksAtDate(index, tasks, date).reduce((a, task) => {
1083
+ const progress = getTaskProgressAtDate(task, date);
1084
+ return a + task.workload * (1 - progress);
1085
+ }, 0);
1086
+ }
1087
+
1088
+ /**
1089
+ * Get the number of tasks that were active at a specific date
1090
+ * @param {object} index
1091
+ * @param {object[]} tasks
1092
+ * @param {Date} date
1093
+ * @return {number} The total number of active tasks at the specified date
1094
+ */
1095
+ function countActiveTasksAtDate(index, tasks, date) {
1096
+ return getActiveTasksAtDate(index, tasks, date).length;
1097
+ }
1098
+
1099
+ /**
1100
+ * Get a list of tasks that were started or completed on a specific date
1101
+ * @param {object} index
1102
+ * @param {object[]} tasks
1103
+ * @param {Date} date
1104
+ * @return {object[]} A list of event objects, with event type and task id
1105
+ */
1106
+ function getTaskEventsAtDate(index, tasks, date) {
1107
+ return tasks
1108
+ .map((task) => {
1109
+ if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
1110
+ return task.history
1111
+ .filter((historyEvent) => historyEvent.date && historyEvent.date.getTime() === date.getTime())
1112
+ .map((historyEvent) => ({
1113
+ eventType: historyEvent.type,
1114
+ task
1115
+ }));
1116
+ }
1117
+ return [
1118
+ (task.created ? task.created.getTime() : 0) === date.getTime() && {
1119
+ eventType: "created",
1120
+ task
1121
+ },
1122
+ (task.started ? task.started.getTime() : 0) === date.getTime() && {
1123
+ eventType: "started",
1124
+ task
1125
+ },
1126
+ (task.completed ? task.completed.getTime() : 0) === date.getTime() && {
1127
+ eventType: "completed",
1128
+ task
1129
+ }
1130
+ ].filter((event) => !!event);
1131
+ })
1132
+ .flat();
1133
+ }
1134
+
1135
+ /**
1136
+ * Quantize a burndown chart date to 1-hour resolution
1137
+ * @param {Date} date
1138
+ * @param {string} resolution One of 'days', 'hours', 'minutes', 'seconds'
1139
+ * @return {Date} The quantized dates
1140
+ */
1141
+ function normaliseDate(date, resolution = 'minutes') {
1142
+ const result = new Date(date.getTime());
1143
+ switch (resolution) {
1144
+ case 'days':
1145
+ result.setHours(0);
1146
+ case 'hours':
1147
+ result.setMinutes(0);
1148
+ case 'minutes':
1149
+ result.setSeconds(0);
1150
+ case 'seconds':
1151
+ result.setMilliseconds(0);
1152
+ default:
1153
+ break;
1154
+ }
1155
+ return result;
1156
+ }
1157
+
1158
+ /**
1159
+ * If a task's column is linked in the index to a custom field with type date, update the custom field's value
1160
+ * in the task data with the current date
1161
+ * @param {object} index
1162
+ * @param {object} taskData
1163
+ * @param {string} columnName
1164
+ * @return {object} The updated task data
1165
+ */
1166
+ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1167
+ // Update built-in column-linked metadata properties first (started and completed dates)
1168
+ taskData = updateColumnLinkedCustomField(index, taskData, columnName, "completed", "once");
1169
+ taskData = updateColumnLinkedCustomField(index, taskData, columnName, "started", "once");
1170
+
1171
+ // Update column-linked custom fields
1172
+ if ("customFields" in index.options) {
1173
+ for (let customField of index.options.customFields) {
1174
+ if (customField.type === "date") {
1175
+ taskData = updateColumnLinkedCustomField(
1176
+ index,
1177
+ taskData,
1178
+ columnName,
1179
+ customField.name,
1180
+ customField.updateDate || "none"
1181
+ );
1182
+ }
1183
+ }
1184
+ }
1185
+ return taskData;
1186
+ }
1187
+
1188
+ /**
1189
+ * If index options contains a list of columns linked to a custom field name and a task's column matches one
1190
+ * of the columns in this list, set the task's custom field value to the current date depending on criteria:
1191
+ * - if 'once', update the value only if it's not currently set
1192
+ * - if 'always', update the value regardless
1193
+ * - otherwise, don't update the value
1194
+ * @param {object} index
1195
+ * @param {object} taskData
1196
+ * @param {string} columnName
1197
+ * @param {string} fieldName
1198
+ * @param {string} [updateCriteria='none']
1199
+ */
1200
+ function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
1201
+ const columnList = `${fieldName}Columns`;
1202
+ if (columnList in index.options && index.options[columnList].indexOf(columnName) !== -1) {
1203
+ switch (updateCriteria) {
1204
+ case "always":
1205
+ taskData = setTaskMetadata(taskData, fieldName, new Date());
1206
+ break;
1207
+ case "once":
1208
+ if (!(fieldName in taskData.metadata && taskData.metadata[fieldName])) {
1209
+ taskData = setTaskMetadata(taskData, fieldName, new Date());
1210
+ }
1211
+ break;
1212
+ default:
1213
+ break;
1214
+ }
1215
+ }
1216
+ return taskData;
1217
+ }
1218
+
1219
+ class Kanbn {
1220
+ ROOT = process.cwd();
1221
+ CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
1222
+ CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
1223
+
1224
+ // Memoize config
1225
+ configMemo = null;
1226
+
1227
+ constructor(root = null) {
1228
+ if(root) {
1229
+ this.ROOT = root
1230
+ this.CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
1231
+ this.CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
65
1232
  }
66
- return true;
67
1233
  }
68
1234
 
69
1235
  /**
70
1236
  * Check if a separate config file exists
71
- * @returns {boolean} True if a config file exists
1237
+ * @returns {Promise<boolean>} True if a config file exists
72
1238
  */
73
- async function configExists() {
74
- return await exists(CONFIG_YAML) || await exists(CONFIG_JSON);
1239
+ async configExists() {
1240
+ return await exists(this.CONFIG_YAML) || await exists(this.CONFIG_JSON);
75
1241
  }
76
1242
 
77
1243
  /**
78
1244
  * Save configuration data to a separate config file
79
1245
  */
80
- async function saveConfig(config) {
81
- if (await exists(CONFIG_YAML)) {
82
- await fs.promises.writeFile(CONFIG_YAML, yaml.stringify(config, 4, 2));
1246
+ async saveConfig(config) {
1247
+ if (await exists(this.CONFIG_YAML)) {
1248
+ await fs.promises.writeFile(this.CONFIG_YAML, yaml.stringify(config, 4, 2));
83
1249
  } else {
84
- await fs.promises.writeFile(CONFIG_JSON, JSON.stringify(config, null, 4));
1250
+ await fs.promises.writeFile(this.CONFIG_JSON, JSON.stringify(config, null, 4));
85
1251
  }
86
1252
  }
87
1253
 
88
1254
  /**
89
- * Get a task path from the id
90
- * @param {string} tasksPath The path to the tasks folder
91
- * @param {string} taskId The task id
92
- * @return {string} The task path
1255
+ * Get configuration settings from the config file if it exists, otherwise return null
1256
+ * @return {Promise<Object|null>} Configuration settings or null if there is no separate config file
93
1257
  */
94
- function getTaskPath(tasksPath, taskId) {
95
- return path.join(tasksPath, addFileExtension(taskId));
1258
+ async getConfig() {
1259
+ if (this.configMemo === null) {
1260
+ let config = null;
1261
+ if (await exists(this.CONFIG_YAML)) {
1262
+ try {
1263
+ config = yaml.load(this.CONFIG_YAML);
1264
+ } catch (error) {
1265
+ throw new Error(`Couldn't load config file: ${error.message}`);
1266
+ }
1267
+ } else if (await exists(this.CONFIG_JSON)) {
1268
+ try {
1269
+ config = JSON.parse(await fs.promises.readFile(this.CONFIG_JSON, { encoding: "utf-8" }));
1270
+ } catch (error) {
1271
+ throw new Error(`Couldn't load config file: ${error.message}`);
1272
+ }
1273
+ }
1274
+ this.configMemo = config;
1275
+ }
1276
+ return this.configMemo;
96
1277
  }
97
1278
 
98
1279
  /**
99
- * Add the file extension to an id if it doesn't already have one
100
- * @param {string} taskId The task id
101
- * @return {string} The task id with .md extension
1280
+ * Clear cached config
102
1281
  */
103
- function addFileExtension(taskId) {
104
- if (!/\.md$/.test(taskId)) {
105
- return `${taskId}.md`;
106
- }
107
- return taskId;
1282
+ clearConfigCache() {
1283
+ this.configMemo = null;
108
1284
  }
109
1285
 
110
1286
  /**
111
- * Remove the file extension from an id if it has one
112
- * @param {string} taskId The task id
113
- * @return {string} The task id without .md extension
1287
+ * Get the name of the folder where the index and tasks are stored
1288
+ * @return {Promise<string>} The kanbn folder name
114
1289
  */
115
- function removeFileExtension(taskId) {
116
- if (/\.md$/.test(taskId)) {
117
- return taskId.slice(0, taskId.length - ".md".length);
1290
+ async getFolderName() {
1291
+ const config = await this.getConfig();
1292
+ if (config !== null && 'mainFolder' in config) {
1293
+ return config.mainFolder;
118
1294
  }
119
- return taskId;
1295
+ return DEFAULT_FOLDER_NAME;
120
1296
  }
121
1297
 
122
1298
  /**
123
- * Get a list of all tracked task ids
124
- * @param {object} index The index object
125
- * @param {?string} [columnName=null] The optional column name to filter tasks by
126
- * @return {Set} A set of task ids appearing in the index
1299
+ * Get the index filename
1300
+ * @return {Promise<string>} The index filename
127
1301
  */
128
- function getTrackedTaskIds(index, columnName = null) {
129
- return new Set(
130
- columnName
131
- ? index.columns[columnName]
132
- : Object.keys(index.columns)
133
- .map((columnName) => index.columns[columnName])
134
- .flat()
135
- );
1302
+ async getIndexFileName() {
1303
+ const config = await this.getConfig();
1304
+ if (config !== null && 'indexFile' in config) {
1305
+ return config.indexFile;
1306
+ }
1307
+ return DEFAULT_INDEX_FILE_NAME;
136
1308
  }
137
1309
 
138
1310
  /**
139
- * Check if a task exists in the index
140
- * @param {object} index The index object
141
- * @param {string} taskId The task id to search for
142
- * @return {boolean} True if the task exists in the index
1311
+ * Get the name of the folder where tasks are stored
1312
+ * @return {Promise<string>} The task folder name
143
1313
  */
144
- function taskInIndex(index, taskId) {
145
- for (let columnName in index.columns) {
146
- if (index.columns[columnName].indexOf(taskId) !== -1) {
147
- return true;
148
- }
1314
+ async getTaskFolderName() {
1315
+ const config = await this.getConfig();
1316
+ if (config !== null && 'taskFolder' in config) {
1317
+ return config.taskFolder;
149
1318
  }
150
- return false;
1319
+ return DEFAULT_TASKS_FOLDER_NAME;
151
1320
  }
152
1321
 
153
1322
  /**
154
- * Find a task in the index and returns the column that it's in
155
- * @param {object} index The index data
156
- * @param {string} taskId The task id to search for
157
- * @return {?string} The column name for the specified task, or null if it wasn't found
1323
+ * Get the name of the archive folder
1324
+ * @return {Promise<string>} The archive folder name
158
1325
  */
159
- function findTaskColumn(index, taskId) {
160
- for (let columnName in index.columns) {
161
- if (index.columns[columnName].indexOf(taskId) !== -1) {
162
- return columnName;
163
- }
1326
+ async getArchiveFolderName() {
1327
+ const config = await this.getConfig();
1328
+ if (config !== null && 'archiveFolder' in config) {
1329
+ return config.archiveFolder;
164
1330
  }
165
- return null;
1331
+ return DEFAULT_ARCHIVE_FOLDER_NAME;
166
1332
  }
167
1333
 
168
1334
  /**
169
- * Add a task id to the specified column in the index
170
- * @param {object} index The index object
171
- * @param {string} taskId The task id to add
172
- * @param {string} columnName The column to add the task to
173
- * @param {?number} [position=null] The position in the column to move the task to, or last position if null
174
- * @return {object} The modified index object
1335
+ * Get the kanbn folder location for the current working directory
1336
+ * @return {Promise<string>} The kanbn folder path
175
1337
  */
176
- function addTaskToIndex(index, taskId, columnName, position = null) {
177
- if (position === null) {
178
- index.columns[columnName].push(taskId);
179
- } else {
180
- index.columns[columnName].splice(position, 0, taskId);
181
- }
182
- return index;
1338
+ async getMainFolder() {
1339
+ return path.join(this.ROOT, await this.getFolderName());
183
1340
  }
184
1341
 
185
1342
  /**
186
- * Remove all instances of a task id from the index
187
- * @param {object} index The index object
188
- * @param {string} taskId The task id to remove
189
- * @return {object} The modified index object
1343
+ * Get the index path
1344
+ * @return {Promise<string>} The kanbn index path
190
1345
  */
191
- function removeTaskFromIndex(index, taskId) {
192
- for (let columnName in index.columns) {
193
- index.columns[columnName] = index.columns[columnName].filter((t) => t !== taskId);
194
- }
195
- return index;
1346
+ async getIndexPath() {
1347
+ return path.join(await this.getMainFolder(), await this.getIndexFileName());
196
1348
  }
197
1349
 
198
1350
  /**
199
- * Rename all instances of a task id in the index
200
- * @param {object} index The index object
201
- * @param {string} taskId The task id to rename
202
- * @param {string} newTaskId The new task id
203
- * @return {object} The modified index object
1351
+ * Get the task folder path
1352
+ * @return {Promise<string>} The kanbn task folder path
204
1353
  */
205
- function renameTaskInIndex(index, taskId, newTaskId) {
206
- for (let columnName in index.columns) {
207
- index.columns[columnName] = index.columns[columnName].map((t) => (t === taskId ? newTaskId : t));
208
- }
209
- return index;
1354
+ async getTaskFolderPath() {
1355
+ return path.join(await this.getMainFolder(), await this.getTaskFolderName());
210
1356
  }
211
1357
 
212
1358
  /**
213
- * Get a metadata property from a task, or undefined if the metadata property doesn't exist or
214
- * if the task has no metadata
215
- * @param {object} taskData The task object
216
- * @param {string} property The metadata property to check
217
- * @return {any} The metadata property value
1359
+ * Get the archive folder path
1360
+ * @return {Promise<string>} The kanbn archive folder path
218
1361
  */
219
- function getTaskMetadata(taskData, property) {
220
- if ("metadata" in taskData && property in taskData.metadata) {
221
- return taskData.metadata[property];
222
- }
223
- return undefined;
1362
+ async getArchiveFolderPath() {
1363
+ return path.join(await this.getMainFolder(), await this.getArchiveFolderName());
224
1364
  }
225
1365
 
226
1366
  /**
227
- * Set a metadata value in a task. If the value is undefined, remove the metadata property instead
228
- * @param {object} taskData The task object
229
- * @param {string} property The metadata property to update
230
- * @param {string} value The value to set
231
- * @return {object} The modified task object
1367
+ * Get the index as an object
1368
+ * @return {Promise<index>} The index
232
1369
  */
233
- function setTaskMetadata(taskData, property, value) {
234
- if (!("metadata" in taskData)) {
235
- taskData.metadata = {};
236
- }
237
- if (property in taskData.metadata && value === undefined) {
238
- delete taskData.metadata[property];
239
- } else {
240
- taskData.metadata[property] = value;
1370
+ async getIndex() {
1371
+ // Check if this folder has been initialised
1372
+ if (!(await this.initialised())) {
1373
+ throw new Error("Not initialised in this folder");
241
1374
  }
242
- return taskData;
1375
+
1376
+ return this.loadIndex();
243
1377
  }
244
1378
 
245
1379
  /**
246
- * Check if a task is completed
247
- * @param {object} index
248
- * @param {object} task
249
- * @return {boolean} True if the task is in a completed column or has a completed date
1380
+ * Get a task as an object
1381
+ * @param {string} taskId The task id to get
1382
+ * @return {Promise<task>} The task
250
1383
  */
251
- function taskCompleted(index, task) {
252
- return (
253
- "completed" in task.metadata ||
254
- ("completedColumns" in index.options &&
255
- index.options.completedColumns.indexOf(findTaskColumn(index, task.id)) !== -1)
256
- );
1384
+ async getTask(taskId) {
1385
+ this.taskExists(taskId);
1386
+ return this.loadTask(taskId);
257
1387
  }
258
1388
 
259
1389
  /**
260
- * Sort a column in the index
261
- * @param {object} index The index object
262
- * @param {object[]} tasks The tasks in the index
263
- * @param {string} columnName The column to sort
264
- * @param {object[]} sorters A list of sorter objects
265
- * @return {object} The modified index object
1390
+ * Add additional index-based information to a task
1391
+ * @param {index} index The index object
1392
+ * @param {task} task The task object
1393
+ * @return {task} The hydrated task
266
1394
  */
267
- function sortColumnInIndex(index, tasks, columnName, sorters) {
268
- // Get a list of tasks in the target column and add computed fields
269
- tasks = tasks.map((task) => ({
270
- ...task,
271
- ...task.metadata,
272
- created: "created" in task.metadata ? task.metadata.created : "",
273
- updated: "updated" in task.metadata ? task.metadata.updated : "",
274
- started: "started" in task.metadata ? task.metadata.started : "",
275
- completed: "completed" in task.metadata ? task.metadata.completed : "",
276
- due: "due" in task.metadata ? task.metadata.due : "",
277
- assigned: "assigned" in task.metadata ? task.metadata.assigned : "",
278
- countSubTasks: task.subTasks.length,
279
- subTasks: task.subTasks.map((subTask) => `[${subTask.completed ? "x" : ""}] ${subTask.text}`).join("\n"),
280
- countTags: "tags" in task.metadata ? task.metadata.tags.length : 0,
281
- tags: "tags" in task.metadata ? task.metadata.tags.join("\n") : "",
282
- countRelations: task.relations.length,
283
- relations: task.relations.map((relation) => `${relation.type} ${relation.task}`).join("\n"),
284
- countComments: task.comments.length,
285
- comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
286
- workload: taskWorkload(index, task),
287
- progress: taskProgress(index, task),
288
- }));
289
-
290
- // Sort the list of tasks
291
- tasks = sortTasks(tasks, sorters);
292
-
293
- // Save the list of tasks back to the index
294
- index.columns[columnName] = tasks.map((task) => task.id);
295
- return index;
1395
+ hydrateTask(index, task) {
1396
+ const completed = taskCompleted(index, task);
1397
+ task.column = findTaskColumn(index, task.id);
1398
+ task.workload = taskWorkload(index, task);
1399
+
1400
+ // Add progress information
1401
+ task.progress = taskProgress(index, task);
1402
+ task.remainingWorkload = Math.ceil(task.workload * (1 - task.progress));
1403
+
1404
+ // Add due information
1405
+ if ("due" in task.metadata) {
1406
+ const dueData = {};
1407
+
1408
+ // A task is overdue if it's due date is in the past and the task is not in a completed column
1409
+ // or doesn't have a completed dates
1410
+ const completedDate = "completed" in task.metadata ? task.metadata.completed : null;
1411
+
1412
+ // Get task due delta - this is the difference between now and the due date, or if the task is completed
1413
+ // this is the difference between the completed and due dates
1414
+ let delta;
1415
+ if (completedDate !== null) {
1416
+ delta = completedDate - task.metadata.due;
1417
+ } else {
1418
+ delta = new Date() - task.metadata.due;
1419
+ }
1420
+
1421
+ // Populate due information
1422
+ dueData.completed = completed;
1423
+ dueData.completedDate = completedDate;
1424
+ dueData.dueDate = task.metadata.due;
1425
+ dueData.overdue = !completed && delta > 0;
1426
+ dueData.dueDelta = delta;
1427
+
1428
+ // Prepare a due message for the task
1429
+ let dueMessage = "";
1430
+ if (completed) {
1431
+ dueMessage += "Completed ";
1432
+ }
1433
+ dueMessage += `${humanizeDuration(delta, {
1434
+ largest: 3,
1435
+ round: true,
1436
+ })} ${delta > 0 ? "overdue" : "remaining"}`;
1437
+ dueData.dueMessage = dueMessage;
1438
+ task.dueData = dueData;
1439
+ }
1440
+ return task;
296
1441
  }
297
1442
 
298
1443
  /**
299
- * Sort a list of tasks
300
- * @param {object[]} tasks
301
- * @param {object[]} sorters
302
- * @return {object[]} The sorted tasks
1444
+ * Return a filtered and sorted list of tasks
1445
+ * @param {index} index The index object
1446
+ * @param {task[]} tasks A list of task objects
1447
+ * @param {object} filters A list of task filters
1448
+ * @param {object[]} sorters A list of task sorters
1449
+ * @return {object[]} A filtered and sorted list of tasks
303
1450
  */
304
- function sortTasks(tasks, sorters) {
305
- tasks.sort((a, b) => {
306
- let compareA, compareB;
307
- for (let sorter of sorters) {
308
- compareA = a[sorter.field];
309
- compareB = b[sorter.field];
310
- if (sorter.filter) {
311
- compareA = sortFilter(compareA, sorter.filter);
312
- compareB = sortFilter(compareB, sorter.filter);
313
- }
314
- if (compareA === compareB) {
315
- continue;
316
- }
317
- return sorter.order === "descending" ? compareValues(compareB, compareA) : compareValues(compareA, compareB);
318
- }
319
- return 0;
320
- });
321
- return tasks;
1451
+ filterAndSortTasks(index, tasks, filters, sorters) {
1452
+ return sortTasks(filterTasks(index, tasks, filters), sorters);
322
1453
  }
323
1454
 
324
1455
  /**
325
- * Transform a value using a sort filter regular expression
326
- * @param {string} value
327
- * @param {string} filter
328
- * @return {string} The transformed value
1456
+ * Overwrite the index file with the specified data
1457
+ * @param {object} indexData Index data to save
329
1458
  */
330
- function sortFilter(value, filter) {
331
- // Filter regex is global and case-insensitive
332
- const matches = [...value.matchAll(new RegExp(filter, "gi"))];
333
- const result = matches.map((match) => {
334
- // If the matched string has named capturing groups, concatenate their contents
335
- if (match.groups) {
336
- return Object.values(match.groups).join("");
1459
+ async saveIndex(indexData) {
1460
+ // Apply column sorting if any sorters are defined in options
1461
+ if ("columnSorting" in indexData.options && Object.keys(indexData.options.columnSorting).length) {
1462
+ for (let columnName in indexData.options.columnSorting) {
1463
+ indexData = sortColumnInIndex(
1464
+ indexData,
1465
+ await this.loadAllTrackedTasks(indexData, columnName),
1466
+ columnName,
1467
+ indexData.options.columnSorting[columnName]
1468
+ );
337
1469
  }
1470
+ }
338
1471
 
339
- // If the matched string has non-named capturing groups, use the contents of the first group
340
- if (match[1]) {
341
- return match[1];
342
- }
1472
+ // If there is a separate config file, save options to this file
1473
+ let ignoreOptions = false;
1474
+ if (await this.configExists()) {
1475
+ await this.saveConfig(indexData.options);
1476
+ ignoreOptions = true;
1477
+ }
343
1478
 
344
- // Otherwise use the matched string
345
- return match[0];
346
- });
347
- return result.join("");
1479
+ // Save index
1480
+ await fs.promises.writeFile(await this.getIndexPath(), parseIndex.json2md(indexData, ignoreOptions));
348
1481
  }
349
1482
 
350
1483
  /**
351
- * Compare two values (supports string, date and number values)
352
- * @param {any} a
353
- * @param {any} b
354
- * @return {number} A positive value if a > b, negative if a < b, otherwise 0
1484
+ * Load the index file and parse it to an object
1485
+ * @return {Promise<object>} The index object
355
1486
  */
356
- function compareValues(a, b) {
357
- if (a === undefined && b === undefined) {
358
- return 0;
1487
+ async loadIndex() {
1488
+ let indexData = "";
1489
+ try {
1490
+ indexData = await fs.promises.readFile(await this.getIndexPath(), { encoding: "utf-8" });
1491
+ } catch (error) {
1492
+ throw new Error(`Couldn't access index file: ${error.message}`);
359
1493
  }
360
- a = utility.coerceUndefined(a, typeof b);
361
- b = utility.coerceUndefined(b, typeof a);
362
- if (typeof a === "string" && typeof b === "string") {
363
- return a.localeCompare(b, undefined, { sensitivity: "accent" });
1494
+ const index = parseIndex.md2json(indexData);
1495
+
1496
+ // If configuration settings exist in a separate config file, merge them with index options
1497
+ const config = await this.getConfig();
1498
+ if (config !== null) {
1499
+ index.options = { ...index.options, ...config };
364
1500
  }
365
- return a - b;
1501
+ return index;
366
1502
  }
367
1503
 
368
1504
  /**
369
- * Filter a list of tasks using a filters object containing field names and filter values
370
- * @param {object} index
371
- * @param {object[]}} tasks
372
- * @param {object} filters
1505
+ * Overwrite a task file with the specified data
1506
+ * @param {string} path The task path
1507
+ * @param {object} taskData The task data
373
1508
  */
374
- function filterTasks(index, tasks, filters) {
375
- return tasks.filter((task) => {
376
- // Get task id and column
377
- const taskId = utility.getTaskId(task.name);
378
- const column = findTaskColumn(index, taskId);
379
-
380
- // If no filters are defined, return all tasks
381
- if (Object.keys(filters).length === 0) {
382
- return true;
383
- }
384
-
385
- // Apply filters
386
- let result = true;
387
-
388
- // Id
389
- if ("id" in filters && !stringFilter(filters.id, task.id)) {
390
- result = false;
391
- }
392
-
393
- // Name
394
- if ("name" in filters && !stringFilter(filters.name, task.name)) {
395
- result = false;
396
- }
397
-
398
- // Description
399
- if ("description" in filters && !stringFilter(filters.description, task.description)) {
400
- result = false;
401
- }
402
-
403
- // Column
404
- if ("column" in filters && !stringFilter(filters.column, column)) {
405
- result = false;
406
- }
407
-
408
- // Created date
409
- if (
410
- "created" in filters &&
411
- (!("created" in task.metadata) || !dateFilter(filters.created, task.metadata.created))
412
- ) {
413
- result = false;
414
- }
415
-
416
- // Updated date
417
- if (
418
- "updated" in filters &&
419
- (!("updated" in task.metadata) || !dateFilter(filters.updated, task.metadata.updated))
420
- ) {
421
- result = false;
422
- }
423
-
424
- // Started date
425
- if (
426
- "started" in filters &&
427
- (!("started" in task.metadata) || !dateFilter(filters.started, task.metadata.started))
428
- ) {
429
- result = false;
430
- }
431
-
432
- // Completed date
433
- if (
434
- "completed" in filters &&
435
- (!("completed" in task.metadata) || !dateFilter(filters.completed, task.metadata.completed))
436
- ) {
437
- result = false;
438
- }
439
-
440
- // Due
441
- if ("due" in filters && (!("due" in task.metadata) || !dateFilter(filters.due, task.metadata.due))) {
442
- result = false;
443
- }
444
-
445
- // Workload
446
- if ("workload" in filters && !numberFilter(filters.workload, taskWorkload(index, task))) {
447
- result = false;
448
- }
449
-
450
- // Progress
451
- if ("progress" in filters && !numberFilter(filters.progress, taskProgress(index, task))) {
452
- result = false;
453
- }
454
-
455
- // Assigned
456
- if (
457
- "assigned" in filters &&
458
- !stringFilter(filters.assigned, "assigned" in task.metadata ? task.metadata.assigned : "")
459
- ) {
460
- result = false;
461
- }
462
-
463
- // Sub-tasks
464
- if (
465
- "sub-task" in filters &&
466
- !stringFilter(
467
- filters["sub-task"],
468
- task.subTasks.map((subTask) => `[${subTask.completed ? "x" : " "}] ${subTask.text}`).join("\n")
469
- )
470
- ) {
471
- result = false;
472
- }
473
-
474
- // Count sub-tasks
475
- if ("count-sub-tasks" in filters && !numberFilter(filters["count-sub-tasks"], task.subTasks.length)) {
476
- result = false;
477
- }
478
-
479
- // Tag
480
- if ("tag" in filters && !stringFilter(filters.tag, task.metadata.tags.join("\n"))) {
481
- result = false;
482
- }
483
-
484
- // Count tags
485
- if ("count-tags" in filters && !numberFilter(filters["count-tags"], task.tags.length)) {
486
- result = false;
487
- }
488
-
489
- // Relation
490
- if (
491
- "relation" in filters &&
492
- !stringFilter(
493
- filters.relation,
494
- task.relations.map((relation) => `${relation.type} ${relation.task}`).join("\n")
495
- )
496
- ) {
497
- result = false;
498
- }
499
-
500
- // Count relations
501
- if ("count-relations" in filters && !numberFilter(filters["count-relations"], task.relations.length)) {
502
- result = false;
503
- }
504
-
505
- // Comments
506
- if (
507
- "comment" in filters &&
508
- !stringFilter(filters.comment, task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"))
509
- ) {
510
- result = false;
511
- }
512
-
513
- // Count comments
514
- if ("count-comments" in filters && !numberFilter(filters["count-comments"], task.comments.length)) {
515
- result = false;
516
- }
517
-
518
- // Custom metadata properties
519
- if ("customFields" in index.options) {
520
- for (let customField of index.options.customFields) {
521
- if (customField.name in filters) {
522
- if (!(customField.name in task.metadata)) {
523
- result = false;
524
- } else {
525
- switch (customField.type) {
526
- case "boolean":
527
- if (task.metadata[customField.name] !== filters[customField.name]) {
528
- result = false;
529
- }
530
- break;
531
- case "number":
532
- if (!numberFilter(filters[customField.name], task.metadata[customField.name])) {
533
- result = false;
534
- }
535
- break;
536
- case "string":
537
- if (!stringFilter(filters[customField.name], task.metadata[customField.name])) {
538
- result = false;
539
- }
540
- break;
541
- case "date":
542
- if (!dateFilter(filters[customField.name], task.metadata[customField.name])) {
543
- result = false;
544
- }
545
- break;
546
- default:
547
- break;
548
- }
549
- }
550
- }
551
- }
552
- }
553
- return result;
554
- });
555
- }
1509
+ async saveTask(path, taskData) {
1510
+ await fs.promises.writeFile(path, parseTask.json2md(taskData));
1511
+ }
556
1512
 
557
1513
  /**
558
- * Check if the input string matches the filter regex
559
- * @param {string|string[]} filter A regular expression or array of regular expressions
560
- * @param {string} input The string to match against
561
- * @return {boolean} True if the input matches the string filter
1514
+ * Load a task file and parse it to an object
1515
+ * @param {string} taskId The task id
1516
+ * @return {Promise<object>} The task object
562
1517
  */
563
- function stringFilter(filter, input) {
564
- if (Array.isArray(filter)) {
565
- filter = filter.join("|");
1518
+ async loadTask(taskId) {
1519
+ const taskPath = path.join(await this.getTaskFolderPath(), addFileExtension(taskId));
1520
+ let taskData = "";
1521
+ try {
1522
+ taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
1523
+ } catch (error) {
1524
+ throw new Error(`Couldn't access task file: ${error.message}`);
566
1525
  }
567
- return new RegExp(filter, "i").test(input);
1526
+ return parseTask.md2json(taskData);
568
1527
  }
569
1528
 
570
1529
  /**
571
- * Check if the input date matches a date (ignore time part), or if multiple dates are passed in, check if the
572
- * input date is between the earliest and latest dates
573
- * @param {Date|Date[]} dates A date or list of dates to check against
574
- * @param {Date} input The input date to match against
575
- * @return {boolean} True if the input matches the date filter
1530
+ * Load all tracked tasks and return an array of task objects
1531
+ * @param {object} index The index object
1532
+ * @param {?string} [columnName=null] The optional column name to filter tasks by
1533
+ * @return {Promise<object[]>} All tracked tasks
576
1534
  */
577
- function dateFilter(dates, input) {
578
- dates = utility.arrayArg(dates);
579
- if (dates.length === 1) {
580
- return utility.compareDates(input, dates[0]);
1535
+ async loadAllTrackedTasks(index, columnName = null) {
1536
+ const result = [];
1537
+ const trackedTasks = getTrackedTaskIds(index, columnName);
1538
+ for (let taskId of trackedTasks) {
1539
+ result.push(await this.loadTask(taskId));
581
1540
  }
582
- const earliest = Math.min(...dates);
583
- const latest = Math.max(...dates);
584
- return input >= earliest && input <= latest;
1541
+ return result;
585
1542
  }
586
1543
 
587
1544
  /**
588
- * Check if the input matches a number, or if multiple numbers are passed in, check if the input is between the
589
- * minimum and maximum numbers
590
- * @param {number|number[]} filter A filter number or array of filter numbers
591
- * @param {number} input The number to match against
592
- * @return {boolean} True if the input matches the number filter
1545
+ * Load a task file from the archive and parse it to an object
1546
+ * @param {string} taskId The task id
1547
+ * @return {Promise<object>} The task object
593
1548
  */
594
- function numberFilter(filter, input) {
595
- filter = utility.arrayArg(filter);
596
- return input >= Math.min(...filter) && input <= Math.max(...filter);
1549
+ async loadArchivedTask(taskId) {
1550
+ const taskPath = path.join(await this.getArchiveFolderPath(), addFileExtension(taskId));
1551
+ let taskData = "";
1552
+ try {
1553
+ taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
1554
+ } catch (error) {
1555
+ throw new Error(`Couldn't access archived task file: ${error.message}`);
1556
+ }
1557
+ return parseTask.md2json(taskData);
597
1558
  }
598
1559
 
599
1560
  /**
600
- * Calculate task workload
1561
+ * Get the date format defined in the index, or the default date format
601
1562
  * @param {object} index The index object
602
- * @param {object} task The task object
603
- * @return {number} The task workload
1563
+ * @return {string} The date format
604
1564
  */
605
- function taskWorkload(index, task) {
606
- const defaultTaskWorkload =
607
- "defaultTaskWorkload" in index.options ? index.options.defaultTaskWorkload : DEFAULT_TASK_WORKLOAD;
608
- const taskWorkloadTags =
609
- "taskWorkloadTags" in index.options ? index.options.taskWorkloadTags : DEFAULT_TASK_WORKLOAD_TAGS;
610
- let workload = 0;
611
- let hasWorkloadTags = false;
612
- if ("tags" in task.metadata) {
613
- for (let workloadTag of Object.keys(taskWorkloadTags)) {
614
- if (task.metadata.tags.indexOf(workloadTag) !== -1) {
615
- workload += taskWorkloadTags[workloadTag];
616
- hasWorkloadTags = true;
617
- }
618
- }
619
- }
620
- if (!hasWorkloadTags) {
621
- workload = defaultTaskWorkload;
622
- }
623
- return workload;
1565
+ getDateFormat(index) {
1566
+ return "dateFormat" in index.options ? index.options.dateFormat : DEFAULT_DATE_FORMAT;
624
1567
  }
625
1568
 
626
1569
  /**
627
- * Get task progress amount
628
- * @param {object} index
629
- * @param {object} task
630
- * @return {number} Task progress
1570
+ * Get the task template for displaying tasks on the kanbn board from the index, or the default task template
1571
+ * @param {object} index The index object
1572
+ * @return {string} The task template
631
1573
  */
632
- function taskProgress(index, task) {
633
- if (taskCompleted(index, task)) {
634
- return 1;
635
- }
636
- return "progress" in task.metadata ? task.metadata.progress : 0;
1574
+ getTaskTemplate(index) {
1575
+ return "taskTemplate" in index.options ? index.options.taskTemplate : DEFAULT_TASK_TEMPLATE;
637
1576
  }
638
1577
 
639
1578
  /**
640
- * Calculate task workload statistics between a start and end date
641
- * @param {object[]} tasks
642
- * @param {string} metadataProperty
643
- * @param {Date} start
644
- * @param {Date} end
645
- * @return {object} A statistics object
1579
+ * Check if the current working directory has been initialised
1580
+ * @return {Promise<boolean>} True if the current working directory has been initialised, otherwise false
646
1581
  */
647
- function taskWorkloadInPeriod(tasks, metadataProperty, start, end) {
648
- const filteredTasks = tasks.filter(
649
- (task) =>
650
- metadataProperty in task.metadata &&
651
- task.metadata[metadataProperty] >= start &&
652
- task.metadata[metadataProperty] <= end
653
- );
654
- return {
655
- tasks: filteredTasks.map((task) => ({
656
- id: task.id,
657
- column: task.column,
658
- workload: task.workload,
659
- })),
660
- workload: filteredTasks.reduce((a, task) => a + task.workload, 0),
661
- };
1582
+ async initialised() {
1583
+ return await exists(await this.getIndexPath());
662
1584
  }
663
1585
 
664
1586
  /**
665
- * Get a list of tasks that were started before and/or completed after a date
666
- * @param {object[]} tasks
667
- * @param {Date} date
668
- * @return {object[]} A filtered list of tasks
1587
+ * Initialise a kanbn board in the current working directory
1588
+ * @param {object} [options={}] Initial columns and other config options
669
1589
  */
670
- function getActiveTasksAtDate(tasks, date) {
671
- return tasks.filter((task) => (
672
- (task.started !== false && task.started <= date) &&
673
- (task.completed === false || task.completed > date)
674
- ));
675
- }
1590
+ async initialise(options = {}) {
1591
+ // Check if a main folder is defined in an existing config file
1592
+ const mainFolder = await this.getMainFolder();
676
1593
 
677
- /**
678
- * Calculate the total workload at a specific date
679
- * @param {object[]} tasks
680
- * @param {Date} date
681
- * @return {number} The total workload at the specified date
682
- */
683
- function getWorkloadAtDate(tasks, date) {
684
- return getActiveTasksAtDate(tasks, date).reduce((a, task) => (a += task.workload), 0);
685
- }
1594
+ // Create main folder if it doesn't already exist
1595
+ if (!(await exists(mainFolder))) {
1596
+ await fs.promises.mkdir(mainFolder, { recursive: true });
1597
+ }
686
1598
 
687
- /**
688
- * Get the number of tasks that were active at a specific date
689
- * @param {object[]} tasks
690
- * @param {Date} date
691
- * @return {number} The total number of active tasks at the specified date
692
- */
693
- function countActiveTasksAtDate(tasks, date) {
694
- return getActiveTasksAtDate(tasks, date).length;
695
- }
1599
+ // Create tasks folder if it doesn't already exist
1600
+ const taskFolder = await this.getTaskFolderPath();
1601
+ if (!(await exists(taskFolder))) {
1602
+ await fs.promises.mkdir(taskFolder, { recursive: true });
1603
+ }
696
1604
 
697
- /**
698
- * Get a list of tasks that were started or completed on a specific date
699
- * @param {object[]} tasks
700
- * @param {Date} date
701
- * @return {object[]} A list of event objects, with event type and task id
702
- */
703
- function getTaskEventsAtDate(tasks, date) {
704
- return [
705
- ...tasks
706
- .filter((task) => (task.created ? task.created.getTime() : 0) === date.getTime())
707
- .map((task) => ({
708
- eventType: "created",
709
- task
710
- })),
711
- ...tasks
712
- .filter((task) => (task.started ? task.started.getTime() : 0) === date.getTime())
713
- .map((task) => ({
714
- eventType: "started",
715
- task
716
- })),
717
- ...tasks
718
- .filter((task) => (task.completed ? task.completed.getTime() : 0) === date.getTime())
719
- .map((task) => ({
720
- eventType: "completed",
721
- task
722
- })),
723
- ];
1605
+ // Create index if one doesn't already exist
1606
+ let index;
1607
+ if (!(await exists(await this.getIndexPath()))) {
1608
+
1609
+ // If config already exists in a separate file, merge it into the options
1610
+ const config = await this.getConfig();
1611
+
1612
+ // Create initial options
1613
+ const opts = Object.assign({}, defaultInitialiseOptions, options);
1614
+ index = {
1615
+ name: opts.name,
1616
+ description: opts.description,
1617
+ options: Object.assign({}, opts.options, config || {}),
1618
+ columns: Object.fromEntries(opts.columns.map((columnName) => [columnName, []])),
1619
+ };
1620
+
1621
+ // Otherwise, if index already exists and we have specified new settings, re-write the index file
1622
+ } else if (Object.keys(options).length > 0) {
1623
+ index = await this.loadIndex();
1624
+ "name" in options && (index.name = options.name);
1625
+ "description" in options && (index.description = options.description);
1626
+ "options" in options && (index.options = Object.assign(index.options, options.options));
1627
+ "columns" in options &&
1628
+ (index.columns = Object.assign(
1629
+ index.columns,
1630
+ Object.fromEntries(
1631
+ options.columns.map((columnName) => [
1632
+ columnName,
1633
+ columnName in index.columns ? index.columns[columnName] : [],
1634
+ ])
1635
+ )
1636
+ ));
1637
+ }
1638
+ await this.saveIndex(index);
724
1639
  }
725
1640
 
726
1641
  /**
727
- * Quantize a burndown chart date to 1-hour resolution
728
- * @param {Date} date
729
- * @param {string} resolution One of 'days', 'hours', 'minutes', 'seconds'
730
- * @return {Date} The quantized dates
1642
+ * Check if a task file exists and is in the index, otherwise throw an error
1643
+ * @param {string} taskId The task id to check
731
1644
  */
732
- function normaliseDate(date, resolution = 'minutes') {
733
- const result = new Date(date.getTime());
734
- switch (resolution) {
735
- case 'days':
736
- result.setHours(0);
737
- case 'hours':
738
- result.setMinutes(0);
739
- case 'minutes':
740
- result.setSeconds(0);
741
- case 'seconds':
742
- result.setMilliseconds(0);
743
- default:
744
- break;
1645
+ async taskExists(taskId) {
1646
+ // Check if this folder has been initialised
1647
+ if (!(await this.initialised())) {
1648
+ throw new Error("Not initialised in this folder");
1649
+ }
1650
+
1651
+ // Check if the task file exists
1652
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1653
+ throw new Error(`No task file found with id "${taskId}"`);
1654
+ }
1655
+
1656
+ // Check that the task is indexed
1657
+ let index = await this.loadIndex();
1658
+ if (!taskInIndex(index, taskId)) {
1659
+ throw new Error(`No task with id "${taskId}" found in the index`);
745
1660
  }
746
- return result;
747
1661
  }
748
1662
 
749
1663
  /**
750
- * If a task's column is linked in the index to a custom field with type date, update the custom field's value
751
- * in the task data with the current date
752
- * @param {object} index
753
- * @param {object} taskData
754
- * @param {string} columnName
755
- * @return {object} The updated task data
1664
+ * Get the column that a task is in or throw an error if the task doesn't exist or isn't indexed
1665
+ * @param {string} taskId The task id to find
1666
+ * @return {Promise<string>} The name of the column the task is in
756
1667
  */
757
- function updateColumnLinkedCustomFields(index, taskData, columnName) {
758
- // Update built-in column-linked metadata properties first (started and completed dates)
759
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "completed", "once");
760
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "started", "once");
1668
+ async findTaskColumn(taskId) {
1669
+ // Check if this folder has been initialised
1670
+ if (!(await this.initialised())) {
1671
+ throw new Error("Not initialised in this folder");
1672
+ }
761
1673
 
762
- // Update column-linked custom fields
763
- if ("customFields" in index.options) {
764
- for (let customField of index.options.customFields) {
765
- if (customField.type === "date") {
766
- taskData = updateColumnLinkedCustomField(
767
- index,
768
- taskData,
769
- columnName,
770
- customField.name,
771
- customField.updateDate || "none"
772
- );
773
- }
774
- }
1674
+ // Check if the task file exists
1675
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1676
+ throw new Error(`No task file found with id "${taskId}"`);
775
1677
  }
776
- return taskData;
1678
+
1679
+ // Check that the task is indexed
1680
+ let index = await this.loadIndex();
1681
+ if (!taskInIndex(index, taskId)) {
1682
+ throw new Error(`No task with id "${taskId}" found in the index`);
1683
+ }
1684
+
1685
+ // Find which column the task is in
1686
+ return findTaskColumn(index, taskId);
777
1687
  }
778
1688
 
779
1689
  /**
780
- * If index options contains a list of columns linked to a custom field name and a task's column matches one
781
- * of the columns in this list, set the task's custom field value to the current date depending on criteria:
782
- * - if 'once', update the value only if it's not currently set
783
- * - if 'always', update the value regardless
784
- * - otherwise, don't update the value
785
- * @param {object} index
786
- * @param {object} taskData
787
- * @param {string} columnName
788
- * @param {string} fieldName
789
- * @param {string} [updateCriteria='none']
1690
+ * Create a task file and add the task to the index
1691
+ * @param {object} taskData The task object
1692
+ * @param {string} columnName The name of the column to add the task to
1693
+ * @return {Promise<string>} The id of the task that was created
790
1694
  */
791
- function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
792
- const columnList = `${fieldName}Columns`;
793
- if (columnList in index.options && index.options[columnList].indexOf(columnName) !== -1) {
794
- switch (updateCriteria) {
795
- case "always":
796
- taskData = setTaskMetadata(taskData, fieldName, new Date());
797
- break;
798
- case "once":
799
- if (!(fieldName in taskData.metadata && taskData.metadata[fieldName])) {
800
- taskData = setTaskMetadata(taskData, fieldName, new Date());
801
- }
802
- break;
803
- default:
804
- break;
805
- }
1695
+ async createTask(taskData, columnName) {
1696
+ // Check if this folder has been initialised
1697
+ if (!(await this.initialised())) {
1698
+ throw new Error("Not initialised in this folder");
806
1699
  }
807
- return taskData;
808
- }
809
1700
 
810
- return {
811
- /**
812
- * Get configuration settings from the config file if it exists, otherwise return null
813
- * @return {Object|null} Configuration settings or null if there is no separate config file
814
- */
815
- async getConfig() {
816
- if (configMemo === null) {
817
- let config = null;
818
- if (await exists(CONFIG_YAML)) {
819
- try {
820
- config = yaml.load(CONFIG_YAML);
821
- } catch (error) {
822
- throw new Error(`Couldn't load config file: ${error.message}`);
823
- }
824
- } else if (await exists(CONFIG_JSON)) {
825
- try {
826
- config = JSON.parse(await fs.promises.readFile(CONFIG_JSON, { encoding: "utf-8" }));
827
- } catch (error) {
828
- throw new Error(`Couldn't load config file: ${error.message}`);
829
- }
830
- }
831
- configMemo = config;
832
- }
833
- return configMemo;
834
- },
835
-
836
- /**
837
- * Clear cached config
838
- */
839
- clearConfigCache() {
840
- configMemo = null;
841
- },
842
-
843
- /**
844
- * Get the name of the folder where the index and tasks are stored
845
- * @return {string} The kanbn folder name
846
- */
847
- async getFolderName() {
848
- const config = await this.getConfig();
849
- if (config !== null && 'mainFolder' in config) {
850
- return config.mainFolder;
851
- }
852
- return DEFAULT_FOLDER_NAME;
853
- },
854
-
855
- /**
856
- * Get the index filename
857
- * @return {string} The index filename
858
- */
859
- async getIndexFileName() {
860
- const config = await this.getConfig();
861
- if (config !== null && 'indexFile' in config) {
862
- return config.indexFile;
863
- }
864
- return DEFAULT_INDEX_FILE_NAME;
865
- },
866
-
867
- /**
868
- * Get the name of the folder where tasks are stored
869
- * @return {string} The task folder name
870
- */
871
- async getTaskFolderName() {
872
- const config = await this.getConfig();
873
- if (config !== null && 'taskFolder' in config) {
874
- return config.taskFolder;
875
- }
876
- return DEFAULT_TASKS_FOLDER_NAME;
877
- },
878
-
879
- /**
880
- * Get the name of the archive folder
881
- * @return {string} The archive folder name
882
- */
883
- async getArchiveFolderName() {
884
- const config = await this.getConfig();
885
- if (config !== null && 'archiveFolder' in config) {
886
- return config.archiveFolder;
887
- }
888
- return DEFAULT_ARCHIVE_FOLDER_NAME;
889
- },
890
-
891
- /**
892
- * Get the kanbn folder location for the current working directory
893
- * @return {string} The kanbn folder path
894
- */
895
- async getMainFolder() {
896
- return path.join(ROOT, await this.getFolderName());
897
- },
898
-
899
- /**
900
- * Get the index path
901
- * @return {string} The kanbn index path
902
- */
903
- async getIndexPath() {
904
- return path.join(await this.getMainFolder(), await this.getIndexFileName());
905
- },
906
-
907
- /**
908
- * Get the task folder path
909
- * @return {string} The kanbn task folder path
910
- */
911
- async getTaskFolderPath() {
912
- return path.join(await this.getMainFolder(), await this.getTaskFolderName());
913
- },
914
-
915
- /**
916
- * Get the archive folder path
917
- * @return {string} The kanbn archive folder path
918
- */
919
- async getArchiveFolderPath() {
920
- return path.join(await this.getMainFolder(), await this.getArchiveFolderName());
921
- },
922
-
923
- /**
924
- * Get the index as an object
925
- * @return {object} The index
926
- */
927
- async getIndex() {
928
- // Check if this folder has been initialised
929
- if (!(await this.initialised())) {
930
- throw new Error("Not initialised in this folder");
931
- }
1701
+ // Make sure the task has a name
1702
+ if (!taskData.name) {
1703
+ throw new Error("Task name cannot be blank");
1704
+ }
932
1705
 
933
- return this.loadIndex();
934
- },
935
-
936
- /**
937
- * Get a task as an object
938
- * @param {string} taskId The task id to get
939
- * @return {object} The task
940
- */
941
- async getTask(taskId) {
942
- this.taskExists(taskId);
943
- return this.loadTask(taskId);
944
- },
945
-
946
- /**
947
- * Add additional index-based information to a task
948
- * @param {object} index The index object
949
- * @param {object} task The task object
950
- * @return {object} The hydrated task
951
- */
952
- hydrateTask(index, task) {
953
- const completed = taskCompleted(index, task);
954
- task.column = findTaskColumn(index, task.id);
955
- task.workload = taskWorkload(index, task);
956
-
957
- // Add progress information
958
- task.progress = taskProgress(index, task);
959
- task.remainingWorkload = Math.ceil(task.workload * (1 - task.progress));
960
-
961
- // Add due information
962
- if ("due" in task.metadata) {
963
- const dueData = {};
964
-
965
- // A task is overdue if it's due date is in the past and the task is not in a completed column
966
- // or doesn't have a completed dates
967
- const completedDate = "completed" in task.metadata ? task.metadata.completed : null;
968
-
969
- // Get task due delta - this is the difference between now and the due date, or if the task is completed
970
- // this is the difference between the completed and due dates
971
- let delta;
972
- if (completedDate !== null) {
973
- delta = completedDate - task.metadata.due;
974
- } else {
975
- delta = new Date() - task.metadata.due;
976
- }
1706
+ // Make sure a task doesn't already exist with the same name
1707
+ const taskId = utility.getTaskId(taskData.name);
1708
+ const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
1709
+ if (await exists(taskPath)) {
1710
+ throw new Error(`A task with id "${taskId}" already exists`);
1711
+ }
977
1712
 
978
- // Populate due information
979
- dueData.completed = completed;
980
- dueData.completedDate = completedDate;
981
- dueData.dueDate = task.metadata.due;
982
- dueData.overdue = !completed && delta > 0;
983
- dueData.dueDelta = delta;
984
-
985
- // Prepare a due message for the task
986
- let dueMessage = "";
987
- if (completed) {
988
- dueMessage += "Completed ";
989
- }
990
- dueMessage += `${humanizeDuration(delta, {
991
- largest: 3,
992
- round: true,
993
- })} ${delta > 0 ? "overdue" : "remaining"}`;
994
- dueData.dueMessage = dueMessage;
995
- task.dueData = dueData;
996
- }
997
- return task;
998
- },
999
-
1000
- /**
1001
- * Return a filtered and sorted list of tasks
1002
- * @param {object} index The index object
1003
- * @param {object[]} tasks A list of task objects
1004
- * @param {object} filters A list of task filters
1005
- * @param {object[]} sorters A list of task sorters
1006
- * @return {object[]} A filtered and sorted list of tasks
1007
- */
1008
- filterAndSortTasks(index, tasks, filters, sorters) {
1009
- return sortTasks(filterTasks(index, tasks, filters), sorters);
1010
- },
1011
-
1012
- /**
1013
- * Overwrite the index file with the specified data
1014
- * @param {object} indexData Index data to save
1015
- */
1016
- async saveIndex(indexData) {
1017
- // Apply column sorting if any sorters are defined in options
1018
- if ("columnSorting" in indexData.options && Object.keys(indexData.options.columnSorting).length) {
1019
- for (let columnName in indexData.options.columnSorting) {
1020
- indexData = sortColumnInIndex(
1021
- indexData,
1022
- await this.loadAllTrackedTasks(indexData, columnName),
1023
- columnName,
1024
- indexData.options.columnSorting[columnName]
1025
- );
1026
- }
1027
- }
1713
+ // Get index and make sure the column exists
1714
+ let index = await this.loadIndex();
1715
+ if (!(columnName in index.columns)) {
1716
+ throw new Error(`Column "${columnName}" doesn't exist`);
1717
+ }
1028
1718
 
1029
- // If there is a separate config file, save options to this file
1030
- let ignoreOptions = false;
1031
- if (await configExists()) {
1032
- await saveConfig(indexData.options);
1033
- ignoreOptions = true;
1034
- }
1719
+ // Check that a task with the same id isn't already indexed
1720
+ if (taskInIndex(index, taskId)) {
1721
+ throw new Error(`A task with id "${taskId}" is already in the index`);
1722
+ }
1035
1723
 
1036
- // Save index
1037
- await fs.promises.writeFile(await this.getIndexPath(), parseIndex.json2md(indexData, ignoreOptions));
1038
- },
1724
+ // Set the created date
1725
+ taskData = setTaskMetadata(taskData, "created", new Date());
1039
1726
 
1040
- /**
1041
- * Load the index file and parse it to an object
1042
- * @return {object} The index object
1043
- */
1044
- async loadIndex() {
1045
- let indexData = "";
1046
- try {
1047
- indexData = await fs.promises.readFile(await this.getIndexPath(), { encoding: "utf-8" });
1048
- } catch (error) {
1049
- throw new Error(`Couldn't access index file: ${error.message}`);
1050
- }
1051
- const index = parseIndex.md2json(indexData);
1727
+ // Add initial history event
1728
+ taskData = appendTaskHistory(taskData, {
1729
+ type: 'created',
1730
+ column: columnName,
1731
+ fromProgress: 0,
1732
+ toProgress: getTaskMetadata(taskData, 'progress') || 0
1733
+ });
1052
1734
 
1053
- // If configuration settings exist in a separate config file, merge them with index options
1054
- const config = await this.getConfig();
1055
- if (config !== null) {
1056
- index.options = { ...index.options, ...config };
1057
- }
1058
- return index;
1059
- },
1060
-
1061
- /**
1062
- * Overwrite a task file with the specified data
1063
- * @param {string} path The task path
1064
- * @param {object} taskData The task data
1065
- */
1066
- async saveTask(path, taskData) {
1067
- await fs.promises.writeFile(path, parseTask.json2md(taskData));
1068
- },
1069
-
1070
- /**
1071
- * Load a task file and parse it to an object
1072
- * @param {string} taskId The task id
1073
- * @return {object} The task object
1074
- */
1075
- async loadTask(taskId) {
1076
- const taskPath = path.join(await this.getTaskFolderPath(), addFileExtension(taskId));
1077
- let taskData = "";
1078
- try {
1079
- taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
1080
- } catch (error) {
1081
- throw new Error(`Couldn't access task file: ${error.message}`);
1082
- }
1083
- return parseTask.md2json(taskData);
1084
- },
1085
-
1086
- /**
1087
- * Load all tracked tasks and return an array of task objects
1088
- * @param {object} index The index object
1089
- * @param {?string} [columnName=null] The optional column name to filter tasks by
1090
- * @return {object[]} All tracked tasks
1091
- */
1092
- async loadAllTrackedTasks(index, columnName = null) {
1093
- const result = [];
1094
- const trackedTasks = getTrackedTaskIds(index, columnName);
1095
- for (let taskId of trackedTasks) {
1096
- result.push(await this.loadTask(taskId));
1097
- }
1098
- return result;
1099
- },
1100
-
1101
- /**
1102
- * Load a task file from the archive and parse it to an object
1103
- * @param {string} taskId The task id
1104
- * @return {object} The task object
1105
- */
1106
- async loadArchivedTask(taskId) {
1107
- const taskPath = path.join(await this.getArchiveFolderPath(), addFileExtension(taskId));
1108
- let taskData = "";
1109
- try {
1110
- taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
1111
- } catch (error) {
1112
- throw new Error(`Couldn't access archived task file: ${error.message}`);
1113
- }
1114
- return parseTask.md2json(taskData);
1115
- },
1116
-
1117
- /**
1118
- * Get the date format defined in the index, or the default date format
1119
- * @param {object} index The index object
1120
- * @return {string} The date format
1121
- */
1122
- getDateFormat(index) {
1123
- return "dateFormat" in index.options ? index.options.dateFormat : DEFAULT_DATE_FORMAT;
1124
- },
1125
-
1126
- /**
1127
- * Get the task template for displaying tasks on the kanbn board from the index, or the default task template
1128
- * @param {object} index The index object
1129
- * @return {string} The task template
1130
- */
1131
- getTaskTemplate(index) {
1132
- return "taskTemplate" in index.options ? index.options.taskTemplate : DEFAULT_TASK_TEMPLATE;
1133
- },
1134
-
1135
- /**
1136
- * Check if the current working directory has been initialised
1137
- * @return {boolean} True if the current working directory has been initialised, otherwise false
1138
- */
1139
- async initialised() {
1140
- return await exists(await this.getIndexPath());
1141
- },
1142
-
1143
- /**
1144
- * Initialise a kanbn board in the current working directory
1145
- * @param {object} [options={}] Initial columns and other config options
1146
- */
1147
- async initialise(options = {}) {
1148
- // Check if a main folder is defined in an existing config file
1149
- const mainFolder = await this.getMainFolder();
1150
-
1151
- // Create main folder if it doesn't already exist
1152
- if (!(await exists(mainFolder))) {
1153
- await fs.promises.mkdir(mainFolder, { recursive: true });
1154
- }
1735
+ // Update task metadata dates
1736
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1737
+ await this.saveTask(taskPath, taskData);
1155
1738
 
1156
- // Create tasks folder if it doesn't already exist
1157
- const taskFolder = await this.getTaskFolderPath();
1158
- if (!(await exists(taskFolder))) {
1159
- await fs.promises.mkdir(taskFolder, { recursive: true });
1160
- }
1739
+ // Add the task to the index
1740
+ index = addTaskToIndex(index, taskId, columnName);
1741
+ await this.saveIndex(index);
1742
+ return taskId;
1743
+ }
1161
1744
 
1162
- // Create index if one doesn't already exist
1163
- let index;
1164
- if (!(await exists(await this.getIndexPath()))) {
1745
+ /**
1746
+ * Add an untracked task to the specified column in the index
1747
+ * @param {string} taskId The untracked task id
1748
+ * @param {string} columnName The column to add the task to
1749
+ * @return {Promise<string>} The id of the task that was added
1750
+ */
1751
+ async addUntrackedTaskToIndex(taskId, columnName) {
1752
+ // Check if this folder has been initialised
1753
+ if (!(await this.initialised())) {
1754
+ throw new Error("Not initialised in this folder");
1755
+ }
1756
+ taskId = removeFileExtension(taskId);
1165
1757
 
1166
- // If config already exists in a separate file, merge it into the options
1167
- const config = await this.getConfig();
1758
+ // Make sure the task file exists
1759
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1760
+ throw new Error(`No task file found with id "${taskId}"`);
1761
+ }
1168
1762
 
1169
- // Create initial options
1170
- const opts = Object.assign({}, defaultInitialiseOptions, options);
1171
- index = {
1172
- name: opts.name,
1173
- description: opts.description,
1174
- options: Object.assign({}, opts.options, config || {}),
1175
- columns: Object.fromEntries(opts.columns.map((columnName) => [columnName, []])),
1176
- };
1763
+ // Get index and make sure the column exists
1764
+ let index = await this.loadIndex();
1765
+ if (!(columnName in index.columns)) {
1766
+ throw new Error(`Column "${columnName}" doesn't exist`);
1767
+ }
1177
1768
 
1178
- // Otherwise, if index already exists and we have specified new settings, re-write the index file
1179
- } else if (Object.keys(options).length > 0) {
1180
- index = await this.loadIndex();
1181
- "name" in options && (index.name = options.name);
1182
- "description" in options && (index.description = options.description);
1183
- "options" in options && (index.options = Object.assign(index.options, options.options));
1184
- "columns" in options &&
1185
- (index.columns = Object.assign(
1186
- index.columns,
1187
- Object.fromEntries(
1188
- options.columns.map((columnName) => [
1189
- columnName,
1190
- columnName in index.columns ? index.columns[columnName] : [],
1191
- ])
1192
- )
1193
- ));
1194
- }
1195
- await this.saveIndex(index);
1196
- },
1197
-
1198
- /**
1199
- * Check if a task file exists and is in the index, otherwise throw an error
1200
- * @param {string} taskId The task id to check
1201
- */
1202
- async taskExists(taskId) {
1203
- // Check if this folder has been initialised
1204
- if (!(await this.initialised())) {
1205
- throw new Error("Not initialised in this folder");
1206
- }
1769
+ // Check that the task isn't already indexed
1770
+ if (taskInIndex(index, taskId)) {
1771
+ throw new Error(`Task "${taskId}" is already in the index`);
1772
+ }
1207
1773
 
1208
- // Check if the task file exists
1209
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1210
- throw new Error(`No task file found with id "${taskId}"`);
1211
- }
1774
+ // Load task data
1775
+ let taskData = await this.loadTask(taskId);
1776
+ const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
1212
1777
 
1213
- // Check that the task is indexed
1214
- let index = await this.loadIndex();
1215
- if (!taskInIndex(index, taskId)) {
1216
- throw new Error(`No task with id "${taskId}" found in the index`);
1217
- }
1218
- },
1219
-
1220
- /**
1221
- * Get the column that a task is in or throw an error if the task doesn't exist or isn't indexed
1222
- * @param {string} taskId The task id to find
1223
- * @return {string} The name of the column the task is in
1224
- */
1225
- async findTaskColumn(taskId) {
1226
- // Check if this folder has been initialised
1227
- if (!(await this.initialised())) {
1228
- throw new Error("Not initialised in this folder");
1229
- }
1778
+ // Update task metadata dates
1779
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1780
+ await this.saveTask(taskPath, taskData);
1230
1781
 
1231
- // Check if the task file exists
1232
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1233
- throw new Error(`No task file found with id "${taskId}"`);
1234
- }
1782
+ // Add the task to the column and save the index
1783
+ index = addTaskToIndex(index, taskId, columnName);
1784
+ await this.saveIndex(index);
1785
+ return taskId;
1786
+ }
1235
1787
 
1236
- // Check that the task is indexed
1237
- let index = await this.loadIndex();
1238
- if (!taskInIndex(index, taskId)) {
1239
- throw new Error(`No task with id "${taskId}" found in the index`);
1240
- }
1788
+ /**
1789
+ * Get a list of tracked tasks (i.e. tasks that are listed in the index)
1790
+ * @param {?string} [columnName=null] The optional column name to filter tasks by
1791
+ * @return {Promise<Set>} A set of task ids
1792
+ */
1793
+ async findTrackedTasks(columnName = null) {
1794
+ // Check if this folder has been initialised
1795
+ if (!(await this.initialised())) {
1796
+ throw new Error("Not initialised in this folder");
1797
+ }
1241
1798
 
1242
- // Find which column the task is in
1243
- return findTaskColumn(index, taskId);
1244
- },
1245
-
1246
- /**
1247
- * Create a task file and add the task to the index
1248
- * @param {object} taskData The task object
1249
- * @param {string} columnName The name of the column to add the task to
1250
- * @return {string} The id of the task that was created
1251
- */
1252
- async createTask(taskData, columnName) {
1253
- // Check if this folder has been initialised
1254
- if (!(await this.initialised())) {
1255
- throw new Error("Not initialised in this folder");
1256
- }
1799
+ // Get all tasks currently in index
1800
+ const index = await this.loadIndex();
1801
+ return getTrackedTaskIds(index, columnName);
1802
+ }
1257
1803
 
1258
- // Make sure the task has a name
1259
- if (!taskData.name) {
1260
- throw new Error("Task name cannot be blank");
1261
- }
1804
+ /**
1805
+ * Get a list of untracked tasks (i.e. markdown files in the tasks folder that aren't listed in the index)
1806
+ * @return {Promise<Set>} A set of untracked task ids
1807
+ */
1808
+ async findUntrackedTasks() {
1809
+ // Check if this folder has been initialised
1810
+ if (!(await this.initialised())) {
1811
+ throw new Error("Not initialised in this folder");
1812
+ }
1262
1813
 
1263
- // Make sure a task doesn't already exist with the same name
1264
- const taskId = utility.getTaskId(taskData.name);
1265
- const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
1266
- if (await exists(taskPath)) {
1267
- throw new Error(`A task with id "${taskId}" already exists`);
1268
- }
1814
+ // Get all tasks currently in index
1815
+ const index = await this.loadIndex();
1816
+ const trackedTasks = getTrackedTaskIds(index);
1269
1817
 
1270
- // Get index and make sure the column exists
1271
- let index = await this.loadIndex();
1272
- if (!(columnName in index.columns)) {
1273
- throw new Error(`Column "${columnName}" doesn't exist`);
1274
- }
1818
+ // Get all tasks in the tasks folder
1819
+ const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
1820
+ const untrackedTasks = new Set(files.map((task) => path.parse(task).name));
1275
1821
 
1276
- // Check that a task with the same id isn't already indexed
1277
- if (taskInIndex(index, taskId)) {
1278
- throw new Error(`A task with id "${taskId}" is already in the index`);
1279
- }
1822
+ // Return the set difference
1823
+ return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
1824
+ }
1280
1825
 
1281
- // Set the created date
1282
- taskData = setTaskMetadata(taskData, "created", new Date());
1826
+ /**
1827
+ * Update an existing task
1828
+ * @param {string} taskId The id of the task to update
1829
+ * @param {object} taskData The new task data
1830
+ * @param {?string} [columnName=null] The column name to move this task to, or null if not moving this task
1831
+ * @return {Promise<string>} The id of the task that was updated
1832
+ */
1833
+ async updateTask(taskId, taskData, columnName = null) {
1834
+ // Check if this folder has been initialised
1835
+ if (!(await this.initialised())) {
1836
+ throw new Error("Not initialised in this folder");
1837
+ }
1838
+ taskId = removeFileExtension(taskId);
1283
1839
 
1284
- // Update task metadata dates
1285
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1286
- await this.saveTask(taskPath, taskData);
1840
+ // Make sure the task file exists
1841
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1842
+ throw new Error(`No task file found with id "${taskId}"`);
1843
+ }
1287
1844
 
1288
- // Add the task to the index
1289
- index = addTaskToIndex(index, taskId, columnName);
1290
- await this.saveIndex(index);
1291
- return taskId;
1292
- },
1293
-
1294
- /**
1295
- * Add an untracked task to the specified column in the index
1296
- * @param {string} taskId The untracked task id
1297
- * @param {string} columnName The column to add the task to
1298
- * @return {string} The id of the task that was added
1299
- */
1300
- async addUntrackedTaskToIndex(taskId, columnName) {
1301
- // Check if this folder has been initialised
1302
- if (!(await this.initialised())) {
1303
- throw new Error("Not initialised in this folder");
1304
- }
1305
- taskId = removeFileExtension(taskId);
1845
+ // Get index and make sure the task is indexed
1846
+ let index = await this.loadIndex();
1847
+ if (!taskInIndex(index, taskId)) {
1848
+ throw new Error(`Task "${taskId}" is not in the index`);
1849
+ }
1306
1850
 
1307
- // Make sure the task file exists
1308
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1309
- throw new Error(`No task file found with id "${taskId}"`);
1310
- }
1851
+ // Make sure the updated task data has a name
1852
+ if (!taskData.name) {
1853
+ throw new Error("Task name cannot be blank");
1854
+ }
1311
1855
 
1312
- // Get index and make sure the column exists
1313
- let index = await this.loadIndex();
1314
- if (!(columnName in index.columns)) {
1315
- throw new Error(`Column "${columnName}" doesn't exist`);
1316
- }
1856
+ // Rename the task if we're updating the name
1857
+ const originalTaskData = await this.loadTask(taskId);
1858
+ if (originalTaskData.name !== taskData.name) {
1859
+ taskId = await this.renameTask(taskId, taskData.name);
1317
1860
 
1318
- // Check that the task isn't already indexed
1319
- if (taskInIndex(index, taskId)) {
1320
- throw new Error(`Task "${taskId}" is already in the index`);
1321
- }
1861
+ // Re-load the index
1862
+ index = await this.loadIndex();
1863
+ }
1322
1864
 
1323
- // Load task data
1324
- let taskData = await this.loadTask(taskId);
1325
- const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
1865
+ // Get index and make sure the column exists
1866
+ if (columnName && !(columnName in index.columns)) {
1867
+ throw new Error(`Column "${columnName}" doesn't exist`);
1868
+ }
1326
1869
 
1327
- // Update task metadata dates
1328
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1329
- await this.saveTask(taskPath, taskData);
1870
+ // Set the updated date
1871
+ taskData = setTaskMetadata(taskData, "updated", new Date());
1872
+
1873
+ // Add history for progress changes only
1874
+ const originalProgress = getTaskMetadata(originalTaskData, 'progress') || 0;
1875
+ const updatedProgress = getTaskMetadata(taskData, 'progress') || 0;
1876
+ if (originalProgress !== updatedProgress) {
1877
+ taskData = appendTaskHistory(taskData, {
1878
+ type: 'progress',
1879
+ fromProgress: originalProgress,
1880
+ toProgress: updatedProgress
1881
+ });
1882
+ }
1330
1883
 
1331
- // Add the task to the column and save the index
1332
- index = addTaskToIndex(index, taskId, columnName);
1333
- await this.saveIndex(index);
1334
- return taskId;
1335
- },
1336
-
1337
- /**
1338
- * Get a list of tracked tasks (i.e. tasks that are listed in the index)
1339
- * @param {?string} [columnName=null] The optional column name to filter tasks by
1340
- * @return {Set} A set of task ids
1341
- */
1342
- async findTrackedTasks(columnName = null) {
1343
- // Check if this folder has been initialised
1344
- if (!(await this.initialised())) {
1345
- throw new Error("Not initialised in this folder");
1346
- }
1884
+ // Save task
1885
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1347
1886
 
1348
- // Get all tasks currently in index
1349
- const index = await this.loadIndex();
1350
- return getTrackedTaskIds(index, columnName);
1351
- },
1352
-
1353
- /**
1354
- * Get a list of untracked tasks (i.e. markdown files in the tasks folder that aren't listed in the index)
1355
- * @return {Set} A set of untracked task ids
1356
- */
1357
- async findUntrackedTasks() {
1358
- // Check if this folder has been initialised
1359
- if (!(await this.initialised())) {
1360
- throw new Error("Not initialised in this folder");
1361
- }
1887
+ // Move the task if we're updating the column
1888
+ if (columnName) {
1889
+ await this.moveTask(taskId, columnName);
1362
1890
 
1363
- // Get all tasks currently in index
1364
- const index = await this.loadIndex();
1365
- const trackedTasks = getTrackedTaskIds(index);
1366
-
1367
- // Get all tasks in the tasks folder
1368
- const files = await glob(`${await this.getTaskFolderPath()}/*.md`);
1369
- const untrackedTasks = new Set(files.map((task) => path.parse(task).name));
1370
-
1371
- // Return the set difference
1372
- return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
1373
- },
1374
-
1375
- /**
1376
- * Update an existing task
1377
- * @param {string} taskId The id of the task to update
1378
- * @param {object} taskData The new task data
1379
- * @param {?string} [columnName=null] The column name to move this task to, or null if not moving this task
1380
- * @return {string} The id of the task that was updated
1381
- */
1382
- async updateTask(taskId, taskData, columnName = null) {
1383
- // Check if this folder has been initialised
1384
- if (!(await this.initialised())) {
1385
- throw new Error("Not initialised in this folder");
1386
- }
1387
- taskId = removeFileExtension(taskId);
1891
+ // Otherwise save the index
1892
+ } else {
1893
+ await this.saveIndex(index);
1894
+ }
1895
+ return taskId;
1896
+ }
1388
1897
 
1389
- // Make sure the task file exists
1390
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1391
- throw new Error(`No task file found with id "${taskId}"`);
1392
- }
1898
+ /**
1899
+ * Change a task name, rename the task file and update the task id in the index
1900
+ * @param {string} taskId The id of the task to rename
1901
+ * @param {string} newTaskName The new task name
1902
+ * @return {Promise<string>} The new id of the task that was renamed
1903
+ */
1904
+ async renameTask(taskId, newTaskName) {
1905
+ // Check if this folder has been initialised
1906
+ if (!(await this.initialised())) {
1907
+ throw new Error("Not initialised in this folder");
1908
+ }
1909
+ taskId = removeFileExtension(taskId);
1393
1910
 
1394
- // Get index and make sure the task is indexed
1395
- let index = await this.loadIndex();
1396
- if (!taskInIndex(index, taskId)) {
1397
- throw new Error(`Task "${taskId}" is not in the index`);
1398
- }
1911
+ // Make sure the task file exists
1912
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1913
+ throw new Error(`No task file found with id "${taskId}"`);
1914
+ }
1399
1915
 
1400
- // Make sure the updated task data has a name
1401
- if (!taskData.name) {
1402
- throw new Error("Task name cannot be blank");
1403
- }
1916
+ // Get index and make sure the task is indexed
1917
+ let index = await this.loadIndex();
1918
+ if (!taskInIndex(index, taskId)) {
1919
+ throw new Error(`Task "${taskId}" is not in the index`);
1920
+ }
1404
1921
 
1405
- // Rename the task if we're updating the name
1406
- const originalTaskData = await this.loadTask(taskId);
1407
- if (originalTaskData.name !== taskData.name) {
1408
- taskId = await this.renameTask(taskId, taskData.name);
1922
+ // Make sure there isn't already a task with the new task id
1923
+ const newTaskId = utility.getTaskId(newTaskName);
1924
+ const newTaskPath = getTaskPath(await this.getTaskFolderPath(), newTaskId);
1925
+ if (await exists(newTaskPath)) {
1926
+ throw new Error(`A task with id "${newTaskId}" already exists`);
1927
+ }
1409
1928
 
1410
- // Re-load the index
1411
- index = await this.loadIndex();
1412
- }
1929
+ // Check that a task with the new id isn't already indexed
1930
+ if (taskInIndex(index, newTaskId)) {
1931
+ throw new Error(`A task with id "${newTaskId}" is already in the index`);
1932
+ }
1413
1933
 
1414
- // Get index and make sure the column exists
1415
- if (columnName && !(columnName in index.columns)) {
1416
- throw new Error(`Column "${columnName}" doesn't exist`);
1417
- }
1934
+ // Update the task name and updated date
1935
+ let taskData = await this.loadTask(taskId);
1936
+ taskData.name = newTaskName;
1937
+ taskData = setTaskMetadata(taskData, "updated", new Date());
1938
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1418
1939
 
1419
- // Set the updated date
1420
- taskData = setTaskMetadata(taskData, "updated", new Date());
1940
+ // Rename the task file
1941
+ await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
1421
1942
 
1422
- // Save task
1423
- await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1943
+ // Update the task id in the index
1944
+ index = renameTaskInIndex(index, taskId, newTaskId);
1945
+ await this.saveIndex(index);
1946
+ return newTaskId;
1947
+ }
1424
1948
 
1425
- // Move the task if we're updating the column
1426
- if (columnName) {
1427
- await this.moveTask(taskId, columnName);
1949
+ /**
1950
+ * Move a task from one column to another column
1951
+ * @param {string} taskId The task id to move
1952
+ * @param {string} columnName The name of the column that the task will be moved to
1953
+ * @param {?number} [position=null] The position to move the task to within the target column
1954
+ * @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
1955
+ * @return {Promise<string>} The id of the task that was moved
1956
+ */
1957
+ async moveTask(taskId, columnName, position = null, relative = false) {
1958
+ // Check if this folder has been initialised
1959
+ if (!(await this.initialised())) {
1960
+ throw new Error("Not initialised in this folder");
1961
+ }
1962
+ taskId = removeFileExtension(taskId);
1428
1963
 
1429
- // Otherwise save the index
1430
- } else {
1431
- await this.saveIndex(index);
1432
- }
1433
- return taskId;
1434
- },
1435
-
1436
- /**
1437
- * Change a task name, rename the task file and update the task id in the index
1438
- * @param {string} taskId The id of the task to rename
1439
- * @param {string} newTaskName The new task name
1440
- * @return {string} The new id of the task that was renamed
1441
- */
1442
- async renameTask(taskId, newTaskName) {
1443
- // Check if this folder has been initialised
1444
- if (!(await this.initialised())) {
1445
- throw new Error("Not initialised in this folder");
1446
- }
1447
- taskId = removeFileExtension(taskId);
1964
+ // Make sure the task file exists
1965
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1966
+ throw new Error(`No task file found with id "${taskId}"`);
1967
+ }
1448
1968
 
1449
- // Make sure the task file exists
1450
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1451
- throw new Error(`No task file found with id "${taskId}"`);
1452
- }
1969
+ // Get index and make sure the task is indexed
1970
+ let index = await this.loadIndex();
1971
+ if (!taskInIndex(index, taskId)) {
1972
+ throw new Error(`Task "${taskId}" is not in the index`);
1973
+ }
1453
1974
 
1454
- // Get index and make sure the task is indexed
1455
- let index = await this.loadIndex();
1456
- if (!taskInIndex(index, taskId)) {
1457
- throw new Error(`Task "${taskId}" is not in the index`);
1458
- }
1975
+ // Make sure the target column exists
1976
+ if (!(columnName in index.columns)) {
1977
+ throw new Error(`Column "${columnName}" doesn't exist`);
1978
+ }
1459
1979
 
1460
- // Make sure there isn't already a task with the new task id
1461
- const newTaskId = utility.getTaskId(newTaskName);
1462
- const newTaskPath = getTaskPath(await this.getTaskFolderPath(), newTaskId);
1463
- if (await exists(newTaskPath)) {
1464
- throw new Error(`A task with id "${newTaskId}" already exists`);
1465
- }
1980
+ // Find current column before mutating data
1981
+ const currentColumnName = findTaskColumn(index, taskId);
1466
1982
 
1467
- // Check that a task with the new id isn't already indexed
1468
- if (taskInIndex(index, newTaskId)) {
1469
- throw new Error(`A task with id "${newTaskId}" is already in the index`);
1470
- }
1983
+ // Update the task's updated date
1984
+ let taskData = await this.loadTask(taskId);
1985
+ taskData = setTaskMetadata(taskData, "updated", new Date());
1471
1986
 
1472
- // Update the task name and updated date
1473
- let taskData = await this.loadTask(taskId);
1474
- taskData.name = newTaskName;
1475
- taskData = setTaskMetadata(taskData, "updated", new Date());
1476
- await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1987
+ // Add history only when a task changes columns
1988
+ if (currentColumnName !== columnName) {
1989
+ taskData = appendTaskHistory(taskData, {
1990
+ type: 'moved',
1991
+ fromColumn: currentColumnName,
1992
+ toColumn: columnName
1993
+ });
1994
+ }
1477
1995
 
1478
- // Rename the task file
1479
- await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
1996
+ // Update task metadata dates
1997
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1998
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1480
1999
 
1481
- // Update the task id in the index
1482
- index = renameTaskInIndex(index, taskId, newTaskId);
1483
- await this.saveIndex(index);
1484
- return newTaskId;
1485
- },
1486
-
1487
- /**
1488
- * Move a task from one column to another column
1489
- * @param {string} taskId The task id to move
1490
- * @param {string} columnName The name of the column that the task will be moved to
1491
- * @param {?number} [position=null] The position to move the task to within the target column
1492
- * @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
1493
- * @return {string} The id of the task that was moved
1494
- */
1495
- async moveTask(taskId, columnName, position = null, relative = false) {
1496
- // Check if this folder has been initialised
1497
- if (!(await this.initialised())) {
1498
- throw new Error("Not initialised in this folder");
2000
+ // If we're moving the task to a new position, calculate the absolute position
2001
+ const currentPosition = index.columns[currentColumnName].indexOf(taskId);
2002
+ if (position) {
2003
+ if (relative) {
2004
+ position = currentPosition + position;
1499
2005
  }
1500
- taskId = removeFileExtension(taskId);
2006
+ position = Math.max(Math.min(position, index.columns[currentColumnName].length), 0);
2007
+ }
1501
2008
 
1502
- // Make sure the task file exists
1503
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1504
- throw new Error(`No task file found with id "${taskId}"`);
1505
- }
2009
+ // Remove the task from its current column and add it to the new column
2010
+ index = removeTaskFromIndex(index, taskId);
2011
+ index = addTaskToIndex(index, taskId, columnName, position);
2012
+ await this.saveIndex(index);
2013
+ return taskId;
2014
+ }
1506
2015
 
1507
- // Get index and make sure the task is indexed
1508
- let index = await this.loadIndex();
1509
- if (!taskInIndex(index, taskId)) {
1510
- throw new Error(`Task "${taskId}" is not in the index`);
1511
- }
2016
+ /**
2017
+ * Remove a task from the index and optionally delete the task file as well
2018
+ * @param {string} taskId The id of the task to remove
2019
+ * @param {boolean} [removeFile=false] True if the task file should be removed
2020
+ * @return {Promise<string>} The id of the task that was deleted
2021
+ */
2022
+ async deleteTask(taskId, removeFile = false) {
2023
+ // Check if this folder has been initialised
2024
+ if (!(await this.initialised())) {
2025
+ throw new Error("Not initialised in this folder");
2026
+ }
2027
+ taskId = removeFileExtension(taskId);
1512
2028
 
1513
- // Make sure the target column exists
1514
- if (!(columnName in index.columns)) {
1515
- throw new Error(`Column "${columnName}" doesn't exist`);
1516
- }
1517
-
1518
- // Update the task's updated date
1519
- let taskData = await this.loadTask(taskId);
1520
- taskData = setTaskMetadata(taskData, "updated", new Date());
1521
-
1522
- // Update task metadata dates
1523
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
1524
- await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
1525
-
1526
- // If we're moving the task to a new position, calculate the absolute position
1527
- const currentColumnName = findTaskColumn(index, taskId);
1528
- const currentPosition = index.columns[currentColumnName].indexOf(taskId);
1529
- if (position) {
1530
- if (relative) {
1531
- position = currentPosition + position;
1532
- }
1533
- position = Math.max(Math.min(position, index.columns[currentColumnName].length), 0);
1534
- }
2029
+ // Get index and make sure the task is indexed
2030
+ let index = await this.loadIndex();
2031
+ if (!taskInIndex(index, taskId)) {
2032
+ throw new Error(`Task "${taskId}" is not in the index`);
2033
+ }
1535
2034
 
1536
- // Remove the task from its current column and add it to the new column
1537
- index = removeTaskFromIndex(index, taskId);
1538
- index = addTaskToIndex(index, taskId, columnName, position);
1539
- await this.saveIndex(index);
1540
- return taskId;
1541
- },
1542
-
1543
- /**
1544
- * Remove a task from the index and optionally delete the task file as well
1545
- * @param {string} taskId The id of the task to remove
1546
- * @param {boolean} [removeFile=false] True if the task file should be removed
1547
- * @return {string} The id of the task that was deleted
1548
- */
1549
- async deleteTask(taskId, removeFile = false) {
1550
- // Check if this folder has been initialised
1551
- if (!(await this.initialised())) {
1552
- throw new Error("Not initialised in this folder");
1553
- }
1554
- taskId = removeFileExtension(taskId);
2035
+ // Remove the task from whichever column it's in
2036
+ index = removeTaskFromIndex(index, taskId);
1555
2037
 
1556
- // Get index and make sure the task is indexed
1557
- let index = await this.loadIndex();
1558
- if (!taskInIndex(index, taskId)) {
1559
- throw new Error(`Task "${taskId}" is not in the index`);
1560
- }
2038
+ // Optionally remove the task file as well
2039
+ if (removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2040
+ await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
2041
+ }
2042
+ await this.saveIndex(index);
2043
+ return taskId;
2044
+ }
1561
2045
 
1562
- // Remove the task from whichever column it's in
1563
- index = removeTaskFromIndex(index, taskId);
2046
+ /**
2047
+ * Search for indexed tasks
2048
+ * @param {object} [filters={}] The filters to apply
2049
+ * @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
2050
+ * @return {Promise<object[]>} A list of tasks that match the filters
2051
+ */
2052
+ async search(filters = {}, quiet = false) {
2053
+ // Check if this folder has been initialised
2054
+ if (!(await this.initialised())) {
2055
+ throw new Error("Not initialised in this folder");
2056
+ }
1564
2057
 
1565
- // Optionally remove the task file as well
1566
- if (removeFile && (await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
1567
- await fs.promises.unlink(getTaskPath(await this.getTaskFolderPath(), taskId));
1568
- }
1569
- await this.saveIndex(index);
1570
- return taskId;
1571
- },
1572
-
1573
- /**
1574
- * Search for indexed tasks
1575
- * @param {object} [filters={}] The filters to apply
1576
- * @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
1577
- * @return {object[]} A list of tasks that match the filters
1578
- */
1579
- async search(filters = {}, quiet = false) {
1580
- // Check if this folder has been initialised
1581
- if (!(await this.initialised())) {
1582
- throw new Error("Not initialised in this folder");
1583
- }
2058
+ // Load all tracked tasks and filter the results
2059
+ const index = await this.loadIndex();
2060
+ let tasks = filterTasks(index, await this.loadAllTrackedTasks(index), filters);
1584
2061
 
1585
- // Load all tracked tasks and filter the results
1586
- const index = await this.loadIndex();
1587
- let tasks = filterTasks(index, await this.loadAllTrackedTasks(index), filters);
2062
+ // Return resulting task ids or the full tasks
2063
+ return tasks.map((task) => {
2064
+ return quiet ? utility.getTaskId(task.name) : this.hydrateTask(index, task);
2065
+ });
2066
+ }
1588
2067
 
1589
- // Return resulting task ids or the full tasks
1590
- return tasks.map((task) => {
1591
- return quiet ? utility.getTaskId(task.name) : this.hydrateTask(index, task);
1592
- });
1593
- },
1594
-
1595
- /**
1596
- * Output project status information
1597
- * @param {boolean} [quiet=false] Output full or partial status information
1598
- * @param {boolean} [untracked=false] Show a list of untracked tasks
1599
- * @param {boolean} [due=false] Show information about overdue tasks and time remaining
1600
- * @param {?string|?number} [sprint=null] The sprint name or number to show stats for, or null for current sprint
1601
- * @param {?Date[]} [dates=null] The date(s) to show stats for, or null for no date filter
1602
- * @return {object|string[]} Project status information as an object, or an array of untracked task filenames
1603
- */
1604
- async status(quiet = false, untracked = false, due = false, sprint = null, dates = null) {
1605
- // Check if this folder has been initialised
1606
- if (!(await this.initialised())) {
1607
- throw new Error("Not initialised in this folder");
1608
- }
2068
+ /**
2069
+ * Output project status information
2070
+ * @param {boolean} [quiet=false] Output full or partial status information
2071
+ * @param {boolean} [untracked=false] Show a list of untracked tasks
2072
+ * @param {boolean} [due=false] Show information about overdue tasks and time remaining
2073
+ * @param {?string|?number} [sprint=null] The sprint name or number to show stats for, or null for current sprint
2074
+ * @param {?Date[]} [dates=null] The date(s) to show stats for, or null for no date filter
2075
+ * @return {Promise<object|string[]>} Project status information as an object, or an array of untracked task filenames
2076
+ */
2077
+ async status(quiet = false, untracked = false, due = false, sprint = null, dates = null) {
2078
+ // Check if this folder has been initialised
2079
+ if (!(await this.initialised())) {
2080
+ throw new Error("Not initialised in this folder");
2081
+ }
1609
2082
 
1610
- // Get index and column names
1611
- const index = await this.loadIndex();
1612
- const columnNames = Object.keys(index.columns);
2083
+ // Get index and column names
2084
+ const index = await this.loadIndex();
2085
+ const columnNames = Object.keys(index.columns);
1613
2086
 
1614
- // Prepare output
1615
- const result = {
1616
- name: index.name,
1617
- };
2087
+ // Prepare output
2088
+ const result = {
2089
+ name: index.name,
2090
+ };
1618
2091
 
1619
- // Get un-tracked tasks if required
1620
- if (untracked) {
1621
- result.untrackedTasks = [...(await this.findUntrackedTasks())].map((taskId) => `${taskId}.md`);
2092
+ // Get un-tracked tasks if required
2093
+ if (untracked) {
2094
+ result.untrackedTasks = [...(await this.findUntrackedTasks())].map((taskId) => `${taskId}.md`);
1622
2095
 
1623
- // If output is quiet, output a list of untracked task filenames
1624
- if (quiet) {
1625
- return result.untrackedTasks;
1626
- }
2096
+ // If output is quiet, output a list of untracked task filenames
2097
+ if (quiet) {
2098
+ return result.untrackedTasks;
1627
2099
  }
2100
+ }
1628
2101
 
1629
- // Get basic project status information
1630
- result.tasks = columnNames.reduce((a, v) => a + index.columns[v].length, 0);
1631
- result.columnTasks = Object.fromEntries(
1632
- columnNames.map((columnName) => [columnName, index.columns[columnName].length])
1633
- );
1634
- if ("startedColumns" in index.options && index.options.startedColumns.length > 0) {
1635
- result.startedTasks = Object.entries(index.columns)
1636
- .filter((c) => index.options.startedColumns.indexOf(c[0]) > -1)
1637
- .reduce((a, c) => a + c[1].length, 0);
1638
- }
1639
- if ("completedColumns" in index.options && index.options.completedColumns.length > 0) {
1640
- result.completedTasks = Object.entries(index.columns)
1641
- .filter((c) => index.options.completedColumns.indexOf(c[0]) > -1)
1642
- .reduce((a, c) => a + c[1].length, 0);
1643
- }
2102
+ // Get basic project status information
2103
+ result.tasks = columnNames.reduce((a, v) => a + index.columns[v].length, 0);
2104
+ result.columnTasks = Object.fromEntries(
2105
+ columnNames.map((columnName) => [columnName, index.columns[columnName].length])
2106
+ );
2107
+ if ("startedColumns" in index.options && index.options.startedColumns.length > 0) {
2108
+ result.startedTasks = Object.entries(index.columns)
2109
+ .filter((c) => index.options.startedColumns.indexOf(c[0]) > -1)
2110
+ .reduce((a, c) => a + c[1].length, 0);
2111
+ }
2112
+ if ("completedColumns" in index.options && index.options.completedColumns.length > 0) {
2113
+ result.completedTasks = Object.entries(index.columns)
2114
+ .filter((c) => index.options.completedColumns.indexOf(c[0]) > -1)
2115
+ .reduce((a, c) => a + c[1].length, 0);
2116
+ }
1644
2117
 
1645
- // If required, load more detailed task information
1646
- if (!quiet) {
1647
- // Load all tracked tasks and hydrate them
1648
- const tasks = [...(await this.loadAllTrackedTasks(index))].map((task) => this.hydrateTask(index, task));
1649
-
1650
- // If showing due information, calculate time remaining or overdue time for each task
1651
- if (due) {
1652
- result.dueTasks = [];
1653
- tasks.forEach((task) => {
1654
- if ("dueData" in task) {
1655
- result.dueTasks.push({
1656
- task: task.id,
1657
- workload: task.workload,
1658
- progress: task.progress,
1659
- remainingWorkload: task.remainingWorkload,
1660
- ...task.dueData,
1661
- });
1662
- }
1663
- });
1664
- }
2118
+ // If required, load more detailed task information
2119
+ if (!quiet) {
2120
+ // Load all tracked tasks and hydrate them
2121
+ const tasks = [...(await this.loadAllTrackedTasks(index))].map((task) => this.hydrateTask(index, task));
1665
2122
 
1666
- // Calculate total and per-column workload
1667
- let totalWorkload = 0,
1668
- totalRemainingWorkload = 0;
1669
- const columnWorkloads = tasks.reduce(
1670
- (a, task) => {
1671
- totalWorkload += task.workload;
1672
- totalRemainingWorkload += task.remainingWorkload;
1673
- a[task.column].workload += task.workload;
1674
- a[task.column].remainingWorkload += task.remainingWorkload;
1675
- return a;
1676
- },
1677
- Object.fromEntries(
1678
- columnNames.map((columnName) => [
1679
- columnName,
1680
- {
1681
- workload: 0,
1682
- remainingWorkload: 0,
1683
- },
1684
- ])
1685
- )
1686
- );
1687
- result.totalWorkload = totalWorkload;
1688
- result.totalRemainingWorkload = totalRemainingWorkload;
1689
- result.columnWorkloads = columnWorkloads;
1690
- result.taskWorkloads = Object.fromEntries(
1691
- tasks.map((task) => [
1692
- task.id,
1693
- {
2123
+ // If showing due information, calculate time remaining or overdue time for each task
2124
+ if (due) {
2125
+ result.dueTasks = [];
2126
+ tasks.forEach((task) => {
2127
+ if ("dueData" in task) {
2128
+ result.dueTasks.push({
2129
+ task: task.id,
1694
2130
  workload: task.workload,
1695
2131
  progress: task.progress,
1696
2132
  remainingWorkload: task.remainingWorkload,
1697
- completed: taskCompleted(index, task),
2133
+ ...task.dueData,
2134
+ });
2135
+ }
2136
+ });
2137
+ }
2138
+
2139
+ // Calculate total and per-column workload
2140
+ let totalWorkload = 0,
2141
+ totalRemainingWorkload = 0;
2142
+ const columnWorkloads = tasks.reduce(
2143
+ (a, task) => {
2144
+ totalWorkload += task.workload;
2145
+ totalRemainingWorkload += task.remainingWorkload;
2146
+ a[task.column].workload += task.workload;
2147
+ a[task.column].remainingWorkload += task.remainingWorkload;
2148
+ return a;
2149
+ },
2150
+ Object.fromEntries(
2151
+ columnNames.map((columnName) => [
2152
+ columnName,
2153
+ {
2154
+ workload: 0,
2155
+ remainingWorkload: 0,
1698
2156
  },
1699
2157
  ])
1700
- );
2158
+ )
2159
+ );
2160
+ result.totalWorkload = totalWorkload;
2161
+ result.totalRemainingWorkload = totalRemainingWorkload;
2162
+ result.columnWorkloads = columnWorkloads;
2163
+ result.taskWorkloads = Object.fromEntries(
2164
+ tasks.map((task) => [
2165
+ task.id,
2166
+ {
2167
+ workload: task.workload,
2168
+ progress: task.progress,
2169
+ remainingWorkload: task.remainingWorkload,
2170
+ completed: taskCompleted(index, task),
2171
+ },
2172
+ ])
2173
+ );
1701
2174
 
1702
- // Calculate assigned task totals and workloads
1703
- const assignedTasks = tasks.reduce((a, task) => {
1704
- if ("assigned" in task.metadata) {
1705
- if (!(task.metadata.assigned in a)) {
1706
- a[task.metadata.assigned] = {
1707
- total: 0,
1708
- workload: 0,
1709
- remainingWorkload: 0,
1710
- };
1711
- }
1712
- a[task.metadata.assigned].total++;
1713
- a[task.metadata.assigned].workload += task.workload;
1714
- a[task.metadata.assigned].remainingWorkload += task.remainingWorkload;
2175
+ // Calculate assigned task totals and workloads
2176
+ const assignedTasks = tasks.reduce((a, task) => {
2177
+ if ("assigned" in task.metadata) {
2178
+ if (!(task.metadata.assigned in a)) {
2179
+ a[task.metadata.assigned] = {
2180
+ total: 0,
2181
+ workload: 0,
2182
+ remainingWorkload: 0,
2183
+ };
1715
2184
  }
1716
- return a;
1717
- }, {});
1718
- if (Object.keys(assignedTasks).length > 0) {
1719
- result.assigned = assignedTasks;
2185
+ a[task.metadata.assigned].total++;
2186
+ a[task.metadata.assigned].workload += task.workload;
2187
+ a[task.metadata.assigned].remainingWorkload += task.remainingWorkload;
1720
2188
  }
1721
-
1722
- // If any sprints are defined in index options, calculate sprint statistics
1723
- if ("sprints" in index.options && index.options.sprints.length) {
1724
- const sprints = index.options.sprints;
1725
-
1726
- // Default to current sprint
1727
- const currentSprint = index.options.sprints.length;
1728
- let sprintIndex = currentSprint - 1;
1729
-
1730
- // Check if we're requesting stats for a specific sprint
1731
- if (sprint !== null) {
1732
- // Select sprint by number (1-based index)
1733
- if (typeof sprint === "number") {
1734
- if (sprint < 1 || sprint > sprints.length) {
1735
- throw new Error(`Sprint ${sprint} does not exist`);
1736
- } else {
1737
- sprintIndex = sprint - 1;
1738
- }
1739
-
1740
- // Or select sprint by name
1741
- } else if (typeof sprint === "string") {
1742
- sprintIndex = sprints.findIndex((s) => s.name === sprint);
1743
- if (sprintIndex === -1) {
1744
- throw new Error(`No sprint found with name "${sprint}"`);
1745
- }
2189
+ return a;
2190
+ }, {});
2191
+ if (Object.keys(assignedTasks).length > 0) {
2192
+ result.assigned = assignedTasks;
2193
+ }
2194
+
2195
+ // If any sprints are defined in index options, calculate sprint statistics
2196
+ if ("sprints" in index.options && index.options.sprints.length) {
2197
+ const sprints = index.options.sprints;
2198
+
2199
+ // Default to current sprint
2200
+ const currentSprint = index.options.sprints.length;
2201
+ let sprintIndex = currentSprint - 1;
2202
+
2203
+ // Check if we're requesting stats for a specific sprint
2204
+ if (sprint !== null) {
2205
+ // Select sprint by number (1-based index)
2206
+ if (typeof sprint === "number") {
2207
+ if (sprint < 1 || sprint > sprints.length) {
2208
+ throw new Error(`Sprint ${sprint} does not exist`);
2209
+ } else {
2210
+ sprintIndex = sprint - 1;
1746
2211
  }
1747
- }
1748
2212
 
1749
- // Add sprint information
1750
- result.sprint = {
1751
- number: sprintIndex + 1,
1752
- name: sprints[sprintIndex].name,
1753
- start: sprints[sprintIndex].start,
1754
- };
1755
- if (currentSprint - 1 !== sprintIndex) {
1756
- if (sprintIndex === sprints.length - 1) {
1757
- result.sprint.end = sprints[sprintIndex + 1].start;
2213
+ // Or select sprint by name
2214
+ } else if (typeof sprint === "string") {
2215
+ sprintIndex = sprints.findIndex((s) => s.name === sprint);
2216
+ if (sprintIndex === -1) {
2217
+ throw new Error(`No sprint found with name "${sprint}"`);
1758
2218
  }
1759
- result.sprint.current = currentSprint;
1760
2219
  }
1761
- if (sprints[sprintIndex].description) {
1762
- result.sprint.description = sprints[sprintIndex].description;
2220
+ }
2221
+
2222
+ // Add sprint information
2223
+ result.sprint = {
2224
+ number: sprintIndex + 1,
2225
+ name: sprints[sprintIndex].name,
2226
+ start: sprints[sprintIndex].start,
2227
+ };
2228
+ if (currentSprint - 1 !== sprintIndex) {
2229
+ if (sprintIndex === sprints.length - 1) {
2230
+ result.sprint.end = sprints[sprintIndex + 1].start;
1763
2231
  }
1764
- const sprintStartDate = sprints[sprintIndex].start;
1765
- const sprintEndDate = sprintIndex === sprints.length - 1 ? new Date() : sprints[sprintIndex + 1].start;
1766
-
1767
- // Calculate sprint duration
1768
- const duration = sprintEndDate - sprintStartDate;
1769
- result.sprint.durationDelta = duration;
1770
- result.sprint.durationMessage = humanizeDuration(duration, {
1771
- largest: 3,
1772
- round: true,
1773
- });
1774
-
1775
- // Add task workload information for the sprint
1776
- result.sprint.created = taskWorkloadInPeriod(tasks, "created", sprintStartDate, sprintEndDate);
1777
- result.sprint.started = taskWorkloadInPeriod(tasks, "started", sprintStartDate, sprintEndDate);
1778
- result.sprint.completed = taskWorkloadInPeriod(tasks, "completed", sprintStartDate, sprintEndDate);
1779
- result.sprint.due = taskWorkloadInPeriod(tasks, "due", sprintStartDate, sprintEndDate);
1780
-
1781
- // Add custom date property workload information for the sprint
1782
- if ("customFields" in index.options) {
1783
- for (let customField of index.options.customFields) {
1784
- if (customField.type === "date") {
1785
- result.sprint[customField.name] = taskWorkloadInPeriod(
1786
- tasks,
1787
- customField.name,
1788
- sprintStartDate,
1789
- sprintEndDate
1790
- );
1791
- }
2232
+ result.sprint.current = currentSprint;
2233
+ }
2234
+ if (sprints[sprintIndex].description) {
2235
+ result.sprint.description = sprints[sprintIndex].description;
2236
+ }
2237
+ const sprintStartDate = sprints[sprintIndex].start;
2238
+ const sprintEndDate = sprintIndex === sprints.length - 1 ? new Date() : sprints[sprintIndex + 1].start;
2239
+
2240
+ // Calculate sprint duration
2241
+ const duration = sprintEndDate - sprintStartDate;
2242
+ result.sprint.durationDelta = duration;
2243
+ result.sprint.durationMessage = humanizeDuration(duration, {
2244
+ largest: 3,
2245
+ round: true,
2246
+ });
2247
+
2248
+ // Add task workload information for the sprint
2249
+ result.sprint.created = taskWorkloadInPeriod(tasks, "created", sprintStartDate, sprintEndDate);
2250
+ result.sprint.started = taskWorkloadInPeriod(tasks, "started", sprintStartDate, sprintEndDate);
2251
+ result.sprint.completed = taskWorkloadInPeriod(tasks, "completed", sprintStartDate, sprintEndDate);
2252
+ result.sprint.due = taskWorkloadInPeriod(tasks, "due", sprintStartDate, sprintEndDate);
2253
+
2254
+ // Add custom date property workload information for the sprint
2255
+ if ("customFields" in index.options) {
2256
+ for (let customField of index.options.customFields) {
2257
+ if (customField.type === "date") {
2258
+ result.sprint[customField.name] = taskWorkloadInPeriod(
2259
+ tasks,
2260
+ customField.name,
2261
+ sprintStartDate,
2262
+ sprintEndDate
2263
+ );
1792
2264
  }
1793
2265
  }
1794
2266
  }
2267
+ }
1795
2268
 
1796
- // If any dates were specified, calculate task statistics for these dates
1797
- if (dates !== null && dates.length > 0) {
1798
- let periodStart, periodEnd;
1799
- result.period = {};
1800
- if (dates.length === 1) {
1801
- periodStart = new Date(+dates[0]);
1802
- periodStart.setHours(0, 0, 0, 0);
1803
- periodEnd = new Date(+dates[0]);
1804
- periodEnd.setHours(23, 59, 59, 999);
1805
- result.period.start = periodStart;
1806
- result.period.end = periodEnd;
1807
- } else {
1808
- result.period.start = periodStart = new Date(Math.min(...dates));
1809
- result.period.end = periodEnd = new Date(Math.max(...dates));
1810
- }
1811
- result.period.created = taskWorkloadInPeriod(tasks, "created", periodStart, periodEnd);
1812
- result.period.started = taskWorkloadInPeriod(tasks, "started", periodStart, periodEnd);
1813
- result.period.completed = taskWorkloadInPeriod(tasks, "completed", periodStart, periodEnd);
1814
- result.period.due = taskWorkloadInPeriod(tasks, "due", periodStart, periodEnd);
1815
-
1816
- // Add custom date property workload information for the selected date range
1817
- if ("customFields" in index.options) {
1818
- for (let customField of index.options.customFields) {
1819
- if (customField.type === "date") {
1820
- result.sprint[customField.name] = taskWorkloadInPeriod(tasks, customField.name, periodStart, periodEnd);
1821
- }
2269
+ // If any dates were specified, calculate task statistics for these dates
2270
+ if (dates !== null && dates.length > 0) {
2271
+ let periodStart, periodEnd;
2272
+ result.period = {};
2273
+ if (dates.length === 1) {
2274
+ periodStart = new Date(+dates[0]);
2275
+ periodStart.setHours(0, 0, 0, 0);
2276
+ periodEnd = new Date(+dates[0]);
2277
+ periodEnd.setHours(23, 59, 59, 999);
2278
+ result.period.start = periodStart;
2279
+ result.period.end = periodEnd;
2280
+ } else {
2281
+ result.period.start = periodStart = new Date(Math.min(...dates));
2282
+ result.period.end = periodEnd = new Date(Math.max(...dates));
2283
+ }
2284
+ result.period.created = taskWorkloadInPeriod(tasks, "created", periodStart, periodEnd);
2285
+ result.period.started = taskWorkloadInPeriod(tasks, "started", periodStart, periodEnd);
2286
+ result.period.completed = taskWorkloadInPeriod(tasks, "completed", periodStart, periodEnd);
2287
+ result.period.due = taskWorkloadInPeriod(tasks, "due", periodStart, periodEnd);
2288
+
2289
+ // Add custom date property workload information for the selected date range
2290
+ if ("customFields" in index.options) {
2291
+ for (let customField of index.options.customFields) {
2292
+ if (customField.type === "date") {
2293
+ result.sprint[customField.name] = taskWorkloadInPeriod(tasks, customField.name, periodStart, periodEnd);
1822
2294
  }
1823
2295
  }
1824
2296
  }
1825
2297
  }
1826
- return result;
1827
- },
1828
-
1829
- /**
1830
- * Validate the index and task files
1831
- * @param {boolean} [save=false] Re-save all files
1832
- * @return {boolean} True if everything validated, otherwise an array of parsing errors
1833
- */
1834
- async validate(save = false) {
1835
- // Check if this folder has been initialised
1836
- if (!(await this.initialised())) {
1837
- throw new Error("Not initialised in this folder");
2298
+ }
2299
+ return result;
2300
+ }
2301
+
2302
+ /**
2303
+ * Validate the index and task files
2304
+ * @param {boolean} [save=false] Re-save all files
2305
+ * @return {Promise<boolean>} True if everything validated, otherwise an array of parsing errors
2306
+ */
2307
+ async validate(save = false) {
2308
+ // Check if this folder has been initialised
2309
+ if (!(await this.initialised())) {
2310
+ throw new Error("Not initialised in this folder");
2311
+ }
2312
+ const errors = [];
2313
+
2314
+ // Load & parse index
2315
+ let index = null;
2316
+ try {
2317
+ index = await this.loadIndex();
2318
+
2319
+ // Re-save index if required
2320
+ if (save) {
2321
+ await this.saveIndex(index);
1838
2322
  }
1839
- const errors = [];
2323
+ } catch (error) {
2324
+ errors.push({
2325
+ task: null,
2326
+ errors: error.message,
2327
+ });
2328
+ }
1840
2329
 
1841
- // Load & parse index
1842
- let index = null;
2330
+ // Exit early if any errors were found in the index
2331
+ if (errors.length) {
2332
+ return errors;
2333
+ }
2334
+
2335
+ // Load & parse tasks
2336
+ const trackedTasks = getTrackedTaskIds(index);
2337
+ for (let taskId of trackedTasks) {
1843
2338
  try {
1844
- index = await this.loadIndex();
2339
+ const task = await this.loadTask(taskId);
1845
2340
 
1846
- // Re-save index if required
2341
+ // Re-save tasks if required
1847
2342
  if (save) {
1848
- await this.saveIndex(index);
2343
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), task);
1849
2344
  }
1850
2345
  } catch (error) {
1851
2346
  errors.push({
1852
- task: null,
2347
+ task: taskId,
1853
2348
  errors: error.message,
1854
2349
  });
1855
2350
  }
2351
+ }
1856
2352
 
1857
- // Exit early if any errors were found in the index
1858
- if (errors.length) {
1859
- return errors;
1860
- }
2353
+ // Return a list of errors or true if there were no errors
2354
+ if (errors.length) {
2355
+ return errors;
2356
+ }
2357
+ return true;
2358
+ }
1861
2359
 
1862
- // Load & parse tasks
1863
- const trackedTasks = getTrackedTaskIds(index);
1864
- for (let taskId of trackedTasks) {
1865
- try {
1866
- const task = await this.loadTask(taskId);
2360
+ /**
2361
+ * Sort a column in the index
2362
+ * @param {string} columnName The column name to sort
2363
+ * @param {object[]} sorters A list of objects containing the field to sort by, filters and sort order
2364
+ * @param {boolean} [save=false] True if the settings should be saved in index
2365
+ */
2366
+ async sort(columnName, sorters, save = false) {
2367
+ // Check if this folder has been initialised
2368
+ if (!(await this.initialised())) {
2369
+ throw new Error("Not initialised in this folder");
2370
+ }
1867
2371
 
1868
- // Re-save tasks if required
1869
- if (save) {
1870
- await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), task);
1871
- }
1872
- } catch (error) {
1873
- errors.push({
1874
- task: taskId,
1875
- errors: error.message,
1876
- });
1877
- }
1878
- }
2372
+ // Get index and make sure the column exists
2373
+ let index = await this.loadIndex();
2374
+ if (!(columnName in index.columns)) {
2375
+ throw new Error(`Column "${columnName}" doesn't exist`);
2376
+ }
1879
2377
 
1880
- // Return a list of errors or true if there were no errors
1881
- if (errors.length) {
1882
- return errors;
1883
- }
1884
- return true;
1885
- },
1886
-
1887
- /**
1888
- * Sort a column in the index
1889
- * @param {string} columnName The column name to sort
1890
- * @param {object[]} sorters A list of objects containing the field to sort by, filters and sort order
1891
- * @param {boolean} [save=false] True if the settings should be saved in index
1892
- */
1893
- async sort(columnName, sorters, save = false) {
1894
- // Check if this folder has been initialised
1895
- if (!(await this.initialised())) {
1896
- throw new Error("Not initialised in this folder");
2378
+ // Save the sorter settings if required (the column will be sorted when saving the index)
2379
+ if (save) {
2380
+ if (!("columnSorting" in index.options)) {
2381
+ index.options.columnSorting = {};
1897
2382
  }
2383
+ index.options.columnSorting[columnName] = sorters;
1898
2384
 
1899
- // Get index and make sure the column exists
1900
- let index = await this.loadIndex();
1901
- if (!(columnName in index.columns)) {
1902
- throw new Error(`Column "${columnName}" doesn't exist`);
2385
+ // Otherwise, remove sorting settings for the specified column and manually sort the column
2386
+ } else {
2387
+ if ("columnSorting" in index.options && columnName in index.options.columnSorting) {
2388
+ delete index.options.columnSorting[columnName];
1903
2389
  }
2390
+ const tasks = await this.loadAllTrackedTasks(index, columnName);
2391
+ index = sortColumnInIndex(index, tasks, columnName, sorters);
2392
+ }
2393
+ await this.saveIndex(index);
2394
+ }
1904
2395
 
1905
- // Save the sorter settings if required (the column will be sorted when saving the index)
1906
- if (save) {
1907
- if (!("columnSorting" in index.options)) {
1908
- index.options.columnSorting = {};
1909
- }
1910
- index.options.columnSorting[columnName] = sorters;
2396
+ /**
2397
+ * Start a sprint
2398
+ * @param {string} name Sprint name
2399
+ * @param {string} description Sprint description
2400
+ * @param {Date} start Sprint start date
2401
+ * @return {Promise<object>} The sprint object
2402
+ */
2403
+ async sprint(name, description, start) {
2404
+ // Check if this folder has been initialised
2405
+ if (!(await this.initialised())) {
2406
+ throw new Error("Not initialised in this folder");
2407
+ }
1911
2408
 
1912
- // Otherwise, remove sorting settings for the specified column and manually sort the column
1913
- } else {
1914
- if ("columnSorting" in index.options && columnName in index.options.columnSorting) {
1915
- delete index.options.columnSorting[columnName];
1916
- }
1917
- const tasks = await this.loadAllTrackedTasks(index, columnName);
1918
- index = sortColumnInIndex(index, tasks, columnName, sorters);
1919
- }
1920
- await this.saveIndex(index);
1921
- },
1922
-
1923
- /**
1924
- * Start a sprint
1925
- * @param {string} name Sprint name
1926
- * @param {string} description Sprint description
1927
- * @param {Date} start Sprint start date
1928
- * @return {object} The sprint object
1929
- */
1930
- async sprint(name, description, start) {
1931
- // Check if this folder has been initialised
1932
- if (!(await this.initialised())) {
1933
- throw new Error("Not initialised in this folder");
1934
- }
2409
+ // Get index and make sure it has a list of sprints in the options
2410
+ const index = await this.loadIndex();
2411
+ if (!("sprints" in index.options)) {
2412
+ index.options.sprints = [];
2413
+ }
2414
+ const sprintNumber = index.options.sprints.length + 1;
2415
+ const sprint = {
2416
+ start: start,
2417
+ };
1935
2418
 
1936
- // Get index and make sure it has a list of sprints in the options
1937
- const index = await this.loadIndex();
1938
- if (!("sprints" in index.options)) {
1939
- index.options.sprints = [];
1940
- }
1941
- const sprintNumber = index.options.sprints.length + 1;
1942
- const sprint = {
1943
- start: start,
1944
- };
2419
+ // If the name is blank, generate a default name
2420
+ if (!name) {
2421
+ sprint.name = `Sprint ${sprintNumber}`;
2422
+ } else {
2423
+ sprint.name = name;
2424
+ }
1945
2425
 
1946
- // If the name is blank, generate a default name
1947
- if (!name) {
1948
- sprint.name = `Sprint ${sprintNumber}`;
1949
- } else {
1950
- sprint.name = name;
1951
- }
2426
+ // Add description if one exists
2427
+ if (description) {
2428
+ sprint.description = description;
2429
+ }
1952
2430
 
1953
- // Add description if one exists
1954
- if (description) {
1955
- sprint.description = description;
1956
- }
2431
+ // Add sprint and save the index
2432
+ index.options.sprints.push(sprint);
2433
+ await this.saveIndex(index);
2434
+ return sprint;
2435
+ }
1957
2436
 
1958
- // Add sprint and save the index
1959
- index.options.sprints.push(sprint);
1960
- await this.saveIndex(index);
1961
- return sprint;
1962
- },
1963
-
1964
- /**
1965
- * Output burndown chart data
1966
- * @param {?string[]} [sprints=null] The sprint names or numbers to show a chart for, or null for
1967
- * the current sprint
1968
- * @param {?Date[]} [dates=null] The dates to show a chart for, or null for no date filter
1969
- * @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
1970
- * @param {?string[]} [columns=null] The columns to filter for, or null for no column filter
1971
- * @param {?string} [normalise=null] The date normalisation mode
1972
- * @return {object} Burndown chart data as an object
1973
- */
1974
- async burndown(sprints = null, dates = null, assigned = null, columns = null, normalise = null) {
1975
- // Check if this folder has been initialised
1976
- if (!(await this.initialised())) {
1977
- throw new Error("Not initialised in this folder");
1978
- }
2437
+ /**
2438
+ * Output burndown chart data
2439
+ * @param {?string[]} [sprints=null] The sprint names or numbers to show a chart for, or null for
2440
+ * the current sprint
2441
+ * @param {?Date[]} [dates=null] The dates to show a chart for, or null for no date filter
2442
+ * @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
2443
+ * @param {?string[]} [columns=null] The columns to filter for, or null for no column filter
2444
+ * @param {?string} [normalise=null] The date normalisation mode
2445
+ * @return {Promise<object>} Burndown chart data as an object
2446
+ */
2447
+ async burndown(sprints = null, dates = null, assigned = null, columns = null, normalise = null) {
2448
+ // Check if this folder has been initialised
2449
+ if (!(await this.initialised())) {
2450
+ throw new Error("Not initialised in this folder");
2451
+ }
1979
2452
 
1980
- // Get index and tasks
1981
- const index = await this.loadIndex();
1982
- const tasks = [...(await this.loadAllTrackedTasks(index))]
1983
- .map((task) => {
1984
- const created = "created" in task.metadata ? task.metadata.created : new Date(0);
1985
- return {
1986
- ...task,
1987
- created,
1988
- started:
1989
- "started" in task.metadata
1990
- ? task.metadata.started
1991
- : "startedColumns" in index.options && index.options.startedColumns.indexOf(task.column) !== -1
1992
- ? created
1993
- : false,
1994
- completed:
1995
- "completed" in task.metadata
1996
- ? task.metadata.completed
1997
- : "completedColumns" in index.options && index.options.completedColumns.indexOf(task.column) !== -1
1998
- ? created
1999
- : false,
2000
- progress: taskProgress(index, task),
2001
- assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
2002
- workload: taskWorkload(index, task),
2003
- column: findTaskColumn(index, task.id),
2004
- };
2005
- })
2006
- .filter(
2007
- (task) =>
2008
- (assigned === null || task.assigned === assigned) &&
2009
- (columns === null || columns.indexOf(task.column) !== -1)
2010
- );
2453
+ // Get index and tasks
2454
+ const index = await this.loadIndex();
2455
+ const tasks = [...(await this.loadAllTrackedTasks(index))]
2456
+ .map((task) => {
2457
+ const taskColumn = findTaskColumn(index, task.id);
2458
+ const created = "created" in task.metadata ? task.metadata.created : new Date(0);
2459
+ return {
2460
+ ...task,
2461
+ created,
2462
+ started:
2463
+ "started" in task.metadata
2464
+ ? task.metadata.started
2465
+ : "startedColumns" in index.options && index.options.startedColumns.indexOf(taskColumn) !== -1
2466
+ ? created
2467
+ : false,
2468
+ completed:
2469
+ "completed" in task.metadata
2470
+ ? task.metadata.completed
2471
+ : "completedColumns" in index.options && index.options.completedColumns.indexOf(taskColumn) !== -1
2472
+ ? created
2473
+ : false,
2474
+ progress: taskProgress(index, task),
2475
+ assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
2476
+ workload: taskWorkload(index, task),
2477
+ column: taskColumn,
2478
+ };
2479
+ })
2480
+ .filter(
2481
+ (task) =>
2482
+ (assigned === null || task.assigned === assigned) &&
2483
+ (columns === null || columns.indexOf(task.column) !== -1)
2484
+ );
2011
2485
 
2012
- // Get sprints and dates to plot from arguments
2013
- const series = [];
2014
- const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
2015
- if (sprints === null && dates === null) {
2016
- if (indexSprints !== null) {
2017
- // Show current sprint
2018
- const currentSprint = indexSprints.length - 1;
2019
- series.push({
2020
- sprint: indexSprints[currentSprint],
2021
- from: new Date(indexSprints[currentSprint].start),
2022
- to: new Date(),
2023
- });
2024
- } else {
2025
- // Show all time
2026
- series.push({
2027
- from: new Date(
2028
- Math.min(
2029
- ...tasks
2030
- .map((t) =>
2031
- [
2032
- "created" in t.metadata && t.metadata.created,
2033
- "started" in t.metadata && t.metadata.started,
2034
- "completed" in t.metadata && (t.metadata.completed || new Date(8640000000000000))
2035
- ].filter((d) => d)
2036
- )
2037
- .flat()
2038
- )
2039
- ),
2040
- to: new Date(),
2041
- });
2042
- }
2486
+ // Get sprints and dates to plot from arguments
2487
+ const series = [];
2488
+ const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
2489
+ if (sprints === null && dates === null) {
2490
+ if (indexSprints !== null) {
2491
+ // Show current sprint
2492
+ const currentSprint = indexSprints.length - 1;
2493
+ series.push({
2494
+ sprint: indexSprints[currentSprint],
2495
+ from: new Date(indexSprints[currentSprint].start),
2496
+ to: new Date(),
2497
+ });
2043
2498
  } else {
2044
- // Show specified sprint
2045
- if (sprints !== null) {
2046
- if (indexSprints === null) {
2047
- throw new Error(`No sprints defined`);
2048
- } else {
2049
- for (sprint of sprints) {
2050
- let sprintIndex = null;
2051
-
2052
- // Select sprint by number (1-based index)
2053
- if (typeof sprint === "number") {
2054
- if (sprint < 1 || sprint > indexSprints.length) {
2055
- throw new Error(`Sprint ${sprint} does not exist`);
2056
- } else {
2057
- sprintIndex = sprint - 1;
2058
- }
2499
+ // Show all time
2500
+ series.push({
2501
+ from: new Date(
2502
+ Math.min(
2503
+ ...tasks
2504
+ .map((t) =>
2505
+ [
2506
+ "created" in t.metadata && t.metadata.created,
2507
+ "started" in t.metadata && t.metadata.started,
2508
+ "completed" in t.metadata && (t.metadata.completed || new Date(8640000000000000))
2509
+ ].filter((d) => d)
2510
+ )
2511
+ .flat()
2512
+ )
2513
+ ),
2514
+ to: new Date(),
2515
+ });
2516
+ }
2517
+ } else {
2518
+ // Show specified sprint
2519
+ if (sprints !== null) {
2520
+ if (indexSprints === null) {
2521
+ throw new Error(`No sprints defined`);
2522
+ } else {
2523
+ for (sprint of sprints) {
2524
+ let sprintIndex = null;
2059
2525
 
2060
- // Or select sprint by name
2061
- } else if (typeof sprint === "string") {
2062
- sprintIndex = indexSprints.findIndex((s) => s.name === sprint);
2063
- if (sprintIndex === -1) {
2064
- throw new Error(`No sprint found with name "${sprint}"`);
2065
- }
2066
- }
2067
- if (sprintIndex === null) {
2068
- throw new Error(`Invalid sprint "${sprint}"`);
2526
+ // Select sprint by number (1-based index)
2527
+ if (typeof sprint === "number") {
2528
+ if (sprint < 1 || sprint > indexSprints.length) {
2529
+ throw new Error(`Sprint ${sprint} does not exist`);
2530
+ } else {
2531
+ sprintIndex = sprint - 1;
2069
2532
  }
2070
2533
 
2071
- // Get sprint start and end
2072
- series.push({
2073
- sprint: indexSprints[sprintIndex],
2074
- from: new Date(indexSprints[sprintIndex].start),
2075
- to: sprintIndex < indexSprints.length - 1 ? new Date(indexSprints[sprintIndex + 1].start) : new Date(),
2076
- });
2534
+ // Or select sprint by name
2535
+ } else if (typeof sprint === "string") {
2536
+ sprintIndex = indexSprints.findIndex((s) => s.name === sprint);
2537
+ if (sprintIndex === -1) {
2538
+ throw new Error(`No sprint found with name "${sprint}"`);
2539
+ }
2077
2540
  }
2541
+ if (sprintIndex === null) {
2542
+ throw new Error(`Invalid sprint "${sprint}"`);
2543
+ }
2544
+
2545
+ // Get sprint start and end
2546
+ series.push({
2547
+ sprint: indexSprints[sprintIndex],
2548
+ from: new Date(indexSprints[sprintIndex].start),
2549
+ to: sprintIndex < indexSprints.length - 1 ? new Date(indexSprints[sprintIndex + 1].start) : new Date(),
2550
+ });
2078
2551
  }
2079
2552
  }
2553
+ }
2080
2554
 
2081
- // Show specified date range
2082
- if (dates !== null) {
2083
- series.push({
2084
- from: new Date(Math.min(...dates)),
2085
- to: dates.length === 1 ? new Date() : new Date(Math.max(...dates)),
2086
- });
2087
- }
2555
+ // Show specified date range
2556
+ if (dates !== null) {
2557
+ series.push({
2558
+ from: new Date(Math.min(...dates)),
2559
+ to: dates.length === 1 ? new Date() : new Date(Math.max(...dates)),
2560
+ });
2561
+ }
2562
+ }
2563
+
2564
+ // If normalise mode is 'auto', find the most appropriate normalisation mode
2565
+ if (normalise === 'auto') {
2566
+ const delta = series[0].to - series[0].from;
2567
+ if (delta >= DAY * 7) {
2568
+ normalise = 'days';
2569
+ } else if (delta >= DAY) {
2570
+ normalise = 'hours';
2571
+ } else if (delta >= HOUR ) {
2572
+ normalise = 'minutes';
2573
+ } else {
2574
+ normalise = 'seconds';
2088
2575
  }
2576
+ }
2577
+ if (normalise !== null) {
2089
2578
 
2090
- // If normalise mode is 'auto', find the most appropriate normalisation mode
2091
- if (normalise === 'auto') {
2092
- const delta = series[0].to - series[0].from;
2093
- if (delta >= DAY * 7) {
2094
- normalise = 'days';
2095
- } else if (delta >= DAY) {
2096
- normalise = 'hours';
2097
- } else if (delta >= HOUR ) {
2098
- normalise = 'minutes';
2099
- } else {
2100
- normalise = 'seconds';
2579
+ // Normalize series from and to dates
2580
+ series.forEach((s) => {
2581
+ s.from = normaliseDate(s.from, normalise);
2582
+ s.to = normaliseDate(s.to, normalise);
2583
+ });
2584
+
2585
+ // Normalise task dates
2586
+ tasks.forEach((task) => {
2587
+ if ("history" in task && Array.isArray(task.history) && task.history.length > 0) {
2588
+ task.history = task.history.map((historyEvent) => ({
2589
+ ...historyEvent,
2590
+ date: historyEvent.date ? normaliseDate(historyEvent.date, normalise) : historyEvent.date
2591
+ }));
2101
2592
  }
2102
- }
2103
- if (normalise !== null) {
2593
+ if (task.created) {
2594
+ task.created = normaliseDate(task.created, normalise);
2595
+ }
2596
+ if (task.started) {
2597
+ task.started = normaliseDate(task.started, normalise);
2598
+ }
2599
+ if (task.completed) {
2600
+ task.completed = normaliseDate(task.completed, normalise);
2601
+ }
2602
+ });
2603
+ }
2104
2604
 
2105
- // Normalize series from and to dates
2106
- series.forEach((s) => {
2107
- s.from = normaliseDate(s.from, normalise);
2108
- s.to = normaliseDate(s.to, normalise);
2109
- });
2605
+ // Get workload datapoints for each period
2606
+ series.forEach((s) => {
2607
+ s.dataPoints = [
2608
+ {
2609
+ x: s.from,
2610
+ y: getWorkloadAtDate(index, tasks, s.from),
2611
+ count: countActiveTasksAtDate(index, tasks, s.from),
2612
+ tasks: getTaskEventsAtDate(index, tasks, s.from),
2613
+ },
2614
+ ...tasks
2615
+ .map((task) => getTaskTimelineDates(task, s.from, s.to))
2616
+ .flat()
2617
+ .filter((d) => d)
2618
+ .map((x) => ({
2619
+ x,
2620
+ y: getWorkloadAtDate(index, tasks, x),
2621
+ count: countActiveTasksAtDate(index, tasks, x),
2622
+ tasks: getTaskEventsAtDate(index, tasks, x),
2623
+ })),
2624
+ {
2625
+ x: s.to,
2626
+ y: getWorkloadAtDate(index, tasks, s.to),
2627
+ count: countActiveTasksAtDate(index, tasks, s.to),
2628
+ tasks: getTaskEventsAtDate(index, tasks, s.to),
2629
+ },
2630
+ ].sort((a, b) => a.x.getTime() - b.x.getTime());
2631
+ });
2632
+ return { series };
2633
+ }
2110
2634
 
2111
- // Normalise task dates
2112
- tasks.forEach((task) => {
2113
- if (task.created) {
2114
- task.created = normaliseDate(task.created, normalise);
2635
+ /**
2636
+ * Output gantt chart data
2637
+ * @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
2638
+ * @param {?string[]} [columns=null] The columns to filter for, or null for no column filter
2639
+ * @param {?Date[]} [dates=null] The dates to filter for (tasks created within range), or null for no date filter
2640
+ * @param {?Date} [now=null] Optional date to treat as the current time
2641
+ * @return {Promise<object>} Gantt chart data as an object
2642
+ */
2643
+ async gantt(assigned = null, columns = null, dates = null, now = null) {
2644
+ // Check if this folder has been initialised
2645
+ if (!(await this.initialised())) {
2646
+ throw new Error("Not initialised in this folder");
2647
+ }
2648
+
2649
+ const effectiveNow = now instanceof Date ? now : new Date();
2650
+
2651
+ // Get index and tasks
2652
+ const index = await this.loadIndex();
2653
+ const tasks = [...(await this.loadAllTrackedTasks(index))]
2654
+ .map((task) => {
2655
+ const taskColumn = findTaskColumn(index, task.id);
2656
+ const created = "created" in task.metadata ? task.metadata.created : new Date(0);
2657
+ return {
2658
+ ...task,
2659
+ created,
2660
+ started: "started" in task.metadata ? task.metadata.started : false,
2661
+ completed: "completed" in task.metadata ? task.metadata.completed : false,
2662
+ postponed: "postponed" in task.metadata ? task.metadata.postponed : false,
2663
+ workload: taskWorkload(index, task),
2664
+ progress: taskProgress(index, task),
2665
+ column: taskColumn,
2666
+ assignedUser: "assigned" in task.metadata ? task.metadata.assigned : null,
2667
+ };
2668
+ })
2669
+ .filter(
2670
+ (task) => {
2671
+ // Filter by assigned user
2672
+ if (assigned !== null && task.assignedUser !== assigned) {
2673
+ return false;
2115
2674
  }
2116
- if (task.started) {
2117
- task.started = normaliseDate(task.started, normalise);
2675
+ // Filter by columns
2676
+ if (columns !== null && !columns.includes(task.column)) {
2677
+ return false;
2118
2678
  }
2119
- if (task.completed) {
2120
- task.completed = normaliseDate(task.completed, normalise);
2679
+ // Filter by date range
2680
+ if (dates !== null && dates.length > 0) {
2681
+ const from = Math.min(...dates);
2682
+ const to = dates.length === 1 ? effectiveNow.getTime() : Math.max(...dates);
2683
+ if (task.created.getTime() < from || task.created.getTime() > to) {
2684
+ return false;
2685
+ }
2121
2686
  }
2122
- });
2123
- }
2687
+ return true;
2688
+ }
2689
+ );
2124
2690
 
2125
- // Get workload datapoints for each period
2126
- series.forEach((s) => {
2127
- s.dataPoints = [
2128
- {
2129
- x: s.from,
2130
- y: getWorkloadAtDate(tasks, s.from),
2131
- count: countActiveTasksAtDate(tasks, s.from),
2132
- tasks: getTaskEventsAtDate(tasks, s.from),
2133
- },
2134
- ...tasks
2135
- .filter((task) => {
2136
- let result = false;
2137
- if (task.created && task.created >= s.from && task.created <= s.to) {
2138
- result = true;
2139
- }
2140
- if (task.started && task.started >= s.from && task.started <= s.to) {
2141
- result = true;
2142
- }
2143
- if (task.completed && task.completed >= s.from && task.completed <= s.to) {
2144
- result = true;
2145
- }
2146
- return result;
2147
- })
2148
- .map((task) => [
2149
- task.created,
2150
- task.started,
2151
- task.completed
2152
- ])
2153
- .flat()
2154
- .filter((d) => d)
2155
- .map((x) => ({
2156
- x,
2157
- y: getWorkloadAtDate(tasks, x),
2158
- count: countActiveTasksAtDate(tasks, x),
2159
- tasks: getTaskEventsAtDate(tasks, x),
2160
- })),
2161
- {
2162
- x: s.to,
2163
- y: getWorkloadAtDate(tasks, s.to),
2164
- count: countActiveTasksAtDate(tasks, s.to),
2165
- tasks: getTaskEventsAtDate(tasks, s.to),
2166
- },
2167
- ].sort((a, b) => a.x.getTime() - b.x.getTime());
2168
- });
2169
- return { series };
2170
- },
2171
-
2172
- /**
2173
- * Add a comment to a task
2174
- * @param {string} taskId The task id
2175
- * @param {string} text The comment text
2176
- * @param {string} author The comment author
2177
- * @return {string} The task id
2178
- */
2179
- async comment(taskId, text, author) {
2180
- // Check if this folder has been initialised
2181
- if (!(await this.initialised())) {
2182
- throw new Error("Not initialised in this folder");
2183
- }
2184
- taskId = removeFileExtension(taskId);
2691
+ return buildGanttSchedule(index, tasks, effectiveNow);
2692
+ }
2185
2693
 
2186
- // Make sure the task file exists
2187
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2188
- throw new Error(`No task file found with id "${taskId}"`);
2189
- }
2694
+ /**
2695
+ * Add a comment to a task
2696
+ * @param {string} taskId The task id
2697
+ * @param {string} text The comment text
2698
+ * @param {string} author The comment author
2699
+ * @return {Promise<string>} The task id
2700
+ */
2701
+ async comment(taskId, text, author) {
2702
+ // Check if this folder has been initialised
2703
+ if (!(await this.initialised())) {
2704
+ throw new Error("Not initialised in this folder");
2705
+ }
2706
+ taskId = removeFileExtension(taskId);
2190
2707
 
2191
- // Get index and make sure the task is indexed
2192
- let index = await this.loadIndex();
2193
- if (!taskInIndex(index, taskId)) {
2194
- throw new Error(`Task "${taskId}" is not in the index`);
2195
- }
2708
+ // Make sure the task file exists
2709
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2710
+ throw new Error(`No task file found with id "${taskId}"`);
2711
+ }
2196
2712
 
2197
- // Make sure the comment text isn't empty
2198
- if (!text) {
2199
- throw new Error("Comment text cannot be empty");
2200
- }
2713
+ // Get index and make sure the task is indexed
2714
+ let index = await this.loadIndex();
2715
+ if (!taskInIndex(index, taskId)) {
2716
+ throw new Error(`Task "${taskId}" is not in the index`);
2717
+ }
2201
2718
 
2202
- // Add the comment
2203
- const taskData = await this.loadTask(taskId);
2204
- const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
2205
- taskData.comments.push({
2206
- text,
2207
- author,
2208
- date: new Date(),
2209
- });
2719
+ // Make sure the comment text isn't empty
2720
+ if (!text) {
2721
+ throw new Error("Comment text cannot be empty");
2722
+ }
2210
2723
 
2211
- // Save the task
2212
- await this.saveTask(taskPath, taskData);
2213
- return taskId;
2214
- },
2215
-
2216
- /**
2217
- * Return a list of archived tasks
2218
- * @return {string[]} A list of archived task ids
2219
- */
2220
- async listArchivedTasks() {
2221
- // Check if this folder has been initialised
2222
- if (!(await this.initialised())) {
2223
- throw new Error("Not initialised in this folder");
2224
- }
2724
+ // Add the comment
2725
+ const taskData = await this.loadTask(taskId);
2726
+ const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
2727
+ taskData.comments.push({
2728
+ text,
2729
+ author,
2730
+ date: new Date(),
2731
+ });
2225
2732
 
2226
- // Make sure the archive folder exists
2227
- const archiveFolder = await this.getArchiveFolderPath();
2228
- if (!(await exists(archiveFolder))) {
2229
- throw new Error("Archive folder doesn't exist");
2230
- }
2733
+ // Save the task
2734
+ await this.saveTask(taskPath, taskData);
2735
+ return taskId;
2736
+ }
2231
2737
 
2232
- // Get a list of archived task files
2233
- const files = await glob(`${archiveFolder}/*.md`);
2234
- return [...new Set(files.map((task) => path.parse(task).name))];
2235
- },
2236
-
2237
- /**
2238
- * Move a task to the archive
2239
- * @param {string} taskId The task id
2240
- * @return {string} The task id
2241
- */
2242
- async archiveTask(taskId) {
2243
- // Check if this folder has been initialised
2244
- if (!(await this.initialised())) {
2245
- throw new Error("Not initialised in this folder");
2246
- }
2247
- taskId = removeFileExtension(taskId);
2738
+ /**
2739
+ * Return a list of archived tasks
2740
+ * @return {Promise<string[]>} A list of archived task ids
2741
+ */
2742
+ async listArchivedTasks() {
2743
+ // Check if this folder has been initialised
2744
+ if (!(await this.initialised())) {
2745
+ throw new Error("Not initialised in this folder");
2746
+ }
2248
2747
 
2249
- // Make sure the task file exists
2250
- if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2251
- throw new Error(`No task file found with id "${taskId}"`);
2252
- }
2748
+ // Make sure the archive folder exists
2749
+ const archiveFolder = await this.getArchiveFolderPath();
2750
+ if (!(await exists(archiveFolder))) {
2751
+ throw new Error("Archive folder doesn't exist");
2752
+ }
2253
2753
 
2254
- // Get index and make sure the task is indexed
2255
- let index = await this.loadIndex();
2256
- if (!taskInIndex(index, taskId)) {
2257
- throw new Error(`Task "${taskId}" is not in the index`);
2258
- }
2754
+ // Get a list of archived task files
2755
+ const files = await glob(`${archiveFolder}/*.md`);
2756
+ return [...new Set(files.map((task) => path.parse(task).name))];
2757
+ }
2259
2758
 
2260
- // Make sure there isn't already an archived task with the same id
2261
- const archiveFolder = await this.getArchiveFolderPath();
2262
- const archivedTaskPath = getTaskPath(archiveFolder, taskId);
2263
- if (await exists(archivedTaskPath)) {
2264
- throw new Error(`An archived task with id "${taskId}" already exists`);
2265
- }
2759
+ /**
2760
+ * Move a task to the archive
2761
+ * @param {string} taskId The task id
2762
+ * @return {Promise<string>} The task id
2763
+ */
2764
+ async archiveTask(taskId) {
2765
+ // Check if this folder has been initialised
2766
+ if (!(await this.initialised())) {
2767
+ throw new Error("Not initialised in this folder");
2768
+ }
2769
+ taskId = removeFileExtension(taskId);
2266
2770
 
2267
- // Create archive folder if it doesn't already exist
2268
- if (!(await exists(archiveFolder))) {
2269
- await fs.promises.mkdir(archiveFolder, { recursive: true });
2270
- }
2771
+ // Make sure the task file exists
2772
+ if (!(await exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
2773
+ throw new Error(`No task file found with id "${taskId}"`);
2774
+ }
2271
2775
 
2272
- // Save the column name in the task's metadata
2273
- let taskData = await this.loadTask(taskId);
2274
- taskData = setTaskMetadata(taskData, "column", findTaskColumn(index, taskId));
2275
-
2276
- // Save the task inside the archive folder
2277
- await this.saveTask(archivedTaskPath, taskData);
2278
-
2279
- // Remove the original task
2280
- await this.deleteTask(taskId, true);
2281
-
2282
- return taskId;
2283
- },
2284
-
2285
- /**
2286
- * Restore a task from the archive
2287
- * @param {string} taskId The task id
2288
- * @param {?string} [columnName=null] The column to restore the task to
2289
- * @return {string} The task id
2290
- */
2291
- async restoreTask(taskId, columnName = null) {
2292
- // Check if this folder has been initialised
2293
- if (!(await this.initialised())) {
2294
- throw new Error("Not initialised in this folder");
2295
- }
2296
- taskId = removeFileExtension(taskId);
2776
+ // Get index and make sure the task is indexed
2777
+ let index = await this.loadIndex();
2778
+ if (!taskInIndex(index, taskId)) {
2779
+ throw new Error(`Task "${taskId}" is not in the index`);
2780
+ }
2297
2781
 
2298
- const archiveFolder = await this.getArchiveFolderPath();
2299
- const archivedTaskPath = getTaskPath(archiveFolder, taskId);
2300
- const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
2782
+ // Make sure there isn't already an archived task with the same id
2783
+ const archiveFolder = await this.getArchiveFolderPath();
2784
+ const archivedTaskPath = getTaskPath(archiveFolder, taskId);
2785
+ if (await exists(archivedTaskPath)) {
2786
+ throw new Error(`An archived task with id "${taskId}" already exists`);
2787
+ }
2301
2788
 
2302
- // Make sure the archive folder exists
2303
- if (!(await exists(archiveFolder))) {
2304
- throw new Error("Archive folder doesn't exist");
2305
- }
2789
+ // Create archive folder if it doesn't already exist
2790
+ if (!(await exists(archiveFolder))) {
2791
+ await fs.promises.mkdir(archiveFolder, { recursive: true });
2792
+ }
2306
2793
 
2307
- // Make sure the task file exists in the archive
2308
- if (!(await exists(archivedTaskPath))) {
2309
- throw new Error(`No archived task found with id "${taskId}"`);
2310
- }
2794
+ // Save the column name in the task's metadata
2795
+ let taskData = await this.loadTask(taskId);
2796
+ const taskColumn = findTaskColumn(index, taskId);
2797
+ taskData = setTaskMetadata(taskData, "column", taskColumn);
2311
2798
 
2312
- // Get index and make sure there isn't already an indexed task with the same id
2313
- let index = await this.loadIndex();
2314
- if (taskInIndex(index, taskId)) {
2315
- throw new Error(`There is already an indexed task with id "${taskId}"`);
2316
- }
2799
+ // Add history event
2800
+ taskData = appendTaskHistory(taskData, {
2801
+ type: 'archived',
2802
+ fromColumn: taskColumn
2803
+ });
2317
2804
 
2318
- // Check if there is already a task with the same id
2319
- if (await exists(taskPath)) {
2320
- throw new Error(`There is already an untracked task with id "${taskId}"`);
2321
- }
2805
+ // Save the task inside the archive folder
2806
+ await this.saveTask(archivedTaskPath, taskData);
2322
2807
 
2323
- // Make sure the index has some columns
2324
- const columns = Object.keys(index.columns);
2325
- if (columns.length === 0) {
2326
- throw new Error('No columns defined in the index');
2327
- }
2808
+ // Remove the original task
2809
+ await this.deleteTask(taskId, true);
2328
2810
 
2329
- // Load the task from the archive
2330
- let taskData = await this.loadArchivedTask(taskId);
2331
- let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
2332
- taskData = setTaskMetadata(taskData, "column", undefined);
2811
+ return taskId;
2812
+ }
2333
2813
 
2334
- // Update task metadata dates and save task
2335
- taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName);
2336
- await this.saveTask(taskPath, taskData);
2814
+ /**
2815
+ * Restore a task from the archive
2816
+ * @param {string} taskId The task id
2817
+ * @param {?string} [columnName=null] The column to restore the task to
2818
+ * @return {Promise<string>} The task id
2819
+ */
2820
+ async restoreTask(taskId, columnName = null) {
2821
+ // Check if this folder has been initialised
2822
+ if (!(await this.initialised())) {
2823
+ throw new Error("Not initialised in this folder");
2824
+ }
2825
+ taskId = removeFileExtension(taskId);
2337
2826
 
2338
- // Add the task to the column and save the index
2339
- index = addTaskToIndex(index, taskId, actualColumnName);
2340
- await this.saveIndex(index);
2827
+ const archiveFolder = await this.getArchiveFolderPath();
2828
+ const archivedTaskPath = getTaskPath(archiveFolder, taskId);
2829
+ const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
2341
2830
 
2342
- // Delete the archived task file
2343
- await fs.promises.unlink(archivedTaskPath);
2831
+ // Make sure the archive folder exists
2832
+ if (!(await exists(archiveFolder))) {
2833
+ throw new Error("Archive folder doesn't exist");
2834
+ }
2344
2835
 
2345
- return taskId;
2346
- },
2836
+ // Make sure the task file exists in the archive
2837
+ if (!(await exists(archivedTaskPath))) {
2838
+ throw new Error(`No archived task found with id "${taskId}"`);
2839
+ }
2347
2840
 
2348
- /**
2349
- * Nuke it from orbit, it's the only way to be sure
2350
- */
2351
- async removeAll() {
2352
- // Check if this folder has been initialised
2353
- if (!(await this.initialised())) {
2354
- throw new Error("Not initialised in this folder");
2355
- }
2356
- rimraf.sync(await this.getMainFolder());
2357
- },
2358
- };
2359
- })();
2841
+ // Get index and make sure there isn't already an indexed task with the same id
2842
+ let index = await this.loadIndex();
2843
+ if (taskInIndex(index, taskId)) {
2844
+ throw new Error(`There is already an indexed task with id "${taskId}"`);
2845
+ }
2846
+
2847
+ // Check if there is already a task with the same id
2848
+ if (await exists(taskPath)) {
2849
+ throw new Error(`There is already an untracked task with id "${taskId}"`);
2850
+ }
2851
+
2852
+ // Make sure the index has some columns
2853
+ const columns = Object.keys(index.columns);
2854
+ if (columns.length === 0) {
2855
+ throw new Error('No columns defined in the index');
2856
+ }
2857
+
2858
+ // Load the task from the archive
2859
+ let taskData = await this.loadArchivedTask(taskId);
2860
+ let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
2861
+ taskData = setTaskMetadata(taskData, "column", undefined);
2862
+
2863
+ // Add history event
2864
+ taskData = appendTaskHistory(taskData, {
2865
+ type: 'restored',
2866
+ toColumn: actualColumnName
2867
+ });
2868
+
2869
+ // Update task metadata dates and save task
2870
+ taskData = updateColumnLinkedCustomFields(index, taskData, actualColumnName);
2871
+ await this.saveTask(taskPath, taskData);
2872
+
2873
+ // Add the task to the column and save the index
2874
+ index = addTaskToIndex(index, taskId, actualColumnName);
2875
+ await this.saveIndex(index);
2876
+
2877
+ // Delete the archived task file
2878
+ await fs.promises.unlink(archivedTaskPath);
2879
+
2880
+ return taskId;
2881
+ }
2882
+
2883
+ /**
2884
+ * Nuke it from orbit, it's the only way to be sure
2885
+ */
2886
+ async removeAll() {
2887
+ // Check if this folder has been initialised
2888
+ if (!(await this.initialised())) {
2889
+ throw new Error("Not initialised in this folder");
2890
+ }
2891
+ rimraf.sync(await this.getMainFolder());
2892
+ }
2893
+ };
2894
+
2895
+ // Preserve backward compatibility for existing callers that expect
2896
+ // require('./main') to expose the Kanbn API directly.
2897
+ const kanbn = new Kanbn();
2898
+
2899
+ module.exports = kanbn;
2900
+ module.exports.Kanbn = Kanbn;