@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/board.js ADDED
@@ -0,0 +1,548 @@
1
+ import { BLOCKED_LABEL } from './links.js';
2
+ import { parseHousekeepingSpec, formatHousekeepingSpec } from './housekeeping-spec.js';
3
+ import { parseBacklogFileSpec, formatBacklogFileSpec } from './backlog-spec.js';
4
+
5
+ // Slugify a column name/heading to an id (lowercase, drop emoji/punctuation, dash spaces).
6
+ // MUST stay identical to the id derivation used when a column has no explicit (id), so that
7
+ // slug(heading) === column.id holds. NOTE: \w (no /u flag) strips accents — "Terminé" → "termin"
8
+ // — which is consistent with how the id was derived, but means an explicit id like "termine"
9
+ // won't match a "## Terminé" heading by slug (name match still works). Known, guarded limit.
10
+ export function legacySlug(s) {
11
+ return s.toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-');
12
+ }
13
+
14
+ // Parse a kanban.md into { config, tasks, formatV2 }. Pure: no globals, no I/O.
15
+ export function parseBoard(content) {
16
+ const tasks = [];
17
+ const config = {
18
+ lastTaskId: 0,
19
+ columns: [],
20
+ categories: [],
21
+ users: [],
22
+ priorities: [],
23
+ tags: [],
24
+ housekeeping: null, // optional "**Housekeeping**:" policy (see housekeeping-spec.js); null = defaults
25
+ backlogFile: null, // optional "**Backlog file**:" { file, column? } (see backlog-spec.js); null = no backlog file
26
+ boardTitle: 'Kanban Board'
27
+ };
28
+
29
+ // Preserve the board's H1 title so a custom title survives a save/migration instead of
30
+ // being reset to the default. Only the FIRST line is considered (the title is always
31
+ // line 1 in both V1 and generated V2 files); this avoids ever mistaking a "# heading"
32
+ // inside a task description for the board title. A non-matching first line keeps the
33
+ // default — never injects wrong content. "##"/"###" can't match (single "#" + space).
34
+ const firstLine = content.split('\n', 1)[0].replace(/\r$/, ''); // tolerate CRLF files
35
+ const titleMatch = firstLine.match(/^#[ \t]+(.+?)[ \t]*$/);
36
+ if (titleMatch) config.boardTitle = titleMatch[1].trim();
37
+
38
+ // Parse config comment
39
+ const configMatch = content.match(/<!-- Config: Last Task ID: (\d+) -->/);
40
+ if (configMatch) {
41
+ config.lastTaskId = parseInt(configMatch[1]);
42
+ }
43
+
44
+ // Parse config section. Stop at the first of: a `---` separator (V2/legacy with
45
+ // separator), the next `## ` heading (legacy file whose config isn't followed by
46
+ // `---`), or end-of-file. Requiring `---` would make a legacy file without it fall
47
+ // back to DEFAULT columns/categories/users/tags, silently losing the user's config.
48
+ const configSection = content.match(/## ⚙️ Configuration\s+([\s\S]*?)(?:\n---|\n##\s|$)/);
49
+ if (configSection) {
50
+ const configText = configSection[1];
51
+
52
+ // Parse columns - FIXED REGEX
53
+ const columnsMatch = configText.match(/\*\*Columns\*\*:\s*(.+)/);
54
+ if (columnsMatch) {
55
+ const usedColIds = new Set();
56
+ config.columns = columnsMatch[1].split('|').map((col, idx) => {
57
+ col = col.trim();
58
+ if (!col) return null;
59
+ const match = col.match(/^(.+?)\s*\((.+?)\)\s*$/);
60
+ let name, baseId;
61
+ if (match) {
62
+ name = match[1].trim();
63
+ baseId = match[2].trim();
64
+ } else {
65
+ // No explicit (id): derive a slug from the name so legacy files
66
+ // without ids still parse (e.g. "📝 To Do" → "to-do") instead of being dropped.
67
+ name = col;
68
+ baseId = legacySlug(col) || ('col' + (idx + 1));
69
+ }
70
+ // Ensure ids are unique (handles slug or explicit-id collisions deterministically).
71
+ let id = baseId, n = 2;
72
+ while (usedColIds.has(id)) id = `${baseId}-${n++}`;
73
+ usedColIds.add(id);
74
+ return { name, id };
75
+ }).filter(Boolean);
76
+ }
77
+
78
+ // Parse categories
79
+ const categoriesMatch = configText.match(/\*\*Categories\*\*:\s*(.+)/);
80
+ if (categoriesMatch) {
81
+ config.categories = categoriesMatch[1].split(',').map(c => c.trim()).filter(Boolean);
82
+ }
83
+
84
+ // Parse users
85
+ const usersMatch = configText.match(/\*\*Users\*\*:\s*(.+)/);
86
+ if (usersMatch) {
87
+ config.users = usersMatch[1].split(',').map(u => u.trim()).filter(Boolean);
88
+ }
89
+
90
+ // Parse priorities
91
+ const prioritiesMatch = configText.match(/\*\*Priorities\*\*:\s*(.+)/);
92
+ if (prioritiesMatch) {
93
+ config.priorities = prioritiesMatch[1].split('|').map(p => p.trim()).filter(Boolean);
94
+ }
95
+
96
+ const bfMatch = configText.match(/\*\*Backlog file\*\*:\s*(.+)/);
97
+ if (bfMatch) config.backlogFile = parseBacklogFileSpec(bfMatch[1].trim());
98
+
99
+ const hkMatch = configText.match(/\*\*Housekeeping\*\*:\s*(.+)/);
100
+ if (hkMatch) config.housekeeping = parseHousekeepingSpec(hkMatch[1].trim());
101
+
102
+ // Parse tags
103
+ const tagsMatch = configText.match(/\*\*Tags\*\*:\s*(.+)/);
104
+ if (tagsMatch) {
105
+ // Same pattern as task tags, so separators like "#bug, #feature" don't leave a comma on the name
106
+ config.tags = (tagsMatch[1].match(/#[\w-]+/g) || []).map(t => t.slice(1));
107
+ }
108
+ }
109
+
110
+ // Default columns if not found
111
+ if (config.columns.length === 0) {
112
+ config.columns = [
113
+ { name: '📝 To Do', id: 'todo' },
114
+ { name: '🚀 In Progress', id: 'in-progress' },
115
+ { name: '👀 In Review', id: 'in-review' },
116
+ { name: '✅ Done', id: 'done' }
117
+ ];
118
+ }
119
+
120
+ // Default categories if not found
121
+ if (config.categories.length === 0) {
122
+ config.categories = ['Frontend', 'Backend', 'Design', 'DevOps', 'Tests', 'Documentation'];
123
+ }
124
+
125
+ // Default users if not found
126
+ if (config.users.length === 0) {
127
+ config.users = ['@user (User)'];
128
+ }
129
+
130
+ // Default priorities if not found
131
+ if (config.priorities.length === 0) {
132
+ config.priorities = ['🔴 Critical', '🟠 High', '🟡 Medium', '🟢 Low'];
133
+ }
134
+
135
+ // Default tags if not found
136
+ if (config.tags.length === 0) {
137
+ config.tags = ['bug', 'feature', 'ui', 'backend', 'urgent', 'refactor', 'docs', 'test'];
138
+ }
139
+
140
+ // Parse tasks. V2: a single "## Tasks" section where each task carries its own
141
+ // **Status** field. Legacy: one "## <Column>" section per status (status inferred
142
+ // from the section). The **Status** field wins; the section is the fallback → old
143
+ // kanban.md files keep working unchanged.
144
+ // Detect V2 by an explicit format marker, OR by a "## Tasks" heading with no legacy
145
+ // column section present (so a legacy column literally named "Tasks" is never
146
+ // mis-detected and silently dropped).
147
+ const reEscape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ const marker = /<!--\s*Format:\s*v(\d+)\s*-->/i.exec(content);
149
+ const hasMarker = !!marker;
150
+ const hasTasksHeading = /(^|\n)##\s+Tasks\s*(\n|$)/.test(content);
151
+ // A legacy column section is detected by its exact name OR by the slug of any "## "
152
+ // heading matching a column id. The slug arm lets an old/hand-edited file whose heading
153
+ // lost its emoji ("## Backlog" for a "📝 Backlog (backlog)" column) still be classified
154
+ // legacy instead of mis-read as V2. It is gated on !hasTasksHeading: a V2-shaped file
155
+ // (single "## Tasks") must NOT be dragged to the legacy path just because a column id
156
+ // happens to equal slug("Tasks")/slug("Configuration") — that would ignore the **Status**
157
+ // fields and corrupt statuses. The exact-name arm still handles a legacy column named
158
+ // literally "Tasks". Real V2 files carry the marker and win via hasMarker regardless.
159
+ const sectionTitles = [...content.matchAll(/(?:^|\n)##\s+([^\r\n]+?)\s*(?:\n|$)/g)].map(m => m[1].trim());
160
+ const hasColumnSections = config.columns.some(c =>
161
+ new RegExp(`(^|\\n)##\\s+${reEscape(c.name)}\\s*(\\n|$)`).test(content) ||
162
+ (!hasTasksHeading && sectionTitles.some(t => legacySlug(t) === c.id)));
163
+ const hasUnified = hasMarker || (hasTasksHeading && !hasColumnSections);
164
+
165
+ if (hasUnified) {
166
+ // The "## Tasks" section runs to the end of the file, so a "## " or "### "
167
+ // heading inside a task's notes/description never truncates the board.
168
+ const body = content.replace(/^[\s\S]*?(?:^|\n)##\s+Tasks\s*(?:\n|$)/, '');
169
+ tasks.push(...parseTaskBlocks(body, config.columns[0].id, true, config.columns));
170
+ } else {
171
+ // Legacy: status comes from the section only — never honour a stray
172
+ // **Status** line that might sit inside an old task's notes.
173
+ config.columns.forEach(column => {
174
+ tasks.push(...parseTasksFromSection(content, column.name, column.id, false, config.columns));
175
+ });
176
+ }
177
+
178
+ // formatV2: tasks live in one "## Tasks" section with a **Status** field (v2 and later).
179
+ // formatVersion: 1 legacy sections, 2 joined metadata lines, 3 one field per line.
180
+ return { config, tasks, formatV2: hasUnified, formatVersion: marker ? parseInt(marker[1], 10) : (hasUnified ? 2 : 1) };
181
+ }
182
+
183
+ // Extract the "## <sectionName>" section, then parse its task blocks.
184
+ // Used by the legacy column path and by the archive (each column / "Archives"
185
+ // is its own section). The V2 unified path calls parseTaskBlocks() directly.
186
+ function parseTasksFromSection(content, sectionName, statusId, honorStatusField = false, columns = []) {
187
+ const sections = content.split(/\n##\s+/);
188
+ // Pass 1 — exact/legacy NAME match. Byte-for-byte the previous behaviour (same
189
+ // substring strip), so a section heading equal to the column name parses exactly as
190
+ // before. A name match always wins over a slug match (pass 2 only runs if none here).
191
+ for (let section of sections) {
192
+ if (section.startsWith(sectionName)) {
193
+ return parseTaskBlocks(section.substring(sectionName.length).trim(), statusId, honorStatusField, columns);
194
+ }
195
+ }
196
+ // Pass 2 — id-centred SLUG fallback: a heading whose slug equals the column id
197
+ // (emoji/case/spacing-insensitive). Strip the whole title line here (the slug is
198
+ // computed on the full title, so the title must not leak into the body).
199
+ for (let section of sections) {
200
+ const nl = section.search(/\r?\n/);
201
+ const title = (nl >= 0 ? section.slice(0, nl) : section).trim();
202
+ if (legacySlug(title) === statusId) {
203
+ return parseTaskBlocks(nl >= 0 ? section.slice(nl).trim() : '', statusId, honorStatusField, columns);
204
+ }
205
+ }
206
+ return [];
207
+ }
208
+
209
+ // Parse "### TASK-NNN | Title" blocks from a chunk of markdown. The split is anchored
210
+ // to line starts so a "### TASK-" appearing mid-line in notes is not split on.
211
+ export function parseTaskBlocks(sectionContent, statusId, honorStatusField = false, columns = []) {
212
+ const tasksFound = [];
213
+ // Only a real, line-anchored "### TASK-<digits> | <title>" header starts a block.
214
+ // A malformed "### TASK-foo" or a header-like line inside notes is NOT a boundary,
215
+ // so it neither drops the following content nor truncates the surrounding task.
216
+ const headerRe = /^###\s+TASK-(\d+)\s*\|\s*(.+?)\s*$/gm;
217
+ const headers = [...sectionContent.matchAll(headerRe)];
218
+ headers.forEach((h, i) => {
219
+ const titlePart = h[2].trim();
220
+ if (!titlePart) return;
221
+ const start = h.index + h[0].length;
222
+ const end = i + 1 < headers.length ? headers[i + 1].index : sectionContent.length;
223
+ const taskContent = sectionContent.slice(start, end);
224
+ const taskId = 'TASK-' + h[1].padStart(3, '0');
225
+ const task = parseTask(taskId, titlePart, taskContent, statusId, honorStatusField, columns);
226
+ if (task) tasksFound.push(task);
227
+ });
228
+ return tasksFound;
229
+ }
230
+
231
+ // "TASK-12, task-007" -> ['TASK-012', 'TASK-007'] (unique, in order; anything that is not an id is ignored)
232
+ export function parseTaskIds(text) {
233
+ const ids = (String(text).match(/\bTASK-\d+\b/gi) || []).map(i => 'TASK-' + String(parseInt(i.slice(5), 10)).padStart(3, '0'));
234
+ return [...new Set(ids)];
235
+ }
236
+
237
+ export const FIELD_BREAK = ' ';
238
+ const FIELD_LABELS = `Status|Order|Priority|Category|Assigned|Created|Started|Due|Finished|Tags|${BLOCKED_LABEL}`;
239
+ const FIELD_LINE_RE = new RegExp(`^\\s*\\*\\*(?:${FIELD_LABELS})\\*\\*\\s*:`);
240
+ const FIELD_SPLIT_RE = new RegExp(`\\s*\\|\\s*(?=\\*\\*(?:${FIELD_LABELS})\\*\\*\\s*:)`);
241
+ const FIELD_SEG_RE = new RegExp(`^\\s*\\*\\*(${FIELD_LABELS})\\*\\*\\s*:\\s*(.*?)\\s*$`);
242
+ const SECTION_LINE_RE = /^\*\*(Subtasks|Notes|Links|Review|Dependencies)\*\*/;
243
+
244
+ // First value of each metadata field, from the lines before the first section. A line may carry
245
+ // several fields joined by " | " (the v1/v2 layout); each segment is read on its own.
246
+ function scanFields(lines) {
247
+ const out = {};
248
+ for (const line of lines) {
249
+ if (SECTION_LINE_RE.test(line)) break;
250
+ if (!FIELD_LINE_RE.test(line)) continue;
251
+ for (const seg of line.split(FIELD_SPLIT_RE)) {
252
+ const m = FIELD_SEG_RE.exec(seg);
253
+ if (m && !(m[1] in out)) out[m[1]] = m[2];
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ // Parse individual task. `columns` is the board's column list, used to validate **Status**.
260
+ function parseTask(id, title, content, fallbackStatus, honorStatusField = false, columns = []) {
261
+ // Metadata lines (**Status**, **Priority**, ...) are read from the lines before the first
262
+ // section, one field per line (format v3) or several joined with " | " (v1/v2 wrote
263
+ // priority/category/assigned and the dates that way): both layouts parse to the same values.
264
+ const lines = content.split('\n');
265
+ const fields = scanFields(lines);
266
+
267
+ // V2+: the **Status** field is authoritative when present and a valid column id;
268
+ // otherwise fall back to the section-derived status (legacy files / archive).
269
+ let status = fallbackStatus;
270
+ if (honorStatusField && fields.Status !== undefined) {
271
+ const s = fields.Status.trim();
272
+ if (columns.some(c => c.id === s)) status = s;
273
+ }
274
+ const task = {
275
+ id,
276
+ title: title.trim(),
277
+ status,
278
+ order: null, // optional manual sort key (**Order**); null = not set
279
+ priority: '',
280
+ category: '',
281
+ assignees: [],
282
+ tags: [],
283
+ blockedBy: [], // ids of the tasks this one waits on (**Blocked by**); "blocks" is derived, never stored
284
+ created: '',
285
+ started: '',
286
+ due: '',
287
+ completed: '',
288
+ description: '',
289
+ subtasks: [],
290
+ notes: '',
291
+ extra: '' // verbatim passthrough for unsupported sections (see below)
292
+ };
293
+
294
+ if (fields.Order !== undefined && /^\d+$/.test(fields.Order)) task.order = parseInt(fields.Order, 10);
295
+ if (fields.Priority !== undefined) task.priority = fields.Priority;
296
+ if (fields.Category !== undefined) task.category = fields.Category;
297
+ if (fields.Assigned !== undefined) task.assignees = fields.Assigned.split(',').map(a => a.trim()).filter(Boolean);
298
+ for (const [label, key] of [['Created', 'created'], ['Started', 'started'], ['Due', 'due'], ['Finished', 'completed']]) {
299
+ const m = fields[label] !== undefined && fields[label].match(/^[\d-]+/);
300
+ if (m) task[key] = m[0];
301
+ }
302
+ if (fields.Tags !== undefined) task.tags = fields.Tags.match(/#[\w-]+/g) || [];
303
+ if (fields['Blocked by'] !== undefined) task.blockedBy = parseTaskIds(fields['Blocked by']);
304
+
305
+ // Parse description (text after dates/tags but before "**Sous-tâches**" or "**Notes**")
306
+ let descriptionLines = [];
307
+ let inDescription = false;
308
+
309
+ for (let line of lines) {
310
+ // Skip metadata lines
311
+ if (FIELD_LINE_RE.test(line) || line.match(/^\*\*(Status|Order|Priority|Category|Assigned|Created|Started|Due|Finished|Tags)\*\*/)) {
312
+ continue;
313
+ }
314
+ // Stop at subsections
315
+ if (line.match(/^\*\*(Subtasks|Notes|Links|Review|Dependencies)\*\*/)) {
316
+ break;
317
+ }
318
+ // Collect description lines
319
+ if (line.trim() && !inDescription) {
320
+ inDescription = true;
321
+ }
322
+ if (inDescription && line.trim()) {
323
+ descriptionLines.push(line.trim());
324
+ }
325
+ }
326
+ task.description = descriptionLines.join('\n'); // full text — the card clamps the display via CSS
327
+
328
+ // Parse subtasks — ONLY inside the **Subtasks** section (until the next "**Field**:" or
329
+ // end), not globally. A global scan would also collect "- [ ]" lines that live inside a
330
+ // preserved **Links**/**Review**/**Dependencies** section (or in notes); the generator
331
+ // would then re-emit them BOTH as subtasks and inside task.extra, duplicating them on
332
+ // every parse→generate→parse cycle (subtasks growing 2→4→6…). Scoping keeps it 1:1.
333
+ let inSubtasksSection = false;
334
+ for (let line of lines) {
335
+ if (/^\s*\*\*Subtasks\*\*/i.test(line)) { inSubtasksSection = true; continue; }
336
+ if (inSubtasksSection && /^\s*\*\*[^*\r\n]+\*\*\s*:/.test(line)) inSubtasksSection = false;
337
+ if (inSubtasksSection) {
338
+ const m = line.match(/- \[(x| )\] (.+)/);
339
+ if (m) task.subtasks.push({ completed: m[1] === 'x', text: m[2].trim() });
340
+ }
341
+ }
342
+
343
+ // Parse notes - everything after **Notes**: until end of task
344
+ const notesMatch = content.match(/\*\*Notes\*\*:\s*\n([\s\S]*?)$/);
345
+ if (notesMatch) {
346
+ task.notes = notesMatch[1].trim();
347
+ }
348
+
349
+ // Preserve unsupported sections verbatim. SCOPE: exactly **Links**/**Review**/**Dependencies**
350
+ // — the three headers the description loop STOPS at but never stores, so they alone vanish
351
+ // on save/migration. Other "**Field**:" content is already kept elsewhere: before a
352
+ // stop-word it stays in the description; after **Notes** it stays in the greedy notes tail.
353
+ // (A non-standard "**Foo**:" placed between **Subtasks** and **Notes** is the one acknowledged
354
+ // gap — rare, and a fully generic capture would clash with bold-prefixed description lines.)
355
+ // Capture each section from its header to the next recognised section or end; the generator
356
+ // re-emits task.extra between subtasks and notes. Description/notes parsing are untouched.
357
+ const extraStart = /^\s*\*\*(Links|Review|Dependencies)\*\*\s*:/i;
358
+ const extraStop = /^\s*\*\*(Status|Order|Priority|Category|Assigned|Created|Started|Due|Finished|Tags|Subtasks)\*\*/i;
359
+ const extraLines = [];
360
+ let capturingExtra = false;
361
+ for (let line of lines) {
362
+ // Stop the whole scan at **Notes**: notes is captured greedily to end-of-block, so
363
+ // anything after it is already preserved there — scanning past it would double-capture.
364
+ if (/^\s*\*\*Notes\*\*/i.test(line)) break;
365
+ if (extraStart.test(line)) { capturingExtra = true; extraLines.push(line); continue; }
366
+ if (capturingExtra && extraStop.test(line)) { capturingExtra = false; }
367
+ if (capturingExtra) extraLines.push(line);
368
+ }
369
+ task.extra = extraLines.join('\n').trim();
370
+
371
+ return task;
372
+ }
373
+
374
+ // Parse an archive.md into { tasks, formatV2 }. The archive is a flat list of task blocks
375
+ // whatever its section heading says (any language). **Status** is honoured when it names one of
376
+ // `columns` (the board's columns), so an archived task keeps the column it came from; otherwise
377
+ // the status is 'archived'.
378
+ export function parseArchive(content, columns = []) {
379
+ const marker = /<!--\s*Format:\s*v(\d+)\s*-->/i.exec(content);
380
+ return {
381
+ tasks: parseTaskBlocks(content, 'archived', true, columns),
382
+ formatV2: !!marker && parseInt(marker[1], 10) >= 2,
383
+ formatVersion: marker ? parseInt(marker[1], 10) : 1
384
+ };
385
+ }
386
+
387
+ // A backlog file (see backlog-spec.js): the same flat list of task blocks as the archive. A task whose
388
+ // **Status** is missing or not a column reads as `column`, the backlog column.
389
+ export function parseBacklog(content, columns = [], column = 'backlog') {
390
+ const marker = /<!--\s*Format:\s*v(\d+)\s*-->/i.exec(content);
391
+ return { tasks: parseTaskBlocks(content, column, true, columns), formatVersion: marker ? parseInt(marker[1], 10) : 1 };
392
+ }
393
+
394
+ const DEFAULT_CATEGORIES = ['Frontend', 'Backend', 'Design', 'DevOps', 'Tests', 'Documentation'];
395
+ const DEFAULT_USERS = ['@user (User)'];
396
+ const DEFAULT_PRIORITIES = ['🔴 Critical', '🟠 High', '🟡 Medium', '🟢 Low'];
397
+ const DEFAULT_TAGS = ['bug', 'feature', 'ui', 'backend', 'urgent', 'refactor', 'docs', 'test'];
398
+
399
+ // The config lists (Categories / Users / Tags) keep ONLY the user-configured values, de-duplicated.
400
+ // Values that appear only on tasks are NOT folded back in — doing so made these lines grow without
401
+ // bound on every save (every tag/category/user ever used stuck forever). Autocomplete still
402
+ // surfaces task-derived values at runtime. Empty lists fall back to the defaults. Returns a copy.
403
+ export function normalizeConfig(config) {
404
+ const out = { ...config };
405
+ out.categories = [...new Set(config.categories || [])];
406
+ out.users = [...new Set(config.users || [])];
407
+ out.tags = [...new Set(config.tags || [])];
408
+ if (out.categories.length === 0) out.categories = [...DEFAULT_CATEGORIES];
409
+ if (out.users.length === 0) out.users = [...DEFAULT_USERS];
410
+ if (!out.priorities || out.priorities.length === 0) out.priorities = [...DEFAULT_PRIORITIES];
411
+ if (out.tags.length === 0) out.tags = [...DEFAULT_TAGS];
412
+ return out;
413
+ }
414
+
415
+ // One task as a v2 block (kanban.md and archive.md share this format). Ends with a blank line.
416
+ export function generateTaskBlock(task) {
417
+ let md = `### ${task.id} | ${task.title}\n`;
418
+ // One field per line, fixed order; empty fields are omitted. Each line ends in two spaces (a Markdown
419
+ // hard break) so any Markdown viewer shows one field per line; the parser ignores trailing spaces.
420
+ const field = (label, value) => `**${label}**: ${value}${FIELD_BREAK}\n`;
421
+ md += field('Status', task.status);
422
+ if (task.order != null) md += field('Order', task.order);
423
+ if (task.priority) md += field('Priority', task.priority);
424
+ if (task.category) md += field('Category', task.category);
425
+ if (task.assignees.length > 0) md += field('Assigned', task.assignees.join(', '));
426
+ if (task.created) md += field('Created', task.created);
427
+ if (task.started) md += field('Started', task.started);
428
+ if (task.due) md += field('Due', task.due);
429
+ if (task.completed) md += field('Finished', task.completed);
430
+ if (task.tags.length > 0) md += field('Tags', task.tags.join(' '));
431
+ if (task.blockedBy && task.blockedBy.length > 0) md += field('Blocked by', task.blockedBy.join(', '));
432
+
433
+ if (task.description) {
434
+ md += `\n${task.description}\n`;
435
+ }
436
+
437
+ if (task.subtasks.length > 0) {
438
+ md += `\n**Subtasks**:\n`;
439
+ task.subtasks.forEach(st => {
440
+ md += `- [${st.completed ? 'x' : ' '}] ${st.text}\n`;
441
+ });
442
+ }
443
+
444
+ // Re-emit preserved unsupported sections verbatim (before Notes, so a re-parse captures them
445
+ // again as task.extra rather than folding them into notes).
446
+ if (task.extra) {
447
+ md += `\n${task.extra}\n`;
448
+ }
449
+
450
+ if (task.notes) {
451
+ md += `\n**Notes**:\n${task.notes}\n`;
452
+ }
453
+
454
+ return md + `\n`; // one blank line between tasks, no ---
455
+ }
456
+
457
+ // One comment under the Format marker that says what the file is and where its rules and tools live, for an agent
458
+ // or person who finds a board in a repository with nothing else to go on. It is optional: nothing reads it. Boards get
459
+ // it when created or migrated; whole-file rewrites keep it only if the file already had it, so deleting it sticks.
460
+ // (It cannot share the Format line: older readers accept only "<!-- Format: vN -->" there.)
461
+ export const SOURCE_COMMENT = '<!-- Kanban.md board. Format, rules and tools (app, CLI, MCP): https://github.com/ashikahmad/Kanban.md -->';
462
+
463
+ // Whether a board, archive or backlog file carries that comment above its first task.
464
+ export function hasSourceComment(text) {
465
+ if (!text) return false;
466
+ const first = text.search(/^###\s+TASK-/m);
467
+ return /<!--\s*Kanban\.md board\b/i.test(first < 0 ? text : text.slice(0, first));
468
+ }
469
+
470
+ // Whole-board serializer: { config, tasks } -> kanban.md text.
471
+ // The Format marker makes V2 detection unambiguous (vs a legacy column named "Tasks").
472
+ // V2 is a single "## Tasks" section; each task carries its own **Status** (= its column) and its
473
+ // order in the file is its rank within its column, so moving a task between columns is a
474
+ // one-line **Status** edit.
475
+ // `sourceComment` writes SOURCE_COMMENT under the Format marker.
476
+ export function generateBoard({ config, tasks }, { sourceComment = false } = {}) {
477
+ const cfg = normalizeConfig(config);
478
+ const boardTitle = (cfg.boardTitle && cfg.boardTitle.trim()) || 'Kanban Board';
479
+ let md = `# ${boardTitle}\n\n<!-- Config: Last Task ID: ${cfg.lastTaskId} -->\n<!-- Format: v3 -->\n${sourceComment ? SOURCE_COMMENT + '\n' : ''}\n`;
480
+
481
+ md += `## ⚙️ Configuration\n\n`;
482
+ md += `**Columns**: ${cfg.columns.map(c => `${c.name} (${c.id})`).join(' | ')}\n\n`;
483
+ md += `**Categories**: ${cfg.categories.join(', ')}\n\n`;
484
+ md += `**Users**: ${cfg.users.join(', ')}\n\n`;
485
+ md += `**Priorities**: ${cfg.priorities.join(' | ')}\n\n`;
486
+ md += `**Tags**: ${cfg.tags.map(t => '#' + t).join(' ')}\n\n`;
487
+ if (cfg.housekeeping) md += `**Housekeeping**: ${formatHousekeepingSpec(cfg.housekeeping)}\n\n`;
488
+ if (cfg.backlogFile) md += `**Backlog file**: ${formatBacklogFileSpec(cfg.backlogFile)}\n\n`;
489
+ md += `---\n\n`;
490
+
491
+ md += `## Tasks\n\n`;
492
+ tasks.forEach(task => { md += generateTaskBlock(task); });
493
+ return md;
494
+ }
495
+
496
+ // archive.md text. `strings` carries the localized heading lines: { title, desc, section }.
497
+ export function generateArchive(tasks, strings, { sourceComment = false } = {}) {
498
+ let md = `${strings.title}\n<!-- Format: v3 -->\n${sourceComment ? SOURCE_COMMENT + '\n' : ''}\n${strings.desc}\n\n${strings.section}\n\n`;
499
+ tasks.forEach(task => { md += generateTaskBlock(task); });
500
+ return md;
501
+ }
502
+
503
+ // Content for a brand-new kanban.md / archive.md.
504
+ export function createDefaultBoardContent() {
505
+ return `# Kanban Board
506
+
507
+ <!-- Config: Last Task ID: 0 -->
508
+ <!-- Format: v3 -->
509
+ ${SOURCE_COMMENT}
510
+
511
+ ## ⚙️ Configuration
512
+
513
+ **Columns**: 📝 To Do (todo) | 🚀 In Progress (in-progress) | 👀 In Review (in-review) | ✅ Done (done)
514
+
515
+ **Categories**: Frontend, Backend, Design, DevOps, Tests, Documentation
516
+
517
+ **Users**: @user (User)
518
+
519
+ **Priorities**: 🔴 Critical | 🟠 High | 🟡 Medium | 🟢 Low
520
+
521
+ **Tags**: #bug #feature #ui #backend #urgent #refactor #docs #test
522
+
523
+ ---
524
+
525
+ ## Tasks
526
+ `;
527
+ }
528
+
529
+ export function createDefaultArchiveContent(strings) {
530
+ return `${strings.title}
531
+ ${SOURCE_COMMENT}
532
+
533
+ ${strings.desc}
534
+
535
+ ${strings.section}
536
+
537
+ `;
538
+ }
539
+
540
+ // Display order of a board's tasks. With manual ordering on (some tasks carry an **Order** key),
541
+ // tasks sort by key; tasks without one come after the keyed ones, in file order. Without any key
542
+ // this is simply file order. Per-column order is this list filtered by status.
543
+ export function orderedTasks(tasks) {
544
+ return tasks
545
+ .map((task, i) => ({ task, i }))
546
+ .sort((a, b) => ((a.task.order == null) - (b.task.order == null)) || ((a.task.order ?? 0) - (b.task.order ?? 0)) || (a.i - b.i))
547
+ .map((x) => x.task);
548
+ }