@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 +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 +160 -0
- package/src/sync/sync.js +54 -470
- package/src/tools/metadata.js +18 -7
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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,
|
|
994
|
+
return indexCanonicalEpigraph(db, {
|
|
995
|
+
projectId: project_id,
|
|
1420
996
|
chapterId,
|
|
1421
|
-
|
|
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);
|
package/src/tools/metadata.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
598
|
-
nextFields.chapter = resolvedChapter.sort_index;
|
|
599
|
-
nextFields.chapter_title = resolvedChapter.title ?? null;
|
|
610
|
+
chapter = resolvedChapter;
|
|
600
611
|
}
|
|
601
612
|
|
|
602
|
-
const updated =
|
|
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"));
|