@basementuniverse/kanbn 2.5.1 → 2.5.3

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/api.md ADDED
@@ -0,0 +1,227 @@
1
+ # Library API
2
+
3
+ This document is for programs that use `@basementuniverse/kanbn` as a dependency, rather than the `kanbn` CLI. For the file formats that the library
4
+ reads and writes, see the [board](index-structure.md) and [task](task-structure.md) structure docs.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install @basementuniverse/kanbn
10
+ ```
11
+
12
+ ## Importing
13
+
14
+ The package's default export is the CLI entry point (an async function with no return value), used
15
+ by the `kanbn` executable. Library consumers should use the named `Kanbn` export instead:
16
+
17
+ ```js
18
+ const { Kanbn } = require('@basementuniverse/kanbn');
19
+ ```
20
+
21
+ ```ts
22
+ import { Kanbn } from '@basementuniverse/kanbn';
23
+ ```
24
+
25
+ Every method on `Kanbn` is documented with its parameter and return types in the package's shipped
26
+ type declarations (`src/main.d.ts`, referenced by the `types` field in `package.json`), so
27
+ TypeScript consumers get full IntelliSense without any extra shim.
28
+
29
+ ## Creating an instance
30
+
31
+ ```js
32
+ const kanbn = new Kanbn();
33
+ ```
34
+
35
+ - The constructor takes an optional `root` (the directory to treat as the current working
36
+ directory, defaulting to `process.cwd()`) and an `options` object: `{ board?: string, caches?:
37
+ any, actions?: boolean }`.
38
+ - `options.board` scopes the instance to a secondary board by slug; omit it (or pass `"main"` /
39
+ `"default"`) to target the main board. Use `kanbn.board(slug)` to get a scoped copy of an
40
+ existing instance instead of constructing a new one — this shares config caching.
41
+ - `options.actions` defaults to `true`. Pass `false`, or call `kanbn.withoutActions()`, to get an
42
+ instance that never runs [action rules](actions.md) — useful for tooling that shouldn't trigger
43
+ side effects.
44
+ - A workspace must be initialised before most methods will work. Check with `initialised()` and
45
+ set one up with `initialise()`:
46
+
47
+ ```js
48
+ const kanbn = new Kanbn();
49
+ if (!(await kanbn.initialised())) {
50
+ await kanbn.initialise({ name: 'My project' });
51
+ }
52
+ ```
53
+
54
+ Methods that operate on a workspace throw an `Error` (with a message such as `"Not initialised in
55
+ this folder"`) rather than returning an error value, so wrap calls in `try`/`catch` or let promise
56
+ rejections propagate.
57
+
58
+ ## Method reference
59
+
60
+ Methods are grouped by what they operate on. See `src/main.d.ts` for full parameter and return
61
+ types, including the shapes of `task`, `index`, `board`, `contributor` and other objects used
62
+ below.
63
+
64
+ ### Workspace lifecycle
65
+
66
+ | Method | Description |
67
+ | --- | --- |
68
+ | `initialised()` | Whether the current working directory has been initialised |
69
+ | `workspaceInitialised()` | Whether the workspace has been initialised, regardless of which board this instance is scoped to |
70
+ | `initialise(options?)` | Initialise a kanbn board in the current working directory |
71
+ | `getMainFolder()` | The `.kanbn` folder path for the current working directory |
72
+ | `removeAll()` | Delete the entire `.kanbn` folder |
73
+
74
+ ### Configuration
75
+
76
+ | Method | Description |
77
+ | --- | --- |
78
+ | `configExists()` | Whether a separate config file exists |
79
+ | `getConfig()` | Configuration settings from the config file, or `null` if there isn't one |
80
+ | `saveConfig(config)` | Save configuration data to a separate config file |
81
+ | `clearConfigCache()` | Clear cached config so the next read hits disk again |
82
+ | `getWorkspaceOptions()` | Workspace-scoped options, from the config file or the main board's front matter |
83
+ | `loadWorkspaceOptions()` | Workspace options along with whether they came from a config file |
84
+ | `getFolderName()` / `getIndexFileName()` / `getTaskFolderName()` / `getArchiveFolderName()` | Configured folder and file names |
85
+
86
+ ### Boards
87
+
88
+ A workspace can have one main board plus any number of secondary boards sharing the same pool of
89
+ task files — see [Multiple Boards](multiple-boards.md).
90
+
91
+ | Method | Description |
92
+ | --- | --- |
93
+ | `board(slug?)` | Get a copy of this instance scoped to another board (alias: `withBoard(slug?)`) |
94
+ | `listBoards()` | Find all boards in the workspace |
95
+ | `getBoardsSummary()` | List boards with column/task counts, completion percentage and last modified date |
96
+ | `boardExists(slug)` | Whether a board exists |
97
+ | `createBoard(slug, options?)` | Create a new secondary board |
98
+ | `initialiseBoard(slug, options?)` | Create a secondary board, or update an existing one |
99
+ | `deleteBoard(slug)` | Delete a board file; returns ids of tasks that are no longer on any board |
100
+ | `renameBoard(slug, newSlug, newName?)` | Rename a board |
101
+ | `findOrphanedTasks(slug)` | Tasks that would become untracked if a board were deleted |
102
+ | `getCrossBoardTasks(allTasks?)` | Every task that appears on more than one board, with the column it occupies on each |
103
+ | `findTaskBoards(taskId)` | Every board that references a task, and the column it occupies on each (alias: `getTaskBoardColumns(taskId)`) |
104
+ | `getIndex()` | The index (board) this instance is scoped to, as an object |
105
+ | `getBoardsConfig()` | The boards config from the config file: exclude list and display order |
106
+
107
+ ### Tasks
108
+
109
+ | Method | Description |
110
+ | --- | --- |
111
+ | `getTask(taskId)` | Get a task as an object |
112
+ | `createTask(taskData, columnName)` | Create a task file and add it to the index |
113
+ | `updateTask(taskId, taskData, columnName?)` | Update an existing task, optionally moving it |
114
+ | `renameTask(taskId, newTaskName)` | Rename a task (changes its id and file name) |
115
+ | `moveTask(taskId, columnName, position?, relative?, add?)` | Move a task between columns |
116
+ | `deleteTask(taskId, removeFile?, allBoards?)` | Remove a task from the index and optionally delete its file |
117
+ | `taskExists(taskId)` | Throws unless the task file exists and is indexed |
118
+ | `taskFileExists(taskId)` | Whether a task file exists, regardless of whether any board references it |
119
+ | `findTaskColumn(taskId)` | The column a task is in, or throws if it doesn't exist / isn't indexed |
120
+ | `findTrackedTasks(columnName?)` | Ids of tasks listed in the index, optionally filtered by column |
121
+ | `findUntrackedTasks()` | Ids of markdown files in the tasks folder that aren't listed in the index |
122
+ | `addUntrackedTaskToIndex(taskId, columnName)` | Add an untracked task to a column in the index |
123
+ | `addTaskToBoard(taskId, columnName)` | Add an existing (already-tracked-elsewhere) task to this board |
124
+ | `search(filters?, quiet?)` | Search for indexed tasks matching filters — see [Filtering and Sorting](filtering-and-sorting.md) |
125
+ | `sort(columnName, sorters, save?)` | Sort a column using the shared sorter model |
126
+ | `comment(taskId, text, author?)` | Add a comment to a task |
127
+
128
+ ### Simple tasks
129
+
130
+ Simple tasks are plain lines in a column that aren't task links; see [Index Structure](index-structure.md).
131
+
132
+ | Method | Description |
133
+ | --- | --- |
134
+ | `findSimpleTasks(input?, index?)` | Simple tasks matching a title, or all of them if no title given |
135
+ | `getSimpleTask(input, index?)` | Resolve a title to exactly one simple task, or throw |
136
+ | `moveSimpleTask(input, columnName, position?)` | Move a simple task to another column on this board |
137
+ | `moveSimpleTaskToBoard(input, targetSlug, columnName?, position?)` | Move a simple task onto another board |
138
+ | `deleteSimpleTask(input)` | Remove a simple task |
139
+ | `promoteSimpleTask(input, columnName?)` | Turn a simple task into a real task file |
140
+
141
+ ### Archive
142
+
143
+ | Method | Description |
144
+ | --- | --- |
145
+ | `listArchivedTasks()` | List archived task ids |
146
+ | `archiveTask(taskId)` | Move a task to the archive |
147
+ | `restoreTask(taskId, columnName?, singleBoard?)` | Restore a task from the archive |
148
+ | `loadArchivedTask(taskId)` | Load an archived task file as an object |
149
+
150
+ ### Contributors
151
+
152
+ See [Contributors](contributors.md).
153
+
154
+ | Method | Description |
155
+ | --- | --- |
156
+ | `getContributors()` | The workspace's contributors, normalised to object form |
157
+ | `findContributor(value)` | Find the contributor a name/alias/display-name value refers to |
158
+ | `currentUser()` | Work out who the current user is (`KANBN_USER`, then git email/name, then git username, then `null`) |
159
+ | `getContributorUsage()` | How contributors are used across tasks, and which names in use aren't known contributors |
160
+ | `findContributorWarnings()` | Tasks whose assigned user or comment author isn't a known contributor |
161
+
162
+ ### Actions
163
+
164
+ See [Actions](actions.md).
165
+
166
+ | Method | Description |
167
+ | --- | --- |
168
+ | `withoutActions()` | Get a copy of this instance that runs no action rules |
169
+ | `actionsAllowed()` | Whether actions should run at all for this instance |
170
+ | `getActionRules(index?)` | The action rules that apply to this board |
171
+ | `findActionWarnings()` | Action rules that are legal but probably not what the author meant |
172
+ | `lastActionWarnings` | Property: rules that were skipped during the last operation |
173
+
174
+ ### Sprints, status and charts
175
+
176
+ See [Sprints](sprints.md).
177
+
178
+ | Method | Description |
179
+ | --- | --- |
180
+ | `sprint(name, description, start)` | Start a new sprint |
181
+ | `status(quiet?, untracked?, due?, sprint?, dates?)` | Project status information |
182
+ | `burndown(sprints?, dates?, assigned?, columns?, normalise?)` | Burndown chart data |
183
+
184
+ ### Validation
185
+
186
+ | Method | Description |
187
+ | --- | --- |
188
+ | `validate(save?)` | Validate the index and task files; `true` on success, otherwise an array of errors |
189
+ | `findMissingTaskFiles(index?)` | Tasks referenced by this board that have no task file |
190
+ | `findColumnContentWarnings(index?)` | Lines in this board's columns that aren't task links |
191
+ | `findWorkspaceUntrackedTasks()` | Tasks that no board references at all |
192
+ | `findTasksOnOtherBoards()` | Tasks that other boards track but this one doesn't |
193
+
194
+ ## Error handling
195
+
196
+ Kanbn methods reject/throw plain `Error` objects with human-readable messages (for example `"Task
197
+ already exists"`, `"Column does not exist"`). There are no custom error classes or machine-readable
198
+ error codes to switch on — match on `error.message` if you need to distinguish specific failures.
199
+
200
+ ## A worked example
201
+
202
+ ```js
203
+ const { Kanbn } = require('@basementuniverse/kanbn');
204
+
205
+ async function main() {
206
+ const kanbn = new Kanbn('/path/to/project');
207
+
208
+ if (!(await kanbn.initialised())) {
209
+ throw new Error('Not a kanbn project');
210
+ }
211
+
212
+ const taskId = await kanbn.createTask(
213
+ { name: 'Write documentation', description: 'Document the library API' },
214
+ 'Todo'
215
+ );
216
+
217
+ await kanbn.moveTask(taskId, 'In Progress');
218
+
219
+ const status = await kanbn.status(false, true);
220
+ console.log(status);
221
+ }
222
+
223
+ main().catch(err => {
224
+ console.error(err.message);
225
+ process.exitCode = 1;
226
+ });
227
+ ```
@@ -25,7 +25,7 @@ The index file can optionally begin with YAML front-matter containing project op
25
25
 
26
26
  There should be a single level-1 heading at the top of the markdown body containing the project name.
27
27
 
28
- The project description should appear below the title. The description can be of any length and can contain markdown, however it must not contain any headings.
28
+ The project description should appear below the title. The description can be of any length and can contain markdown, however it must not contain any level-2 headings. Deeper headings (level 3 and below) are fine, as are level-2 headings inside a fenced code block.
29
29
 
30
30
  Below the description there should be one or more level-2 headings. The 'Options' name is reserved for project options.
31
31
 
package/docs/index.md CHANGED
@@ -7,6 +7,7 @@ To get started quickly, check out the [Quick Start](quick-start.md) guide.
7
7
  ## Contents
8
8
 
9
9
  - [Quick Start](quick-start.md)
10
+ - [Library API](api.md) — using `@basementuniverse/kanbn` as a dependency instead of the CLI
10
11
  - [Index Structure](index-structure.md) — the board file and all project options
11
12
  - [Task Structure](task-structure.md) — task files, metadata, sub-tasks, relations, comments and history
12
13
  - [Multiple Boards](multiple-boards.md) — several boards over one shared pool of tasks
@@ -64,7 +64,9 @@ There should be a single level-1 heading at the top of the markdown body contain
64
64
 
65
65
  The task description should appear below the title. The description can be of any length and can contain markdown.
66
66
 
67
- The following level-2 headings are reserved for special purposes (`## Metadata`, `## Sub-tasks`, `## Relations`, `## Comments` and `## History`). Any other level-2 heading is treated as part of the description.
67
+ The following level-2 headings are reserved for special purposes (`## Metadata`, `## Sub-tasks`, `## Relations`, `## Comments` and `## History`). Any other level-2 heading is treated as part of the description, as is a heading at any other level - a `### Sub-tasks` heading in a description is description content, not the task's sub-tasks.
68
+
69
+ Headings inside fenced code blocks are ignored, so a shell or Python comment like `# install deps` in a ```` ```bash ```` block stays where you wrote it.
68
70
 
69
71
  ## Metadata
70
72
 
package/index.js CHANGED
@@ -42,3 +42,5 @@ module.exports = async () => {
42
42
  route.id
43
43
  );
44
44
  };
45
+
46
+ module.exports.Kanbn = require('./src/main').Kanbn;
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@basementuniverse/kanbn",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "description": "A CLI Kanban application",
5
5
  "main": "index.js",
6
+ "types": "src/main.d.ts",
6
7
  "scripts": {
7
8
  "test": "KANBN_ENV=test qunit ./test/**/*.test.js",
8
9
  "coverage": "KANBN_ENV=test c8 --reporter=text --reporter=html --include=src/** --include=index.js qunit ./test/**/*.test.js"
@@ -38,6 +39,7 @@
38
39
  "dotenv": "^8.2.0",
39
40
  "front-matter": "^4.0.2",
40
41
  "fuzzy": "^0.1.3",
42
+ "glob": "^7.2.3",
41
43
  "glob-promise": "^3.4.0",
42
44
  "humanize-duration": "^3.25.0",
43
45
  "inquirer": "^7.3.3",
@@ -231,6 +231,46 @@ function validateOptions(options) {
231
231
  }
232
232
  }
233
233
 
234
+ /**
235
+ * Check whether a parsed section is a level-2 heading
236
+ *
237
+ * The index format reserves level 2 for columns and for the Options section. A deeper heading is
238
+ * part of the project description: it used to be promoted into a column of its own, which put an
239
+ * empty phantom column on the board and rewrote the user's `###` as a `##`
240
+ * @param {object} section A section from parseMarkdown
241
+ * @return {boolean}
242
+ */
243
+ function isLevel2(section) {
244
+ return /^## /.test(section.heading);
245
+ }
246
+
247
+ /**
248
+ * Compile the project name's section and any sub-headings below it into a project description
249
+ *
250
+ * The Options section and the columns have already been taken out of the index by this point, so
251
+ * everything left is description
252
+ * @param {object} index A parsed index
253
+ * @param {string} name The project name, i.e. the title of the level-1 heading at the top of the file
254
+ * @param {string[]} columnNames The headings that are columns
255
+ * @return {string}
256
+ */
257
+ function compileDescription(index, name, columnNames) {
258
+ const description = [];
259
+ for (const heading in index) {
260
+ if (heading === 'raw' || columnNames.indexOf(heading) !== -1) {
261
+ continue;
262
+ }
263
+
264
+ // The project name's heading line is stored separately as the name, so it isn't repeated in the
265
+ // description. Every other heading keeps its line
266
+ description.push(
267
+ heading === name ? '' : index[heading].heading,
268
+ index[heading].content
269
+ );
270
+ }
271
+ return description.join('\n\n').trim();
272
+ }
273
+
234
274
  /**
235
275
  * Get the task id from a column list item, or null if the item isn't a task link
236
276
  *
@@ -416,12 +456,9 @@ module.exports = {
416
456
  // Get name
417
457
  name = indexHeadings[0];
418
458
 
419
- // Get description
420
- description = name in index ? index[name].content.trim() : '';
421
-
422
459
  // Parse options
423
460
  // Options will be serialized back to front-matter, this check remains here for backwards-compatibility
424
- if ('Options' in index) {
461
+ if ('Options' in index && isLevel2(index['Options'])) {
425
462
 
426
463
  // Get embedded options and make sure it's an object
427
464
  const embeddedOptions = yaml.parse(index['Options'].content.trim().replace(/```(yaml|yml)?/g, ''));
@@ -431,11 +468,15 @@ module.exports = {
431
468
 
432
469
  // Merge with front matter options
433
470
  options = Object.assign(options, embeddedOptions);
471
+ delete index['Options'];
434
472
  }
435
473
  validateOptions(options);
436
474
 
437
475
  // Parse columns
438
- const columnNames = Object.keys(index).filter(column => ['raw', 'Options', name].indexOf(column) === -1);
476
+ // Only level-2 headings are columns, as documented - anything deeper belongs to the description
477
+ const columnNames = Object.keys(index).filter(
478
+ column => ['raw', name].indexOf(column) === -1 && isLevel2(index[column])
479
+ );
439
480
  for (const columnName of columnNames) {
440
481
  let parsed = null;
441
482
  try {
@@ -452,6 +493,10 @@ module.exports = {
452
493
  columnContent[columnName] = parsed.content;
453
494
  }
454
495
  }
496
+
497
+ // Get description
498
+ // Everything that isn't the Options section or a column is description, sub-headings included
499
+ description = compileDescription(index, name, columnNames);
455
500
  } catch (error) {
456
501
  throw new Error(`Unable to parse index: ${error.message}`);
457
502
  }
@@ -1,3 +1,97 @@
1
+ /**
2
+ * Match an ATX heading line, e.g. `## Title`
3
+ *
4
+ * The heading must start at column 0. CommonMark also allows up to 3 leading spaces, but kanbn has
5
+ * always required none, and loosening it now would start hoisting indented lines out of task files
6
+ * that parse correctly today
7
+ */
8
+ const HEADING_REGEX = /^(#{1,6}) (.+)$/;
9
+
10
+ /**
11
+ * Match the opening or closing line of a fenced code block
12
+ *
13
+ * Up to 3 leading spaces are allowed, as in CommonMark - at 4 spaces the line is part of an indented
14
+ * code block instead, which can't contain a heading anyway because a heading must start at column 0
15
+ */
16
+ const FENCE_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/;
17
+
18
+ /**
19
+ * Find the ATX headings in a markdown document, ignoring anything inside a fenced code block
20
+ *
21
+ * Tracking fences is the whole reason this isn't a single regex: `# install deps` inside a ```bash
22
+ * block is a shell comment, not a heading, and hoisting it out of the block destroys the user's code.
23
+ * The same goes for Python comments, Ruby comments, C preprocessor directives and so on
24
+ * @param {string} markdown
25
+ * @return {object[]} Each heading's line, title, start index and the index its content starts at
26
+ */
27
+ function findHeadings(markdown) {
28
+ const headings = [];
29
+
30
+ // The fence currently open, as { char, length }, or null if we're not inside a code block
31
+ let fence = null;
32
+ let index = 0;
33
+ for (const rawLine of markdown.split('\n')) {
34
+
35
+ // Tolerate CRLF line endings: without this the trailing carriage return ends up inside the
36
+ // heading title, and from there inside task names and ids
37
+ const line = rawLine.replace(/\r$/, '');
38
+ const fenceMatch = line.match(FENCE_REGEX);
39
+ if (fence === null) {
40
+ if (fenceMatch && isFenceStart(fenceMatch)) {
41
+ fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
42
+ } else {
43
+ const headingMatch = line.match(HEADING_REGEX);
44
+ if (headingMatch) {
45
+ headings.push({
46
+ heading: headingMatch[0],
47
+ title: headingMatch[2],
48
+ index,
49
+
50
+ // Where this heading's content starts: the line after the heading line
51
+ contentIndex: index + rawLine.length + 1
52
+ });
53
+ }
54
+ }
55
+ } else if (isFenceEnd(fenceMatch, fence)) {
56
+ fence = null;
57
+ }
58
+
59
+ // + 1 for the newline that split() removed
60
+ index += rawLine.length + 1;
61
+ }
62
+ return headings;
63
+ }
64
+
65
+ /**
66
+ * Check if a fence line opens a code block
67
+ *
68
+ * A backtick fence's info string can't itself contain a backtick - ```` ```a`b ```` is a paragraph
69
+ * containing code spans, not a code block. Tilde fences have no such restriction
70
+ * @param {object} fenceMatch A FENCE_REGEX match
71
+ * @return {boolean}
72
+ */
73
+ function isFenceStart(fenceMatch) {
74
+ return fenceMatch[1][0] !== '`' || fenceMatch[2].indexOf('`') === -1;
75
+ }
76
+
77
+ /**
78
+ * Check if a line closes the currently open code block
79
+ *
80
+ * A closing fence has to use the same character as the fence that opened the block, be at least as
81
+ * long, and carry no info string - so a ``` line doesn't close a ~~~ block, and neither does ```js
82
+ * @param {?object} fenceMatch A FENCE_REGEX match, or null if the line isn't a fence
83
+ * @param {object} fence The currently open fence
84
+ * @return {boolean}
85
+ */
86
+ function isFenceEnd(fenceMatch, fence) {
87
+ return (
88
+ fenceMatch !== null &&
89
+ fenceMatch[1][0] === fence.char &&
90
+ fenceMatch[1].length >= fence.length &&
91
+ fenceMatch[2].trim() === ''
92
+ );
93
+ }
94
+
1
95
  module.exports = function parseMarkdown(markdown) {
2
96
  if (!markdown) {
3
97
  throw new Error('data is null, undefined or empty');
@@ -9,30 +103,39 @@ module.exports = function parseMarkdown(markdown) {
9
103
  if (markdown === '') {
10
104
  throw new Error('data is an empty string');
11
105
  }
12
- const headings = [...markdown.matchAll(/^#{1,6} (?<title>.+)/gm)].map(({ 0: heading, 1: title, index }) => ({
13
- heading,
14
- title,
15
- index
16
- }));
106
+ const headings = findHeadings(markdown);
17
107
  if (headings.length > 0 && headings[0].index > 0) {
18
108
  headings.unshift({
19
109
  heading: '',
20
110
  title: 'raw',
21
- index: 0
111
+ index: 0,
112
+ contentIndex: 0
22
113
  });
23
114
  }
24
115
  const parsed = {};
25
116
  for (let i = 0; i < headings.length; i++) {
117
+ const content = markdown.slice(
118
+ headings[i].contentIndex,
119
+ i < headings.length - 1
120
+ ? headings[i + 1].index
121
+ : undefined
122
+ ).trim();
123
+
124
+ // A repeated heading title used to overwrite the earlier one, silently discarding everything
125
+ // written under it - two `## Todo` columns meant losing every task in the first one. Merging the
126
+ // sections keeps the content, and keeping the repeated heading line means the section still
127
+ // looks the way the user wrote it after a read/write cycle
128
+ if (headings[i].title in parsed) {
129
+ parsed[headings[i].title].content = [
130
+ parsed[headings[i].title].content,
131
+ headings[i].heading,
132
+ content
133
+ ].filter(part => part !== '').join('\n\n');
134
+ continue;
135
+ }
26
136
  parsed[headings[i].title] = {
27
137
  heading: headings[i].heading,
28
- content: markdown.slice(
29
- // The + 1 skips the newline that ends the heading line; the synthetic 'raw' entry for
30
- // content before the first heading has no heading line, so it starts at index 0
31
- headings[i].index + headings[i].heading.length + (headings[i].heading ? 1 : 0),
32
- i < headings.length - 1
33
- ? headings[i + 1].index
34
- : undefined
35
- ).trim()
138
+ content
36
139
  };
37
140
  }
38
141
  return parsed;
package/src/parse-task.js CHANGED
@@ -6,21 +6,44 @@ const chrono = require('chrono-node');
6
6
  const validate = require('jsonschema').validate;
7
7
  const parseMarkdown = require('./parse-markdown');
8
8
 
9
+ /**
10
+ * Get a reserved section from a parsed task, or null if the task doesn't have one
11
+ *
12
+ * A section only counts as reserved if it's a level-2 heading, which is what the task format
13
+ * documents and what json2md writes. A `# Comments` or `### Sub-tasks` heading written into a
14
+ * description is description content, and used to hijack the section it happened to share a name
15
+ * with - turning a paragraph about comments into the task's comments, or failing the whole parse
16
+ * @param {object} task A parsed task
17
+ * @param {string} title A reserved section title
18
+ * @return {?object} The section, or null if the task doesn't have it at level 2
19
+ */
20
+ function getReservedSection(task, title) {
21
+ return title in task && /^## /.test(task[title].heading) ? task[title] : null;
22
+ }
23
+
9
24
  /**
10
25
  * Compile separate headings together into a task description
26
+ *
27
+ * Reserved sections have already been removed from the parsed task by this point, so everything
28
+ * left is description
11
29
  * @param {object} data
30
+ * @param {string} name The task name, i.e. the title of the level-1 heading at the top of the file
12
31
  */
13
- function compileDescription(data) {
32
+ function compileDescription(data, name) {
14
33
  const description = [];
15
34
  if ('raw' in data) {
16
35
  description.push(data.raw.content.replace(/[\r\n]{3}/g, '\n\n').trim());
17
36
  }
18
37
  for (let heading in data) {
19
- if (['raw', 'Metadata', 'Sub-tasks', 'Relations', 'History', 'Comments'].indexOf(heading) !== -1) {
38
+ if (heading === 'raw') {
20
39
  continue;
21
40
  }
41
+
42
+ // The task name's heading line is stored separately as the task name, so it isn't repeated in
43
+ // the description. Every other heading keeps its line: this used to drop the line from *every*
44
+ // level-1 heading, silently deleting the text of any `# Heading` written into a description
22
45
  description.push(
23
- /^# (.*)/.test(data[heading].heading) ? '' : data[heading].heading,
46
+ heading === name ? '' : data[heading].heading,
24
47
  data[heading].content
25
48
  );
26
49
  }
@@ -359,10 +382,11 @@ module.exports = {
359
382
 
360
383
  // Parse metadata
361
384
  // Metadata will be serialized back to front-matter, this check remains here for backwards compatibility
362
- if ('Metadata' in task) {
385
+ const metadataSection = getReservedSection(task, 'Metadata');
386
+ if (metadataSection !== null) {
363
387
 
364
388
  // Get embedded metadata and make sure it's an object
365
- const embeddedMetadata = yaml.parse(task['Metadata'].content.trim().replace(/```(yaml|yml)?/g, ''));
389
+ const embeddedMetadata = yaml.parse(metadataSection.content.trim().replace(/```(yaml|yml)?/g, ''));
366
390
  if (typeof embeddedMetadata !== 'object') {
367
391
  throw new Error('invalid metadata content');
368
392
  }
@@ -434,9 +458,10 @@ module.exports = {
434
458
  }
435
459
 
436
460
  // Parse sub-tasks
437
- if ('Sub-tasks' in task) {
461
+ const subTasksSection = getReservedSection(task, 'Sub-tasks');
462
+ if (subTasksSection !== null) {
438
463
  try {
439
- subTasks = marked.lexer(task['Sub-tasks'].content)[0].items.map(item => ({
464
+ subTasks = marked.lexer(subTasksSection.content)[0].items.map(item => ({
440
465
  text: item.text.trim(),
441
466
  completed: item.checked || false
442
467
  }));
@@ -447,9 +472,10 @@ module.exports = {
447
472
  }
448
473
 
449
474
  // Parse relations
450
- if ('Relations' in task) {
475
+ const relationsSection = getReservedSection(task, 'Relations');
476
+ if (relationsSection !== null) {
451
477
  try {
452
- relations = marked.lexer(task['Relations'].content)[0].items.map(item => {
478
+ relations = marked.lexer(relationsSection.content)[0].items.map(item => {
453
479
  const parts = item.tokens[0].tokens[0].text.split(' ');
454
480
  return parts.length === 1
455
481
  ? {
@@ -468,7 +494,8 @@ module.exports = {
468
494
  }
469
495
 
470
496
  // Parse comments
471
- if ('Comments' in task) {
497
+ const commentsSection = getReservedSection(task, 'Comments');
498
+ if (commentsSection !== null) {
472
499
  try {
473
500
  // const commentsHeading = '## Comments';
474
501
  // const start = data.indexOf(commentsHeading) + commentsHeading.length;
@@ -479,7 +506,7 @@ module.exports = {
479
506
  // end = data.length;
480
507
  // }
481
508
  // const parsedComments = marked.lexer(data.slice(start, end).trim())[0].items;
482
- const parsedComments = marked.lexer(task['Comments'].content)[0].items;
509
+ const parsedComments = marked.lexer(commentsSection.content)[0].items;
483
510
  for (let parsedComment of parsedComments) {
484
511
  const comment = { text: [] };
485
512
  const parts = parsedComment.text.split('\n');
@@ -506,9 +533,10 @@ module.exports = {
506
533
  }
507
534
 
508
535
  // Parse history
509
- if ('History' in task) {
536
+ const historySection = getReservedSection(task, 'History');
537
+ if (historySection !== null) {
510
538
  try {
511
- const parsedHistory = marked.lexer(task['History'].content)[0].items;
539
+ const parsedHistory = marked.lexer(historySection.content)[0].items;
512
540
  for (let parsedHistoryEvent of parsedHistory) {
513
541
  const historyEvent = {};
514
542
  const parts = parsedHistoryEvent.text.split('\n');
@@ -563,7 +591,7 @@ module.exports = {
563
591
 
564
592
  // Assemble description
565
593
  // const descriptionParts = [];
566
- description = compileDescription(task);
594
+ description = compileDescription(task, name);
567
595
  // description = descriptionParts.join('\n\n');
568
596
  } catch (error) {
569
597
  throw new Error(`Unable to parse task: ${error.message}`);