@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.
- package/docs/commands/burndown.txt +7 -1
- package/docs/commands/gantt.txt +36 -0
- package/docs/commands/help.txt +1 -0
- package/docs/index-structure.md +20 -1
- package/docs/quick-start.md +1 -1
- package/docs/task-structure.md +47 -0
- package/example/.kanbn/index.md +63 -0
- package/example/.kanbn/tasks/add-basic-activity-feed.md +53 -0
- package/example/.kanbn/tasks/add-passwordless-login-option.md +34 -0
- package/example/.kanbn/tasks/add-usage-alert-email-thresholds.md +35 -0
- package/example/.kanbn/tasks/build-email-template-system.md +30 -0
- package/example/.kanbn/tasks/build-invoice-download-endpoint.md +43 -0
- package/example/.kanbn/tasks/build-tenant-settings-page.md +48 -0
- package/example/.kanbn/tasks/create-organization-switcher.md +53 -0
- package/example/.kanbn/tasks/create-sandbox-environment-provisioner.md +36 -0
- package/example/.kanbn/tasks/create-self-serve-cancellation-flow.md +38 -0
- package/example/.kanbn/tasks/define-product-pricing-strategy.md +37 -0
- package/example/.kanbn/tasks/design-onboarding-checklist.md +30 -0
- package/example/.kanbn/tasks/design-team-invite-expiry-flow.md +33 -0
- package/example/.kanbn/tasks/implement-data-retention-policy-jobs.md +30 -0
- package/example/.kanbn/tasks/implement-feature-flags-foundation.md +36 -0
- package/example/.kanbn/tasks/implement-project-creation-wizard.md +44 -0
- package/example/.kanbn/tasks/implement-stripe-webhook-signature-check.md +54 -0
- package/example/.kanbn/tasks/implement-team-permissions-ui.md +57 -0
- package/example/.kanbn/tasks/implement-user-signup-and-login.md +62 -0
- package/example/.kanbn/tasks/integrate-crm-lead-sync.md +35 -0
- package/example/.kanbn/tasks/legal-review-terms-and-privacy.md +30 -0
- package/example/.kanbn/tasks/migrate-legacy-events-to-new-schema.md +48 -0
- package/example/.kanbn/tasks/optimize-dashboard-first-load.md +52 -0
- package/example/.kanbn/tasks/prototype-report-export-scheduler.md +34 -0
- package/example/.kanbn/tasks/publish-internal-qa-checklist.md +53 -0
- package/example/.kanbn/tasks/setup-ci-pipeline.md +53 -0
- package/package.json +3 -2
- package/routes/gantt.json +27 -0
- package/skills/kanbn-plan/SKILL.md +0 -0
- package/src/board.js +21 -2
- package/src/controller/add.js +33 -1
- package/src/controller/burndown.js +72 -5
- package/src/controller/edit.js +68 -1
- package/src/controller/find.js +5 -0
- package/src/controller/gantt.js +375 -0
- package/src/controller/init.js +2 -1
- package/src/controller/sort.js +8 -0
- package/src/main.d.ts +295 -340
- package/src/main.js +2591 -2050
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
90
|
-
* @
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
*
|
|
100
|
-
* @param {string} taskId The task id
|
|
101
|
-
* @return {string} The task id with .md extension
|
|
1280
|
+
* Clear cached config
|
|
102
1281
|
*/
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return `${taskId}.md`;
|
|
106
|
-
}
|
|
107
|
-
return taskId;
|
|
1282
|
+
clearConfigCache() {
|
|
1283
|
+
this.configMemo = null;
|
|
108
1284
|
}
|
|
109
1285
|
|
|
110
1286
|
/**
|
|
111
|
-
*
|
|
112
|
-
* @
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1290
|
+
async getFolderName() {
|
|
1291
|
+
const config = await this.getConfig();
|
|
1292
|
+
if (config !== null && 'mainFolder' in config) {
|
|
1293
|
+
return config.mainFolder;
|
|
118
1294
|
}
|
|
119
|
-
return
|
|
1295
|
+
return DEFAULT_FOLDER_NAME;
|
|
120
1296
|
}
|
|
121
1297
|
|
|
122
1298
|
/**
|
|
123
|
-
* Get
|
|
124
|
-
* @
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
140
|
-
* @
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
1319
|
+
return DEFAULT_TASKS_FOLDER_NAME;
|
|
151
1320
|
}
|
|
152
1321
|
|
|
153
1322
|
/**
|
|
154
|
-
*
|
|
155
|
-
* @
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
1331
|
+
return DEFAULT_ARCHIVE_FOLDER_NAME;
|
|
166
1332
|
}
|
|
167
1333
|
|
|
168
1334
|
/**
|
|
169
|
-
*
|
|
170
|
-
* @
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
*
|
|
187
|
-
* @
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
*
|
|
200
|
-
* @
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
214
|
-
*
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
*
|
|
228
|
-
* @
|
|
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
|
-
|
|
234
|
-
if
|
|
235
|
-
|
|
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
|
-
|
|
1375
|
+
|
|
1376
|
+
return this.loadIndex();
|
|
243
1377
|
}
|
|
244
1378
|
|
|
245
1379
|
/**
|
|
246
|
-
*
|
|
247
|
-
* @param {
|
|
248
|
-
* @
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
*
|
|
261
|
-
* @param {
|
|
262
|
-
* @param {
|
|
263
|
-
* @
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
*
|
|
300
|
-
* @param {
|
|
301
|
-
* @param {
|
|
302
|
-
* @
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
*
|
|
326
|
-
* @param {
|
|
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
|
-
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
*
|
|
352
|
-
* @
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
1501
|
+
return index;
|
|
366
1502
|
}
|
|
367
1503
|
|
|
368
1504
|
/**
|
|
369
|
-
*
|
|
370
|
-
* @param {
|
|
371
|
-
* @param {object
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
*
|
|
559
|
-
* @param {string
|
|
560
|
-
* @
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
1526
|
+
return parseTask.md2json(taskData);
|
|
568
1527
|
}
|
|
569
1528
|
|
|
570
1529
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* @param {
|
|
574
|
-
* @
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
583
|
-
const latest = Math.max(...dates);
|
|
584
|
-
return input >= earliest && input <= latest;
|
|
1541
|
+
return result;
|
|
585
1542
|
}
|
|
586
1543
|
|
|
587
1544
|
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* @
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
*
|
|
1561
|
+
* Get the date format defined in the index, or the default date format
|
|
601
1562
|
* @param {object} index The index object
|
|
602
|
-
* @
|
|
603
|
-
* @return {number} The task workload
|
|
1563
|
+
* @return {string} The date format
|
|
604
1564
|
*/
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
628
|
-
* @param {object} index
|
|
629
|
-
* @
|
|
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
|
-
|
|
633
|
-
|
|
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
|
-
*
|
|
641
|
-
* @
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
*
|
|
666
|
-
* @param {object[]
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
*
|
|
728
|
-
* @param {
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
*
|
|
751
|
-
*
|
|
752
|
-
* @
|
|
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
|
-
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
//
|
|
763
|
-
if (
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
if (
|
|
794
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
},
|
|
1724
|
+
// Set the created date
|
|
1725
|
+
taskData = setTaskMetadata(taskData, "created", new Date());
|
|
1039
1726
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1774
|
+
// Load task data
|
|
1775
|
+
let taskData = await this.loadTask(taskId);
|
|
1776
|
+
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
1212
1777
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1822
|
+
// Return the set difference
|
|
1823
|
+
return new Set([...untrackedTasks].filter((x) => !trackedTasks.has(x)));
|
|
1824
|
+
}
|
|
1280
1825
|
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
//
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
}
|
|
1861
|
+
// Re-load the index
|
|
1862
|
+
index = await this.loadIndex();
|
|
1863
|
+
}
|
|
1322
1864
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
1940
|
+
// Rename the task file
|
|
1941
|
+
await fs.promises.rename(getTaskPath(await this.getTaskFolderPath(), taskId), newTaskPath);
|
|
1421
1942
|
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1461
|
-
|
|
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
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
taskData
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1996
|
+
// Update task metadata dates
|
|
1997
|
+
taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
|
|
1998
|
+
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
|
|
1480
1999
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
2006
|
+
position = Math.max(Math.min(position, index.columns[currentColumnName].length), 0);
|
|
2007
|
+
}
|
|
1501
2008
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2083
|
+
// Get index and column names
|
|
2084
|
+
const index = await this.loadIndex();
|
|
2085
|
+
const columnNames = Object.keys(index.columns);
|
|
1613
2086
|
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2087
|
+
// Prepare output
|
|
2088
|
+
const result = {
|
|
2089
|
+
name: index.name,
|
|
2090
|
+
};
|
|
1618
2091
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
2092
|
+
// Get un-tracked tasks if required
|
|
2093
|
+
if (untracked) {
|
|
2094
|
+
result.untrackedTasks = [...(await this.findUntrackedTasks())].map((taskId) => `${taskId}.md`);
|
|
1622
2095
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
(
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
} else
|
|
1742
|
-
sprintIndex =
|
|
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
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
-
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
errors.push({
|
|
2325
|
+
task: null,
|
|
2326
|
+
errors: error.message,
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
1840
2329
|
|
|
1841
|
-
|
|
1842
|
-
|
|
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
|
-
|
|
2339
|
+
const task = await this.loadTask(taskId);
|
|
1845
2340
|
|
|
1846
|
-
// Re-save
|
|
2341
|
+
// Re-save tasks if required
|
|
1847
2342
|
if (save) {
|
|
1848
|
-
await this.
|
|
2343
|
+
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), task);
|
|
1849
2344
|
}
|
|
1850
2345
|
} catch (error) {
|
|
1851
2346
|
errors.push({
|
|
1852
|
-
task:
|
|
2347
|
+
task: taskId,
|
|
1853
2348
|
errors: error.message,
|
|
1854
2349
|
});
|
|
1855
2350
|
}
|
|
2351
|
+
}
|
|
1856
2352
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
//
|
|
1900
|
-
|
|
1901
|
-
if (
|
|
1902
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
sprint.name = name;
|
|
1951
|
-
}
|
|
2426
|
+
// Add description if one exists
|
|
2427
|
+
if (description) {
|
|
2428
|
+
sprint.description = description;
|
|
2429
|
+
}
|
|
1952
2430
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
-
//
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
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
|
-
//
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
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
|
-
|
|
2117
|
-
|
|
2675
|
+
// Filter by columns
|
|
2676
|
+
if (columns !== null && !columns.includes(task.column)) {
|
|
2677
|
+
return false;
|
|
2118
2678
|
}
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
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
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
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
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
}
|
|
2733
|
+
// Save the task
|
|
2734
|
+
await this.saveTask(taskPath, taskData);
|
|
2735
|
+
return taskId;
|
|
2736
|
+
}
|
|
2231
2737
|
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
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
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2799
|
+
// Add history event
|
|
2800
|
+
taskData = appendTaskHistory(taskData, {
|
|
2801
|
+
type: 'archived',
|
|
2802
|
+
fromColumn: taskColumn
|
|
2803
|
+
});
|
|
2317
2804
|
|
|
2318
|
-
|
|
2319
|
-
|
|
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
|
-
|
|
2324
|
-
|
|
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
|
-
|
|
2330
|
-
|
|
2331
|
-
let actualColumnName = columnName || getTaskMetadata(taskData, "column") || columns[0];
|
|
2332
|
-
taskData = setTaskMetadata(taskData, "column", undefined);
|
|
2811
|
+
return taskId;
|
|
2812
|
+
}
|
|
2333
2813
|
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
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
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2827
|
+
const archiveFolder = await this.getArchiveFolderPath();
|
|
2828
|
+
const archivedTaskPath = getTaskPath(archiveFolder, taskId);
|
|
2829
|
+
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
|
|
2341
2830
|
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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;
|