@hanna84/mcp-writing 3.9.1 → 3.9.3

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.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
8
+
9
+ - refactor(structure): extract target architecture M1 boundaries [`#199`](https://github.com/hannasdev/mcp-writing/pull/199)
10
+
11
+ #### [v3.9.2](https://github.com/hannasdev/mcp-writing/compare/v3.9.1...v3.9.2)
12
+
13
+ > 17 May 2026
14
+
15
+ - docs: clarify agent guidance [`#198`](https://github.com/hannasdev/mcp-writing/pull/198)
16
+ - Release 3.9.2 [`751e68b`](https://github.com/hannasdev/mcp-writing/commit/751e68bf4d4471df7f91a8bde6ad1393c655694e)
17
+
7
18
  #### [v3.9.1](https://github.com/hannasdev/mcp-writing/compare/v3.9.0...v3.9.1)
8
19
 
20
+ > 17 May 2026
21
+
9
22
  - docs: add target architecture migration snapshot [`#197`](https://github.com/hannasdev/mcp-writing/pull/197)
23
+ - Release 3.9.1 [`8dc317a`](https://github.com/hannasdev/mcp-writing/commit/8dc317a15265ad0161062ee976456080f546ed0b)
10
24
 
11
25
  #### [v3.9.0](https://github.com/hannasdev/mcp-writing/compare/v3.8.2...v3.9.0)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
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,137 @@
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 normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
113
+ const derived = inferScenePositionFromPath(syncDir, filePath);
114
+ const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
115
+ const normalized = { ...meta };
116
+
117
+ if (derived.part !== null) normalized.part = derived.part;
118
+ if (derived.chapter !== null) normalized.chapter = derived.chapter;
119
+ if (chapterStructure.chapter?.chapter_id) {
120
+ normalized.chapter_id = chapterStructure.chapter.chapter_id;
121
+ normalized.chapter = chapterStructure.chapter.sort_index;
122
+ normalized.chapter_title = chapterStructure.chapter.title;
123
+ }
124
+ if (chapterStructure.role) {
125
+ normalized.scene_role = chapterStructure.role;
126
+ }
127
+
128
+ return {
129
+ meta: normalized,
130
+ derived,
131
+ chapterStructure,
132
+ mismatches: {
133
+ part: derived.part !== null && meta.part != null && meta.part !== derived.part,
134
+ chapter: derived.chapter !== null && meta.chapter != null && meta.chapter !== derived.chapter,
135
+ },
136
+ };
137
+ }
package/src/sync/sync.js CHANGED
@@ -2,8 +2,25 @@ 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
+ inferChapterStructureFromPath,
7
+ inferScenePositionFromPath,
8
+ normalizeSceneMetaForPath,
9
+ slugifyChapterValue,
10
+ } from "../structure/structure-inference.js";
11
+ import {
12
+ resolveIndexedChapterForFile,
13
+ upsertCanonicalChapterRecord,
14
+ } from "../structure/chapter-indexing.js";
15
+ import { indexCanonicalEpigraph } from "../structure/epigraph-indexing.js";
5
16
  const { load: parseYaml, dump: stringifyYaml } = yaml;
6
17
 
18
+ export {
19
+ inferChapterStructureFromPath,
20
+ inferScenePositionFromPath,
21
+ normalizeSceneMetaForPath,
22
+ };
23
+
7
24
  // ---------------------------------------------------------------------------
8
25
  // Pure utilities (no DB dependency — easy to unit test)
9
26
  // ---------------------------------------------------------------------------
@@ -62,142 +79,6 @@ export function sidecarPath(filePath) {
62
79
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
63
80
  }
64
81
 
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
82
  // Structural directory names that are never project slugs under projects/<id>/.
202
83
  const PROJECT_STRUCTURAL_DIRS = new Set(["world", "scenes", "misc", "fragments", "feedback", "draft"]);
203
84
 
@@ -1061,129 +942,6 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
1061
942
  }
1062
943
  }
1063
944
 
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
945
  export function indexSceneFile(db, syncDir, file, meta, prose) {
1188
946
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
1189
947
  const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
@@ -1203,223 +961,45 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
1203
961
  project_id, universe_id ?? null, project_id
1204
962
  );
1205
963
 
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,
964
+ const relativePath = path.relative(syncDir, file);
965
+ const chapterResolution = resolveIndexedChapterForFile(db, {
966
+ syncDir,
967
+ projectId: project_id,
968
+ filePath: file,
969
+ relativePath,
970
+ meta,
971
+ chapterStructure,
972
+ });
973
+ const {
974
+ chapterId,
975
+ chapterSortIndex,
976
+ chapterTitle,
977
+ chapterWarning,
978
+ upsertChapter,
979
+ } = chapterResolution;
980
+
981
+ if (upsertChapter) {
982
+ upsertCanonicalChapterRecord(db, {
1241
983
  projectId: project_id,
1242
- derivedChapterId,
1243
- sortIndex: chapterSortIndex,
1244
- title: chapterTitle,
1245
- sourcePath: chapterSourcePath,
1246
- allowSourcePathMatch: allowChapterSourcePathMatch,
984
+ ...upsertChapter,
985
+ buildSourceChecksum: ({ sortIndex, title, logline }) => checksumProse(`${sortIndex}:${title}:${logline ?? ""}`),
1247
986
  });
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
987
  }
1308
988
 
1309
989
  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,
990
+ return indexCanonicalEpigraph(db, {
991
+ projectId: project_id,
1420
992
  chapterId,
1421
- epigraphId,
1422
- };
993
+ chapterSortIndex,
994
+ chapterStructure,
995
+ meta,
996
+ prose,
997
+ file,
998
+ relativePath,
999
+ chapterWarning,
1000
+ buildProseChecksum: checksumProse,
1001
+ buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
1002
+ });
1423
1003
  }
1424
1004
 
1425
1005
  const newChecksum = checksumProse(prose);