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