mdboard 1.3.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +117 -59
- package/index.html +2161 -1579
- package/package.json +7 -5
- package/presets/kanban/api.json +91 -0
- package/presets/kanban/cli.json +69 -0
- package/presets/kanban/docs.json +29 -0
- package/presets/kanban/entities.json +128 -0
- package/presets/kanban/structure.json +15 -0
- package/presets/kanban/ui.json +86 -0
- package/presets/scrum/api.json +98 -0
- package/presets/scrum/cli.json +120 -0
- package/presets/scrum/docs.json +43 -0
- package/presets/scrum/entities.json +268 -0
- package/presets/scrum/structure.json +32 -0
- package/presets/scrum/ui.json +201 -0
- package/presets/shape-up/api.json +40 -0
- package/presets/shape-up/cli.json +44 -0
- package/presets/shape-up/docs.json +32 -0
- package/presets/shape-up/entities.json +140 -0
- package/presets/shape-up/structure.json +28 -0
- package/presets/shape-up/ui.json +114 -0
- package/src/cli/cli.js +186 -210
- package/src/cli/config.js +234 -0
- package/src/cli/init.js +128 -76
- package/src/cli/preset.js +849 -0
- package/src/cli/skill.js +417 -0
- package/src/cli/status.js +126 -96
- package/src/core/config.js +491 -38
- package/src/core/history.js +17 -1
- package/src/core/scanner.js +373 -463
- package/src/core/workspace.js +0 -15
- package/src/server/api.js +464 -741
- package/src/server/server.js +105 -130
- package/build.js +0 -44
- package/defaults.json +0 -43
- package/src/cli/sync.js +0 -194
- package/src/cli/theme.js +0 -142
- package/src/client/app.js +0 -266
- package/src/client/board.js +0 -157
- package/src/client/core.js +0 -331
- package/src/client/editor.js +0 -318
- package/src/client/history.js +0 -137
- package/src/client/metrics.js +0 -38
- package/src/client/milestones.js +0 -77
- package/src/client/notes.js +0 -183
- package/src/client/overview.js +0 -104
- package/src/client/panel.js +0 -637
- package/src/client/styles.css +0 -471
- package/src/client/table.js +0 -111
- package/src/client/template.html +0 -144
- package/src/client/themes.js +0 -261
- package/src/client/workspace.js +0 -164
- package/src/core/agent-scanner.js +0 -260
package/src/core/scanner.js
CHANGED
|
@@ -1,74 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mdboard —
|
|
2
|
+
* mdboard — Dynamic file scanner and in-memory model
|
|
3
3
|
*
|
|
4
|
-
* Scans project directories
|
|
5
|
-
*
|
|
4
|
+
* Scans project directories based on config-driven hierarchy.
|
|
5
|
+
* No hardcoded entity types — everything comes from entities.json + structure.json.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
8
10
|
const fs = require('fs');
|
|
9
11
|
const path = require('path');
|
|
10
12
|
const { parseFrontmatter, serializeYaml } = require('./yaml');
|
|
13
|
+
const config = require('./config');
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
return {
|
|
14
|
-
project: null,
|
|
15
|
-
milestones: [],
|
|
16
|
-
epics: [],
|
|
17
|
-
tasks: [],
|
|
18
|
-
sprints: [],
|
|
19
|
-
boards: [],
|
|
20
|
-
reviews: [],
|
|
21
|
-
notes: [],
|
|
22
|
-
};
|
|
23
|
-
}
|
|
15
|
+
// --- Utilities ---
|
|
24
16
|
|
|
25
17
|
function safeReadFile(filePath) {
|
|
26
|
-
try {
|
|
27
|
-
return fs.readFileSync(filePath, 'utf-8');
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
18
|
+
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
|
31
19
|
}
|
|
32
20
|
|
|
33
21
|
function safeDirEntries(dir) {
|
|
34
|
-
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
try { return fs.readdirSync(dir).filter(e => !e.startsWith('.')); } catch { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function slugify(text) {
|
|
26
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a filename matches an entity's file pattern.
|
|
31
|
+
* Supports "README.md" (directory entity) and "PREFIX-NNN.md" (file entity).
|
|
32
|
+
*/
|
|
33
|
+
function matchesFilePattern(filename, entityDef) {
|
|
34
|
+
const pattern = entityDef.file || 'README.md';
|
|
35
|
+
if (pattern === 'README.md') return filename === 'README.md';
|
|
36
|
+
if (pattern === 'PREFIX-NNN.md') {
|
|
37
|
+
if (!filename.endsWith('.md')) return false;
|
|
38
|
+
const prefix = entityDef.prefix;
|
|
39
|
+
return prefix && filename.startsWith(prefix + '-');
|
|
38
40
|
}
|
|
41
|
+
return filename === pattern;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Check if an entity uses directory-based storage (README.md inside a named dir).
|
|
46
|
+
*/
|
|
47
|
+
function isDirEntity(entityDef) {
|
|
48
|
+
return (entityDef.file || 'README.md') === 'README.md';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Model ---
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create an empty model. Collections are keyed by entity type.
|
|
55
|
+
* @param {object} cfg - Loaded config
|
|
56
|
+
* @returns {object}
|
|
57
|
+
*/
|
|
58
|
+
function createModel(cfg) {
|
|
59
|
+
const model = { project: null, entities: {} };
|
|
60
|
+
for (const type of config.getEntityTypes(cfg)) {
|
|
61
|
+
model.entities[type] = [];
|
|
48
62
|
}
|
|
49
|
-
return
|
|
63
|
+
return model;
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
// --- Scanner ---
|
|
67
|
+
|
|
52
68
|
/**
|
|
53
|
-
* Scan a single source directory and return all
|
|
69
|
+
* Scan a single source directory and return all entities found.
|
|
54
70
|
*
|
|
55
71
|
* @param {string} sourcePath - Absolute path to the project/ directory
|
|
56
|
-
* @param {object}
|
|
57
|
-
* @param {object} sourceMeta - { name, label, color, type, readonly }
|
|
58
|
-
* @returns {object} - { project,
|
|
72
|
+
* @param {object} cfg - Loaded config
|
|
73
|
+
* @param {object} [sourceMeta] - { name, label, color, type, readonly }
|
|
74
|
+
* @returns {object} - { project, entities: { cycle: [...], bet: [...], ... } }
|
|
59
75
|
*/
|
|
60
|
-
function scanSource(sourcePath,
|
|
61
|
-
const result =
|
|
62
|
-
project: null,
|
|
63
|
-
milestones: [],
|
|
64
|
-
epics: [],
|
|
65
|
-
tasks: [],
|
|
66
|
-
sprints: [],
|
|
67
|
-
boards: [],
|
|
68
|
-
reviews: [],
|
|
69
|
-
notes: [],
|
|
70
|
-
};
|
|
71
|
-
|
|
76
|
+
function scanSource(sourcePath, cfg, sourceMeta) {
|
|
77
|
+
const result = createModel(cfg);
|
|
72
78
|
if (!fs.existsSync(sourcePath)) return result;
|
|
73
79
|
|
|
74
80
|
const meta = sourceMeta || {};
|
|
@@ -93,201 +99,230 @@ function scanSource(sourcePath, config, sourceMeta) {
|
|
|
93
99
|
return item;
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
// Scan PROJECT.md
|
|
96
103
|
const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
|
|
97
104
|
if (projectMd) {
|
|
98
105
|
const parsed = parseFrontmatter(projectMd);
|
|
99
106
|
result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
// Scan hierarchy entities
|
|
110
|
+
const hierarchy = config.getHierarchy(cfg);
|
|
111
|
+
scanHierarchyLevel(sourcePath, hierarchy, [], result, cfg, tag, qualifyId);
|
|
112
|
+
|
|
113
|
+
// Scan standalone entities
|
|
114
|
+
const standalone = (cfg.structure && cfg.structure.standalone) || {};
|
|
115
|
+
for (const [type, def] of Object.entries(standalone)) {
|
|
116
|
+
scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Recursively scan a level of the hierarchy.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} basePath - Current filesystem path
|
|
126
|
+
* @param {object} levelDef - Hierarchy level definition { entityType: { dir, children } }
|
|
127
|
+
* @param {object[]} ancestors - Array of { type, slug } for parent context
|
|
128
|
+
* @param {object} result - Model being built
|
|
129
|
+
* @param {object} cfg - Config
|
|
130
|
+
* @param {function} tag - Source tagger
|
|
131
|
+
* @param {function} qualifyId - ID qualifier
|
|
132
|
+
*/
|
|
133
|
+
function scanHierarchyLevel(basePath, levelDef, ancestors, result, cfg, tag, qualifyId) {
|
|
134
|
+
for (const [type, def] of Object.entries(levelDef)) {
|
|
135
|
+
const entityDef = config.getEntity(cfg, type);
|
|
136
|
+
if (!entityDef) continue;
|
|
137
|
+
|
|
138
|
+
const entityDir = path.join(basePath, def.dir);
|
|
139
|
+
if (!fs.existsSync(entityDir)) continue;
|
|
140
|
+
|
|
141
|
+
if (isDirEntity(entityDef)) {
|
|
142
|
+
// Directory entity: each subdirectory is an instance
|
|
143
|
+
for (const slug of safeDirEntries(entityDir)) {
|
|
144
|
+
const instanceDir = path.join(entityDir, slug);
|
|
145
|
+
if (!fs.statSync(instanceDir).isDirectory()) continue;
|
|
146
|
+
|
|
147
|
+
const content = safeReadFile(path.join(instanceDir, 'README.md'));
|
|
148
|
+
if (!content) continue;
|
|
149
|
+
|
|
150
|
+
const parsed = parseFrontmatter(content);
|
|
151
|
+
const relFile = buildRelFile(ancestors, def.dir, slug, 'README.md');
|
|
152
|
+
|
|
153
|
+
const item = qualifyId(tag({
|
|
154
|
+
...parsed.frontmatter,
|
|
155
|
+
_contentLength: parsed.content ? parsed.content.length : 0,
|
|
156
|
+
_type: type,
|
|
157
|
+
_dir: slug,
|
|
158
|
+
_file: relFile,
|
|
159
|
+
}));
|
|
121
160
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const epicPath = path.join(epicsDir, epic);
|
|
126
|
-
if (!fs.statSync(epicPath).isDirectory()) continue;
|
|
127
|
-
|
|
128
|
-
const epicReadme = safeReadFile(path.join(epicPath, 'README.md'));
|
|
129
|
-
if (epicReadme) {
|
|
130
|
-
const parsed = parseFrontmatter(epicReadme);
|
|
131
|
-
result.epics.push(qualifyId(tag({
|
|
132
|
-
...parsed.frontmatter, content: parsed.content,
|
|
133
|
-
_dir: epic, _milestone: ms,
|
|
134
|
-
_file: `${msDir}/${ms}/${epicDir}/${epic}/README.md`
|
|
135
|
-
})));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const backlogDir = path.join(epicPath, taskDir);
|
|
139
|
-
if (fs.existsSync(backlogDir)) {
|
|
140
|
-
for (const feat of safeDirEntries(backlogDir)) {
|
|
141
|
-
if (!isTaskFile(feat, config)) continue;
|
|
142
|
-
|
|
143
|
-
const featContent = safeReadFile(path.join(backlogDir, feat));
|
|
144
|
-
if (featContent) {
|
|
145
|
-
const parsed = parseFrontmatter(featContent);
|
|
146
|
-
result.tasks.push(qualifyId(tag({
|
|
147
|
-
...parsed.frontmatter, content: parsed.content,
|
|
148
|
-
_filename: feat, _epic: epic, _milestone: ms,
|
|
149
|
-
_file: `${msDir}/${ms}/${epicDir}/${epic}/${taskDir}/${feat}`,
|
|
150
|
-
})));
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
161
|
+
// Store ancestor references
|
|
162
|
+
for (const anc of ancestors) {
|
|
163
|
+
item['_' + anc.type] = anc.slug;
|
|
154
164
|
}
|
|
155
|
-
}
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const planMd = safeReadFile(path.join(spPath, 'plan.md'));
|
|
164
|
-
if (planMd) {
|
|
165
|
-
const parsed = parseFrontmatter(planMd);
|
|
166
|
-
result.sprints.push(qualifyId(tag({
|
|
167
|
-
...parsed.frontmatter, content: parsed.content,
|
|
168
|
-
_dir: sp, _milestone: ms,
|
|
169
|
-
_file: `${msDir}/${ms}/${sprintDir}/${sp}/plan.md`
|
|
170
|
-
})));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const boardMd = safeReadFile(path.join(spPath, 'board.md'));
|
|
174
|
-
if (boardMd) {
|
|
175
|
-
const parsed = parseFrontmatter(boardMd);
|
|
176
|
-
result.boards.push(tag({
|
|
177
|
-
...parsed.frontmatter, content: parsed.content,
|
|
178
|
-
_dir: sp, _milestone: ms,
|
|
179
|
-
_file: `${msDir}/${ms}/${sprintDir}/${sp}/board.md`
|
|
180
|
-
}));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const reviewMd = safeReadFile(path.join(spPath, 'review.md'));
|
|
184
|
-
if (reviewMd) {
|
|
185
|
-
const parsed = parseFrontmatter(reviewMd);
|
|
186
|
-
result.reviews.push(tag({
|
|
187
|
-
...parsed.frontmatter, content: parsed.content,
|
|
188
|
-
_dir: sp, _milestone: ms,
|
|
189
|
-
_file: `${msDir}/${ms}/${sprintDir}/${sp}/review.md`
|
|
190
|
-
}));
|
|
191
|
-
}
|
|
166
|
+
result.entities[type].push(item);
|
|
167
|
+
|
|
168
|
+
// Recurse into children
|
|
169
|
+
if (def.children) {
|
|
170
|
+
const childAncestors = [...ancestors, { type, slug, dir: def.dir }];
|
|
171
|
+
scanHierarchyLevel(instanceDir, def.children, childAncestors, result, cfg, tag, qualifyId);
|
|
192
172
|
}
|
|
193
173
|
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
content: parsed.content,
|
|
212
|
-
_file: 'notes/' + file,
|
|
213
|
-
...Object.fromEntries(
|
|
214
|
-
Object.entries(parsed.frontmatter).filter(([k]) => k !== 'title' && k !== 'created' && k !== 'updated')
|
|
215
|
-
),
|
|
174
|
+
} else {
|
|
175
|
+
// File entity: each matching file in the dir is an instance
|
|
176
|
+
for (const file of safeDirEntries(entityDir)) {
|
|
177
|
+
if (!matchesFilePattern(file, entityDef)) continue;
|
|
178
|
+
|
|
179
|
+
const content = safeReadFile(path.join(entityDir, file));
|
|
180
|
+
if (!content) continue;
|
|
181
|
+
|
|
182
|
+
const parsed = parseFrontmatter(content);
|
|
183
|
+
const relFile = buildRelFile(ancestors, def.dir, null, file);
|
|
184
|
+
|
|
185
|
+
const item = qualifyId(tag({
|
|
186
|
+
...parsed.frontmatter,
|
|
187
|
+
_contentLength: parsed.content ? parsed.content.length : 0,
|
|
188
|
+
_type: type,
|
|
189
|
+
_filename: file,
|
|
190
|
+
_file: relFile,
|
|
216
191
|
}));
|
|
192
|
+
|
|
193
|
+
for (const anc of ancestors) {
|
|
194
|
+
item['_' + anc.type] = anc.slug;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result.entities[type].push(item);
|
|
217
198
|
}
|
|
218
199
|
}
|
|
219
200
|
}
|
|
220
|
-
|
|
221
|
-
return result;
|
|
222
201
|
}
|
|
223
202
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
(!
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Scan a standalone entity directory (flat, not in hierarchy).
|
|
205
|
+
*/
|
|
206
|
+
function scanStandaloneEntity(sourcePath, type, def, result, cfg, tag, qualifyId) {
|
|
207
|
+
const entityDef = config.getEntity(cfg, type);
|
|
208
|
+
if (!entityDef) return;
|
|
209
|
+
|
|
210
|
+
const dir = path.join(sourcePath, def.dir);
|
|
211
|
+
if (!fs.existsSync(dir)) return;
|
|
212
|
+
|
|
213
|
+
if (isDirEntity(entityDef)) {
|
|
214
|
+
for (const slug of safeDirEntries(dir)) {
|
|
215
|
+
const instanceDir = path.join(dir, slug);
|
|
216
|
+
if (!fs.statSync(instanceDir).isDirectory()) continue;
|
|
217
|
+
|
|
218
|
+
const content = safeReadFile(path.join(instanceDir, 'README.md'));
|
|
219
|
+
if (!content) continue;
|
|
220
|
+
|
|
221
|
+
const parsed = parseFrontmatter(content);
|
|
222
|
+
result.entities[type].push(qualifyId(tag({
|
|
223
|
+
...parsed.frontmatter,
|
|
224
|
+
_contentLength: parsed.content ? parsed.content.length : 0,
|
|
225
|
+
_type: type,
|
|
226
|
+
_dir: slug,
|
|
227
|
+
_file: def.dir + '/' + slug + '/README.md',
|
|
228
|
+
})));
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
for (const file of safeDirEntries(dir)) {
|
|
232
|
+
if (!matchesFilePattern(file, entityDef)) continue;
|
|
233
|
+
|
|
234
|
+
const content = safeReadFile(path.join(dir, file));
|
|
235
|
+
if (!content) continue;
|
|
236
|
+
|
|
237
|
+
const parsed = parseFrontmatter(content);
|
|
238
|
+
const slug = file.replace(/\.md$/, '');
|
|
239
|
+
|
|
240
|
+
result.entities[type].push(qualifyId(tag({
|
|
241
|
+
id: parsed.frontmatter.id || slug,
|
|
242
|
+
title: parsed.frontmatter.title || slug,
|
|
243
|
+
...parsed.frontmatter,
|
|
244
|
+
_contentLength: parsed.content ? parsed.content.length : 0,
|
|
245
|
+
_type: type,
|
|
246
|
+
_filename: file,
|
|
247
|
+
_file: def.dir + '/' + file,
|
|
248
|
+
})));
|
|
249
|
+
}
|
|
246
250
|
}
|
|
247
251
|
}
|
|
248
252
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
for (const [k, v] of Object.entries(updates)) {
|
|
260
|
-
if (k === 'content') { body = v; continue; }
|
|
261
|
-
if (k.startsWith('_')) continue;
|
|
262
|
-
newFm[k] = v;
|
|
253
|
+
/**
|
|
254
|
+
* Build a relative file path from ancestors.
|
|
255
|
+
* Each ancestor has { type, slug, dir } where dir is the hierarchy directory name.
|
|
256
|
+
*/
|
|
257
|
+
function buildRelFile(ancestors, dir, slug, filename) {
|
|
258
|
+
const parts = [];
|
|
259
|
+
for (const anc of ancestors) {
|
|
260
|
+
parts.push(anc.dir); // hierarchy dir name (e.g. "cycles")
|
|
261
|
+
parts.push(anc.slug); // instance slug (e.g. "cycle-01")
|
|
263
262
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
parts.push(dir);
|
|
264
|
+
if (slug) parts.push(slug);
|
|
265
|
+
parts.push(filename);
|
|
266
|
+
return parts.join('/');
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// --- Progress ---
|
|
270
|
+
|
|
269
271
|
/**
|
|
270
|
-
*
|
|
272
|
+
* Compute progress for all entities that have children.
|
|
273
|
+
* Walks the hierarchy bottom-up: leaf entities contribute to parent progress.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} model - Scanned model
|
|
276
|
+
* @param {object} cfg - Config
|
|
271
277
|
*/
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
function computeProgress(model, cfg) {
|
|
279
|
+
const flat = config.flattenHierarchy(cfg);
|
|
280
|
+
const completedStatuses = (cfg.entities && cfg.entities.completedStatuses) || [];
|
|
281
|
+
|
|
282
|
+
// Find the leaf entity type (deepest in hierarchy, not standalone)
|
|
283
|
+
const hierarchyEntities = flat.filter(e => !e.standalone);
|
|
284
|
+
if (hierarchyEntities.length === 0) return;
|
|
285
|
+
|
|
286
|
+
const leafType = hierarchyEntities[hierarchyEntities.length - 1].type;
|
|
287
|
+
const leafItems = model.entities[leafType] || [];
|
|
288
|
+
|
|
289
|
+
// For each non-leaf hierarchy entity, compute progress from its leaf descendants
|
|
290
|
+
for (const entry of hierarchyEntities) {
|
|
291
|
+
if (entry.type === leafType) continue;
|
|
292
|
+
|
|
293
|
+
const items = model.entities[entry.type] || [];
|
|
294
|
+
for (const item of items) {
|
|
295
|
+
// Find leaf items that descend from this entity
|
|
296
|
+
const descendants = leafItems.filter(leaf => {
|
|
297
|
+
return leaf['_' + entry.type] === item._dir &&
|
|
298
|
+
(!item._source || leaf._source === item._source);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const total = descendants.length;
|
|
302
|
+
const done = descendants.filter(d => {
|
|
303
|
+
const status = d.status || '';
|
|
304
|
+
return completedStatuses.includes(status);
|
|
305
|
+
}).length;
|
|
306
|
+
|
|
307
|
+
item._childCount = total;
|
|
308
|
+
item._completedCount = done;
|
|
309
|
+
item._progress = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
310
|
+
item._totalPoints = descendants.reduce((sum, d) => sum + (d.points || 0), 0);
|
|
311
|
+
item._completedPoints = descendants
|
|
312
|
+
.filter(d => completedStatuses.includes(d.status || ''))
|
|
313
|
+
.reduce((sum, d) => sum + (d.points || 0), 0);
|
|
314
|
+
}
|
|
282
315
|
}
|
|
283
316
|
}
|
|
284
317
|
|
|
318
|
+
// --- CRUD ---
|
|
319
|
+
|
|
285
320
|
/**
|
|
286
321
|
* Get the next auto-incremented ID for an entity type.
|
|
287
322
|
*
|
|
288
|
-
* @param {object[]} items - Existing items
|
|
289
|
-
* @param {string} prefix - ID prefix (e.g. "TASK", "
|
|
290
|
-
* @returns {string}
|
|
323
|
+
* @param {object[]} items - Existing items
|
|
324
|
+
* @param {string} prefix - ID prefix (e.g. "TASK", "CYC", "BET")
|
|
325
|
+
* @returns {string}
|
|
291
326
|
*/
|
|
292
327
|
function getNextId(items, prefix) {
|
|
293
328
|
let max = 0;
|
|
@@ -304,308 +339,183 @@ function getNextId(items, prefix) {
|
|
|
304
339
|
}
|
|
305
340
|
|
|
306
341
|
/**
|
|
307
|
-
* Create a new
|
|
342
|
+
* Create a new entity.
|
|
343
|
+
* Generic function that replaces createMilestone, createEpic, createTask, createSprint.
|
|
308
344
|
*
|
|
309
345
|
* @param {string} sourcePath - Absolute path to project/ dir
|
|
310
|
-
* @param {object}
|
|
311
|
-
* @param {
|
|
312
|
-
* @param {object
|
|
313
|
-
* @
|
|
346
|
+
* @param {object} cfg - Loaded config
|
|
347
|
+
* @param {string} type - Entity type
|
|
348
|
+
* @param {object} data - Entity data (title + field values + parent slugs)
|
|
349
|
+
* @param {object[]} existingItems - Current items of this type for ID generation
|
|
350
|
+
* @returns {object} - { id, slug, file }
|
|
314
351
|
*/
|
|
315
|
-
function
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
352
|
+
function createEntity(sourcePath, cfg, type, data, existingItems) {
|
|
353
|
+
const entityDef = config.getEntity(cfg, type);
|
|
354
|
+
if (!entityDef) throw new Error('Unknown entity type: ' + type);
|
|
355
|
+
|
|
356
|
+
const prefix = entityDef.prefix;
|
|
357
|
+
const id = getNextId(existingItems, prefix);
|
|
358
|
+
|
|
359
|
+
// Build ancestor ids map from data
|
|
360
|
+
const ancestors = config.getAncestors(cfg, type);
|
|
361
|
+
const ids = {};
|
|
362
|
+
for (const anc of ancestors) {
|
|
363
|
+
if (!data[anc]) {
|
|
364
|
+
throw new Error(anc + ' is required to create a ' + type);
|
|
365
|
+
}
|
|
366
|
+
ids[anc] = data[anc];
|
|
326
367
|
}
|
|
327
368
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
369
|
+
// resolveEntityPath includes root (e.g. "project/cycles/..."), but sourcePath
|
|
370
|
+
// already points to project/, so strip the root prefix
|
|
371
|
+
const root = config.getRoot(cfg);
|
|
372
|
+
const relPath = config.resolveEntityPath(cfg, type, ids).replace(new RegExp('^' + root + '/'), '');
|
|
373
|
+
const dirPath = path.join(sourcePath, relPath);
|
|
332
374
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
};
|
|
338
|
-
if (data.priority) fm.priority = data.priority;
|
|
339
|
-
if (data.points != null) fm.points = data.points;
|
|
340
|
-
if (data.assigned) fm.assigned = data.assigned;
|
|
341
|
-
if (data.sprint) fm.sprint = data.sprint;
|
|
342
|
-
if (data.links) fm.links = data.links;
|
|
343
|
-
if (data.ai) fm.ai = data.ai;
|
|
344
|
-
fm.created = new Date().toISOString().split('T')[0];
|
|
375
|
+
if (isDirEntity(entityDef)) {
|
|
376
|
+
// Directory entity
|
|
377
|
+
const slug = slugify(data.title || id);
|
|
378
|
+
const instanceDir = path.join(dirPath, slug);
|
|
345
379
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
380
|
+
if (fs.existsSync(instanceDir)) {
|
|
381
|
+
throw new Error(entityDef.singular + ' directory already exists: ' + slug);
|
|
382
|
+
}
|
|
383
|
+
fs.mkdirSync(instanceDir, { recursive: true });
|
|
384
|
+
|
|
385
|
+
// Create child directories
|
|
386
|
+
const children = config.getChildren(cfg, type);
|
|
387
|
+
for (const childType of children) {
|
|
388
|
+
const childEntry = config.flattenHierarchy(cfg).find(e => e.type === childType);
|
|
389
|
+
if (childEntry) {
|
|
390
|
+
fs.mkdirSync(path.join(instanceDir, childEntry.dir), { recursive: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
350
393
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
394
|
+
const fm = buildFrontmatter(cfg, type, id, data);
|
|
395
|
+
const yaml = serializeYaml(fm);
|
|
396
|
+
const content = data.content || '';
|
|
397
|
+
fs.writeFileSync(
|
|
398
|
+
path.join(instanceDir, 'README.md'),
|
|
399
|
+
'---\n' + yaml + '\n---\n\n' + content + '\n',
|
|
400
|
+
'utf-8'
|
|
401
|
+
);
|
|
354
402
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
function createMilestone(sourcePath, config, data, existingMilestones) {
|
|
365
|
-
const id = getNextId(existingMilestones, 'MS');
|
|
366
|
-
const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
367
|
-
const msDir = config.entities.milestone.dir;
|
|
368
|
-
const dirPath = path.join(sourcePath, msDir, slug);
|
|
369
|
-
|
|
370
|
-
if (fs.existsSync(dirPath)) {
|
|
371
|
-
throw new Error('Milestone directory already exists: ' + slug);
|
|
372
|
-
}
|
|
373
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
374
|
-
|
|
375
|
-
const fm = {
|
|
376
|
-
id: id,
|
|
377
|
-
title: data.title || 'New Milestone',
|
|
378
|
-
status: data.status || 'planned',
|
|
379
|
-
};
|
|
380
|
-
if (data.deadline) fm.deadline = data.deadline;
|
|
381
|
-
if (data.tracks) fm.tracks = data.tracks;
|
|
382
|
-
if (data.ai) fm.ai = data.ai;
|
|
383
|
-
fm.created = new Date().toISOString().split('T')[0];
|
|
403
|
+
const relFile = relPath + '/' + slug + '/README.md';
|
|
404
|
+
return { id, slug, file: relFile };
|
|
405
|
+
|
|
406
|
+
} else {
|
|
407
|
+
// File entity
|
|
408
|
+
const filename = id + '.md';
|
|
409
|
+
if (!fs.existsSync(dirPath)) {
|
|
410
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
411
|
+
}
|
|
384
412
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
413
|
+
const fm = buildFrontmatter(cfg, type, id, data);
|
|
414
|
+
const yaml = serializeYaml(fm);
|
|
415
|
+
const content = data.content || '';
|
|
416
|
+
fs.writeFileSync(
|
|
417
|
+
path.join(dirPath, filename),
|
|
418
|
+
'---\n' + yaml + '\n---\n\n' + content + '\n',
|
|
419
|
+
'utf-8'
|
|
420
|
+
);
|
|
389
421
|
|
|
390
|
-
|
|
391
|
-
|
|
422
|
+
const relFile = relPath + '/' + filename;
|
|
423
|
+
return { id, slug: id, file: relFile };
|
|
424
|
+
}
|
|
392
425
|
}
|
|
393
426
|
|
|
394
427
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* @param {string} sourcePath - Absolute path to project/ dir
|
|
398
|
-
* @param {object} config - mdboard config
|
|
399
|
-
* @param {object} data - { title, status, priority, milestone, content }
|
|
400
|
-
* @param {object[]} existingEpics - Current epics for ID generation
|
|
401
|
-
* @returns {object} - { id, dir, file }
|
|
428
|
+
* Build frontmatter object from entity fields config and provided data.
|
|
402
429
|
*/
|
|
403
|
-
function
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const id = getNextId(existingEpics, 'EPIC');
|
|
407
|
-
const slug = (data.title || id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
430
|
+
function buildFrontmatter(cfg, type, id, data) {
|
|
431
|
+
const fields = config.getFields(cfg, type);
|
|
432
|
+
const fm = { id };
|
|
408
433
|
|
|
409
|
-
|
|
410
|
-
const epicDir = config.entities.epic.dir;
|
|
411
|
-
const taskDir = config.entities.task.dir;
|
|
434
|
+
if (data.title) fm.title = data.title;
|
|
412
435
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
436
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
437
|
+
if (data[name] !== undefined) {
|
|
438
|
+
fm[name] = data[name];
|
|
439
|
+
} else if (def.default !== undefined) {
|
|
440
|
+
fm[name] = def.default;
|
|
441
|
+
}
|
|
416
442
|
}
|
|
417
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
418
|
-
fs.mkdirSync(path.join(dirPath, taskDir), { recursive: true });
|
|
419
|
-
|
|
420
|
-
const fm = {
|
|
421
|
-
id: id,
|
|
422
|
-
title: data.title || 'New Epic',
|
|
423
|
-
status: data.status || 'active',
|
|
424
|
-
};
|
|
425
|
-
if (data.priority) fm.priority = data.priority;
|
|
426
|
-
if (data.dependencies) fm.dependencies = data.dependencies;
|
|
427
|
-
if (data.ai) fm.ai = data.ai;
|
|
428
|
-
fm.created = new Date().toISOString().split('T')[0];
|
|
429
443
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const filePath = path.join(dirPath, 'README.md');
|
|
433
|
-
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
434
|
-
|
|
435
|
-
const relFile = `${msDir}/${data.milestone}/${epicDir}/${slug}/README.md`;
|
|
436
|
-
return { id, dir: slug, file: relFile };
|
|
444
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
445
|
+
return fm;
|
|
437
446
|
}
|
|
438
447
|
|
|
439
448
|
/**
|
|
440
|
-
*
|
|
449
|
+
* Update an entity's markdown file.
|
|
441
450
|
*
|
|
442
451
|
* @param {string} sourcePath - Absolute path to project/ dir
|
|
443
|
-
* @param {
|
|
444
|
-
* @param {object}
|
|
445
|
-
* @param {object[]} existingSprints - Current sprints for ID generation
|
|
446
|
-
* @returns {object} - { id, dir, file }
|
|
452
|
+
* @param {string} relFile - Relative file path
|
|
453
|
+
* @param {object} updates - Fields to update
|
|
447
454
|
*/
|
|
448
|
-
function
|
|
449
|
-
|
|
455
|
+
function updateEntity(sourcePath, relFile, updates) {
|
|
456
|
+
const filePath = path.join(sourcePath, relFile);
|
|
457
|
+
if (!fs.existsSync(filePath)) throw new Error('File not found: ' + relFile);
|
|
450
458
|
|
|
451
|
-
const
|
|
452
|
-
const
|
|
459
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
460
|
+
const parsed = parseFrontmatter(raw);
|
|
453
461
|
|
|
454
|
-
const
|
|
455
|
-
|
|
462
|
+
const newFm = { ...parsed.frontmatter };
|
|
463
|
+
let body = parsed.content;
|
|
456
464
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
465
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
466
|
+
if (k === 'content') { body = v; continue; }
|
|
467
|
+
if (k.startsWith('_')) continue;
|
|
468
|
+
newFm[k] = v;
|
|
460
469
|
}
|
|
461
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
462
|
-
|
|
463
|
-
const fm = {
|
|
464
|
-
id: id,
|
|
465
|
-
status: data.status || 'planned',
|
|
466
|
-
};
|
|
467
|
-
if (data.goal) fm.goal = data.goal;
|
|
468
|
-
if (data.start_date) fm.start_date = data.start_date;
|
|
469
|
-
if (data.end_date) fm.end_date = data.end_date;
|
|
470
|
-
if (data.planned_points != null) fm.planned_points = data.planned_points;
|
|
471
|
-
if (data.features) fm.features = data.features;
|
|
472
|
-
fm.created = new Date().toISOString().split('T')[0];
|
|
473
|
-
|
|
474
|
-
const yaml = serializeYaml(fm);
|
|
475
|
-
const content = data.content || '';
|
|
476
|
-
const filePath = path.join(dirPath, 'plan.md');
|
|
477
|
-
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
478
470
|
|
|
479
|
-
const
|
|
480
|
-
|
|
471
|
+
const yaml = serializeYaml(newFm);
|
|
472
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
|
|
481
473
|
}
|
|
482
474
|
|
|
483
475
|
/**
|
|
484
|
-
* Delete an
|
|
485
|
-
* Git history preserves the file for recovery if needed.
|
|
476
|
+
* Delete (archive) an entity.
|
|
486
477
|
*
|
|
487
478
|
* @param {string} sourcePath - Absolute path to project/ dir
|
|
488
479
|
* @param {object} item - The item with _file property
|
|
489
480
|
* @returns {boolean}
|
|
490
481
|
*/
|
|
491
|
-
function
|
|
482
|
+
function deleteEntity(sourcePath, item) {
|
|
492
483
|
if (!item || !item._file) throw new Error('Item has no file path');
|
|
493
|
-
|
|
494
484
|
const srcPath = path.join(sourcePath, item._file);
|
|
495
|
-
if (!fs.existsSync(srcPath)) throw new Error('
|
|
496
|
-
|
|
485
|
+
if (!fs.existsSync(srcPath)) throw new Error('File not found: ' + item._file);
|
|
497
486
|
fs.unlinkSync(srcPath);
|
|
498
487
|
return true;
|
|
499
488
|
}
|
|
500
489
|
|
|
501
490
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* @param {string} sourcePath - Absolute path to project/ dir
|
|
505
|
-
* @param {object} data - { title, content }
|
|
506
|
-
* @param {object[]} existingNotes - Current notes to check slug uniqueness
|
|
507
|
-
* @returns {object} - { id, file }
|
|
491
|
+
* Merge results from multiple scanSource calls into a single model.
|
|
508
492
|
*/
|
|
509
|
-
function
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const existingIds = new Set(existingNotes.map(n => n.id || n._originalId));
|
|
516
|
-
let finalSlug = slug;
|
|
517
|
-
let counter = 1;
|
|
518
|
-
while (existingIds.has(finalSlug)) {
|
|
519
|
-
finalSlug = slug + '-' + counter;
|
|
520
|
-
counter++;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const dir = path.join(sourcePath, 'notes');
|
|
524
|
-
if (!fs.existsSync(dir)) {
|
|
525
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const today = new Date().toISOString().split('T')[0];
|
|
529
|
-
const fm = { title: title, created: today, updated: today };
|
|
530
|
-
const yaml = serializeYaml(fm);
|
|
531
|
-
const content = data.content || '';
|
|
532
|
-
const filePath = path.join(dir, finalSlug + '.md');
|
|
533
|
-
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + content + '\n', 'utf-8');
|
|
534
|
-
|
|
535
|
-
return { id: finalSlug, file: 'notes/' + finalSlug + '.md' };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// ── Agentic AI properties ──────────────────────────────────────────
|
|
539
|
-
|
|
540
|
-
const AI_FIELDS = ['skills', 'agents', 'mcps', 'commands', 'context'];
|
|
541
|
-
|
|
542
|
-
function extractAiProps(item) {
|
|
543
|
-
const result = {};
|
|
544
|
-
if (!item || !item.ai || typeof item.ai !== 'object') return result;
|
|
545
|
-
for (const field of AI_FIELDS) {
|
|
546
|
-
const val = item.ai[field];
|
|
547
|
-
if (val != null) {
|
|
548
|
-
result[field] = Array.isArray(val) ? val : [val];
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
return result;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function mergeAiProps(layers) {
|
|
555
|
-
const merged = {};
|
|
556
|
-
for (const layer of layers) {
|
|
557
|
-
for (const field of AI_FIELDS) {
|
|
558
|
-
const arr = layer[field];
|
|
559
|
-
if (!arr || arr.length === 0) continue;
|
|
560
|
-
if (!merged[field]) merged[field] = [];
|
|
561
|
-
for (const v of arr) {
|
|
562
|
-
if (merged[field].indexOf(v) === -1) merged[field].push(v);
|
|
493
|
+
function mergeResults(model, results) {
|
|
494
|
+
for (const r of results) {
|
|
495
|
+
if (r.project && !model.project) model.project = r.project;
|
|
496
|
+
for (const type of Object.keys(model.entities)) {
|
|
497
|
+
if (r.entities[type]) {
|
|
498
|
+
model.entities[type].push(...r.entities[type]);
|
|
563
499
|
}
|
|
564
500
|
}
|
|
565
501
|
}
|
|
566
|
-
return merged;
|
|
567
502
|
}
|
|
568
503
|
|
|
569
|
-
|
|
570
|
-
const projectAi = model.project ? extractAiProps(model.project) : {};
|
|
571
|
-
|
|
572
|
-
for (const ms of model.milestones) {
|
|
573
|
-
const msOwn = extractAiProps(ms);
|
|
574
|
-
const msResolved = mergeAiProps([projectAi, msOwn]);
|
|
575
|
-
ms._ai = Object.keys(msResolved).length > 0 ? msResolved : null;
|
|
576
|
-
ms._aiOwn = Object.keys(msOwn).length > 0 ? msOwn : null;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
for (const epic of model.epics) {
|
|
580
|
-
const epicOwn = extractAiProps(epic);
|
|
581
|
-
const parentMs = model.milestones.find(m =>
|
|
582
|
-
m._dir === epic._milestone && (!epic._source || m._source === epic._source)
|
|
583
|
-
);
|
|
584
|
-
const msAi = parentMs ? extractAiProps(parentMs) : {};
|
|
585
|
-
const epicResolved = mergeAiProps([projectAi, msAi, epicOwn]);
|
|
586
|
-
epic._ai = Object.keys(epicResolved).length > 0 ? epicResolved : null;
|
|
587
|
-
epic._aiOwn = Object.keys(epicOwn).length > 0 ? epicOwn : null;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
for (const task of model.tasks) {
|
|
591
|
-
const taskOwn = extractAiProps(task);
|
|
592
|
-
const parentEpic = model.epics.find(e =>
|
|
593
|
-
e._dir === task._epic && e._milestone === task._milestone &&
|
|
594
|
-
(!task._source || e._source === task._source)
|
|
595
|
-
);
|
|
596
|
-
const parentMs = model.milestones.find(m =>
|
|
597
|
-
m._dir === task._milestone && (!task._source || m._source === task._source)
|
|
598
|
-
);
|
|
599
|
-
const msAi = parentMs ? extractAiProps(parentMs) : {};
|
|
600
|
-
const epicAi = parentEpic ? extractAiProps(parentEpic) : {};
|
|
601
|
-
const taskResolved = mergeAiProps([projectAi, msAi, epicAi, taskOwn]);
|
|
602
|
-
task._ai = Object.keys(taskResolved).length > 0 ? taskResolved : null;
|
|
603
|
-
task._aiOwn = Object.keys(taskOwn).length > 0 ? taskOwn : null;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
504
|
+
// --- Export ---
|
|
606
505
|
|
|
607
506
|
module.exports = {
|
|
608
|
-
createModel,
|
|
609
|
-
|
|
610
|
-
|
|
507
|
+
createModel,
|
|
508
|
+
scanSource,
|
|
509
|
+
computeProgress,
|
|
510
|
+
mergeResults,
|
|
511
|
+
getNextId,
|
|
512
|
+
createEntity,
|
|
513
|
+
updateEntity,
|
|
514
|
+
deleteEntity,
|
|
515
|
+
// Utilities
|
|
516
|
+
safeReadFile,
|
|
517
|
+
safeDirEntries,
|
|
518
|
+
slugify,
|
|
519
|
+
matchesFilePattern,
|
|
520
|
+
isDirEntity,
|
|
611
521
|
};
|