mdboard 1.3.0 → 2.1.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 (53) hide show
  1. package/bin.js +117 -59
  2. package/index.html +2161 -1579
  3. package/package.json +7 -5
  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 +186 -210
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +128 -76
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +126 -96
  28. package/src/core/config.js +491 -38
  29. package/src/core/history.js +17 -1
  30. package/src/core/scanner.js +373 -463
  31. package/src/core/workspace.js +0 -15
  32. package/src/server/api.js +464 -741
  33. package/src/server/server.js +105 -130
  34. package/build.js +0 -44
  35. package/defaults.json +0 -43
  36. package/src/cli/sync.js +0 -194
  37. package/src/cli/theme.js +0 -142
  38. package/src/client/app.js +0 -266
  39. package/src/client/board.js +0 -157
  40. package/src/client/core.js +0 -331
  41. package/src/client/editor.js +0 -318
  42. package/src/client/history.js +0 -137
  43. package/src/client/metrics.js +0 -38
  44. package/src/client/milestones.js +0 -77
  45. package/src/client/notes.js +0 -183
  46. package/src/client/overview.js +0 -104
  47. package/src/client/panel.js +0 -637
  48. package/src/client/styles.css +0 -471
  49. package/src/client/table.js +0 -111
  50. package/src/client/template.html +0 -144
  51. package/src/client/themes.js +0 -261
  52. package/src/client/workspace.js +0 -164
  53. package/src/core/agent-scanner.js +0 -260
package/src/server/api.js CHANGED
@@ -1,20 +1,25 @@
1
1
  /**
2
- * mdboard — API handlers
2
+ * mdboard — Dynamic API handlers
3
3
  *
4
- * All REST API route handlers. Receives a context object with
5
- * model, config, and helper functions.
6
- *
7
- * Supports:
8
- * - Source-scoped routes: /api/sources/:name/tasks, etc.
9
- * - Overview routes: /api/overview/milestones, etc.
10
- * - Legacy routes: /api/tasks, etc. (backward compatible)
11
- * - CRUD: POST to create, DELETE to archive
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.
12
7
  */
13
8
 
9
+ 'use strict';
10
+
14
11
  const { URL } = require('url');
15
12
  const fs = require('fs');
16
- const { resolveTheme } = require('../core/config');
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');
17
19
  const { isValidProject } = require('../core/history');
20
+ const { computeStatus } = require('../cli/status');
21
+
22
+ // --- HTTP helpers ---
18
23
 
19
24
  function jsonResponse(res, data, status = 200) {
20
25
  res.writeHead(status, {
@@ -37,198 +42,195 @@ function parseBody(req) {
37
42
  });
38
43
  }
39
44
 
45
+ // --- Route map ---
46
+
40
47
  /**
41
- * Build PATCH route map from config.
48
+ * Build a map of pluralName → entityType from config.
49
+ * e.g. "tasks" → "task", "cycles" → "cycle"
42
50
  */
43
- function buildPatchMap(config) {
44
- const map = {
45
- features: 'tasks', tasks: 'tasks',
46
- epics: 'epics', milestones: 'milestones', sprints: 'sprints',
47
- notes: 'notes',
48
- };
49
- map[config.entities.task.plural.toLowerCase()] = 'tasks';
50
- map[config.entities.epic.plural.toLowerCase()] = 'epics';
51
- map[config.entities.milestone.plural.toLowerCase()] = 'milestones';
52
- map[config.entities.sprint.plural.toLowerCase()] = 'sprints';
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
+ }
53
59
  return map;
54
60
  }
55
61
 
56
- function sourceFields(item) {
57
- if (!item._source) return {};
58
- return {
59
- source: item._source,
60
- sourceLabel: item._sourceLabel || item._source,
61
- sourceColor: item._sourceColor || null,
62
- readonly: item._readonly || false,
63
- author: item._author || null,
64
- };
65
- }
62
+ // --- Generic formatters ---
66
63
 
67
- function formatTask(f) {
68
- return {
69
- id: f.id,
70
- title: f.title,
71
- epic: f._epic || f.epic,
72
- milestone: f._milestone || f.milestone,
73
- sprint: f.sprint,
74
- status: f.status,
75
- priority: f.priority,
76
- points: f.points,
77
- assigned: f.assigned,
78
- branches: f.branches,
79
- pull_requests: f.pull_requests,
80
- links: f.links || null,
81
- created: f.created,
82
- started: f.started,
83
- completed: f.completed,
84
- content: f.content,
85
- ai: f._ai && Object.keys(f._ai).length > 0 ? f._ai : undefined,
86
- aiOwn: f._aiOwn && Object.keys(f._aiOwn).length > 0 ? f._aiOwn : undefined,
87
- ...sourceFields(f),
88
- };
89
- }
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
+ }
90
83
 
91
- function formatMilestone(ms) {
92
- return {
93
- id: ms.id,
94
- title: ms.title,
95
- status: ms.status,
96
- deadline: ms.deadline,
97
- tracks: ms.tracks || null,
98
- progress: ms._progress || 0,
99
- featureCount: ms._featureCount || 0,
100
- completedCount: ms._completedCount || 0,
101
- created: ms.created,
102
- content: ms.content,
103
- ai: ms._ai && Object.keys(ms._ai).length > 0 ? ms._ai : undefined,
104
- aiOwn: ms._aiOwn && Object.keys(ms._aiOwn).length > 0 ? ms._aiOwn : undefined,
105
- ...sourceFields(ms),
106
- };
107
- }
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
+ }
108
98
 
109
- function formatEpic(e) {
110
- return {
111
- id: e.id,
112
- title: e.title,
113
- milestone: e._milestone,
114
- status: e.status,
115
- priority: e.priority,
116
- dependencies: e.dependencies,
117
- featureCount: e._featureCount || 0,
118
- completedCount: e._completedCount || 0,
119
- totalPoints: e._totalPoints || 0,
120
- progress: e._progress || 0,
121
- content: e.content,
122
- ai: e._ai && Object.keys(e._ai).length > 0 ? e._ai : undefined,
123
- aiOwn: e._aiOwn && Object.keys(e._aiOwn).length > 0 ? e._aiOwn : undefined,
124
- ...sourceFields(e),
125
- };
126
- }
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
+ }
127
104
 
128
- function formatSprint(s) {
129
- return {
130
- id: s.id,
131
- milestone: s._milestone,
132
- status: s.status,
133
- goal: s.goal,
134
- start_date: s.start_date,
135
- end_date: s.end_date,
136
- planned_points: s.planned_points,
137
- completed_points: s.completed_points,
138
- features: s.features,
139
- ...sourceFields(s),
140
- };
141
- }
105
+ // Content — lazy-loaded from disk
106
+ if (includeContent && ctx) {
107
+ result.content = loadItemContent(item, ctx) || '';
108
+ }
142
109
 
143
- function formatNote(n, includeContent) {
144
- const result = {
145
- id: n.id,
146
- title: n.title,
147
- created: n.created,
148
- updated: n.updated,
149
- ...sourceFields(n),
150
- };
151
- if (includeContent) result.content = n.content;
152
110
  return result;
153
111
  }
154
112
 
155
- function filterTasks(tasks, url) {
156
- const status = url.searchParams.get('status');
157
- const epic = url.searchParams.get('epic');
158
- const milestone = url.searchParams.get('milestone');
159
- const sprint = url.searchParams.get('sprint');
160
- const source = url.searchParams.get('source');
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
+ }
161
155
 
162
- if (status) tasks = tasks.filter(f => f.status === status);
163
- if (epic) tasks = tasks.filter(f => f.epic === epic);
164
- if (milestone) tasks = tasks.filter(f => f.milestone === milestone);
165
- if (sprint) tasks = tasks.filter(f => f.sprint === sprint);
166
- if (source) tasks = tasks.filter(f => f.source === source);
156
+ // Special: source filter
157
+ const source = url.searchParams.get('source');
158
+ if (source) {
159
+ items = items.filter(item => item._source === source);
160
+ }
167
161
 
168
- return tasks;
162
+ return items;
169
163
  }
170
164
 
171
- function computeHealth(model, config) {
172
- const completedStatus = config.completedStatus;
173
- const inProgressStatus = (config.statuses.task.find(s => s.icon === 'half-circle') || {}).key || 'in-progress';
174
-
175
- const activeMilestone = model.milestones.find(m => m.status === 'active');
176
- const activeSprint = model.sprints.find(s => s.status === 'active');
177
- const totalFeatures = model.tasks.length;
178
- const completedFeatures = model.tasks.filter(f => f.status === completedStatus).length;
179
- const inProgressFeatures = model.tasks.filter(f => f.status === inProgressStatus).length;
180
-
181
- const completedSprints = model.sprints.filter(s => s.status === 'completed');
182
- let velocity = null;
183
- if (completedSprints.length > 0) {
184
- const totalVelocity = completedSprints.reduce((sum, s) => {
185
- const planned = s.planned_points || 1;
186
- const completed = s.completed_points || 0;
187
- return sum + Math.round((completed / planned) * 100);
188
- }, 0);
189
- velocity = Math.round(totalVelocity / completedSprints.length);
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;
190
192
  }
191
193
 
192
- return {
193
- status: 'ok',
194
- activeMilestone: activeMilestone ? activeMilestone.id : null,
195
- activeSprint: activeSprint ? activeSprint.id : null,
196
- totalFeatures,
197
- completedFeatures,
198
- inProgressFeatures,
199
- velocity,
200
- };
194
+ return result;
201
195
  }
202
196
 
203
197
  /**
204
- * Get items filtered by source name from the global model.
198
+ * Compute health status.
205
199
  */
206
- function itemsBySource(model, sourceName) {
207
- const filter = (arr) => arr.filter(x => x._source === sourceName);
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
+
208
223
  return {
209
- project: model.project && model.project._source === sourceName ? model.project : null,
210
- milestones: filter(model.milestones),
211
- epics: filter(model.epics),
212
- tasks: filter(model.tasks),
213
- sprints: filter(model.sprints),
214
- boards: filter(model.boards),
215
- reviews: filter(model.reviews),
216
- notes: filter(model.notes),
224
+ status: 'ok',
225
+ totals: { total, completed, inProgress },
226
+ active: activeTop ? { type: topType, id: activeTop.id, title: activeTop.title } : null,
217
227
  };
218
228
  }
219
229
 
220
- /**
221
- * Handle all API requests.
222
- *
223
- * @param {object} req - HTTP request
224
- * @param {object} res - HTTP response
225
- * @param {object} ctx - Context object:
226
- * model, config, port, projectDir, broadcast, sources, sourceStates,
227
- * updateFile(sourceName, relFile, updates), rescanAll(), syncRemotes(),
228
- * createItem(sourceName, collection, data), archiveItem(sourceName, collection, id)
229
- */
230
+ // --- Main API handler ---
231
+
230
232
  async function handleApi(req, res, ctx) {
231
- const { model, config, port, sources } = ctx;
233
+ const { model, config: cfg, port, sources } = ctx;
232
234
  const url = new URL(req.url, `http://localhost:${port}`);
233
235
  const pathname = url.pathname;
234
236
 
@@ -242,652 +244,373 @@ async function handleApi(req, res, ctx) {
242
244
  return res.end();
243
245
  }
244
246
 
245
- // ─── Source-scoped routes: /api/sources/:name/... ───────────────────
247
+ const routeMap = buildRouteMap(cfg);
248
+
249
+ // --- Source-scoped routes: /api/sources/:name/... ---
246
250
  const sourceMatch = pathname.match(/^\/api\/sources\/([^/]+)\/(.+)$/);
247
- if (sourceMatch) {
251
+ if (sourceMatch && sourceMatch[2] !== 'sync') {
248
252
  const sourceName = decodeURIComponent(sourceMatch[1]);
249
253
  const subPath = sourceMatch[2];
250
- return handleSourceRoute(req, res, ctx, url, sourceName, subPath);
254
+ return handleSourceRoute(req, res, ctx, url, routeMap, sourceName, subPath);
251
255
  }
252
256
 
253
- // ─── Overview routes: /api/overview/... ─────────────────────────────
254
- if (pathname.startsWith('/api/overview/')) {
255
- return handleOverviewRoute(req, res, ctx, url, pathname);
256
- }
257
-
258
- // ─── GET /api/history ───────────────────────────────────────────────
259
- if (req.method === 'GET' && pathname === '/api/history') {
260
- if (ctx.getProjectHistory) {
261
- return jsonResponse(res, ctx.getProjectHistory());
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
+ };
262
283
  }
263
- return jsonResponse(res, []);
284
+ return jsonResponse(res, result);
264
285
  }
265
286
 
266
- // ─── POST routes ────────────────────────────────────────────────────
267
- if (req.method === 'POST') {
268
- if (pathname === '/api/history/switch') {
269
- const body = await parseBody(req);
270
- const targetPath = body && body.path;
271
- if (!targetPath) {
272
- return jsonResponse(res, { error: 'Missing path' }, 400);
273
- }
274
- if (!fs.existsSync(targetPath)) {
275
- return jsonResponse(res, { error: 'Directory does not exist: ' + targetPath }, 400);
276
- }
277
- if (!isValidProject(targetPath)) {
278
- return jsonResponse(res, { error: 'Not a valid mdboard project: ' + targetPath }, 400);
279
- }
280
- if (ctx.loadProject) {
281
- try {
282
- ctx.loadProject(targetPath);
283
- const name = (ctx.model && ctx.model.project && ctx.model.project.name) || require('path').basename(targetPath);
284
- return jsonResponse(res, { ok: true, projectDir: ctx.projectDir, name });
285
- } catch (err) {
286
- return jsonResponse(res, { error: err.message }, 500);
287
- }
288
- }
289
- return jsonResponse(res, { error: 'Project switching not available' }, 500);
290
- }
291
-
292
- if (pathname === '/api/theme') {
293
- const body = await parseBody(req);
294
- if (!body.themeId || !body.css) {
295
- return jsonResponse(res, { error: 'Missing themeId or css' }, 400);
296
- }
297
-
298
- const path = require('path');
299
- const os = require('os');
300
-
301
- if (body.setDefault) {
302
- // Global: write CSS to ~/.config/mdboard/mdboard.css
303
- const globalDir = path.join(os.homedir(), '.config', 'mdboard');
304
- if (!fs.existsSync(globalDir)) fs.mkdirSync(globalDir, { recursive: true });
305
- fs.writeFileSync(path.join(globalDir, 'mdboard.css'), body.css, 'utf-8');
306
- // Also save theme ID in config JSON
307
- const configPath = path.join(globalDir, 'mdboard.json');
308
- let cfg = {};
309
- try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {}
310
- cfg.theme = body.themeId;
311
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
312
- } else {
313
- // Local: write CSS to project/mdboard.css
314
- const projectCssPath = path.join(ctx.projectDir, 'project', 'mdboard.css');
315
- const fallbackCssPath = path.join(ctx.projectDir, 'mdboard.css');
316
- const cssPath = fs.existsSync(path.dirname(projectCssPath)) ? projectCssPath : fallbackCssPath;
317
- fs.writeFileSync(cssPath, body.css, 'utf-8');
318
- }
319
-
320
- return jsonResponse(res, { ok: true });
321
- }
322
-
323
- if (pathname === '/api/sources/sync') {
324
- if (ctx.syncRemotes) {
325
- try {
326
- await ctx.syncRemotes();
327
- return jsonResponse(res, { ok: true, message: 'Sync complete' });
328
- } catch (err) {
329
- return jsonResponse(res, { error: err.message }, 500);
330
- }
331
- }
332
- return jsonResponse(res, { ok: true, message: 'No remote sources' });
333
- }
334
-
335
- // Legacy CRUD: POST /api/tasks, /api/milestones, etc.
336
- const postMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints', notes: 'notes' };
337
- const postMatch = pathname.match(/^\/api\/([\w-]+)$/);
338
- if (postMatch && postMap[postMatch[1]]) {
339
- return handleLegacyCreate(req, res, ctx, postMap[postMatch[1]]);
340
- }
341
-
342
- return jsonResponse(res, { error: 'Not found' }, 404);
287
+ // GET /api/project
288
+ if (req.method === 'GET' && pathname === '/api/project') {
289
+ return jsonResponse(res, model.project || {});
343
290
  }
344
291
 
345
- // ─── DELETE routes (legacy): DELETE /api/:collection/:id ────────────
346
- if (req.method === 'DELETE') {
347
- const deleteMatch = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
348
- if (deleteMatch) {
349
- const patchMap = buildPatchMap(config);
350
- const collection = patchMap[deleteMatch[1]];
351
- if (collection) {
352
- return handleDelete(req, res, ctx, collection, decodeURIComponent(deleteMatch[2]));
353
- }
354
- }
355
- return jsonResponse(res, { error: 'Not found' }, 404);
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
+ }));
356
312
  }
357
313
 
358
- // ─── PATCH routes (legacy) ──────────────────────────────────────────
359
- if (req.method === 'PATCH') {
360
- const match = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
361
- if (match) {
362
- const patchMap = buildPatchMap(config);
363
- const collection = patchMap[match[1]];
364
- if (collection) {
365
- return handlePatch(req, res, collection, decodeURIComponent(match[2]), ctx);
366
- }
367
- }
368
- return jsonResponse(res, { error: 'Not found' }, 404);
314
+ // GET /api/metrics
315
+ if (req.method === 'GET' && pathname === '/api/metrics') {
316
+ return jsonResponse(res, computeMetrics(model, cfg));
369
317
  }
370
318
 
371
- // ─── GET /api/notes/:id ─────────────────────────────────────────────
372
- if (req.method === 'GET') {
373
- const noteMatch = pathname.match(/^\/api\/notes\/(.+)$/);
374
- if (noteMatch) {
375
- const noteId = decodeURIComponent(noteMatch[1]);
376
- const note = model.notes.find(n => n.id === noteId);
377
- if (!note) return jsonResponse(res, { error: 'Note not found: ' + noteId }, 404);
378
- return jsonResponse(res, formatNote(note, true));
379
- }
319
+ // GET /api/health
320
+ if (req.method === 'GET' && pathname === '/api/health') {
321
+ return jsonResponse(res, computeHealth(model, cfg));
380
322
  }
381
323
 
382
- // ─── GET routes (legacy + global) ──────────────────────────────────
383
- switch (pathname) {
384
- case '/api/config': {
385
- const result = {
386
- entities: config.entities,
387
- statuses: config.statuses,
388
- priorities: config.priorities,
389
- boardColumns: config.boardColumns,
390
- completedStatus: config.completedStatus,
391
- };
392
- const theme = resolveTheme(ctx.projectDir, process.env.MDBOARD_CONFIG);
393
- result.theme = theme || null;
394
- result.hasProject = ctx.hasProject != null ? ctx.hasProject : true;
395
- result.projectDir = ctx.projectDir || null;
396
- if (sources && sources.length > 0) {
397
- result.workspace = {
398
- sources: sources.map(s => ({
399
- name: s.name, label: s.label || s.name,
400
- color: s.color || null, icon: s.icon || s.name.charAt(0).toUpperCase(),
401
- type: s.type || 'local',
402
- readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
403
- })),
404
- hasMultipleSources: sources.length > 1,
405
- };
406
- }
407
- return jsonResponse(res, result);
408
- }
409
-
410
- case '/api/project':
411
- return jsonResponse(res, model.project || {});
324
+ // GET /api/status
325
+ if (req.method === 'GET' && pathname === '/api/status') {
326
+ return jsonResponse(res, computeStatus(model, cfg));
327
+ }
412
328
 
413
- case '/api/milestones':
414
- return jsonResponse(res, model.milestones.map(formatMilestone));
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
+ }
415
335
 
416
- case '/api/epics':
417
- return jsonResponse(res, model.epics.map(formatEpic));
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`);
418
345
 
419
- case '/api/tasks':
420
- case '/api/features':
421
- return jsonResponse(res, filterTasks(model.tasks.map(formatTask), url));
346
+ if (ctx.sseClients) ctx.sseClients.add(res);
422
347
 
423
- case '/api/sprints':
424
- return jsonResponse(res, model.sprints.map(formatSprint));
348
+ const keepalive = setInterval(() => {
349
+ try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
350
+ }, 30000);
425
351
 
426
- case '/api/sprint': {
427
- const activeSprint = model.sprints.find(s => s.status === 'active');
428
- if (!activeSprint) return jsonResponse(res, null);
352
+ req.on('close', () => {
353
+ if (ctx.sseClients) ctx.sseClients.delete(res);
354
+ clearInterval(keepalive);
355
+ });
356
+ return;
357
+ }
429
358
 
430
- const board = model.boards.find(b =>
431
- b._dir === activeSprint._dir && b._milestone === activeSprint._milestone &&
432
- (!activeSprint._source || b._source === activeSprint._source)
433
- );
434
- return jsonResponse(res, {
435
- ...formatSprint(activeSprint),
436
- board: board ? board.content : null,
437
- });
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);
438
364
  }
439
365
 
440
- case '/api/metrics': {
441
- const cs = config.completedStatus;
442
- return jsonResponse(res, {
443
- totalTasks: model.tasks.length,
444
- completedTasks: model.tasks.filter(t => t.status === cs).length,
445
- totalMilestones: model.milestones.length,
446
- totalEpics: model.epics.length,
447
- totalPoints: model.tasks.reduce((sum, t) => sum + (t.points || 0), 0),
448
- completedPoints: model.tasks.filter(t => t.status === cs).reduce((sum, t) => sum + (t.points || 0), 0),
449
- });
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');
450
375
  }
451
376
 
452
- case '/api/health':
453
- return jsonResponse(res, computeHealth(model, config));
454
-
455
- case '/api/sources': {
456
- if (!sources || sources.length === 0) return jsonResponse(res, []);
457
-
458
- return jsonResponse(res, sources.map(s => {
459
- const sourceTasks = model.tasks.filter(t => t._source === s.name);
460
- const completedStatus = config.completedStatus;
461
- return {
462
- name: s.name,
463
- label: s.label || s.name,
464
- color: s.color || null,
465
- icon: s.icon || s.name.charAt(0).toUpperCase(),
466
- type: s.type || 'local',
467
- readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
468
- taskCount: sourceTasks.length,
469
- completedCount: sourceTasks.filter(t => t.status === completedStatus).length,
470
- lastSync: s._lastSync || null,
471
- error: s._error || null,
472
- };
473
- }));
474
- }
475
-
476
- case '/api/notes':
477
- return jsonResponse(res, model.notes.map(function(n) { return formatNote(n, false); }));
478
-
479
- case '/api/ai-suggestions':
480
- return jsonResponse(res, ctx.getAiSuggestions ? ctx.getAiSuggestions() : { skills: [], agents: [], mcps: [], commands: [], context: [] });
481
-
482
- case '/api/events': {
483
- res.writeHead(200, {
484
- 'Content-Type': 'text/event-stream',
485
- 'Cache-Control': 'no-cache',
486
- 'Connection': 'keep-alive',
487
- 'Access-Control-Allow-Origin': '*',
488
- });
489
- res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
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
+ }
490
395
 
491
- if (ctx.sseClients) {
492
- ctx.sseClients.add(res);
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);
493
410
  }
494
-
495
- const keepalive = setInterval(() => {
496
- try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
497
- }, 30000);
498
-
499
- req.on('close', () => {
500
- if (ctx.sseClients) ctx.sseClients.delete(res);
501
- clearInterval(keepalive);
502
- });
503
- return;
504
411
  }
505
-
506
- default:
507
- return jsonResponse(res, { error: 'Not found' }, 404);
412
+ return jsonResponse(res, { error: 'Project switching not available' }, 500);
508
413
  }
509
- }
510
414
 
511
- // ═══════════════════════════════════════════════════════════════════════
512
- // Source-scoped routes
513
- // ═══════════════════════════════════════════════════════════════════════
514
-
515
- async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
516
- const { model, config, sources } = ctx;
517
-
518
- // Validate source
519
- const source = sources ? sources.find(s => s.name === sourceName) : null;
520
- if (!source) {
521
- return jsonResponse(res, { error: 'Source not found: ' + sourceName }, 404);
522
- }
523
-
524
- const sourceModel = itemsBySource(model, sourceName);
525
- const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
526
-
527
- // ── GET routes ──
528
- if (req.method === 'GET') {
529
- // GET /api/sources/:name/notes/:id
530
- const noteSubMatch = subPath.match(/^notes\/(.+)$/);
531
- if (noteSubMatch) {
532
- const noteId = decodeURIComponent(noteSubMatch[1]);
533
- const note = (sourceModel.notes || []).find(n => n.id === noteId);
534
- if (!note) return jsonResponse(res, { error: 'Note not found: ' + noteId }, 404);
535
- return jsonResponse(res, formatNote(note, true));
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: [] };
536
429
  }
430
+ if (!Array.isArray(ws.sources)) ws.sources = [];
537
431
 
538
- switch (subPath) {
539
- case 'config': {
540
- // Source-specific config (from sourceStates if available)
541
- const sourceState = ctx.sourceStates ? ctx.sourceStates.get(sourceName) : null;
542
- const srcConfig = sourceState ? sourceState.config : config;
543
- return jsonResponse(res, {
544
- entities: srcConfig.entities,
545
- statuses: srcConfig.statuses,
546
- priorities: srcConfig.priorities,
547
- boardColumns: srcConfig.boardColumns,
548
- completedStatus: srcConfig.completedStatus,
549
- source: { name: source.name, label: source.label, color: source.color, readonly: isReadonly },
550
- });
551
- }
552
-
553
- case 'project': {
554
- const sourceState = ctx.sourceStates ? ctx.sourceStates.get(sourceName) : null;
555
- const proj = sourceState && sourceState.model ? sourceState.model.project : sourceModel.project;
556
- return jsonResponse(res, proj || {});
557
- }
558
-
559
- case 'milestones':
560
- return jsonResponse(res, sourceModel.milestones.map(formatMilestone));
561
-
562
- case 'epics':
563
- return jsonResponse(res, sourceModel.epics.map(formatEpic));
564
-
565
- case 'tasks':
566
- return jsonResponse(res, filterTasks(sourceModel.tasks.map(formatTask), url));
567
-
568
- case 'sprints':
569
- return jsonResponse(res, sourceModel.sprints.map(formatSprint));
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
+ }
570
436
 
571
- case 'sprint': {
572
- const activeSprint = sourceModel.sprints.find(s => s.status === 'active');
573
- if (!activeSprint) return jsonResponse(res, null);
574
- const board = sourceModel.boards.find(b =>
575
- b._dir === activeSprint._dir && b._milestone === activeSprint._milestone
576
- );
577
- return jsonResponse(res, {
578
- ...formatSprint(activeSprint),
579
- board: board ? board.content : null,
580
- });
581
- }
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;
582
448
 
583
- case 'notes':
584
- return jsonResponse(res, (sourceModel.notes || []).map(function(n) { return formatNote(n, false); }));
585
-
586
- case 'health':
587
- return jsonResponse(res, computeHealth(sourceModel, config));
588
-
589
- case 'metrics': {
590
- const cs2 = config.completedStatus;
591
- return jsonResponse(res, {
592
- totalTasks: sourceModel.tasks.length,
593
- completedTasks: sourceModel.tasks.filter(t => t.status === cs2).length,
594
- totalMilestones: sourceModel.milestones.length,
595
- totalEpics: sourceModel.epics.length,
596
- totalPoints: sourceModel.tasks.reduce((sum, t) => sum + (t.points || 0), 0),
597
- completedPoints: sourceModel.tasks.filter(t => t.status === cs2).reduce((sum, t) => sum + (t.points || 0), 0),
598
- });
599
- }
449
+ ws.sources.push(entry);
450
+ fs.writeFileSync(wsPath, JSON.stringify(ws, null, 2) + '\n', 'utf-8');
600
451
 
601
- default:
602
- return jsonResponse(res, { error: 'Not found' }, 404);
603
- }
452
+ if (ctx.onWorkspaceChange) ctx.onWorkspaceChange();
453
+ return jsonResponse(res, { ok: true, source: entry }, 201);
604
454
  }
605
455
 
606
- // ── POST routes (create) ──
607
- if (req.method === 'POST') {
608
- if (isReadonly) {
609
- return jsonResponse(res, { error: 'Source is read-only' }, 403);
610
- }
611
-
612
- const entityMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints', notes: 'notes' };
613
- if (entityMap[subPath] && ctx.createItem) {
614
- const body = await parseBody(req);
456
+ // POST /api/sources/sync
457
+ if (req.method === 'POST' && pathname === '/api/sources/sync') {
458
+ if (ctx.syncRemotes) {
615
459
  try {
616
- const result = ctx.createItem(sourceName, entityMap[subPath], body);
617
- if (ctx.rescanAll) ctx.rescanAll();
618
- if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
619
- return jsonResponse(res, { ok: true, ...result }, 201);
460
+ await ctx.syncRemotes();
461
+ return jsonResponse(res, { ok: true, message: 'Sync complete' });
620
462
  } catch (err) {
621
- return jsonResponse(res, { error: err.message }, 400);
463
+ return jsonResponse(res, { error: err.message }, 500);
622
464
  }
623
465
  }
624
-
625
- return jsonResponse(res, { error: 'Not found' }, 404);
466
+ return jsonResponse(res, { ok: true, message: 'No remote sources' });
626
467
  }
627
468
 
628
- // ── PATCH routes: /api/sources/:name/:entity/:id ──
629
- if (req.method === 'PATCH') {
630
- const patchMatch = subPath.match(/^([\w-]+)\/(.+)$/);
631
- if (patchMatch) {
632
- const patchMap = buildPatchMap(config);
633
- const collection = patchMap[patchMatch[1]];
634
- if (collection) {
635
- const id = decodeURIComponent(patchMatch[2]);
636
- return handlePatch(req, res, collection, id, ctx, sourceName);
637
- }
638
- }
639
- return jsonResponse(res, { error: 'Not found' }, 404);
640
- }
469
+ // --- Dynamic CRUD routes ---
641
470
 
642
- // ── DELETE routes: /api/sources/:name/:entity/:id ──
643
- if (req.method === 'DELETE') {
644
- if (isReadonly) {
645
- return jsonResponse(res, { error: 'Source is read-only' }, 403);
646
- }
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];
647
477
 
648
- const deleteMatch = subPath.match(/^([\w-]+)\/(.+)$/);
649
- if (deleteMatch) {
650
- const patchMap = buildPatchMap(config);
651
- const collection = patchMap[deleteMatch[1]];
652
- if (collection) {
653
- const id = decodeURIComponent(deleteMatch[2]);
654
- return handleDelete(req, res, ctx, collection, id, sourceName);
655
- }
478
+ if (entityType) {
479
+ return handleCrud(req, res, ctx, url, cfg, routeMap, entityType, id);
656
480
  }
657
- return jsonResponse(res, { error: 'Not found' }, 404);
658
481
  }
659
482
 
660
- return jsonResponse(res, { error: 'Method not allowed' }, 405);
483
+ return jsonResponse(res, { error: 'Not found' }, 404);
661
484
  }
662
485
 
663
- // ═══════════════════════════════════════════════════════════════════════
664
- // Overview routes
665
- // ═══════════════════════════════════════════════════════════════════════
486
+ // --- Dynamic CRUD handler ---
666
487
 
667
- function handleOverviewRoute(req, res, ctx, url, pathname) {
668
- const { model, config } = ctx;
488
+ async function handleCrud(req, res, ctx, url, cfg, routeMap, entityType, id, sourceName) {
489
+ const { model } = ctx;
490
+ const items = model.entities[entityType] || [];
669
491
 
670
- if (req.method !== 'GET') {
671
- return jsonResponse(res, { error: 'Method not allowed' }, 405);
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);
672
497
  }
673
498
 
674
- switch (pathname) {
675
- case '/api/overview/milestones': {
676
- // Global milestones with tracked sub-milestone progress
677
- const globalMs = model.milestones.map(ms => {
678
- const formatted = formatMilestone(ms);
679
-
680
- // If this milestone has `tracks`, compute combined progress
681
- if (ms.tracks && Array.isArray(ms.tracks)) {
682
- const tracked = [];
683
- for (const ref of ms.tracks) {
684
- const parts = String(ref).split(':');
685
- if (parts.length === 2) {
686
- const [srcName, msId] = parts;
687
- const sub = model.milestones.find(m =>
688
- (m._source === srcName) && (m.id === ref || m._originalId === msId || m.id === msId)
689
- );
690
- if (sub) {
691
- tracked.push({
692
- ref,
693
- source: sub._source,
694
- sourceColor: sub._sourceColor,
695
- id: sub.id,
696
- title: sub.title,
697
- progress: sub._progress || 0,
698
- featureCount: sub._featureCount || 0,
699
- completedCount: sub._completedCount || 0,
700
- });
701
- }
702
- }
703
- }
704
- formatted.tracked = tracked;
705
- if (tracked.length > 0) {
706
- const totalFeatures = tracked.reduce((s, t) => s + t.featureCount, 0);
707
- const totalCompleted = tracked.reduce((s, t) => s + t.completedCount, 0);
708
- formatted.combinedProgress = totalFeatures > 0 ? Math.round((totalCompleted / totalFeatures) * 100) : 0;
709
- }
710
- }
711
- return formatted;
712
- });
713
- return jsonResponse(res, globalMs);
714
- }
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
+ }
715
505
 
716
- case '/api/overview/links': {
717
- // Collect all cross-project links
718
- const links = [];
719
- for (const task of model.tasks) {
720
- if (task.links && Array.isArray(task.links)) {
721
- for (const link of task.links) {
722
- links.push({
723
- from: task.id,
724
- fromSource: task._source || null,
725
- fromSourceColor: task._sourceColor || null,
726
- to: link,
727
- toSource: String(link).split(':')[0] || null,
728
- });
729
- }
730
- }
731
- }
506
+ // POST create
507
+ if (req.method === 'POST' && !id) {
508
+ const body = await parseBody(req);
732
509
 
733
- // Compute reverse links
734
- const reverseMap = {};
735
- for (const link of links) {
736
- if (!reverseMap[link.to]) reverseMap[link.to] = [];
737
- reverseMap[link.to].push({ from: link.from, source: link.fromSource, sourceColor: link.fromSourceColor });
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);
738
521
  }
739
-
740
- return jsonResponse(res, { links, reverseLinks: reverseMap });
741
522
  }
742
523
 
743
- case '/api/overview/metrics': {
744
- // Aggregated metrics across all sources
745
- const completedStatus = config.completedStatus;
746
- const sourceMetrics = {};
747
-
748
- if (ctx.sources) {
749
- for (const s of ctx.sources) {
750
- const tasks = model.tasks.filter(t => t._source === s.name);
751
- sourceMetrics[s.name] = {
752
- label: s.label || s.name,
753
- color: s.color || null,
754
- totalTasks: tasks.length,
755
- completedTasks: tasks.filter(t => t.status === completedStatus).length,
756
- totalPoints: tasks.reduce((sum, t) => sum + (t.points || 0), 0),
757
- };
758
- }
759
- }
524
+ if (!ctx.createItem) return jsonResponse(res, { error: 'Create not supported' }, 400);
760
525
 
761
- return jsonResponse(res, {
762
- totalTasks: model.tasks.length,
763
- completedTasks: model.tasks.filter(t => t.status === completedStatus).length,
764
- totalMilestones: model.milestones.length,
765
- totalEpics: model.epics.length,
766
- sources: sourceMetrics,
767
- });
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);
768
533
  }
769
-
770
- default:
771
- return jsonResponse(res, { error: 'Not found' }, 404);
772
- }
773
- }
774
-
775
- // ═══════════════════════════════════════════════════════════════════════
776
- // CRUD helpers
777
- // ═══════════════════════════════════════════════════════════════════════
778
-
779
- async function handlePatch(req, res, collection, id, ctx, sourceName) {
780
- const { model, config } = ctx;
781
- const body = await parseBody(req);
782
-
783
- // Resolve item — try exact id first, then try stripping source prefix
784
- let item;
785
- const findIn = (arr) => {
786
- let found = arr.find(x => x.id === id);
787
- if (!found) found = arr.find(x => x._originalId === id);
788
- // If sourceName is provided, further filter
789
- if (found && sourceName && found._source && found._source !== sourceName) found = null;
790
- return found;
791
- };
792
-
793
- switch (collection) {
794
- case 'tasks': item = findIn(model.tasks); break;
795
- case 'epics': item = findIn(model.epics); break;
796
- case 'milestones': item = findIn(model.milestones); break;
797
- case 'sprints': item = findIn(model.sprints); break;
798
- case 'notes': item = findIn(model.notes); break;
799
534
  }
800
535
 
801
- if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
802
-
803
- // Readonly guard
804
- if (item._readonly) {
805
- return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
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
+ }
806
551
  }
807
552
 
808
- try {
809
- if (ctx.updateFile) {
810
- ctx.updateFile(item._source || null, item._file, body);
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);
811
566
  }
812
- if (ctx.rescanAll) ctx.rescanAll();
813
- if (ctx.broadcast) {
814
- ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
815
- }
816
- return jsonResponse(res, { ok: true });
817
- } catch (err) {
818
- return jsonResponse(res, { error: err.message }, 500);
819
567
  }
568
+
569
+ return jsonResponse(res, { error: 'Method not allowed' }, 405);
820
570
  }
821
571
 
822
- async function handleDelete(req, res, ctx, collection, id, sourceName) {
823
- const { model } = ctx;
572
+ // --- Source-scoped routes ---
824
573
 
825
- let item;
826
- const findIn = (arr) => {
827
- let found = arr.find(x => x.id === id);
828
- if (!found) found = arr.find(x => x._originalId === id);
829
- if (found && sourceName && found._source && found._source !== sourceName) found = null;
830
- return found;
831
- };
574
+ async function handleSourceRoute(req, res, ctx, url, routeMap, sourceName, subPath) {
575
+ const { model, config: cfg, sources } = ctx;
832
576
 
833
- switch (collection) {
834
- case 'tasks': item = findIn(model.tasks); break;
835
- case 'epics': item = findIn(model.epics); break;
836
- case 'milestones': item = findIn(model.milestones); break;
837
- case 'sprints': item = findIn(model.sprints); break;
838
- case 'notes': item = findIn(model.notes); break;
839
- }
577
+ const source = sources ? sources.find(s => s.name === sourceName) : null;
578
+ if (!source) return jsonResponse(res, { error: 'Source not found: ' + sourceName }, 404);
840
579
 
841
- if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
580
+ const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
842
581
 
843
- if (item._readonly) {
844
- return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
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);
845
585
  }
846
586
 
847
- try {
848
- if (ctx.archiveItem) {
849
- ctx.archiveItem(item._source || null, item);
850
- }
851
- if (ctx.rescanAll) ctx.rescanAll();
852
- if (ctx.broadcast) {
853
- ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
854
- }
855
- return jsonResponse(res, { ok: true, archived: true });
856
- } catch (err) {
857
- return jsonResponse(res, { error: err.message }, 500);
858
- }
859
- }
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);
860
591
 
861
- async function handleLegacyCreate(req, res, ctx, collection) {
862
- const body = await parseBody(req);
592
+ const plural = subMatch[1];
593
+ const id = subMatch[2] || null;
594
+ const entityType = routeMap[plural];
863
595
 
864
- // Resolve source: explicit _source from body, or legacy (no workspace) mode
865
- let sourceName = null;
866
- if (ctx.sources && ctx.sources.length > 0) {
867
- if (body._source) {
868
- sourceName = body._source;
869
- } else {
870
- // Prefer non-overview, non-readonly writable source
871
- const writable = ctx.sources.filter(s =>
872
- s.name !== 'overview' && !(s.readonly != null ? s.readonly : (s.type === 'remote'))
873
- );
874
- sourceName = writable.length > 0 ? writable[0].name
875
- : (ctx.sources.find(s => !(s.readonly != null ? s.readonly : (s.type === 'remote')))?.name || null);
876
- }
877
- }
596
+ if (!entityType) return jsonResponse(res, { error: 'Not found' }, 404);
878
597
 
879
- if (!ctx.createItem) {
880
- return jsonResponse(res, { error: 'Create not supported' }, 400);
881
- }
598
+ // Filter model items by source
599
+ const items = (model.entities[entityType] || []).filter(i => i._source === sourceName);
882
600
 
883
- try {
884
- const result = ctx.createItem(sourceName, collection, body);
885
- if (ctx.rescanAll) ctx.rescanAll();
886
- if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
887
- return jsonResponse(res, { ok: true, ...result }, 201);
888
- } catch (err) {
889
- return jsonResponse(res, { error: err.message }, 400);
890
- }
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;
891
614
  }
892
615
 
893
- module.exports = { handleApi, jsonResponse, buildPatchMap };
616
+ module.exports = { handleApi, jsonResponse, buildRouteMap, formatEntity, filterEntities, computeMetrics, computeHealth };