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/api.js +752 -0
- package/bin.js +47 -2
- package/config.js +1 -1
- package/index.html +642 -51
- package/init.js +9 -0
- package/package.json +7 -2
- package/scanner.js +491 -0
- package/server.js +238 -630
- 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
|
*/
|
|
@@ -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 {
|
|
17
|
-
const {
|
|
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 '--
|
|
41
|
-
case '
|
|
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
|
-
//
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
//
|
|
75
|
+
// SSE clients
|
|
190
76
|
// ---------------------------------------------------------------------------
|
|
191
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
//
|
|
87
|
+
// Model & scanning — sourceStates tracks per-source state
|
|
227
88
|
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
106
|
+
return config;
|
|
256
107
|
}
|
|
257
108
|
|
|
258
109
|
function scanAll() {
|
|
259
|
-
model
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
+
// File update helper — resolves correct projectPath per source
|
|
395
166
|
// ---------------------------------------------------------------------------
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
//
|
|
178
|
+
// Remote sync
|
|
407
179
|
// ---------------------------------------------------------------------------
|
|
408
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
'.
|
|
713
|
-
'.
|
|
714
|
-
'.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
});
|