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,616 @@
1
+ /**
2
+ * mdboard — Dynamic API handlers
3
+ *
4
+ * All REST API route handlers, fully config-driven.
5
+ * No hardcoded entity names — everything generated from entities.json,
6
+ * structure.json, and api.json.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { URL } = require('url');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const {
16
+ getEntityTypes, getEntity, getFields, getFilterableFields,
17
+ getAncestors, isCompletedStatus, flattenHierarchy,
18
+ } = require('../core/config');
19
+ const { isValidProject } = require('../core/history');
20
+ const { computeStatus } = require('../cli/status');
21
+
22
+ // --- HTTP helpers ---
23
+
24
+ function jsonResponse(res, data, status = 200) {
25
+ res.writeHead(status, {
26
+ 'Content-Type': 'application/json',
27
+ 'Access-Control-Allow-Origin': '*',
28
+ 'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
29
+ 'Access-Control-Allow-Headers': 'Content-Type',
30
+ });
31
+ res.end(JSON.stringify(data, null, 2));
32
+ }
33
+
34
+ function parseBody(req) {
35
+ return new Promise((resolve) => {
36
+ const chunks = [];
37
+ req.on('data', c => chunks.push(c));
38
+ req.on('end', () => {
39
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
40
+ catch { resolve({}); }
41
+ });
42
+ });
43
+ }
44
+
45
+ // --- Route map ---
46
+
47
+ /**
48
+ * Build a map of pluralName → entityType from config.
49
+ * e.g. "tasks" → "task", "cycles" → "cycle"
50
+ */
51
+ function buildRouteMap(cfg) {
52
+ const map = {};
53
+ for (const type of getEntityTypes(cfg)) {
54
+ const entity = getEntity(cfg, type);
55
+ if (entity && entity.plural) {
56
+ map[entity.plural.toLowerCase()] = type;
57
+ }
58
+ }
59
+ return map;
60
+ }
61
+
62
+ // --- Generic formatters ---
63
+
64
+ /**
65
+ * Format an entity for API response.
66
+ * Replaces formatTask/formatEpic/formatMilestone/etc.
67
+ */
68
+ function formatEntity(item, cfg, type, includeContent, ctx) {
69
+ const fields = getFields(cfg, type);
70
+ const result = {};
71
+
72
+ // Core fields
73
+ result.id = item.id;
74
+ result.title = item.title;
75
+ result.created = item.created;
76
+
77
+ // Entity-defined fields
78
+ for (const name of Object.keys(fields)) {
79
+ if (item[name] !== undefined) {
80
+ result[name] = item[name];
81
+ }
82
+ }
83
+
84
+ // Computed fields
85
+ if (item._progress !== undefined) result._progress = item._progress;
86
+ if (item._childCount !== undefined) result._childCount = item._childCount;
87
+ if (item._completedCount !== undefined) result._completedCount = item._completedCount;
88
+ if (item._totalPoints !== undefined) result._totalPoints = item._totalPoints;
89
+ if (item._completedPoints !== undefined) result._completedPoints = item._completedPoints;
90
+
91
+ // Source fields
92
+ if (item._source) {
93
+ result._source = item._source;
94
+ result._sourceLabel = item._sourceLabel || item._source;
95
+ result._sourceColor = item._sourceColor || null;
96
+ result._readonly = item._readonly || false;
97
+ }
98
+
99
+ // Ancestor refs
100
+ const ancestors = getAncestors(cfg, type);
101
+ for (const anc of ancestors) {
102
+ if (item['_' + anc] !== undefined) result['_' + anc] = item['_' + anc];
103
+ }
104
+
105
+ // Content — lazy-loaded from disk
106
+ if (includeContent && ctx) {
107
+ result.content = loadItemContent(item, ctx) || '';
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Lazy-load content from disk for a single entity.
115
+ */
116
+ function loadItemContent(item, ctx) {
117
+ if (!item._file) return '';
118
+ try {
119
+ const { parseFrontmatter } = require('../core/yaml');
120
+ let basePath;
121
+ if (item._source && ctx.sources) {
122
+ const src = ctx.sources.find(s => s.name === item._source);
123
+ basePath = src && src._resolvedPath ? src._resolvedPath : null;
124
+ }
125
+ if (!basePath) {
126
+ basePath = path.join(ctx.projectDir, 'project');
127
+ }
128
+ const raw = fs.readFileSync(path.join(basePath, item._file), 'utf-8');
129
+ const parsed = parseFrontmatter(raw);
130
+ return parsed.content || '';
131
+ } catch {
132
+ return '';
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Filter entities using query params.
138
+ * Uses getFilterableFields to know which params to accept.
139
+ */
140
+ function filterEntities(items, cfg, type, url) {
141
+ const filterableFields = getFilterableFields(cfg, type);
142
+
143
+ for (const field of filterableFields) {
144
+ const value = url.searchParams.get(field);
145
+ if (!value) continue;
146
+
147
+ items = items.filter(item => {
148
+ // Check direct field
149
+ if (item[field] === value) return true;
150
+ // Check ancestor ref (prefixed with _)
151
+ if (item['_' + field] === value) return true;
152
+ return false;
153
+ });
154
+ }
155
+
156
+ // Special: source filter
157
+ const source = url.searchParams.get('source');
158
+ if (source) {
159
+ items = items.filter(item => item._source === source);
160
+ }
161
+
162
+ return items;
163
+ }
164
+
165
+ /**
166
+ * Compute generic metrics across all entity types.
167
+ */
168
+ function computeMetrics(model, cfg) {
169
+ const result = { entities: {} };
170
+ const flat = flattenHierarchy(cfg);
171
+ const hierarchyEntities = flat.filter(e => !e.standalone);
172
+ const leafType = hierarchyEntities.length > 0
173
+ ? hierarchyEntities[hierarchyEntities.length - 1].type
174
+ : null;
175
+
176
+ for (const type of getEntityTypes(cfg)) {
177
+ const items = model.entities[type] || [];
178
+ const total = items.length;
179
+ const completed = items.filter(i => isCompletedStatus(cfg, i.status)).length;
180
+
181
+ const entry = { total, completed };
182
+
183
+ // Leaf type gets point metrics
184
+ if (type === leafType) {
185
+ entry.points = items.reduce((sum, i) => sum + (i.points || 0), 0);
186
+ entry.completedPoints = items
187
+ .filter(i => isCompletedStatus(cfg, i.status))
188
+ .reduce((sum, i) => sum + (i.points || 0), 0);
189
+ }
190
+
191
+ result.entities[type] = entry;
192
+ }
193
+
194
+ return result;
195
+ }
196
+
197
+ /**
198
+ * Compute health status.
199
+ */
200
+ function computeHealth(model, cfg) {
201
+ const flat = flattenHierarchy(cfg);
202
+ const hierarchyEntities = flat.filter(e => !e.standalone);
203
+
204
+ const leafType = hierarchyEntities.length > 0
205
+ ? hierarchyEntities[hierarchyEntities.length - 1].type
206
+ : null;
207
+
208
+ const leafItems = leafType ? (model.entities[leafType] || []) : [];
209
+ const total = leafItems.length;
210
+ const completed = leafItems.filter(i => isCompletedStatus(cfg, i.status)).length;
211
+
212
+ // Find in-progress: look for status field values with "half-circle" icon
213
+ const inProgress = leafItems.filter(i => {
214
+ if (!i.status) return false;
215
+ return !isCompletedStatus(cfg, i.status) && i.status !== 'todo' && i.status !== 'planned';
216
+ }).length;
217
+
218
+ // Active top-level entity
219
+ const topType = hierarchyEntities.length > 0 ? hierarchyEntities[0].type : null;
220
+ const topItems = topType ? (model.entities[topType] || []) : [];
221
+ const activeTop = topItems.find(i => i.status === 'active');
222
+
223
+ return {
224
+ status: 'ok',
225
+ totals: { total, completed, inProgress },
226
+ active: activeTop ? { type: topType, id: activeTop.id, title: activeTop.title } : null,
227
+ };
228
+ }
229
+
230
+ // --- Main API handler ---
231
+
232
+ async function handleApi(req, res, ctx) {
233
+ const { model, config: cfg, port, sources } = ctx;
234
+ const url = new URL(req.url, `http://localhost:${port}`);
235
+ const pathname = url.pathname;
236
+
237
+ // CORS preflight
238
+ if (req.method === 'OPTIONS') {
239
+ res.writeHead(204, {
240
+ 'Access-Control-Allow-Origin': '*',
241
+ 'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
242
+ 'Access-Control-Allow-Headers': 'Content-Type',
243
+ });
244
+ return res.end();
245
+ }
246
+
247
+ const routeMap = buildRouteMap(cfg);
248
+
249
+ // --- Source-scoped routes: /api/sources/:name/... ---
250
+ const sourceMatch = pathname.match(/^\/api\/sources\/([^/]+)\/(.+)$/);
251
+ if (sourceMatch && sourceMatch[2] !== 'sync') {
252
+ const sourceName = decodeURIComponent(sourceMatch[1]);
253
+ const subPath = sourceMatch[2];
254
+ return handleSourceRoute(req, res, ctx, url, routeMap, sourceName, subPath);
255
+ }
256
+
257
+ // --- System routes ---
258
+
259
+ // GET /api/config
260
+ if (req.method === 'GET' && pathname === '/api/config') {
261
+ const result = {
262
+ preset: cfg._preset,
263
+ entities: cfg.entities.entities,
264
+ hierarchy: cfg.structure.hierarchy,
265
+ standalone: cfg.structure.standalone,
266
+ completedStatuses: cfg.entities.completedStatuses,
267
+ priorities: cfg.entities.priorities,
268
+ ui: cfg.ui,
269
+ theme: cfg._theme,
270
+ hasProject: ctx.hasProject != null ? ctx.hasProject : true,
271
+ projectDir: ctx.projectDir || null,
272
+ };
273
+ if (sources && sources.length > 0) {
274
+ result.workspace = {
275
+ sources: sources.map(s => ({
276
+ name: s.name, label: s.label || s.name,
277
+ color: s.color || null, icon: s.icon || s.name.charAt(0).toUpperCase(),
278
+ type: s.type || 'local',
279
+ readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
280
+ })),
281
+ hasMultipleSources: sources.length > 1,
282
+ };
283
+ }
284
+ return jsonResponse(res, result);
285
+ }
286
+
287
+ // GET /api/project
288
+ if (req.method === 'GET' && pathname === '/api/project') {
289
+ return jsonResponse(res, model.project || {});
290
+ }
291
+
292
+ // GET /api/sources
293
+ if (req.method === 'GET' && pathname === '/api/sources') {
294
+ if (!sources || sources.length === 0) return jsonResponse(res, []);
295
+ return jsonResponse(res, sources.map(s => {
296
+ const leafType = getLeafType(cfg);
297
+ const leafItems = leafType ? (model.entities[leafType] || []) : [];
298
+ const sourceTasks = leafItems.filter(t => t._source === s.name);
299
+ return {
300
+ name: s.name,
301
+ label: s.label || s.name,
302
+ color: s.color || null,
303
+ icon: s.icon || s.name.charAt(0).toUpperCase(),
304
+ type: s.type || 'local',
305
+ readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
306
+ taskCount: sourceTasks.length,
307
+ completedCount: sourceTasks.filter(t => isCompletedStatus(cfg, t.status)).length,
308
+ lastSync: s._lastSync || null,
309
+ error: s._error || null,
310
+ };
311
+ }));
312
+ }
313
+
314
+ // GET /api/metrics
315
+ if (req.method === 'GET' && pathname === '/api/metrics') {
316
+ return jsonResponse(res, computeMetrics(model, cfg));
317
+ }
318
+
319
+ // GET /api/health
320
+ if (req.method === 'GET' && pathname === '/api/health') {
321
+ return jsonResponse(res, computeHealth(model, cfg));
322
+ }
323
+
324
+ // GET /api/status
325
+ if (req.method === 'GET' && pathname === '/api/status') {
326
+ return jsonResponse(res, computeStatus(model, cfg));
327
+ }
328
+
329
+ // GET /api/history — auto-prune entries with missing directories
330
+ if (req.method === 'GET' && pathname === '/api/history') {
331
+ const { pruneHistory } = require('../core/history');
332
+ pruneHistory();
333
+ return jsonResponse(res, ctx.getProjectHistory ? ctx.getProjectHistory() : []);
334
+ }
335
+
336
+ // GET /api/events (SSE)
337
+ if (req.method === 'GET' && pathname === '/api/events') {
338
+ res.writeHead(200, {
339
+ 'Content-Type': 'text/event-stream',
340
+ 'Cache-Control': 'no-cache',
341
+ 'Connection': 'keep-alive',
342
+ 'Access-Control-Allow-Origin': '*',
343
+ });
344
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
345
+
346
+ if (ctx.sseClients) ctx.sseClients.add(res);
347
+
348
+ const keepalive = setInterval(() => {
349
+ try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
350
+ }, 30000);
351
+
352
+ req.on('close', () => {
353
+ if (ctx.sseClients) ctx.sseClients.delete(res);
354
+ clearInterval(keepalive);
355
+ });
356
+ return;
357
+ }
358
+
359
+ // POST /api/theme
360
+ if (req.method === 'POST' && pathname === '/api/theme') {
361
+ const body = await parseBody(req);
362
+ if (!body.themeId || !body.css) {
363
+ return jsonResponse(res, { error: 'Missing themeId or css' }, 400);
364
+ }
365
+
366
+ if (body.setDefault) {
367
+ const globalDir = path.join(os.homedir(), '.config', 'mdboard');
368
+ if (!fs.existsSync(globalDir)) fs.mkdirSync(globalDir, { recursive: true });
369
+ fs.writeFileSync(path.join(globalDir, 'mdboard.css'), body.css, 'utf-8');
370
+ const configPath = path.join(globalDir, 'mdboard.json');
371
+ let globalCfg = {};
372
+ try { globalCfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {}
373
+ globalCfg.theme = body.themeId;
374
+ fs.writeFileSync(configPath, JSON.stringify(globalCfg, null, 2) + '\n', 'utf-8');
375
+ }
376
+
377
+ // Save CSS to project
378
+ const projectCssPath = path.join(ctx.projectDir, 'project', 'mdboard.css');
379
+ const fallbackCssPath = path.join(ctx.projectDir, 'mdboard.css');
380
+ const cssPath = fs.existsSync(path.dirname(projectCssPath)) ? projectCssPath : fallbackCssPath;
381
+ fs.writeFileSync(cssPath, body.css, 'utf-8');
382
+
383
+ // Persist themeId in project/mdboard.json
384
+ const projectJsonPath = path.join(ctx.projectDir, 'project', 'mdboard.json');
385
+ const fallbackJsonPath = path.join(ctx.projectDir, 'mdboard.json');
386
+ const jsonPath = fs.existsSync(path.dirname(projectJsonPath)) ? projectJsonPath : fallbackJsonPath;
387
+ let projectCfg = {};
388
+ try { projectCfg = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); } catch {}
389
+ projectCfg.theme = body.themeId;
390
+ fs.writeFileSync(jsonPath, JSON.stringify(projectCfg, null, 2) + '\n', 'utf-8');
391
+
392
+ if (ctx.reloadConfig) ctx.reloadConfig();
393
+ return jsonResponse(res, { ok: true });
394
+ }
395
+
396
+ // POST /api/history/switch
397
+ if (req.method === 'POST' && pathname === '/api/history/switch') {
398
+ const body = await parseBody(req);
399
+ const targetPath = body && body.path;
400
+ if (!targetPath) return jsonResponse(res, { error: 'Missing path' }, 400);
401
+ if (!fs.existsSync(targetPath)) return jsonResponse(res, { error: 'Directory does not exist: ' + targetPath }, 400);
402
+ if (!isValidProject(targetPath)) return jsonResponse(res, { error: 'Not a valid mdboard project: ' + targetPath }, 400);
403
+ if (ctx.loadProject) {
404
+ try {
405
+ ctx.loadProject(targetPath);
406
+ const name = (ctx.model && ctx.model.project && ctx.model.project.name) || path.basename(targetPath);
407
+ return jsonResponse(res, { ok: true, projectDir: ctx.projectDir, name });
408
+ } catch (err) {
409
+ return jsonResponse(res, { error: err.message }, 500);
410
+ }
411
+ }
412
+ return jsonResponse(res, { error: 'Project switching not available' }, 500);
413
+ }
414
+
415
+ // POST /api/sources — add a new source to workspace.json
416
+ if (req.method === 'POST' && pathname === '/api/sources') {
417
+ const body = await parseBody(req);
418
+ if (!body.name || !body.type) return jsonResponse(res, { error: 'Missing name or type' }, 400);
419
+ if (body.type === 'local' && !body.path) return jsonResponse(res, { error: 'Missing path for local source' }, 400);
420
+ if (body.type === 'remote' && !body.url) return jsonResponse(res, { error: 'Missing url for remote source' }, 400);
421
+
422
+ // Load or create workspace.json
423
+ const wsPath = ctx.workspacePath || path.join(ctx.projectDir, 'workspace.json');
424
+ let ws;
425
+ try {
426
+ ws = JSON.parse(fs.readFileSync(wsPath, 'utf-8'));
427
+ } catch {
428
+ ws = { sources: [] };
429
+ }
430
+ if (!Array.isArray(ws.sources)) ws.sources = [];
431
+
432
+ // Check duplicates
433
+ if (ws.sources.some(s => s.name === body.name)) {
434
+ return jsonResponse(res, { error: 'A source with name "' + body.name + '" already exists' }, 400);
435
+ }
436
+
437
+ const entry = { name: body.name, type: body.type };
438
+ if (body.type === 'local') {
439
+ entry.path = path.relative(path.dirname(wsPath), body.path) || body.path;
440
+ if (body.root) entry.root = body.root;
441
+ } else {
442
+ entry.url = body.url;
443
+ entry.branch = body.branch || 'main';
444
+ entry.readonly = true;
445
+ if (body.root) entry.root = body.root;
446
+ }
447
+ if (body.color) entry.color = body.color;
448
+
449
+ ws.sources.push(entry);
450
+ fs.writeFileSync(wsPath, JSON.stringify(ws, null, 2) + '\n', 'utf-8');
451
+
452
+ if (ctx.onWorkspaceChange) ctx.onWorkspaceChange();
453
+ return jsonResponse(res, { ok: true, source: entry }, 201);
454
+ }
455
+
456
+ // POST /api/sources/sync
457
+ if (req.method === 'POST' && pathname === '/api/sources/sync') {
458
+ if (ctx.syncRemotes) {
459
+ try {
460
+ await ctx.syncRemotes();
461
+ return jsonResponse(res, { ok: true, message: 'Sync complete' });
462
+ } catch (err) {
463
+ return jsonResponse(res, { error: err.message }, 500);
464
+ }
465
+ }
466
+ return jsonResponse(res, { ok: true, message: 'No remote sources' });
467
+ }
468
+
469
+ // --- Dynamic CRUD routes ---
470
+
471
+ // Match /api/:plural or /api/:plural/:id
472
+ const crudMatch = pathname.match(/^\/api\/([\w-]+?)(?:\/([\w:.-]+))?$/);
473
+ if (crudMatch) {
474
+ const plural = crudMatch[1];
475
+ const id = crudMatch[2] ? decodeURIComponent(crudMatch[2]) : null;
476
+ const entityType = routeMap[plural];
477
+
478
+ if (entityType) {
479
+ return handleCrud(req, res, ctx, url, cfg, routeMap, entityType, id);
480
+ }
481
+ }
482
+
483
+ return jsonResponse(res, { error: 'Not found' }, 404);
484
+ }
485
+
486
+ // --- Dynamic CRUD handler ---
487
+
488
+ async function handleCrud(req, res, ctx, url, cfg, routeMap, entityType, id, sourceName) {
489
+ const { model } = ctx;
490
+ const items = model.entities[entityType] || [];
491
+
492
+ // GET list
493
+ if (req.method === 'GET' && !id) {
494
+ const filtered = filterEntities(items, cfg, entityType, url);
495
+ const formatted = filtered.map(item => formatEntity(item, cfg, entityType, false));
496
+ return jsonResponse(res, formatted);
497
+ }
498
+
499
+ // GET single
500
+ if (req.method === 'GET' && id) {
501
+ const item = items.find(i => i.id === id) || items.find(i => i._originalId === id);
502
+ if (!item) return jsonResponse(res, { error: entityType + ' not found: ' + id }, 404);
503
+ return jsonResponse(res, formatEntity(item, cfg, entityType, true, ctx));
504
+ }
505
+
506
+ // POST create
507
+ if (req.method === 'POST' && !id) {
508
+ const body = await parseBody(req);
509
+
510
+ // Resolve source
511
+ let targetSource = sourceName || null;
512
+ if (!targetSource && ctx.sources && ctx.sources.length > 0) {
513
+ if (body._source) {
514
+ targetSource = body._source;
515
+ } else {
516
+ const writable = ctx.sources.filter(s =>
517
+ !(s.readonly != null ? s.readonly : (s.type === 'remote'))
518
+ );
519
+ targetSource = writable.length > 0 ? writable[0].name
520
+ : (ctx.sources.find(s => !(s.readonly != null ? s.readonly : (s.type === 'remote')))?.name || null);
521
+ }
522
+ }
523
+
524
+ if (!ctx.createItem) return jsonResponse(res, { error: 'Create not supported' }, 400);
525
+
526
+ try {
527
+ const result = ctx.createItem(targetSource, entityType, body);
528
+ if (ctx.rescanAll) ctx.rescanAll();
529
+ if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
530
+ return jsonResponse(res, { ok: true, ...result }, 201);
531
+ } catch (err) {
532
+ return jsonResponse(res, { error: err.message }, 400);
533
+ }
534
+ }
535
+
536
+ // PATCH update
537
+ if (req.method === 'PATCH' && id) {
538
+ const item = items.find(i => i.id === id) || items.find(i => i._originalId === id);
539
+ if (!item) return jsonResponse(res, { error: entityType + ' not found: ' + id }, 404);
540
+ if (item._readonly) return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
541
+
542
+ const body = await parseBody(req);
543
+ try {
544
+ if (ctx.updateFile) ctx.updateFile(item._source || null, item._file, body);
545
+ if (ctx.rescanAll) ctx.rescanAll();
546
+ if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
547
+ return jsonResponse(res, { ok: true });
548
+ } catch (err) {
549
+ return jsonResponse(res, { error: err.message }, 500);
550
+ }
551
+ }
552
+
553
+ // DELETE
554
+ if (req.method === 'DELETE' && id) {
555
+ const item = items.find(i => i.id === id) || items.find(i => i._originalId === id);
556
+ if (!item) return jsonResponse(res, { error: entityType + ' not found: ' + id }, 404);
557
+ if (item._readonly) return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
558
+
559
+ try {
560
+ if (ctx.deleteItem) ctx.deleteItem(item._source || null, item);
561
+ if (ctx.rescanAll) ctx.rescanAll();
562
+ if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
563
+ return jsonResponse(res, { ok: true, deleted: true });
564
+ } catch (err) {
565
+ return jsonResponse(res, { error: err.message }, 500);
566
+ }
567
+ }
568
+
569
+ return jsonResponse(res, { error: 'Method not allowed' }, 405);
570
+ }
571
+
572
+ // --- Source-scoped routes ---
573
+
574
+ async function handleSourceRoute(req, res, ctx, url, routeMap, sourceName, subPath) {
575
+ const { model, config: cfg, sources } = ctx;
576
+
577
+ const source = sources ? sources.find(s => s.name === sourceName) : null;
578
+ if (!source) return jsonResponse(res, { error: 'Source not found: ' + sourceName }, 404);
579
+
580
+ const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
581
+
582
+ // Check readonly for write operations
583
+ if ((req.method === 'POST' || req.method === 'DELETE') && isReadonly) {
584
+ return jsonResponse(res, { error: 'Source is read-only' }, 403);
585
+ }
586
+
587
+ // Parse subPath: could be "tasks", "tasks/TASK-001", etc.
588
+ const decodedSubPath = decodeURIComponent(subPath);
589
+ const subMatch = decodedSubPath.match(/^([\w-]+?)(?:\/([\w:.-]+))?$/);
590
+ if (!subMatch) return jsonResponse(res, { error: 'Not found' }, 404);
591
+
592
+ const plural = subMatch[1];
593
+ const id = subMatch[2] || null;
594
+ const entityType = routeMap[plural];
595
+
596
+ if (!entityType) return jsonResponse(res, { error: 'Not found' }, 404);
597
+
598
+ // Filter model items by source
599
+ const items = (model.entities[entityType] || []).filter(i => i._source === sourceName);
600
+
601
+ // Create a source-filtered context for CRUD
602
+ const sourceModel = { ...model, entities: { ...model.entities, [entityType]: items } };
603
+ const sourceCtx = { ...ctx, model: sourceModel };
604
+
605
+ return handleCrud(req, res, sourceCtx, url, cfg, routeMap, entityType, id, sourceName);
606
+ }
607
+
608
+ // --- Utility ---
609
+
610
+ function getLeafType(cfg) {
611
+ const flat = flattenHierarchy(cfg);
612
+ const hierarchyEntities = flat.filter(e => !e.standalone);
613
+ return hierarchyEntities.length > 0 ? hierarchyEntities[hierarchyEntities.length - 1].type : null;
614
+ }
615
+
616
+ module.exports = { handleApi, jsonResponse, buildRouteMap, formatEntity, filterEntities, computeMetrics, computeHealth };