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.
- package/bin.js +117 -59
- package/index.html +2161 -1579
- package/package.json +7 -5
- 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 +186 -210
- package/src/cli/config.js +234 -0
- package/src/cli/init.js +128 -76
- package/src/cli/preset.js +849 -0
- package/src/cli/skill.js +417 -0
- package/src/cli/status.js +126 -96
- package/src/core/config.js +491 -38
- package/src/core/history.js +17 -1
- package/src/core/scanner.js +373 -463
- package/src/core/workspace.js +0 -15
- package/src/server/api.js +464 -741
- package/src/server/server.js +105 -130
- package/build.js +0 -44
- package/defaults.json +0 -43
- package/src/cli/sync.js +0 -194
- package/src/cli/theme.js +0 -142
- package/src/client/app.js +0 -266
- package/src/client/board.js +0 -157
- package/src/client/core.js +0 -331
- package/src/client/editor.js +0 -318
- package/src/client/history.js +0 -137
- package/src/client/metrics.js +0 -38
- package/src/client/milestones.js +0 -77
- package/src/client/notes.js +0 -183
- package/src/client/overview.js +0 -104
- package/src/client/panel.js +0 -637
- package/src/client/styles.css +0 -471
- package/src/client/table.js +0 -111
- package/src/client/template.html +0 -144
- package/src/client/themes.js +0 -261
- package/src/client/workspace.js +0 -164
- 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
|
|
5
|
-
*
|
|
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
|
|
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
|
|
48
|
+
* Build a map of pluralName → entityType from config.
|
|
49
|
+
* e.g. "tasks" → "task", "cycles" → "cycle"
|
|
42
50
|
*/
|
|
43
|
-
function
|
|
44
|
-
const map = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
162
|
+
return items;
|
|
169
163
|
}
|
|
170
164
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
*
|
|
198
|
+
* Compute health status.
|
|
205
199
|
*/
|
|
206
|
-
function
|
|
207
|
-
const
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
//
|
|
267
|
-
if (req.method === '
|
|
268
|
-
|
|
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
|
-
//
|
|
346
|
-
if (req.method === '
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
//
|
|
359
|
-
if (req.method === '
|
|
360
|
-
|
|
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
|
-
//
|
|
372
|
-
if (req.method === 'GET') {
|
|
373
|
-
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
348
|
+
const keepalive = setInterval(() => {
|
|
349
|
+
try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
|
|
350
|
+
}, 30000);
|
|
425
351
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
352
|
+
req.on('close', () => {
|
|
353
|
+
if (ctx.sseClients) ctx.sseClients.delete(res);
|
|
354
|
+
clearInterval(keepalive);
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
429
358
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
}
|
|
452
|
+
if (ctx.onWorkspaceChange) ctx.onWorkspaceChange();
|
|
453
|
+
return jsonResponse(res, { ok: true, source: entry }, 201);
|
|
604
454
|
}
|
|
605
455
|
|
|
606
|
-
//
|
|
607
|
-
if (req.method === 'POST') {
|
|
608
|
-
if (
|
|
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
|
-
|
|
617
|
-
|
|
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 },
|
|
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
|
-
//
|
|
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
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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: '
|
|
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
|
|
668
|
-
const { model
|
|
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
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
823
|
-
const { model } = ctx;
|
|
572
|
+
// --- Source-scoped routes ---
|
|
824
573
|
|
|
825
|
-
|
|
826
|
-
const
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
580
|
+
const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
|
|
842
581
|
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
862
|
-
const
|
|
592
|
+
const plural = subMatch[1];
|
|
593
|
+
const id = subMatch[2] || null;
|
|
594
|
+
const entityType = routeMap[plural];
|
|
863
595
|
|
|
864
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
598
|
+
// Filter model items by source
|
|
599
|
+
const items = (model.entities[entityType] || []).filter(i => i._source === sourceName);
|
|
882
600
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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,
|
|
616
|
+
module.exports = { handleApi, jsonResponse, buildRouteMap, formatEntity, filterEntities, computeMetrics, computeHealth };
|