mdboard 1.2.0 → 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.
Files changed (40) hide show
  1. package/bin.js +130 -44
  2. package/index.html +3321 -1195
  3. package/package.json +10 -11
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +338 -0
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +175 -0
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +180 -0
  28. package/src/core/config.js +551 -0
  29. package/src/core/history.js +146 -0
  30. package/src/core/scanner.js +521 -0
  31. package/{workspace.js → src/core/workspace.js} +0 -15
  32. package/{yaml.js → src/core/yaml.js} +5 -1
  33. package/src/server/api.js +616 -0
  34. package/{server.js → src/server/server.js} +180 -132
  35. package/{watcher.js → src/server/watcher.js} +40 -9
  36. package/api.js +0 -752
  37. package/config.js +0 -73
  38. package/defaults.json +0 -43
  39. package/init.js +0 -109
  40. package/scanner.js +0 -491
@@ -0,0 +1,521 @@
1
+ /**
2
+ * mdboard — Dynamic file scanner and in-memory model
3
+ *
4
+ * Scans project directories based on config-driven hierarchy.
5
+ * No hardcoded entity types — everything comes from entities.json + structure.json.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { parseFrontmatter, serializeYaml } = require('./yaml');
13
+ const config = require('./config');
14
+
15
+ // --- Utilities ---
16
+
17
+ function safeReadFile(filePath) {
18
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
19
+ }
20
+
21
+ function safeDirEntries(dir) {
22
+ try { return fs.readdirSync(dir).filter(e => !e.startsWith('.')); } catch { return []; }
23
+ }
24
+
25
+ function slugify(text) {
26
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled';
27
+ }
28
+
29
+ /**
30
+ * Check if a filename matches an entity's file pattern.
31
+ * Supports "README.md" (directory entity) and "PREFIX-NNN.md" (file entity).
32
+ */
33
+ function matchesFilePattern(filename, entityDef) {
34
+ const pattern = entityDef.file || 'README.md';
35
+ if (pattern === 'README.md') return filename === 'README.md';
36
+ if (pattern === 'PREFIX-NNN.md') {
37
+ if (!filename.endsWith('.md')) return false;
38
+ const prefix = entityDef.prefix;
39
+ return prefix && filename.startsWith(prefix + '-');
40
+ }
41
+ return filename === pattern;
42
+ }
43
+
44
+ /**
45
+ * Check if an entity uses directory-based storage (README.md inside a named dir).
46
+ */
47
+ function isDirEntity(entityDef) {
48
+ return (entityDef.file || 'README.md') === 'README.md';
49
+ }
50
+
51
+ // --- Model ---
52
+
53
+ /**
54
+ * Create an empty model. Collections are keyed by entity type.
55
+ * @param {object} cfg - Loaded config
56
+ * @returns {object}
57
+ */
58
+ function createModel(cfg) {
59
+ const model = { project: null, entities: {} };
60
+ for (const type of config.getEntityTypes(cfg)) {
61
+ model.entities[type] = [];
62
+ }
63
+ return model;
64
+ }
65
+
66
+ // --- Scanner ---
67
+
68
+ /**
69
+ * Scan a single source directory and return all entities found.
70
+ *
71
+ * @param {string} sourcePath - Absolute path to the project/ directory
72
+ * @param {object} cfg - Loaded config
73
+ * @param {object} [sourceMeta] - { name, label, color, type, readonly }
74
+ * @returns {object} - { project, entities: { cycle: [...], bet: [...], ... } }
75
+ */
76
+ function scanSource(sourcePath, cfg, sourceMeta) {
77
+ const result = createModel(cfg);
78
+ if (!fs.existsSync(sourcePath)) return result;
79
+
80
+ const meta = sourceMeta || {};
81
+ const sourcePrefix = meta.name ? meta.name + ':' : '';
82
+
83
+ function tag(item) {
84
+ if (meta.name) {
85
+ item._source = meta.name;
86
+ item._sourceLabel = meta.label || meta.name;
87
+ item._sourceColor = meta.color || null;
88
+ item._sourceType = meta.type || 'local';
89
+ item._readonly = meta.readonly || false;
90
+ }
91
+ return item;
92
+ }
93
+
94
+ function qualifyId(item) {
95
+ if (sourcePrefix && item.id) {
96
+ item._originalId = item.id;
97
+ item.id = sourcePrefix + item.id;
98
+ }
99
+ return item;
100
+ }
101
+
102
+ // Scan PROJECT.md
103
+ const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
104
+ if (projectMd) {
105
+ const parsed = parseFrontmatter(projectMd);
106
+ result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
107
+ }
108
+
109
+ // Scan hierarchy entities
110
+ const hierarchy = config.getHierarchy(cfg);
111
+ scanHierarchyLevel(sourcePath, hierarchy, [], result, cfg, tag, qualifyId);
112
+
113
+ // Scan standalone entities
114
+ const standalone = (cfg.structure && cfg.structure.standalone) || {};
115
+ for (const [type, def] of Object.entries(standalone)) {
116
+ scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Recursively scan a level of the hierarchy.
124
+ *
125
+ * @param {string} basePath - Current filesystem path
126
+ * @param {object} levelDef - Hierarchy level definition { entityType: { dir, children } }
127
+ * @param {object[]} ancestors - Array of { type, slug } for parent context
128
+ * @param {object} result - Model being built
129
+ * @param {object} cfg - Config
130
+ * @param {function} tag - Source tagger
131
+ * @param {function} qualifyId - ID qualifier
132
+ */
133
+ function scanHierarchyLevel(basePath, levelDef, ancestors, result, cfg, tag, qualifyId) {
134
+ for (const [type, def] of Object.entries(levelDef)) {
135
+ const entityDef = config.getEntity(cfg, type);
136
+ if (!entityDef) continue;
137
+
138
+ const entityDir = path.join(basePath, def.dir);
139
+ if (!fs.existsSync(entityDir)) continue;
140
+
141
+ if (isDirEntity(entityDef)) {
142
+ // Directory entity: each subdirectory is an instance
143
+ for (const slug of safeDirEntries(entityDir)) {
144
+ const instanceDir = path.join(entityDir, slug);
145
+ if (!fs.statSync(instanceDir).isDirectory()) continue;
146
+
147
+ const content = safeReadFile(path.join(instanceDir, 'README.md'));
148
+ if (!content) continue;
149
+
150
+ const parsed = parseFrontmatter(content);
151
+ const relFile = buildRelFile(ancestors, def.dir, slug, 'README.md');
152
+
153
+ const item = qualifyId(tag({
154
+ ...parsed.frontmatter,
155
+ _contentLength: parsed.content ? parsed.content.length : 0,
156
+ _type: type,
157
+ _dir: slug,
158
+ _file: relFile,
159
+ }));
160
+
161
+ // Store ancestor references
162
+ for (const anc of ancestors) {
163
+ item['_' + anc.type] = anc.slug;
164
+ }
165
+
166
+ result.entities[type].push(item);
167
+
168
+ // Recurse into children
169
+ if (def.children) {
170
+ const childAncestors = [...ancestors, { type, slug, dir: def.dir }];
171
+ scanHierarchyLevel(instanceDir, def.children, childAncestors, result, cfg, tag, qualifyId);
172
+ }
173
+ }
174
+ } else {
175
+ // File entity: each matching file in the dir is an instance
176
+ for (const file of safeDirEntries(entityDir)) {
177
+ if (!matchesFilePattern(file, entityDef)) continue;
178
+
179
+ const content = safeReadFile(path.join(entityDir, file));
180
+ if (!content) continue;
181
+
182
+ const parsed = parseFrontmatter(content);
183
+ const relFile = buildRelFile(ancestors, def.dir, null, file);
184
+
185
+ const item = qualifyId(tag({
186
+ ...parsed.frontmatter,
187
+ _contentLength: parsed.content ? parsed.content.length : 0,
188
+ _type: type,
189
+ _filename: file,
190
+ _file: relFile,
191
+ }));
192
+
193
+ for (const anc of ancestors) {
194
+ item['_' + anc.type] = anc.slug;
195
+ }
196
+
197
+ result.entities[type].push(item);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Scan a standalone entity directory (flat, not in hierarchy).
205
+ */
206
+ function scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId) {
207
+ const entityDef = config.getEntity(cfg, type);
208
+ if (!entityDef) return;
209
+
210
+ const dir = path.join(sourcePath, def.dir);
211
+ if (!fs.existsSync(dir)) return;
212
+
213
+ if (isDirEntity(entityDef)) {
214
+ for (const slug of safeDirEntries(dir)) {
215
+ const instanceDir = path.join(dir, slug);
216
+ if (!fs.statSync(instanceDir).isDirectory()) continue;
217
+
218
+ const content = safeReadFile(path.join(instanceDir, 'README.md'));
219
+ if (!content) continue;
220
+
221
+ const parsed = parseFrontmatter(content);
222
+ result.entities[type].push(qualifyId(tag({
223
+ ...parsed.frontmatter,
224
+ _contentLength: parsed.content ? parsed.content.length : 0,
225
+ _type: type,
226
+ _dir: slug,
227
+ _file: def.dir + '/' + slug + '/README.md',
228
+ })));
229
+ }
230
+ } else {
231
+ for (const file of safeDirEntries(dir)) {
232
+ if (!matchesFilePattern(file, entityDef)) continue;
233
+
234
+ const content = safeReadFile(path.join(dir, file));
235
+ if (!content) continue;
236
+
237
+ const parsed = parseFrontmatter(content);
238
+ const slug = file.replace(/\.md$/, '');
239
+
240
+ result.entities[type].push(qualifyId(tag({
241
+ id: parsed.frontmatter.id || slug,
242
+ title: parsed.frontmatter.title || slug,
243
+ ...parsed.frontmatter,
244
+ _contentLength: parsed.content ? parsed.content.length : 0,
245
+ _type: type,
246
+ _filename: file,
247
+ _file: def.dir + '/' + file,
248
+ })));
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Build a relative file path from ancestors.
255
+ * Each ancestor has { type, slug, dir } where dir is the hierarchy directory name.
256
+ */
257
+ function buildRelFile(ancestors, dir, slug, filename) {
258
+ const parts = [];
259
+ for (const anc of ancestors) {
260
+ parts.push(anc.dir); // hierarchy dir name (e.g. "cycles")
261
+ parts.push(anc.slug); // instance slug (e.g. "cycle-01")
262
+ }
263
+ parts.push(dir);
264
+ if (slug) parts.push(slug);
265
+ parts.push(filename);
266
+ return parts.join('/');
267
+ }
268
+
269
+ // --- Progress ---
270
+
271
+ /**
272
+ * Compute progress for all entities that have children.
273
+ * Walks the hierarchy bottom-up: leaf entities contribute to parent progress.
274
+ *
275
+ * @param {object} model - Scanned model
276
+ * @param {object} cfg - Config
277
+ */
278
+ function computeProgress(model, cfg) {
279
+ const flat = config.flattenHierarchy(cfg);
280
+ const completedStatuses = (cfg.entities && cfg.entities.completedStatuses) || [];
281
+
282
+ // Find the leaf entity type (deepest in hierarchy, not standalone)
283
+ const hierarchyEntities = flat.filter(e => !e.standalone);
284
+ if (hierarchyEntities.length === 0) return;
285
+
286
+ const leafType = hierarchyEntities[hierarchyEntities.length - 1].type;
287
+ const leafItems = model.entities[leafType] || [];
288
+
289
+ // For each non-leaf hierarchy entity, compute progress from its leaf descendants
290
+ for (const entry of hierarchyEntities) {
291
+ if (entry.type === leafType) continue;
292
+
293
+ const items = model.entities[entry.type] || [];
294
+ for (const item of items) {
295
+ // Find leaf items that descend from this entity
296
+ const descendants = leafItems.filter(leaf => {
297
+ return leaf['_' + entry.type] === item._dir &&
298
+ (!item._source || leaf._source === item._source);
299
+ });
300
+
301
+ const total = descendants.length;
302
+ const done = descendants.filter(d => {
303
+ const status = d.status || '';
304
+ return completedStatuses.includes(status);
305
+ }).length;
306
+
307
+ item._childCount = total;
308
+ item._completedCount = done;
309
+ item._progress = total > 0 ? Math.round((done / total) * 100) : 0;
310
+ item._totalPoints = descendants.reduce((sum, d) => sum + (d.points || 0), 0);
311
+ item._completedPoints = descendants
312
+ .filter(d => completedStatuses.includes(d.status || ''))
313
+ .reduce((sum, d) => sum + (d.points || 0), 0);
314
+ }
315
+ }
316
+ }
317
+
318
+ // --- CRUD ---
319
+
320
+ /**
321
+ * Get the next auto-incremented ID for an entity type.
322
+ *
323
+ * @param {object[]} items - Existing items
324
+ * @param {string} prefix - ID prefix (e.g. "TASK", "CYC", "BET")
325
+ * @returns {string}
326
+ */
327
+ function getNextId(items, prefix) {
328
+ let max = 0;
329
+ const re = new RegExp('^(?:[\\w]+:)?' + prefix + '-(\\d+)$');
330
+ for (const item of items) {
331
+ const id = item._originalId || item.id || '';
332
+ const m = id.match(re);
333
+ if (m) {
334
+ const n = parseInt(m[1], 10);
335
+ if (n > max) max = n;
336
+ }
337
+ }
338
+ return prefix + '-' + String(max + 1).padStart(3, '0');
339
+ }
340
+
341
+ /**
342
+ * Create a new entity.
343
+ * Generic function that replaces createMilestone, createEpic, createTask, createSprint.
344
+ *
345
+ * @param {string} sourcePath - Absolute path to project/ dir
346
+ * @param {object} cfg - Loaded config
347
+ * @param {string} type - Entity type
348
+ * @param {object} data - Entity data (title + field values + parent slugs)
349
+ * @param {object[]} existingItems - Current items of this type for ID generation
350
+ * @returns {object} - { id, slug, file }
351
+ */
352
+ function createEntity(sourcePath, cfg, type, data, existingItems) {
353
+ const entityDef = config.getEntity(cfg, type);
354
+ if (!entityDef) throw new Error('Unknown entity type: ' + type);
355
+
356
+ const prefix = entityDef.prefix;
357
+ const id = getNextId(existingItems, prefix);
358
+
359
+ // Build ancestor ids map from data
360
+ const ancestors = config.getAncestors(cfg, type);
361
+ const ids = {};
362
+ for (const anc of ancestors) {
363
+ if (!data[anc]) {
364
+ throw new Error(anc + ' is required to create a ' + type);
365
+ }
366
+ ids[anc] = data[anc];
367
+ }
368
+
369
+ // resolveEntityPath includes root (e.g. "project/cycles/..."), but sourcePath
370
+ // already points to project/, so strip the root prefix
371
+ const root = config.getRoot(cfg);
372
+ const relPath = config.resolveEntityPath(cfg, type, ids).replace(new RegExp('^' + root + '/'), '');
373
+ const dirPath = path.join(sourcePath, relPath);
374
+
375
+ if (isDirEntity(entityDef)) {
376
+ // Directory entity
377
+ const slug = slugify(data.title || id);
378
+ const instanceDir = path.join(dirPath, slug);
379
+
380
+ if (fs.existsSync(instanceDir)) {
381
+ throw new Error(entityDef.singular + ' directory already exists: ' + slug);
382
+ }
383
+ fs.mkdirSync(instanceDir, { recursive: true });
384
+
385
+ // Create child directories
386
+ const children = config.getChildren(cfg, type);
387
+ for (const childType of children) {
388
+ const childEntry = config.flattenHierarchy(cfg).find(e => e.type === childType);
389
+ if (childEntry) {
390
+ fs.mkdirSync(path.join(instanceDir, childEntry.dir), { recursive: true });
391
+ }
392
+ }
393
+
394
+ const fm = buildFrontmatter(cfg, type, id, data);
395
+ const yaml = serializeYaml(fm);
396
+ const content = data.content || '';
397
+ fs.writeFileSync(
398
+ path.join(instanceDir, 'README.md'),
399
+ '---\n' + yaml + '\n---\n\n' + content + '\n',
400
+ 'utf-8'
401
+ );
402
+
403
+ const relFile = relPath + '/' + slug + '/README.md';
404
+ return { id, slug, file: relFile };
405
+
406
+ } else {
407
+ // File entity
408
+ const filename = id + '.md';
409
+ if (!fs.existsSync(dirPath)) {
410
+ fs.mkdirSync(dirPath, { recursive: true });
411
+ }
412
+
413
+ const fm = buildFrontmatter(cfg, type, id, data);
414
+ const yaml = serializeYaml(fm);
415
+ const content = data.content || '';
416
+ fs.writeFileSync(
417
+ path.join(dirPath, filename),
418
+ '---\n' + yaml + '\n---\n\n' + content + '\n',
419
+ 'utf-8'
420
+ );
421
+
422
+ const relFile = relPath + '/' + filename;
423
+ return { id, slug: id, file: relFile };
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Build frontmatter object from entity fields config and provided data.
429
+ */
430
+ function buildFrontmatter(cfg, type, id, data) {
431
+ const fields = config.getFields(cfg, type);
432
+ const fm = { id };
433
+
434
+ if (data.title) fm.title = data.title;
435
+
436
+ for (const [name, def] of Object.entries(fields)) {
437
+ if (data[name] !== undefined) {
438
+ fm[name] = data[name];
439
+ } else if (def.default !== undefined) {
440
+ fm[name] = def.default;
441
+ }
442
+ }
443
+
444
+ fm.created = new Date().toISOString().split('T')[0];
445
+ return fm;
446
+ }
447
+
448
+ /**
449
+ * Update an entity's markdown file.
450
+ *
451
+ * @param {string} sourcePath - Absolute path to project/ dir
452
+ * @param {string} relFile - Relative file path
453
+ * @param {object} updates - Fields to update
454
+ */
455
+ function updateEntity(sourcePath, relFile, updates) {
456
+ const filePath = path.join(sourcePath, relFile);
457
+ if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
458
+
459
+ const raw = fs.readFileSync(filePath, 'utf-8');
460
+ const parsed = parseFrontmatter(raw);
461
+
462
+ const newFm = { ...parsed.frontmatter };
463
+ let body = parsed.content;
464
+
465
+ for (const [k, v] of Object.entries(updates)) {
466
+ if (k === 'content') { body = v; continue; }
467
+ if (k.startsWith('_')) continue;
468
+ newFm[k] = v;
469
+ }
470
+
471
+ const yaml = serializeYaml(newFm);
472
+ fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
473
+ }
474
+
475
+ /**
476
+ * Delete (archive) an entity.
477
+ *
478
+ * @param {string} sourcePath - Absolute path to project/ dir
479
+ * @param {object} item - The item with _file property
480
+ * @returns {boolean}
481
+ */
482
+ function deleteEntity(sourcePath, item) {
483
+ if (!item || !item._file) throw new Error('Item has no file path');
484
+ const srcPath = path.join(sourcePath, item._file);
485
+ if (!fs.existsSync(srcPath)) throw new Error('File not found: ' + item._file);
486
+ fs.unlinkSync(srcPath);
487
+ return true;
488
+ }
489
+
490
+ /**
491
+ * Merge results from multiple scanSource calls into a single model.
492
+ */
493
+ function mergeResults(model, results) {
494
+ for (const r of results) {
495
+ if (r.project && !model.project) model.project = r.project;
496
+ for (const type of Object.keys(model.entities)) {
497
+ if (r.entities[type]) {
498
+ model.entities[type].push(...r.entities[type]);
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ // --- Export ---
505
+
506
+ module.exports = {
507
+ createModel,
508
+ scanSource,
509
+ computeProgress,
510
+ mergeResults,
511
+ getNextId,
512
+ createEntity,
513
+ updateEntity,
514
+ deleteEntity,
515
+ // Utilities
516
+ safeReadFile,
517
+ safeDirEntries,
518
+ slugify,
519
+ matchesFilePattern,
520
+ isDirEntity,
521
+ };
@@ -78,21 +78,6 @@ function loadWorkspace(projectDir, explicitPath) {
78
78
  }
79
79
  }
80
80
 
81
- // Also resolve the overview (workspace root's project/ dir)
82
- const overviewPath = path.join(projectDir, 'project');
83
- if (fs.existsSync(overviewPath)) {
84
- ws.sources.unshift({
85
- name: 'overview',
86
- label: 'Overview',
87
- icon: '\u2605',
88
- color: '#5B6EF5',
89
- type: 'local',
90
- readonly: false,
91
- _resolvedPath: overviewPath,
92
- _repoRoot: projectDir,
93
- });
94
- }
95
-
96
81
  ws._path = wsPath;
97
82
  ws.settings = ws.settings || {};
98
83
 
@@ -117,7 +117,11 @@ function serializeYaml(obj) {
117
117
  } else if (typeof value === 'object') {
118
118
  lines.push(key + ':');
119
119
  for (const [k, v] of Object.entries(value)) {
120
- lines.push(' ' + k + ': ' + serializeValue(v));
120
+ if (Array.isArray(v)) {
121
+ lines.push(' ' + k + ': ' + (v.length === 0 ? '[]' : '[' + v.map(sv => serializeValue(sv)).join(', ') + ']'));
122
+ } else {
123
+ lines.push(' ' + k + ': ' + serializeValue(v));
124
+ }
121
125
  }
122
126
  } else {
123
127
  lines.push(key + ': ' + serializeValue(value));