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/server.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * Zero-dependency Node.js server that reads markdown project management
6
6
  * files and serves a visual dashboard + JSON API.
7
7
  *
8
+ * Supports multi-repo workspaces via workspace.json.
9
+ *
8
10
  * Usage:
9
11
  * mdboard --project /path/to/workspace --port 3333
10
12
  */
@@ -12,7 +14,13 @@
12
14
  const http = require('http');
13
15
  const fs = require('fs');
14
16
  const path = require('path');
15
- const { URL } = require('url');
17
+ const os = require('os');
18
+ const { loadConfig, deepMerge } = require('./config');
19
+ const { createModel, scanSource, computeProgress, updateMarkdownFile, mergeResults,
20
+ createTask, createMilestone, createEpic, createSprint, archiveItem } = require('./scanner');
21
+ const { setupWatchers } = require('./watcher');
22
+ const { handleApi } = require('./api');
23
+ const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('./workspace');
16
24
 
17
25
  // ---------------------------------------------------------------------------
18
26
  // CLI argument parsing
@@ -20,6 +28,7 @@ const { URL } = require('url');
20
28
  const args = process.argv.slice(2);
21
29
  let projectDir = process.cwd();
22
30
  let port = 3333;
31
+ let workspacePath = process.env.MDBOARD_WORKSPACE || null;
23
32
 
24
33
  for (let i = 0; i < args.length; i++) {
25
34
  switch (args[i]) {
@@ -29,13 +38,16 @@ for (let i = 0; i < args.length; i++) {
29
38
  case '--port':
30
39
  port = parseInt(args[++i], 10) || 3333;
31
40
  break;
41
+ case '--config':
42
+ i++;
43
+ break;
44
+ case '--workspace':
45
+ workspacePath = path.resolve(args[++i] || '.');
46
+ break;
32
47
  case 'init':
33
- case '-h':
34
- case '--help':
35
- case '-v':
36
- case '--version':
37
- case 'help':
38
- // These are handled by bin.js; if server.js is called directly, ignore.
48
+ case '-h': case '--help':
49
+ case '-v': case '--version':
50
+ case 'help': case 'cache':
39
51
  break;
40
52
  }
41
53
  }
@@ -44,595 +56,213 @@ const projectPath = path.join(projectDir, 'project');
44
56
  const boardDir = __dirname;
45
57
 
46
58
  // ---------------------------------------------------------------------------
47
- // YAML frontmatter parser
59
+ // Load configuration
48
60
  // ---------------------------------------------------------------------------
49
- function parseFrontmatter(content) {
50
- const result = { frontmatter: {}, content: '' };
51
- if (!content.startsWith('---')) return { frontmatter: {}, content };
52
-
53
- const end = content.indexOf('\n---', 3);
54
- if (end === -1) return { frontmatter: {}, content };
55
-
56
- const yaml = content.substring(4, end).trim();
57
- result.content = content.substring(end + 4).trim();
58
- result.frontmatter = parseYaml(yaml);
59
- return result;
60
- }
61
-
62
- function parseYaml(text) {
63
- const obj = {};
64
- const lines = text.split('\n');
65
- let i = 0;
66
-
67
- while (i < lines.length) {
68
- const line = lines[i];
69
- const match = line.match(/^(\w[\w.-]*)\s*:\s*(.*)/);
70
-
71
- if (!match) { i++; continue; }
72
-
73
- const key = match[1];
74
- const rawValue = match[2].trim();
75
-
76
- if (rawValue === '' || rawValue === '') {
77
- const nested = {};
78
- let dashList = null;
79
- let j = i + 1;
61
+ let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
80
62
 
81
- while (j < lines.length) {
82
- const nextLine = lines[j];
83
- if (!nextLine.match(/^\s/) || nextLine.trim() === '') break;
84
-
85
- const dashMatch = nextLine.match(/^\s+-\s+(.*)/);
86
- const nestedMatch = nextLine.match(/^\s+(\w[\w.-]*)\s*:\s*(.*)/);
87
-
88
- if (dashMatch) {
89
- if (!dashList) dashList = [];
90
- dashList.push(parseValue(dashMatch[1].trim()));
91
- } else if (nestedMatch) {
92
- nested[nestedMatch[1]] = parseValue(nestedMatch[2].trim());
93
- }
94
- j++;
95
- }
96
-
97
- obj[key] = dashList || (Object.keys(nested).length > 0 ? nested : parseValue(rawValue));
98
- i = j;
99
- continue;
100
- }
101
-
102
- obj[key] = parseValue(rawValue);
103
- i++;
104
- }
105
-
106
- return obj;
107
- }
108
-
109
- function parseValue(raw) {
110
- if (raw === '' || raw === undefined) return null;
111
-
112
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
113
- return raw.slice(1, -1);
114
- }
115
-
116
- if (raw.startsWith('[') && raw.endsWith(']')) {
117
- const inner = raw.slice(1, -1).trim();
118
- if (inner === '') return [];
119
- return inner.split(',').map(s => parseValue(s.trim()));
120
- }
121
-
122
- if (raw === 'true') return true;
123
- if (raw === 'false') return false;
124
- if (raw === 'null' || raw === '~') return null;
125
- if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
126
-
127
- return raw;
63
+ function reloadConfig() {
64
+ config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
128
65
  }
129
66
 
130
67
  // ---------------------------------------------------------------------------
131
- // YAML serializer (for writing back to .md files)
68
+ // Workspace
132
69
  // ---------------------------------------------------------------------------
133
- function serializeValue(v) {
134
- if (v === null || v === undefined) return '';
135
- if (typeof v === 'boolean') return v ? 'true' : 'false';
136
- if (typeof v === 'number') return String(v);
137
- if (typeof v === 'string') {
138
- if (/[:#\[\]{}&*!|>'"%@`,\n]/.test(v) || v === '' || v === 'true' || v === 'false' || v === 'null') {
139
- return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
140
- }
141
- return v;
142
- }
143
- return String(v);
144
- }
145
-
146
- function serializeYaml(obj) {
147
- const lines = [];
148
- for (const [key, value] of Object.entries(obj)) {
149
- if (key.startsWith('_')) continue;
150
- if (value === undefined) continue;
151
- if (value === null) {
152
- lines.push(key + ':');
153
- continue;
154
- }
155
- if (Array.isArray(value)) {
156
- if (value.length === 0) {
157
- lines.push(key + ': []');
158
- } else {
159
- lines.push(key + ': [' + value.map(v => serializeValue(v)).join(', ') + ']');
160
- }
161
- } else if (typeof value === 'object') {
162
- lines.push(key + ':');
163
- for (const [k, v] of Object.entries(value)) {
164
- lines.push(' ' + k + ': ' + serializeValue(v));
165
- }
166
- } else {
167
- lines.push(key + ': ' + serializeValue(value));
168
- }
169
- }
170
- return lines.join('\n');
171
- }
70
+ let workspace = loadWorkspace(projectDir, workspacePath);
71
+ let sources = workspace ? workspace.sources : [];
72
+ let syncInterval = null;
172
73
 
173
74
  // ---------------------------------------------------------------------------
174
- // Markdown file updater
75
+ // SSE clients
175
76
  // ---------------------------------------------------------------------------
176
- function updateMarkdownFile(relFile, updates) {
177
- const filePath = path.join(projectPath, relFile);
178
- if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
179
-
180
- const raw = fs.readFileSync(filePath, 'utf-8');
181
- const parsed = parseFrontmatter(raw);
182
-
183
- const newFm = { ...parsed.frontmatter };
184
- let body = parsed.content;
77
+ const sseClients = new Set();
185
78
 
186
- for (const [k, v] of Object.entries(updates)) {
187
- if (k === 'content') { body = v; continue; }
188
- if (k.startsWith('_')) continue;
189
- newFm[k] = v;
79
+ function broadcast(data) {
80
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
81
+ for (const res of sseClients) {
82
+ try { res.write(payload); } catch { sseClients.delete(res); }
190
83
  }
191
-
192
- const yaml = serializeYaml(newFm);
193
- fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
194
84
  }
195
85
 
196
86
  // ---------------------------------------------------------------------------
197
- // Body parser
87
+ // Model & scanning — sourceStates tracks per-source state
198
88
  // ---------------------------------------------------------------------------
199
- function parseBody(req) {
200
- return new Promise((resolve) => {
201
- const chunks = [];
202
- req.on('data', c => chunks.push(c));
203
- req.on('end', () => {
204
- try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
205
- catch { resolve({}); }
206
- });
207
- });
208
- }
209
-
210
- // ---------------------------------------------------------------------------
211
- // File scanner and in-memory model
212
- // ---------------------------------------------------------------------------
213
- const model = {
214
- project: null,
215
- milestones: [],
216
- epics: [],
217
- features: [],
218
- sprints: [],
219
- boards: [],
220
- reviews: [],
221
- metrics: null,
222
- };
223
-
224
- function safeReadFile(filePath) {
225
- try {
226
- return fs.readFileSync(filePath, 'utf-8');
227
- } catch {
228
- return null;
89
+ let model = createModel();
90
+ const sourceStates = new Map();
91
+
92
+ function loadSourceConfig(source) {
93
+ // Try to load per-source mdboard.json
94
+ if (!source._repoRoot) return config;
95
+ const candidates = [
96
+ path.join(source._resolvedPath, 'mdboard.json'),
97
+ path.join(source._repoRoot, 'mdboard.json'),
98
+ ];
99
+ for (const p of candidates) {
100
+ try {
101
+ const raw = fs.readFileSync(p, 'utf-8');
102
+ const srcCfg = JSON.parse(raw);
103
+ return deepMerge(config, srcCfg);
104
+ } catch { continue; }
229
105
  }
106
+ return config;
230
107
  }
231
108
 
232
109
  function scanAll() {
233
- model.project = null;
234
- model.milestones = [];
235
- model.epics = [];
236
- model.features = [];
237
- model.sprints = [];
238
- model.boards = [];
239
- model.reviews = [];
240
- model.metrics = null;
241
-
242
- if (!fs.existsSync(projectPath)) return;
243
-
244
- const projectMd = safeReadFile(path.join(projectPath, 'PROJECT.md'));
245
- if (projectMd) {
246
- const parsed = parseFrontmatter(projectMd);
247
- model.project = { ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' };
248
- }
249
-
250
- const metricsMd = safeReadFile(path.join(projectPath, 'metrics.md'));
251
- if (metricsMd) {
252
- const parsed = parseFrontmatter(metricsMd);
253
- model.metrics = { ...parsed.frontmatter, content: parsed.content, _file: 'metrics.md' };
254
- }
255
-
256
- const milestonesDir = path.join(projectPath, 'milestones');
257
- if (fs.existsSync(milestonesDir)) {
258
- for (const ms of safeDirEntries(milestonesDir)) {
259
- const msPath = path.join(milestonesDir, ms);
260
- if (!fs.statSync(msPath).isDirectory()) continue;
261
-
262
- const msReadme = safeReadFile(path.join(msPath, 'README.md'));
263
- if (msReadme) {
264
- const parsed = parseFrontmatter(msReadme);
265
- model.milestones.push({ ...parsed.frontmatter, content: parsed.content, _dir: ms, _file: `milestones/${ms}/README.md` });
266
- }
267
-
268
- const epicsDir = path.join(msPath, 'epics');
269
- if (fs.existsSync(epicsDir)) {
270
- for (const epic of safeDirEntries(epicsDir)) {
271
- const epicPath = path.join(epicsDir, epic);
272
- if (!fs.statSync(epicPath).isDirectory()) continue;
273
-
274
- const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
275
- if (epicReadme) {
276
- const parsed = parseFrontmatter(epicReadme);
277
- model.epics.push({ ...parsed.frontmatter, content: parsed.content, _dir: epic, _milestone: ms, _file: `milestones/${ms}/epics/${epic}/README.md` });
278
- }
279
-
280
- const backlogDir = path.join(epicPath, 'backlog');
281
- if (fs.existsSync(backlogDir)) {
282
- for (const feat of safeDirEntries(backlogDir)) {
283
- if (!feat.startsWith('FEAT-') || !feat.endsWith('.md')) continue;
284
-
285
- const featContent = safeReadFile(path.join(backlogDir, feat));
286
- if (featContent) {
287
- const parsed = parseFrontmatter(featContent);
288
- model.features.push({
289
- ...parsed.frontmatter,
290
- content: parsed.content,
291
- _filename: feat,
292
- _epic: epic,
293
- _milestone: ms,
294
- _file: `milestones/${ms}/epics/${epic}/backlog/${feat}`,
295
- });
296
- }
297
- }
110
+ model = createModel();
111
+ sourceStates.clear();
112
+
113
+ if (workspace && sources.length > 0) {
114
+ // Multi-source mode
115
+ const results = [];
116
+ for (const source of sources) {
117
+ const sourcePath = source._resolvedPath;
118
+ if (!sourcePath) continue;
119
+
120
+ const readonly = source.readonly != null ? source.readonly : (source.type === 'remote');
121
+ const meta = {
122
+ name: source.name,
123
+ label: source.label || source.name,
124
+ color: source.color || null,
125
+ type: source.type,
126
+ readonly,
127
+ };
128
+
129
+ // Load per-source config
130
+ const srcConfig = loadSourceConfig(source);
131
+ const result = scanSource(sourcePath, srcConfig, meta);
132
+
133
+ // Add author info if showAuthor is enabled
134
+ if (workspace.settings && workspace.settings.showAuthor) {
135
+ const repoRoot = source._repoRoot;
136
+ if (repoRoot) {
137
+ for (const task of result.tasks) {
138
+ task._author = getGitAuthor(repoRoot, task._file);
298
139
  }
299
140
  }
300
141
  }
301
142
 
302
- const sprintsDir = path.join(msPath, 'sprints');
303
- if (fs.existsSync(sprintsDir)) {
304
- for (const sp of safeDirEntries(sprintsDir)) {
305
- const spPath = path.join(sprintsDir, sp);
306
- if (!fs.statSync(spPath).isDirectory()) continue;
307
-
308
- const planMd = safeReadFile(path.join(spPath, 'plan.md'));
309
- if (planMd) {
310
- const parsed = parseFrontmatter(planMd);
311
- model.sprints.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/plan.md` });
312
- }
313
-
314
- const boardMd = safeReadFile(path.join(spPath, 'board.md'));
315
- if (boardMd) {
316
- const parsed = parseFrontmatter(boardMd);
317
- model.boards.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/board.md` });
318
- }
143
+ // Store per-source state
144
+ sourceStates.set(source.name, {
145
+ model: result,
146
+ config: srcConfig,
147
+ sourcePath,
148
+ repoRoot: source._repoRoot,
149
+ source,
150
+ });
319
151
 
320
- const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
321
- if (reviewMd) {
322
- const parsed = parseFrontmatter(reviewMd);
323
- model.reviews.push({ ...parsed.frontmatter, content: parsed.content, _dir: sp, _milestone: ms, _file: `milestones/${ms}/sprints/${sp}/review.md` });
324
- }
325
- }
326
- }
152
+ results.push(result);
327
153
  }
154
+ mergeResults(model, results);
155
+ } else {
156
+ // Legacy single-source mode
157
+ const result = scanSource(projectPath, config, {});
158
+ mergeResults(model, [result]);
328
159
  }
329
160
 
330
- computeProgress();
331
- }
332
-
333
- function safeDirEntries(dir) {
334
- try {
335
- return fs.readdirSync(dir).filter(e => !e.startsWith('.'));
336
- } catch {
337
- return [];
338
- }
339
- }
340
-
341
- function computeProgress() {
342
- for (const epic of model.epics) {
343
- const epicFeatures = model.features.filter(f => f._epic === epic._dir && f._milestone === epic._milestone);
344
- const done = epicFeatures.filter(f => f.status === 'done').length;
345
- epic._featureCount = epicFeatures.length;
346
- epic._completedCount = done;
347
- epic._progress = epicFeatures.length > 0 ? Math.round((done / epicFeatures.length) * 100) : 0;
348
- epic._totalPoints = epicFeatures.reduce((sum, f) => sum + (f.points || 0), 0);
349
- }
350
-
351
- for (const ms of model.milestones) {
352
- const msFeatures = model.features.filter(f => f._milestone === ms._dir);
353
- const done = msFeatures.filter(f => f.status === 'done').length;
354
- ms._featureCount = msFeatures.length;
355
- ms._completedCount = done;
356
- ms._progress = msFeatures.length > 0 ? Math.round((done / msFeatures.length) * 100) : 0;
357
- }
161
+ computeProgress(model, config.completedStatus);
358
162
  }
359
163
 
360
164
  // ---------------------------------------------------------------------------
361
- // SSE clients
165
+ // File update helper — resolves correct projectPath per source
362
166
  // ---------------------------------------------------------------------------
363
- const sseClients = new Set();
364
-
365
- function broadcast(data) {
366
- const payload = `data: ${JSON.stringify(data)}\n\n`;
367
- for (const res of sseClients) {
368
- try { res.write(payload); } catch { sseClients.delete(res); }
167
+ function updateFile(sourceName, relFile, updates) {
168
+ if (sourceName && sources.length > 0) {
169
+ const source = sources.find(s => s.name === sourceName);
170
+ if (source && source._resolvedPath) {
171
+ return updateMarkdownFile(source._resolvedPath, relFile, updates);
172
+ }
369
173
  }
174
+ return updateMarkdownFile(projectPath, relFile, updates);
370
175
  }
371
176
 
372
177
  // ---------------------------------------------------------------------------
373
- // File watcher
178
+ // Remote sync
374
179
  // ---------------------------------------------------------------------------
375
- const watchTimers = new Map();
180
+ async function doSyncRemotes() {
181
+ if (!workspace || sources.length === 0) return;
376
182
 
377
- function setupWatcher() {
378
- if (!fs.existsSync(projectPath)) return;
183
+ const remoteSources = sources.filter(s => s.type === 'remote');
184
+ if (remoteSources.length === 0) return;
379
185
 
380
- try {
381
- fs.watch(projectPath, { recursive: true }, (eventType, filename) => {
382
- if (!filename || !filename.endsWith('.md')) return;
186
+ await syncAllRemotes(remoteSources);
187
+ scanAll();
188
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
189
+ }
383
190
 
384
- const key = filename;
385
- if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
191
+ function startSyncInterval() {
192
+ if (syncInterval) clearInterval(syncInterval);
193
+ if (!workspace) return;
386
194
 
387
- watchTimers.set(key, setTimeout(() => {
388
- watchTimers.delete(key);
389
- scanAll();
390
- broadcast({ type: 'update', file: filename, timestamp: new Date().toISOString() });
391
- }, 200));
195
+ const refreshSeconds = (workspace.settings && workspace.settings.refresh) || 300;
196
+ const remoteSources = sources.filter(s => s.type === 'remote');
197
+ if (remoteSources.length === 0) return;
198
+
199
+ syncInterval = setInterval(() => {
200
+ doSyncRemotes().catch(err => {
201
+ console.warn(' Warning: remote sync failed:', err.message);
392
202
  });
393
- } catch {
394
- console.warn(' Warning: file watching unavailable on this platform');
395
- }
203
+ }, refreshSeconds * 1000);
396
204
  }
397
205
 
398
206
  // ---------------------------------------------------------------------------
399
- // API handlers
207
+ // Static file serving
400
208
  // ---------------------------------------------------------------------------
401
- function jsonResponse(res, data, status = 200) {
402
- res.writeHead(status, {
403
- 'Content-Type': 'application/json',
404
- 'Access-Control-Allow-Origin': '*',
405
- 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
406
- 'Access-Control-Allow-Headers': 'Content-Type',
407
- });
408
- res.end(JSON.stringify(data, null, 2));
409
- }
410
-
411
- async function handlePatch(req, res, collection, id) {
412
- const body = await parseBody(req);
209
+ const MIME_TYPES = {
210
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
211
+ '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
212
+ '.ico': 'image/x-icon', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
213
+ '.gif': 'image/gif', '.webp': 'image/webp',
214
+ };
413
215
 
414
- let item;
415
- switch (collection) {
416
- case 'features': item = model.features.find(f => f.id === id); break;
417
- case 'epics': item = model.epics.find(e => e.id === id); break;
418
- case 'milestones': item = model.milestones.find(m => m.id === id); break;
419
- case 'sprints': item = model.sprints.find(s => s.id === id); break;
420
- }
421
-
422
- if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
423
-
424
- try {
425
- updateMarkdownFile(item._file, body);
426
- scanAll();
427
- broadcast({ type: 'update', timestamp: new Date().toISOString() });
428
- return jsonResponse(res, { ok: true });
429
- } catch (err) {
430
- return jsonResponse(res, { error: err.message }, 500);
216
+ function findCustomCss() {
217
+ const candidates = [
218
+ path.join(projectPath, 'mdboard.css'),
219
+ path.join(projectDir, 'mdboard.css'),
220
+ path.join(os.homedir(), '.config', 'mdboard', 'mdboard.css'),
221
+ ];
222
+ for (const p of candidates) {
223
+ if (fs.existsSync(p)) return p;
431
224
  }
225
+ return null;
432
226
  }
433
227
 
434
- async function handleApi(req, res) {
228
+ function serveStatic(req, res) {
435
229
  const url = new URL(req.url, `http://localhost:${port}`);
436
- const pathname = url.pathname;
437
-
438
- // CORS preflight
439
- if (req.method === 'OPTIONS') {
440
- res.writeHead(204, {
441
- 'Access-Control-Allow-Origin': '*',
442
- 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
443
- 'Access-Control-Allow-Headers': 'Content-Type',
444
- });
445
- return res.end();
446
- }
447
230
 
448
- // PATCH routes
449
- if (req.method === 'PATCH') {
450
- let match;
451
- if ((match = pathname.match(/^\/api\/features\/(.+)$/))) {
452
- return handlePatch(req, res, 'features', decodeURIComponent(match[1]));
231
+ if (url.pathname === '/mdboard.css') {
232
+ const cssPath = findCustomCss();
233
+ if (cssPath) {
234
+ try {
235
+ const content = fs.readFileSync(cssPath);
236
+ res.writeHead(200, { 'Content-Type': 'text/css' });
237
+ res.end(content);
238
+ return;
239
+ } catch { /* Fall through */ }
453
240
  }
454
- if ((match = pathname.match(/^\/api\/epics\/(.+)$/))) {
455
- return handlePatch(req, res, 'epics', decodeURIComponent(match[1]));
456
- }
457
- if ((match = pathname.match(/^\/api\/milestones\/(.+)$/))) {
458
- return handlePatch(req, res, 'milestones', decodeURIComponent(match[1]));
459
- }
460
- if ((match = pathname.match(/^\/api\/sprints\/(.+)$/))) {
461
- return handlePatch(req, res, 'sprints', decodeURIComponent(match[1]));
462
- }
463
- return jsonResponse(res, { error: 'Not found' }, 404);
241
+ res.writeHead(204);
242
+ res.end();
243
+ return;
464
244
  }
465
245
 
466
- // GET routes
467
- switch (pathname) {
468
- case '/api/project':
469
- return jsonResponse(res, model.project || {});
470
-
471
- case '/api/milestones':
472
- return jsonResponse(res, model.milestones.map(ms => ({
473
- id: ms.id,
474
- title: ms.title,
475
- status: ms.status,
476
- deadline: ms.deadline,
477
- progress: ms._progress || 0,
478
- featureCount: ms._featureCount || 0,
479
- completedCount: ms._completedCount || 0,
480
- created: ms.created,
481
- content: ms.content,
482
- })));
483
-
484
- case '/api/epics':
485
- return jsonResponse(res, model.epics.map(e => ({
486
- id: e.id,
487
- title: e.title,
488
- milestone: e._milestone,
489
- status: e.status,
490
- priority: e.priority,
491
- dependencies: e.dependencies,
492
- featureCount: e._featureCount || 0,
493
- completedCount: e._completedCount || 0,
494
- totalPoints: e._totalPoints || 0,
495
- progress: e._progress || 0,
496
- content: e.content,
497
- })));
498
-
499
- case '/api/features': {
500
- let features = model.features.map(f => ({
501
- id: f.id,
502
- title: f.title,
503
- epic: f._epic || f.epic,
504
- milestone: f._milestone || f.milestone,
505
- sprint: f.sprint,
506
- status: f.status,
507
- priority: f.priority,
508
- points: f.points,
509
- assigned: f.assigned,
510
- branches: f.branches,
511
- pull_requests: f.pull_requests,
512
- created: f.created,
513
- started: f.started,
514
- completed: f.completed,
515
- content: f.content,
516
- }));
517
-
518
- const status = url.searchParams.get('status');
519
- const epic = url.searchParams.get('epic');
520
- const milestone = url.searchParams.get('milestone');
521
- const sprint = url.searchParams.get('sprint');
522
-
523
- if (status) features = features.filter(f => f.status === status);
524
- if (epic) features = features.filter(f => f.epic === epic);
525
- if (milestone) features = features.filter(f => f.milestone === milestone);
526
- if (sprint) features = features.filter(f => f.sprint === sprint);
527
-
528
- return jsonResponse(res, features);
529
- }
530
-
531
- case '/api/sprints':
532
- return jsonResponse(res, model.sprints.map(s => ({
533
- id: s.id,
534
- milestone: s._milestone,
535
- status: s.status,
536
- goal: s.goal,
537
- start_date: s.start_date,
538
- end_date: s.end_date,
539
- planned_points: s.planned_points,
540
- completed_points: s.completed_points,
541
- features: s.features,
542
- })));
543
-
544
- case '/api/sprint': {
545
- const activeSprint = model.sprints.find(s => s.status === 'active');
546
- if (!activeSprint) return jsonResponse(res, null);
547
-
548
- const board = model.boards.find(b => b._dir === activeSprint._dir && b._milestone === activeSprint._milestone);
549
- return jsonResponse(res, {
550
- id: activeSprint.id,
551
- milestone: activeSprint._milestone,
552
- status: activeSprint.status,
553
- goal: activeSprint.goal,
554
- start_date: activeSprint.start_date,
555
- end_date: activeSprint.end_date,
556
- planned_points: activeSprint.planned_points,
557
- completed_points: activeSprint.completed_points,
558
- features: activeSprint.features,
559
- board: board ? board.content : null,
560
- });
561
- }
562
-
563
- case '/api/metrics':
564
- return jsonResponse(res, model.metrics || {});
565
-
566
- case '/api/health': {
567
- const activeMilestone = model.milestones.find(m => m.status === 'active');
568
- const activeSprint = model.sprints.find(s => s.status === 'active');
569
- const totalFeatures = model.features.length;
570
- const completedFeatures = model.features.filter(f => f.status === 'done').length;
571
- const inProgressFeatures = model.features.filter(f => f.status === 'in-progress').length;
572
-
573
- const completedSprints = model.sprints.filter(s => s.status === 'completed');
574
- let velocity = null;
575
- if (completedSprints.length > 0) {
576
- const totalVelocity = completedSprints.reduce((sum, s) => {
577
- const planned = s.planned_points || 1;
578
- const completed = s.completed_points || 0;
579
- return sum + Math.round((completed / planned) * 100);
580
- }, 0);
581
- velocity = Math.round(totalVelocity / completedSprints.length);
246
+ if (url.pathname === '/logo') {
247
+ if (config.logo && !config.logo.startsWith('http')) {
248
+ const logoPath = path.resolve(projectDir, config.logo);
249
+ if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
250
+ try {
251
+ const logoContent = fs.readFileSync(logoPath);
252
+ const logoExt = path.extname(logoPath);
253
+ const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
254
+ res.writeHead(200, { 'Content-Type': logoMime });
255
+ res.end(logoContent);
256
+ return;
257
+ } catch { /* fall through */ }
582
258
  }
583
-
584
- return jsonResponse(res, {
585
- status: 'ok',
586
- activeMilestone: activeMilestone ? activeMilestone.id : null,
587
- activeSprint: activeSprint ? activeSprint.id : null,
588
- totalFeatures,
589
- completedFeatures,
590
- inProgressFeatures,
591
- velocity,
592
- });
593
259
  }
594
-
595
- case '/api/events':
596
- res.writeHead(200, {
597
- 'Content-Type': 'text/event-stream',
598
- 'Cache-Control': 'no-cache',
599
- 'Connection': 'keep-alive',
600
- 'Access-Control-Allow-Origin': '*',
601
- });
602
- res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
603
- sseClients.add(res);
604
-
605
- const keepalive = setInterval(() => {
606
- try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
607
- }, 30000);
608
-
609
- req.on('close', () => {
610
- sseClients.delete(res);
611
- clearInterval(keepalive);
612
- });
613
- return;
614
-
615
- default:
616
- return jsonResponse(res, { error: 'Not found' }, 404);
260
+ res.writeHead(404);
261
+ res.end('Not found');
262
+ return;
617
263
  }
618
- }
619
-
620
- // ---------------------------------------------------------------------------
621
- // Static file serving
622
- // ---------------------------------------------------------------------------
623
- const MIME_TYPES = {
624
- '.html': 'text/html',
625
- '.css': 'text/css',
626
- '.js': 'application/javascript',
627
- '.json': 'application/json',
628
- '.png': 'image/png',
629
- '.svg': 'image/svg+xml',
630
- '.ico': 'image/x-icon',
631
- };
632
264
 
633
- function serveStatic(req, res) {
634
265
  let filePath = path.join(boardDir, 'index.html');
635
-
636
266
  if (!filePath.startsWith(boardDir)) {
637
267
  res.writeHead(403);
638
268
  res.end('Forbidden');
@@ -655,10 +285,70 @@ function serveStatic(req, res) {
655
285
  // ---------------------------------------------------------------------------
656
286
  // HTTP server
657
287
  // ---------------------------------------------------------------------------
288
+ // ---------------------------------------------------------------------------
289
+ // CRUD helpers — resolve source path and delegate to scanner
290
+ // ---------------------------------------------------------------------------
291
+ function createItemFn(sourceName, collection, data) {
292
+ let sourcePath = projectPath;
293
+ let cfg = config;
294
+
295
+ if (sourceName && sources.length > 0) {
296
+ const source = sources.find(s => s.name === sourceName);
297
+ if (source && source._resolvedPath) {
298
+ sourcePath = source._resolvedPath;
299
+ const state = sourceStates.get(sourceName);
300
+ if (state) cfg = state.config;
301
+ }
302
+ }
303
+
304
+ // Use ALL items across sources to compute next ID — prevents cross-source collisions
305
+ switch (collection) {
306
+ case 'tasks':
307
+ return createTask(sourcePath, cfg, data, model.tasks);
308
+ case 'milestones':
309
+ return createMilestone(sourcePath, cfg, data, model.milestones);
310
+ case 'epics':
311
+ return createEpic(sourcePath, cfg, data, model.epics);
312
+ case 'sprints':
313
+ return createSprint(sourcePath, cfg, data, model.sprints);
314
+ default:
315
+ throw new Error('Unknown collection: ' + collection);
316
+ }
317
+ }
318
+
319
+ function archiveItemFn(sourceName, item) {
320
+ let sourcePath = projectPath;
321
+
322
+ if (sourceName && sources.length > 0) {
323
+ const source = sources.find(s => s.name === sourceName);
324
+ if (source && source._resolvedPath) {
325
+ sourcePath = source._resolvedPath;
326
+ }
327
+ }
328
+
329
+ return archiveItem(sourcePath, item);
330
+ }
331
+
332
+ const apiCtx = {
333
+ get model() { return model; },
334
+ get config() { return config; },
335
+ port,
336
+ projectDir,
337
+ get sources() { return sources; },
338
+ get sourceStates() { return sourceStates; },
339
+ sseClients,
340
+ broadcast,
341
+ updateFile,
342
+ rescanAll: () => scanAll(),
343
+ syncRemotes: doSyncRemotes,
344
+ createItem: createItemFn,
345
+ archiveItem: archiveItemFn,
346
+ };
347
+
658
348
  const server = http.createServer(async (req, res) => {
659
349
  try {
660
350
  if (req.url.startsWith('/api/')) {
661
- await handleApi(req, res);
351
+ await handleApi(req, res, apiCtx);
662
352
  } else {
663
353
  serveStatic(req, res);
664
354
  }
@@ -673,20 +363,55 @@ const server = http.createServer(async (req, res) => {
673
363
  // ---------------------------------------------------------------------------
674
364
  // Startup
675
365
  // ---------------------------------------------------------------------------
676
- if (!fs.existsSync(projectPath)) {
366
+ if (!workspace && !fs.existsSync(projectPath)) {
677
367
  console.warn(`\n Warning: project/ directory not found at ${projectPath}`);
678
368
  console.warn(' Run `mdboard init` to scaffold a new project.\n');
679
369
  }
680
370
 
681
371
  scanAll();
682
- setupWatcher();
372
+
373
+ // Setup watchers
374
+ const projectDirs = [];
375
+ const rootDirs = [projectDir];
376
+
377
+ if (workspace && sources.length > 0) {
378
+ for (const s of sources) {
379
+ if (s._resolvedPath && fs.existsSync(s._resolvedPath)) {
380
+ projectDirs.push(s._resolvedPath);
381
+ }
382
+ }
383
+ } else if (fs.existsSync(projectPath)) {
384
+ projectDirs.push(projectPath);
385
+ }
386
+
387
+ setupWatchers({
388
+ projectDirs,
389
+ rootDirs,
390
+ onScan: () => scanAll(),
391
+ onConfigReload: () => reloadConfig(),
392
+ onBroadcast: (data) => broadcast(data),
393
+ workspacePath: workspace ? workspace._path : null,
394
+ onWorkspaceChange: () => {
395
+ workspace = loadWorkspace(projectDir, workspacePath);
396
+ sources = workspace ? workspace.sources : [];
397
+ scanAll();
398
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
399
+ },
400
+ });
401
+
402
+ // Initial async sync + start periodic sync
403
+ doSyncRemotes().catch(() => {});
404
+ startSyncInterval();
683
405
 
684
406
  server.listen(port, () => {
407
+ const sourceInfo = workspace && sources.length > 0
408
+ ? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
409
+ : '';
685
410
  console.log(`
686
411
  mdboard — Project Dashboard
687
412
  Project: ${projectDir}
688
- Server: http://localhost:${port}
689
- ${fs.existsSync(projectPath) ? '\n Watching project/ for changes...' : ''}
413
+ Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
414
+ ${projectDirs.length > 0 ? '\n Watching ' + projectDirs.length + ' director' + (projectDirs.length === 1 ? 'y' : 'ies') + ' for changes...' : ''}
690
415
  `);
691
416
  });
692
417
 
@@ -700,6 +425,7 @@ server.on('error', (err) => {
700
425
 
701
426
  process.on('SIGINT', () => {
702
427
  console.log('\n Shutting down...');
428
+ if (syncInterval) clearInterval(syncInterval);
703
429
  for (const client of sseClients) {
704
430
  try { client.end(); } catch { /* ignore */ }
705
431
  }
@@ -707,5 +433,6 @@ process.on('SIGINT', () => {
707
433
  });
708
434
 
709
435
  process.on('SIGTERM', () => {
436
+ if (syncInterval) clearInterval(syncInterval);
710
437
  server.close(() => process.exit(0));
711
438
  });