@hanna84/mcp-writing 1.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/README.md +193 -0
- package/db.js +127 -0
- package/index.js +892 -0
- package/metadata-lint.js +332 -0
- package/package.json +42 -0
- package/scripts/import.js +360 -0
- package/scripts/lint-metadata.mjs +28 -0
- package/scripts/merge-scrivx.js +319 -0
- package/scripts/split-versions.js +26 -0
- package/sync.js +431 -0
package/sync.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
const { load: parseYaml, dump: stringifyYaml } = yaml;
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Pure utilities (no DB dependency — easy to unit test)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export function checksumProse(prose) {
|
|
12
|
+
let hash = 5381;
|
|
13
|
+
for (let i = 0; i < prose.length; i++) {
|
|
14
|
+
hash = ((hash << 5) + hash) ^ prose.charCodeAt(i);
|
|
15
|
+
hash = hash >>> 0;
|
|
16
|
+
}
|
|
17
|
+
return hash.toString(16);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function walkFiles(dir, fileList = []) {
|
|
21
|
+
if (!fs.existsSync(dir)) return fileList;
|
|
22
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
const full = path.join(dir, entry.name);
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
walkFiles(full, fileList);
|
|
26
|
+
} else if (entry.isSymbolicLink()) {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.statSync(full).isDirectory()) walkFiles(full, fileList);
|
|
29
|
+
else if (entry.name.endsWith(".md") || entry.name.endsWith(".txt")) fileList.push(full);
|
|
30
|
+
} catch { /* broken symlink — skip */ }
|
|
31
|
+
} else if (entry.name.endsWith(".md") || entry.name.endsWith(".txt")) {
|
|
32
|
+
fileList.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return fileList;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function walkSidecars(dir, fileList = []) {
|
|
39
|
+
if (!fs.existsSync(dir)) return fileList;
|
|
40
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
const full = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
walkSidecars(full, fileList);
|
|
44
|
+
} else if (entry.isSymbolicLink()) {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.statSync(full).isDirectory()) walkSidecars(full, fileList);
|
|
47
|
+
else if (entry.name.endsWith(".meta.yaml")) fileList.push(full);
|
|
48
|
+
} catch { /* broken symlink — skip */ }
|
|
49
|
+
} else if (entry.name.endsWith(".meta.yaml")) {
|
|
50
|
+
fileList.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return fileList;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sidecarPath(filePath) {
|
|
57
|
+
return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function inferScenePositionFromPath(syncDir, filePath) {
|
|
61
|
+
const rel = path.relative(syncDir, filePath);
|
|
62
|
+
const parts = rel.split(path.sep);
|
|
63
|
+
let part = null;
|
|
64
|
+
let chapter = null;
|
|
65
|
+
|
|
66
|
+
for (const segment of parts) {
|
|
67
|
+
const partMatch = segment.match(/^part-(\d+)$/i);
|
|
68
|
+
if (partMatch) part = parseInt(partMatch[1], 10);
|
|
69
|
+
|
|
70
|
+
const chapterMatch = segment.match(/^chapter-(\d+)$/i);
|
|
71
|
+
if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { part, chapter };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
|
|
78
|
+
const derived = inferScenePositionFromPath(syncDir, filePath);
|
|
79
|
+
const normalized = { ...meta };
|
|
80
|
+
|
|
81
|
+
if (derived.part !== null) normalized.part = derived.part;
|
|
82
|
+
if (derived.chapter !== null) normalized.chapter = derived.chapter;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
meta: normalized,
|
|
86
|
+
derived,
|
|
87
|
+
mismatches: {
|
|
88
|
+
part: derived.part !== null && meta.part != null && meta.part !== derived.part,
|
|
89
|
+
chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function inferProjectAndUniverse(syncDir, filePath) {
|
|
95
|
+
const rel = path.relative(syncDir, filePath);
|
|
96
|
+
const parts = rel.split(path.sep);
|
|
97
|
+
|
|
98
|
+
if (parts[0] === "universes" && parts.length >= 3) {
|
|
99
|
+
return { universe_id: parts[1], project_id: `${parts[1]}/${parts[2]}` };
|
|
100
|
+
}
|
|
101
|
+
if (parts[0] === "projects" && parts.length >= 2) {
|
|
102
|
+
return { universe_id: null, project_id: parts[1] };
|
|
103
|
+
}
|
|
104
|
+
return { universe_id: null, project_id: parts[0] ?? "default" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isWorldFile(syncDir, filePath) {
|
|
108
|
+
const rel = path.relative(syncDir, filePath);
|
|
109
|
+
return rel.includes(`${path.sep}world${path.sep}`) || rel.includes("/world/");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseFile(filePath) {
|
|
113
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
114
|
+
return matter(raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read metadata for a scene file. Priority: sidecar > frontmatter.
|
|
119
|
+
* If sidecar doesn't exist but frontmatter does, auto-generates the sidecar.
|
|
120
|
+
* Returns { meta, sidecarGenerated }.
|
|
121
|
+
*/
|
|
122
|
+
export function readMeta(filePath, syncDir, { writable = false } = {}) {
|
|
123
|
+
const sidecar = sidecarPath(filePath);
|
|
124
|
+
|
|
125
|
+
if (fs.existsSync(sidecar)) {
|
|
126
|
+
const raw = fs.readFileSync(sidecar, "utf8");
|
|
127
|
+
const parsed = parseYaml(raw) ?? {};
|
|
128
|
+
return { ...normalizeSceneMetaForPath(syncDir, filePath, parsed), sourceMeta: parsed, sidecarGenerated: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fall back to frontmatter
|
|
132
|
+
const { data: frontmatter } = parseFile(filePath);
|
|
133
|
+
if (!Object.keys(frontmatter).length) {
|
|
134
|
+
return { ...normalizeSceneMetaForPath(syncDir, filePath, {}), sourceMeta: {}, sidecarGenerated: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalized = normalizeSceneMetaForPath(syncDir, filePath, frontmatter);
|
|
138
|
+
|
|
139
|
+
// Auto-migrate: write sidecar from frontmatter (only if writable)
|
|
140
|
+
if (writable) {
|
|
141
|
+
try {
|
|
142
|
+
fs.writeFileSync(sidecar, stringifyYaml(normalized.meta), "utf8");
|
|
143
|
+
return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: true };
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Write metadata back to the sidecar file for a scene.
|
|
152
|
+
*/
|
|
153
|
+
export function writeMeta(filePath, meta) {
|
|
154
|
+
fs.writeFileSync(sidecarPath(filePath), stringifyYaml(meta), "utf8");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check whether the sync dir is writable.
|
|
159
|
+
*/
|
|
160
|
+
export function isSyncDirWritable(syncDir) {
|
|
161
|
+
try {
|
|
162
|
+
const probe = path.join(syncDir, ".mcp-write-check");
|
|
163
|
+
fs.writeFileSync(probe, "");
|
|
164
|
+
fs.unlinkSync(probe);
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// DB-dependent sync (takes db + syncDir as arguments for testability)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export function indexWorldFile(db, syncDir, file, meta) {
|
|
176
|
+
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
177
|
+
const rel = path.relative(syncDir, file);
|
|
178
|
+
|
|
179
|
+
if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) {
|
|
180
|
+
if (!meta.character_id) return;
|
|
181
|
+
db.prepare(`
|
|
182
|
+
INSERT INTO characters (character_id, project_id, universe_id, name, role, arc_summary, first_appearance, file_path)
|
|
183
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
|
+
ON CONFLICT (character_id) DO UPDATE SET
|
|
185
|
+
name = excluded.name, role = excluded.role, arc_summary = excluded.arc_summary,
|
|
186
|
+
first_appearance = excluded.first_appearance, file_path = excluded.file_path
|
|
187
|
+
`).run(
|
|
188
|
+
meta.character_id, project_id ?? null, universe_id ?? null,
|
|
189
|
+
meta.name ?? meta.character_id, meta.role ?? null, meta.arc_summary ?? null,
|
|
190
|
+
meta.first_appearance ?? null, file
|
|
191
|
+
);
|
|
192
|
+
db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(meta.character_id);
|
|
193
|
+
for (const t of (meta.traits ?? [])) {
|
|
194
|
+
db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(
|
|
195
|
+
meta.character_id, t
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
} else if (rel.includes(`${path.sep}places${path.sep}`) || rel.includes("/places/")) {
|
|
199
|
+
if (!meta.place_id) return;
|
|
200
|
+
db.prepare(`
|
|
201
|
+
INSERT INTO places (place_id, project_id, universe_id, name, file_path)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?)
|
|
203
|
+
ON CONFLICT (place_id) DO UPDATE SET name = excluded.name, file_path = excluded.file_path
|
|
204
|
+
`).run(
|
|
205
|
+
meta.place_id, project_id ?? null, universe_id ?? null,
|
|
206
|
+
meta.name ?? meta.place_id, file
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
212
|
+
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
213
|
+
|
|
214
|
+
if (universe_id) {
|
|
215
|
+
db.prepare(`INSERT OR IGNORE INTO universes (universe_id, name) VALUES (?, ?)`).run(
|
|
216
|
+
universe_id, universe_id
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
db.prepare(`INSERT OR IGNORE INTO projects (project_id, universe_id, name) VALUES (?, ?, ?)`).run(
|
|
220
|
+
project_id, universe_id ?? null, project_id
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const newChecksum = checksumProse(prose);
|
|
224
|
+
const existing = db.prepare(
|
|
225
|
+
`SELECT prose_checksum FROM scenes WHERE scene_id = ? AND project_id = ?`
|
|
226
|
+
).get(meta.scene_id, project_id);
|
|
227
|
+
|
|
228
|
+
const isStale = existing && existing.prose_checksum !== newChecksum ? 1 : 0;
|
|
229
|
+
|
|
230
|
+
db.prepare(`
|
|
231
|
+
INSERT INTO scenes (
|
|
232
|
+
scene_id, project_id, title, part, chapter, pov, logline, scene_change,
|
|
233
|
+
causality, stakes, scene_functions,
|
|
234
|
+
save_the_cat_beat, timeline_position, story_time, word_count,
|
|
235
|
+
file_path, prose_checksum, metadata_stale, updated_at
|
|
236
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
|
+
ON CONFLICT (scene_id, project_id) DO UPDATE SET
|
|
238
|
+
title = excluded.title,
|
|
239
|
+
part = excluded.part,
|
|
240
|
+
chapter = excluded.chapter,
|
|
241
|
+
pov = excluded.pov,
|
|
242
|
+
logline = excluded.logline,
|
|
243
|
+
scene_change = excluded.scene_change,
|
|
244
|
+
causality = excluded.causality,
|
|
245
|
+
stakes = excluded.stakes,
|
|
246
|
+
scene_functions = excluded.scene_functions,
|
|
247
|
+
save_the_cat_beat = excluded.save_the_cat_beat,
|
|
248
|
+
timeline_position = excluded.timeline_position,
|
|
249
|
+
story_time = excluded.story_time,
|
|
250
|
+
word_count = excluded.word_count,
|
|
251
|
+
file_path = excluded.file_path,
|
|
252
|
+
prose_checksum = excluded.prose_checksum,
|
|
253
|
+
metadata_stale = CASE WHEN excluded.prose_checksum != scenes.prose_checksum THEN 1 ELSE scenes.metadata_stale END,
|
|
254
|
+
updated_at = excluded.updated_at
|
|
255
|
+
`).run(
|
|
256
|
+
meta.scene_id, project_id,
|
|
257
|
+
meta.title ?? null, meta.part ?? null, meta.chapter ?? null,
|
|
258
|
+
meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
|
|
259
|
+
meta.scene_change ?? meta.change ?? null,
|
|
260
|
+
meta.causality ?? null, meta.stakes ?? null,
|
|
261
|
+
meta.scene_functions?.length ? JSON.stringify(meta.scene_functions) : null,
|
|
262
|
+
meta.save_the_cat_beat ?? meta.save_the_cat ?? null,
|
|
263
|
+
meta.timeline_position ?? null, meta.story_time ?? null,
|
|
264
|
+
meta.word_count ?? prose.split(/\s+/).filter(Boolean).length,
|
|
265
|
+
file, newChecksum, isStale,
|
|
266
|
+
new Date().toISOString()
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
db.prepare(`DELETE FROM scene_characters WHERE scene_id = ?`).run(meta.scene_id);
|
|
270
|
+
db.prepare(`DELETE FROM scene_places WHERE scene_id = ?`).run(meta.scene_id);
|
|
271
|
+
db.prepare(`DELETE FROM scene_tags WHERE scene_id = ?`).run(meta.scene_id);
|
|
272
|
+
|
|
273
|
+
for (const c of (meta.characters ?? [])) {
|
|
274
|
+
// Version continuity markers (e.g. v7.3, v3.3b) are tracked as tags, not characters
|
|
275
|
+
if (/^v\d[\d.a-z]*$/i.test(c)) {
|
|
276
|
+
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(meta.scene_id, c);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let cid = c;
|
|
280
|
+
// If the value looks like a name rather than an ID, try to resolve it
|
|
281
|
+
if (!/^char-/.test(c)) {
|
|
282
|
+
// 1. Exact name match (case-insensitive)
|
|
283
|
+
let row = db.prepare(`SELECT character_id FROM characters WHERE lower(name) = lower(?)`).get(c);
|
|
284
|
+
// 2. Word-overlap: all words in the keyword appear in the stored name
|
|
285
|
+
// Handles "Victor Sidorin" → "Victor Alexeyvich Sidorin"
|
|
286
|
+
if (!row) {
|
|
287
|
+
const words = c.toLowerCase().split(/\s+/).filter(Boolean);
|
|
288
|
+
const all = db.prepare(`SELECT character_id, name FROM characters`).all();
|
|
289
|
+
const match = all.find(r =>
|
|
290
|
+
words.every(w => r.name.toLowerCase().includes(w))
|
|
291
|
+
);
|
|
292
|
+
if (match) row = match;
|
|
293
|
+
}
|
|
294
|
+
if (row) cid = row.character_id;
|
|
295
|
+
}
|
|
296
|
+
db.prepare(`INSERT OR IGNORE INTO scene_characters (scene_id, character_id) VALUES (?, ?)`).run(
|
|
297
|
+
meta.scene_id, cid
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
for (const p of (meta.places ?? [])) {
|
|
301
|
+
db.prepare(`INSERT OR IGNORE INTO scene_places (scene_id, place_id) VALUES (?, ?)`).run(
|
|
302
|
+
meta.scene_id, p
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
for (const t of (meta.tags ?? [])) {
|
|
306
|
+
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(
|
|
307
|
+
meta.scene_id, t
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
for (const v of (meta.versions ?? [])) {
|
|
311
|
+
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, tag) VALUES (?, ?)`).run(
|
|
312
|
+
meta.scene_id, v
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
db.prepare(`INSERT OR REPLACE INTO scenes_fts (scene_id, project_id, logline, title) VALUES (?, ?, ?, ?)`).run(
|
|
317
|
+
meta.scene_id, project_id, meta.logline ?? meta.synopsis ?? "", meta.title ?? ""
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return { isStale };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
324
|
+
const files = walkFiles(syncDir);
|
|
325
|
+
let indexed = 0;
|
|
326
|
+
let staleMarked = 0;
|
|
327
|
+
let skipped = 0;
|
|
328
|
+
let sidecarsMigrated = 0;
|
|
329
|
+
const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
|
|
330
|
+
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
331
|
+
const warnings = [];
|
|
332
|
+
|
|
333
|
+
// --- Pass 1: world files (characters/places must be indexed before scenes
|
|
334
|
+
// so that character name → ID resolution in scene_characters works) ---
|
|
335
|
+
for (const file of files) {
|
|
336
|
+
if (!isWorldFile(syncDir, file)) continue;
|
|
337
|
+
try {
|
|
338
|
+
const { meta } = readMeta(file, syncDir, { writable });
|
|
339
|
+
if (!Object.keys(meta).length) {
|
|
340
|
+
const { data } = parseFile(file);
|
|
341
|
+
indexWorldFile(db, syncDir, file, data);
|
|
342
|
+
} else {
|
|
343
|
+
indexWorldFile(db, syncDir, file, meta);
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Pass 2: scene files ---
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
if (isWorldFile(syncDir, file)) continue;
|
|
353
|
+
try {
|
|
354
|
+
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
|
|
355
|
+
if (sidecarGenerated) sidecarsMigrated++;
|
|
356
|
+
|
|
357
|
+
if (!meta.scene_id) {
|
|
358
|
+
skipped++;
|
|
359
|
+
if (!quiet) warnings.push(`Skipped (no scene_id): ${path.relative(syncDir, file)}`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Duplicate scene_id detection
|
|
364
|
+
const { project_id } = inferProjectAndUniverse(syncDir, file);
|
|
365
|
+
const key = `${meta.scene_id}::${project_id}`;
|
|
366
|
+
if (seenSceneIds.has(key)) {
|
|
367
|
+
warnings.push(
|
|
368
|
+
`Duplicate scene_id "${meta.scene_id}" in project "${project_id}":\n` +
|
|
369
|
+
` ${path.relative(syncDir, seenSceneIds.get(key))}\n` +
|
|
370
|
+
` ${path.relative(syncDir, file)}`
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
seenSceneIds.set(key, file);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (mismatches.part || mismatches.chapter) {
|
|
377
|
+
const details = [];
|
|
378
|
+
if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
|
|
379
|
+
if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
|
|
380
|
+
warnings.push(
|
|
381
|
+
`Path/metadata mismatch for scene "${meta.scene_id}": ${path.relative(syncDir, file)} (${details.join(", ")}). Using path-derived values.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { data: _frontmatter, content: prose } = parseFile(file);
|
|
386
|
+
const { isStale } = indexSceneFile(db, syncDir, file, meta, prose);
|
|
387
|
+
indexedSceneIds.add(meta.scene_id);
|
|
388
|
+
if (isStale) staleMarked++;
|
|
389
|
+
indexed++;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- Orphaned sidecar detection ---
|
|
396
|
+
const sidecars = walkSidecars(syncDir);
|
|
397
|
+
for (const sidecar of sidecars) {
|
|
398
|
+
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
399
|
+
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|
|
400
|
+
if (!fs.existsSync(prose) && !fs.existsSync(proseTxt)) {
|
|
401
|
+
let orphanedSceneId = null;
|
|
402
|
+
try {
|
|
403
|
+
const raw = fs.readFileSync(sidecar, "utf8");
|
|
404
|
+
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
405
|
+
} catch {}
|
|
406
|
+
|
|
407
|
+
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
408
|
+
warnings.push(
|
|
409
|
+
`Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${path.relative(syncDir, sidecar)} — prose file has moved. Consider relocating the sidecar alongside the prose file.`
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
|
|
413
|
+
warnings.push(
|
|
414
|
+
`Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${path.relative(syncDir, sidecar)}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!quiet) {
|
|
421
|
+
process.stderr.write(
|
|
422
|
+
`[mcp-writing] Sync complete: ${indexed} scenes indexed, ${staleMarked} marked stale` +
|
|
423
|
+
(sidecarsMigrated ? `, ${sidecarsMigrated} sidecars auto-generated` : "") +
|
|
424
|
+
(skipped ? `, ${skipped} files skipped` : "") + "\n"
|
|
425
|
+
);
|
|
426
|
+
for (const w of warnings) {
|
|
427
|
+
process.stderr.write(`[mcp-writing] WARNING: ${w}\n`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { indexed, staleMarked, skipped, sidecarsMigrated, warnings };
|
|
431
|
+
}
|