mdboard 1.0.0 → 1.2.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/api.js +752 -0
- package/bin.js +56 -0
- package/config.js +73 -0
- package/defaults.json +43 -0
- package/index.html +865 -137
- package/init.js +45 -4
- package/package.json +9 -2
- package/scanner.js +491 -0
- package/server.js +269 -542
- package/watcher.js +131 -0
- package/workspace.js +220 -0
- package/yaml.js +129 -0
package/api.js
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — API handlers
|
|
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
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { URL } = require('url');
|
|
15
|
+
|
|
16
|
+
function jsonResponse(res, data, status = 200) {
|
|
17
|
+
res.writeHead(status, {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'Access-Control-Allow-Origin': '*',
|
|
20
|
+
'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
|
|
21
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
22
|
+
});
|
|
23
|
+
res.end(JSON.stringify(data, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseBody(req) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
req.on('data', c => chunks.push(c));
|
|
30
|
+
req.on('end', () => {
|
|
31
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
32
|
+
catch { resolve({}); }
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build PATCH route map from config.
|
|
39
|
+
*/
|
|
40
|
+
function buildPatchMap(config) {
|
|
41
|
+
const map = {
|
|
42
|
+
features: 'tasks', tasks: 'tasks',
|
|
43
|
+
epics: 'epics', milestones: 'milestones', sprints: 'sprints',
|
|
44
|
+
};
|
|
45
|
+
map[config.entities.task.plural.toLowerCase()] = 'tasks';
|
|
46
|
+
map[config.entities.epic.plural.toLowerCase()] = 'epics';
|
|
47
|
+
map[config.entities.milestone.plural.toLowerCase()] = 'milestones';
|
|
48
|
+
map[config.entities.sprint.plural.toLowerCase()] = 'sprints';
|
|
49
|
+
return map;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sourceFields(item) {
|
|
53
|
+
if (!item._source) return {};
|
|
54
|
+
return {
|
|
55
|
+
source: item._source,
|
|
56
|
+
sourceLabel: item._sourceLabel || item._source,
|
|
57
|
+
sourceColor: item._sourceColor || null,
|
|
58
|
+
readonly: item._readonly || false,
|
|
59
|
+
author: item._author || null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatTask(f) {
|
|
64
|
+
return {
|
|
65
|
+
id: f.id,
|
|
66
|
+
title: f.title,
|
|
67
|
+
epic: f._epic || f.epic,
|
|
68
|
+
milestone: f._milestone || f.milestone,
|
|
69
|
+
sprint: f.sprint,
|
|
70
|
+
status: f.status,
|
|
71
|
+
priority: f.priority,
|
|
72
|
+
points: f.points,
|
|
73
|
+
assigned: f.assigned,
|
|
74
|
+
branches: f.branches,
|
|
75
|
+
pull_requests: f.pull_requests,
|
|
76
|
+
links: f.links || null,
|
|
77
|
+
created: f.created,
|
|
78
|
+
started: f.started,
|
|
79
|
+
completed: f.completed,
|
|
80
|
+
content: f.content,
|
|
81
|
+
...sourceFields(f),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatMilestone(ms) {
|
|
86
|
+
return {
|
|
87
|
+
id: ms.id,
|
|
88
|
+
title: ms.title,
|
|
89
|
+
status: ms.status,
|
|
90
|
+
deadline: ms.deadline,
|
|
91
|
+
tracks: ms.tracks || null,
|
|
92
|
+
progress: ms._progress || 0,
|
|
93
|
+
featureCount: ms._featureCount || 0,
|
|
94
|
+
completedCount: ms._completedCount || 0,
|
|
95
|
+
created: ms.created,
|
|
96
|
+
content: ms.content,
|
|
97
|
+
...sourceFields(ms),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatEpic(e) {
|
|
102
|
+
return {
|
|
103
|
+
id: e.id,
|
|
104
|
+
title: e.title,
|
|
105
|
+
milestone: e._milestone,
|
|
106
|
+
status: e.status,
|
|
107
|
+
priority: e.priority,
|
|
108
|
+
dependencies: e.dependencies,
|
|
109
|
+
featureCount: e._featureCount || 0,
|
|
110
|
+
completedCount: e._completedCount || 0,
|
|
111
|
+
totalPoints: e._totalPoints || 0,
|
|
112
|
+
progress: e._progress || 0,
|
|
113
|
+
content: e.content,
|
|
114
|
+
...sourceFields(e),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatSprint(s) {
|
|
119
|
+
return {
|
|
120
|
+
id: s.id,
|
|
121
|
+
milestone: s._milestone,
|
|
122
|
+
status: s.status,
|
|
123
|
+
goal: s.goal,
|
|
124
|
+
start_date: s.start_date,
|
|
125
|
+
end_date: s.end_date,
|
|
126
|
+
planned_points: s.planned_points,
|
|
127
|
+
completed_points: s.completed_points,
|
|
128
|
+
features: s.features,
|
|
129
|
+
...sourceFields(s),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function filterTasks(tasks, url) {
|
|
134
|
+
const status = url.searchParams.get('status');
|
|
135
|
+
const epic = url.searchParams.get('epic');
|
|
136
|
+
const milestone = url.searchParams.get('milestone');
|
|
137
|
+
const sprint = url.searchParams.get('sprint');
|
|
138
|
+
const source = url.searchParams.get('source');
|
|
139
|
+
|
|
140
|
+
if (status) tasks = tasks.filter(f => f.status === status);
|
|
141
|
+
if (epic) tasks = tasks.filter(f => f.epic === epic);
|
|
142
|
+
if (milestone) tasks = tasks.filter(f => f.milestone === milestone);
|
|
143
|
+
if (sprint) tasks = tasks.filter(f => f.sprint === sprint);
|
|
144
|
+
if (source) tasks = tasks.filter(f => f.source === source);
|
|
145
|
+
|
|
146
|
+
return tasks;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function computeHealth(model, config) {
|
|
150
|
+
const completedStatus = config.completedStatus;
|
|
151
|
+
const inProgressStatus = (config.statuses.task.find(s => s.icon === 'half-circle') || {}).key || 'in-progress';
|
|
152
|
+
|
|
153
|
+
const activeMilestone = model.milestones.find(m => m.status === 'active');
|
|
154
|
+
const activeSprint = model.sprints.find(s => s.status === 'active');
|
|
155
|
+
const totalFeatures = model.tasks.length;
|
|
156
|
+
const completedFeatures = model.tasks.filter(f => f.status === completedStatus).length;
|
|
157
|
+
const inProgressFeatures = model.tasks.filter(f => f.status === inProgressStatus).length;
|
|
158
|
+
|
|
159
|
+
const completedSprints = model.sprints.filter(s => s.status === 'completed');
|
|
160
|
+
let velocity = null;
|
|
161
|
+
if (completedSprints.length > 0) {
|
|
162
|
+
const totalVelocity = completedSprints.reduce((sum, s) => {
|
|
163
|
+
const planned = s.planned_points || 1;
|
|
164
|
+
const completed = s.completed_points || 0;
|
|
165
|
+
return sum + Math.round((completed / planned) * 100);
|
|
166
|
+
}, 0);
|
|
167
|
+
velocity = Math.round(totalVelocity / completedSprints.length);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
status: 'ok',
|
|
172
|
+
activeMilestone: activeMilestone ? activeMilestone.id : null,
|
|
173
|
+
activeSprint: activeSprint ? activeSprint.id : null,
|
|
174
|
+
totalFeatures,
|
|
175
|
+
completedFeatures,
|
|
176
|
+
inProgressFeatures,
|
|
177
|
+
velocity,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get items filtered by source name from the global model.
|
|
183
|
+
*/
|
|
184
|
+
function itemsBySource(model, sourceName) {
|
|
185
|
+
const filter = (arr) => arr.filter(x => x._source === sourceName);
|
|
186
|
+
return {
|
|
187
|
+
project: model.project && model.project._source === sourceName ? model.project : null,
|
|
188
|
+
milestones: filter(model.milestones),
|
|
189
|
+
epics: filter(model.epics),
|
|
190
|
+
tasks: filter(model.tasks),
|
|
191
|
+
sprints: filter(model.sprints),
|
|
192
|
+
boards: filter(model.boards),
|
|
193
|
+
reviews: filter(model.reviews),
|
|
194
|
+
metrics: model.metrics && model.metrics._source === sourceName ? model.metrics : null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Handle all API requests.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} req - HTTP request
|
|
202
|
+
* @param {object} res - HTTP response
|
|
203
|
+
* @param {object} ctx - Context object:
|
|
204
|
+
* model, config, port, projectDir, broadcast, sources, sourceStates,
|
|
205
|
+
* updateFile(sourceName, relFile, updates), rescanAll(), syncRemotes(),
|
|
206
|
+
* createItem(sourceName, collection, data), archiveItem(sourceName, collection, id)
|
|
207
|
+
*/
|
|
208
|
+
async function handleApi(req, res, ctx) {
|
|
209
|
+
const { model, config, port, sources } = ctx;
|
|
210
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
211
|
+
const pathname = url.pathname;
|
|
212
|
+
|
|
213
|
+
// CORS preflight
|
|
214
|
+
if (req.method === 'OPTIONS') {
|
|
215
|
+
res.writeHead(204, {
|
|
216
|
+
'Access-Control-Allow-Origin': '*',
|
|
217
|
+
'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
|
|
218
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
219
|
+
});
|
|
220
|
+
return res.end();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Source-scoped routes: /api/sources/:name/... ───────────────────
|
|
224
|
+
const sourceMatch = pathname.match(/^\/api\/sources\/([^/]+)\/(.+)$/);
|
|
225
|
+
if (sourceMatch) {
|
|
226
|
+
const sourceName = decodeURIComponent(sourceMatch[1]);
|
|
227
|
+
const subPath = sourceMatch[2];
|
|
228
|
+
return handleSourceRoute(req, res, ctx, url, sourceName, subPath);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Overview routes: /api/overview/... ─────────────────────────────
|
|
232
|
+
if (pathname.startsWith('/api/overview/')) {
|
|
233
|
+
return handleOverviewRoute(req, res, ctx, url, pathname);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── POST routes ────────────────────────────────────────────────────
|
|
237
|
+
if (req.method === 'POST') {
|
|
238
|
+
if (pathname === '/api/sources/sync') {
|
|
239
|
+
if (ctx.syncRemotes) {
|
|
240
|
+
try {
|
|
241
|
+
await ctx.syncRemotes();
|
|
242
|
+
return jsonResponse(res, { ok: true, message: 'Sync complete' });
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return jsonResponse(res, { error: err.message }, 500);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return jsonResponse(res, { ok: true, message: 'No remote sources' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Legacy CRUD: POST /api/tasks, /api/milestones, etc.
|
|
251
|
+
const postMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
|
|
252
|
+
const postMatch = pathname.match(/^\/api\/([\w-]+)$/);
|
|
253
|
+
if (postMatch && postMap[postMatch[1]]) {
|
|
254
|
+
return handleLegacyCreate(req, res, ctx, postMap[postMatch[1]]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── DELETE routes (legacy): DELETE /api/:collection/:id ────────────
|
|
261
|
+
if (req.method === 'DELETE') {
|
|
262
|
+
const deleteMatch = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
|
|
263
|
+
if (deleteMatch) {
|
|
264
|
+
const patchMap = buildPatchMap(config);
|
|
265
|
+
const collection = patchMap[deleteMatch[1]];
|
|
266
|
+
if (collection) {
|
|
267
|
+
return handleDelete(req, res, ctx, collection, decodeURIComponent(deleteMatch[2]));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── PATCH routes (legacy) ──────────────────────────────────────────
|
|
274
|
+
if (req.method === 'PATCH') {
|
|
275
|
+
const match = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
|
|
276
|
+
if (match) {
|
|
277
|
+
const patchMap = buildPatchMap(config);
|
|
278
|
+
const collection = patchMap[match[1]];
|
|
279
|
+
if (collection) {
|
|
280
|
+
return handlePatch(req, res, collection, decodeURIComponent(match[2]), ctx);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── GET routes (legacy + global) ──────────────────────────────────
|
|
287
|
+
switch (pathname) {
|
|
288
|
+
case '/api/config': {
|
|
289
|
+
const result = {
|
|
290
|
+
entities: config.entities,
|
|
291
|
+
statuses: config.statuses,
|
|
292
|
+
priorities: config.priorities,
|
|
293
|
+
boardColumns: config.boardColumns,
|
|
294
|
+
completedStatus: config.completedStatus,
|
|
295
|
+
};
|
|
296
|
+
if (sources && sources.length > 0) {
|
|
297
|
+
result.workspace = {
|
|
298
|
+
sources: sources.map(s => ({
|
|
299
|
+
name: s.name, label: s.label || s.name,
|
|
300
|
+
color: s.color || null, icon: s.icon || s.name.charAt(0).toUpperCase(),
|
|
301
|
+
type: s.type || 'local',
|
|
302
|
+
readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
|
|
303
|
+
})),
|
|
304
|
+
hasMultipleSources: sources.length > 1,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return jsonResponse(res, result);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case '/api/project':
|
|
311
|
+
return jsonResponse(res, model.project || {});
|
|
312
|
+
|
|
313
|
+
case '/api/milestones':
|
|
314
|
+
return jsonResponse(res, model.milestones.map(formatMilestone));
|
|
315
|
+
|
|
316
|
+
case '/api/epics':
|
|
317
|
+
return jsonResponse(res, model.epics.map(formatEpic));
|
|
318
|
+
|
|
319
|
+
case '/api/tasks':
|
|
320
|
+
case '/api/features':
|
|
321
|
+
return jsonResponse(res, filterTasks(model.tasks.map(formatTask), url));
|
|
322
|
+
|
|
323
|
+
case '/api/sprints':
|
|
324
|
+
return jsonResponse(res, model.sprints.map(formatSprint));
|
|
325
|
+
|
|
326
|
+
case '/api/sprint': {
|
|
327
|
+
const activeSprint = model.sprints.find(s => s.status === 'active');
|
|
328
|
+
if (!activeSprint) return jsonResponse(res, null);
|
|
329
|
+
|
|
330
|
+
const board = model.boards.find(b =>
|
|
331
|
+
b._dir === activeSprint._dir && b._milestone === activeSprint._milestone &&
|
|
332
|
+
(!activeSprint._source || b._source === activeSprint._source)
|
|
333
|
+
);
|
|
334
|
+
return jsonResponse(res, {
|
|
335
|
+
...formatSprint(activeSprint),
|
|
336
|
+
board: board ? board.content : null,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case '/api/metrics':
|
|
341
|
+
return jsonResponse(res, model.metrics || {});
|
|
342
|
+
|
|
343
|
+
case '/api/health':
|
|
344
|
+
return jsonResponse(res, computeHealth(model, config));
|
|
345
|
+
|
|
346
|
+
case '/api/sources': {
|
|
347
|
+
if (!sources || sources.length === 0) return jsonResponse(res, []);
|
|
348
|
+
|
|
349
|
+
return jsonResponse(res, sources.map(s => {
|
|
350
|
+
const sourceTasks = model.tasks.filter(t => t._source === s.name);
|
|
351
|
+
const completedStatus = config.completedStatus;
|
|
352
|
+
return {
|
|
353
|
+
name: s.name,
|
|
354
|
+
label: s.label || s.name,
|
|
355
|
+
color: s.color || null,
|
|
356
|
+
icon: s.icon || s.name.charAt(0).toUpperCase(),
|
|
357
|
+
type: s.type || 'local',
|
|
358
|
+
readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
|
|
359
|
+
taskCount: sourceTasks.length,
|
|
360
|
+
completedCount: sourceTasks.filter(t => t.status === completedStatus).length,
|
|
361
|
+
lastSync: s._lastSync || null,
|
|
362
|
+
error: s._error || null,
|
|
363
|
+
};
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case '/api/events': {
|
|
368
|
+
res.writeHead(200, {
|
|
369
|
+
'Content-Type': 'text/event-stream',
|
|
370
|
+
'Cache-Control': 'no-cache',
|
|
371
|
+
'Connection': 'keep-alive',
|
|
372
|
+
'Access-Control-Allow-Origin': '*',
|
|
373
|
+
});
|
|
374
|
+
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
|
375
|
+
|
|
376
|
+
if (ctx.sseClients) {
|
|
377
|
+
ctx.sseClients.add(res);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const keepalive = setInterval(() => {
|
|
381
|
+
try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
|
|
382
|
+
}, 30000);
|
|
383
|
+
|
|
384
|
+
req.on('close', () => {
|
|
385
|
+
if (ctx.sseClients) ctx.sseClients.delete(res);
|
|
386
|
+
clearInterval(keepalive);
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
default:
|
|
392
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
397
|
+
// Source-scoped routes
|
|
398
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
399
|
+
|
|
400
|
+
async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
|
|
401
|
+
const { model, config, sources } = ctx;
|
|
402
|
+
|
|
403
|
+
// Validate source
|
|
404
|
+
const source = sources ? sources.find(s => s.name === sourceName) : null;
|
|
405
|
+
if (!source) {
|
|
406
|
+
return jsonResponse(res, { error: 'Source not found: ' + sourceName }, 404);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const sourceModel = itemsBySource(model, sourceName);
|
|
410
|
+
const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
|
|
411
|
+
|
|
412
|
+
// ── GET routes ──
|
|
413
|
+
if (req.method === 'GET') {
|
|
414
|
+
switch (subPath) {
|
|
415
|
+
case 'config': {
|
|
416
|
+
// Source-specific config (from sourceStates if available)
|
|
417
|
+
const sourceState = ctx.sourceStates ? ctx.sourceStates.get(sourceName) : null;
|
|
418
|
+
const srcConfig = sourceState ? sourceState.config : config;
|
|
419
|
+
return jsonResponse(res, {
|
|
420
|
+
entities: srcConfig.entities,
|
|
421
|
+
statuses: srcConfig.statuses,
|
|
422
|
+
priorities: srcConfig.priorities,
|
|
423
|
+
boardColumns: srcConfig.boardColumns,
|
|
424
|
+
completedStatus: srcConfig.completedStatus,
|
|
425
|
+
source: { name: source.name, label: source.label, color: source.color, readonly: isReadonly },
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
case 'project':
|
|
430
|
+
return jsonResponse(res, sourceModel.project || {});
|
|
431
|
+
|
|
432
|
+
case 'milestones':
|
|
433
|
+
return jsonResponse(res, sourceModel.milestones.map(formatMilestone));
|
|
434
|
+
|
|
435
|
+
case 'epics':
|
|
436
|
+
return jsonResponse(res, sourceModel.epics.map(formatEpic));
|
|
437
|
+
|
|
438
|
+
case 'tasks':
|
|
439
|
+
return jsonResponse(res, filterTasks(sourceModel.tasks.map(formatTask), url));
|
|
440
|
+
|
|
441
|
+
case 'sprints':
|
|
442
|
+
return jsonResponse(res, sourceModel.sprints.map(formatSprint));
|
|
443
|
+
|
|
444
|
+
case 'sprint': {
|
|
445
|
+
const activeSprint = sourceModel.sprints.find(s => s.status === 'active');
|
|
446
|
+
if (!activeSprint) return jsonResponse(res, null);
|
|
447
|
+
const board = sourceModel.boards.find(b =>
|
|
448
|
+
b._dir === activeSprint._dir && b._milestone === activeSprint._milestone
|
|
449
|
+
);
|
|
450
|
+
return jsonResponse(res, {
|
|
451
|
+
...formatSprint(activeSprint),
|
|
452
|
+
board: board ? board.content : null,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
case 'health':
|
|
457
|
+
return jsonResponse(res, computeHealth(sourceModel, config));
|
|
458
|
+
|
|
459
|
+
case 'metrics':
|
|
460
|
+
return jsonResponse(res, sourceModel.metrics || {});
|
|
461
|
+
|
|
462
|
+
default:
|
|
463
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── POST routes (create) ──
|
|
468
|
+
if (req.method === 'POST') {
|
|
469
|
+
if (isReadonly) {
|
|
470
|
+
return jsonResponse(res, { error: 'Source is read-only' }, 403);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const entityMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
|
|
474
|
+
if (entityMap[subPath] && ctx.createItem) {
|
|
475
|
+
const body = await parseBody(req);
|
|
476
|
+
try {
|
|
477
|
+
const result = ctx.createItem(sourceName, entityMap[subPath], body);
|
|
478
|
+
if (ctx.rescanAll) ctx.rescanAll();
|
|
479
|
+
if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
480
|
+
return jsonResponse(res, { ok: true, ...result }, 201);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
return jsonResponse(res, { error: err.message }, 400);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── PATCH routes: /api/sources/:name/:entity/:id ──
|
|
490
|
+
if (req.method === 'PATCH') {
|
|
491
|
+
const patchMatch = subPath.match(/^([\w-]+)\/(.+)$/);
|
|
492
|
+
if (patchMatch) {
|
|
493
|
+
const patchMap = buildPatchMap(config);
|
|
494
|
+
const collection = patchMap[patchMatch[1]];
|
|
495
|
+
if (collection) {
|
|
496
|
+
const id = decodeURIComponent(patchMatch[2]);
|
|
497
|
+
return handlePatch(req, res, collection, id, ctx, sourceName);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── DELETE routes: /api/sources/:name/:entity/:id ──
|
|
504
|
+
if (req.method === 'DELETE') {
|
|
505
|
+
if (isReadonly) {
|
|
506
|
+
return jsonResponse(res, { error: 'Source is read-only' }, 403);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const deleteMatch = subPath.match(/^([\w-]+)\/(.+)$/);
|
|
510
|
+
if (deleteMatch) {
|
|
511
|
+
const patchMap = buildPatchMap(config);
|
|
512
|
+
const collection = patchMap[deleteMatch[1]];
|
|
513
|
+
if (collection) {
|
|
514
|
+
const id = decodeURIComponent(deleteMatch[2]);
|
|
515
|
+
return handleDelete(req, res, ctx, collection, id, sourceName);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return jsonResponse(res, { error: 'Method not allowed' }, 405);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
525
|
+
// Overview routes
|
|
526
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
527
|
+
|
|
528
|
+
function handleOverviewRoute(req, res, ctx, url, pathname) {
|
|
529
|
+
const { model, config } = ctx;
|
|
530
|
+
|
|
531
|
+
if (req.method !== 'GET') {
|
|
532
|
+
return jsonResponse(res, { error: 'Method not allowed' }, 405);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
switch (pathname) {
|
|
536
|
+
case '/api/overview/milestones': {
|
|
537
|
+
// Global milestones with tracked sub-milestone progress
|
|
538
|
+
const globalMs = model.milestones.map(ms => {
|
|
539
|
+
const formatted = formatMilestone(ms);
|
|
540
|
+
|
|
541
|
+
// If this milestone has `tracks`, compute combined progress
|
|
542
|
+
if (ms.tracks && Array.isArray(ms.tracks)) {
|
|
543
|
+
const tracked = [];
|
|
544
|
+
for (const ref of ms.tracks) {
|
|
545
|
+
const parts = String(ref).split(':');
|
|
546
|
+
if (parts.length === 2) {
|
|
547
|
+
const [srcName, msId] = parts;
|
|
548
|
+
const sub = model.milestones.find(m =>
|
|
549
|
+
(m._source === srcName) && (m.id === ref || m._originalId === msId || m.id === msId)
|
|
550
|
+
);
|
|
551
|
+
if (sub) {
|
|
552
|
+
tracked.push({
|
|
553
|
+
ref,
|
|
554
|
+
source: sub._source,
|
|
555
|
+
sourceColor: sub._sourceColor,
|
|
556
|
+
id: sub.id,
|
|
557
|
+
title: sub.title,
|
|
558
|
+
progress: sub._progress || 0,
|
|
559
|
+
featureCount: sub._featureCount || 0,
|
|
560
|
+
completedCount: sub._completedCount || 0,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
formatted.tracked = tracked;
|
|
566
|
+
if (tracked.length > 0) {
|
|
567
|
+
const totalFeatures = tracked.reduce((s, t) => s + t.featureCount, 0);
|
|
568
|
+
const totalCompleted = tracked.reduce((s, t) => s + t.completedCount, 0);
|
|
569
|
+
formatted.combinedProgress = totalFeatures > 0 ? Math.round((totalCompleted / totalFeatures) * 100) : 0;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return formatted;
|
|
573
|
+
});
|
|
574
|
+
return jsonResponse(res, globalMs);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
case '/api/overview/links': {
|
|
578
|
+
// Collect all cross-project links
|
|
579
|
+
const links = [];
|
|
580
|
+
for (const task of model.tasks) {
|
|
581
|
+
if (task.links && Array.isArray(task.links)) {
|
|
582
|
+
for (const link of task.links) {
|
|
583
|
+
links.push({
|
|
584
|
+
from: task.id,
|
|
585
|
+
fromSource: task._source || null,
|
|
586
|
+
fromSourceColor: task._sourceColor || null,
|
|
587
|
+
to: link,
|
|
588
|
+
toSource: String(link).split(':')[0] || null,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Compute reverse links
|
|
595
|
+
const reverseMap = {};
|
|
596
|
+
for (const link of links) {
|
|
597
|
+
if (!reverseMap[link.to]) reverseMap[link.to] = [];
|
|
598
|
+
reverseMap[link.to].push({ from: link.from, source: link.fromSource, sourceColor: link.fromSourceColor });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return jsonResponse(res, { links, reverseLinks: reverseMap });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
case '/api/overview/metrics': {
|
|
605
|
+
// Aggregated metrics across all sources
|
|
606
|
+
const completedStatus = config.completedStatus;
|
|
607
|
+
const sourceMetrics = {};
|
|
608
|
+
|
|
609
|
+
if (ctx.sources) {
|
|
610
|
+
for (const s of ctx.sources) {
|
|
611
|
+
const tasks = model.tasks.filter(t => t._source === s.name);
|
|
612
|
+
sourceMetrics[s.name] = {
|
|
613
|
+
label: s.label || s.name,
|
|
614
|
+
color: s.color || null,
|
|
615
|
+
totalTasks: tasks.length,
|
|
616
|
+
completedTasks: tasks.filter(t => t.status === completedStatus).length,
|
|
617
|
+
totalPoints: tasks.reduce((sum, t) => sum + (t.points || 0), 0),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return jsonResponse(res, {
|
|
623
|
+
totalTasks: model.tasks.length,
|
|
624
|
+
completedTasks: model.tasks.filter(t => t.status === completedStatus).length,
|
|
625
|
+
totalMilestones: model.milestones.length,
|
|
626
|
+
totalEpics: model.epics.length,
|
|
627
|
+
sources: sourceMetrics,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
default:
|
|
632
|
+
return jsonResponse(res, { error: 'Not found' }, 404);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
637
|
+
// CRUD helpers
|
|
638
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
639
|
+
|
|
640
|
+
async function handlePatch(req, res, collection, id, ctx, sourceName) {
|
|
641
|
+
const { model, config } = ctx;
|
|
642
|
+
const body = await parseBody(req);
|
|
643
|
+
|
|
644
|
+
// Resolve item — try exact id first, then try stripping source prefix
|
|
645
|
+
let item;
|
|
646
|
+
const findIn = (arr) => {
|
|
647
|
+
let found = arr.find(x => x.id === id);
|
|
648
|
+
if (!found) found = arr.find(x => x._originalId === id);
|
|
649
|
+
// If sourceName is provided, further filter
|
|
650
|
+
if (found && sourceName && found._source && found._source !== sourceName) found = null;
|
|
651
|
+
return found;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
switch (collection) {
|
|
655
|
+
case 'tasks': item = findIn(model.tasks); break;
|
|
656
|
+
case 'epics': item = findIn(model.epics); break;
|
|
657
|
+
case 'milestones': item = findIn(model.milestones); break;
|
|
658
|
+
case 'sprints': item = findIn(model.sprints); break;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
|
|
662
|
+
|
|
663
|
+
// Readonly guard
|
|
664
|
+
if (item._readonly) {
|
|
665
|
+
return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
if (ctx.updateFile) {
|
|
670
|
+
ctx.updateFile(item._source || null, item._file, body);
|
|
671
|
+
}
|
|
672
|
+
if (ctx.rescanAll) ctx.rescanAll();
|
|
673
|
+
if (ctx.broadcast) {
|
|
674
|
+
ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
675
|
+
}
|
|
676
|
+
return jsonResponse(res, { ok: true });
|
|
677
|
+
} catch (err) {
|
|
678
|
+
return jsonResponse(res, { error: err.message }, 500);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function handleDelete(req, res, ctx, collection, id, sourceName) {
|
|
683
|
+
const { model } = ctx;
|
|
684
|
+
|
|
685
|
+
let item;
|
|
686
|
+
const findIn = (arr) => {
|
|
687
|
+
let found = arr.find(x => x.id === id);
|
|
688
|
+
if (!found) found = arr.find(x => x._originalId === id);
|
|
689
|
+
if (found && sourceName && found._source && found._source !== sourceName) found = null;
|
|
690
|
+
return found;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
switch (collection) {
|
|
694
|
+
case 'tasks': item = findIn(model.tasks); break;
|
|
695
|
+
case 'epics': item = findIn(model.epics); break;
|
|
696
|
+
case 'milestones': item = findIn(model.milestones); break;
|
|
697
|
+
case 'sprints': item = findIn(model.sprints); break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
|
|
701
|
+
|
|
702
|
+
if (item._readonly) {
|
|
703
|
+
return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
if (ctx.archiveItem) {
|
|
708
|
+
ctx.archiveItem(item._source || null, item);
|
|
709
|
+
}
|
|
710
|
+
if (ctx.rescanAll) ctx.rescanAll();
|
|
711
|
+
if (ctx.broadcast) {
|
|
712
|
+
ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
713
|
+
}
|
|
714
|
+
return jsonResponse(res, { ok: true, archived: true });
|
|
715
|
+
} catch (err) {
|
|
716
|
+
return jsonResponse(res, { error: err.message }, 500);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function handleLegacyCreate(req, res, ctx, collection) {
|
|
721
|
+
const body = await parseBody(req);
|
|
722
|
+
|
|
723
|
+
// Resolve source: explicit _source from body, or legacy (no workspace) mode
|
|
724
|
+
let sourceName = null;
|
|
725
|
+
if (ctx.sources && ctx.sources.length > 0) {
|
|
726
|
+
if (body._source) {
|
|
727
|
+
sourceName = body._source;
|
|
728
|
+
} else {
|
|
729
|
+
// Prefer non-overview, non-readonly writable source
|
|
730
|
+
const writable = ctx.sources.filter(s =>
|
|
731
|
+
s.name !== 'overview' && !(s.readonly != null ? s.readonly : (s.type === 'remote'))
|
|
732
|
+
);
|
|
733
|
+
sourceName = writable.length > 0 ? writable[0].name
|
|
734
|
+
: (ctx.sources.find(s => !(s.readonly != null ? s.readonly : (s.type === 'remote')))?.name || null);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!ctx.createItem) {
|
|
739
|
+
return jsonResponse(res, { error: 'Create not supported' }, 400);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
const result = ctx.createItem(sourceName, collection, body);
|
|
744
|
+
if (ctx.rescanAll) ctx.rescanAll();
|
|
745
|
+
if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
746
|
+
return jsonResponse(res, { ok: true, ...result }, 201);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
return jsonResponse(res, { error: err.message }, 400);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
module.exports = { handleApi, jsonResponse, buildPatchMap };
|