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/init.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { loadConfig } = require('./config');
|
|
5
6
|
|
|
6
7
|
const cwd = process.cwd();
|
|
7
8
|
const projectPath = path.join(cwd, 'project');
|
|
8
9
|
const projectName = path.basename(cwd);
|
|
9
10
|
const today = new Date().toISOString().split('T')[0];
|
|
11
|
+
const config = loadConfig(cwd);
|
|
10
12
|
|
|
11
13
|
if (fs.existsSync(projectPath)) {
|
|
12
14
|
console.log(`\n project/ already exists at ${projectPath}`);
|
|
@@ -14,9 +16,11 @@ if (fs.existsSync(projectPath)) {
|
|
|
14
16
|
process.exit(0);
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
const msDir = config.entities.milestone.dir;
|
|
20
|
+
|
|
17
21
|
// Create directories
|
|
18
22
|
fs.mkdirSync(projectPath, { recursive: true });
|
|
19
|
-
fs.mkdirSync(path.join(projectPath,
|
|
23
|
+
fs.mkdirSync(path.join(projectPath, msDir), { recursive: true });
|
|
20
24
|
fs.mkdirSync(path.join(projectPath, 'archive'), { recursive: true });
|
|
21
25
|
|
|
22
26
|
// PROJECT.md
|
|
@@ -52,17 +56,54 @@ updated: ${today}
|
|
|
52
56
|
|
|
53
57
|
`, 'utf-8');
|
|
54
58
|
|
|
59
|
+
// mdboard.json — example config
|
|
60
|
+
fs.writeFileSync(path.join(projectPath, 'mdboard.json'), `{
|
|
61
|
+
"entities": {
|
|
62
|
+
"task": {
|
|
63
|
+
"singular": "Task",
|
|
64
|
+
"plural": "Tasks",
|
|
65
|
+
"prefix": "TASK"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
`, 'utf-8');
|
|
70
|
+
|
|
71
|
+
// mdboard.css — example custom theme
|
|
72
|
+
fs.writeFileSync(path.join(projectPath, 'mdboard.css'), `/* mdboard custom theme
|
|
73
|
+
Override any CSS variable from the dashboard.
|
|
74
|
+
Example:
|
|
75
|
+
:root {
|
|
76
|
+
--bg: #FFFFFF;
|
|
77
|
+
--surface: #F8F9FA;
|
|
78
|
+
--text: #212529;
|
|
79
|
+
--accent: #0969DA;
|
|
80
|
+
}
|
|
81
|
+
*/
|
|
82
|
+
`, 'utf-8');
|
|
83
|
+
|
|
55
84
|
console.log(`
|
|
56
85
|
mdboard init — project scaffolded
|
|
57
86
|
|
|
58
87
|
Created:
|
|
59
88
|
project/PROJECT.md
|
|
60
89
|
project/metrics.md
|
|
61
|
-
project/
|
|
90
|
+
project/${msDir}/
|
|
62
91
|
project/archive/
|
|
92
|
+
project/mdboard.json
|
|
93
|
+
project/mdboard.css
|
|
63
94
|
|
|
64
95
|
Next steps:
|
|
65
96
|
1. Edit project/PROJECT.md with your project details
|
|
66
|
-
2. Create milestones and epics under project/
|
|
67
|
-
3.
|
|
97
|
+
2. Create milestones and epics under project/${msDir}/
|
|
98
|
+
3. Customize project/mdboard.json for your workflow
|
|
99
|
+
4. Run \`mdboard\` to start the dashboard
|
|
100
|
+
|
|
101
|
+
Multi-repo workspace:
|
|
102
|
+
Create a workspace.json to manage multiple repos:
|
|
103
|
+
{
|
|
104
|
+
"sources": [
|
|
105
|
+
{ "name": "api", "path": "../api-repo", "icon": "🔧" },
|
|
106
|
+
{ "name": "web", "path": "../web-repo", "icon": "🌐" }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
68
109
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Git-based project management dashboard. Reads markdown files with YAML frontmatter and serves a visual kanban board, table, milestones, and metrics views.",
|
|
5
5
|
"main": "./server.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
"bin.js",
|
|
11
11
|
"server.js",
|
|
12
12
|
"index.html",
|
|
13
|
-
"init.js"
|
|
13
|
+
"init.js",
|
|
14
|
+
"config.js",
|
|
15
|
+
"defaults.json",
|
|
16
|
+
"scanner.js",
|
|
17
|
+
"watcher.js",
|
|
18
|
+
"api.js",
|
|
19
|
+
"yaml.js",
|
|
20
|
+
"workspace.js"
|
|
14
21
|
],
|
|
15
22
|
"keywords": [
|
|
16
23
|
"project-management",
|
package/scanner.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — File scanner and in-memory model
|
|
3
|
+
*
|
|
4
|
+
* Scans project directories for markdown files and builds
|
|
5
|
+
* the in-memory model. Supports multi-source scanning.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { parseFrontmatter, serializeYaml } = require('./yaml');
|
|
11
|
+
|
|
12
|
+
function createModel() {
|
|
13
|
+
return {
|
|
14
|
+
project: null,
|
|
15
|
+
milestones: [],
|
|
16
|
+
epics: [],
|
|
17
|
+
tasks: [],
|
|
18
|
+
sprints: [],
|
|
19
|
+
boards: [],
|
|
20
|
+
reviews: [],
|
|
21
|
+
metrics: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeReadFile(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeDirEntries(dir) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readdirSync(dir).filter(e => !e.startsWith('.'));
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isTaskFile(filename, config) {
|
|
42
|
+
if (!filename.endsWith('.md')) return false;
|
|
43
|
+
const prefix = config.entities.task.prefix;
|
|
44
|
+
if (filename.startsWith(prefix + '-')) return true;
|
|
45
|
+
const legacy = config.entities.task.legacyPrefixes || [];
|
|
46
|
+
for (const lp of legacy) {
|
|
47
|
+
if (filename.startsWith(lp + '-')) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scan a single source directory and return all items found.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} sourcePath - Absolute path to the project/ directory
|
|
56
|
+
* @param {object} config - mdboard config object
|
|
57
|
+
* @param {object} sourceMeta - { name, label, color, type, readonly }
|
|
58
|
+
* @returns {object} - { project, milestones, epics, tasks, sprints, boards, reviews, metrics }
|
|
59
|
+
*/
|
|
60
|
+
function scanSource(sourcePath, config, sourceMeta) {
|
|
61
|
+
const result = {
|
|
62
|
+
project: null,
|
|
63
|
+
milestones: [],
|
|
64
|
+
epics: [],
|
|
65
|
+
tasks: [],
|
|
66
|
+
sprints: [],
|
|
67
|
+
boards: [],
|
|
68
|
+
reviews: [],
|
|
69
|
+
metrics: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(sourcePath)) return result;
|
|
73
|
+
|
|
74
|
+
const meta = sourceMeta || {};
|
|
75
|
+
const sourcePrefix = meta.name ? meta.name + ':' : '';
|
|
76
|
+
|
|
77
|
+
function tag(item) {
|
|
78
|
+
if (meta.name) {
|
|
79
|
+
item._source = meta.name;
|
|
80
|
+
item._sourceLabel = meta.label || meta.name;
|
|
81
|
+
item._sourceColor = meta.color || null;
|
|
82
|
+
item._sourceType = meta.type || 'local';
|
|
83
|
+
item._readonly = meta.readonly || false;
|
|
84
|
+
}
|
|
85
|
+
return item;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function qualifyId(item) {
|
|
89
|
+
if (sourcePrefix && item.id) {
|
|
90
|
+
item._originalId = item.id;
|
|
91
|
+
item.id = sourcePrefix + item.id;
|
|
92
|
+
}
|
|
93
|
+
return item;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
|
|
97
|
+
if (projectMd) {
|
|
98
|
+
const parsed = parseFrontmatter(projectMd);
|
|
99
|
+
result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const metricsMd = safeReadFile(path.join(sourcePath, 'metrics.md'));
|
|
103
|
+
if (metricsMd) {
|
|
104
|
+
const parsed = parseFrontmatter(metricsMd);
|
|
105
|
+
result.metrics = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'metrics.md' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const msDir = config.entities.milestone.dir;
|
|
109
|
+
const epicDir = config.entities.epic.dir;
|
|
110
|
+
const taskDir = config.entities.task.dir;
|
|
111
|
+
const sprintDir = config.entities.sprint.dir;
|
|
112
|
+
|
|
113
|
+
const milestonesDir = path.join(sourcePath, msDir);
|
|
114
|
+
if (fs.existsSync(milestonesDir)) {
|
|
115
|
+
for (const ms of safeDirEntries(milestonesDir)) {
|
|
116
|
+
const msPath = path.join(milestonesDir, ms);
|
|
117
|
+
if (!fs.statSync(msPath).isDirectory()) continue;
|
|
118
|
+
|
|
119
|
+
const msReadme = safeReadFile(path.join(msPath, 'README.md'));
|
|
120
|
+
if (msReadme) {
|
|
121
|
+
const parsed = parseFrontmatter(msReadme);
|
|
122
|
+
result.milestones.push(qualifyId(tag({
|
|
123
|
+
...parsed.frontmatter, content: parsed.content,
|
|
124
|
+
_dir: ms, _file: `${msDir}/${ms}/README.md`
|
|
125
|
+
})));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const epicsDir = path.join(msPath, epicDir);
|
|
129
|
+
if (fs.existsSync(epicsDir)) {
|
|
130
|
+
for (const epic of safeDirEntries(epicsDir)) {
|
|
131
|
+
const epicPath = path.join(epicsDir, epic);
|
|
132
|
+
if (!fs.statSync(epicPath).isDirectory()) continue;
|
|
133
|
+
|
|
134
|
+
const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
|
|
135
|
+
if (epicReadme) {
|
|
136
|
+
const parsed = parseFrontmatter(epicReadme);
|
|
137
|
+
result.epics.push(qualifyId(tag({
|
|
138
|
+
...parsed.frontmatter, content: parsed.content,
|
|
139
|
+
_dir: epic, _milestone: ms,
|
|
140
|
+
_file: `${msDir}/${ms}/${epicDir}/${epic}/README.md`
|
|
141
|
+
})));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const backlogDir = path.join(epicPath, taskDir);
|
|
145
|
+
if (fs.existsSync(backlogDir)) {
|
|
146
|
+
for (const feat of safeDirEntries(backlogDir)) {
|
|
147
|
+
if (!isTaskFile(feat, config)) continue;
|
|
148
|
+
|
|
149
|
+
const featContent = safeReadFile(path.join(backlogDir, feat));
|
|
150
|
+
if (featContent) {
|
|
151
|
+
const parsed = parseFrontmatter(featContent);
|
|
152
|
+
result.tasks.push(qualifyId(tag({
|
|
153
|
+
...parsed.frontmatter, content: parsed.content,
|
|
154
|
+
_filename: feat, _epic: epic, _milestone: ms,
|
|
155
|
+
_file: `${msDir}/${ms}/${epicDir}/${epic}/${taskDir}/${feat}`,
|
|
156
|
+
})));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sprintsDir = path.join(msPath, sprintDir);
|
|
164
|
+
if (fs.existsSync(sprintsDir)) {
|
|
165
|
+
for (const sp of safeDirEntries(sprintsDir)) {
|
|
166
|
+
const spPath = path.join(sprintsDir, sp);
|
|
167
|
+
if (!fs.statSync(spPath).isDirectory()) continue;
|
|
168
|
+
|
|
169
|
+
const planMd = safeReadFile(path.join(spPath, 'plan.md'));
|
|
170
|
+
if (planMd) {
|
|
171
|
+
const parsed = parseFrontmatter(planMd);
|
|
172
|
+
result.sprints.push(qualifyId(tag({
|
|
173
|
+
...parsed.frontmatter, content: parsed.content,
|
|
174
|
+
_dir: sp, _milestone: ms,
|
|
175
|
+
_file: `${msDir}/${ms}/${sprintDir}/${sp}/plan.md`
|
|
176
|
+
})));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const boardMd = safeReadFile(path.join(spPath, 'board.md'));
|
|
180
|
+
if (boardMd) {
|
|
181
|
+
const parsed = parseFrontmatter(boardMd);
|
|
182
|
+
result.boards.push(tag({
|
|
183
|
+
...parsed.frontmatter, content: parsed.content,
|
|
184
|
+
_dir: sp, _milestone: ms,
|
|
185
|
+
_file: `${msDir}/${ms}/${sprintDir}/${sp}/board.md`
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
|
|
190
|
+
if (reviewMd) {
|
|
191
|
+
const parsed = parseFrontmatter(reviewMd);
|
|
192
|
+
result.reviews.push(tag({
|
|
193
|
+
...parsed.frontmatter, content: parsed.content,
|
|
194
|
+
_dir: sp, _milestone: ms,
|
|
195
|
+
_file: `${msDir}/${ms}/${sprintDir}/${sp}/review.md`
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function computeProgress(model, completedStatus) {
|
|
207
|
+
for (const epic of model.epics) {
|
|
208
|
+
const epicTasks = model.tasks.filter(f =>
|
|
209
|
+
f._epic === epic._dir && f._milestone === epic._milestone &&
|
|
210
|
+
(!epic._source || f._source === epic._source)
|
|
211
|
+
);
|
|
212
|
+
const done = epicTasks.filter(f => f.status === completedStatus).length;
|
|
213
|
+
epic._featureCount = epicTasks.length;
|
|
214
|
+
epic._completedCount = done;
|
|
215
|
+
epic._progress = epicTasks.length > 0 ? Math.round((done / epicTasks.length) * 100) : 0;
|
|
216
|
+
epic._totalPoints = epicTasks.reduce((sum, f) => sum + (f.points || 0), 0);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const ms of model.milestones) {
|
|
220
|
+
const msTasks = model.tasks.filter(f =>
|
|
221
|
+
f._milestone === ms._dir &&
|
|
222
|
+
(!ms._source || f._source === ms._source)
|
|
223
|
+
);
|
|
224
|
+
const done = msTasks.filter(f => f.status === completedStatus).length;
|
|
225
|
+
ms._featureCount = msTasks.length;
|
|
226
|
+
ms._completedCount = done;
|
|
227
|
+
ms._progress = msTasks.length > 0 ? Math.round((done / msTasks.length) * 100) : 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function updateMarkdownFile(projectPath, relFile, updates) {
|
|
232
|
+
const filePath = path.join(projectPath, relFile);
|
|
233
|
+
if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
|
|
234
|
+
|
|
235
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
236
|
+
const parsed = parseFrontmatter(raw);
|
|
237
|
+
|
|
238
|
+
const newFm = { ...parsed.frontmatter };
|
|
239
|
+
let body = parsed.content;
|
|
240
|
+
|
|
241
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
242
|
+
if (k === 'content') { body = v; continue; }
|
|
243
|
+
if (k.startsWith('_')) continue;
|
|
244
|
+
newFm[k] = v;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const yaml = serializeYaml(newFm);
|
|
248
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Merge results from multiple scanSource calls into a single model.
|
|
253
|
+
*/
|
|
254
|
+
function mergeResults(model, results) {
|
|
255
|
+
for (const r of results) {
|
|
256
|
+
if (r.project && !model.project) model.project = r.project;
|
|
257
|
+
model.milestones.push(...r.milestones);
|
|
258
|
+
model.epics.push(...r.epics);
|
|
259
|
+
model.tasks.push(...r.tasks);
|
|
260
|
+
model.sprints.push(...r.sprints);
|
|
261
|
+
model.boards.push(...r.boards);
|
|
262
|
+
model.reviews.push(...r.reviews);
|
|
263
|
+
if (r.metrics && !model.metrics) model.metrics = r.metrics;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the next auto-incremented ID for an entity type.
|
|
269
|
+
*
|
|
270
|
+
* @param {object[]} items - Existing items array
|
|
271
|
+
* @param {string} prefix - ID prefix (e.g. "TASK", "MS", "EPIC", "SP")
|
|
272
|
+
* @returns {string} - Next ID like "TASK-004"
|
|
273
|
+
*/
|
|
274
|
+
function getNextId(items, prefix) {
|
|
275
|
+
let max = 0;
|
|
276
|
+
const re = new RegExp('^(?:[\\w]+:)?' + prefix + '-(\\d+)$');
|
|
277
|
+
for (const item of items) {
|
|
278
|
+
const id = item._originalId || item.id || '';
|
|
279
|
+
const m = id.match(re);
|
|
280
|
+
if (m) {
|
|
281
|
+
const n = parseInt(m[1], 10);
|
|
282
|
+
if (n > max) max = n;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return prefix + '-' + String(max + 1).padStart(3, '0');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create a new task markdown file.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
292
|
+
* @param {object} config - mdboard config
|
|
293
|
+
* @param {object} data - { title, status, priority, points, assigned, sprint, milestone, epic, content }
|
|
294
|
+
* @param {object[]} existingTasks - Current tasks for ID generation
|
|
295
|
+
* @returns {object} - { id, file }
|
|
296
|
+
*/
|
|
297
|
+
function createTask(sourcePath, config, data, existingTasks) {
|
|
298
|
+
const prefix = config.entities.task.prefix;
|
|
299
|
+
const id = getNextId(existingTasks, prefix);
|
|
300
|
+
const filename = id + '.md';
|
|
301
|
+
|
|
302
|
+
const msDir = config.entities.milestone.dir;
|
|
303
|
+
const epicDir = config.entities.epic.dir;
|
|
304
|
+
const taskDir = config.entities.task.dir;
|
|
305
|
+
|
|
306
|
+
if (!data.milestone || !data.epic) {
|
|
307
|
+
throw new Error('milestone and epic are required to create a task');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const dir = path.join(sourcePath, msDir, data.milestone, epicDir, data.epic, taskDir);
|
|
311
|
+
if (!fs.existsSync(dir)) {
|
|
312
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fm = {
|
|
316
|
+
id: id,
|
|
317
|
+
title: data.title || 'New Task',
|
|
318
|
+
status: data.status || 'backlog',
|
|
319
|
+
};
|
|
320
|
+
if (data.priority) fm.priority = data.priority;
|
|
321
|
+
if (data.points != null) fm.points = data.points;
|
|
322
|
+
if (data.assigned) fm.assigned = data.assigned;
|
|
323
|
+
if (data.sprint) fm.sprint = data.sprint;
|
|
324
|
+
if (data.links) fm.links = data.links;
|
|
325
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
326
|
+
|
|
327
|
+
const yaml = serializeYaml(fm);
|
|
328
|
+
const content = data.content || '';
|
|
329
|
+
const filePath = path.join(dir, filename);
|
|
330
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
331
|
+
|
|
332
|
+
const relFile = `${msDir}/${data.milestone}/${epicDir}/${data.epic}/${taskDir}/${filename}`;
|
|
333
|
+
return { id, file: relFile };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Create a new milestone directory with README.md.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
340
|
+
* @param {object} config - mdboard config
|
|
341
|
+
* @param {object} data - { title, status, deadline, content, tracks }
|
|
342
|
+
* @param {object[]} existingMilestones - Current milestones for ID generation
|
|
343
|
+
* @returns {object} - { id, dir, file }
|
|
344
|
+
*/
|
|
345
|
+
function createMilestone(sourcePath, config, data, existingMilestones) {
|
|
346
|
+
const id = getNextId(existingMilestones, 'MS');
|
|
347
|
+
const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
348
|
+
const msDir = config.entities.milestone.dir;
|
|
349
|
+
const dirPath = path.join(sourcePath, msDir, slug);
|
|
350
|
+
|
|
351
|
+
if (fs.existsSync(dirPath)) {
|
|
352
|
+
throw new Error('Milestone directory already exists: ' + slug);
|
|
353
|
+
}
|
|
354
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
355
|
+
|
|
356
|
+
const fm = {
|
|
357
|
+
id: id,
|
|
358
|
+
title: data.title || 'New Milestone',
|
|
359
|
+
status: data.status || 'planned',
|
|
360
|
+
};
|
|
361
|
+
if (data.deadline) fm.deadline = data.deadline;
|
|
362
|
+
if (data.tracks) fm.tracks = data.tracks;
|
|
363
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
364
|
+
|
|
365
|
+
const yaml = serializeYaml(fm);
|
|
366
|
+
const content = data.content || '';
|
|
367
|
+
const filePath = path.join(dirPath, 'README.md');
|
|
368
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
369
|
+
|
|
370
|
+
const relFile = `${msDir}/${slug}/README.md`;
|
|
371
|
+
return { id, dir: slug, file: relFile };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Create a new epic directory with README.md and backlog/.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
378
|
+
* @param {object} config - mdboard config
|
|
379
|
+
* @param {object} data - { title, status, priority, milestone, content }
|
|
380
|
+
* @param {object[]} existingEpics - Current epics for ID generation
|
|
381
|
+
* @returns {object} - { id, dir, file }
|
|
382
|
+
*/
|
|
383
|
+
function createEpic(sourcePath, config, data, existingEpics) {
|
|
384
|
+
if (!data.milestone) throw new Error('milestone is required to create an epic');
|
|
385
|
+
|
|
386
|
+
const id = getNextId(existingEpics, 'EPIC');
|
|
387
|
+
const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
388
|
+
|
|
389
|
+
const msDir = config.entities.milestone.dir;
|
|
390
|
+
const epicDir = config.entities.epic.dir;
|
|
391
|
+
const taskDir = config.entities.task.dir;
|
|
392
|
+
|
|
393
|
+
const dirPath = path.join(sourcePath, msDir, data.milestone, epicDir, slug);
|
|
394
|
+
if (fs.existsSync(dirPath)) {
|
|
395
|
+
throw new Error('Epic directory already exists: ' + slug);
|
|
396
|
+
}
|
|
397
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
398
|
+
fs.mkdirSync(path.join(dirPath, taskDir), { recursive: true });
|
|
399
|
+
|
|
400
|
+
const fm = {
|
|
401
|
+
id: id,
|
|
402
|
+
title: data.title || 'New Epic',
|
|
403
|
+
status: data.status || 'active',
|
|
404
|
+
};
|
|
405
|
+
if (data.priority) fm.priority = data.priority;
|
|
406
|
+
if (data.dependencies) fm.dependencies = data.dependencies;
|
|
407
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
408
|
+
|
|
409
|
+
const yaml = serializeYaml(fm);
|
|
410
|
+
const content = data.content || '';
|
|
411
|
+
const filePath = path.join(dirPath, 'README.md');
|
|
412
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
413
|
+
|
|
414
|
+
const relFile = `${msDir}/${data.milestone}/${epicDir}/${slug}/README.md`;
|
|
415
|
+
return { id, dir: slug, file: relFile };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Create a new sprint directory with plan.md.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
422
|
+
* @param {object} config - mdboard config
|
|
423
|
+
* @param {object} data - { goal, status, start_date, end_date, milestone, planned_points, features, content }
|
|
424
|
+
* @param {object[]} existingSprints - Current sprints for ID generation
|
|
425
|
+
* @returns {object} - { id, dir, file }
|
|
426
|
+
*/
|
|
427
|
+
function createSprint(sourcePath, config, data, existingSprints) {
|
|
428
|
+
if (!data.milestone) throw new Error('milestone is required to create a sprint');
|
|
429
|
+
|
|
430
|
+
const id = getNextId(existingSprints, 'SP');
|
|
431
|
+
const slug = id.toLowerCase();
|
|
432
|
+
|
|
433
|
+
const msDir = config.entities.milestone.dir;
|
|
434
|
+
const sprintDir = config.entities.sprint.dir;
|
|
435
|
+
|
|
436
|
+
const dirPath = path.join(sourcePath, msDir, data.milestone, sprintDir, slug);
|
|
437
|
+
if (fs.existsSync(dirPath)) {
|
|
438
|
+
throw new Error('Sprint directory already exists: ' + slug);
|
|
439
|
+
}
|
|
440
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
441
|
+
|
|
442
|
+
const fm = {
|
|
443
|
+
id: id,
|
|
444
|
+
status: data.status || 'planned',
|
|
445
|
+
};
|
|
446
|
+
if (data.goal) fm.goal = data.goal;
|
|
447
|
+
if (data.start_date) fm.start_date = data.start_date;
|
|
448
|
+
if (data.end_date) fm.end_date = data.end_date;
|
|
449
|
+
if (data.planned_points != null) fm.planned_points = data.planned_points;
|
|
450
|
+
if (data.features) fm.features = data.features;
|
|
451
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
452
|
+
|
|
453
|
+
const yaml = serializeYaml(fm);
|
|
454
|
+
const content = data.content || '';
|
|
455
|
+
const filePath = path.join(dirPath, 'plan.md');
|
|
456
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
457
|
+
|
|
458
|
+
const relFile = `${msDir}/${data.milestone}/${sprintDir}/${slug}/plan.md`;
|
|
459
|
+
return { id, dir: slug, file: relFile };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Archive an item by moving its file/directory to project/archive/.
|
|
464
|
+
*
|
|
465
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
466
|
+
* @param {object} item - The item with _file property
|
|
467
|
+
* @returns {boolean}
|
|
468
|
+
*/
|
|
469
|
+
function archiveItem(sourcePath, item) {
|
|
470
|
+
if (!item || !item._file) throw new Error('Item has no file path');
|
|
471
|
+
|
|
472
|
+
const srcPath = path.join(sourcePath, item._file);
|
|
473
|
+
if (!fs.existsSync(srcPath)) throw new Error('Source file not found: ' + item._file);
|
|
474
|
+
|
|
475
|
+
const archiveDir = path.join(sourcePath, 'archive');
|
|
476
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
477
|
+
|
|
478
|
+
const basename = path.basename(srcPath);
|
|
479
|
+
const timestamp = Date.now();
|
|
480
|
+
const destName = timestamp + '-' + basename;
|
|
481
|
+
const destPath = path.join(archiveDir, destName);
|
|
482
|
+
|
|
483
|
+
fs.renameSync(srcPath, destPath);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
module.exports = {
|
|
488
|
+
createModel, scanSource, computeProgress, isTaskFile,
|
|
489
|
+
safeReadFile, safeDirEntries, updateMarkdownFile, mergeResults,
|
|
490
|
+
getNextId, createTask, createMilestone, createEpic, createSprint, archiveItem
|
|
491
|
+
};
|