mdboard 1.1.0 → 1.3.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 +83 -10
- package/build.js +44 -0
- package/index.html +2344 -134
- package/package.json +8 -6
- package/src/cli/cli.js +362 -0
- package/src/cli/init.js +123 -0
- package/src/cli/status.js +150 -0
- package/src/cli/sync.js +194 -0
- package/src/cli/theme.js +142 -0
- package/src/client/app.js +266 -0
- package/src/client/board.js +157 -0
- package/src/client/core.js +331 -0
- package/src/client/editor.js +318 -0
- package/src/client/history.js +137 -0
- package/src/client/metrics.js +38 -0
- package/src/client/milestones.js +77 -0
- package/src/client/notes.js +183 -0
- package/src/client/overview.js +104 -0
- package/src/client/panel.js +637 -0
- package/src/client/styles.css +471 -0
- package/src/client/table.js +111 -0
- package/src/client/template.html +144 -0
- package/src/client/themes.js +261 -0
- package/src/client/workspace.js +164 -0
- package/src/core/agent-scanner.js +260 -0
- package/{config.js → src/core/config.js} +27 -2
- package/src/core/history.js +130 -0
- package/src/core/scanner.js +611 -0
- package/src/core/workspace.js +220 -0
- package/src/core/yaml.js +133 -0
- package/src/server/api.js +893 -0
- package/src/server/server.js +511 -0
- package/src/server/watcher.js +162 -0
- package/init.js +0 -100
- package/server.js +0 -830
package/server.js
DELETED
|
@@ -1,830 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* mdboard — Project Dashboard Server
|
|
4
|
-
*
|
|
5
|
-
* Zero-dependency Node.js server that reads markdown project management
|
|
6
|
-
* files and serves a visual dashboard + JSON API.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* mdboard --project /path/to/workspace --port 3333
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const http = require('http');
|
|
13
|
-
const fs = require('fs');
|
|
14
|
-
const path = require('path');
|
|
15
|
-
const os = require('os');
|
|
16
|
-
const { URL } = require('url');
|
|
17
|
-
const { loadConfig } = require('./config');
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// CLI argument parsing
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
const args = process.argv.slice(2);
|
|
23
|
-
let projectDir = process.cwd();
|
|
24
|
-
let port = 3333;
|
|
25
|
-
|
|
26
|
-
for (let i = 0; i < args.length; i++) {
|
|
27
|
-
switch (args[i]) {
|
|
28
|
-
case '--project':
|
|
29
|
-
projectDir = path.resolve(args[++i] || '.');
|
|
30
|
-
break;
|
|
31
|
-
case '--port':
|
|
32
|
-
port = parseInt(args[++i], 10) || 3333;
|
|
33
|
-
break;
|
|
34
|
-
case '--config':
|
|
35
|
-
// Handled by bin.js via process.env.MDBOARD_CONFIG; skip value here.
|
|
36
|
-
i++;
|
|
37
|
-
break;
|
|
38
|
-
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.
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const projectPath = path.join(projectDir, 'project');
|
|
50
|
-
const boardDir = __dirname;
|
|
51
|
-
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Load configuration
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
56
|
-
|
|
57
|
-
function reloadConfig() {
|
|
58
|
-
config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
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)
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
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
|
-
}
|
|
187
|
-
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
|
-
// Markdown file updater
|
|
190
|
-
// ---------------------------------------------------------------------------
|
|
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;
|
|
200
|
-
|
|
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;
|
|
205
|
-
}
|
|
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
|
-
}
|
|
224
|
-
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// File scanner and in-memory model
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
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
|
-
}
|
|
246
|
-
|
|
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;
|
|
254
|
-
}
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
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
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
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
|
-
}
|
|
350
|
-
|
|
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
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
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
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
// SSE clients
|
|
395
|
-
// ---------------------------------------------------------------------------
|
|
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); }
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// ---------------------------------------------------------------------------
|
|
406
|
-
// File watcher
|
|
407
|
-
// ---------------------------------------------------------------------------
|
|
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
|
-
}
|
|
439
|
-
|
|
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
|
-
}
|
|
464
|
-
|
|
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));
|
|
476
|
-
}
|
|
477
|
-
|
|
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
|
-
}
|
|
500
|
-
|
|
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
|
-
}
|
|
514
|
-
|
|
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',
|
|
525
|
-
});
|
|
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
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// ---------------------------------------------------------------------------
|
|
708
|
-
// Static file serving
|
|
709
|
-
// ---------------------------------------------------------------------------
|
|
710
|
-
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',
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
function findCustomCss() {
|
|
721
|
-
const candidates = [
|
|
722
|
-
path.join(projectPath, 'mdboard.css'),
|
|
723
|
-
path.join(projectDir, 'mdboard.css'),
|
|
724
|
-
path.join(os.homedir(), '.config', 'mdboard', 'mdboard.css'),
|
|
725
|
-
];
|
|
726
|
-
for (const p of candidates) {
|
|
727
|
-
if (fs.existsSync(p)) return p;
|
|
728
|
-
}
|
|
729
|
-
return null;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function serveStatic(req, res) {
|
|
733
|
-
const url = new URL(req.url, `http://localhost:${port}`);
|
|
734
|
-
|
|
735
|
-
// Serve custom CSS
|
|
736
|
-
if (url.pathname === '/mdboard.css') {
|
|
737
|
-
const cssPath = findCustomCss();
|
|
738
|
-
if (cssPath) {
|
|
739
|
-
try {
|
|
740
|
-
const content = fs.readFileSync(cssPath);
|
|
741
|
-
res.writeHead(200, { 'Content-Type': 'text/css' });
|
|
742
|
-
res.end(content);
|
|
743
|
-
return;
|
|
744
|
-
} catch {
|
|
745
|
-
// Fall through to 204
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
res.writeHead(204);
|
|
749
|
-
res.end();
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
let filePath = path.join(boardDir, 'index.html');
|
|
754
|
-
|
|
755
|
-
if (!filePath.startsWith(boardDir)) {
|
|
756
|
-
res.writeHead(403);
|
|
757
|
-
res.end('Forbidden');
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const ext = path.extname(filePath);
|
|
762
|
-
const contentType = MIME_TYPES[ext] || 'text/plain';
|
|
763
|
-
|
|
764
|
-
try {
|
|
765
|
-
const content = fs.readFileSync(filePath);
|
|
766
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
767
|
-
res.end(content);
|
|
768
|
-
} catch {
|
|
769
|
-
res.writeHead(404);
|
|
770
|
-
res.end('Not found');
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// ---------------------------------------------------------------------------
|
|
775
|
-
// HTTP server
|
|
776
|
-
// ---------------------------------------------------------------------------
|
|
777
|
-
const server = http.createServer(async (req, res) => {
|
|
778
|
-
try {
|
|
779
|
-
if (req.url.startsWith('/api/')) {
|
|
780
|
-
await handleApi(req, res);
|
|
781
|
-
} else {
|
|
782
|
-
serveStatic(req, res);
|
|
783
|
-
}
|
|
784
|
-
} catch (err) {
|
|
785
|
-
if (!res.headersSent) {
|
|
786
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
787
|
-
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
// ---------------------------------------------------------------------------
|
|
793
|
-
// Startup
|
|
794
|
-
// ---------------------------------------------------------------------------
|
|
795
|
-
if (!fs.existsSync(projectPath)) {
|
|
796
|
-
console.warn(`\n Warning: project/ directory not found at ${projectPath}`);
|
|
797
|
-
console.warn(' Run `mdboard init` to scaffold a new project.\n');
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
scanAll();
|
|
801
|
-
setupWatcher();
|
|
802
|
-
|
|
803
|
-
server.listen(port, () => {
|
|
804
|
-
console.log(`
|
|
805
|
-
mdboard — Project Dashboard
|
|
806
|
-
Project: ${projectDir}
|
|
807
|
-
Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}
|
|
808
|
-
${fs.existsSync(projectPath) ? '\n Watching project/ for changes...' : ''}
|
|
809
|
-
`);
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
server.on('error', (err) => {
|
|
813
|
-
if (err.code === 'EADDRINUSE') {
|
|
814
|
-
console.error(`\n Error: Port ${port} is already in use. Use --port <number> to pick a different port.\n`);
|
|
815
|
-
process.exit(1);
|
|
816
|
-
}
|
|
817
|
-
throw err;
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
process.on('SIGINT', () => {
|
|
821
|
-
console.log('\n Shutting down...');
|
|
822
|
-
for (const client of sseClients) {
|
|
823
|
-
try { client.end(); } catch { /* ignore */ }
|
|
824
|
-
}
|
|
825
|
-
server.close(() => process.exit(0));
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
process.on('SIGTERM', () => {
|
|
829
|
-
server.close(() => process.exit(0));
|
|
830
|
-
});
|