@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
package/src/board.js CHANGED
@@ -55,6 +55,23 @@ module.exports = (() => {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Render the simple tasks in a column - lines in the board file that aren't task links. They have
60
+ * no fields to interpolate and take no part in filtering or sorting, so they're shown dimmed at
61
+ * the end of the column, and only in the default layout where the columns on screen are the
62
+ * columns in the file
63
+ * @param {object} index
64
+ * @param {string} columnName
65
+ * @return {string[]} The rendered simple tasks
66
+ */
67
+ function getSimpleTaskStrings(index, columnName) {
68
+ return ((index.columnContent || {})[columnName] || [])
69
+ .filter(entry => !entry.block && !!entry.text && entry.text.indexOf('\n') === -1)
70
+
71
+ // A caret starts a markup sequence in terminal-kit, so any in the user's own text are escaped
72
+ .map(entry => `^-${entry.text.replace(/\^/g, '^^')}^:`);
73
+ }
74
+
58
75
  /**
59
76
  * Get a column heading with icons
60
77
  * @param {object} index
@@ -166,7 +183,13 @@ module.exports = (() => {
166
183
  table.push(board.headings.map(heading => heading.heading));
167
184
  board.lanes.forEach(lane => table.push(
168
185
  [lane.name],
169
- lane.columns.map(column => column.map(task => getTaskString(index, task)).join(TASK_SEPARATOR))
186
+ lane.columns.map((column, i) => {
187
+ const cell = column.map(task => getTaskString(index, task));
188
+ if (view === null) {
189
+ cell.push(...getSimpleTaskStrings(index, board.headings[i].name));
190
+ }
191
+ return cell.join(TASK_SEPARATOR);
192
+ })
170
193
  ));
171
194
 
172
195
  // Display as a table
@@ -3,7 +3,6 @@ const utility = require('../utility');
3
3
  const inquirer = require('inquirer');
4
4
  const fuzzy = require('fuzzy');
5
5
  const chrono = require('chrono-node');
6
- const getGitUsername = require('../git-user-name');
7
6
 
8
7
  // The boards this command targets - set from --board before anything else runs
9
8
  let kanbn = workspace;
@@ -19,9 +18,11 @@ inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'))
19
18
  * @param {string[]} taskIds
20
19
  * @param {string} columnName
21
20
  * @param {string[]} columnNames
21
+ * @param {?string} currentUser
22
+ * @param {object[]} contributors
22
23
  * @return {Promise<any>}
23
24
  */
24
- async function interactiveCreateTask(taskData, taskIds, columnName, columnNames) {
25
+ async function interactiveCreateTask(taskData, taskIds, columnName, columnNames, currentUser, contributors) {
25
26
  const dueDateExists = (
26
27
  'metadata' in taskData &&
27
28
  'due' in taskData.metadata &&
@@ -127,13 +128,12 @@ async function interactiveCreateTask(taskData, taskIds, columnName, columnNames)
127
128
  default: false,
128
129
  when: answers => !assignedExists
129
130
  },
130
- {
131
- type: 'input',
131
+ utility.assignedPrompt({
132
132
  name: 'assigned',
133
133
  message: 'Assigned to:',
134
- default: assignedExists ? taskData.metadata.assigned : getGitUsername(),
134
+ default: assignedExists ? taskData.metadata.assigned : (currentUser || ''),
135
135
  when: answers => answers.setAssigned || assignedExists
136
- },
136
+ }, contributors),
137
137
  {
138
138
  type: 'recursive',
139
139
  name: 'subTasks',
@@ -250,9 +250,11 @@ async function createTask(taskData, columnName) {
250
250
  `Created task "${taskId}" in column "${columnName}"` +
251
251
  (boardTargets.length > 1 || !first.main ? ` on board "${first.slug}"` : '')
252
252
  );
253
+ utility.showActionWarnings(first.kanbn);
253
254
  for (const target of rest) {
254
255
  await target.kanbn.addTaskToBoard(taskId, target.columnName);
255
256
  console.log(`Added task "${taskId}" to column "${target.columnName}" on board "${target.slug}"`);
257
+ utility.showActionWarnings(target.kanbn);
256
258
  }
257
259
  } catch (error) {
258
260
  utility.error(error);
@@ -400,10 +402,12 @@ module.exports = async args => {
400
402
 
401
403
  // Assigned
402
404
  if (args.assigned) {
403
- const gitUsername = getGitUsername();
404
405
  if (args.assigned === true) {
405
- if (gitUsername) {
406
- taskData.metadata.assigned = gitUsername;
406
+
407
+ // A bare --assigned means "me", canonicalised against the contributors list if there is one
408
+ const currentUser = await kanbn.currentUser();
409
+ if (currentUser) {
410
+ taskData.metadata.assigned = currentUser;
407
411
  }
408
412
  } else {
409
413
  taskData.metadata.assigned = utility.strArg(args.assigned);
@@ -508,7 +512,14 @@ module.exports = async args => {
508
512
 
509
513
  // Create task interactively
510
514
  if (args.interactive) {
511
- interactiveCreateTask(taskData, taskIds, columnName, columnNames)
515
+ interactiveCreateTask(
516
+ taskData,
517
+ taskIds,
518
+ columnName,
519
+ columnNames,
520
+ await kanbn.currentUser(),
521
+ await kanbn.getContributors()
522
+ )
512
523
  .then(answers => {
513
524
  taskData.name = answers.name;
514
525
  if ('description' in answers) {
@@ -13,6 +13,7 @@ function archiveTask(taskId) {
13
13
  .archiveTask(taskId)
14
14
  .then(taskId => {
15
15
  console.log(`Archived task "${taskId}"`);
16
+ utility.showActionWarnings(kanbn);
16
17
  })
17
18
  .catch(error => {
18
19
  utility.error(error);
@@ -24,16 +24,25 @@ module.exports = async args => {
24
24
  return;
25
25
  }
26
26
 
27
- // Load and hydrate all tracked tasks
28
- const tasks = (await kanbn.loadAllTrackedTasks(index)).map(
29
- task => kanbn.hydrateTask(index, task)
30
- );
27
+ // Load and hydrate all tracked tasks. A task the board references but which has no task file is
28
+ // skipped rather than fatal, and reported by the hint below
29
+ let tasks, missingTaskFiles;
30
+ try {
31
+ tasks = (await kanbn.loadAllTrackedTasks(index)).map(
32
+ task => kanbn.hydrateTask(index, task)
33
+ );
34
+ missingTaskFiles = await kanbn.findMissingTaskFiles(index);
35
+ } catch (error) {
36
+ utility.error(error);
37
+ return;
38
+ }
31
39
 
32
40
  // Show the board
33
41
  board
34
42
  .show(index, tasks, args.view, args.json)
35
43
  .then(async () => {
36
44
  const verbose = !args.json && kanbn.isVerbose(index);
45
+ utility.showMissingTaskFilesHint(missingTaskFiles, verbose);
37
46
  utility.showDateDriftHint(kanbn.getDateDrift(index, tasks), verbose);
38
47
  utility.showBoardCountHint(await workspace.listBoards(), verbose);
39
48
  })
@@ -98,10 +98,13 @@ module.exports = async args => {
98
98
  }
99
99
  }
100
100
 
101
- // Get assigned
101
+ // Get assigned, expanding "@me" to the current user
102
102
  let assigned = null;
103
103
  if (args.assigned) {
104
- assigned = utility.strArg(args.assigned);
104
+ assigned = await utility.expandCurrentUser(kanbn, utility.strArg(args.assigned));
105
+ if (assigned === null) {
106
+ return;
107
+ }
105
108
  }
106
109
 
107
110
  // Get columns
@@ -1,7 +1,6 @@
1
1
  const workspace = require('../main');
2
2
  const utility = require('../utility');
3
3
  const inquirer = require('inquirer');
4
- const getGitUsername = require('../git-user-name');
5
4
 
6
5
  // The board this command targets - set from --board before anything else runs
7
6
  let kanbn = workspace;
@@ -46,6 +45,7 @@ function addComment(taskId, text, author) {
46
45
  .comment(taskId, text, author)
47
46
  .then(taskId => {
48
47
  console.log(`Added comment to task "${taskId}"`);
48
+ utility.showActionWarnings(kanbn);
49
49
  })
50
50
  .catch(error => {
51
51
  utility.error(error);
@@ -88,7 +88,10 @@ module.exports = async args => {
88
88
  if (args.author && typeof args.author === 'string') {
89
89
  commentAuthor = utility.strArg(args.author);
90
90
  } else {
91
- commentAuthor = getGitUsername();
91
+
92
+ // Nobody at all is a legitimate answer on a machine with no git identity, and an empty author is
93
+ // what a comment without one looks like
94
+ commentAuthor = (await kanbn.currentUser()) || '';
92
95
  }
93
96
 
94
97
  // Add comment interactively
@@ -0,0 +1,166 @@
1
+ const workspace = require('../main');
2
+ const utility = require('../utility');
3
+
4
+ /**
5
+ * Show the workspace's contributors
6
+ * @param {object[]} contributors
7
+ * @param {?string} currentUser
8
+ * @param {boolean} json
9
+ */
10
+ function showContributors(contributors, currentUser, json) {
11
+ if (json) {
12
+ console.log(JSON.stringify({ contributors, currentUser }, null, 2));
13
+ return;
14
+ }
15
+ if (!contributors.length) {
16
+ console.log('No contributors are declared in this workspace');
17
+ console.log(utility.replaceTags(
18
+ `\n{d}Add a {d}{b}contributors{b}{d} list to your config file or the main board's front matter -{d} ` +
19
+ `{b}kanbn contributors --help{b}`
20
+ ));
21
+ showCurrentUser(currentUser);
22
+ return;
23
+ }
24
+ console.log(`${contributors.length} ${contributors.length === 1 ? 'contributor' : 'contributors'}:`);
25
+ for (const contributor of contributors) {
26
+ console.log(utility.replaceTags(
27
+ `\n{b}${contributor.name}{b}` +
28
+ (contributor.name === currentUser ? ' (you)' : '')
29
+ ));
30
+ if (contributor.displayName !== contributor.name) {
31
+ console.log(` ${contributor.displayName}`);
32
+ }
33
+ if (contributor.email) {
34
+ console.log(` ${contributor.email}`);
35
+ }
36
+ if (contributor.aliases.length) {
37
+ console.log(` also known as ${contributor.aliases.map(alias => `"${alias}"`).join(', ')}`);
38
+ }
39
+ if (contributor.colour) {
40
+ console.log(` ${contributor.colour}`);
41
+ }
42
+ }
43
+ showCurrentUser(currentUser, contributors);
44
+ }
45
+
46
+ /**
47
+ * Say who kanbn thinks the current user is, since that's what a bare --assigned and "@me" resolve to
48
+ * @param {?string} currentUser
49
+ * @param {object[]} [contributors=[]]
50
+ */
51
+ function showCurrentUser(currentUser, contributors = []) {
52
+ if (currentUser === null) {
53
+ console.log(utility.replaceTags(
54
+ `\n{d}Kanbn can't work out who you are. Set {d}{b}KANBN_USER{b}{d}, or set a git username with{d} ` +
55
+ `{b}git config user.name "your name"{b}`
56
+ ));
57
+ return;
58
+ }
59
+ if (contributors.length && !contributors.some(contributor => contributor.name === currentUser)) {
60
+ console.log(utility.replaceTags(
61
+ `\n{d}You are{d} {b}${currentUser}{b}{d}, which isn't in the contributors list{d}`
62
+ ));
63
+ return;
64
+ }
65
+ console.log(utility.replaceTags(`\n{d}You are{d} {b}${currentUser}{b}`));
66
+ }
67
+
68
+ /**
69
+ * Show how contributors are used across the workspace's tasks, and which names are in use that the
70
+ * contributors list doesn't know about
71
+ * @param {object} usage
72
+ * @param {?string} currentUser
73
+ * @param {boolean} json
74
+ */
75
+ function showUsage(usage, currentUser, json) {
76
+ if (json) {
77
+ console.log(JSON.stringify({ ...usage, currentUser }, null, 2));
78
+ return;
79
+ }
80
+ if (usage.contributors.length) {
81
+ console.log(`${usage.contributors.length} ${usage.contributors.length === 1 ? 'contributor' : 'contributors'}:`);
82
+ for (const contributor of usage.contributors) {
83
+ console.log(utility.replaceTags(
84
+ `\n{b}${contributor.name}{b}` +
85
+ (contributor.name === currentUser ? ' (you)' : '')
86
+ ));
87
+ console.log(
88
+ ` ${contributor.tasks} ${contributor.tasks === 1 ? 'task' : 'tasks'}, ` +
89
+ `assigned ${contributor.assigned}, ` +
90
+ `${contributor.comments} ${contributor.comments === 1 ? 'comment' : 'comments'}`
91
+ );
92
+
93
+ // A contributor matched through an alias or a different case is worth showing: it's a spelling
94
+ // that works today only because the alias is there to catch it
95
+ for (const spelling of contributor.spellings.filter(s => s.value !== contributor.name)) {
96
+ console.log(` written as "${spelling.value}" in ${spelling.tasks.length} ${spelling.tasks.length === 1 ? 'task' : 'tasks'}`);
97
+ }
98
+ }
99
+ } else {
100
+ console.log('No contributors are declared in this workspace');
101
+ }
102
+
103
+ if (!usage.unknown.length) {
104
+ console.log(
105
+ usage.contributors.length
106
+ ? '\nEvery name used in a task matches a contributor'
107
+ : '\nNo names are used in any task'
108
+ );
109
+ return;
110
+ }
111
+ console.log(
112
+ `\n${usage.unknown.length} ${usage.unknown.length === 1 ? 'name is' : 'names are'} used in tasks ` +
113
+ `but ${usage.unknown.length === 1 ? "isn't" : "aren't"} a known contributor:`
114
+ );
115
+ for (const unknown of usage.unknown) {
116
+ console.log(utility.replaceTags(
117
+ `\n{b}${unknown.value}{b}` +
118
+ (unknown.value === currentUser ? ' (you)' : '')
119
+ ));
120
+ console.log(
121
+ ` ${unknown.tasks.length} ${unknown.tasks.length === 1 ? 'task' : 'tasks'}, ` +
122
+ `assigned ${unknown.assigned}, ` +
123
+ `${unknown.comments} ${unknown.comments === 1 ? 'comment' : 'comments'}`
124
+ );
125
+ console.log(` ${unknown.tasks.join(', ')}`);
126
+ }
127
+ console.log(utility.replaceTags(
128
+ `\n{d}Add these to {d}{b}contributors{b}{d}, or as an {d}{b}alias{b}{d} of a contributor already ` +
129
+ `in the list. Nothing is rewritten either way - {d}{b}assigned{b}{d} and comment {d}{b}author{b}` +
130
+ `{d} are free text{d}`
131
+ ));
132
+ }
133
+
134
+ module.exports = async args => {
135
+
136
+ // Contributors are workspace-scoped, so this command isn't board-scoped either
137
+ if (!await workspace.workspaceInitialised()) {
138
+ utility.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
139
+ return;
140
+ }
141
+
142
+ let currentUser;
143
+ try {
144
+ currentUser = await workspace.currentUser();
145
+ } catch (error) {
146
+ utility.error(error);
147
+ return;
148
+ }
149
+
150
+ // Show how the contributors are actually used across the workspace's tasks
151
+ if (args.usage) {
152
+ try {
153
+ showUsage(await workspace.getContributorUsage(), currentUser, args.json);
154
+ } catch (error) {
155
+ utility.error(error);
156
+ }
157
+ return;
158
+ }
159
+
160
+ // List contributors
161
+ try {
162
+ showContributors(await workspace.getContributors(), currentUser, args.json);
163
+ } catch (error) {
164
+ utility.error(error);
165
+ }
166
+ };
@@ -3,7 +3,6 @@ const utility = require('../utility');
3
3
  const inquirer = require('inquirer');
4
4
  const fuzzy = require('fuzzy');
5
5
  const chrono = require('chrono-node');
6
- const getGitUsername = require('../git-user-name');
7
6
 
8
7
  // The board this command targets - set from --board before anything else runs
9
8
  let kanbn = workspace;
@@ -18,9 +17,11 @@ inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'))
18
17
  * @param {string[]} taskIds
19
18
  * @param {string} columnName
20
19
  * @param {string[]} columnNames
20
+ * @param {?string} currentUser
21
+ * @param {object[]} contributors
21
22
  * @return {Promise<any>}
22
23
  */
23
- async function interactive(taskData, taskIds, columnName, columnNames) {
24
+ async function interactive(taskData, taskIds, columnName, columnNames, currentUser, contributors) {
24
25
  const dueDateExists = (
25
26
  'metadata' in taskData &&
26
27
  'due' in taskData.metadata &&
@@ -226,13 +227,12 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
226
227
  default: false,
227
228
  when: answers => !assignedExists
228
229
  },
229
- {
230
- type: 'input',
230
+ utility.assignedPrompt({
231
231
  name: 'assigned',
232
232
  message: 'Assigned to:',
233
- default: assignedExists ? taskData.metadata.assigned : getGitUsername(),
233
+ default: assignedExists ? taskData.metadata.assigned : (currentUser || ''),
234
234
  when: answers => answers.setAssigned || answers.editAssigned === 'edit'
235
- },
235
+ }, contributors),
236
236
  {
237
237
  type: 'recursive',
238
238
  name: 'addSubTasks',
@@ -450,6 +450,7 @@ function updateTask(taskId, taskData, columnName, unsetFields = []) {
450
450
  .updateTask(taskId, taskData, columnName, unsetFields)
451
451
  .then(taskId => {
452
452
  console.log(`Updated task "${taskId}"`);
453
+ utility.showActionWarnings(kanbn);
453
454
  })
454
455
  .catch(error => {
455
456
  utility.error(error);
@@ -466,18 +467,34 @@ module.exports = async args => {
466
467
  kanbn = scoped;
467
468
 
468
469
  // Get the task that we're editing
469
- const taskId = args._[1];
470
+ let taskId = args._[1];
470
471
  if (!taskId) {
471
472
  utility.error('No task id specified\nTry running {b}kanbn edit "task id"{b}');
472
473
  return;
473
474
  }
474
475
 
475
- // Make sure the task exists
476
+ // Make sure the task exists. Editing a simple task is how it becomes a real one - there is nothing
477
+ // to edit until it has a file - so promote it first and then edit the task that produces
476
478
  try {
477
479
  await kanbn.taskExists(taskId);
478
480
  } catch (error) {
479
- utility.error(error);
480
- return;
481
+ if (await kanbn.taskFileExists(taskId)) {
482
+ utility.error(error);
483
+ return;
484
+ }
485
+ const simpleTask = await utility.resolveSimpleTask(kanbn, taskId, error);
486
+ if (simpleTask === null) {
487
+ return;
488
+ }
489
+ try {
490
+ taskId = await kanbn.promoteSimpleTask(taskId);
491
+ console.log(utility.replaceTags(
492
+ `{d}Promoted simple task "${simpleTask.text}" to task "${taskId}"{d}`
493
+ ));
494
+ } catch (promoteError) {
495
+ utility.error(promoteError);
496
+ return;
497
+ }
481
498
  }
482
499
 
483
500
  // Get the index and make sure it has some columns
@@ -582,10 +599,12 @@ module.exports = async args => {
582
599
  if (!('metadata' in taskData)) {
583
600
  taskData.metadata = {};
584
601
  }
585
- const gitUsername = getGitUsername();
586
602
  if (args.assigned === true) {
587
- if (gitUsername) {
588
- taskData.metadata.assigned = gitUsername;
603
+
604
+ // A bare --assigned means "me", canonicalised against the contributors list if there is one
605
+ const currentUser = await kanbn.currentUser();
606
+ if (currentUser) {
607
+ taskData.metadata.assigned = currentUser;
589
608
  }
590
609
  } else {
591
610
  taskData.metadata.assigned = utility.strArg(args.assigned);
@@ -813,7 +832,14 @@ module.exports = async args => {
813
832
 
814
833
  // Update task interactively
815
834
  if (args.interactive) {
816
- interactive(taskData, taskIds, columnName, columnNames)
835
+ interactive(
836
+ taskData,
837
+ taskIds,
838
+ columnName,
839
+ columnNames,
840
+ await kanbn.currentUser(),
841
+ await kanbn.getContributors()
842
+ )
817
843
  .then(answers => {
818
844
 
819
845
  // Name
@@ -114,6 +114,31 @@ const searchFields = [
114
114
  name: 'Assigned',
115
115
  field: 'assigned',
116
116
  type: 'string'
117
+ },
118
+ {
119
+ name: 'Overdue',
120
+ field: 'overdue',
121
+ type: 'boolean'
122
+ },
123
+ {
124
+ name: 'Is started',
125
+ field: 'is-started',
126
+ type: 'boolean'
127
+ },
128
+ {
129
+ name: 'Is completed',
130
+ field: 'is-completed',
131
+ type: 'boolean'
132
+ },
133
+ {
134
+ name: 'In started column',
135
+ field: 'in-started-column',
136
+ type: 'boolean'
137
+ },
138
+ {
139
+ name: 'In completed column',
140
+ field: 'in-completed-column',
141
+ type: 'boolean'
117
142
  }
118
143
  ];
119
144
 
@@ -284,6 +309,24 @@ function convertNumericFilters(filters, filterName) {
284
309
  return true;
285
310
  }
286
311
 
312
+ /**
313
+ * Check that a filter holds a boolean value. Repeating a boolean option is meaningless, so the last
314
+ * value wins
315
+ * @param {object} filters The current filter object
316
+ * @param {string} filterName The property name for the filter
317
+ * @return {boolean} True if the value is a boolean
318
+ */
319
+ function convertBooleanFilters(filters, filterName) {
320
+ const value = Array.isArray(filters[filterName])
321
+ ? filters[filterName][filters[filterName].length - 1]
322
+ : filters[filterName];
323
+ if (typeof value !== 'boolean') {
324
+ return false;
325
+ }
326
+ filters[filterName] = value;
327
+ return true;
328
+ }
329
+
287
330
  /**
288
331
  * Convert a filter or array of filters to date values
289
332
  * @param {object} filters The current filter object
@@ -343,14 +386,28 @@ module.exports = async args => {
343
386
  }
344
387
  }
345
388
 
346
- // Get filters from args
389
+ // Get filters from args. Boolean filters are checked with "in" because false is a filter value in
390
+ // its own right - "--no-overdue" means "not overdue", not "no overdue filter"
347
391
  const filters = {};
348
- for (let filterProperty of searchFields.map(s => s.field)) {
349
- if (args[filterProperty]) {
350
- filters[filterProperty] = args[filterProperty];
392
+ for (let searchField of searchFields) {
393
+ if (searchField.type === 'boolean') {
394
+ if (searchField.field in args) {
395
+ filters[searchField.field] = args[searchField.field];
396
+ }
397
+ } else if (args[searchField.field]) {
398
+ filters[searchField.field] = args[searchField.field];
351
399
  }
352
400
  }
353
401
 
402
+ // Expand "@me" in the assigned filter
403
+ if ('assigned' in filters) {
404
+ const assigned = await utility.expandCurrentUser(kanbn, filters.assigned);
405
+ if (assigned === null) {
406
+ return;
407
+ }
408
+ filters.assigned = assigned;
409
+ }
410
+
354
411
  // Check and convert numeric filters
355
412
  if ('count-sub-tasks' in filters) {
356
413
  if (!convertNumericFilters(filters, 'count-sub-tasks')) {
@@ -389,6 +446,20 @@ module.exports = async args => {
389
446
  }
390
447
  }
391
448
 
449
+ // Check computed boolean filters
450
+ for (const [filterName, label] of [
451
+ ['overdue', 'Overdue'],
452
+ ['is-started', 'Is started'],
453
+ ['is-completed', 'Is completed'],
454
+ ['in-started-column', 'In started column'],
455
+ ['in-completed-column', 'In completed column']
456
+ ]) {
457
+ if (filterName in filters && !convertBooleanFilters(filters, filterName)) {
458
+ utility.error(`${label} filter value must be a boolean`);
459
+ return;
460
+ }
461
+ }
462
+
392
463
  // Check date filters
393
464
  if ('created' in filters) {
394
465
  if (!convertDateFilters(filters, 'created')) {
@@ -324,10 +324,13 @@ module.exports = async args => {
324
324
  kanbn = scoped;
325
325
  const index = await kanbn.getIndex();
326
326
 
327
- // Get assigned
327
+ // Get assigned, expanding "@me" to the current user
328
328
  let assigned = null;
329
329
  if (args.assigned) {
330
- assigned = utility.strArg(args.assigned);
330
+ assigned = await utility.expandCurrentUser(kanbn, utility.strArg(args.assigned));
331
+ if (assigned === null) {
332
+ return;
333
+ }
331
334
  }
332
335
 
333
336
  // Get columns
@@ -65,10 +65,13 @@ module.exports = async args => {
65
65
  }
66
66
  }
67
67
 
68
- // Get assigned
68
+ // Get assigned, expanding "@me" to the current user
69
69
  let assigned = null;
70
70
  if (args.assigned) {
71
- assigned = utility.strArg(args.assigned);
71
+ assigned = await utility.expandCurrentUser(kanbn, utility.strArg(args.assigned));
72
+ if (assigned === null) {
73
+ return;
74
+ }
72
75
  }
73
76
 
74
77
  // Get task ids