@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/.claude/skills/markdown-task-manager/SKILL.md +77 -0
- package/LICENSE +373 -0
- package/README.md +77 -2
- package/cli/init.js +139 -0
- package/cli/kanbanmd.js +192 -0
- package/cli/serve.js +161 -0
- package/core/backlog-spec.js +48 -0
- package/core/board-url.js +35 -0
- package/core/board.js +548 -0
- package/core/changes.js +130 -0
- package/core/dates.js +14 -0
- package/core/housekeeping-spec.js +62 -0
- package/core/housekeeping.js +81 -0
- package/core/index.js +14 -0
- package/core/links.js +55 -0
- package/core/migrate.js +69 -0
- package/core/ops.js +750 -0
- package/core/patch.js +438 -0
- package/core/read.js +252 -0
- package/core/share.js +41 -0
- package/core/text.js +145 -0
- package/io/node-fs.js +188 -0
- package/kanbanmd.html +1 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +201 -0
- package/package.json +50 -4
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
|
+
}
|