@basementuniverse/kanbn 2.1.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/coverage/tmp/coverage-916017-1788028598821-0.json +1 -0
  3. package/coverage/tmp/{coverage-214293-1787777184569-0.json → coverage-916018-1788028597179-0.json} +1 -1
  4. package/coverage/tmp/coverage-916036-1788028598796-0.json +1 -0
  5. package/docs/actions.md +337 -0
  6. package/docs/advanced-configuration.md +32 -0
  7. package/docs/commands/add.txt +8 -1
  8. package/docs/commands/archive.txt +6 -0
  9. package/docs/commands/board.txt +4 -0
  10. package/docs/commands/burndown.txt +1 -0
  11. package/docs/commands/comment.txt +8 -1
  12. package/docs/commands/contributors.txt +58 -0
  13. package/docs/commands/edit.txt +13 -1
  14. package/docs/commands/find.txt +28 -1
  15. package/docs/commands/gantt.txt +1 -0
  16. package/docs/commands/help.txt +1 -0
  17. package/docs/commands/history.txt +1 -0
  18. package/docs/commands/move.txt +17 -0
  19. package/docs/commands/remove.txt +10 -0
  20. package/docs/commands/restore.txt +6 -0
  21. package/docs/commands/sort.txt +18 -0
  22. package/docs/commands/task.txt +4 -0
  23. package/docs/commands/validate.txt +14 -1
  24. package/docs/contributors.md +145 -0
  25. package/docs/filtering-and-sorting.md +60 -3
  26. package/docs/index-structure.md +119 -11
  27. package/docs/index.md +3 -1
  28. package/docs/multiple-boards.md +1 -0
  29. package/docs/task-structure.md +9 -2
  30. package/example/advanced/kanbn.yml +65 -0
  31. package/package.json +1 -1
  32. package/routes/add.json +5 -1
  33. package/routes/archive.json +6 -2
  34. package/routes/comment.json +5 -1
  35. package/routes/contributors.json +18 -0
  36. package/routes/edit.json +5 -1
  37. package/routes/move.json +4 -2
  38. package/routes/remove.json +6 -2
  39. package/routes/restore.json +6 -0
  40. package/routes/sort.json +5 -0
  41. package/src/actions.js +904 -0
  42. package/src/board.js +24 -1
  43. package/src/controller/add.js +21 -10
  44. package/src/controller/archive.js +1 -0
  45. package/src/controller/board.js +13 -4
  46. package/src/controller/burndown.js +5 -2
  47. package/src/controller/comment.js +5 -2
  48. package/src/controller/contributors.js +166 -0
  49. package/src/controller/edit.js +40 -14
  50. package/src/controller/find.js +75 -4
  51. package/src/controller/gantt.js +5 -2
  52. package/src/controller/history.js +5 -2
  53. package/src/controller/move.js +84 -10
  54. package/src/controller/remove.js +39 -2
  55. package/src/controller/restore.js +1 -0
  56. package/src/controller/sort.js +40 -0
  57. package/src/controller/task.js +12 -1
  58. package/src/controller/validate.js +141 -4
  59. package/src/git-user-name.js +5 -15
  60. package/src/git-user.js +55 -0
  61. package/src/main.d.ts +204 -4
  62. package/src/main.js +1412 -38
  63. package/src/parse-index.js +205 -17
  64. package/src/utility.js +135 -1
  65. package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
@@ -112,6 +112,66 @@ function validateOptions(options) {
112
112
  required: ['name', 'type']
113
113
  }
114
114
  },
115
+ 'contributors': {
116
+ type: 'array',
117
+ items: {
118
+ oneOf: [
119
+
120
+ // A bare name, which is all most workspaces ever need
121
+ { type: 'string' },
122
+ {
123
+ type: 'object',
124
+ properties: {
125
+ 'name': { type: 'string' },
126
+ 'displayName': { type: 'string' },
127
+ 'email': { type: 'string' },
128
+ 'aliases': {
129
+ type: 'array',
130
+ items: { type: 'string' }
131
+ },
132
+ 'colour': { type: 'string' }
133
+ },
134
+ required: ['name']
135
+ }
136
+ ]
137
+ }
138
+ },
139
+ 'actionsFile': { type: 'string' },
140
+
141
+ // The rules themselves are only shape-checked here: what they mean is checked by
142
+ // actions.findRuleErrors(), which knows the event names, the verbs and this board's columns and
143
+ // can say what is wrong rather than which sub-schema failed
144
+ 'actions': {
145
+ type: 'array',
146
+ items: {
147
+ type: 'object',
148
+ properties: {
149
+ 'name': { type: 'string' },
150
+ 'on': { type: 'string' },
151
+ 'when': { type: 'object' },
152
+ 'for': {
153
+ type: 'object',
154
+ properties: {
155
+ 'related': { type: 'string' },
156
+ 'direction': {
157
+ type: 'string',
158
+ enum: [
159
+ 'incoming',
160
+ 'outgoing'
161
+ ]
162
+ },
163
+ 'where': { type: 'object' }
164
+ }
165
+ },
166
+ 'anyBoard': { type: 'boolean' },
167
+ 'then': {
168
+ type: 'array',
169
+ items: { type: 'object' }
170
+ }
171
+ },
172
+ required: ['on', 'then']
173
+ }
174
+ },
115
175
  'views': {
116
176
  type: 'array',
117
177
  items: {
@@ -171,6 +231,126 @@ function validateOptions(options) {
171
231
  }
172
232
  }
173
233
 
234
+ /**
235
+ * Get the task id from a column list item, or null if the item isn't a task link
236
+ *
237
+ * A task link is a list item that begins with a markdown link, e.g. `- [task-id](tasks/task-id.md)`.
238
+ * Anything else - a bare title, a checkbox item, a note - is content that this file preserves but
239
+ * doesn't interpret
240
+ * @param {object} item A list item token
241
+ * @return {?string} The task id, or null if this item isn't a task link
242
+ */
243
+ function taskIdFromListItem(item) {
244
+ const block = item.tokens && item.tokens[0];
245
+ const inline = block && block.tokens && block.tokens[0];
246
+ return inline && inline.type === 'link' ? inline.text : null;
247
+ }
248
+
249
+ /**
250
+ * Remove trailing whitespace from a markdown token's raw source, keeping any internal line breaks so
251
+ * that multi-line items survive a read/write cycle intact
252
+ * @param {string} raw
253
+ * @return {string}
254
+ */
255
+ function trimRaw(raw) {
256
+ return String(raw).replace(/\s+$/, '');
257
+ }
258
+
259
+ /**
260
+ * Split a column's markdown into the task ids it references and everything else it contains
261
+ *
262
+ * Every top-level token is considered, not just the first list: a single line using a different
263
+ * bullet character splits one list into several, and reading only the first one silently dropped
264
+ * every task after it. Content that isn't a task link is recorded with the position it occupied so
265
+ * that it can be written back where the user put it
266
+ * @param {string} markdown The column's markdown content
267
+ * @return {{taskIds: string[], content: object[]}}
268
+ */
269
+ function parseColumnContent(markdown) {
270
+ const taskIds = [], content = [];
271
+ if (!markdown) {
272
+ return { taskIds, content };
273
+ }
274
+ let position = 0;
275
+ for (const token of marked.lexer(markdown)) {
276
+ if (token.type === 'space') {
277
+ continue;
278
+ }
279
+ if (token.type === 'list') {
280
+ for (const item of token.items) {
281
+ const taskId = taskIdFromListItem(item);
282
+ if (taskId === null) {
283
+ content.push({ position, text: item.text.trim(), raw: trimRaw(item.raw) });
284
+ } else {
285
+ taskIds.push(taskId);
286
+ }
287
+ position++;
288
+ }
289
+ continue;
290
+ }
291
+
292
+ // A paragraph, quote, code block or anything else that isn't a list is content as well. It's
293
+ // written back surrounded by blank lines so that it doesn't merge into a neighbouring list
294
+ content.push({
295
+ position,
296
+ text: ('text' in token ? String(token.text) : trimRaw(token.raw)).trim(),
297
+ raw: trimRaw(token.raw),
298
+ block: true
299
+ });
300
+ position++;
301
+ }
302
+ return { taskIds, content };
303
+ }
304
+
305
+ /**
306
+ * Rebuild a column's markdown from its task ids and preserved content
307
+ * @param {string[]} taskIds
308
+ * @param {object[]} content
309
+ * @return {string} The column's markdown content
310
+ */
311
+ function buildColumnContent(taskIds, content) {
312
+ const lines = taskIds.map(taskId => `- [${taskId}](tasks/${taskId}.md)`);
313
+
314
+ // Positions were recorded against the column as it was read. Tasks move, so a position can end up
315
+ // past the end of the column - clamping keeps the line in the column it was written in
316
+ for (const entry of [...content].sort((a, b) => a.position - b.position)) {
317
+ lines.splice(
318
+ Math.min(entry.position, lines.length),
319
+ 0,
320
+ entry.block ? `\n${entry.raw}\n` : entry.raw
321
+ );
322
+ }
323
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
324
+ }
325
+
326
+ /**
327
+ * Validate the preserved column content object
328
+ * @param {object} columnContent
329
+ */
330
+ function validateColumnContent(columnContent) {
331
+ const result = validate(columnContent, {
332
+ type: 'object',
333
+ patternProperties: {
334
+ '^[\w ]+$': {
335
+ type: 'array',
336
+ items: {
337
+ type: 'object',
338
+ properties: {
339
+ 'position': { type: 'number' },
340
+ 'text': { type: 'string' },
341
+ 'raw': { type: 'string' },
342
+ 'block': { type: 'boolean' }
343
+ },
344
+ required: ['position', 'raw']
345
+ }
346
+ }
347
+ }
348
+ });
349
+ if (result.errors.length) {
350
+ throw new Error(result.errors.map(error => `${error.property} ${error.message}`).join('\n'));
351
+ }
352
+ }
353
+
174
354
  /**
175
355
  * Validate the columns object
176
356
  * @param {object} columns
@@ -198,7 +378,7 @@ module.exports = {
198
378
  * @return {object}
199
379
  */
200
380
  md2json(data) {
201
- let name = '', description = '', options = {}, columns = {};
381
+ let name = '', description = '', options = {}, columns = {}, columnContent = {};
202
382
  try {
203
383
 
204
384
  // Check data type
@@ -256,26 +436,32 @@ module.exports = {
256
436
 
257
437
  // Parse columns
258
438
  const columnNames = Object.keys(index).filter(column => ['raw', 'Options', name].indexOf(column) === -1);
259
- if (columnNames.length) {
260
- columns = Object.fromEntries(columnNames.map(columnName => {
261
- try {
262
- return [
263
- columnName,
264
- index[columnName].content
265
- ? marked.lexer(index[columnName].content)[0].items.map(item => item.tokens[0].tokens[0].text)
266
- : []
267
- ];
268
- } catch (error) {
269
- throw new Error(`column "${columnName}" must contain a list`);
270
- }
271
- }));
439
+ for (const columnName of columnNames) {
440
+ let parsed = null;
441
+ try {
442
+ parsed = parseColumnContent(index[columnName].content);
443
+ } catch (error) {
444
+ throw new Error(`unable to parse column "${columnName}" (${error.message})`);
445
+ }
446
+ columns[columnName] = parsed.taskIds;
447
+
448
+ // Anything in the column that isn't a task link is kept verbatim rather than rejected, so
449
+ // that a hand-written note or a mistyped link is preserved instead of being thrown away the
450
+ // next time the board is saved. Nothing else in kanbn reads it
451
+ if (parsed.content.length) {
452
+ columnContent[columnName] = parsed.content;
453
+ }
272
454
  }
273
455
  } catch (error) {
274
456
  throw new Error(`Unable to parse index: ${error.message}`);
275
457
  }
276
458
 
277
459
  // Assemble index object
278
- return { name, description, options, columns };
460
+ const result = { name, description, options, columns };
461
+ if (Object.keys(columnContent).length) {
462
+ result.columnContent = columnContent;
463
+ }
464
+ return result;
279
465
  },
280
466
 
281
467
  /**
@@ -323,11 +509,13 @@ module.exports = {
323
509
  }
324
510
  validateColumns(data.columns);
325
511
 
326
- // Add columns
512
+ // Add columns, putting any preserved non-task content back where it was found
513
+ const columnContent = 'columnContent' in data && data.columnContent !== null ? data.columnContent : {};
514
+ validateColumnContent(columnContent);
327
515
  for (let column in data.columns) {
328
516
  result.push(
329
517
  `## ${column}`,
330
- data.columns[column].map(task => `- [${task}](tasks/${task}.md)`).join('\n')
518
+ buildColumnContent(data.columns[column], column in columnContent ? columnContent[column] : [])
331
519
  );
332
520
  }
333
521
  } catch (error) {
package/src/utility.js CHANGED
@@ -1,3 +1,5 @@
1
+ const fuzzy = require('fuzzy');
2
+
1
3
  module.exports = (() => {
2
4
  const tags = {
3
5
  b: 'bold',
@@ -38,6 +40,12 @@ module.exports = (() => {
38
40
  return null;
39
41
  }
40
42
  const kanbn = await workspace.boardFromArgs(args);
43
+
44
+ // Actions are declarative rules with no code execution, so this is a way around a misbehaving
45
+ // rule rather than a safety control
46
+ if (args.actions === false || args['no-actions'] === true) {
47
+ kanbn.actionsEnabled = false;
48
+ }
41
49
  if (!await kanbn.workspaceInitialised()) {
42
50
  this.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
43
51
  return null;
@@ -75,7 +83,7 @@ module.exports = (() => {
75
83
 
76
84
  const targets = [];
77
85
  for (let i = 0; i < slugs.length; i++) {
78
- const kanbn = await this.resolveBoard(workspace, { board: slugs[i] });
86
+ const kanbn = await this.resolveBoard(workspace, { board: slugs[i], actions: args.actions });
79
87
  if (kanbn === null) {
80
88
  return null;
81
89
  }
@@ -148,6 +156,76 @@ module.exports = (() => {
148
156
  console.log(this.replaceTags(`\n{d}${boards.length} boards available -{d} {b}kanbn boards{b}`));
149
157
  },
150
158
 
159
+ /**
160
+ * Resolve a string to exactly one simple task on a board, for the commands that accept one.
161
+ * Real tasks always win, so this should only be called once no task has matched
162
+ * @param {object} kanbn The board-scoped Kanbn instance
163
+ * @param {string} input The title to match
164
+ * @param {Error|string} taskError The error to report if nothing matches, i.e. the error the
165
+ * command would have printed before simple tasks existed
166
+ * @param {?object[]} [matches=null] Matches already found, to save looking them up again
167
+ * @return {Promise<?object>} The matching simple task, or null if the error has been reported
168
+ */
169
+ async resolveSimpleTask(kanbn, input, taskError, matches = null) {
170
+ if (matches === null) {
171
+ try {
172
+ matches = await kanbn.findSimpleTasks(input);
173
+ } catch (error) {
174
+ this.error(error);
175
+ return null;
176
+ }
177
+ }
178
+ if (!matches.length) {
179
+ this.error(taskError);
180
+ return null;
181
+ }
182
+
183
+ // Two lines with the same title are two different lines. Picking one of them silently would
184
+ // eventually move or delete the wrong one
185
+ if (matches.length > 1) {
186
+ this.error(
187
+ `"${input}" matches ${matches.length} simple tasks:\n` +
188
+ matches.map(match => ` "${match.text}" in column "${match.column}"`).join('\n') +
189
+ '\nEdit the board file directly, or rename one of them'
190
+ );
191
+ return null;
192
+ }
193
+ return matches[0];
194
+ },
195
+
196
+ /**
197
+ * Report rules that were skipped during an operation
198
+ *
199
+ * A rule that quietly stops working is worse than no rule, so these go to stderr and aren't
200
+ * suppressed by verbose - the operation itself succeeded, which is why they aren't errors
201
+ * @param {object} kanbn The board-scoped Kanbn instance the operation ran on
202
+ */
203
+ showActionWarnings(kanbn) {
204
+ const warnings = (kanbn && kanbn.lastActionWarnings) || [];
205
+ if (!warnings.length) {
206
+ return;
207
+ }
208
+ for (const warning of warnings) {
209
+ console.error(this.replaceTags(`{d}action:{d} ${warning}`));
210
+ }
211
+ },
212
+
213
+ /**
214
+ * Show a hint about tasks that a board references but which have no task file. These are skipped
215
+ * rather than fatal, so the hint is the only sign that the board is showing less than it lists
216
+ * @param {object[]} missing Missing task entries from kanbn.findMissingTaskFiles()
217
+ * @param {boolean} show False to suppress the hint
218
+ */
219
+ showMissingTaskFilesHint(missing, show = true) {
220
+ if (!show || !missing.length) {
221
+ return;
222
+ }
223
+ const summary =
224
+ `${missing.length} ${missing.length === 1 ? 'task has' : 'tasks have'} no task file and ` +
225
+ `${missing.length === 1 ? 'was' : 'were'} skipped`;
226
+ console.log(this.replaceTags(`\n{d}${summary} - run{d} {b}kanbn validate{b} {d}for details{d}`));
227
+ },
228
+
151
229
  /**
152
230
  * Show a hint about tasks whose dates don't match the column they're in. Hints are suppressed
153
231
  * for machine-readable output and when the board sets verbose to false
@@ -168,6 +246,62 @@ module.exports = (() => {
168
246
  ));
169
247
  },
170
248
 
249
+ /**
250
+ * Build the prompt definition for a task's assigned user
251
+ *
252
+ * With contributors configured this is an autocomplete over the list, which is where the typo
253
+ * mitigation comes from; without them it's the free-text input it has always been. Free text is
254
+ * accepted either way - contributors are advisory, never an enum, so a name that isn't in the
255
+ * list is still a perfectly good answer
256
+ * @param {object} options The prompt properties to merge in, i.e. name, message, default, when
257
+ * @param {object[]} contributors The workspace's normalised contributors
258
+ * @return {object} A prompt definition
259
+ */
260
+ assignedPrompt(options, contributors) {
261
+ if (!contributors.length) {
262
+ return { type: 'input', ...options };
263
+ }
264
+
265
+ // Suggestions are the canonical names, because that's the value written into the task file
266
+ const names = contributors.map(contributor => contributor.name);
267
+ return {
268
+ type: 'autocomplete',
269
+ suggestOnly: true,
270
+ source: (answers, input) => Promise.resolve(
271
+ fuzzy.filter(input || '', names).map(result => result.string)
272
+ ),
273
+ ...options
274
+ };
275
+ },
276
+
277
+ /**
278
+ * Expand "@me" in a filter value to the current user
279
+ *
280
+ * The substitution is kept here, at the argument-parsing boundary: "@me" means something when
281
+ * filtering and nothing at all when setting a value, and a literal "@me" in a task file is
282
+ * nonsense nobody needs supported
283
+ * @param {object} kanbn The board-scoped Kanbn instance
284
+ * @param {string|string[]} value The filter value from the arguments
285
+ * @return {Promise<string|string[]|null>} The expanded value, or null if "@me" can't be resolved
286
+ */
287
+ async expandCurrentUser(kanbn, value) {
288
+ const isMe = v => typeof v === 'string' && v.trim() === '@me';
289
+ const values = this.arrayArg(value);
290
+ if (!values.some(isMe)) {
291
+ return value;
292
+ }
293
+ const currentUser = await kanbn.currentUser();
294
+ if (!currentUser) {
295
+ this.error(
296
+ "Unable to work out who you are, so {b}@me{b} can't be resolved\n" +
297
+ 'Set {b}KANBN_USER{b}, or set a git username with {b}git config user.name "your name"{b}'
298
+ );
299
+ return null;
300
+ }
301
+ const expanded = values.map(v => (isMe(v) ? currentUser : v));
302
+ return Array.isArray(value) ? expanded : expanded[0];
303
+ },
304
+
171
305
  /**
172
306
  * Convert a string to simplified paramcase, e.g:
173
307
  * PascalCase -> pascalcase