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.
- package/bin.js +130 -44
- package/index.html +3321 -1195
- package/package.json +10 -11
- package/presets/kanban/api.json +91 -0
- package/presets/kanban/cli.json +69 -0
- package/presets/kanban/docs.json +29 -0
- package/presets/kanban/entities.json +128 -0
- package/presets/kanban/structure.json +15 -0
- package/presets/kanban/ui.json +86 -0
- package/presets/scrum/api.json +98 -0
- package/presets/scrum/cli.json +120 -0
- package/presets/scrum/docs.json +43 -0
- package/presets/scrum/entities.json +268 -0
- package/presets/scrum/structure.json +32 -0
- package/presets/scrum/ui.json +201 -0
- package/presets/shape-up/api.json +40 -0
- package/presets/shape-up/cli.json +44 -0
- package/presets/shape-up/docs.json +32 -0
- package/presets/shape-up/entities.json +140 -0
- package/presets/shape-up/structure.json +28 -0
- package/presets/shape-up/ui.json +114 -0
- package/src/cli/cli.js +338 -0
- package/src/cli/config.js +234 -0
- package/src/cli/init.js +175 -0
- package/src/cli/preset.js +849 -0
- package/src/cli/skill.js +417 -0
- package/src/cli/status.js +180 -0
- package/src/core/config.js +551 -0
- package/src/core/history.js +146 -0
- package/src/core/scanner.js +521 -0
- package/{workspace.js → src/core/workspace.js} +0 -15
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/src/server/api.js +616 -0
- package/{server.js → src/server/server.js} +180 -132
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/api.js +0 -752
- package/config.js +0 -73
- package/defaults.json +0 -43
- package/init.js +0 -109
- 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 };
|