@hanna84/mcp-writing 3.9.4 → 3.9.5
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/sync/sync.js +259 -96
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
|
|
8
|
+
|
|
9
|
+
- refactor(sync): complete target architecture M3 [`#201`](https://github.com/hannasdev/mcp-writing/pull/201)
|
|
10
|
+
|
|
7
11
|
#### [v3.9.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
|
|
8
12
|
|
|
13
|
+
> 17 May 2026
|
|
14
|
+
|
|
9
15
|
- refactor(structure): centralize scene structure patches [`#200`](https://github.com/hannasdev/mcp-writing/pull/200)
|
|
16
|
+
- Release 3.9.4 [`ac77e80`](https://github.com/hannasdev/mcp-writing/commit/ac77e801d94ca576923a46a6d22c8f3230988d76)
|
|
10
17
|
|
|
11
18
|
#### [v3.9.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
|
|
12
19
|
|
package/package.json
CHANGED
package/src/sync/sync.js
CHANGED
|
@@ -74,11 +74,34 @@ export function walkSidecars(dir, fileList = []) {
|
|
|
74
74
|
return fileList;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function buildSyncDiagnostic(message, { type = null, ...details } = {}) {
|
|
78
|
+
return { type, message, ...details };
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
function isNestedMirrorPath(syncDir, filePath) {
|
|
78
82
|
const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
|
|
79
83
|
return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
export function scanSyncFiles(syncDir) {
|
|
87
|
+
const files = [];
|
|
88
|
+
const diagnostics = [];
|
|
89
|
+
|
|
90
|
+
for (const file of walkFiles(syncDir)) {
|
|
91
|
+
if (isNestedMirrorPath(syncDir, file)) {
|
|
92
|
+
const relativePath = path.relative(syncDir, file);
|
|
93
|
+
diagnostics.push(buildSyncDiagnostic(`Ignored nested mirror path: ${relativePath}`, {
|
|
94
|
+
type: "nested_mirror",
|
|
95
|
+
relativePath,
|
|
96
|
+
}));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
files.push(file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { files, diagnostics };
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
export function sidecarPath(filePath) {
|
|
83
106
|
return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
|
|
84
107
|
}
|
|
@@ -813,8 +836,14 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
|
|
|
813
836
|
return docId;
|
|
814
837
|
}
|
|
815
838
|
|
|
816
|
-
function pruneMissingReferenceDocs(db, seenDocIds) {
|
|
817
|
-
const
|
|
839
|
+
function pruneMissingReferenceDocs(db, seenDocIds, syncDir) {
|
|
840
|
+
const scope = inferReferenceScopeFromSyncDir(syncDir);
|
|
841
|
+
const rows = scope?.project_id
|
|
842
|
+
? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE project_id = ?`).all(scope.project_id)
|
|
843
|
+
: scope?.universe_id
|
|
844
|
+
? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE universe_id = ?`).all(scope.universe_id)
|
|
845
|
+
: db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
|
|
846
|
+
|
|
818
847
|
for (const row of rows) {
|
|
819
848
|
if (seenDocIds.has(row.doc_id)) continue;
|
|
820
849
|
db.prepare(`
|
|
@@ -946,9 +975,157 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
|
|
|
946
975
|
}
|
|
947
976
|
}
|
|
948
977
|
|
|
949
|
-
export function
|
|
978
|
+
export function pruneSyncDerivedIndexes(db, syncDir, {
|
|
979
|
+
seenSceneKeys,
|
|
980
|
+
seenEpigraphKeys,
|
|
981
|
+
seenChapterKeys,
|
|
982
|
+
sceneIndexFailures,
|
|
983
|
+
}) {
|
|
984
|
+
if (!canPruneScenes(syncDir)) return { pruned: false, reason: "scope_not_prunable" };
|
|
985
|
+
if (sceneIndexFailures !== 0) return { pruned: false, reason: "scene_index_failures" };
|
|
986
|
+
|
|
987
|
+
pruneMissingScenes(db, seenSceneKeys, syncDir);
|
|
988
|
+
pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
|
|
989
|
+
pruneMissingChapters(db, seenChapterKeys, syncDir);
|
|
990
|
+
return { pruned: true, reason: null };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export function regenerateReferenceAndWorldIndexes(db, syncDir, files, {
|
|
994
|
+
writable = false,
|
|
995
|
+
pruneReferenceDocs = false,
|
|
996
|
+
} = {}) {
|
|
997
|
+
const indexedReferenceDocIds = new Set();
|
|
998
|
+
|
|
999
|
+
for (const file of files) {
|
|
1000
|
+
if (isReferenceFile(syncDir, file)) {
|
|
1001
|
+
try {
|
|
1002
|
+
const { data, content } = parseFile(file);
|
|
1003
|
+
const docId = indexReferenceFile(db, syncDir, file, data, content);
|
|
1004
|
+
indexedReferenceDocIds.add(docId);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1007
|
+
}
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (!isWorldFile(syncDir, file)) continue;
|
|
1012
|
+
try {
|
|
1013
|
+
const { meta } = readMeta(file, syncDir, { writable });
|
|
1014
|
+
if (!Object.keys(meta).length) {
|
|
1015
|
+
const { data } = parseFile(file);
|
|
1016
|
+
indexWorldFile(db, syncDir, file, data);
|
|
1017
|
+
} else {
|
|
1018
|
+
indexWorldFile(db, syncDir, file, meta);
|
|
1019
|
+
}
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (pruneReferenceDocs && canPruneReferenceDocs(syncDir)) {
|
|
1026
|
+
pruneMissingReferenceDocs(db, indexedReferenceDocIds, syncDir);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
indexedReferenceDocIds,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export function buildStructureDiagnostic(message, { type = "chapter_structure", ...details } = {}) {
|
|
1035
|
+
return { type, message, ...details };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function observeStructureForFile(syncDir, file, {
|
|
1039
|
+
meta = {},
|
|
1040
|
+
sourceMeta = {},
|
|
1041
|
+
derived = {},
|
|
1042
|
+
mismatches = {},
|
|
1043
|
+
} = {}) {
|
|
950
1044
|
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
1045
|
+
const relativePath = path.relative(syncDir, file);
|
|
951
1046
|
const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
|
|
1047
|
+
const diagnostics = [];
|
|
1048
|
+
|
|
1049
|
+
if (mismatches.part || mismatches.chapter) {
|
|
1050
|
+
const details = [];
|
|
1051
|
+
if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
|
|
1052
|
+
if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
|
|
1053
|
+
diagnostics.push(buildStructureDiagnostic(
|
|
1054
|
+
`Path/metadata mismatch for scene "${meta.scene_id}": ${relativePath} (${details.join(", ")}). Using path-derived values.`,
|
|
1055
|
+
{
|
|
1056
|
+
type: "path_metadata_mismatch",
|
|
1057
|
+
sceneId: meta.scene_id ?? null,
|
|
1058
|
+
relativePath,
|
|
1059
|
+
}
|
|
1060
|
+
));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return {
|
|
1064
|
+
universeId: universe_id,
|
|
1065
|
+
projectId: project_id,
|
|
1066
|
+
relativePath,
|
|
1067
|
+
chapterStructure,
|
|
1068
|
+
observedChapter: chapterStructure.chapter
|
|
1069
|
+
? {
|
|
1070
|
+
chapterId: chapterStructure.chapter.chapter_id,
|
|
1071
|
+
sortIndex: chapterStructure.chapter.sort_index,
|
|
1072
|
+
title: chapterStructure.chapter.title,
|
|
1073
|
+
folderKey: chapterStructure.chapter.folder_key,
|
|
1074
|
+
sourceKind: chapterStructure.chapter.source_kind,
|
|
1075
|
+
}
|
|
1076
|
+
: null,
|
|
1077
|
+
observedEpigraph: chapterStructure.isEpigraph
|
|
1078
|
+
? {
|
|
1079
|
+
epigraphId: meta.epigraph_id ?? null,
|
|
1080
|
+
chapterId: meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null,
|
|
1081
|
+
relativePath,
|
|
1082
|
+
}
|
|
1083
|
+
: null,
|
|
1084
|
+
diagnostics,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export function buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure = observeStructureForFile(syncDir, file, { meta })) {
|
|
1089
|
+
const chapterResolution = resolveIndexedChapterForFile(db, {
|
|
1090
|
+
syncDir,
|
|
1091
|
+
projectId: observedStructure.projectId,
|
|
1092
|
+
filePath: file,
|
|
1093
|
+
relativePath: observedStructure.relativePath,
|
|
1094
|
+
meta,
|
|
1095
|
+
chapterStructure: observedStructure.chapterStructure,
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
universeId: observedStructure.universeId,
|
|
1100
|
+
projectId: observedStructure.projectId,
|
|
1101
|
+
observedStructure,
|
|
1102
|
+
chapterResolution,
|
|
1103
|
+
canonicalChapter: chapterResolution.upsertChapter,
|
|
1104
|
+
diagnostics: chapterResolution.chapterWarning
|
|
1105
|
+
? [buildStructureDiagnostic(chapterResolution.chapterWarning)]
|
|
1106
|
+
: [],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function readSceneMetadataForSync(syncDir, file, { writable = false } = {}) {
|
|
1111
|
+
return readMeta(file, syncDir, { writable });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function readSceneFileForSync(syncDir, file, { writable = false } = {}) {
|
|
1115
|
+
const metadataRead = readSceneMetadataForSync(syncDir, file, { writable });
|
|
1116
|
+
const { data: frontmatter, content: prose } = parseFile(file);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
...metadataRead,
|
|
1120
|
+
frontmatter,
|
|
1121
|
+
prose,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure } = {}) {
|
|
1126
|
+
const canonicalIndexPlan = buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure);
|
|
1127
|
+
const { universeId: universe_id, projectId: project_id } = canonicalIndexPlan;
|
|
1128
|
+
const { chapterStructure } = canonicalIndexPlan.observedStructure;
|
|
952
1129
|
const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
|
|
953
1130
|
const explicitSceneLinks = collectExplicitReferenceLinks(
|
|
954
1131
|
meta,
|
|
@@ -965,22 +1142,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
965
1142
|
project_id, universe_id ?? null, project_id
|
|
966
1143
|
);
|
|
967
1144
|
|
|
968
|
-
const relativePath = path.relative(syncDir, file);
|
|
969
|
-
const chapterResolution = resolveIndexedChapterForFile(db, {
|
|
970
|
-
syncDir,
|
|
971
|
-
projectId: project_id,
|
|
972
|
-
filePath: file,
|
|
973
|
-
relativePath,
|
|
974
|
-
meta,
|
|
975
|
-
chapterStructure,
|
|
976
|
-
});
|
|
977
1145
|
const {
|
|
978
1146
|
chapterId,
|
|
979
1147
|
chapterSortIndex,
|
|
980
1148
|
chapterTitle,
|
|
981
1149
|
chapterWarning,
|
|
982
1150
|
upsertChapter,
|
|
983
|
-
} = chapterResolution;
|
|
1151
|
+
} = canonicalIndexPlan.chapterResolution;
|
|
984
1152
|
|
|
985
1153
|
if (upsertChapter) {
|
|
986
1154
|
upsertCanonicalChapterRecord(db, {
|
|
@@ -991,7 +1159,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
991
1159
|
}
|
|
992
1160
|
|
|
993
1161
|
if (chapterStructure.isEpigraph) {
|
|
994
|
-
|
|
1162
|
+
const result = indexCanonicalEpigraph(db, {
|
|
995
1163
|
projectId: project_id,
|
|
996
1164
|
chapterId,
|
|
997
1165
|
chapterSortIndex,
|
|
@@ -999,11 +1167,12 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
999
1167
|
meta,
|
|
1000
1168
|
prose,
|
|
1001
1169
|
file,
|
|
1002
|
-
relativePath,
|
|
1170
|
+
relativePath: canonicalIndexPlan.observedStructure.relativePath,
|
|
1003
1171
|
chapterWarning,
|
|
1004
1172
|
buildProseChecksum: checksumProse,
|
|
1005
1173
|
buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
|
|
1006
1174
|
});
|
|
1175
|
+
return { ...result, canonicalIndexPlan };
|
|
1007
1176
|
}
|
|
1008
1177
|
|
|
1009
1178
|
const newChecksum = checksumProse(prose);
|
|
@@ -1141,7 +1310,48 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
1141
1310
|
relation: "informs",
|
|
1142
1311
|
});
|
|
1143
1312
|
|
|
1144
|
-
return { isStale, chapterId, warning: chapterWarning };
|
|
1313
|
+
return { isStale, chapterId, warning: chapterWarning, canonicalIndexPlan };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function observeOrphanedSidecars(syncDir, { indexedSceneIds = new Set() } = {}) {
|
|
1317
|
+
const diagnostics = [];
|
|
1318
|
+
const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
|
|
1319
|
+
|
|
1320
|
+
for (const sidecar of sidecars) {
|
|
1321
|
+
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
1322
|
+
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|
|
1323
|
+
if (fs.existsSync(prose) || fs.existsSync(proseTxt)) continue;
|
|
1324
|
+
|
|
1325
|
+
let orphanedSceneId = null;
|
|
1326
|
+
try {
|
|
1327
|
+
const raw = fs.readFileSync(sidecar, "utf8");
|
|
1328
|
+
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
1329
|
+
} catch { /* empty */ }
|
|
1330
|
+
|
|
1331
|
+
const relativePath = path.relative(syncDir, sidecar);
|
|
1332
|
+
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
1333
|
+
diagnostics.push(buildSyncDiagnostic(
|
|
1334
|
+
`Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${relativePath} — prose file has moved. Consider relocating the sidecar alongside the prose file.`,
|
|
1335
|
+
{
|
|
1336
|
+
type: "moved_scene",
|
|
1337
|
+
sceneId: orphanedSceneId,
|
|
1338
|
+
relativePath,
|
|
1339
|
+
}
|
|
1340
|
+
));
|
|
1341
|
+
} else {
|
|
1342
|
+
const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
|
|
1343
|
+
diagnostics.push(buildSyncDiagnostic(
|
|
1344
|
+
`Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${relativePath}`,
|
|
1345
|
+
{
|
|
1346
|
+
type: "orphaned_sidecar",
|
|
1347
|
+
sceneId: orphanedSceneId,
|
|
1348
|
+
relativePath,
|
|
1349
|
+
}
|
|
1350
|
+
));
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return diagnostics;
|
|
1145
1355
|
}
|
|
1146
1356
|
|
|
1147
1357
|
const WARNING_TYPE_LABELS = {
|
|
@@ -1166,7 +1376,7 @@ const WARNING_PATTERNS = [
|
|
|
1166
1376
|
|
|
1167
1377
|
const MAX_WARNING_EXAMPLES = 5;
|
|
1168
1378
|
|
|
1169
|
-
function buildWarningSummary(warnings) {
|
|
1379
|
+
export function buildWarningSummary(warnings) {
|
|
1170
1380
|
const summary = {};
|
|
1171
1381
|
for (const w of warnings) {
|
|
1172
1382
|
const firstLine = w.split("\n")[0];
|
|
@@ -1186,7 +1396,6 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1186
1396
|
// (for example after imports or path repairs) are reflected immediately.
|
|
1187
1397
|
UNIVERSE_PROJECT_ROOT_CACHE.clear();
|
|
1188
1398
|
|
|
1189
|
-
const files = walkFiles(syncDir);
|
|
1190
1399
|
let indexed = 0;
|
|
1191
1400
|
let staleMarked = 0;
|
|
1192
1401
|
let epigraphsIndexed = 0;
|
|
@@ -1198,69 +1407,43 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1198
1407
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
1199
1408
|
const seenChapterKeys = new Set();
|
|
1200
1409
|
const seenEpigraphKeys = new Set();
|
|
1201
|
-
const indexedReferenceDocIds = new Set();
|
|
1202
1410
|
let sceneIndexFailures = 0;
|
|
1203
1411
|
const warnings = [];
|
|
1204
1412
|
const chapterFoldersByProject = new Map();
|
|
1205
1413
|
const roleFoldersByProject = new Map();
|
|
1206
1414
|
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
scanFiles.push(file);
|
|
1415
|
+
const syncScan = scanSyncFiles(syncDir);
|
|
1416
|
+
const scanFiles = syncScan.files;
|
|
1417
|
+
for (const diagnostic of syncScan.diagnostics) {
|
|
1418
|
+
warnings.push(diagnostic.message);
|
|
1214
1419
|
}
|
|
1215
1420
|
|
|
1216
1421
|
// --- Pass 1: world files and reference docs (characters/places must be indexed
|
|
1217
1422
|
// before scenes so that character name -> ID resolution in scene_characters works) ---
|
|
1218
|
-
|
|
1219
|
-
if (isReferenceFile(syncDir, file)) {
|
|
1220
|
-
try {
|
|
1221
|
-
const { data, content } = parseFile(file);
|
|
1222
|
-
const docId = indexReferenceFile(db, syncDir, file, data, content);
|
|
1223
|
-
indexedReferenceDocIds.add(docId);
|
|
1224
|
-
} catch (err) {
|
|
1225
|
-
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1226
|
-
}
|
|
1227
|
-
continue;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
if (!isWorldFile(syncDir, file)) continue;
|
|
1231
|
-
try {
|
|
1232
|
-
const { meta } = readMeta(file, syncDir, { writable });
|
|
1233
|
-
if (!Object.keys(meta).length) {
|
|
1234
|
-
const { data } = parseFile(file);
|
|
1235
|
-
indexWorldFile(db, syncDir, file, data);
|
|
1236
|
-
} else {
|
|
1237
|
-
indexWorldFile(db, syncDir, file, meta);
|
|
1238
|
-
}
|
|
1239
|
-
} catch (err) {
|
|
1240
|
-
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
if (canPruneReferenceDocs(syncDir)) {
|
|
1245
|
-
pruneMissingReferenceDocs(db, indexedReferenceDocIds);
|
|
1246
|
-
}
|
|
1423
|
+
regenerateReferenceAndWorldIndexes(db, syncDir, scanFiles, { writable, pruneReferenceDocs: true });
|
|
1247
1424
|
|
|
1248
1425
|
// --- Pass 2: scene files ---
|
|
1249
1426
|
for (const file of scanFiles) {
|
|
1250
1427
|
if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
|
|
1251
1428
|
try {
|
|
1252
|
-
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } =
|
|
1429
|
+
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readSceneMetadataForSync(syncDir, file, { writable });
|
|
1253
1430
|
if (sidecarGenerated) sidecarsMigrated++;
|
|
1254
|
-
const
|
|
1431
|
+
const structureObservation = observeStructureForFile(syncDir, file, {
|
|
1432
|
+
meta,
|
|
1433
|
+
sourceMeta,
|
|
1434
|
+
derived,
|
|
1435
|
+
mismatches,
|
|
1436
|
+
});
|
|
1437
|
+
const { chapterStructure } = structureObservation;
|
|
1255
1438
|
|
|
1256
1439
|
if (!meta.scene_id && !chapterStructure.isEpigraph) {
|
|
1257
1440
|
skipped++;
|
|
1258
|
-
if (!quiet) warnings.push(`Skipped (no scene_id): ${
|
|
1441
|
+
if (!quiet) warnings.push(`Skipped (no scene_id): ${structureObservation.relativePath}`);
|
|
1259
1442
|
continue;
|
|
1260
1443
|
}
|
|
1261
1444
|
|
|
1262
1445
|
// Duplicate scene_id detection
|
|
1263
|
-
const
|
|
1446
|
+
const project_id = structureObservation.projectId;
|
|
1264
1447
|
const key = `${meta.scene_id}::${project_id}`;
|
|
1265
1448
|
if (meta.scene_id && seenSceneIds.has(key)) {
|
|
1266
1449
|
warnings.push(
|
|
@@ -1284,18 +1467,17 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1284
1467
|
}
|
|
1285
1468
|
}
|
|
1286
1469
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
|
|
1290
|
-
if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
|
|
1291
|
-
warnings.push(
|
|
1292
|
-
`Path/metadata mismatch for scene "${meta.scene_id}": ${path.relative(syncDir, file)} (${details.join(", ")}). Using path-derived values.`
|
|
1293
|
-
);
|
|
1470
|
+
for (const diagnostic of structureObservation.diagnostics) {
|
|
1471
|
+
warnings.push(diagnostic.message);
|
|
1294
1472
|
}
|
|
1295
1473
|
|
|
1296
|
-
const {
|
|
1297
|
-
|
|
1298
|
-
|
|
1474
|
+
const { content: prose } = parseFile(file);
|
|
1475
|
+
|
|
1476
|
+
const result = indexSceneFile(db, syncDir, file, meta, prose, { observedStructure: structureObservation });
|
|
1477
|
+
const canonicalDiagnostics = result.canonicalIndexPlan?.diagnostics ?? [];
|
|
1478
|
+
if (canonicalDiagnostics.length) {
|
|
1479
|
+
for (const diagnostic of canonicalDiagnostics) warnings.push(diagnostic.message);
|
|
1480
|
+
} else if (result.warning) {
|
|
1299
1481
|
warnings.push(result.warning);
|
|
1300
1482
|
}
|
|
1301
1483
|
if (result.chapterId) {
|
|
@@ -1337,35 +1519,16 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1337
1519
|
}
|
|
1338
1520
|
}
|
|
1339
1521
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1522
|
+
pruneSyncDerivedIndexes(db, syncDir, {
|
|
1523
|
+
seenSceneKeys,
|
|
1524
|
+
seenEpigraphKeys,
|
|
1525
|
+
seenChapterKeys,
|
|
1526
|
+
sceneIndexFailures,
|
|
1527
|
+
});
|
|
1345
1528
|
|
|
1346
1529
|
// --- Orphaned sidecar detection ---
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
1350
|
-
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|
|
1351
|
-
if (!fs.existsSync(prose) && !fs.existsSync(proseTxt)) {
|
|
1352
|
-
let orphanedSceneId = null;
|
|
1353
|
-
try {
|
|
1354
|
-
const raw = fs.readFileSync(sidecar, "utf8");
|
|
1355
|
-
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
1356
|
-
} catch { /* empty */ }
|
|
1357
|
-
|
|
1358
|
-
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
1359
|
-
warnings.push(
|
|
1360
|
-
`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.`
|
|
1361
|
-
);
|
|
1362
|
-
} else {
|
|
1363
|
-
const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
|
|
1364
|
-
warnings.push(
|
|
1365
|
-
`Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${path.relative(syncDir, sidecar)}`
|
|
1366
|
-
);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1530
|
+
for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
|
|
1531
|
+
warnings.push(diagnostic.message);
|
|
1369
1532
|
}
|
|
1370
1533
|
|
|
1371
1534
|
const warningSummary = buildWarningSummary(warnings);
|