@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.
@@ -0,0 +1,130 @@
1
+ // Turn "the page's state differs from the last saved state" into a list of ops, and apply a list of
2
+ // ops to a pair of file texts. This is what lets the page keep mutating its in-memory arrays (and
3
+ // its ~16 save call sites) while the file is only ever changed by minimal, verified patches that are
4
+ // applied to the file's CURRENT content, so edits made elsewhere in the meantime survive.
5
+ //
6
+ // State shape (the page's own): { config, tasks, archive, backlog? } (see parseBoard / parseArchive;
7
+ // backlog is the optional backlog file's tasks). With a backlog file a task's status decides which file
8
+ // it lives in, so moving a task into or out of the backlog is just a status update.
9
+ // Not expressible as ops (reported in `unsupported`, caller falls back): changes to preserved
10
+ // sections (`extra`), the board title, or a task that appears directly in the archive.
11
+ import { applyOp, orderKinds } from './ops.js';
12
+ import { deepEqual } from './text.js';
13
+
14
+ const TASK_KEYS = ['title', 'status', 'order', 'priority', 'category', 'assignees', 'tags', 'blockedBy', 'created', 'started', 'due', 'completed', 'description', 'subtasks', 'notes'];
15
+ const LISTS = ['categories', 'users', 'priorities', 'tags'];
16
+ const byId = (tasks) => new Map(tasks.map((t) => [t.id, t]));
17
+
18
+ export function diffToOps(base, next) {
19
+ const ops = [], unsupported = [];
20
+ // "live" = the board plus the backlog file: the two files a task moves between by changing its status
21
+ const bB = byId([...base.tasks, ...(base.backlog || [])]), bA = byId(base.archive);
22
+ const nB = byId([...next.tasks, ...(next.backlog || [])]), nA = byId(next.archive);
23
+ const baseColumnIds = base.config.columns.map((c) => c.id);
24
+ const nextColumnIds = next.config.columns.map((c) => c.id);
25
+
26
+ // 1. deleted
27
+ for (const id of [...bB.keys(), ...bA.keys()]) if (!nB.has(id) && !nA.has(id)) ops.push({ type: 'deleteTask', id });
28
+
29
+ // 2. archived / restored
30
+ for (const id of bB.keys()) if (nA.has(id)) ops.push({ type: 'archiveTask', id });
31
+ for (const id of bA.keys()) if (nB.has(id)) ops.push({ type: 'restoreTask', id });
32
+
33
+ // 3. columns. Tasks whose column id was renamed or removed are re-homed by `reassign`, so the
34
+ // per-task status updates for them are skipped below.
35
+ const reassign = {};
36
+ const colChanged = !deepEqual(base.config.columns, next.config.columns);
37
+ const rehomed = new Set();
38
+ if (colChanged) {
39
+ for (const old of baseColumnIds.filter((id) => !nextColumnIds.includes(id))) {
40
+ // Where did the tasks of this removed column end up? Renamed column: they followed it. Deleted
41
+ // column: the page leaves a board task with the old id, which on disk means "first column"
42
+ // (how an unknown status reads back); an archived task is left alone.
43
+ const dest = new Map(); // destination -> task ids
44
+ const note = (id, to) => dest.set(to, [...(dest.get(to) || []), id]);
45
+ for (const [id, bt] of bB) {
46
+ if (bt.status !== old) continue;
47
+ const nt = nB.get(id) || nA.get(id);
48
+ if (!nt) continue;
49
+ if (nA.has(id) && !nextColumnIds.includes(nt.status)) continue; // archived with an orphan status: left alone, like the rest of the archive
50
+ note(id, nextColumnIds.includes(nt.status) ? nt.status : nextColumnIds[0]);
51
+ }
52
+ for (const [id, bt] of bA) {
53
+ if (bt.status !== old) continue;
54
+ const nt = nA.get(id) || nB.get(id);
55
+ if (nt && nextColumnIds.includes(nt.status)) note(id, nt.status);
56
+ }
57
+ if (!dest.size) continue;
58
+ // The most common destination becomes the column-wide reassign; any task that went elsewhere
59
+ // is moved by its own updateTask afterwards.
60
+ const [to, ids] = [...dest.entries()].sort((a, b) => b[1].length - a[1].length)[0];
61
+ reassign[old] = to;
62
+ for (const id of ids) rehomed.add(id);
63
+ }
64
+ ops.push({ type: 'setColumns', columns: next.config.columns.map((c) => ({ name: c.name, id: c.id })), ...(Object.keys(reassign).length && { reassign }) });
65
+ }
66
+
67
+ // 4. field changes on tasks present in both
68
+ const updatesFor = (bt, nt) => {
69
+ const set = {};
70
+ for (const k of TASK_KEYS) {
71
+ if (k === 'status' && rehomed.has(nt.id)) continue;
72
+ if (!deepEqual(bt[k], nt[k])) set[k] = k === 'order' && nt[k] === null ? '' : nt[k];
73
+ }
74
+ if (!deepEqual(bt.extra, nt.extra)) unsupported.push(`${nt.id}: preserved sections (Links/Review/Dependencies) cannot be edited`);
75
+ return set;
76
+ };
77
+ for (const [id, nt] of [...nB, ...nA]) {
78
+ const bt = bB.get(id) || bA.get(id);
79
+ if (!bt) continue;
80
+ let before = bt;
81
+ if (bA.has(id) && nB.has(id)) { // restored: the restore op itself applies the page's fallback status
82
+ const fallback = next.config.columns.some((c) => c.id === bt.status) ? bt.status : next.config.columns[0].id;
83
+ before = { ...bt, status: fallback };
84
+ }
85
+ const set = updatesFor(before, nt);
86
+ if (Object.keys(set).length) ops.push({ type: 'updateTask', id, set });
87
+ }
88
+
89
+ // 5. created (appended last, as the page appends)
90
+ for (const [id, nt] of nB) {
91
+ if (bB.has(id) || bA.has(id)) continue;
92
+ const task = {};
93
+ for (const k of TASK_KEYS) {
94
+ const v = nt[k];
95
+ if (k === 'order' ? v !== null : !(v === '' || (Array.isArray(v) && !v.length))) task[k] = v;
96
+ }
97
+ ops.push({ type: 'createTask', task, clientId: id });
98
+ }
99
+ for (const id of nA.keys()) if (!bA.has(id) && !bB.has(id)) unsupported.push(`${id}: a task appeared directly in the archive`);
100
+
101
+ // 6. config lists
102
+ for (const name of LISTS) {
103
+ if (!deepEqual(base.config[name], next.config[name])) ops.push({ type: 'setConfigList', name, values: next.config[name] });
104
+ }
105
+ if (base.config.boardTitle !== next.config.boardTitle) unsupported.push('the board title changed');
106
+ if (!deepEqual(base.config.housekeeping, next.config.housekeeping)) unsupported.push('the housekeeping settings changed');
107
+
108
+ return { ops, unsupported };
109
+ }
110
+
111
+ // Apply ops in order to { board, archive, backlog } texts. Returns the final texts, the files to write (only
112
+ // those that really differ from the start, destinations before sources) and each op's result. Stops at the
113
+ // first failing op (its BoardError carries `opIndex`).
114
+ export function applyOps(ws, ops, options = {}) {
115
+ const start = { board: ws.board, archive: ws.archive ?? null, backlog: ws.backlog ?? null };
116
+ let cur = { ...start };
117
+ const results = [], moves = [], names = {};
118
+ for (let i = 0; i < ops.length; i++) {
119
+ const { clientId, ...op } = ops[i];
120
+ let out;
121
+ try { out = applyOp(cur, op, options); }
122
+ catch (e) { e.opIndex = i; throw e; }
123
+ for (const f of out.files) names[f.kind] = f.name;
124
+ moves.push(...out.moves);
125
+ cur = { board: out.board, archive: out.archive, backlog: out.backlog };
126
+ results.push({ ...out.result, ...(clientId && { clientId }) });
127
+ }
128
+ const kinds = orderKinds(Object.keys(names).filter((k) => cur[k] !== start[k]), moves);
129
+ return { ...cur, results, files: kinds.map((k) => ({ name: names[k], kind: k, text: cur[k] })) };
130
+ }
package/core/dates.js ADDED
@@ -0,0 +1,14 @@
1
+ // Date helpers on YYYY-MM-DD strings. Pure.
2
+ export function localToday() {
3
+ const d = new Date();
4
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
5
+ }
6
+
7
+ // Days since 1970-01-01 for a real calendar date, else null (JS would roll 2026-02-31 over; we do not).
8
+ export function dayNumber(iso) {
9
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
10
+ if (!m) return null;
11
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
12
+ const d = new Date(t);
13
+ return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? t / 86400000 : null;
14
+ }
@@ -0,0 +1,62 @@
1
+ // The optional "**Housekeeping**:" config line and the policy it resolves to. Pure, no imports.
2
+ //
3
+ // **Housekeeping**: done=done after=30d | backlog=backlog after=90d | max=200
4
+ // **Housekeeping**: off
5
+ //
6
+ // Absent line = the defaults below (nudges are ON by default; they only ever suggest, never act).
7
+ // Only what is written in the line is stored in config.housekeeping; resolveHousekeeping fills in
8
+ // the rest, and guesses the done / backlog columns from their names when none is configured.
9
+ export const HOUSEKEEPING_DEFAULTS = { doneAfterDays: 30, backlogAfterDays: 90, max: 200, backlogFileAbove: 100 };
10
+
11
+ const DONE_NAME = /^(done|completed?|closed|finished|released|shipped)$/i;
12
+ const BACKLOG_NAME = /backlog/i;
13
+ const plain = (s) => s.replace(/[^\w\s-]/g, '').trim();
14
+
15
+ // "done=done after=30d | backlog=backlog after=90d | max=200" | "off" -> { enabled, done?, backlog?, max? } | null
16
+ export function parseHousekeepingSpec(text) {
17
+ if (text === undefined || text === null) return null;
18
+ const out = { enabled: true };
19
+ for (const seg of String(text).split('|').map((s) => s.trim()).filter(Boolean)) {
20
+ if (/^off$/i.test(seg)) return { enabled: false };
21
+ // a segment is "<name>[=<value>] [after=<N>d]": done=done after=30d, backlog after=90d, max=200
22
+ const [head, ...rest] = seg.split(/\s+/);
23
+ const [name, value] = head.split('=');
24
+ const kv = Object.fromEntries(rest.map((tok) => tok.split('=')).filter((p) => p.length === 2).map(([k, v]) => [k.toLowerCase(), v]));
25
+ const days = kv.after !== undefined && /^\d+d?$/i.test(kv.after) ? parseInt(kv.after, 10) : undefined;
26
+ const key = name.toLowerCase();
27
+ if (key === 'done' || key === 'backlog') out[key] = { ...(value && { column: value }), ...(days !== undefined && { afterDays: days }) };
28
+ else if (key === 'max' && /^\d+$/.test(value || '')) out.max = parseInt(value, 10);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ export function formatHousekeepingSpec(h) {
34
+ if (!h) return '';
35
+ if (h.enabled === false) return 'off';
36
+ const parts = [];
37
+ const side = (name, v) => { if (v) parts.push([`${name}=${v.column || ''}`.replace(/=$/, ''), v.afterDays !== undefined ? `after=${v.afterDays}d` : ''].filter(Boolean).join(' ')); };
38
+ side('done', h.done);
39
+ side('backlog', h.backlog);
40
+ if (h.max !== undefined) parts.push(`max=${h.max}`);
41
+ return parts.join(' | ') || 'on';
42
+ }
43
+
44
+ // Canonical stored form of a user-supplied object (what parse(format(x)) gives back).
45
+ export function normalizeHousekeeping(h) {
46
+ return parseHousekeepingSpec(formatHousekeepingSpec(h)) || null;
47
+ }
48
+
49
+ // The full policy for a board: explicit values, else defaults; done / backlog columns guessed by name.
50
+ export function resolveHousekeeping(config) {
51
+ const h = config.housekeeping || { enabled: true };
52
+ const cols = config.columns || [];
53
+ const find = (re) => (cols.find((c) => re.test(c.id) || re.test(plain(c.name))) || {}).id || null;
54
+ const known = (id) => (id && cols.some((c) => c.id === id) ? id : null);
55
+ return {
56
+ enabled: h.enabled !== false,
57
+ done: { column: known(h.done && h.done.column) || (h.done && h.done.column ? null : find(DONE_NAME)), afterDays: (h.done && h.done.afterDays) ?? HOUSEKEEPING_DEFAULTS.doneAfterDays },
58
+ backlog: { column: known(h.backlog && h.backlog.column) || (h.backlog && h.backlog.column ? null : find(BACKLOG_NAME)), afterDays: (h.backlog && h.backlog.afterDays) ?? HOUSEKEEPING_DEFAULTS.backlogAfterDays },
59
+ max: h.max ?? HOUSEKEEPING_DEFAULTS.max,
60
+ backlogFileAbove: HOUSEKEEPING_DEFAULTS.backlogFileAbove,
61
+ };
62
+ }
@@ -0,0 +1,81 @@
1
+ // Housekeeping: which tasks a board would be better off without, per its policy (see
2
+ // housekeeping-spec.js). Suggestions only: nothing here changes a file. Acting on one is an
3
+ // ordinary op (archiveTasks), so it is one verified diff and can be previewed with dry_run.
4
+ import { parseBoard, parseBacklog } from './board.js';
5
+ import { resolveBacklogFile } from './backlog-spec.js';
6
+ import { resolveHousekeeping } from './housekeeping-spec.js';
7
+ import { dayNumber, localToday } from './dates.js';
8
+
9
+ const LIST_CAP = 20;
10
+
11
+ // The board plus, when a backlog file is configured, its tasks: housekeeping is about all live tasks.
12
+ function loadLive(ws) {
13
+ const bp = parseBoard(ws.board);
14
+ const bf = resolveBacklogFile(bp.config);
15
+ const store = bf && typeof ws.backlog === 'string' ? parseBacklog(ws.backlog, bp.config.columns, bf.column).tasks : [];
16
+ return { bp, bf, live: [...bp.tasks, ...store] };
17
+ }
18
+
19
+ function candidates(bp, live, policy, today) {
20
+ const now = dayNumber(today);
21
+ const age = (iso) => { const d = dayNumber(iso); return d === null || now === null ? null : now - d; };
22
+ const done = policy.done.column ? bp.tasks.filter((t) => t.status === policy.done.column) : [];
23
+ const doneOld = done.filter((t) => t.completed && age(t.completed) >= policy.done.afterDays)
24
+ .sort((a, b) => a.completed.localeCompare(b.completed));
25
+ const backlog = policy.backlog.column ? live.filter((t) => t.status === policy.backlog.column) : [];
26
+ const backlogStale = backlog.filter((t) => {
27
+ const created = t.created ? age(t.created) : null;
28
+ const overdue = t.due && !t.completed ? age(t.due) : null; // how long past its due date, if it is
29
+ return (created !== null && created >= policy.backlog.afterDays) || (overdue !== null && overdue >= policy.backlog.afterDays);
30
+ }).sort((a, b) => (a.created || '').localeCompare(b.created || ''));
31
+ return { done, doneOld, backlog, backlogStale };
32
+ }
33
+
34
+ // { policy, suggestions: [{ kind, count, ..., ids (capped), message }] }; suggestions is empty when
35
+ // nothing is over a threshold (so it costs nothing to include).
36
+ export function getHousekeeping(ws, { today = localToday() } = {}) {
37
+ const { bp, bf, live } = loadLive(ws);
38
+ const policy = resolveHousekeeping(bp.config);
39
+ const out = { policy, suggestions: [] };
40
+ if (!policy.enabled) return out;
41
+ const c = candidates(bp, live, policy, today);
42
+ const capped = (tasks) => tasks.slice(0, LIST_CAP).map((t) => t.id);
43
+
44
+ if (c.doneOld.length) {
45
+ const undated = c.done.filter((t) => !t.completed).length;
46
+ out.suggestions.push({
47
+ kind: 'archive_done', count: c.doneOld.length, afterDays: policy.done.afterDays, ids: capped(c.doneOld), ...(undated && { undated }),
48
+ message: `${c.doneOld.length} task(s) in "${policy.done.column}" were finished ${policy.done.afterDays}+ days ago: archive them to keep the board small.`,
49
+ });
50
+ }
51
+ if (c.backlogStale.length) {
52
+ out.suggestions.push({
53
+ kind: 'stale_backlog', count: c.backlogStale.length, afterDays: policy.backlog.afterDays, ids: capped(c.backlogStale),
54
+ message: `${c.backlogStale.length} task(s) in "${policy.backlog.column}" are ${policy.backlog.afterDays}+ days old: review, archive or delete them.`,
55
+ });
56
+ }
57
+ // A big backlog is the one thing a separate file fixes: suggest it (once it is on, there is nothing to suggest).
58
+ if (!bf && c.backlog.length > policy.backlogFileAbove) {
59
+ out.suggestions.push({ kind: 'backlog_large', count: c.backlog.length, max: policy.backlogFileAbove, column: policy.backlog.column, message: `${c.backlog.length} tasks in "${policy.backlog.column}" (limit ${policy.backlogFileAbove}): move them to their own backlog file to keep the board small.` });
60
+ }
61
+ if (bp.tasks.length > policy.max) {
62
+ out.suggestions.push({ kind: 'board_large', count: bp.tasks.length, max: policy.max, message: `${bp.tasks.length} tasks on the board (limit ${policy.max}): archive what is finished.` });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // Every id the stale-backlog nudge flags (the page highlights those cards).
68
+ export function staleBacklogIds(ws, { today = localToday() } = {}) {
69
+ const { bp, live } = loadLive(ws);
70
+ const policy = resolveHousekeeping(bp.config);
71
+ if (!policy.enabled) return [];
72
+ return candidates(bp, live, policy, today).backlogStale.map((t) => t.id);
73
+ }
74
+
75
+ // Every id the "archive done" nudge would archive (the suggestion's own list is capped for display).
76
+ export function archiveCandidates(ws, { today = localToday() } = {}) {
77
+ const { bp, live } = loadLive(ws);
78
+ const policy = resolveHousekeeping(bp.config);
79
+ if (!policy.enabled) return [];
80
+ return candidates(bp, live, policy, today).doneOld.map((t) => t.id);
81
+ }
package/core/index.js ADDED
@@ -0,0 +1,14 @@
1
+ // Everything the page needs from the core, as one bundle entry (see build.mjs). Node code imports
2
+ // the individual modules directly.
3
+ export * from './text.js';
4
+ export * from './board.js';
5
+ export * from './migrate.js';
6
+ export * from './ops.js';
7
+ export * from './read.js';
8
+ export * from './changes.js';
9
+ export * from './housekeeping-spec.js';
10
+ export * from './backlog-spec.js';
11
+ export * from './share.js';
12
+ export * from './links.js';
13
+ export * from './board-url.js';
14
+ export * from './housekeeping.js';
package/core/links.js ADDED
@@ -0,0 +1,55 @@
1
+ // The label as a metadata line only when the value is nothing but task ids. A hand-written line such as
2
+ // "**Blocked by**: waiting on legal" is ordinary description text and stays exactly as written.
3
+ export const BLOCKED_LABEL = 'Blocked by(?=\\*\\*\\s*:[ \\t]*(?:[Tt][Aa][Ss][Kk]-\\d+[\\s,]*)+$)';
4
+
5
+ // Task links: **Blocked by** is the only stored relation. "Blocks" is derived from it, so the two can never
6
+ // disagree. Advice only: nothing here stops a move or a save.
7
+
8
+ // A task is finished when it sits in the board's last column or has been archived (an archived task counts as
9
+ // done whatever its status line says: archiving is how a finished task leaves the board).
10
+ // `where` says which file a task lives in: 'board' | 'backlog' | 'archive'.
11
+ export function indexTasks({ board = [], backlog = [], archive = [] }) {
12
+ const byId = new Map();
13
+ for (const [where, tasks] of [['archive', archive], ['backlog', backlog], ['board', board]]) {
14
+ for (const task of tasks) byId.set(task.id, { task, where });
15
+ }
16
+ return byId;
17
+ }
18
+
19
+ export function isFinished(entry, columns) {
20
+ if (!entry) return false;
21
+ if (entry.where === 'archive') return true;
22
+ return columns.length > 0 && entry.task.status === columns[columns.length - 1].id;
23
+ }
24
+
25
+ // What a task waits on: { open: [entry], done: [entry], unknown: [id] }. `open` is what the badge counts; a
26
+ // blocker in the backlog file is open, an id that matches no task is unknown and counts for nothing.
27
+ export function blockersOf(task, byId, columns) {
28
+ const out = { open: [], done: [], unknown: [] };
29
+ for (const id of task.blockedBy || []) {
30
+ const entry = byId.get(id);
31
+ if (!entry) out.unknown.push(id);
32
+ else (isFinished(entry, columns) ? out.done : out.open).push(entry);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ // The tasks whose **Blocked by** names `id`, board and backlog first (they are the ones still in play).
38
+ export function blockedByThis(id, byId) {
39
+ return [...byId.values()].filter(({ task }) => (task.blockedBy || []).includes(id));
40
+ }
41
+
42
+ // Does following **Blocked by** links from `id` come back to it? (A warning in the UI, never an error.)
43
+ export function hasCycle(id, byId) {
44
+ const seen = new Set();
45
+ const walk = (cur) => {
46
+ for (const next of (byId.get(cur)?.task.blockedBy) || []) {
47
+ if (next === id) return true;
48
+ if (seen.has(next)) continue;
49
+ seen.add(next);
50
+ if (walk(next)) return true;
51
+ }
52
+ return false;
53
+ };
54
+ return walk(id);
55
+ }
@@ -0,0 +1,69 @@
1
+ // Migration of kanban.md / archive.md to the current format (v3: one metadata field per line).
2
+ //
3
+ // Accepts v1 (status by section) and v2 (joined metadata lines). The file is rewritten
4
+ // canonically, and the result is only returned if re-parsing it gives exactly the same tasks and
5
+ // configuration as the original: a migration that would lose or change anything is refused.
6
+ import { parseBoard, parseArchive, generateBoard, generateTaskBlock, normalizeConfig, SOURCE_COMMENT, hasSourceComment } from './board.js';
7
+ import { BoardError, deepEqual, firstDifference, detectEol, toEol, splitLines, stripEol } from './text.js';
8
+
9
+ export const CURRENT_VERSION = 3;
10
+
11
+ const unsafe = (message, details) => new BoardError('migration_unsafe', message, { details, hint: 'The file was left untouched. Fix the reported problem by hand and run the migration again.' });
12
+
13
+ // The parser keeps a file's line endings inside notes and preserved sections, so when a mixed
14
+ // file is rewritten with one ending, the expected tasks are normalized the same way.
15
+ const withEol = (tasks, eol) => tasks.map((t) => ({ ...t, notes: t.notes.replace(/\r?\n/g, eol), extra: t.extra.replace(/\r?\n/g, eol) }));
16
+
17
+ function assertTotalParse(md, tasks, what) {
18
+ // Every "### TASK-" line must have produced exactly one distinct task, or a rewrite would drop one.
19
+ const blocks = (md.match(/^###\s+TASK-/gm) || []).length;
20
+ const unique = new Set(tasks.map((t) => t.id)).size;
21
+ if (tasks.length !== blocks || unique !== blocks) {
22
+ throw unsafe(`${what}: ${tasks.length} of ${blocks} task blocks could be read (${unique} distinct ids)`, { blocks, parsed: tasks.length, unique });
23
+ }
24
+ }
25
+
26
+ // kanban.md text -> { text, from, changed }
27
+ export function migrateBoardText(md) {
28
+ const before = parseBoard(md);
29
+ const from = before.formatVersion;
30
+ if (from >= CURRENT_VERSION) return { text: md, from, changed: false };
31
+ assertTotalParse(md, before.tasks, 'kanban.md');
32
+
33
+ const eol = detectEol(md);
34
+ const text = toEol(generateBoard({ config: before.config, tasks: before.tasks }, { sourceComment: true }), eol);
35
+
36
+ const after = parseBoard(text);
37
+ const diff = firstDifference(
38
+ { config: normalizeConfig(before.config), tasks: withEol(before.tasks, eol) },
39
+ { config: after.config, tasks: after.tasks });
40
+ if (diff || after.formatVersion !== CURRENT_VERSION) throw unsafe('kanban.md would not read back identically after migration', diff || { path: 'formatVersion' });
41
+ return { text, from, changed: true };
42
+ }
43
+
44
+ // archive.md text -> { text, from, changed }. The archive's own heading lines (title, description,
45
+ // section, in whatever language) are kept verbatim; only the format marker and the task blocks change.
46
+ export function migrateArchiveText(md, columns) {
47
+ const before = parseArchive(md, columns);
48
+ const from = before.formatVersion;
49
+ if (from >= CURRENT_VERSION) return { text: md, from, changed: false };
50
+ assertTotalParse(md, before.tasks, 'archive.md');
51
+
52
+ const eol = detectEol(md);
53
+ const lines = splitLines(md);
54
+ let firstTask = lines.findIndex((l) => /^###\s+TASK-/.test(l));
55
+ if (firstTask < 0) firstTask = lines.length;
56
+ const head = lines.slice(0, firstTask).filter((l) => !/<!--\s*Format:/i.test(l));
57
+ if (!head.length) head.push('# Archive' + eol);
58
+ const hadSource = hasSourceComment(md);
59
+ head.splice(1, 0, `<!-- Format: v${CURRENT_VERSION} -->` + eol, ...(hadSource ? [] : [SOURCE_COMMENT + eol]));
60
+ while (head.length && stripEol(head[head.length - 1]).trim() === '') head.pop();
61
+ const text = toEol(head.join('') + '\n\n' + before.tasks.map(generateTaskBlock).join(''), eol);
62
+
63
+ const after = parseArchive(text, columns);
64
+ const diff = firstDifference(withEol(before.tasks, eol), after.tasks);
65
+ if (diff || after.formatVersion !== CURRENT_VERSION || !deepEqual(after.tasks.length, before.tasks.length)) {
66
+ throw unsafe('archive.md would not read back identically after migration', diff || { path: 'formatVersion' });
67
+ }
68
+ return { text, from, changed: true };
69
+ }