@hanna84/mcp-writing 1.11.1 → 1.11.2
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 +10 -0
- package/db.js +6 -0
- package/importer.js +27 -6
- package/index.js +20 -12
- package/metadata-lint.js +1 -0
- package/package.json +1 -1
- package/scripts/async-job-runner.mjs +5 -0
- package/scripts/generate-tool-docs.mjs +2 -2
- package/scripts/merge-scrivx.js +4 -1
- package/scrivener-direct.js +292 -25
- package/sync.js +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,21 @@ 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
|
+
#### [v1.11.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.11.1...v1.11.2)
|
|
9
|
+
|
|
10
|
+
- Scrivener beta: opt-in chapter organization + raw keyword tags [`#60`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/60)
|
|
12
|
+
|
|
7
13
|
#### [v1.11.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
14
|
/compare/v1.11.0...v1.11.1)
|
|
9
15
|
|
|
16
|
+
> 22 April 2026
|
|
17
|
+
|
|
10
18
|
- Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
|
|
11
19
|
/pull/59)
|
|
20
|
+
- Release 1.11.1 [`85bb5c6`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/85bb5c6c3643256eb6866c7b75cc3e3f87ce1412)
|
|
12
22
|
|
|
13
23
|
#### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
24
|
/compare/v1.10.0...v1.11.0)
|
package/db.js
CHANGED
|
@@ -18,6 +18,7 @@ export const SCHEMA = `
|
|
|
18
18
|
title TEXT,
|
|
19
19
|
part INTEGER,
|
|
20
20
|
chapter INTEGER,
|
|
21
|
+
chapter_title TEXT,
|
|
21
22
|
pov TEXT,
|
|
22
23
|
logline TEXT,
|
|
23
24
|
scene_change TEXT,
|
|
@@ -124,6 +125,11 @@ export function openDb(dbPath) {
|
|
|
124
125
|
const db = new DatabaseSync(dbPath);
|
|
125
126
|
db.exec(SCHEMA);
|
|
126
127
|
|
|
128
|
+
const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
|
|
129
|
+
if (!sceneColumns.some(column => column.name === "chapter_title")) {
|
|
130
|
+
db.exec(`ALTER TABLE scenes ADD COLUMN chapter_title TEXT;`);
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
// Rebuild legacy FTS table if it predates keyword indexing.
|
|
128
134
|
// Preserve existing indexed rows so metadata search remains available
|
|
129
135
|
// even before the next sync pass repopulates from source files.
|
package/importer.js
CHANGED
|
@@ -107,20 +107,35 @@ function loadYamlFile(filePath) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
function walkSidecarFiles(dir, fileList = []) {
|
|
111
|
+
if (!fs.existsSync(dir)) return fileList;
|
|
112
|
+
|
|
113
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
114
|
+
const full = path.join(dir, entry.name);
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
// Skip nested mirror trees under scenes/ (e.g. scenes/projects/... or scenes/universes/...)
|
|
117
|
+
// to avoid accidental reconciliation against duplicated sidecars.
|
|
118
|
+
if (/^(projects|universes)$/i.test(entry.name)) continue;
|
|
119
|
+
walkSidecarFiles(full, fileList);
|
|
120
|
+
} else if (entry.isFile() && entry.name.endsWith(".meta.yaml")) {
|
|
121
|
+
fileList.push(full);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return fileList;
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
function buildExistingSceneIndex(dir) {
|
|
111
129
|
const byBinderId = new Map();
|
|
112
130
|
if (!fs.existsSync(dir)) return byBinderId;
|
|
113
131
|
|
|
114
|
-
for (const
|
|
115
|
-
if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
|
|
116
|
-
|
|
117
|
-
const sidecarPath = path.join(dir, entry.name);
|
|
132
|
+
for (const sidecarPath of walkSidecarFiles(dir)) {
|
|
118
133
|
const proseCandidates = [
|
|
119
134
|
sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
|
|
120
135
|
sidecarPath.replace(/\.meta\.yaml$/, ".md"),
|
|
121
136
|
];
|
|
122
137
|
const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
|
|
123
|
-
const proseName = prosePath ? path.basename(prosePath) :
|
|
138
|
+
const proseName = prosePath ? path.basename(prosePath) : path.basename(sidecarPath).replace(/\.meta\.yaml$/, ".txt");
|
|
124
139
|
const parsedName = parseFilename(proseName);
|
|
125
140
|
const meta = loadYamlFile(sidecarPath);
|
|
126
141
|
const binderId = meta.external_source === "scrivener" && meta.external_id
|
|
@@ -342,7 +357,12 @@ export function importScrivenerSync({
|
|
|
342
357
|
const title = cleanTitle(rawTitle);
|
|
343
358
|
const existingScene = existingScenes.get(String(binderId)) ?? null;
|
|
344
359
|
const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
|
|
345
|
-
const
|
|
360
|
+
const targetDir = existingScene?.prosePath
|
|
361
|
+
? path.dirname(existingScene.prosePath)
|
|
362
|
+
: existingScene?.sidecarPath
|
|
363
|
+
? path.dirname(existingScene.sidecarPath)
|
|
364
|
+
: scenesDir;
|
|
365
|
+
const destFile = path.join(targetDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
|
|
346
366
|
const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
|
|
347
367
|
|
|
348
368
|
const meta = {
|
|
@@ -366,6 +386,7 @@ export function importScrivenerSync({
|
|
|
366
386
|
}
|
|
367
387
|
logger(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
|
|
368
388
|
} else {
|
|
389
|
+
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
|
369
390
|
fs.copyFileSync(file, destFile);
|
|
370
391
|
fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
|
|
371
392
|
|
package/index.js
CHANGED
|
@@ -775,7 +775,7 @@ function createMcpServer() {
|
|
|
775
775
|
// ---- import_scrivener_sync ----------------------------------------------
|
|
776
776
|
s.tool(
|
|
777
777
|
"import_scrivener_sync",
|
|
778
|
-
"Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID.
|
|
778
|
+
"[STABLE] Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. This is the recommended default path for first-time setup before sync().",
|
|
779
779
|
{
|
|
780
780
|
source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
|
|
781
781
|
project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
|
|
@@ -897,15 +897,16 @@ function createMcpServer() {
|
|
|
897
897
|
// ---- merge_scrivener_project_beta --------------------------------------
|
|
898
898
|
s.tool(
|
|
899
899
|
"merge_scrivener_project_beta",
|
|
900
|
-
"[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in
|
|
900
|
+
"[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in, requires sidecars to already exist (for example, from import_scrivener_sync), and may be sensitive to Scrivener internal format changes.",
|
|
901
901
|
{
|
|
902
902
|
source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
|
|
903
903
|
project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
|
|
904
904
|
scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id. Use this for non-standard sync layouts."),
|
|
905
905
|
dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
|
|
906
906
|
auto_sync: z.boolean().optional().describe("If true (default), runs sync() after a non-dry-run merge."),
|
|
907
|
+
organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies (e.g., chapter-7-harbor/). Chapter metadata is always extracted to sidecars regardless of this flag."),
|
|
907
908
|
},
|
|
908
|
-
async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true }) => {
|
|
909
|
+
async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true, organize_by_chapters = false }) => {
|
|
909
910
|
if (project_id !== undefined) {
|
|
910
911
|
const projectIdCheck = validateProjectId(project_id);
|
|
911
912
|
if (!projectIdCheck.ok) {
|
|
@@ -943,6 +944,7 @@ function createMcpServer() {
|
|
|
943
944
|
projectId: project_id,
|
|
944
945
|
scenesDir: normalizedScenesDir,
|
|
945
946
|
dryRun: Boolean(dry_run),
|
|
947
|
+
organizeByChapters: Boolean(organize_by_chapters),
|
|
946
948
|
});
|
|
947
949
|
} catch (error) {
|
|
948
950
|
return errorResponse(
|
|
@@ -973,10 +975,14 @@ function createMcpServer() {
|
|
|
973
975
|
dry_run: mergeResult.dryRun,
|
|
974
976
|
sidecar_files: mergeResult.sidecarFiles,
|
|
975
977
|
updated: mergeResult.updated,
|
|
978
|
+
relocated: mergeResult.relocated,
|
|
976
979
|
unchanged: mergeResult.unchanged,
|
|
977
980
|
no_data: mergeResult.noData,
|
|
978
981
|
field_add_counts: mergeResult.fieldAddCounts,
|
|
979
982
|
preview_changes: mergeResult.previewChanges,
|
|
983
|
+
warnings: mergeResult.warnings,
|
|
984
|
+
warnings_truncated: mergeResult.warningsTruncated,
|
|
985
|
+
warning_summary: mergeResult.warningSummary,
|
|
980
986
|
stats: {
|
|
981
987
|
sync_map_entries: mergeResult.stats.syncMapEntries,
|
|
982
988
|
keyword_map_entries: mergeResult.stats.keywordMapEntries,
|
|
@@ -1009,7 +1015,7 @@ function createMcpServer() {
|
|
|
1009
1015
|
// ---- async import/merge jobs --------------------------------------------
|
|
1010
1016
|
s.tool(
|
|
1011
1017
|
"import_scrivener_sync_async",
|
|
1012
|
-
"Start an asynchronous Scrivener External Folder Sync import job. Returns immediately with a job_id to poll via get_async_job_status.",
|
|
1018
|
+
"[STABLE] Start an asynchronous Scrivener External Folder Sync import job. This is the recommended default import path when the sync tree is large. Returns immediately with a job_id to poll via get_async_job_status.",
|
|
1013
1019
|
{
|
|
1014
1020
|
source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
|
|
1015
1021
|
project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
@@ -1089,15 +1095,16 @@ function createMcpServer() {
|
|
|
1089
1095
|
|
|
1090
1096
|
s.tool(
|
|
1091
1097
|
"merge_scrivener_project_beta_async",
|
|
1092
|
-
"Start an asynchronous
|
|
1098
|
+
"[BETA] Start an asynchronous Scrivener metadata merge job from a `.scriv` project into existing scene sidecars. Use this only after the stable import path has created sidecars. Returns immediately with a job_id to poll via get_async_job_status.",
|
|
1093
1099
|
{
|
|
1094
1100
|
source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
|
|
1095
1101
|
project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1096
1102
|
scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
|
|
1097
1103
|
dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
|
|
1098
1104
|
auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async merge finishes."),
|
|
1105
|
+
organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies. Chapter metadata is always extracted to sidecars."),
|
|
1099
1106
|
},
|
|
1100
|
-
async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false }) => {
|
|
1107
|
+
async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false, organize_by_chapters = false }) => {
|
|
1101
1108
|
if (project_id !== undefined) {
|
|
1102
1109
|
const projectIdCheck = validateProjectId(project_id);
|
|
1103
1110
|
if (!projectIdCheck.ok) {
|
|
@@ -1136,6 +1143,7 @@ function createMcpServer() {
|
|
|
1136
1143
|
project_id,
|
|
1137
1144
|
scenes_dir: normalizedScenesDir,
|
|
1138
1145
|
dry_run: Boolean(dry_run),
|
|
1146
|
+
organize_by_chapters: Boolean(organize_by_chapters),
|
|
1139
1147
|
},
|
|
1140
1148
|
context: {
|
|
1141
1149
|
sync_dir: SYNC_DIR,
|
|
@@ -1423,7 +1431,7 @@ function createMcpServer() {
|
|
|
1423
1431
|
},
|
|
1424
1432
|
async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
|
|
1425
1433
|
let query = `
|
|
1426
|
-
SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.pov,
|
|
1434
|
+
SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
|
|
1427
1435
|
s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
1428
1436
|
s.save_the_cat_beat, s.timeline_position, s.story_time,
|
|
1429
1437
|
s.word_count, s.metadata_stale
|
|
@@ -1581,7 +1589,7 @@ function createMcpServer() {
|
|
|
1581
1589
|
},
|
|
1582
1590
|
async ({ character_id, project_id, page, page_size }) => {
|
|
1583
1591
|
let query = `
|
|
1584
|
-
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
|
|
1592
|
+
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
|
|
1585
1593
|
s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
1586
1594
|
s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
|
|
1587
1595
|
FROM scenes s
|
|
@@ -1859,7 +1867,7 @@ function createMcpServer() {
|
|
|
1859
1867
|
|
|
1860
1868
|
if (!shouldPaginate) {
|
|
1861
1869
|
const rows = db.prepare(`
|
|
1862
|
-
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
|
|
1870
|
+
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
|
|
1863
1871
|
FROM scenes_fts f
|
|
1864
1872
|
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
1865
1873
|
WHERE scenes_fts MATCH ?
|
|
@@ -1876,7 +1884,7 @@ function createMcpServer() {
|
|
|
1876
1884
|
const offset = (normalizedPage - 1) * safePageSize;
|
|
1877
1885
|
|
|
1878
1886
|
const rows = db.prepare(`
|
|
1879
|
-
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
|
|
1887
|
+
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
|
|
1880
1888
|
FROM scenes_fts f
|
|
1881
1889
|
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
1882
1890
|
WHERE scenes_fts MATCH ?
|
|
@@ -1942,7 +1950,7 @@ function createMcpServer() {
|
|
|
1942
1950
|
}
|
|
1943
1951
|
|
|
1944
1952
|
const rows = db.prepare(`
|
|
1945
|
-
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
|
|
1953
|
+
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
|
|
1946
1954
|
st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
|
|
1947
1955
|
FROM scenes s
|
|
1948
1956
|
JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
|
|
@@ -2283,7 +2291,7 @@ function createMcpServer() {
|
|
|
2283
2291
|
let query = `
|
|
2284
2292
|
SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
|
|
2285
2293
|
r.scene_id, r.note,
|
|
2286
|
-
s.part, s.chapter, s.timeline_position, s.title AS scene_title
|
|
2294
|
+
s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
|
|
2287
2295
|
FROM character_relationships r
|
|
2288
2296
|
LEFT JOIN scenes s ON s.scene_id = r.scene_id
|
|
2289
2297
|
WHERE r.from_character = ? AND r.to_character = ?
|
package/metadata-lint.js
CHANGED
|
@@ -32,6 +32,7 @@ const sceneSchema = z.object({
|
|
|
32
32
|
title: z.string().min(1).optional(),
|
|
33
33
|
part: z.number().int().positive().optional(),
|
|
34
34
|
chapter: z.number().int().positive().optional(),
|
|
35
|
+
chapter_title: z.string().min(1).optional(),
|
|
35
36
|
pov: z.string().min(1).optional(),
|
|
36
37
|
logline: z.string().min(1).optional(),
|
|
37
38
|
save_the_cat_beat: z.string().min(1).optional(),
|
package/package.json
CHANGED
|
@@ -61,10 +61,14 @@ function normalizeMergeResult(mergeResult) {
|
|
|
61
61
|
dry_run: mergeResult.dryRun,
|
|
62
62
|
sidecar_files: mergeResult.sidecarFiles,
|
|
63
63
|
updated: mergeResult.updated,
|
|
64
|
+
relocated: mergeResult.relocated,
|
|
64
65
|
unchanged: mergeResult.unchanged,
|
|
65
66
|
no_data: mergeResult.noData,
|
|
66
67
|
field_add_counts: mergeResult.fieldAddCounts,
|
|
67
68
|
preview_changes: mergeResult.previewChanges,
|
|
69
|
+
warnings: mergeResult.warnings,
|
|
70
|
+
warnings_truncated: mergeResult.warningsTruncated,
|
|
71
|
+
warning_summary: mergeResult.warningSummary,
|
|
68
72
|
stats: {
|
|
69
73
|
sync_map_entries: mergeResult.stats.syncMapEntries,
|
|
70
74
|
keyword_map_entries: mergeResult.stats.keywordMapEntries,
|
|
@@ -121,6 +125,7 @@ async function main() {
|
|
|
121
125
|
projectId: request.args?.project_id,
|
|
122
126
|
scenesDir: request.args?.scenes_dir,
|
|
123
127
|
dryRun: Boolean(request.args?.dry_run),
|
|
128
|
+
organizeByChapters: Boolean(request.args?.organize_by_chapters),
|
|
124
129
|
});
|
|
125
130
|
writeResult(resultPath, normalizeMergeResult(result));
|
|
126
131
|
return;
|
|
@@ -425,9 +425,9 @@ function generateMarkdown(tools) {
|
|
|
425
425
|
lines.push('_No parameters._');
|
|
426
426
|
} else {
|
|
427
427
|
lines.push('| Parameter | Type | Required | Description |');
|
|
428
|
-
lines.push('
|
|
428
|
+
lines.push('| --- | --- | :---: | --- |');
|
|
429
429
|
for (const p of tool.params) {
|
|
430
|
-
const req = p.optional ? '' : '
|
|
430
|
+
const req = p.optional ? 'No' : 'Yes';
|
|
431
431
|
const desc = p.description.replace(/\|/g, '\\|'); // escape pipes
|
|
432
432
|
lines.push(`| \`${p.name}\` | \`${p.type}\` | ${req} | ${desc} |`);
|
|
433
433
|
}
|
package/scripts/merge-scrivx.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Options:
|
|
12
12
|
* --project <id> Project ID (default: derived from mcp-sync-dir name)
|
|
13
13
|
* --dry-run Show what would change without writing anything
|
|
14
|
+
* --organize-by-chapters Relocate scene files into part/chapter folders
|
|
14
15
|
*
|
|
15
16
|
* What it merges into scene sidecars:
|
|
16
17
|
* synopsis - from Files/Data/<UUID>/synopsis.txt
|
|
@@ -34,13 +35,14 @@ import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
|
|
|
34
35
|
// ---------------------------------------------------------------------------
|
|
35
36
|
const args = process.argv.slice(2);
|
|
36
37
|
if (args.length < 2 || args[0] === "--help") {
|
|
37
|
-
console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run]");
|
|
38
|
+
console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run] [--organize-by-chapters]");
|
|
38
39
|
process.exit(args[0] === "--help" ? 0 : 1);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const scrivPath = path.resolve(args[0]);
|
|
42
43
|
const mcpSyncDir = path.resolve(args[1]);
|
|
43
44
|
const dryRun = args.includes("--dry-run");
|
|
45
|
+
const organizeByChapters = args.includes("--organize-by-chapters");
|
|
44
46
|
const projectIdx = args.indexOf("--project");
|
|
45
47
|
const projectId = projectIdx !== -1
|
|
46
48
|
? args[projectIdx + 1]
|
|
@@ -58,6 +60,7 @@ try {
|
|
|
58
60
|
mcpSyncDir,
|
|
59
61
|
projectId,
|
|
60
62
|
dryRun,
|
|
63
|
+
organizeByChapters,
|
|
61
64
|
logger: line => console.log(line),
|
|
62
65
|
});
|
|
63
66
|
} catch (err) {
|
package/scrivener-direct.js
CHANGED
|
@@ -23,6 +23,9 @@ function children(el, tag) {
|
|
|
23
23
|
|
|
24
24
|
function walkYamls(dir, list = []) {
|
|
25
25
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
if (entry.isDirectory() && /^(projects|universes)$/i.test(entry.name)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
26
29
|
const full = path.join(dir, entry.name);
|
|
27
30
|
if (entry.isDirectory()) walkYamls(full, list);
|
|
28
31
|
else if (entry.name.endsWith(".meta.yaml")) list.push(full);
|
|
@@ -34,29 +37,170 @@ function isPlainObject(value) {
|
|
|
34
37
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
function slugifyPathSegment(value) {
|
|
41
|
+
return String(value ?? "")
|
|
42
|
+
.normalize("NFKD")
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
46
|
+
.replace(/^-+|-+$/g, "")
|
|
47
|
+
.slice(0, 50);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function chapterFolderName(chapter, chapterTitle) {
|
|
51
|
+
if (chapter === null || chapter === undefined) return null;
|
|
52
|
+
const suffix = slugifyPathSegment(chapterTitle);
|
|
53
|
+
return suffix ? `chapter-${chapter}-${suffix}` : `chapter-${chapter}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sceneContainerDir(scenesDir, part, chapter, chapterTitle, organizeByChapters = true) {
|
|
57
|
+
const segments = [scenesDir];
|
|
58
|
+
if (!organizeByChapters) {
|
|
59
|
+
return path.join(...segments);
|
|
60
|
+
}
|
|
61
|
+
if (part !== null && part !== undefined) segments.push(`part-${part}`);
|
|
62
|
+
const chapterDir = chapterFolderName(chapter, chapterTitle);
|
|
63
|
+
if (chapterDir) segments.push(chapterDir);
|
|
64
|
+
return path.join(...segments);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findProsePathForSidecar(sidecarPath) {
|
|
68
|
+
const proseCandidates = [
|
|
69
|
+
sidecarPath.replace(/\.meta\.yaml$/, ".md"),
|
|
70
|
+
sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
|
|
71
|
+
];
|
|
72
|
+
return proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function moveFileIfNeeded(fromPath, toPath) {
|
|
76
|
+
if (!fromPath || fromPath === toPath) return;
|
|
77
|
+
fs.mkdirSync(path.dirname(toPath), { recursive: true });
|
|
78
|
+
if (fs.existsSync(toPath)) {
|
|
79
|
+
return {
|
|
80
|
+
moved: false,
|
|
81
|
+
warning: {
|
|
82
|
+
code: "relocate_destination_exists",
|
|
83
|
+
message: "Skipped moving prose file because destination already exists.",
|
|
84
|
+
from_path: fromPath,
|
|
85
|
+
to_path: toPath,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
fs.renameSync(fromPath, toPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!error || typeof error !== "object" || error.code !== "EXDEV") {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
fs.copyFileSync(fromPath, toPath);
|
|
97
|
+
fs.unlinkSync(fromPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { moved: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const KNOWN_CUSTOM_FIELD_IDS = new Set([
|
|
104
|
+
"savethecat!",
|
|
105
|
+
"causality",
|
|
106
|
+
"stakes",
|
|
107
|
+
"change",
|
|
108
|
+
"f:character",
|
|
109
|
+
"f:mood",
|
|
110
|
+
"f:theme",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const MAX_RETURNED_WARNINGS = 25;
|
|
114
|
+
|
|
115
|
+
function recordWarning(summary, warning) {
|
|
116
|
+
if (!summary[warning.code]) {
|
|
117
|
+
summary[warning.code] = { count: 0, examples: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entry = summary[warning.code];
|
|
121
|
+
entry.count++;
|
|
122
|
+
|
|
123
|
+
if (entry.examples.length < 5) {
|
|
124
|
+
const example = { message: warning.message };
|
|
125
|
+
for (const key of ["file", "sync_number", "field_id", "value", "uuid", "from_path", "to_path", "moved_to"]) {
|
|
126
|
+
if (warning[key] !== undefined && warning[key] !== null) {
|
|
127
|
+
example[key] = warning[key];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
entry.examples.push(example);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pushWarning(warnings, warningSummary, warning) {
|
|
135
|
+
recordWarning(warningSummary, warning);
|
|
136
|
+
|
|
137
|
+
if (warnings.length < MAX_RETURNED_WARNINGS) {
|
|
138
|
+
warnings.push(warning);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
37
145
|
function buildMergeDataFromProject(projectData, uuid) {
|
|
38
|
-
const { metaByUUID, partByUUID, chapterByUUID } = projectData;
|
|
39
|
-
const { customFields,
|
|
146
|
+
const { metaByUUID, partByUUID, chapterByUUID, chapterTitleByUUID } = projectData;
|
|
147
|
+
const { customFields, tags, synopsis } = metaByUUID[uuid] ?? {};
|
|
40
148
|
const part = partByUUID[uuid] ?? null;
|
|
41
149
|
const chapter = chapterByUUID[uuid] ?? null;
|
|
150
|
+
const chapterTitle = chapterTitleByUUID[uuid] ?? null;
|
|
151
|
+
const warnings = [];
|
|
42
152
|
|
|
43
|
-
if (!customFields && !
|
|
153
|
+
if (!customFields && !tags && !synopsis && part === null && chapter === null && !chapterTitle) {
|
|
154
|
+
return { mergeData: null, warnings };
|
|
155
|
+
}
|
|
44
156
|
|
|
45
157
|
const out = {};
|
|
46
158
|
|
|
47
159
|
if (part !== null) out.part = part;
|
|
48
160
|
if (chapter !== null) out.chapter = chapter;
|
|
161
|
+
if (chapterTitle) out.chapter_title = chapterTitle;
|
|
49
162
|
if (synopsis) out.synopsis = synopsis;
|
|
50
|
-
if (
|
|
51
|
-
|
|
163
|
+
if (tags?.length) out.tags = tags;
|
|
164
|
+
|
|
165
|
+
for (const [fieldId, value] of Object.entries(customFields ?? {})) {
|
|
166
|
+
if (!KNOWN_CUSTOM_FIELD_IDS.has(fieldId) && String(value ?? "").trim()) {
|
|
167
|
+
warnings.push({
|
|
168
|
+
code: "ignored_custom_field",
|
|
169
|
+
message: `Ignored unsupported Scrivener custom field '${fieldId}'.`,
|
|
170
|
+
field_id: fieldId,
|
|
171
|
+
value: String(value),
|
|
172
|
+
uuid,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
52
176
|
|
|
53
177
|
const stcBeat = customFields?.["savethecat!"];
|
|
54
178
|
if (stcBeat && typeof stcBeat === "string" && stcBeat.trim()) {
|
|
55
179
|
out.save_the_cat_beat = stcBeat.trim();
|
|
56
180
|
}
|
|
57
181
|
|
|
58
|
-
const
|
|
59
|
-
const
|
|
182
|
+
const causalityRaw = customFields?.["causality"];
|
|
183
|
+
const stakesRaw = customFields?.["stakes"];
|
|
184
|
+
const causality = Number(causalityRaw ?? 0);
|
|
185
|
+
const stakes = Number(stakesRaw ?? 0);
|
|
186
|
+
if (causalityRaw !== undefined && String(causalityRaw).trim() && Number.isNaN(causality)) {
|
|
187
|
+
warnings.push({
|
|
188
|
+
code: "invalid_custom_field_value",
|
|
189
|
+
message: "Ignored non-numeric Scrivener custom field value for 'causality'.",
|
|
190
|
+
field_id: "causality",
|
|
191
|
+
value: String(causalityRaw),
|
|
192
|
+
uuid,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (stakesRaw !== undefined && String(stakesRaw).trim() && Number.isNaN(stakes)) {
|
|
196
|
+
warnings.push({
|
|
197
|
+
code: "invalid_custom_field_value",
|
|
198
|
+
message: "Ignored non-numeric Scrivener custom field value for 'stakes'.",
|
|
199
|
+
field_id: "stakes",
|
|
200
|
+
value: String(stakesRaw),
|
|
201
|
+
uuid,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
60
204
|
if (causality) out.causality = causality;
|
|
61
205
|
if (stakes) out.stakes = stakes;
|
|
62
206
|
|
|
@@ -69,7 +213,10 @@ function buildMergeDataFromProject(projectData, uuid) {
|
|
|
69
213
|
if (customFields?.["f:theme"] === "Yes" || customFields?.["f:theme"] === true) fnFlags.push("theme");
|
|
70
214
|
if (fnFlags.length) out.scene_functions = fnFlags;
|
|
71
215
|
|
|
72
|
-
return
|
|
216
|
+
return {
|
|
217
|
+
mergeData: Object.keys(out).length ? out : null,
|
|
218
|
+
warnings,
|
|
219
|
+
};
|
|
73
220
|
}
|
|
74
221
|
|
|
75
222
|
export function mergeSidecarData(existing, mergeData) {
|
|
@@ -144,15 +291,13 @@ export function loadScrivenerProjectData(scrivPath) {
|
|
|
144
291
|
}
|
|
145
292
|
}
|
|
146
293
|
|
|
147
|
-
const
|
|
148
|
-
const versions = [];
|
|
294
|
+
const tags = [];
|
|
149
295
|
const kwEl = children(item, "Keywords")[0];
|
|
150
296
|
if (kwEl) {
|
|
151
297
|
for (const kwId of children(kwEl, "KeywordID")) {
|
|
152
298
|
const name = keywordMap[text(kwId)];
|
|
153
299
|
if (!name) continue;
|
|
154
|
-
|
|
155
|
-
else characters.push(name);
|
|
300
|
+
tags.push(name);
|
|
156
301
|
}
|
|
157
302
|
}
|
|
158
303
|
|
|
@@ -163,11 +308,16 @@ export function loadScrivenerProjectData(scrivPath) {
|
|
|
163
308
|
if (candidate) synopsis = candidate;
|
|
164
309
|
}
|
|
165
310
|
|
|
166
|
-
metaByUUID[uuid] = {
|
|
311
|
+
metaByUUID[uuid] = {
|
|
312
|
+
customFields,
|
|
313
|
+
tags: [...new Set(tags)],
|
|
314
|
+
synopsis,
|
|
315
|
+
};
|
|
167
316
|
}
|
|
168
317
|
|
|
169
318
|
const partByUUID = {};
|
|
170
319
|
const chapterByUUID = {};
|
|
320
|
+
const chapterTitleByUUID = {};
|
|
171
321
|
let partNum = 0;
|
|
172
322
|
let chapterNum = 0;
|
|
173
323
|
|
|
@@ -176,21 +326,29 @@ export function loadScrivenerProjectData(scrivPath) {
|
|
|
176
326
|
const uuid = attr(child, "UUID");
|
|
177
327
|
const type = attr(child, "Type");
|
|
178
328
|
const childrenEl = children(child, "Children")[0];
|
|
329
|
+
const title = text(children(child, "Title")[0]);
|
|
179
330
|
|
|
180
331
|
if (type === "Folder" && currentPart === null) {
|
|
181
332
|
partNum++;
|
|
182
|
-
if (childrenEl) walkHierarchy(childrenEl, partNum, null);
|
|
333
|
+
if (childrenEl) walkHierarchy(childrenEl, { number: partNum, title }, null);
|
|
183
334
|
} else if (type === "Folder") {
|
|
184
335
|
chapterNum++;
|
|
336
|
+
const nextChapter = { number: chapterNum, title };
|
|
185
337
|
if (uuid) {
|
|
186
|
-
|
|
338
|
+
if (currentPart?.number !== null && currentPart?.number !== undefined) {
|
|
339
|
+
partByUUID[uuid] = currentPart.number;
|
|
340
|
+
}
|
|
187
341
|
chapterByUUID[uuid] = chapterNum;
|
|
342
|
+
if (title) chapterTitleByUUID[uuid] = title;
|
|
188
343
|
}
|
|
189
|
-
if (childrenEl) walkHierarchy(childrenEl, currentPart,
|
|
344
|
+
if (childrenEl) walkHierarchy(childrenEl, currentPart, nextChapter);
|
|
190
345
|
} else if (type === "Text") {
|
|
191
|
-
if (uuid && currentChapter !== null) {
|
|
192
|
-
|
|
193
|
-
|
|
346
|
+
if (uuid && currentChapter?.number !== null && currentChapter?.number !== undefined) {
|
|
347
|
+
if (currentPart?.number !== null && currentPart?.number !== undefined) {
|
|
348
|
+
partByUUID[uuid] = currentPart.number;
|
|
349
|
+
}
|
|
350
|
+
chapterByUUID[uuid] = currentChapter.number;
|
|
351
|
+
if (currentChapter.title) chapterTitleByUUID[uuid] = currentChapter.title;
|
|
194
352
|
}
|
|
195
353
|
}
|
|
196
354
|
}
|
|
@@ -214,6 +372,7 @@ export function loadScrivenerProjectData(scrivPath) {
|
|
|
214
372
|
metaByUUID,
|
|
215
373
|
partByUUID,
|
|
216
374
|
chapterByUUID,
|
|
375
|
+
chapterTitleByUUID,
|
|
217
376
|
};
|
|
218
377
|
}
|
|
219
378
|
|
|
@@ -223,6 +382,7 @@ export function mergeScrivenerProjectMetadata({
|
|
|
223
382
|
projectId,
|
|
224
383
|
scenesDir: scenesDirOverride,
|
|
225
384
|
dryRun = false,
|
|
385
|
+
organizeByChapters = false,
|
|
226
386
|
logger = () => {},
|
|
227
387
|
}) {
|
|
228
388
|
const mcpSyncDirAbs = path.resolve(mcpSyncDir);
|
|
@@ -268,15 +428,25 @@ export function mergeScrivenerProjectMetadata({
|
|
|
268
428
|
let unchanged = 0;
|
|
269
429
|
let noData = 0;
|
|
270
430
|
let skippedNoBracketId = 0;
|
|
431
|
+
let relocated = 0;
|
|
271
432
|
const fieldAddCounts = {};
|
|
272
433
|
const previewChanges = [];
|
|
434
|
+
const warnings = [];
|
|
435
|
+
const warningSummary = {};
|
|
436
|
+
let warningsTruncated = false;
|
|
273
437
|
|
|
274
438
|
for (const sidecarPath of sidecarFiles) {
|
|
275
439
|
const filename = path.basename(sidecarPath);
|
|
440
|
+
const prosePath = findProsePathForSidecar(sidecarPath);
|
|
276
441
|
const match = filename.match(/\[(\d+)\]\.meta\.yaml$/);
|
|
277
442
|
if (!match) {
|
|
278
443
|
logger(` SKIP (no bracket ID) ${filename}`);
|
|
279
444
|
skippedNoBracketId++;
|
|
445
|
+
warningsTruncated = pushWarning(warnings, warningSummary, {
|
|
446
|
+
code: "missing_bracket_id",
|
|
447
|
+
message: "Skipped sidecar because filename does not include a Scrivener sync number in brackets.",
|
|
448
|
+
file: filename,
|
|
449
|
+
}) || warningsTruncated;
|
|
280
450
|
continue;
|
|
281
451
|
}
|
|
282
452
|
|
|
@@ -285,10 +455,20 @@ export function mergeScrivenerProjectMetadata({
|
|
|
285
455
|
if (!uuid) {
|
|
286
456
|
logger(` SKIP (no UUID for [${syncNum}]) ${filename}`);
|
|
287
457
|
noData++;
|
|
458
|
+
warningsTruncated = pushWarning(warnings, warningSummary, {
|
|
459
|
+
code: "missing_uuid_mapping",
|
|
460
|
+
message: `Skipped sidecar because Scrivener sync number [${syncNum}] has no UUID mapping in the project.`,
|
|
461
|
+
file: filename,
|
|
462
|
+
sync_number: syncNum,
|
|
463
|
+
}) || warningsTruncated;
|
|
288
464
|
continue;
|
|
289
465
|
}
|
|
290
466
|
|
|
291
|
-
const mergeData = buildMergeDataFromProject(projectData, uuid);
|
|
467
|
+
const { mergeData, warnings: mergeWarnings } = buildMergeDataFromProject(projectData, uuid);
|
|
468
|
+
for (const warning of mergeWarnings) {
|
|
469
|
+
warningsTruncated = pushWarning(warnings, warningSummary, { ...warning, file: filename }) || warningsTruncated;
|
|
470
|
+
}
|
|
471
|
+
|
|
292
472
|
if (!mergeData) {
|
|
293
473
|
unchanged++;
|
|
294
474
|
continue;
|
|
@@ -300,8 +480,22 @@ export function mergeScrivenerProjectMetadata({
|
|
|
300
480
|
}
|
|
301
481
|
const existing = existingRaw ?? {};
|
|
302
482
|
const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
|
|
303
|
-
|
|
304
|
-
|
|
483
|
+
const effective = changed ? merged : existing;
|
|
484
|
+
const targetDir = sceneContainerDir(
|
|
485
|
+
scenesDir,
|
|
486
|
+
effective.part ?? null,
|
|
487
|
+
effective.chapter ?? null,
|
|
488
|
+
effective.chapter_title ?? null,
|
|
489
|
+
organizeByChapters,
|
|
490
|
+
);
|
|
491
|
+
const targetSidecarPath = organizeByChapters ? path.join(targetDir, filename) : sidecarPath;
|
|
492
|
+
const targetProsePath = prosePath
|
|
493
|
+
? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
|
|
494
|
+
: null;
|
|
495
|
+
const needsMove = path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
|
|
496
|
+
|| (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
|
|
497
|
+
|
|
498
|
+
if (!changed && !needsMove) {
|
|
305
499
|
unchanged++;
|
|
306
500
|
continue;
|
|
307
501
|
}
|
|
@@ -311,23 +505,92 @@ export function mergeScrivenerProjectMetadata({
|
|
|
311
505
|
}
|
|
312
506
|
|
|
313
507
|
if (previewChanges.length < 25) {
|
|
314
|
-
previewChanges.push({
|
|
508
|
+
previewChanges.push({
|
|
509
|
+
file: filename,
|
|
510
|
+
added_keys: [...newKeys],
|
|
511
|
+
...(needsMove ? { moved_to: path.relative(scenesDir, targetSidecarPath) || filename } : {}),
|
|
512
|
+
});
|
|
315
513
|
}
|
|
316
514
|
|
|
515
|
+
let didRelocate = false;
|
|
516
|
+
|
|
317
517
|
if (dryRun) {
|
|
318
518
|
logger(` DRY ${filename}`);
|
|
319
519
|
for (const key of newKeys) {
|
|
320
520
|
logger(` + ${key}: ${JSON.stringify(mergeData[key]).slice(0, 80)}`);
|
|
321
521
|
}
|
|
522
|
+
if (needsMove) {
|
|
523
|
+
logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
|
|
524
|
+
}
|
|
525
|
+
didRelocate = needsMove;
|
|
322
526
|
} else {
|
|
323
|
-
|
|
324
|
-
|
|
527
|
+
let proseMoveWarning = null;
|
|
528
|
+
let shouldRelocateSidecar = organizeByChapters;
|
|
529
|
+
|
|
530
|
+
if (
|
|
531
|
+
shouldRelocateSidecar
|
|
532
|
+
&& path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
|
|
533
|
+
&& fs.existsSync(targetSidecarPath)
|
|
534
|
+
) {
|
|
535
|
+
shouldRelocateSidecar = false;
|
|
536
|
+
warningsTruncated = pushWarning(
|
|
537
|
+
warnings,
|
|
538
|
+
warningSummary,
|
|
539
|
+
{
|
|
540
|
+
code: "relocate_sidecar_destination_exists",
|
|
541
|
+
message: "Skipped relocating sidecar because destination already exists.",
|
|
542
|
+
from_path: sidecarPath,
|
|
543
|
+
to_path: targetSidecarPath,
|
|
544
|
+
file: filename,
|
|
545
|
+
}
|
|
546
|
+
) || warningsTruncated;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (shouldRelocateSidecar && prosePath && targetProsePath) {
|
|
550
|
+
const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
|
|
551
|
+
if (moveResult?.warning) {
|
|
552
|
+
proseMoveWarning = moveResult.warning;
|
|
553
|
+
shouldRelocateSidecar = false;
|
|
554
|
+
warningsTruncated = pushWarning(
|
|
555
|
+
warnings,
|
|
556
|
+
warningSummary,
|
|
557
|
+
{
|
|
558
|
+
...moveResult.warning,
|
|
559
|
+
file: filename,
|
|
560
|
+
}
|
|
561
|
+
) || warningsTruncated;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const finalSidecarPath = shouldRelocateSidecar ? targetSidecarPath : sidecarPath;
|
|
566
|
+
fs.mkdirSync(path.dirname(finalSidecarPath), { recursive: true });
|
|
567
|
+
fs.writeFileSync(finalSidecarPath, yaml.dump(effective, { lineWidth: 120 }), "utf8");
|
|
568
|
+
if (
|
|
569
|
+
shouldRelocateSidecar
|
|
570
|
+
&& path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
|
|
571
|
+
&& fs.existsSync(sidecarPath)
|
|
572
|
+
) {
|
|
573
|
+
fs.unlinkSync(sidecarPath);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const changes = [];
|
|
577
|
+
if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
|
|
578
|
+
if (needsMove && shouldRelocateSidecar) {
|
|
579
|
+
changes.push(`moved to ${path.relative(scenesDir, targetSidecarPath) || filename}`);
|
|
580
|
+
}
|
|
581
|
+
if (proseMoveWarning) {
|
|
582
|
+
changes.push("sidecar kept in place (prose move skipped)");
|
|
583
|
+
}
|
|
584
|
+
logger(` OK ${filename}${changes.length ? ` [${changes.join("; ")}]` : ""}`);
|
|
585
|
+
didRelocate = needsMove && shouldRelocateSidecar;
|
|
325
586
|
}
|
|
587
|
+
if (didRelocate) relocated++;
|
|
326
588
|
updated++;
|
|
327
589
|
}
|
|
328
590
|
|
|
329
591
|
logger(`\n${"─".repeat(50)}`);
|
|
330
592
|
logger(`Updated: ${updated} sidecars${dryRun ? " (dry run)" : ""}`);
|
|
593
|
+
if (relocated) logger(`Relocated: ${relocated} scene file pair(s)`);
|
|
331
594
|
logger(`Unchanged: ${unchanged} (already complete or no new data)`);
|
|
332
595
|
if (skippedNoBracketId) logger(`Skipped: ${skippedNoBracketId} (no bracket ID in filename)`);
|
|
333
596
|
if (noData) logger(`No data: ${noData} (no matching binder entry)`);
|
|
@@ -340,11 +603,15 @@ export function mergeScrivenerProjectMetadata({
|
|
|
340
603
|
dryRun: Boolean(dryRun),
|
|
341
604
|
sidecarFiles: sidecarFiles.length,
|
|
342
605
|
updated,
|
|
606
|
+
relocated,
|
|
343
607
|
unchanged,
|
|
344
608
|
skippedNoBracketId,
|
|
345
609
|
noData,
|
|
346
610
|
fieldAddCounts,
|
|
347
611
|
previewChanges,
|
|
612
|
+
warnings,
|
|
613
|
+
warningsTruncated,
|
|
614
|
+
warningSummary,
|
|
348
615
|
stats: {
|
|
349
616
|
syncMapEntries: Object.keys(projectData.syncNumToUUID).length,
|
|
350
617
|
keywordMapEntries: Object.keys(projectData.keywordMap).length,
|
package/sync.js
CHANGED
|
@@ -69,10 +69,10 @@ export function inferScenePositionFromPath(syncDir, filePath) {
|
|
|
69
69
|
let chapter = null;
|
|
70
70
|
|
|
71
71
|
for (const segment of parts) {
|
|
72
|
-
const partMatch = segment.match(/^part-(\d+)
|
|
72
|
+
const partMatch = segment.match(/^part-(\d+)(?:-.+)?$/i);
|
|
73
73
|
if (partMatch) part = parseInt(partMatch[1], 10);
|
|
74
74
|
|
|
75
|
-
const chapterMatch = segment.match(/^chapter-(\d+)
|
|
75
|
+
const chapterMatch = segment.match(/^chapter-(\d+)(?:-.+)?$/i);
|
|
76
76
|
if (chapterMatch) chapter = parseInt(chapterMatch[1], 10);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -107,8 +107,8 @@ const UNIVERSE_PROJECT_ROOT_CACHE = new Map();
|
|
|
107
107
|
function isProjectStructuralDir(name) {
|
|
108
108
|
const normalized = String(name ?? "").toLowerCase();
|
|
109
109
|
return PROJECT_STRUCTURAL_DIRS.has(normalized)
|
|
110
|
-
|| /^part-\d
|
|
111
|
-
|| /^chapter-\d
|
|
110
|
+
|| /^part-\d+(?:-.+)?$/.test(normalized)
|
|
111
|
+
|| /^chapter-\d+(?:-.+)?$/.test(normalized);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
function isBookSlug(name) {
|
|
@@ -480,15 +480,16 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
480
480
|
|
|
481
481
|
db.prepare(`
|
|
482
482
|
INSERT INTO scenes (
|
|
483
|
-
scene_id, project_id, title, part, chapter, pov, logline, scene_change,
|
|
483
|
+
scene_id, project_id, title, part, chapter, chapter_title, pov, logline, scene_change,
|
|
484
484
|
causality, stakes, scene_functions,
|
|
485
485
|
save_the_cat_beat, timeline_position, story_time, word_count,
|
|
486
486
|
file_path, prose_checksum, metadata_stale, updated_at
|
|
487
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
487
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
488
488
|
ON CONFLICT (scene_id, project_id) DO UPDATE SET
|
|
489
489
|
title = excluded.title,
|
|
490
490
|
part = excluded.part,
|
|
491
491
|
chapter = excluded.chapter,
|
|
492
|
+
chapter_title = excluded.chapter_title,
|
|
492
493
|
pov = excluded.pov,
|
|
493
494
|
logline = excluded.logline,
|
|
494
495
|
scene_change = excluded.scene_change,
|
|
@@ -505,7 +506,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
505
506
|
updated_at = excluded.updated_at
|
|
506
507
|
`).run(
|
|
507
508
|
meta.scene_id, project_id,
|
|
508
|
-
meta.title ?? null, meta.part ?? null, meta.chapter ?? null,
|
|
509
|
+
meta.title ?? null, meta.part ?? null, meta.chapter ?? null, meta.chapter_title ?? null,
|
|
509
510
|
meta.pov ?? null, meta.logline ?? meta.synopsis ?? null,
|
|
510
511
|
meta.scene_change ?? meta.change ?? null,
|
|
511
512
|
meta.causality ?? null, meta.stakes ?? null,
|