@ashikahmad/kanbanmd 0.0.0-stage → 2.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/core/patch.js ADDED
@@ -0,0 +1,438 @@
1
+ // Locators and surgical edits on the raw lines of kanban.md / archive.md.
2
+ //
3
+ // A Doc is the file as an array of lines (each keeping its own terminator) plus its EOL style.
4
+ // Every function here edits a Doc in place by splicing whole lines or replacing a substring of one
5
+ // line, so every byte it does not name stays exactly as it was (hand formatting, CRLF, unknown
6
+ // sections). The locators reuse the same patterns as the parser in board.js, and ops.js verifies
7
+ // every result by re-parsing it, so a disagreement between the two aborts the write.
8
+ import { splitLines, stripEol, lineEol, detectEol, toEol } from './text.js';
9
+ import { generateBoard, generateTaskBlock, FIELD_BREAK } from './board.js';
10
+ import { formatHousekeepingSpec } from './housekeeping-spec.js';
11
+ import { formatBacklogFileSpec } from './backlog-spec.js';
12
+ import { BLOCKED_LABEL } from './links.js';
13
+
14
+ export class Doc {
15
+ constructor(text) {
16
+ this.lines = splitLines(text);
17
+ this.eol = detectEol(text);
18
+ }
19
+ toString() { return this.lines.join(''); }
20
+ }
21
+
22
+ // Thrown by a patcher that cannot express an edit line-wise on this particular layout; ops.js then
23
+ // regenerates just that one task block.
24
+ export class Fallback extends Error {}
25
+
26
+ const HEADER_RE = /^###\s+TASK-(\d+)\s*\|\s*(.+?)\s*$/;
27
+ const META_LINE_RE = new RegExp(`^\\*\\*(?:Status|Order|Priority|Category|Assigned|Created|Started|Due|Finished|Tags|${BLOCKED_LABEL})\\*\\*`);
28
+ const STOP_RE = /^\*\*(Subtasks|Notes|Links|Review|Dependencies)\*\*/;
29
+ // One metadata field per line, in this fixed order. [task key, label in the file]
30
+ const FIELDS = [['status', 'Status'], ['order', 'Order'], ['priority', 'Priority'], ['category', 'Category'], ['assignees', 'Assigned'],
31
+ ['created', 'Created'], ['started', 'Started'], ['due', 'Due'], ['completed', 'Finished'], ['tags', 'Tags'], ['blockedBy', 'Blocked by']];
32
+ const FIELD_LABELS = FIELDS.map(([, l]) => (l === 'Blocked by' ? BLOCKED_LABEL : l)).join('|');
33
+ const FIELD_LINE = new RegExp(`^(\\s*\\*\\*(${FIELD_LABELS})\\*\\*\\s*:[ \\t]*)(.*)$`);
34
+ const taskId = (digits) => 'TASK-' + digits.padStart(3, '0');
35
+ const isBlank = (line) => stripEol(line).trim() === '';
36
+
37
+ // ---- locating ---------------------------------------------------------------------------------
38
+
39
+ // Line index just after the "## Tasks" heading, or -1.
40
+ export function tasksStart(lines) {
41
+ const i = lines.findIndex((l) => /^##\s+Tasks\s*$/.test(stripEol(l)));
42
+ return i < 0 ? -1 : i + 1;
43
+ }
44
+
45
+ // Task blocks at or after line `from`: [{ id, start, end }], end exclusive.
46
+ export function findBlocks(lines, from = 0) {
47
+ const heads = [];
48
+ for (let i = Math.max(0, from); i < lines.length; i++) {
49
+ const m = HEADER_RE.exec(stripEol(lines[i]));
50
+ if (m) heads.push({ id: taskId(m[1]), start: i });
51
+ }
52
+ return heads.map((h, k) => ({ ...h, end: k + 1 < heads.length ? heads[k + 1].start : lines.length }));
53
+ }
54
+
55
+ // Blocks of the board's "## Tasks" section, or of a whole archive.
56
+ export function blocksOf(doc, kind) {
57
+ return findBlocks(doc.lines, kind === 'board' ? Math.max(0, tasksStart(doc.lines)) : 0);
58
+ }
59
+
60
+ export function locateTask(doc, kind, id) {
61
+ const hits = blocksOf(doc, kind).filter((b) => b.id === id);
62
+ if (hits.length !== 1) return null; // missing, or ambiguous (duplicate id): never guess
63
+ return { ...hits[0], kind };
64
+ }
65
+
66
+ export const blockText = (doc, loc) => doc.lines.slice(loc.start, loc.end).join('');
67
+
68
+ // Where each field of a block lives. Indices are absolute line numbers; -1 / null = absent.
69
+ export function fieldsOf(doc, loc) {
70
+ const { start, end } = loc;
71
+ const L = (i) => stripEol(doc.lines[i]);
72
+ const f = {
73
+ header: start, fields: {},
74
+ desc: null, descInterleaved: false,
75
+ subHeaders: [], subItems: [], notes: -1, extraStart: -1, stop: end, lastContent: start,
76
+ };
77
+ let inSub = false;
78
+ for (let i = start + 1; i < end; i++) {
79
+ const line = L(i);
80
+ if (line.trim()) f.lastContent = i;
81
+ if (f.stop === end && STOP_RE.test(line)) f.stop = i;
82
+ if (f.notes < 0 && /\*\*Notes\*\*:[ \t\r]*$/.test(line) && lineEol(doc.lines[i])) f.notes = i;
83
+ if (f.extraStart < 0 && /^\s*\*\*(Links|Review|Dependencies)\*\*\s*:/i.test(line)) f.extraStart = i;
84
+
85
+ // subtasks: same state machine as the parser
86
+ if (/^\s*\*\*Subtasks\*\*/i.test(line)) { inSub = true; f.subHeaders.push(i); continue; }
87
+ if (inSub && /^\s*\*\*[^*\r\n]+\*\*\s*:/.test(line)) inSub = false;
88
+ if (inSub) {
89
+ const m = /- \[(x| )\] (.+)/.exec(line);
90
+ if (m) f.subItems.push({ line: i, box: m.index + 3, completed: m[1] === 'x', text: m[2].trim() });
91
+ }
92
+ }
93
+ // metadata fields: the first line per label before the first section, like the parser. A line
94
+ // that joins several fields with " | " (hand-edited, or older files) registers each of them,
95
+ // all marked joined, so an edit falls back to rewriting the block instead of mangling the line.
96
+ for (let i = start + 1; i < f.stop; i++) {
97
+ const line = L(i);
98
+ const m = FIELD_LINE.exec(line);
99
+ if (!m) continue;
100
+ const parts = line.split(new RegExp(`\\s*\\|\\s*(?=\\*\\*(?:${FIELD_LABELS})\\*\\*\\s*:)`));
101
+ parts.forEach((part, k) => {
102
+ const pm = new RegExp(`^\\s*\\*\\*(${FIELD_LABELS})\\*\\*\\s*:\\s*(.*?)\\s*$`).exec(part);
103
+ if (pm && !f.fields[pm[1]]) f.fields[pm[1]] = { line: i, valueStart: k === 0 ? m[1].length : -1, value: pm[2], joined: parts.length > 1 };
104
+ });
105
+ }
106
+ // description: non-blank, non-metadata lines before the first stop line
107
+ const dl = [];
108
+ let interleaved = false;
109
+ for (let i = start + 1; i < f.stop; i++) {
110
+ const line = L(i);
111
+ if (META_LINE_RE.test(line) || FIELD_LINE.test(line)) { if (dl.length) interleaved = true; continue; }
112
+ if (line.trim()) dl.push(i);
113
+ }
114
+ if (dl.length) f.desc = { first: dl[0], last: dl[dl.length - 1], count: dl.length };
115
+ f.descInterleaved = interleaved;
116
+ return f;
117
+ }
118
+
119
+ // ---- line-level helpers -----------------------------------------------------------------------
120
+
121
+ const withEol = (doc, text) => text + doc.eol;
122
+ function ensureEolAt(doc, i) {
123
+ if (i >= 0 && i < doc.lines.length && !lineEol(doc.lines[i])) doc.lines[i] += doc.eol;
124
+ }
125
+ function insertLines(doc, at, texts) {
126
+ if (at > 0) ensureEolAt(doc, at - 1);
127
+ doc.lines.splice(at, 0, ...texts.map((t) => withEol(doc, t)));
128
+ }
129
+ function replaceInLine(doc, i, from, to, text) {
130
+ const eol = lineEol(doc.lines[i]);
131
+ const body = stripEol(doc.lines[i]);
132
+ doc.lines[i] = body.slice(0, from) + text + body.slice(to) + eol;
133
+ }
134
+ function removeLines(doc, from, to) { doc.lines.splice(from, to - from); }
135
+ // Remove lines [from, to) plus one adjacent blank line, so no double blank is left behind.
136
+ function removeRegion(doc, from, to) {
137
+ if (from > 0 && isBlank(doc.lines[from - 1])) from--;
138
+ removeLines(doc, from, to);
139
+ }
140
+
141
+ // ---- task field edits (each takes the doc and a located block) --------------------------------
142
+
143
+ export function setTitle(doc, loc, title) {
144
+ const m = /^(###\s+TASK-\d+\s*\|\s*)(.+?)(\s*)$/.exec(stripEol(doc.lines[loc.start]));
145
+ replaceInLine(doc, loc.start, m[1].length, m[1].length + m[2].length, title);
146
+ }
147
+
148
+ // The text a task value takes on its line, or null when the field should be absent.
149
+ export function fieldText(key, value) {
150
+ if (key === 'assignees') return value.length ? value.join(', ') : null;
151
+ if (key === 'tags') return value.length ? value.join(' ') : null;
152
+ if (key === 'blockedBy') return value && value.length ? value.join(', ') : null;
153
+ if (key === 'order') return value === null || value === undefined ? null : String(value);
154
+ return value ? String(value) : null;
155
+ }
156
+
157
+ // Current text of a metadata line (the value after the label), or null when absent.
158
+ export function getField(doc, loc, key) {
159
+ const label = FIELDS.find(([k]) => k === key)[1];
160
+ const e = fieldsOf(doc, loc).fields[label];
161
+ return e ? e.value : null;
162
+ }
163
+
164
+ // Set one metadata field to `value` (a task value; empty/null removes the line). Edits just that
165
+ // line, or inserts it in canonical order after the nearest field above it that exists.
166
+ export function setField(doc, loc, key, value) {
167
+ const idx = FIELDS.findIndex(([k]) => k === key);
168
+ const label = FIELDS[idx][1];
169
+ const text = fieldText(key, value);
170
+ const f = fieldsOf(doc, loc);
171
+ const e = f.fields[label];
172
+ if (e) {
173
+ if (e.joined) throw new Fallback('metadata fields joined on one line');
174
+ if (text === null) removeLines(doc, e.line, e.line + 1);
175
+ else replaceInLine(doc, e.line, e.valueStart, stripEol(doc.lines[e.line]).trimEnd().length, text); // keeps the line's own trailing break
176
+ return;
177
+ }
178
+ if (text === null) return;
179
+ let at = loc.start + 1;
180
+ for (let i = 0; i < idx; i++) {
181
+ const above = f.fields[FIELDS[i][1]];
182
+ if (above) at = Math.max(at, above.line + 1);
183
+ }
184
+ insertLines(doc, at, [`**${label}**: ${text}${FIELD_BREAK}`]);
185
+ }
186
+
187
+ // Description: the non-blank lines between the metadata and the first section.
188
+ export function setDescription(doc, loc, description) {
189
+ const f = fieldsOf(doc, loc);
190
+ if (f.descInterleaved) throw new Fallback('metadata inside description');
191
+ const newLines = description ? description.split('\n') : [];
192
+ if (f.desc) {
193
+ if (newLines.length) {
194
+ doc.lines.splice(f.desc.first, f.desc.last - f.desc.first + 1, ...newLines.map((l) => withEol(doc, l)));
195
+ ensureEolAt(doc, f.desc.first + newLines.length - 1);
196
+ } else {
197
+ removeRegion(doc, f.desc.first, f.desc.last + 1);
198
+ }
199
+ return;
200
+ }
201
+ if (!newLines.length) return;
202
+ // After the last metadata line, set off by blank lines (the generator's layout).
203
+ let at = loc.start + 1;
204
+ for (let i = loc.start + 1; i < f.stop; i++) if (META_LINE_RE.test(stripEol(doc.lines[i]))) at = i + 1;
205
+ const block = ['', ...newLines];
206
+ const next = doc.lines[at];
207
+ if (next !== undefined && at < loc.end && !isBlank(next)) block.push('');
208
+ insertLines(doc, at, block);
209
+ }
210
+
211
+ // ---- subtasks ---------------------------------------------------------------------------------
212
+
213
+ const itemText = (s) => `- [${s.completed ? 'x' : ' '}] ${s.text}`;
214
+
215
+ // Where a brand-new **Subtasks** section goes: before the first extra/notes section, else at the
216
+ // end of the block's content.
217
+ function newSectionAt(doc, loc, f) {
218
+ const marks = [f.extraStart, f.notes].filter((i) => i >= 0);
219
+ return marks.length ? Math.min(...marks) : f.lastContent + 1;
220
+ }
221
+
222
+ function insertSubtaskSection(doc, loc, f, items) {
223
+ const at = newSectionAt(doc, loc, f);
224
+ const lines = ['', '**Subtasks**:', ...items.map(itemText)];
225
+ if (at > 0 && isBlank(doc.lines[at - 1])) lines.shift(); // already set off by a blank line
226
+ if (at < loc.end && !isBlank(doc.lines[at])) lines.push('');
227
+ insertLines(doc, at, lines);
228
+ }
229
+
230
+ export function toggleSubtaskAt(doc, loc, index, completed) {
231
+ const f = fieldsOf(doc, loc);
232
+ const it = f.subItems[index];
233
+ if (!it) throw new Fallback('no such subtask');
234
+ replaceInLine(doc, it.line, it.box, it.box + 1, completed ? 'x' : ' ');
235
+ }
236
+
237
+ export function setSubtasks(doc, loc, subtasks) {
238
+ const f = fieldsOf(doc, loc);
239
+ const items = f.subItems;
240
+ // Same texts, only completion flags differ: flip single characters.
241
+ if (items.length === subtasks.length && items.every((it, i) => it.text === subtasks[i].text)) {
242
+ subtasks.forEach((s, i) => { if (s.completed !== items[i].completed) toggleSubtaskAt(doc, loc, i, s.completed); });
243
+ return;
244
+ }
245
+ if (f.subHeaders.length > 1) throw new Fallback('several Subtasks sections');
246
+ if (!items.length) {
247
+ if (f.subHeaders.length) throw new Fallback('empty Subtasks section');
248
+ if (subtasks.length) insertSubtaskSection(doc, loc, f, subtasks);
249
+ return;
250
+ }
251
+ const first = items[0].line, last = items[items.length - 1].line;
252
+ for (let i = first; i <= last; i++) {
253
+ if (!items.some((it) => it.line === i) && !isBlank(doc.lines[i])) throw new Fallback('foreign lines among subtasks');
254
+ }
255
+ if (subtasks.length) {
256
+ doc.lines.splice(first, last - first + 1, ...subtasks.map((s) => withEol(doc, itemText(s))));
257
+ ensureEolAt(doc, first + subtasks.length - 1);
258
+ } else {
259
+ removeRegion(doc, f.subHeaders.length ? f.subHeaders[0] : first, last + 1);
260
+ }
261
+ }
262
+
263
+ export function addSubtask(doc, loc, subtask) {
264
+ const f = fieldsOf(doc, loc);
265
+ if (f.subHeaders.length > 1) throw new Fallback('several Subtasks sections');
266
+ if (f.subItems.length) {
267
+ insertLines(doc, f.subItems[f.subItems.length - 1].line + 1, [itemText(subtask)]);
268
+ } else if (f.subHeaders.length) {
269
+ insertLines(doc, f.subHeaders[0] + 1, [itemText(subtask)]);
270
+ } else {
271
+ insertSubtaskSection(doc, loc, f, [subtask]);
272
+ }
273
+ }
274
+
275
+ // ---- notes ------------------------------------------------------------------------------------
276
+
277
+ export function setNotes(doc, loc, notes) {
278
+ const f = fieldsOf(doc, loc);
279
+ const newLines = notes ? notes.split(/\r?\n/) : [];
280
+ if (f.notes >= 0) {
281
+ if (newLines.length) {
282
+ doc.lines.splice(f.notes + 1, f.lastContent - f.notes, ...newLines.map((l) => withEol(doc, l)));
283
+ ensureEolAt(doc, f.notes + newLines.length);
284
+ } else {
285
+ removeRegion(doc, f.notes, f.lastContent + 1);
286
+ }
287
+ return;
288
+ }
289
+ if (newLines.length) insertLines(doc, f.lastContent + 1, ['', '**Notes**:', ...newLines]);
290
+ }
291
+
292
+ // The line ending that terminates a block's last non-blank line (what a newly appended line follows).
293
+ export function lastContentEol(doc, loc) {
294
+ const f = fieldsOf(doc, loc);
295
+ return lineEol(doc.lines[f.lastContent]) || doc.eol;
296
+ }
297
+
298
+ export function appendNote(doc, loc, text) {
299
+ const f = fieldsOf(doc, loc);
300
+ const newLines = text.split(/\r?\n/);
301
+ if (f.notes >= 0 && f.lastContent > f.notes) insertLines(doc, f.lastContent + 1, newLines);
302
+ else setNotes(doc, loc, text);
303
+ }
304
+
305
+ // ---- whole blocks -----------------------------------------------------------------------------
306
+
307
+ // Regenerate one task block canonically (fallback when a layout cannot be patched line-wise).
308
+ export function replaceBlock(doc, loc, task) {
309
+ const lines = splitLines(toEol(generateTaskBlock(task), doc.eol));
310
+ doc.lines.splice(loc.start, loc.end - loc.start, ...lines);
311
+ }
312
+
313
+ // Make a block's text safe to paste at the end of a file: ends with a newline and a blank line.
314
+ function normalizeBlockLines(doc, lines) {
315
+ const out = [...lines]; // verbatim: a block keeps the line endings it already has
316
+ if (!out.length) return out;
317
+ if (!lineEol(out[out.length - 1])) out[out.length - 1] += doc.eol;
318
+ if (!isBlank(out[out.length - 1])) out.push(doc.eol);
319
+ return out;
320
+ }
321
+
322
+ // True when every line of the doc ends the same way (so a foreign line ending would stand out).
323
+ export function hasUniformEol(doc) {
324
+ const ends = new Set(doc.lines.map(lineEol).filter(Boolean));
325
+ return ends.size <= 1;
326
+ }
327
+
328
+ // Reorder the task blocks of a file: the block now at position i is the one that was at order[i].
329
+ // Everything before the first block is untouched, and every block moves verbatim.
330
+ export function reorderBlocks(doc, kind, order) {
331
+ const blocks = blocksOf(doc, kind);
332
+ const pieces = blocks.map((b) => doc.lines.slice(b.start, b.end));
333
+ const head = doc.lines.slice(0, blocks[0].start);
334
+ const body = order.flatMap((from, i) => (i === order.length - 1 ? pieces[from] : normalizeBlockLines(doc, pieces[from])));
335
+ doc.lines = [...head, ...body];
336
+ }
337
+
338
+ // Re-terminate every line of a block with `eol` (only when moving between files that differ).
339
+ export const convertBlockEol = (lines, eol) => lines.map((l) => toEol(l, eol));
340
+
341
+ export function cutBlock(doc, loc) {
342
+ const lines = doc.lines.splice(loc.start, loc.end - loc.start);
343
+ return lines;
344
+ }
345
+
346
+ // Insert block lines before line index `at` (use doc.lines.length to append).
347
+ export function pasteBlock(doc, at, blockLines) {
348
+ const lines = normalizeBlockLines(doc, blockLines);
349
+ if (at > 0) ensureEolAt(doc, at - 1);
350
+ // keep a blank line between the previous content and the pasted block
351
+ if (at > 0 && !isBlank(doc.lines[at - 1])) lines.unshift(doc.eol);
352
+ doc.lines.splice(at, 0, ...lines);
353
+ }
354
+
355
+ export function appendBlockText(doc, text) {
356
+ pasteBlock(doc, doc.lines.length, splitLines(toEol(text, doc.eol)));
357
+ }
358
+
359
+ // Mark an (empty) archive as v3 so the app does not "migrate" it later.
360
+ export function setArchiveMarker(doc, version = 3) {
361
+ const i = doc.lines.findIndex((l) => /<!--\s*Format:/i.test(l));
362
+ if (i >= 0) replaceInLine(doc, i, 0, stripEol(doc.lines[i]).length, `<!-- Format: v${version} -->`);
363
+ else insertLines(doc, Math.min(1, doc.lines.length), [`<!-- Format: v${version} -->`]);
364
+ }
365
+
366
+ // ---- header comment and config ----------------------------------------------------------------
367
+
368
+ export function setLastTaskId(doc, n) {
369
+ const re = /<!-- Config: Last Task ID: (\d+) -->/;
370
+ const i = doc.lines.findIndex((l) => re.test(l));
371
+ if (i >= 0) {
372
+ const m = re.exec(doc.lines[i]);
373
+ const digits = m[1];
374
+ const at = m.index + m[0].indexOf(digits);
375
+ replaceInLine(doc, i, at, at + digits.length, String(n).padStart(digits.length, '0'));
376
+ return;
377
+ }
378
+ const comment = `<!-- Config: Last Task ID: ${n} -->`;
379
+ if (doc.lines.length > 1 && isBlank(doc.lines[1])) insertLines(doc, 2, [comment]);
380
+ else insertLines(doc, Math.min(1, doc.lines.length), ['', comment]);
381
+ }
382
+
383
+ const CONFIG_LABELS = { columns: 'Columns', categories: 'Categories', users: 'Users', priorities: 'Priorities', tags: 'Tags', housekeeping: 'Housekeeping', backlogFile: 'Backlog file' };
384
+
385
+ function configRange(doc) {
386
+ const h = doc.lines.findIndex((l) => stripEol(l).startsWith('## ⚙️ Configuration'));
387
+ if (h < 0) return null;
388
+ let to = doc.lines.length;
389
+ for (let i = h + 1; i < doc.lines.length; i++) {
390
+ if (/^---/.test(doc.lines[i]) || /^##\s/.test(doc.lines[i])) { to = i; break; }
391
+ }
392
+ return { from: h + 1, to };
393
+ }
394
+
395
+ // Set one config list line (**Columns** / **Categories** / ...). `value` is the text after the label.
396
+ // `fullConfig` (the new config) is used only to create the whole section when the file has none.
397
+ export function setConfigLine(doc, key, value, fullConfig) {
398
+ const label = CONFIG_LABELS[key];
399
+ const range = configRange(doc);
400
+ if (!range) {
401
+ const gen = splitLines(generateBoard({ config: fullConfig, tasks: [] }));
402
+ const a = gen.findIndex((l) => stripEol(l).startsWith('## ⚙️ Configuration'));
403
+ const b = gen.findIndex((l) => /^##\s+Tasks\s*$/.test(stripEol(l)));
404
+ const at = Math.max(0, tasksStart(doc.lines) - 1);
405
+ insertLines(doc, at, gen.slice(a, b).map(stripEol));
406
+ return;
407
+ }
408
+ const re = new RegExp(`\\*\\*${label}\\*\\*:\\s*(.+)`);
409
+ for (let i = range.from; i < range.to; i++) {
410
+ const m = re.exec(stripEol(doc.lines[i]));
411
+ if (m) { replaceInLine(doc, i, m.index, stripEol(doc.lines[i]).length, `**${label}**: ${value}`); return; }
412
+ }
413
+ const blockBefore = range.to > 0 && !isBlank(doc.lines[range.to - 1]);
414
+ insertLines(doc, range.to, [...(blockBefore ? [''] : []), `**${label}**: ${value}`, '']);
415
+ }
416
+
417
+ // Remove one config line (and the blank line after it). No-op when absent.
418
+ export function removeConfigLine(doc, key) {
419
+ const range = configRange(doc);
420
+ if (!range) return;
421
+ const re = new RegExp(`\\*\\*${CONFIG_LABELS[key]}\\*\\*:\\s*(.+)`);
422
+ for (let i = range.from; i < range.to; i++) {
423
+ if (re.test(stripEol(doc.lines[i]))) {
424
+ removeLines(doc, i, i + (i + 1 < doc.lines.length && isBlank(doc.lines[i + 1]) ? 2 : 1));
425
+ return;
426
+ }
427
+ }
428
+ }
429
+
430
+ export const configValueText = {
431
+ columns: (v) => v.map((c) => `${c.name} (${c.id})`).join(' | '),
432
+ categories: (v) => v.join(', '),
433
+ users: (v) => v.join(', '),
434
+ priorities: (v) => v.join(' | '),
435
+ tags: (v) => v.map((t) => '#' + t).join(' '),
436
+ housekeeping: (v) => formatHousekeepingSpec(v),
437
+ backlogFile: (v) => formatBacklogFileSpec(v),
438
+ };