mdboard 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.js +752 -0
- package/bin.js +56 -0
- package/config.js +73 -0
- package/defaults.json +43 -0
- package/index.html +865 -137
- package/init.js +45 -4
- package/package.json +9 -2
- package/scanner.js +491 -0
- package/server.js +269 -542
- package/watcher.js +131 -0
- package/workspace.js +220 -0
- package/yaml.js +129 -0
package/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
|
|
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 '--
|
|
35
|
-
case '
|
|
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
|
-
//
|
|
59
|
+
// Load configuration
|
|
48
60
|
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
//
|
|
68
|
+
// Workspace
|
|
132
69
|
// ---------------------------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
75
|
+
// SSE clients
|
|
175
76
|
// ---------------------------------------------------------------------------
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
87
|
+
// Model & scanning — sourceStates tracks per-source state
|
|
198
88
|
// ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
+
// File update helper — resolves correct projectPath per source
|
|
362
166
|
// ---------------------------------------------------------------------------
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
//
|
|
178
|
+
// Remote sync
|
|
374
179
|
// ---------------------------------------------------------------------------
|
|
375
|
-
|
|
180
|
+
async function doSyncRemotes() {
|
|
181
|
+
if (!workspace || sources.length === 0) return;
|
|
376
182
|
|
|
377
|
-
|
|
378
|
-
if (
|
|
183
|
+
const remoteSources = sources.filter(s => s.type === 'remote');
|
|
184
|
+
if (remoteSources.length === 0) return;
|
|
379
185
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
186
|
+
await syncAllRemotes(remoteSources);
|
|
187
|
+
scanAll();
|
|
188
|
+
broadcast({ type: 'update', timestamp: new Date().toISOString() });
|
|
189
|
+
}
|
|
383
190
|
|
|
384
|
-
|
|
385
|
-
|
|
191
|
+
function startSyncInterval() {
|
|
192
|
+
if (syncInterval) clearInterval(syncInterval);
|
|
193
|
+
if (!workspace) return;
|
|
386
194
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
}
|
|
394
|
-
console.warn(' Warning: file watching unavailable on this platform');
|
|
395
|
-
}
|
|
203
|
+
}, refreshSeconds * 1000);
|
|
396
204
|
}
|
|
397
205
|
|
|
398
206
|
// ---------------------------------------------------------------------------
|
|
399
|
-
//
|
|
207
|
+
// Static file serving
|
|
400
208
|
// ---------------------------------------------------------------------------
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
});
|