@hanna84/mcp-writing 1.11.0 → 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 +20 -0
- package/README.md +12 -0
- package/db.js +6 -0
- package/importer.js +27 -6
- package/index.js +249 -17
- package/metadata-lint.js +1 -0
- package/package.json +1 -1
- package/scripts/async-job-runner.mjs +52 -2
- 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,31 @@ 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
|
+
|
|
13
|
+
#### [v1.11.1](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.11.0...v1.11.1)
|
|
15
|
+
|
|
16
|
+
> 22 April 2026
|
|
17
|
+
|
|
18
|
+
- Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/59)
|
|
20
|
+
- Release 1.11.1 [`85bb5c6`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/85bb5c6c3643256eb6866c7b75cc3e3f87ce1412)
|
|
22
|
+
|
|
7
23
|
#### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.10.0...v1.11.0)
|
|
9
25
|
|
|
26
|
+
> 21 April 2026
|
|
27
|
+
|
|
10
28
|
- feat: add reusable manual real-data Scrivener test runner [`#58`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/58)
|
|
30
|
+
- Release 1.11.0 [`5938d51`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/5938d51b277dcce2f5fcfe728ff2112d3af644e5)
|
|
12
32
|
|
|
13
33
|
#### [v1.10.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.9.4...v1.10.0)
|
package/README.md
CHANGED
|
@@ -76,5 +76,17 @@ Goal: let AI propose prose edits without losing control of your draft.
|
|
|
76
76
|
|
|
77
77
|
Outcome: you get AI speed with explicit approval and recoverable history for every applied change.
|
|
78
78
|
|
|
79
|
+
### 5) Refreshing scene-character links after imports or major rewrites
|
|
80
|
+
|
|
81
|
+
Goal: rebuild scene-to-character links in a controlled way after imported prose changes or metadata drift.
|
|
82
|
+
|
|
83
|
+
1. Start with `enrich_scene_characters_batch` using the default `dry_run=true` to preview inferred links for a project, chapter, or explicit scene list.
|
|
84
|
+
2. Poll `get_async_job_status` until the batch job completes, then review `job.result.results` for changed scenes, ambiguous matches, and partial failures.
|
|
85
|
+
3. Spot-check a few affected scenes with `get_scene_prose` if the changes touch important continuity or cast-heavy chapters.
|
|
86
|
+
4. Re-run `enrich_scene_characters_batch` with `dry_run=false` once the preview looks correct.
|
|
87
|
+
5. If you want a destructive overwrite instead of additive merge behavior, use `replace_mode=replace` with `confirm_replace=true` deliberately.
|
|
88
|
+
|
|
89
|
+
Outcome: character-link maintenance becomes a preview-first batch operation instead of a one-off regex script or manual sidecar cleanup.
|
|
90
|
+
|
|
79
91
|
## License
|
|
80
92
|
AGPL-3.0-only
|
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
|
@@ -17,6 +17,7 @@ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, lis
|
|
|
17
17
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
18
18
|
import { importScrivenerSync, validateProjectId } from "./importer.js";
|
|
19
19
|
import { mergeScrivenerProjectMetadata } from "./scrivener-direct.js";
|
|
20
|
+
import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
|
|
20
21
|
|
|
21
22
|
const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
|
|
22
23
|
const DB_PATH = process.env.DB_PATH ?? "./writing.db";
|
|
@@ -119,12 +120,14 @@ function toPublicJob(job, includeResult = true) {
|
|
|
119
120
|
finished_at: job.finishedAt,
|
|
120
121
|
pid: job.pid,
|
|
121
122
|
error: job.error,
|
|
123
|
+
...(job.progress ? { progress: job.progress } : {}),
|
|
122
124
|
...(includeResult ? { result: job.result } : {}),
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
127
129
|
pruneAsyncJobs();
|
|
130
|
+
const progressPrefix = ASYNC_PROGRESS_PREFIX;
|
|
128
131
|
|
|
129
132
|
const id = randomUUID();
|
|
130
133
|
const tmpPrefix = path.join(os.tmpdir(), "mcp-writing-job-");
|
|
@@ -156,14 +159,38 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
156
159
|
requestPath,
|
|
157
160
|
resultPath,
|
|
158
161
|
result: null,
|
|
162
|
+
progress: null,
|
|
159
163
|
error: null,
|
|
160
164
|
onComplete,
|
|
161
165
|
child,
|
|
162
166
|
};
|
|
163
167
|
asyncJobs.set(id, job);
|
|
164
168
|
|
|
165
|
-
|
|
166
|
-
|
|
169
|
+
let stdoutBuffer = "";
|
|
170
|
+
child.stdout.on("data", (chunk) => {
|
|
171
|
+
stdoutBuffer += chunk.toString("utf8");
|
|
172
|
+
const lines = stdoutBuffer.split("\n");
|
|
173
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed.startsWith(progressPrefix)) continue;
|
|
178
|
+
const payload = trimmed.slice(progressPrefix.length);
|
|
179
|
+
try {
|
|
180
|
+
const progress = JSON.parse(payload);
|
|
181
|
+
if (progress && typeof progress === "object") {
|
|
182
|
+
const nextProgress = {
|
|
183
|
+
total_scenes: Number(progress.total_scenes ?? 0),
|
|
184
|
+
processed_scenes: Number(progress.processed_scenes ?? 0),
|
|
185
|
+
scenes_changed: Number(progress.scenes_changed ?? 0),
|
|
186
|
+
failed_scenes: Number(progress.failed_scenes ?? 0),
|
|
187
|
+
};
|
|
188
|
+
job.progress = nextProgress;
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// Ignore malformed progress lines; they are best-effort telemetry.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
167
194
|
});
|
|
168
195
|
child.stderr.on("data", () => {
|
|
169
196
|
// avoid crashing on stderr backpressure for noisy runs
|
|
@@ -187,12 +214,32 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
187
214
|
const payload = readJsonIfExists(resultPath);
|
|
188
215
|
const successful = payload?.ok === true;
|
|
189
216
|
const cancelledBySignal = signal === "SIGTERM" || signal === "SIGKILL";
|
|
217
|
+
const cancelledByPayload = payload?.cancelled === true;
|
|
190
218
|
|
|
191
219
|
job.finishedAt = new Date().toISOString();
|
|
192
220
|
job.result = payload;
|
|
193
221
|
|
|
222
|
+
const hasProgressFields = payload && (
|
|
223
|
+
payload.total_scenes !== undefined
|
|
224
|
+
|| payload.processed_scenes !== undefined
|
|
225
|
+
|| payload.scenes_changed !== undefined
|
|
226
|
+
|| payload.failed_scenes !== undefined
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (payload && payload.ok === true && hasProgressFields) {
|
|
230
|
+
job.progress = {
|
|
231
|
+
total_scenes: Number(payload.total_scenes ?? job.progress?.total_scenes ?? 0),
|
|
232
|
+
processed_scenes: Number(payload.processed_scenes ?? job.progress?.processed_scenes ?? 0),
|
|
233
|
+
scenes_changed: Number(payload.scenes_changed ?? job.progress?.scenes_changed ?? 0),
|
|
234
|
+
failed_scenes: Number(payload.failed_scenes ?? job.progress?.failed_scenes ?? 0),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
194
238
|
if (job.status === "cancelling") {
|
|
195
|
-
if (
|
|
239
|
+
if (cancelledByPayload) {
|
|
240
|
+
job.status = "cancelled";
|
|
241
|
+
job.error = "Async job cancelled after returning partial results.";
|
|
242
|
+
} else if (successful && !cancelledBySignal) {
|
|
196
243
|
// Race: cancellation was requested as work completed successfully.
|
|
197
244
|
job.status = "completed";
|
|
198
245
|
} else {
|
|
@@ -379,6 +426,63 @@ function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
|
|
|
379
426
|
};
|
|
380
427
|
}
|
|
381
428
|
|
|
429
|
+
function resolveBatchTargetScenes(dbHandle, {
|
|
430
|
+
projectId,
|
|
431
|
+
sceneIds,
|
|
432
|
+
part,
|
|
433
|
+
chapter,
|
|
434
|
+
onlyStale,
|
|
435
|
+
}) {
|
|
436
|
+
const projectExists = Boolean(
|
|
437
|
+
dbHandle.prepare(`SELECT 1 FROM projects WHERE project_id = ? LIMIT 1`).get(projectId)
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (sceneIds?.length) {
|
|
441
|
+
const placeholders = sceneIds.map(() => "?").join(",");
|
|
442
|
+
const existingRows = dbHandle.prepare(
|
|
443
|
+
`SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
|
|
444
|
+
).all(projectId, ...sceneIds);
|
|
445
|
+
const existing = new Set(existingRows.map(row => row.scene_id));
|
|
446
|
+
const missing = sceneIds.filter(sceneId => !existing.has(sceneId));
|
|
447
|
+
if (missing.length > 0) {
|
|
448
|
+
return { ok: false, code: "NOT_FOUND", message: `Requested scene IDs were not found in project '${projectId}'.`, details: { missing_scene_ids: missing, project_id: projectId } };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const conditions = ["project_id = ?"];
|
|
453
|
+
const params = [projectId];
|
|
454
|
+
|
|
455
|
+
if (sceneIds?.length) {
|
|
456
|
+
const placeholders = sceneIds.map(() => "?").join(",");
|
|
457
|
+
conditions.push(`scene_id IN (${placeholders})`);
|
|
458
|
+
params.push(...sceneIds);
|
|
459
|
+
}
|
|
460
|
+
if (part !== undefined) {
|
|
461
|
+
conditions.push("part = ?");
|
|
462
|
+
params.push(part);
|
|
463
|
+
}
|
|
464
|
+
if (chapter !== undefined) {
|
|
465
|
+
conditions.push("chapter = ?");
|
|
466
|
+
params.push(chapter);
|
|
467
|
+
}
|
|
468
|
+
if (onlyStale) {
|
|
469
|
+
conditions.push("metadata_stale = 1");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const query = `
|
|
473
|
+
SELECT scene_id, project_id, file_path
|
|
474
|
+
FROM scenes
|
|
475
|
+
WHERE ${conditions.join(" AND ")}
|
|
476
|
+
ORDER BY part, chapter, timeline_position
|
|
477
|
+
`;
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
rows: dbHandle.prepare(query).all(...params),
|
|
482
|
+
project_exists: projectExists,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
382
486
|
function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
|
|
383
487
|
const prefix = kind === "character" ? "char" : "place";
|
|
384
488
|
const idKey = kind === "character" ? "character_id" : "place_id";
|
|
@@ -671,7 +775,7 @@ function createMcpServer() {
|
|
|
671
775
|
// ---- import_scrivener_sync ----------------------------------------------
|
|
672
776
|
s.tool(
|
|
673
777
|
"import_scrivener_sync",
|
|
674
|
-
"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().",
|
|
675
779
|
{
|
|
676
780
|
source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
|
|
677
781
|
project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
|
|
@@ -793,15 +897,16 @@ function createMcpServer() {
|
|
|
793
897
|
// ---- merge_scrivener_project_beta --------------------------------------
|
|
794
898
|
s.tool(
|
|
795
899
|
"merge_scrivener_project_beta",
|
|
796
|
-
"[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.",
|
|
797
901
|
{
|
|
798
902
|
source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
|
|
799
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."),
|
|
800
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."),
|
|
801
905
|
dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
|
|
802
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."),
|
|
803
908
|
},
|
|
804
|
-
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 }) => {
|
|
805
910
|
if (project_id !== undefined) {
|
|
806
911
|
const projectIdCheck = validateProjectId(project_id);
|
|
807
912
|
if (!projectIdCheck.ok) {
|
|
@@ -839,6 +944,7 @@ function createMcpServer() {
|
|
|
839
944
|
projectId: project_id,
|
|
840
945
|
scenesDir: normalizedScenesDir,
|
|
841
946
|
dryRun: Boolean(dry_run),
|
|
947
|
+
organizeByChapters: Boolean(organize_by_chapters),
|
|
842
948
|
});
|
|
843
949
|
} catch (error) {
|
|
844
950
|
return errorResponse(
|
|
@@ -869,10 +975,14 @@ function createMcpServer() {
|
|
|
869
975
|
dry_run: mergeResult.dryRun,
|
|
870
976
|
sidecar_files: mergeResult.sidecarFiles,
|
|
871
977
|
updated: mergeResult.updated,
|
|
978
|
+
relocated: mergeResult.relocated,
|
|
872
979
|
unchanged: mergeResult.unchanged,
|
|
873
980
|
no_data: mergeResult.noData,
|
|
874
981
|
field_add_counts: mergeResult.fieldAddCounts,
|
|
875
982
|
preview_changes: mergeResult.previewChanges,
|
|
983
|
+
warnings: mergeResult.warnings,
|
|
984
|
+
warnings_truncated: mergeResult.warningsTruncated,
|
|
985
|
+
warning_summary: mergeResult.warningSummary,
|
|
876
986
|
stats: {
|
|
877
987
|
sync_map_entries: mergeResult.stats.syncMapEntries,
|
|
878
988
|
keyword_map_entries: mergeResult.stats.keywordMapEntries,
|
|
@@ -905,7 +1015,7 @@ function createMcpServer() {
|
|
|
905
1015
|
// ---- async import/merge jobs --------------------------------------------
|
|
906
1016
|
s.tool(
|
|
907
1017
|
"import_scrivener_sync_async",
|
|
908
|
-
"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.",
|
|
909
1019
|
{
|
|
910
1020
|
source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
|
|
911
1021
|
project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
@@ -985,15 +1095,16 @@ function createMcpServer() {
|
|
|
985
1095
|
|
|
986
1096
|
s.tool(
|
|
987
1097
|
"merge_scrivener_project_beta_async",
|
|
988
|
-
"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.",
|
|
989
1099
|
{
|
|
990
1100
|
source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
|
|
991
1101
|
project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
992
1102
|
scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
|
|
993
1103
|
dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
|
|
994
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."),
|
|
995
1106
|
},
|
|
996
|
-
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 }) => {
|
|
997
1108
|
if (project_id !== undefined) {
|
|
998
1109
|
const projectIdCheck = validateProjectId(project_id);
|
|
999
1110
|
if (!projectIdCheck.ok) {
|
|
@@ -1032,6 +1143,7 @@ function createMcpServer() {
|
|
|
1032
1143
|
project_id,
|
|
1033
1144
|
scenes_dir: normalizedScenesDir,
|
|
1034
1145
|
dry_run: Boolean(dry_run),
|
|
1146
|
+
organize_by_chapters: Boolean(organize_by_chapters),
|
|
1035
1147
|
},
|
|
1036
1148
|
context: {
|
|
1037
1149
|
sync_dir: SYNC_DIR,
|
|
@@ -1062,9 +1174,129 @@ function createMcpServer() {
|
|
|
1062
1174
|
}
|
|
1063
1175
|
);
|
|
1064
1176
|
|
|
1177
|
+
s.tool(
|
|
1178
|
+
"enrich_scene_characters_batch",
|
|
1179
|
+
"Start an asynchronous batch job that infers scene character mentions and updates scene metadata links. Version 1 uses canonical character names only (no aliases). Defaults to dry_run=true.",
|
|
1180
|
+
{
|
|
1181
|
+
project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1182
|
+
scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
|
|
1183
|
+
part: z.number().int().optional().describe("Optional part number filter."),
|
|
1184
|
+
chapter: z.number().int().optional().describe("Optional chapter number filter."),
|
|
1185
|
+
only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
|
|
1186
|
+
dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
|
|
1187
|
+
replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
|
|
1188
|
+
max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
|
|
1189
|
+
include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
|
|
1190
|
+
confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
|
|
1191
|
+
},
|
|
1192
|
+
async ({
|
|
1193
|
+
project_id,
|
|
1194
|
+
scene_ids,
|
|
1195
|
+
part,
|
|
1196
|
+
chapter,
|
|
1197
|
+
only_stale = false,
|
|
1198
|
+
dry_run = true,
|
|
1199
|
+
replace_mode = "merge",
|
|
1200
|
+
max_scenes = 200,
|
|
1201
|
+
include_match_details = false,
|
|
1202
|
+
confirm_replace = false,
|
|
1203
|
+
}) => {
|
|
1204
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1205
|
+
if (!projectIdCheck.ok) {
|
|
1206
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (replace_mode === "replace" && !confirm_replace) {
|
|
1210
|
+
return errorResponse(
|
|
1211
|
+
"VALIDATION_ERROR",
|
|
1212
|
+
"replace_mode=replace requires confirm_replace=true.",
|
|
1213
|
+
{ replace_mode, confirm_replace }
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (!dry_run && !SYNC_DIR_WRITABLE) {
|
|
1218
|
+
return errorResponse(
|
|
1219
|
+
"READ_ONLY",
|
|
1220
|
+
"Cannot run batch character enrichment in write mode: sync dir is read-only.",
|
|
1221
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const characterRows = db.prepare(`
|
|
1226
|
+
SELECT character_id, name
|
|
1227
|
+
FROM characters
|
|
1228
|
+
WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
|
|
1229
|
+
ORDER BY length(name) DESC
|
|
1230
|
+
`).all(project_id, project_id);
|
|
1231
|
+
|
|
1232
|
+
const targetResolution = resolveBatchTargetScenes(db, {
|
|
1233
|
+
projectId: project_id,
|
|
1234
|
+
sceneIds: scene_ids,
|
|
1235
|
+
part,
|
|
1236
|
+
chapter,
|
|
1237
|
+
onlyStale: Boolean(only_stale),
|
|
1238
|
+
});
|
|
1239
|
+
if (!targetResolution.ok) {
|
|
1240
|
+
return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const targetScenes = targetResolution.rows;
|
|
1244
|
+
const projectExists = targetResolution.project_exists !== false;
|
|
1245
|
+
if (targetScenes.length > max_scenes) {
|
|
1246
|
+
return errorResponse(
|
|
1247
|
+
"VALIDATION_ERROR",
|
|
1248
|
+
`Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
|
|
1249
|
+
{
|
|
1250
|
+
matched_scenes: targetScenes.length,
|
|
1251
|
+
max_scenes,
|
|
1252
|
+
project_id,
|
|
1253
|
+
}
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const job = startAsyncJob({
|
|
1258
|
+
kind: "enrich_scene_characters_batch",
|
|
1259
|
+
requestPayload: {
|
|
1260
|
+
kind: "enrich_scene_characters_batch",
|
|
1261
|
+
args: {
|
|
1262
|
+
project_id,
|
|
1263
|
+
dry_run: Boolean(dry_run),
|
|
1264
|
+
replace_mode,
|
|
1265
|
+
include_match_details: Boolean(include_match_details),
|
|
1266
|
+
project_exists: projectExists,
|
|
1267
|
+
target_scenes: targetScenes,
|
|
1268
|
+
character_rows: characterRows,
|
|
1269
|
+
},
|
|
1270
|
+
context: { sync_dir: SYNC_DIR },
|
|
1271
|
+
},
|
|
1272
|
+
onComplete: (completedJob) => {
|
|
1273
|
+
if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
|
|
1274
|
+
|
|
1275
|
+
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1276
|
+
|
|
1277
|
+
const changedScenes = (completedJob.result.results ?? [])
|
|
1278
|
+
.filter(row => row.status === "changed")
|
|
1279
|
+
.map(row => row.scene_id);
|
|
1280
|
+
|
|
1281
|
+
for (const sceneId of changedScenes) {
|
|
1282
|
+
db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
|
|
1283
|
+
.run(sceneId, project_id);
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
return jsonResponse({
|
|
1289
|
+
ok: true,
|
|
1290
|
+
async: true,
|
|
1291
|
+
job: toPublicJob(job, false),
|
|
1292
|
+
next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1065
1297
|
s.tool(
|
|
1066
1298
|
"get_async_job_status",
|
|
1067
|
-
"Get status and result for an asynchronous job started by import_scrivener_sync_async or
|
|
1299
|
+
"Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta_async, or enrich_scene_characters_batch.",
|
|
1068
1300
|
{
|
|
1069
1301
|
job_id: z.string().describe("Job ID returned by an async start tool."),
|
|
1070
1302
|
include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
|
|
@@ -1081,7 +1313,7 @@ function createMcpServer() {
|
|
|
1081
1313
|
|
|
1082
1314
|
s.tool(
|
|
1083
1315
|
"list_async_jobs",
|
|
1084
|
-
"List asynchronous
|
|
1316
|
+
"List asynchronous jobs currently known to this server.",
|
|
1085
1317
|
{
|
|
1086
1318
|
include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
|
|
1087
1319
|
},
|
|
@@ -1199,7 +1431,7 @@ function createMcpServer() {
|
|
|
1199
1431
|
},
|
|
1200
1432
|
async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
|
|
1201
1433
|
let query = `
|
|
1202
|
-
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,
|
|
1203
1435
|
s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
1204
1436
|
s.save_the_cat_beat, s.timeline_position, s.story_time,
|
|
1205
1437
|
s.word_count, s.metadata_stale
|
|
@@ -1357,7 +1589,7 @@ function createMcpServer() {
|
|
|
1357
1589
|
},
|
|
1358
1590
|
async ({ character_id, project_id, page, page_size }) => {
|
|
1359
1591
|
let query = `
|
|
1360
|
-
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,
|
|
1361
1593
|
s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
1362
1594
|
s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
|
|
1363
1595
|
FROM scenes s
|
|
@@ -1635,7 +1867,7 @@ function createMcpServer() {
|
|
|
1635
1867
|
|
|
1636
1868
|
if (!shouldPaginate) {
|
|
1637
1869
|
const rows = db.prepare(`
|
|
1638
|
-
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
|
|
1639
1871
|
FROM scenes_fts f
|
|
1640
1872
|
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
1641
1873
|
WHERE scenes_fts MATCH ?
|
|
@@ -1652,7 +1884,7 @@ function createMcpServer() {
|
|
|
1652
1884
|
const offset = (normalizedPage - 1) * safePageSize;
|
|
1653
1885
|
|
|
1654
1886
|
const rows = db.prepare(`
|
|
1655
|
-
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
|
|
1656
1888
|
FROM scenes_fts f
|
|
1657
1889
|
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
1658
1890
|
WHERE scenes_fts MATCH ?
|
|
@@ -1718,7 +1950,7 @@ function createMcpServer() {
|
|
|
1718
1950
|
}
|
|
1719
1951
|
|
|
1720
1952
|
const rows = db.prepare(`
|
|
1721
|
-
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,
|
|
1722
1954
|
st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
|
|
1723
1955
|
FROM scenes s
|
|
1724
1956
|
JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
|
|
@@ -2059,7 +2291,7 @@ function createMcpServer() {
|
|
|
2059
2291
|
let query = `
|
|
2060
2292
|
SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
|
|
2061
2293
|
r.scene_id, r.note,
|
|
2062
|
-
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
|
|
2063
2295
|
FROM character_relationships r
|
|
2064
2296
|
LEFT JOIN scenes s ON s.scene_id = r.scene_id
|
|
2065
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
|
@@ -2,12 +2,24 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { importScrivenerSync } from "../importer.js";
|
|
4
4
|
import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
|
|
5
|
+
import { runSceneCharacterBatch } from "../scene-character-batch.js";
|
|
6
|
+
import { ASYNC_PROGRESS_PREFIX } from "../async-progress.js";
|
|
7
|
+
|
|
8
|
+
const PROGRESS_PREFIX = ASYNC_PROGRESS_PREFIX;
|
|
5
9
|
|
|
6
10
|
function writeResult(resultPath, payload) {
|
|
7
11
|
fs.mkdirSync(path.dirname(resultPath), { recursive: true });
|
|
8
12
|
fs.writeFileSync(resultPath, JSON.stringify(payload, null, 2), "utf8");
|
|
9
13
|
}
|
|
10
14
|
|
|
15
|
+
function writeProgress(payload) {
|
|
16
|
+
try {
|
|
17
|
+
process.stdout.write(`${PROGRESS_PREFIX}${JSON.stringify(payload)}\n`);
|
|
18
|
+
} catch {
|
|
19
|
+
// Best-effort only; never fail the job due to progress telemetry.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
function normalizeImportResult(importResult) {
|
|
12
24
|
const importPayload = {
|
|
13
25
|
source_dir: importResult.scrivenerDir,
|
|
@@ -49,10 +61,14 @@ function normalizeMergeResult(mergeResult) {
|
|
|
49
61
|
dry_run: mergeResult.dryRun,
|
|
50
62
|
sidecar_files: mergeResult.sidecarFiles,
|
|
51
63
|
updated: mergeResult.updated,
|
|
64
|
+
relocated: mergeResult.relocated,
|
|
52
65
|
unchanged: mergeResult.unchanged,
|
|
53
66
|
no_data: mergeResult.noData,
|
|
54
67
|
field_add_counts: mergeResult.fieldAddCounts,
|
|
55
68
|
preview_changes: mergeResult.previewChanges,
|
|
69
|
+
warnings: mergeResult.warnings,
|
|
70
|
+
warnings_truncated: mergeResult.warningsTruncated,
|
|
71
|
+
warning_summary: mergeResult.warningSummary,
|
|
56
72
|
stats: {
|
|
57
73
|
sync_map_entries: mergeResult.stats.syncMapEntries,
|
|
58
74
|
keyword_map_entries: mergeResult.stats.keywordMapEntries,
|
|
@@ -68,7 +84,14 @@ function normalizeMergeResult(mergeResult) {
|
|
|
68
84
|
};
|
|
69
85
|
}
|
|
70
86
|
|
|
71
|
-
function
|
|
87
|
+
function normalizeSceneCharacterBatchResult(batchResult) {
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
...batchResult,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
72
95
|
const requestPath = process.argv[2];
|
|
73
96
|
const resultPath = process.argv[3];
|
|
74
97
|
|
|
@@ -102,16 +125,43 @@ function main() {
|
|
|
102
125
|
projectId: request.args?.project_id,
|
|
103
126
|
scenesDir: request.args?.scenes_dir,
|
|
104
127
|
dryRun: Boolean(request.args?.dry_run),
|
|
128
|
+
organizeByChapters: Boolean(request.args?.organize_by_chapters),
|
|
105
129
|
});
|
|
106
130
|
writeResult(resultPath, normalizeMergeResult(result));
|
|
107
131
|
return;
|
|
108
132
|
}
|
|
109
133
|
|
|
134
|
+
if (request.kind === "enrich_scene_characters_batch") {
|
|
135
|
+
let cancellationRequested = false;
|
|
136
|
+
const handleSigterm = () => {
|
|
137
|
+
cancellationRequested = true;
|
|
138
|
+
};
|
|
139
|
+
process.on("SIGTERM", handleSigterm);
|
|
140
|
+
|
|
141
|
+
const result = await runSceneCharacterBatch({
|
|
142
|
+
syncDir,
|
|
143
|
+
args: {
|
|
144
|
+
project_id: request.args?.project_id,
|
|
145
|
+
dry_run: Boolean(request.args?.dry_run),
|
|
146
|
+
replace_mode: request.args?.replace_mode ?? "merge",
|
|
147
|
+
include_match_details: Boolean(request.args?.include_match_details),
|
|
148
|
+
project_exists: request.args?.project_exists !== false,
|
|
149
|
+
target_scenes: request.args?.target_scenes ?? [],
|
|
150
|
+
character_rows: request.args?.character_rows ?? [],
|
|
151
|
+
},
|
|
152
|
+
onProgress: progress => writeProgress({ kind: request.kind, ...progress }),
|
|
153
|
+
shouldCancel: () => cancellationRequested,
|
|
154
|
+
});
|
|
155
|
+
process.off("SIGTERM", handleSigterm);
|
|
156
|
+
writeResult(resultPath, normalizeSceneCharacterBatchResult(result));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
110
160
|
throw new Error(`Unsupported async job kind '${request.kind}'.`);
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
try {
|
|
114
|
-
main();
|
|
164
|
+
await main();
|
|
115
165
|
} catch (error) {
|
|
116
166
|
const resultPath = process.argv[3];
|
|
117
167
|
if (resultPath) {
|
|
@@ -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,
|