mdboard 1.2.0 → 2.0.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 +130 -44
- package/index.html +3321 -1195
- package/package.json +10 -11
- 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 +338 -0
- package/src/cli/config.js +234 -0
- package/src/cli/init.js +175 -0
- package/src/cli/preset.js +849 -0
- package/src/cli/skill.js +417 -0
- package/src/cli/status.js +180 -0
- package/src/core/config.js +551 -0
- package/src/core/history.js +146 -0
- package/src/core/scanner.js +521 -0
- package/{workspace.js → src/core/workspace.js} +0 -15
- package/{yaml.js → src/core/yaml.js} +5 -1
- package/src/server/api.js +616 -0
- package/{server.js → src/server/server.js} +180 -132
- package/{watcher.js → src/server/watcher.js} +40 -9
- package/api.js +0 -752
- package/config.js +0 -73
- package/defaults.json +0 -43
- package/init.js +0 -109
- package/scanner.js +0 -491
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdboard — Dynamic file scanner and in-memory model
|
|
3
|
+
*
|
|
4
|
+
* Scans project directories based on config-driven hierarchy.
|
|
5
|
+
* No hardcoded entity types — everything comes from entities.json + structure.json.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { parseFrontmatter, serializeYaml } = require('./yaml');
|
|
13
|
+
const config = require('./config');
|
|
14
|
+
|
|
15
|
+
// --- Utilities ---
|
|
16
|
+
|
|
17
|
+
function safeReadFile(filePath) {
|
|
18
|
+
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeDirEntries(dir) {
|
|
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 + '-');
|
|
40
|
+
}
|
|
41
|
+
return filename === pattern;
|
|
42
|
+
}
|
|
43
|
+
|
|
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] = [];
|
|
62
|
+
}
|
|
63
|
+
return model;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Scanner ---
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scan a single source directory and return all entities found.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} sourcePath - Absolute path to the project/ directory
|
|
72
|
+
* @param {object} cfg - Loaded config
|
|
73
|
+
* @param {object} [sourceMeta] - { name, label, color, type, readonly }
|
|
74
|
+
* @returns {object} - { project, entities: { cycle: [...], bet: [...], ... } }
|
|
75
|
+
*/
|
|
76
|
+
function scanSource(sourcePath, cfg, sourceMeta) {
|
|
77
|
+
const result = createModel(cfg);
|
|
78
|
+
if (!fs.existsSync(sourcePath)) return result;
|
|
79
|
+
|
|
80
|
+
const meta = sourceMeta || {};
|
|
81
|
+
const sourcePrefix = meta.name ? meta.name + ':' : '';
|
|
82
|
+
|
|
83
|
+
function tag(item) {
|
|
84
|
+
if (meta.name) {
|
|
85
|
+
item._source = meta.name;
|
|
86
|
+
item._sourceLabel = meta.label || meta.name;
|
|
87
|
+
item._sourceColor = meta.color || null;
|
|
88
|
+
item._sourceType = meta.type || 'local';
|
|
89
|
+
item._readonly = meta.readonly || false;
|
|
90
|
+
}
|
|
91
|
+
return item;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function qualifyId(item) {
|
|
95
|
+
if (sourcePrefix && item.id) {
|
|
96
|
+
item._originalId = item.id;
|
|
97
|
+
item.id = sourcePrefix + item.id;
|
|
98
|
+
}
|
|
99
|
+
return item;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Scan PROJECT.md
|
|
103
|
+
const projectMd = safeReadFile(path.join(sourcePath, 'PROJECT.md'));
|
|
104
|
+
if (projectMd) {
|
|
105
|
+
const parsed = parseFrontmatter(projectMd);
|
|
106
|
+
result.project = tag({ ...parsed.frontmatter, content: parsed.content, _file: 'PROJECT.md' });
|
|
107
|
+
}
|
|
108
|
+
|
|
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
|
+
}));
|
|
160
|
+
|
|
161
|
+
// Store ancestor references
|
|
162
|
+
for (const anc of ancestors) {
|
|
163
|
+
item['_' + anc.type] = anc.slug;
|
|
164
|
+
}
|
|
165
|
+
|
|
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);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
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,
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
for (const anc of ancestors) {
|
|
194
|
+
item['_' + anc.type] = anc.slug;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result.entities[type].push(item);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
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
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
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")
|
|
262
|
+
}
|
|
263
|
+
parts.push(dir);
|
|
264
|
+
if (slug) parts.push(slug);
|
|
265
|
+
parts.push(filename);
|
|
266
|
+
return parts.join('/');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Progress ---
|
|
270
|
+
|
|
271
|
+
/**
|
|
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
|
|
277
|
+
*/
|
|
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
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- CRUD ---
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Get the next auto-incremented ID for an entity type.
|
|
322
|
+
*
|
|
323
|
+
* @param {object[]} items - Existing items
|
|
324
|
+
* @param {string} prefix - ID prefix (e.g. "TASK", "CYC", "BET")
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
function getNextId(items, prefix) {
|
|
328
|
+
let max = 0;
|
|
329
|
+
const re = new RegExp('^(?:[\\w]+:)?' + prefix + '-(\\d+)$');
|
|
330
|
+
for (const item of items) {
|
|
331
|
+
const id = item._originalId || item.id || '';
|
|
332
|
+
const m = id.match(re);
|
|
333
|
+
if (m) {
|
|
334
|
+
const n = parseInt(m[1], 10);
|
|
335
|
+
if (n > max) max = n;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return prefix + '-' + String(max + 1).padStart(3, '0');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create a new entity.
|
|
343
|
+
* Generic function that replaces createMilestone, createEpic, createTask, createSprint.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
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 }
|
|
351
|
+
*/
|
|
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];
|
|
367
|
+
}
|
|
368
|
+
|
|
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);
|
|
374
|
+
|
|
375
|
+
if (isDirEntity(entityDef)) {
|
|
376
|
+
// Directory entity
|
|
377
|
+
const slug = slugify(data.title || id);
|
|
378
|
+
const instanceDir = path.join(dirPath, slug);
|
|
379
|
+
|
|
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
|
+
}
|
|
393
|
+
|
|
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
|
+
);
|
|
402
|
+
|
|
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
|
+
}
|
|
412
|
+
|
|
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
|
+
);
|
|
421
|
+
|
|
422
|
+
const relFile = relPath + '/' + filename;
|
|
423
|
+
return { id, slug: id, file: relFile };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Build frontmatter object from entity fields config and provided data.
|
|
429
|
+
*/
|
|
430
|
+
function buildFrontmatter(cfg, type, id, data) {
|
|
431
|
+
const fields = config.getFields(cfg, type);
|
|
432
|
+
const fm = { id };
|
|
433
|
+
|
|
434
|
+
if (data.title) fm.title = data.title;
|
|
435
|
+
|
|
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
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
fm.created = new Date().toISOString().split('T')[0];
|
|
445
|
+
return fm;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Update an entity's markdown file.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
452
|
+
* @param {string} relFile - Relative file path
|
|
453
|
+
* @param {object} updates - Fields to update
|
|
454
|
+
*/
|
|
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);
|
|
458
|
+
|
|
459
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
460
|
+
const parsed = parseFrontmatter(raw);
|
|
461
|
+
|
|
462
|
+
const newFm = { ...parsed.frontmatter };
|
|
463
|
+
let body = parsed.content;
|
|
464
|
+
|
|
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;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const yaml = serializeYaml(newFm);
|
|
472
|
+
fs.writeFileSync(filePath, '---\n' + yaml + '\n---\n\n' + (body || '') + '\n', 'utf-8');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Delete (archive) an entity.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} sourcePath - Absolute path to project/ dir
|
|
479
|
+
* @param {object} item - The item with _file property
|
|
480
|
+
* @returns {boolean}
|
|
481
|
+
*/
|
|
482
|
+
function deleteEntity(sourcePath, item) {
|
|
483
|
+
if (!item || !item._file) throw new Error('Item has no file path');
|
|
484
|
+
const srcPath = path.join(sourcePath, item._file);
|
|
485
|
+
if (!fs.existsSync(srcPath)) throw new Error('File not found: ' + item._file);
|
|
486
|
+
fs.unlinkSync(srcPath);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Merge results from multiple scanSource calls into a single model.
|
|
492
|
+
*/
|
|
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]);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- Export ---
|
|
505
|
+
|
|
506
|
+
module.exports = {
|
|
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,
|
|
521
|
+
};
|
|
@@ -78,21 +78,6 @@ function loadWorkspace(projectDir, explicitPath) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Also resolve the overview (workspace root's project/ dir)
|
|
82
|
-
const overviewPath = path.join(projectDir, 'project');
|
|
83
|
-
if (fs.existsSync(overviewPath)) {
|
|
84
|
-
ws.sources.unshift({
|
|
85
|
-
name: 'overview',
|
|
86
|
-
label: 'Overview',
|
|
87
|
-
icon: '\u2605',
|
|
88
|
-
color: '#5B6EF5',
|
|
89
|
-
type: 'local',
|
|
90
|
-
readonly: false,
|
|
91
|
-
_resolvedPath: overviewPath,
|
|
92
|
-
_repoRoot: projectDir,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
81
|
ws._path = wsPath;
|
|
97
82
|
ws.settings = ws.settings || {};
|
|
98
83
|
|
|
@@ -117,7 +117,11 @@ function serializeYaml(obj) {
|
|
|
117
117
|
} else if (typeof value === 'object') {
|
|
118
118
|
lines.push(key + ':');
|
|
119
119
|
for (const [k, v] of Object.entries(value)) {
|
|
120
|
-
|
|
120
|
+
if (Array.isArray(v)) {
|
|
121
|
+
lines.push(' ' + k + ': ' + (v.length === 0 ? '[]' : '[' + v.map(sv => serializeValue(sv)).join(', ') + ']'));
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(' ' + k + ': ' + serializeValue(v));
|
|
124
|
+
}
|
|
121
125
|
}
|
|
122
126
|
} else {
|
|
123
127
|
lines.push(key + ': ' + serializeValue(value));
|