@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 +14 -0
- package/package.json +3 -2
- package/src/structure/chapter-indexing.js +284 -0
- package/src/structure/epigraph-indexing.js +127 -0
- package/src/structure/structure-inference.js +137 -0
- package/src/sync/sync.js +50 -470
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.
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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);
|