@hanna84/mcp-writing 3.7.1 → 3.8.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/CHANGELOG.md +7 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/core/db.js +191 -0
- package/src/core/helpers.js +6 -1
- package/src/review-bundles/review-bundles-planner.js +11 -3
- package/src/review-bundles/review-bundles-renderer.js +86 -14
- package/src/sync/sync.js +557 -11
- package/src/tools/review-bundles.js +10 -4
- package/src/tools/search.js +225 -19
- package/src/tools/styleguide.js +7 -1
- package/src/tools/sync.js +3 -0
package/src/sync/sync.js
CHANGED
|
@@ -79,16 +79,118 @@ export function inferScenePositionFromPath(syncDir, filePath) {
|
|
|
79
79
|
return { part, chapter };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function titleCaseFolderLabel(value) {
|
|
83
|
+
return String(value ?? "")
|
|
84
|
+
.replace(/[-_]+/g, " ")
|
|
85
|
+
.replace(/\s+/g, " ")
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/\b\w/g, char => char.toUpperCase());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function slugifyChapterValue(value) {
|
|
91
|
+
return String(value ?? "")
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isExplicitChapterContainer(parts, index) {
|
|
98
|
+
const parent = parts[index - 1]?.toLowerCase() ?? null;
|
|
99
|
+
return parent === "draft" || parent === "scenes";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function inferChapterStructureFromPath(syncDir, filePath, meta = {}) {
|
|
103
|
+
const rel = path.relative(syncDir, filePath);
|
|
104
|
+
const parts = rel.split(path.sep);
|
|
105
|
+
let role = null;
|
|
106
|
+
let chapterFolder = null;
|
|
107
|
+
let chapterSortIndex = null;
|
|
108
|
+
let chapterTitle = null;
|
|
109
|
+
let chapterFolderKey = null;
|
|
110
|
+
|
|
111
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
112
|
+
const segment = parts[index];
|
|
113
|
+
const normalized = segment.toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (normalized === "prologue" || normalized === "00-prologue") {
|
|
116
|
+
role = "prologue";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (normalized === "epilogue" || normalized === "99-epilogue") {
|
|
120
|
+
role = "epilogue";
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let match = segment.match(/^(\d+)-(.+)$/);
|
|
125
|
+
if (!match) {
|
|
126
|
+
match = segment.match(/^chapter-(\d+)(?:-(.+))?$/i);
|
|
127
|
+
}
|
|
128
|
+
if (!match || !isExplicitChapterContainer(parts, index)) continue;
|
|
129
|
+
|
|
130
|
+
chapterFolder = segment;
|
|
131
|
+
chapterSortIndex = Number.parseInt(match[1], 10);
|
|
132
|
+
chapterTitle = titleCaseFolderLabel(match[2] ?? `Chapter ${chapterSortIndex}`);
|
|
133
|
+
chapterFolderKey = parts.slice(0, index + 1).join(path.sep);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const baseName = path.basename(filePath, path.extname(filePath)).toLowerCase();
|
|
137
|
+
const explicitEpigraph = meta.kind === "epigraph"
|
|
138
|
+
|| meta.type === "epigraph"
|
|
139
|
+
|| typeof meta.epigraph_id === "string"
|
|
140
|
+
|| baseName === "epigraph";
|
|
141
|
+
|
|
142
|
+
if (chapterSortIndex == null) {
|
|
143
|
+
const fallback = inferScenePositionFromPath(syncDir, filePath);
|
|
144
|
+
if (fallback.chapter != null) {
|
|
145
|
+
chapterSortIndex = fallback.chapter;
|
|
146
|
+
chapterTitle = titleCaseFolderLabel(meta.chapter_title ?? `Chapter ${fallback.chapter}`);
|
|
147
|
+
chapterFolderKey = chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (chapterSortIndex == null) {
|
|
152
|
+
return {
|
|
153
|
+
role,
|
|
154
|
+
isEpigraph: explicitEpigraph,
|
|
155
|
+
chapter: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const chapterSlug = slugifyChapterValue(chapterTitle) || `chapter-${chapterSortIndex}`;
|
|
160
|
+
return {
|
|
161
|
+
role,
|
|
162
|
+
isEpigraph: explicitEpigraph,
|
|
163
|
+
chapter: {
|
|
164
|
+
chapter_id: `ch-${String(chapterSortIndex).padStart(2, "0")}-${chapterSlug}`,
|
|
165
|
+
sort_index: chapterSortIndex,
|
|
166
|
+
title: chapterTitle,
|
|
167
|
+
folder_name: chapterFolder ?? `chapter-${chapterSortIndex}`,
|
|
168
|
+
folder_key: chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep),
|
|
169
|
+
source_kind: chapterFolder ? "chapter_folder" : "legacy_layout",
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
82
174
|
export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
|
|
83
175
|
const derived = inferScenePositionFromPath(syncDir, filePath);
|
|
176
|
+
const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
|
|
84
177
|
const normalized = { ...meta };
|
|
85
178
|
|
|
86
179
|
if (derived.part !== null) normalized.part = derived.part;
|
|
87
180
|
if (derived.chapter !== null) normalized.chapter = derived.chapter;
|
|
181
|
+
if (chapterStructure.chapter?.chapter_id) {
|
|
182
|
+
normalized.chapter_id = chapterStructure.chapter.chapter_id;
|
|
183
|
+
normalized.chapter = chapterStructure.chapter.sort_index;
|
|
184
|
+
normalized.chapter_title = chapterStructure.chapter.title;
|
|
185
|
+
}
|
|
186
|
+
if (chapterStructure.role) {
|
|
187
|
+
normalized.scene_role = chapterStructure.role;
|
|
188
|
+
}
|
|
88
189
|
|
|
89
190
|
return {
|
|
90
191
|
meta: normalized,
|
|
91
192
|
derived,
|
|
193
|
+
chapterStructure,
|
|
92
194
|
mismatches: {
|
|
93
195
|
part: derived.part !== null && meta.part != null && meta.part !== derived.part,
|
|
94
196
|
chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
|
|
@@ -926,8 +1028,165 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
|
|
|
926
1028
|
}
|
|
927
1029
|
}
|
|
928
1030
|
|
|
1031
|
+
function pruneMissingChapters(db, seenChapterKeys, syncDir) {
|
|
1032
|
+
const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
|
|
1033
|
+
const rows = projectScope
|
|
1034
|
+
? db.prepare(`SELECT chapter_id, project_id FROM chapters WHERE project_id = ?`).all(projectScope)
|
|
1035
|
+
: db.prepare(`SELECT chapter_id, project_id FROM chapters`).all();
|
|
1036
|
+
|
|
1037
|
+
for (const row of rows) {
|
|
1038
|
+
const key = `${row.chapter_id}::${row.project_id}`;
|
|
1039
|
+
if (seenChapterKeys.has(key)) continue;
|
|
1040
|
+
db.prepare(`DELETE FROM epigraph_characters WHERE project_id = ? AND epigraph_id IN (SELECT epigraph_id FROM epigraphs WHERE project_id = ? AND chapter_id = ?)`)
|
|
1041
|
+
.run(row.project_id, row.project_id, row.chapter_id);
|
|
1042
|
+
db.prepare(`DELETE FROM epigraph_tags WHERE project_id = ? AND epigraph_id IN (SELECT epigraph_id FROM epigraphs WHERE project_id = ? AND chapter_id = ?)`)
|
|
1043
|
+
.run(row.project_id, row.project_id, row.chapter_id);
|
|
1044
|
+
db.prepare(`DELETE FROM epigraphs WHERE project_id = ? AND chapter_id = ?`).run(row.project_id, row.chapter_id);
|
|
1045
|
+
db.prepare(`DELETE FROM chapters WHERE chapter_id = ? AND project_id = ?`).run(row.chapter_id, row.project_id);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
|
|
1050
|
+
const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
|
|
1051
|
+
const rows = projectScope
|
|
1052
|
+
? db.prepare(`SELECT epigraph_id, project_id FROM epigraphs WHERE project_id = ?`).all(projectScope)
|
|
1053
|
+
: db.prepare(`SELECT epigraph_id, project_id FROM epigraphs`).all();
|
|
1054
|
+
|
|
1055
|
+
for (const row of rows) {
|
|
1056
|
+
const key = `${row.epigraph_id}::${row.project_id}`;
|
|
1057
|
+
if (seenEpigraphKeys.has(key)) continue;
|
|
1058
|
+
db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
|
|
1059
|
+
db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
|
|
1060
|
+
db.prepare(`DELETE FROM epigraphs WHERE epigraph_id = ? AND project_id = ?`).run(row.epigraph_id, row.project_id);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function resolveCanonicalChapterRecord(db, {
|
|
1065
|
+
syncDir,
|
|
1066
|
+
projectId,
|
|
1067
|
+
derivedChapterId,
|
|
1068
|
+
sortIndex,
|
|
1069
|
+
title,
|
|
1070
|
+
sourcePath,
|
|
1071
|
+
allowSourcePathMatch = false,
|
|
1072
|
+
}) {
|
|
1073
|
+
if (!projectId || sortIndex == null || !title) return null;
|
|
1074
|
+
|
|
1075
|
+
const normalizedSourcePath = sourcePath ?? null;
|
|
1076
|
+
const bySourcePath = allowSourcePathMatch && normalizedSourcePath
|
|
1077
|
+
? db.prepare(`
|
|
1078
|
+
SELECT chapter_id, title, sort_index, logline, source_checksum, metadata_stale
|
|
1079
|
+
FROM chapters
|
|
1080
|
+
WHERE project_id = ? AND source_path = ?
|
|
1081
|
+
`).get(projectId, normalizedSourcePath)
|
|
1082
|
+
: null;
|
|
1083
|
+
|
|
1084
|
+
if (bySourcePath) {
|
|
1085
|
+
return {
|
|
1086
|
+
...bySourcePath,
|
|
1087
|
+
chapter_id: bySourcePath.chapter_id,
|
|
1088
|
+
title,
|
|
1089
|
+
sort_index: sortIndex,
|
|
1090
|
+
source_path: normalizedSourcePath,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const byTitle = db.prepare(`
|
|
1095
|
+
SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
|
|
1096
|
+
FROM chapters
|
|
1097
|
+
WHERE project_id = ? AND title = ?
|
|
1098
|
+
ORDER BY chapter_id
|
|
1099
|
+
`).all(projectId, title);
|
|
1100
|
+
|
|
1101
|
+
if (byTitle.length === 1) {
|
|
1102
|
+
const existingTitleSourcePath = byTitle[0].source_path ?? null;
|
|
1103
|
+
const existingTitleSourceExists = Boolean(
|
|
1104
|
+
syncDir
|
|
1105
|
+
&& existingTitleSourcePath
|
|
1106
|
+
&& fs.existsSync(path.join(syncDir, existingTitleSourcePath))
|
|
1107
|
+
);
|
|
1108
|
+
const canReuseByTitle = allowSourcePathMatch || byTitle[0].sort_index === sortIndex;
|
|
1109
|
+
if (canReuseByTitle && (!existingTitleSourceExists || existingTitleSourcePath === normalizedSourcePath)) {
|
|
1110
|
+
return {
|
|
1111
|
+
...byTitle[0],
|
|
1112
|
+
chapter_id: byTitle[0].chapter_id,
|
|
1113
|
+
title,
|
|
1114
|
+
sort_index: sortIndex,
|
|
1115
|
+
source_path: normalizedSourcePath,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (byTitle.length > 1) {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const bySortIndex = db.prepare(`
|
|
1125
|
+
SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
|
|
1126
|
+
FROM chapters
|
|
1127
|
+
WHERE project_id = ? AND sort_index = ?
|
|
1128
|
+
`).get(projectId, sortIndex);
|
|
1129
|
+
|
|
1130
|
+
if (bySortIndex) {
|
|
1131
|
+
const existingSourceExists = Boolean(
|
|
1132
|
+
syncDir
|
|
1133
|
+
&& bySortIndex.source_path
|
|
1134
|
+
&& fs.existsSync(path.join(syncDir, bySortIndex.source_path))
|
|
1135
|
+
);
|
|
1136
|
+
if (
|
|
1137
|
+
normalizedSourcePath
|
|
1138
|
+
&& bySortIndex.source_path
|
|
1139
|
+
&& bySortIndex.source_path !== normalizedSourcePath
|
|
1140
|
+
&& existingSourceExists
|
|
1141
|
+
) {
|
|
1142
|
+
return {
|
|
1143
|
+
ambiguous: true,
|
|
1144
|
+
existingSourcePath: bySortIndex.source_path,
|
|
1145
|
+
conflictingSourcePath: normalizedSourcePath,
|
|
1146
|
+
sort_index: sortIndex,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
...bySortIndex,
|
|
1151
|
+
chapter_id: bySortIndex.chapter_id,
|
|
1152
|
+
title,
|
|
1153
|
+
sort_index: sortIndex,
|
|
1154
|
+
source_path: normalizedSourcePath,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return {
|
|
1159
|
+
chapter_id: derivedChapterId,
|
|
1160
|
+
title,
|
|
1161
|
+
sort_index: sortIndex,
|
|
1162
|
+
source_path: normalizedSourcePath,
|
|
1163
|
+
logline: null,
|
|
1164
|
+
source_checksum: null,
|
|
1165
|
+
metadata_stale: 0,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function parkConflictingChapterSortIndex(db, { projectId, chapterId, targetSortIndex }) {
|
|
1170
|
+
if (!projectId || !chapterId || targetSortIndex == null) return;
|
|
1171
|
+
|
|
1172
|
+
const conflictingChapter = db.prepare(`
|
|
1173
|
+
SELECT chapter_id, sort_index
|
|
1174
|
+
FROM chapters
|
|
1175
|
+
WHERE project_id = ? AND sort_index = ? AND chapter_id != ?
|
|
1176
|
+
`).get(projectId, targetSortIndex, chapterId);
|
|
1177
|
+
|
|
1178
|
+
if (!conflictingChapter) return;
|
|
1179
|
+
|
|
1180
|
+
db.prepare(`
|
|
1181
|
+
UPDATE chapters
|
|
1182
|
+
SET sort_index = ?
|
|
1183
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
1184
|
+
`).run(-1000000 - Number(conflictingChapter.sort_index), projectId, conflictingChapter.chapter_id);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
929
1187
|
export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
930
1188
|
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
1189
|
+
const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
|
|
931
1190
|
const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
|
|
932
1191
|
const explicitSceneLinks = collectExplicitReferenceLinks(
|
|
933
1192
|
meta,
|
|
@@ -944,6 +1203,225 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
944
1203
|
project_id, universe_id ?? null, project_id
|
|
945
1204
|
);
|
|
946
1205
|
|
|
1206
|
+
let chapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
|
|
1207
|
+
let chapterSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
|
|
1208
|
+
let chapterTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? (chapterSortIndex != null ? `Chapter ${chapterSortIndex}` : null);
|
|
1209
|
+
const chapterSourcePath = chapterStructure.chapter?.folder_key ?? path.dirname(file);
|
|
1210
|
+
const allowChapterSourcePathMatch = chapterStructure.chapter?.source_kind === "chapter_folder";
|
|
1211
|
+
let chapterWarning = null;
|
|
1212
|
+
const explicitSceneChapterId = !chapterStructure.isEpigraph ? meta.chapter_id ?? null : null;
|
|
1213
|
+
let explicitSceneCanonicalChapter = null;
|
|
1214
|
+
|
|
1215
|
+
if (explicitSceneChapterId && !chapterStructure.chapter) {
|
|
1216
|
+
explicitSceneCanonicalChapter = db.prepare(`
|
|
1217
|
+
SELECT chapter_id, sort_index, title
|
|
1218
|
+
FROM chapters
|
|
1219
|
+
WHERE chapter_id = ? AND project_id = ?
|
|
1220
|
+
`).get(explicitSceneChapterId, project_id);
|
|
1221
|
+
if (explicitSceneCanonicalChapter) {
|
|
1222
|
+
chapterId = explicitSceneCanonicalChapter.chapter_id;
|
|
1223
|
+
chapterSortIndex = explicitSceneCanonicalChapter.sort_index ?? null;
|
|
1224
|
+
chapterTitle = explicitSceneCanonicalChapter.title ?? null;
|
|
1225
|
+
} else {
|
|
1226
|
+
// Scene-level explicit chapter links must target an existing canonical chapter.
|
|
1227
|
+
chapterSortIndex = null;
|
|
1228
|
+
chapterTitle = null;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const derivedChapterId = (
|
|
1232
|
+
chapterId
|
|
1233
|
+
?? (chapterSortIndex != null && chapterTitle
|
|
1234
|
+
? `ch-${String(chapterSortIndex).padStart(2, "0")}-${slugifyChapterValue(chapterTitle) || `chapter-${chapterSortIndex}`}`
|
|
1235
|
+
: null)
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
if (!explicitSceneCanonicalChapter && chapterSortIndex != null && chapterTitle) {
|
|
1239
|
+
const canonicalChapter = resolveCanonicalChapterRecord(db, {
|
|
1240
|
+
syncDir,
|
|
1241
|
+
projectId: project_id,
|
|
1242
|
+
derivedChapterId,
|
|
1243
|
+
sortIndex: chapterSortIndex,
|
|
1244
|
+
title: chapterTitle,
|
|
1245
|
+
sourcePath: chapterSourcePath,
|
|
1246
|
+
allowSourcePathMatch: allowChapterSourcePathMatch,
|
|
1247
|
+
});
|
|
1248
|
+
if (canonicalChapter?.ambiguous) {
|
|
1249
|
+
chapterWarning = `Chapter structure warning: duplicate chapter order ${chapterSortIndex} in project "${project_id}" for ${canonicalChapter.existingSourcePath} and ${canonicalChapter.conflictingSourcePath}.`;
|
|
1250
|
+
chapterId = null;
|
|
1251
|
+
} else {
|
|
1252
|
+
chapterId = canonicalChapter?.chapter_id ?? chapterId;
|
|
1253
|
+
}
|
|
1254
|
+
if (chapterId) {
|
|
1255
|
+
parkConflictingChapterSortIndex(db, {
|
|
1256
|
+
projectId: project_id,
|
|
1257
|
+
chapterId,
|
|
1258
|
+
targetSortIndex: chapterSortIndex,
|
|
1259
|
+
});
|
|
1260
|
+
const existingChapter = db.prepare(
|
|
1261
|
+
`SELECT logline, source_checksum, metadata_stale FROM chapters WHERE chapter_id = ? AND project_id = ?`
|
|
1262
|
+
).get(chapterId, project_id);
|
|
1263
|
+
const chapterLogline = meta.chapter_logline ?? existingChapter?.logline ?? null;
|
|
1264
|
+
const chapterChecksum = checksumProse(`${chapterSortIndex}:${chapterTitle}:${chapterLogline ?? ""}`);
|
|
1265
|
+
db.prepare(`
|
|
1266
|
+
INSERT INTO chapters (
|
|
1267
|
+
chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
|
|
1268
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1269
|
+
ON CONFLICT (chapter_id, project_id) DO UPDATE SET
|
|
1270
|
+
title = excluded.title,
|
|
1271
|
+
sort_index = excluded.sort_index,
|
|
1272
|
+
logline = excluded.logline,
|
|
1273
|
+
source_path = excluded.source_path,
|
|
1274
|
+
source_checksum = excluded.source_checksum,
|
|
1275
|
+
metadata_stale = CASE
|
|
1276
|
+
WHEN excluded.source_checksum != chapters.source_checksum THEN 1
|
|
1277
|
+
ELSE chapters.metadata_stale
|
|
1278
|
+
END,
|
|
1279
|
+
updated_at = excluded.updated_at
|
|
1280
|
+
`).run(
|
|
1281
|
+
chapterId,
|
|
1282
|
+
project_id,
|
|
1283
|
+
chapterTitle,
|
|
1284
|
+
chapterSortIndex,
|
|
1285
|
+
chapterLogline,
|
|
1286
|
+
chapterSourcePath,
|
|
1287
|
+
chapterChecksum,
|
|
1288
|
+
existingChapter && existingChapter.source_checksum !== chapterChecksum ? 1 : 0,
|
|
1289
|
+
new Date().toISOString()
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (!chapterStructure.isEpigraph && chapterId && (chapterSortIndex == null || !chapterTitle)) {
|
|
1295
|
+
const canonicalChapter = db.prepare(`
|
|
1296
|
+
SELECT chapter_id, sort_index, title
|
|
1297
|
+
FROM chapters
|
|
1298
|
+
WHERE chapter_id = ? AND project_id = ?
|
|
1299
|
+
`).get(chapterId, project_id);
|
|
1300
|
+
if (!canonicalChapter) {
|
|
1301
|
+
chapterWarning = `Scene references unknown chapter_id '${chapterId}': ${path.relative(syncDir, file)}`;
|
|
1302
|
+
chapterId = null;
|
|
1303
|
+
} else {
|
|
1304
|
+
chapterSortIndex = chapterSortIndex ?? canonicalChapter.sort_index ?? null;
|
|
1305
|
+
chapterTitle = chapterTitle ?? canonicalChapter.title ?? null;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (chapterStructure.isEpigraph) {
|
|
1310
|
+
const canonicalChapter = chapterId
|
|
1311
|
+
? db.prepare(`SELECT chapter_id FROM chapters WHERE chapter_id = ? AND project_id = ?`).get(chapterId, project_id)
|
|
1312
|
+
: null;
|
|
1313
|
+
if (!chapterId || !canonicalChapter) {
|
|
1314
|
+
const reason = chapterWarning
|
|
1315
|
+
?? (chapterId
|
|
1316
|
+
? `Epigraph references unknown chapter_id '${chapterId}': ${path.relative(syncDir, file)}`
|
|
1317
|
+
: null)
|
|
1318
|
+
?? (chapterStructure.chapter && chapterSortIndex != null
|
|
1319
|
+
? `Ambiguous chapter linkage from duplicate chapter order ${chapterSortIndex}: ${path.relative(syncDir, file)}`
|
|
1320
|
+
: `Epigraph requires explicit chapter linkage: ${path.relative(syncDir, file)}`);
|
|
1321
|
+
return { isStale: 0, skippedAsEpigraph: true, warning: reason };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const defaultEpigraphId = `epi-${slugifyChapterValue(`${project_id}-${chapterId}`)}`;
|
|
1325
|
+
const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
|
|
1326
|
+
const epigraphChecksum = checksumProse(prose);
|
|
1327
|
+
const epigraphById = db.prepare(`
|
|
1328
|
+
SELECT epigraph_id, chapter_id, prose_checksum
|
|
1329
|
+
FROM epigraphs
|
|
1330
|
+
WHERE epigraph_id = ? AND project_id = ?
|
|
1331
|
+
`).get(requestedEpigraphId, project_id);
|
|
1332
|
+
const epigraphByChapter = db.prepare(`
|
|
1333
|
+
SELECT epigraph_id, chapter_id, prose_checksum
|
|
1334
|
+
FROM epigraphs
|
|
1335
|
+
WHERE chapter_id = ? AND project_id = ?
|
|
1336
|
+
`).get(chapterId, project_id);
|
|
1337
|
+
|
|
1338
|
+
if (
|
|
1339
|
+
epigraphById
|
|
1340
|
+
&& epigraphById.chapter_id !== chapterId
|
|
1341
|
+
&& (!epigraphByChapter || epigraphByChapter.epigraph_id !== epigraphById.epigraph_id)
|
|
1342
|
+
) {
|
|
1343
|
+
return {
|
|
1344
|
+
isStale: 0,
|
|
1345
|
+
skippedAsEpigraph: true,
|
|
1346
|
+
warning: `Epigraph identity conflict for chapter '${chapterId}': requested epigraph_id '${requestedEpigraphId}' already belongs to another chapter in project '${project_id}'.`,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const existingEpigraph = epigraphByChapter ?? epigraphById ?? null;
|
|
1351
|
+
const epigraphId = meta.epigraph_id
|
|
1352
|
+
? requestedEpigraphId
|
|
1353
|
+
: (epigraphByChapter?.epigraph_id ?? requestedEpigraphId);
|
|
1354
|
+
const previousEpigraphId = existingEpigraph?.epigraph_id ?? epigraphId;
|
|
1355
|
+
const existingChecksum = existingEpigraph?.prose_checksum ?? null;
|
|
1356
|
+
const epigraphIsStale = existingChecksum !== null && existingChecksum !== epigraphChecksum ? 1 : 0;
|
|
1357
|
+
const timestamp = new Date().toISOString();
|
|
1358
|
+
|
|
1359
|
+
if (existingEpigraph) {
|
|
1360
|
+
db.prepare(`
|
|
1361
|
+
UPDATE epigraphs
|
|
1362
|
+
SET epigraph_id = ?,
|
|
1363
|
+
chapter_id = ?,
|
|
1364
|
+
body = ?,
|
|
1365
|
+
file_path = ?,
|
|
1366
|
+
prose_checksum = ?,
|
|
1367
|
+
metadata_stale = CASE
|
|
1368
|
+
WHEN ? != prose_checksum THEN 1
|
|
1369
|
+
ELSE metadata_stale
|
|
1370
|
+
END,
|
|
1371
|
+
updated_at = ?
|
|
1372
|
+
WHERE epigraph_id = ? AND project_id = ?
|
|
1373
|
+
`).run(
|
|
1374
|
+
epigraphId,
|
|
1375
|
+
chapterId,
|
|
1376
|
+
prose,
|
|
1377
|
+
file,
|
|
1378
|
+
epigraphChecksum,
|
|
1379
|
+
epigraphChecksum,
|
|
1380
|
+
timestamp,
|
|
1381
|
+
previousEpigraphId,
|
|
1382
|
+
project_id
|
|
1383
|
+
);
|
|
1384
|
+
} else {
|
|
1385
|
+
db.prepare(`
|
|
1386
|
+
INSERT INTO epigraphs (
|
|
1387
|
+
epigraph_id, project_id, chapter_id, body, file_path, prose_checksum, metadata_stale, updated_at
|
|
1388
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1389
|
+
`).run(
|
|
1390
|
+
epigraphId,
|
|
1391
|
+
project_id,
|
|
1392
|
+
chapterId,
|
|
1393
|
+
prose,
|
|
1394
|
+
file,
|
|
1395
|
+
epigraphChecksum,
|
|
1396
|
+
0,
|
|
1397
|
+
timestamp
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, project_id);
|
|
1402
|
+
db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, project_id);
|
|
1403
|
+
if (previousEpigraphId !== epigraphId) {
|
|
1404
|
+
db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, project_id);
|
|
1405
|
+
db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, project_id);
|
|
1406
|
+
}
|
|
1407
|
+
for (const characterId of (meta.characters ?? [])) {
|
|
1408
|
+
db.prepare(`INSERT OR IGNORE INTO epigraph_characters (epigraph_id, project_id, character_id) VALUES (?, ?, ?)`)
|
|
1409
|
+
.run(epigraphId, project_id, characterId);
|
|
1410
|
+
}
|
|
1411
|
+
for (const tag of (meta.tags ?? [])) {
|
|
1412
|
+
db.prepare(`INSERT OR IGNORE INTO epigraph_tags (epigraph_id, project_id, tag) VALUES (?, ?, ?)`)
|
|
1413
|
+
.run(epigraphId, project_id, tag);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
return {
|
|
1417
|
+
isStale: epigraphIsStale,
|
|
1418
|
+
skippedAsEpigraph: true,
|
|
1419
|
+
epigraphIndexed: true,
|
|
1420
|
+
chapterId,
|
|
1421
|
+
epigraphId,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
947
1425
|
const newChecksum = checksumProse(prose);
|
|
948
1426
|
const existing = db.prepare(
|
|
949
1427
|
`SELECT prose_checksum FROM scenes WHERE scene_id = ? AND project_id = ?`
|
|
@@ -953,12 +1431,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
953
1431
|
|
|
954
1432
|
db.prepare(`
|
|
955
1433
|
INSERT INTO scenes (
|
|
956
|
-
scene_id, project_id, title, part, chapter, chapter_title, pov, logline, scene_change,
|
|
1434
|
+
scene_id, project_id, chapter_id, scene_role, title, part, chapter, chapter_title, pov, logline, scene_change,
|
|
957
1435
|
causality, stakes, scene_functions,
|
|
958
1436
|
save_the_cat_beat, timeline_position, story_time, word_count,
|
|
959
1437
|
file_path, prose_checksum, metadata_stale, updated_at
|
|
960
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1438
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
961
1439
|
ON CONFLICT (scene_id, project_id) DO UPDATE SET
|
|
1440
|
+
chapter_id = excluded.chapter_id,
|
|
1441
|
+
scene_role = excluded.scene_role,
|
|
962
1442
|
title = excluded.title,
|
|
963
1443
|
part = excluded.part,
|
|
964
1444
|
chapter = excluded.chapter,
|
|
@@ -979,7 +1459,8 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
979
1459
|
updated_at = excluded.updated_at
|
|
980
1460
|
`).run(
|
|
981
1461
|
meta.scene_id, project_id,
|
|
982
|
-
|
|
1462
|
+
chapterId, meta.scene_role ?? chapterStructure.role ?? null,
|
|
1463
|
+
meta.title ?? null, meta.part ?? null, chapterSortIndex, chapterTitle,
|
|
983
1464
|
meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
|
|
984
1465
|
meta.scene_change ?? meta.change ?? null,
|
|
985
1466
|
meta.causality ?? null, meta.stakes ?? null,
|
|
@@ -1076,13 +1557,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
1076
1557
|
relation: "informs",
|
|
1077
1558
|
});
|
|
1078
1559
|
|
|
1079
|
-
return { isStale };
|
|
1560
|
+
return { isStale, chapterId, warning: chapterWarning };
|
|
1080
1561
|
}
|
|
1081
1562
|
|
|
1082
1563
|
const WARNING_TYPE_LABELS = {
|
|
1083
1564
|
no_scene_id: "Skipped (no scene_id)",
|
|
1084
1565
|
duplicate_scene_id: "Duplicate scene_id",
|
|
1085
1566
|
path_metadata_mismatch: "Path/metadata mismatch",
|
|
1567
|
+
chapter_structure: "Chapter structure",
|
|
1086
1568
|
orphaned_sidecar: "Orphaned sidecar",
|
|
1087
1569
|
moved_scene: "Moved scene",
|
|
1088
1570
|
nested_mirror: "Ignored nested mirror path",
|
|
@@ -1092,6 +1574,7 @@ const WARNING_PATTERNS = [
|
|
|
1092
1574
|
{ type: "no_scene_id", re: /^Skipped \(no scene_id\):/ },
|
|
1093
1575
|
{ type: "duplicate_scene_id", re: /^Duplicate scene_id/ },
|
|
1094
1576
|
{ type: "path_metadata_mismatch", re: /^Path\/metadata mismatch/ },
|
|
1577
|
+
{ type: "chapter_structure", re: /^(Chapter structure warning|Epigraph requires explicit chapter linkage|Epigraph references unknown chapter_id|Scene references unknown chapter_id|Ambiguous chapter linkage|Epigraph identity conflict)/ },
|
|
1095
1578
|
{ type: "moved_scene", re: /^Moved scene detected:/ },
|
|
1096
1579
|
{ type: "orphaned_sidecar", re: /^Orphaned sidecar/ },
|
|
1097
1580
|
{ type: "nested_mirror", re: /^Ignored nested mirror path:/ },
|
|
@@ -1122,14 +1605,20 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1122
1605
|
const files = walkFiles(syncDir);
|
|
1123
1606
|
let indexed = 0;
|
|
1124
1607
|
let staleMarked = 0;
|
|
1608
|
+
let epigraphsIndexed = 0;
|
|
1609
|
+
let epigraphsStaleMarked = 0;
|
|
1125
1610
|
let skipped = 0;
|
|
1126
1611
|
let sidecarsMigrated = 0;
|
|
1127
1612
|
const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
|
|
1128
1613
|
const seenSceneKeys = new Set();
|
|
1129
1614
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
1615
|
+
const seenChapterKeys = new Set();
|
|
1616
|
+
const seenEpigraphKeys = new Set();
|
|
1130
1617
|
const indexedReferenceDocIds = new Set();
|
|
1131
1618
|
let sceneIndexFailures = 0;
|
|
1132
1619
|
const warnings = [];
|
|
1620
|
+
const chapterFoldersByProject = new Map();
|
|
1621
|
+
const roleFoldersByProject = new Map();
|
|
1133
1622
|
|
|
1134
1623
|
const scanFiles = [];
|
|
1135
1624
|
for (const file of files) {
|
|
@@ -1178,8 +1667,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1178
1667
|
try {
|
|
1179
1668
|
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
|
|
1180
1669
|
if (sidecarGenerated) sidecarsMigrated++;
|
|
1670
|
+
const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
|
|
1181
1671
|
|
|
1182
|
-
if (!meta.scene_id) {
|
|
1672
|
+
if (!meta.scene_id && !chapterStructure.isEpigraph) {
|
|
1183
1673
|
skipped++;
|
|
1184
1674
|
if (!quiet) warnings.push(`Skipped (no scene_id): ${path.relative(syncDir, file)}`);
|
|
1185
1675
|
continue;
|
|
@@ -1188,16 +1678,27 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1188
1678
|
// Duplicate scene_id detection
|
|
1189
1679
|
const { project_id } = inferProjectAndUniverse(syncDir, file);
|
|
1190
1680
|
const key = `${meta.scene_id}::${project_id}`;
|
|
1191
|
-
if (seenSceneIds.has(key)) {
|
|
1681
|
+
if (meta.scene_id && seenSceneIds.has(key)) {
|
|
1192
1682
|
warnings.push(
|
|
1193
1683
|
`Duplicate scene_id "${meta.scene_id}" in project "${project_id}":\n` +
|
|
1194
1684
|
` ${path.relative(syncDir, seenSceneIds.get(key))}\n` +
|
|
1195
1685
|
` ${path.relative(syncDir, file)}`
|
|
1196
1686
|
);
|
|
1197
|
-
} else {
|
|
1687
|
+
} else if (meta.scene_id) {
|
|
1198
1688
|
seenSceneIds.set(key, file);
|
|
1199
1689
|
}
|
|
1200
|
-
|
|
1690
|
+
if (chapterStructure.role) {
|
|
1691
|
+
const roleKey = `${project_id}::${chapterStructure.role}`;
|
|
1692
|
+
const existingRoleFolder = roleFoldersByProject.get(roleKey);
|
|
1693
|
+
const currentRoleFolder = path.dirname(file);
|
|
1694
|
+
if (!existingRoleFolder) {
|
|
1695
|
+
roleFoldersByProject.set(roleKey, currentRoleFolder);
|
|
1696
|
+
} else if (existingRoleFolder !== currentRoleFolder) {
|
|
1697
|
+
warnings.push(
|
|
1698
|
+
`Chapter structure warning: multiple ${chapterStructure.role} folders in project "${project_id}": ${path.relative(syncDir, existingRoleFolder)} and ${path.relative(syncDir, currentRoleFolder)}.`
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1201
1702
|
|
|
1202
1703
|
if (mismatches.part || mismatches.chapter) {
|
|
1203
1704
|
const details = [];
|
|
@@ -1209,7 +1710,40 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1209
1710
|
}
|
|
1210
1711
|
|
|
1211
1712
|
const { data: _frontmatter, content: prose } = parseFile(file);
|
|
1212
|
-
const
|
|
1713
|
+
const result = indexSceneFile(db, syncDir, file, meta, prose);
|
|
1714
|
+
if (result.warning) {
|
|
1715
|
+
warnings.push(result.warning);
|
|
1716
|
+
}
|
|
1717
|
+
if (result.chapterId) {
|
|
1718
|
+
seenChapterKeys.add(`${result.chapterId}::${project_id}`);
|
|
1719
|
+
}
|
|
1720
|
+
if (chapterStructure.chapter && result.chapterId) {
|
|
1721
|
+
const chapterMapKey = `${project_id}::${chapterStructure.chapter.sort_index}`;
|
|
1722
|
+
const existingChapterFolder = chapterFoldersByProject.get(chapterMapKey);
|
|
1723
|
+
if (!existingChapterFolder) {
|
|
1724
|
+
chapterFoldersByProject.set(chapterMapKey, {
|
|
1725
|
+
title: chapterStructure.chapter.title,
|
|
1726
|
+
folder_key: chapterStructure.chapter.folder_key,
|
|
1727
|
+
});
|
|
1728
|
+
} else if (existingChapterFolder.folder_key !== chapterStructure.chapter.folder_key) {
|
|
1729
|
+
warnings.push(
|
|
1730
|
+
`Chapter structure warning: duplicate chapter order ${chapterStructure.chapter.sort_index} in project "${project_id}" for ${chapterStructure.chapter.folder_key} and ${existingChapterFolder.folder_key}.`
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (result.skippedAsEpigraph) {
|
|
1735
|
+
if (result.epigraphIndexed && result.epigraphId) {
|
|
1736
|
+
const epigraphId = result.epigraphId;
|
|
1737
|
+
seenEpigraphKeys.add(`${epigraphId}::${project_id}`);
|
|
1738
|
+
}
|
|
1739
|
+
if (result.epigraphIndexed) {
|
|
1740
|
+
epigraphsIndexed++;
|
|
1741
|
+
if (result.isStale) epigraphsStaleMarked++;
|
|
1742
|
+
}
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
const { isStale } = result;
|
|
1746
|
+
if (meta.scene_id) seenSceneKeys.add(key);
|
|
1213
1747
|
indexedSceneIds.add(meta.scene_id);
|
|
1214
1748
|
if (isStale) staleMarked++;
|
|
1215
1749
|
indexed++;
|
|
@@ -1221,6 +1755,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1221
1755
|
|
|
1222
1756
|
if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
|
|
1223
1757
|
pruneMissingScenes(db, seenSceneKeys, syncDir);
|
|
1758
|
+
pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
|
|
1759
|
+
pruneMissingChapters(db, seenChapterKeys, syncDir);
|
|
1224
1760
|
}
|
|
1225
1761
|
|
|
1226
1762
|
// --- Orphaned sidecar detection ---
|
|
@@ -1252,7 +1788,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1252
1788
|
|
|
1253
1789
|
if (!quiet) {
|
|
1254
1790
|
process.stderr.write(
|
|
1255
|
-
`[mcp-writing] Sync complete: ${indexed} scenes indexed, ${staleMarked} marked stale` +
|
|
1791
|
+
`[mcp-writing] Sync complete: ${indexed} scenes indexed, ${staleMarked} scenes marked stale` +
|
|
1792
|
+
(epigraphsIndexed ? `, ${epigraphsIndexed} epigraphs indexed, ${epigraphsStaleMarked} epigraphs marked stale` : "") +
|
|
1256
1793
|
(sidecarsMigrated ? `, ${sidecarsMigrated} sidecars auto-generated` : "") +
|
|
1257
1794
|
(skipped ? `, ${skipped} files skipped` : "") + "\n"
|
|
1258
1795
|
);
|
|
@@ -1262,5 +1799,14 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1262
1799
|
process.stderr.write(`[mcp-writing] WARNING: ${label}: ${count} file(s). First example: ${entry.examples[0]}\n`);
|
|
1263
1800
|
}
|
|
1264
1801
|
}
|
|
1265
|
-
return {
|
|
1802
|
+
return {
|
|
1803
|
+
indexed,
|
|
1804
|
+
staleMarked,
|
|
1805
|
+
epigraphsIndexed,
|
|
1806
|
+
epigraphsStaleMarked,
|
|
1807
|
+
skipped,
|
|
1808
|
+
sidecarsMigrated,
|
|
1809
|
+
warnings,
|
|
1810
|
+
warningSummary,
|
|
1811
|
+
};
|
|
1266
1812
|
}
|