@hanna84/mcp-writing 3.9.2 → 3.9.4

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 CHANGED
@@ -4,9 +4,23 @@ 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.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
8
+
9
+ - refactor(structure): centralize scene structure patches [`#200`](https://github.com/hannasdev/mcp-writing/pull/200)
10
+
11
+ #### [v3.9.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
12
+
13
+ > 17 May 2026
14
+
15
+ - refactor(structure): extract target architecture M1 boundaries [`#199`](https://github.com/hannasdev/mcp-writing/pull/199)
16
+ - Release 3.9.3 [`55c87ea`](https://github.com/hannasdev/mcp-writing/commit/55c87ea405841153765144d32c57803b6139a103)
17
+
7
18
  #### [v3.9.2](https://github.com/hannasdev/mcp-writing/compare/v3.9.1...v3.9.2)
8
19
 
20
+ > 17 May 2026
21
+
9
22
  - docs: clarify agent guidance [`#198`](https://github.com/hannasdev/mcp-writing/pull/198)
23
+ - Release 3.9.2 [`751e68b`](https://github.com/hannasdev/mcp-writing/commit/751e68bf4d4471df7f91a8bde6ad1393c655694e)
10
24
 
11
25
  #### [v3.9.1](https://github.com/hannasdev/mcp-writing/compare/v3.9.0...v3.9.1)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.9.2",
3
+ "version": "3.9.4",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "src/setup/",
50
50
  "src/scripts/",
51
51
  "src/styleguide/",
52
+ "src/structure/",
52
53
  "src/sync/",
53
54
  "src/tools/",
54
55
  "src/workflows/",
@@ -70,7 +71,7 @@
70
71
  "normalize:scene-characters": "node --experimental-sqlite src/scripts/normalize-scene-characters.mjs",
71
72
  "setup:openclaw-env": "sh src/scripts/setup-openclaw-env.sh",
72
73
  "release": "release-it",
73
- "lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/styleguide src/sync src/tools src/workflows src/world",
74
+ "lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/structure src/styleguide src/sync src/tools src/workflows src/world",
74
75
  "guard:legacy-root-imports": "node src/scripts/check-legacy-root-imports.mjs",
75
76
  "docs": "node src/scripts/generate-tool-docs.mjs",
76
77
  "lint:metadata": "node src/scripts/lint-metadata.mjs",
@@ -0,0 +1,284 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { slugifyChapterValue } from "./structure-inference.js";
4
+
5
+ function deriveChapterId(chapterId, sortIndex, title) {
6
+ return chapterId
7
+ ?? (sortIndex != null && title
8
+ ? `ch-${String(sortIndex).padStart(2, "0")}-${slugifyChapterValue(title) || `chapter-${sortIndex}`}`
9
+ : null);
10
+ }
11
+
12
+ export function resolveCanonicalChapterRecord(db, {
13
+ syncDir,
14
+ projectId,
15
+ derivedChapterId,
16
+ sortIndex,
17
+ title,
18
+ sourcePath,
19
+ allowSourcePathMatch = false,
20
+ }) {
21
+ if (!projectId || sortIndex == null || !title) return null;
22
+
23
+ const normalizedSourcePath = sourcePath ?? null;
24
+ const bySourcePath = allowSourcePathMatch && normalizedSourcePath
25
+ ? db.prepare(`
26
+ SELECT chapter_id, title, sort_index, logline, source_checksum, metadata_stale
27
+ FROM chapters
28
+ WHERE project_id = ? AND source_path = ?
29
+ `).get(projectId, normalizedSourcePath)
30
+ : null;
31
+
32
+ if (bySourcePath) {
33
+ return {
34
+ ...bySourcePath,
35
+ chapter_id: bySourcePath.chapter_id,
36
+ title,
37
+ sort_index: sortIndex,
38
+ source_path: normalizedSourcePath,
39
+ };
40
+ }
41
+
42
+ const byTitle = db.prepare(`
43
+ SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
44
+ FROM chapters
45
+ WHERE project_id = ? AND title = ?
46
+ ORDER BY chapter_id
47
+ `).all(projectId, title);
48
+
49
+ if (byTitle.length === 1) {
50
+ const existingTitleSourcePath = byTitle[0].source_path ?? null;
51
+ const existingTitleSourceExists = Boolean(
52
+ syncDir
53
+ && existingTitleSourcePath
54
+ && fs.existsSync(path.join(syncDir, existingTitleSourcePath))
55
+ );
56
+ const canReuseByTitle = allowSourcePathMatch || byTitle[0].sort_index === sortIndex;
57
+ if (canReuseByTitle && (!existingTitleSourceExists || existingTitleSourcePath === normalizedSourcePath)) {
58
+ return {
59
+ ...byTitle[0],
60
+ chapter_id: byTitle[0].chapter_id,
61
+ title,
62
+ sort_index: sortIndex,
63
+ source_path: normalizedSourcePath,
64
+ };
65
+ }
66
+ }
67
+
68
+ if (byTitle.length > 1) {
69
+ return null;
70
+ }
71
+
72
+ const bySortIndex = db.prepare(`
73
+ SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
74
+ FROM chapters
75
+ WHERE project_id = ? AND sort_index = ?
76
+ `).get(projectId, sortIndex);
77
+
78
+ if (bySortIndex) {
79
+ const existingSourceExists = Boolean(
80
+ syncDir
81
+ && bySortIndex.source_path
82
+ && fs.existsSync(path.join(syncDir, bySortIndex.source_path))
83
+ );
84
+ if (
85
+ normalizedSourcePath
86
+ && bySortIndex.source_path
87
+ && bySortIndex.source_path !== normalizedSourcePath
88
+ && existingSourceExists
89
+ ) {
90
+ return {
91
+ ambiguous: true,
92
+ existingSourcePath: bySortIndex.source_path,
93
+ conflictingSourcePath: normalizedSourcePath,
94
+ sort_index: sortIndex,
95
+ };
96
+ }
97
+ return {
98
+ ...bySortIndex,
99
+ chapter_id: bySortIndex.chapter_id,
100
+ title,
101
+ sort_index: sortIndex,
102
+ source_path: normalizedSourcePath,
103
+ };
104
+ }
105
+
106
+ return {
107
+ chapter_id: derivedChapterId,
108
+ title,
109
+ sort_index: sortIndex,
110
+ source_path: normalizedSourcePath,
111
+ logline: null,
112
+ source_checksum: null,
113
+ metadata_stale: 0,
114
+ };
115
+ }
116
+
117
+ export function parkConflictingChapterSortIndex(db, { projectId, chapterId, targetSortIndex }) {
118
+ if (!projectId || !chapterId || targetSortIndex == null) return;
119
+
120
+ const conflictingChapter = db.prepare(`
121
+ SELECT chapter_id, sort_index
122
+ FROM chapters
123
+ WHERE project_id = ? AND sort_index = ? AND chapter_id != ?
124
+ `).get(projectId, targetSortIndex, chapterId);
125
+
126
+ if (!conflictingChapter) return;
127
+
128
+ db.prepare(`
129
+ UPDATE chapters
130
+ SET sort_index = ?
131
+ WHERE project_id = ? AND chapter_id = ?
132
+ `).run(-1000000 - Number(conflictingChapter.sort_index), projectId, conflictingChapter.chapter_id);
133
+ }
134
+
135
+ export function upsertCanonicalChapterRecord(db, {
136
+ projectId,
137
+ chapterId,
138
+ sortIndex,
139
+ title,
140
+ sourcePath,
141
+ logline,
142
+ buildSourceChecksum,
143
+ updatedAt = new Date().toISOString(),
144
+ }) {
145
+ if (!projectId || !chapterId || sortIndex == null || !title) return null;
146
+
147
+ parkConflictingChapterSortIndex(db, {
148
+ projectId,
149
+ chapterId,
150
+ targetSortIndex: sortIndex,
151
+ });
152
+
153
+ const existingChapter = db.prepare(
154
+ `SELECT logline, source_checksum, metadata_stale FROM chapters WHERE chapter_id = ? AND project_id = ?`
155
+ ).get(chapterId, projectId);
156
+ const chapterLogline = logline ?? existingChapter?.logline ?? null;
157
+ const chapterChecksum = buildSourceChecksum({
158
+ sortIndex,
159
+ title,
160
+ logline: chapterLogline,
161
+ });
162
+
163
+ db.prepare(`
164
+ INSERT INTO chapters (
165
+ chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
166
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
167
+ ON CONFLICT (chapter_id, project_id) DO UPDATE SET
168
+ title = excluded.title,
169
+ sort_index = excluded.sort_index,
170
+ logline = excluded.logline,
171
+ source_path = excluded.source_path,
172
+ source_checksum = excluded.source_checksum,
173
+ metadata_stale = CASE
174
+ WHEN excluded.source_checksum != chapters.source_checksum THEN 1
175
+ ELSE chapters.metadata_stale
176
+ END,
177
+ updated_at = excluded.updated_at
178
+ `).run(
179
+ chapterId,
180
+ projectId,
181
+ title,
182
+ sortIndex,
183
+ chapterLogline,
184
+ sourcePath,
185
+ chapterChecksum,
186
+ existingChapter && existingChapter.source_checksum !== chapterChecksum ? 1 : 0,
187
+ updatedAt
188
+ );
189
+
190
+ return {
191
+ chapterId,
192
+ logline: chapterLogline,
193
+ sourceChecksum: chapterChecksum,
194
+ metadataStale: existingChapter && existingChapter.source_checksum !== chapterChecksum ? 1 : 0,
195
+ };
196
+ }
197
+
198
+ export function resolveIndexedChapterForFile(db, {
199
+ syncDir,
200
+ projectId,
201
+ filePath,
202
+ relativePath,
203
+ meta = {},
204
+ chapterStructure,
205
+ }) {
206
+ let chapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
207
+ let chapterSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
208
+ let chapterTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? (chapterSortIndex != null ? `Chapter ${chapterSortIndex}` : null);
209
+ const chapterSourcePath = chapterStructure.chapter?.folder_key ?? path.dirname(filePath);
210
+ const allowChapterSourcePathMatch = chapterStructure.chapter?.source_kind === "chapter_folder";
211
+ let chapterWarning = null;
212
+ let shouldUpsertChapter = false;
213
+ const explicitSceneChapterId = !chapterStructure.isEpigraph ? meta.chapter_id ?? null : null;
214
+ let explicitSceneCanonicalChapter = null;
215
+
216
+ if (explicitSceneChapterId && !chapterStructure.chapter) {
217
+ explicitSceneCanonicalChapter = db.prepare(`
218
+ SELECT chapter_id, sort_index, title
219
+ FROM chapters
220
+ WHERE chapter_id = ? AND project_id = ?
221
+ `).get(explicitSceneChapterId, projectId);
222
+ if (explicitSceneCanonicalChapter) {
223
+ chapterId = explicitSceneCanonicalChapter.chapter_id;
224
+ chapterSortIndex = explicitSceneCanonicalChapter.sort_index ?? null;
225
+ chapterTitle = explicitSceneCanonicalChapter.title ?? null;
226
+ } else {
227
+ chapterSortIndex = null;
228
+ chapterTitle = null;
229
+ }
230
+ }
231
+
232
+ const derivedChapterId = deriveChapterId(chapterId, chapterSortIndex, chapterTitle);
233
+
234
+ if (!explicitSceneCanonicalChapter && chapterSortIndex != null && chapterTitle) {
235
+ const canonicalChapter = resolveCanonicalChapterRecord(db, {
236
+ syncDir,
237
+ projectId,
238
+ derivedChapterId,
239
+ sortIndex: chapterSortIndex,
240
+ title: chapterTitle,
241
+ sourcePath: chapterSourcePath,
242
+ allowSourcePathMatch: allowChapterSourcePathMatch,
243
+ });
244
+ if (canonicalChapter?.ambiguous) {
245
+ chapterWarning = `Chapter structure warning: duplicate chapter order ${chapterSortIndex} in project "${projectId}" for ${canonicalChapter.existingSourcePath} and ${canonicalChapter.conflictingSourcePath}.`;
246
+ chapterId = null;
247
+ } else {
248
+ chapterId = canonicalChapter?.chapter_id ?? chapterId;
249
+ }
250
+ shouldUpsertChapter = Boolean(chapterId);
251
+ }
252
+
253
+ if (!chapterStructure.isEpigraph && chapterId && (chapterSortIndex == null || !chapterTitle)) {
254
+ const canonicalChapter = db.prepare(`
255
+ SELECT chapter_id, sort_index, title
256
+ FROM chapters
257
+ WHERE chapter_id = ? AND project_id = ?
258
+ `).get(chapterId, projectId);
259
+ if (!canonicalChapter) {
260
+ chapterWarning = `Scene references unknown chapter_id '${chapterId}': ${relativePath}`;
261
+ chapterId = null;
262
+ } else {
263
+ chapterSortIndex = chapterSortIndex ?? canonicalChapter.sort_index ?? null;
264
+ chapterTitle = chapterTitle ?? canonicalChapter.title ?? null;
265
+ }
266
+ }
267
+
268
+ return {
269
+ chapterId,
270
+ chapterSortIndex,
271
+ chapterTitle,
272
+ chapterSourcePath,
273
+ chapterWarning,
274
+ upsertChapter: shouldUpsertChapter
275
+ ? {
276
+ chapterId,
277
+ sortIndex: chapterSortIndex,
278
+ title: chapterTitle,
279
+ sourcePath: chapterSourcePath,
280
+ logline: meta.chapter_logline,
281
+ }
282
+ : null,
283
+ };
284
+ }
@@ -0,0 +1,127 @@
1
+ export function indexCanonicalEpigraph(db, {
2
+ projectId,
3
+ chapterId,
4
+ chapterSortIndex,
5
+ chapterStructure,
6
+ meta = {},
7
+ prose,
8
+ file,
9
+ relativePath,
10
+ chapterWarning = null,
11
+ buildProseChecksum,
12
+ buildDefaultEpigraphId,
13
+ updatedAt = new Date().toISOString(),
14
+ }) {
15
+ const canonicalChapter = chapterId
16
+ ? db.prepare(`SELECT chapter_id FROM chapters WHERE chapter_id = ? AND project_id = ?`).get(chapterId, projectId)
17
+ : null;
18
+ if (!chapterId || !canonicalChapter) {
19
+ const reason = chapterWarning
20
+ ?? (chapterId
21
+ ? `Epigraph references unknown chapter_id '${chapterId}': ${relativePath}`
22
+ : null)
23
+ ?? (chapterStructure.chapter && chapterSortIndex != null
24
+ ? `Ambiguous chapter linkage from duplicate chapter order ${chapterSortIndex}: ${relativePath}`
25
+ : `Epigraph requires explicit chapter linkage: ${relativePath}`);
26
+ return { isStale: 0, skippedAsEpigraph: true, warning: reason };
27
+ }
28
+
29
+ const defaultEpigraphId = buildDefaultEpigraphId({ projectId, chapterId });
30
+ const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
31
+ const epigraphChecksum = buildProseChecksum(prose);
32
+ const epigraphById = db.prepare(`
33
+ SELECT epigraph_id, chapter_id, prose_checksum
34
+ FROM epigraphs
35
+ WHERE epigraph_id = ? AND project_id = ?
36
+ `).get(requestedEpigraphId, projectId);
37
+ const epigraphByChapter = db.prepare(`
38
+ SELECT epigraph_id, chapter_id, prose_checksum
39
+ FROM epigraphs
40
+ WHERE chapter_id = ? AND project_id = ?
41
+ `).get(chapterId, projectId);
42
+
43
+ if (
44
+ epigraphById
45
+ && epigraphById.chapter_id !== chapterId
46
+ && (!epigraphByChapter || epigraphByChapter.epigraph_id !== epigraphById.epigraph_id)
47
+ ) {
48
+ return {
49
+ isStale: 0,
50
+ skippedAsEpigraph: true,
51
+ warning: `Epigraph identity conflict for chapter '${chapterId}': requested epigraph_id '${requestedEpigraphId}' already belongs to another chapter in project '${projectId}'.`,
52
+ };
53
+ }
54
+
55
+ const existingEpigraph = epigraphByChapter ?? epigraphById ?? null;
56
+ const epigraphId = meta.epigraph_id
57
+ ? requestedEpigraphId
58
+ : (epigraphByChapter?.epigraph_id ?? requestedEpigraphId);
59
+ const previousEpigraphId = existingEpigraph?.epigraph_id ?? epigraphId;
60
+ const existingChecksum = existingEpigraph?.prose_checksum ?? null;
61
+ const epigraphIsStale = existingChecksum !== null && existingChecksum !== epigraphChecksum ? 1 : 0;
62
+
63
+ if (existingEpigraph) {
64
+ db.prepare(`
65
+ UPDATE epigraphs
66
+ SET epigraph_id = ?,
67
+ chapter_id = ?,
68
+ body = ?,
69
+ file_path = ?,
70
+ prose_checksum = ?,
71
+ metadata_stale = CASE
72
+ WHEN ? != prose_checksum THEN 1
73
+ ELSE metadata_stale
74
+ END,
75
+ updated_at = ?
76
+ WHERE epigraph_id = ? AND project_id = ?
77
+ `).run(
78
+ epigraphId,
79
+ chapterId,
80
+ prose,
81
+ file,
82
+ epigraphChecksum,
83
+ epigraphChecksum,
84
+ updatedAt,
85
+ previousEpigraphId,
86
+ projectId
87
+ );
88
+ } else {
89
+ db.prepare(`
90
+ INSERT INTO epigraphs (
91
+ epigraph_id, project_id, chapter_id, body, file_path, prose_checksum, metadata_stale, updated_at
92
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
93
+ `).run(
94
+ epigraphId,
95
+ projectId,
96
+ chapterId,
97
+ prose,
98
+ file,
99
+ epigraphChecksum,
100
+ 0,
101
+ updatedAt
102
+ );
103
+ }
104
+
105
+ db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, projectId);
106
+ db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(previousEpigraphId, projectId);
107
+ if (previousEpigraphId !== epigraphId) {
108
+ db.prepare(`DELETE FROM epigraph_characters WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, projectId);
109
+ db.prepare(`DELETE FROM epigraph_tags WHERE epigraph_id = ? AND project_id = ?`).run(epigraphId, projectId);
110
+ }
111
+ for (const characterId of (meta.characters ?? [])) {
112
+ db.prepare(`INSERT OR IGNORE INTO epigraph_characters (epigraph_id, project_id, character_id) VALUES (?, ?, ?)`)
113
+ .run(epigraphId, projectId, characterId);
114
+ }
115
+ for (const tag of (meta.tags ?? [])) {
116
+ db.prepare(`INSERT OR IGNORE INTO epigraph_tags (epigraph_id, project_id, tag) VALUES (?, ?, ?)`)
117
+ .run(epigraphId, projectId, tag);
118
+ }
119
+
120
+ return {
121
+ isStale: epigraphIsStale,
122
+ skippedAsEpigraph: true,
123
+ epigraphIndexed: true,
124
+ chapterId,
125
+ epigraphId,
126
+ };
127
+ }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+
3
+ export function inferScenePositionFromPath(syncDir, filePath) {
4
+ const rel = path.relative(syncDir, filePath);
5
+ const parts = rel.split(path.sep);
6
+ let part = null;
7
+ let chapter = null;
8
+
9
+ for (const segment of parts) {
10
+ const partMatch = segment.match(/^part-(\d+)(?:-.+)?$/i);
11
+ if (partMatch) part = parseInt(partMatch[1], 10);
12
+
13
+ const chapterMatch = segment.match(/^chapter-(\d+)(?:-.+)?$/i);
14
+ if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
15
+ }
16
+
17
+ return { part, chapter };
18
+ }
19
+
20
+ function titleCaseFolderLabel(value) {
21
+ return String(value ?? "")
22
+ .replace(/[-_]+/g, " ")
23
+ .replace(/\s+/g, " ")
24
+ .trim()
25
+ .replace(/\b\w/g, char => char.toUpperCase());
26
+ }
27
+
28
+ export function slugifyChapterValue(value) {
29
+ return String(value ?? "")
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, "-")
32
+ .replace(/^-+|-+$/g, "");
33
+ }
34
+
35
+ function isExplicitChapterContainer(parts, index) {
36
+ const parent = parts[index - 1]?.toLowerCase() ?? null;
37
+ return parent === "draft" || parent === "scenes";
38
+ }
39
+
40
+ export function inferChapterStructureFromPath(syncDir, filePath, meta = {}) {
41
+ const rel = path.relative(syncDir, filePath);
42
+ const parts = rel.split(path.sep);
43
+ let role = null;
44
+ let chapterFolder = null;
45
+ let chapterSortIndex = null;
46
+ let chapterTitle = null;
47
+ let chapterFolderKey = null;
48
+
49
+ for (let index = 0; index < parts.length - 1; index += 1) {
50
+ const segment = parts[index];
51
+ const normalized = segment.toLowerCase();
52
+
53
+ if (normalized === "prologue" || normalized === "00-prologue") {
54
+ role = "prologue";
55
+ continue;
56
+ }
57
+ if (normalized === "epilogue" || normalized === "99-epilogue") {
58
+ role = "epilogue";
59
+ continue;
60
+ }
61
+
62
+ let match = segment.match(/^(\d+)-(.+)$/);
63
+ if (!match) {
64
+ match = segment.match(/^chapter-(\d+)(?:-(.+))?$/i);
65
+ }
66
+ if (!match || !isExplicitChapterContainer(parts, index)) continue;
67
+
68
+ chapterFolder = segment;
69
+ chapterSortIndex = Number.parseInt(match[1], 10);
70
+ chapterTitle = titleCaseFolderLabel(match[2] ?? `Chapter ${chapterSortIndex}`);
71
+ chapterFolderKey = parts.slice(0, index + 1).join(path.sep);
72
+ }
73
+
74
+ const baseName = path.basename(filePath, path.extname(filePath)).toLowerCase();
75
+ const explicitEpigraph = meta.kind === "epigraph"
76
+ || meta.type === "epigraph"
77
+ || typeof meta.epigraph_id === "string"
78
+ || baseName === "epigraph";
79
+
80
+ if (chapterSortIndex == null) {
81
+ const fallback = inferScenePositionFromPath(syncDir, filePath);
82
+ if (fallback.chapter != null) {
83
+ chapterSortIndex = fallback.chapter;
84
+ chapterTitle = titleCaseFolderLabel(meta.chapter_title ?? `Chapter ${fallback.chapter}`);
85
+ chapterFolderKey = chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep);
86
+ }
87
+ }
88
+
89
+ if (chapterSortIndex == null) {
90
+ return {
91
+ role,
92
+ isEpigraph: explicitEpigraph,
93
+ chapter: null,
94
+ };
95
+ }
96
+
97
+ const chapterSlug = slugifyChapterValue(chapterTitle) || `chapter-${chapterSortIndex}`;
98
+ return {
99
+ role,
100
+ isEpigraph: explicitEpigraph,
101
+ chapter: {
102
+ chapter_id: `ch-${String(chapterSortIndex).padStart(2, "0")}-${chapterSlug}`,
103
+ sort_index: chapterSortIndex,
104
+ title: chapterTitle,
105
+ folder_name: chapterFolder ?? `chapter-${chapterSortIndex}`,
106
+ folder_key: chapterFolderKey ?? parts.slice(0, Math.max(0, parts.length - 1)).join(path.sep),
107
+ source_kind: chapterFolder ? "chapter_folder" : "legacy_layout",
108
+ },
109
+ };
110
+ }
111
+
112
+ export function buildSceneStructurePatch(syncDir, filePath, meta = {}, { chapter } = {}) {
113
+ const derived = inferScenePositionFromPath(syncDir, filePath);
114
+ const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
115
+ const patch = {};
116
+
117
+ if (derived.part !== null) patch.part = derived.part;
118
+ if (derived.chapter !== null) patch.chapter = derived.chapter;
119
+ if (chapterStructure.chapter?.chapter_id) {
120
+ patch.chapter_id = chapterStructure.chapter.chapter_id;
121
+ patch.chapter = chapterStructure.chapter.sort_index;
122
+ patch.chapter_title = chapterStructure.chapter.title;
123
+ }
124
+ if (chapterStructure.role) {
125
+ patch.scene_role = chapterStructure.role;
126
+ }
127
+ if (chapter !== undefined) {
128
+ if (chapter === null) {
129
+ patch.chapter_id = null;
130
+ patch.chapter = null;
131
+ patch.chapter_title = null;
132
+ } else {
133
+ patch.chapter_id = chapter.chapter_id;
134
+ patch.chapter = chapter.sort_index;
135
+ patch.chapter_title = chapter.title ?? null;
136
+ }
137
+ }
138
+
139
+ return {
140
+ patch,
141
+ derived,
142
+ chapterStructure,
143
+ mismatches: {
144
+ part: derived.part !== null && meta.part != null && meta.part !== derived.part,
145
+ chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
146
+ },
147
+ };
148
+ }
149
+
150
+ export function applySceneStructurePatch(syncDir, filePath, meta = {}, options = {}) {
151
+ const plan = buildSceneStructurePatch(syncDir, filePath, meta, options);
152
+ return {
153
+ ...plan,
154
+ meta: { ...meta, ...plan.patch },
155
+ };
156
+ }
157
+
158
+ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
159
+ return applySceneStructurePatch(syncDir, filePath, meta);
160
+ }
package/src/sync/sync.js CHANGED
@@ -2,8 +2,29 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
+ import {
6
+ applySceneStructurePatch,
7
+ buildSceneStructurePatch,
8
+ inferChapterStructureFromPath,
9
+ inferScenePositionFromPath,
10
+ normalizeSceneMetaForPath,
11
+ slugifyChapterValue,
12
+ } from "../structure/structure-inference.js";
13
+ import {
14
+ resolveIndexedChapterForFile,
15
+ upsertCanonicalChapterRecord,
16
+ } from "../structure/chapter-indexing.js";
17
+ import { indexCanonicalEpigraph } from "../structure/epigraph-indexing.js";
5
18
  const { load: parseYaml, dump: stringifyYaml } = yaml;
6
19
 
20
+ export {
21
+ applySceneStructurePatch,
22
+ buildSceneStructurePatch,
23
+ inferChapterStructureFromPath,
24
+ inferScenePositionFromPath,
25
+ normalizeSceneMetaForPath,
26
+ };
27
+
7
28
  // ---------------------------------------------------------------------------
8
29
  // Pure utilities (no DB dependency — easy to unit test)
9
30
  // ---------------------------------------------------------------------------
@@ -62,142 +83,6 @@ export function sidecarPath(filePath) {
62
83
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
63
84
  }
64
85
 
65
- export function inferScenePositionFromPath(syncDir, filePath) {
66
- const rel = path.relative(syncDir, filePath);
67
- const parts = rel.split(path.sep);
68
- let part = null;
69
- let chapter = null;
70
-
71
- for (const segment of parts) {
72
- const partMatch = segment.match(/^part-(\d+)(?:-.+)?$/i);
73
- if (partMatch) part = parseInt(partMatch[1], 10);
74
-
75
- const chapterMatch = segment.match(/^chapter-(\d+)(?:-.+)?$/i);
76
- if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
77
- }
78
-
79
- return { part, chapter };
80
- }
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
-
174
- export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
175
- const derived = inferScenePositionFromPath(syncDir, filePath);
176
- const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
177
- const normalized = { ...meta };
178
-
179
- if (derived.part !== null) normalized.part = derived.part;
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
- }
189
-
190
- return {
191
- meta: normalized,
192
- derived,
193
- chapterStructure,
194
- mismatches: {
195
- part: derived.part !== null && meta.part != null && meta.part !== derived.part,
196
- chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
197
- },
198
- };
199
- }
200
-
201
86
  // Structural directory names that are never project slugs under projects/<id>/.
202
87
  const PROJECT_STRUCTURAL_DIRS = new Set(["world", "scenes", "misc", "fragments", "feedback", "draft"]);
203
88
 
@@ -1061,129 +946,6 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
1061
946
  }
1062
947
  }
1063
948
 
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
-
1187
949
  export function indexSceneFile(db, syncDir, file, meta, prose) {
1188
950
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
1189
951
  const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
@@ -1203,223 +965,45 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1203
965
  project_id, universe_id ?? null, project_id
1204
966
  );
1205
967
 
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,
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
+ const {
978
+ chapterId,
979
+ chapterSortIndex,
980
+ chapterTitle,
981
+ chapterWarning,
982
+ upsertChapter,
983
+ } = chapterResolution;
984
+
985
+ if (upsertChapter) {
986
+ upsertCanonicalChapterRecord(db, {
1241
987
  projectId: project_id,
1242
- derivedChapterId,
1243
- sortIndex: chapterSortIndex,
1244
- title: chapterTitle,
1245
- sourcePath: chapterSourcePath,
1246
- allowSourcePathMatch: allowChapterSourcePathMatch,
988
+ ...upsertChapter,
989
+ buildSourceChecksum: ({ sortIndex, title, logline }) => checksumProse(`${sortIndex}:${title}:${logline ?? ""}`),
1247
990
  });
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
991
  }
1308
992
 
1309
993
  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,
994
+ return indexCanonicalEpigraph(db, {
995
+ projectId: project_id,
1420
996
  chapterId,
1421
- epigraphId,
1422
- };
997
+ chapterSortIndex,
998
+ chapterStructure,
999
+ meta,
1000
+ prose,
1001
+ file,
1002
+ relativePath,
1003
+ chapterWarning,
1004
+ buildProseChecksum: checksumProse,
1005
+ buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
1006
+ });
1423
1007
  }
1424
1008
 
1425
1009
  const newChecksum = checksumProse(prose);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
- import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
4
+ import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
6
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
7
  import {
@@ -545,6 +545,7 @@ export function registerMetadataTools(s, {
545
545
  try {
546
546
  const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
547
547
  const nextFields = { ...fields };
548
+ let chapter = undefined;
548
549
 
549
550
  if (fields.chapter_id === null && fields.chapter !== undefined) {
550
551
  return errorResponse(
@@ -559,8 +560,20 @@ export function registerMetadataTools(s, {
559
560
  }
560
561
 
561
562
  if (fields.chapter_id === null) {
562
- nextFields.chapter = null;
563
- nextFields.chapter_title = null;
563
+ const structurePlan = applySceneStructurePatch(SYNC_DIR, scene.file_path, meta);
564
+ if (structurePlan.derived.chapter !== null || structurePlan.chapterStructure.chapter?.chapter_id) {
565
+ return errorResponse(
566
+ "VALIDATION_ERROR",
567
+ "chapter_id cannot be cleared for a scene whose file path implies a chapter.",
568
+ {
569
+ project_id,
570
+ scene_id,
571
+ chapter_id: null,
572
+ path_chapter: structurePlan.chapterStructure.chapter?.chapter_id ?? structurePlan.derived.chapter,
573
+ }
574
+ );
575
+ }
576
+ chapter = null;
564
577
  } else if (fields.chapter_id !== undefined || fields.chapter !== undefined) {
565
578
  const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
566
579
  projectId: project_id,
@@ -594,12 +607,10 @@ export function registerMetadataTools(s, {
594
607
  );
595
608
  }
596
609
 
597
- nextFields.chapter_id = resolvedChapter.chapter_id;
598
- nextFields.chapter = resolvedChapter.sort_index;
599
- nextFields.chapter_title = resolvedChapter.title ?? null;
610
+ chapter = resolvedChapter;
600
611
  }
601
612
 
602
- const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }).meta;
613
+ const updated = applySceneStructurePatch(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }, { chapter }).meta;
603
614
  writeMeta(scene.file_path, updated);
604
615
 
605
616
  const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));