@hanna84/mcp-writing 1.10.0 → 1.11.1
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/index.js +229 -5
- package/package.json +2 -1
- package/scripts/async-job-runner.mjs +47 -2
- package/scripts/manual-scrivener-realtest.mjs +262 -0
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.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.11.0...v1.11.1)
|
|
9
|
+
|
|
10
|
+
- Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/59)
|
|
12
|
+
|
|
13
|
+
#### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.10.0...v1.11.0)
|
|
15
|
+
|
|
16
|
+
> 21 April 2026
|
|
17
|
+
|
|
18
|
+
- feat: add reusable manual real-data Scrivener test runner [`#58`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/58)
|
|
20
|
+
- Release 1.11.0 [`5938d51`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/5938d51b277dcce2f5fcfe728ff2112d3af644e5)
|
|
22
|
+
|
|
7
23
|
#### [v1.10.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.9.4...v1.10.0)
|
|
9
25
|
|
|
26
|
+
> 21 April 2026
|
|
27
|
+
|
|
10
28
|
- feat: Scrivener direct extraction beta (M1, M2, M2.5) [`#57`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/57)
|
|
30
|
+
- Release 1.10.0 [`504bd7f`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/504bd7facfaf11052e98127e2ae0a104259d8a57)
|
|
12
32
|
|
|
13
33
|
#### [v1.9.4](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.9.3...v1.9.4)
|
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/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";
|
|
@@ -1062,9 +1166,129 @@ function createMcpServer() {
|
|
|
1062
1166
|
}
|
|
1063
1167
|
);
|
|
1064
1168
|
|
|
1169
|
+
s.tool(
|
|
1170
|
+
"enrich_scene_characters_batch",
|
|
1171
|
+
"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.",
|
|
1172
|
+
{
|
|
1173
|
+
project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1174
|
+
scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
|
|
1175
|
+
part: z.number().int().optional().describe("Optional part number filter."),
|
|
1176
|
+
chapter: z.number().int().optional().describe("Optional chapter number filter."),
|
|
1177
|
+
only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
|
|
1178
|
+
dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
|
|
1179
|
+
replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
|
|
1180
|
+
max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
|
|
1181
|
+
include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
|
|
1182
|
+
confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
|
|
1183
|
+
},
|
|
1184
|
+
async ({
|
|
1185
|
+
project_id,
|
|
1186
|
+
scene_ids,
|
|
1187
|
+
part,
|
|
1188
|
+
chapter,
|
|
1189
|
+
only_stale = false,
|
|
1190
|
+
dry_run = true,
|
|
1191
|
+
replace_mode = "merge",
|
|
1192
|
+
max_scenes = 200,
|
|
1193
|
+
include_match_details = false,
|
|
1194
|
+
confirm_replace = false,
|
|
1195
|
+
}) => {
|
|
1196
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
1197
|
+
if (!projectIdCheck.ok) {
|
|
1198
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (replace_mode === "replace" && !confirm_replace) {
|
|
1202
|
+
return errorResponse(
|
|
1203
|
+
"VALIDATION_ERROR",
|
|
1204
|
+
"replace_mode=replace requires confirm_replace=true.",
|
|
1205
|
+
{ replace_mode, confirm_replace }
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (!dry_run && !SYNC_DIR_WRITABLE) {
|
|
1210
|
+
return errorResponse(
|
|
1211
|
+
"READ_ONLY",
|
|
1212
|
+
"Cannot run batch character enrichment in write mode: sync dir is read-only.",
|
|
1213
|
+
{ sync_dir: SYNC_DIR_ABS }
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const characterRows = db.prepare(`
|
|
1218
|
+
SELECT character_id, name
|
|
1219
|
+
FROM characters
|
|
1220
|
+
WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
|
|
1221
|
+
ORDER BY length(name) DESC
|
|
1222
|
+
`).all(project_id, project_id);
|
|
1223
|
+
|
|
1224
|
+
const targetResolution = resolveBatchTargetScenes(db, {
|
|
1225
|
+
projectId: project_id,
|
|
1226
|
+
sceneIds: scene_ids,
|
|
1227
|
+
part,
|
|
1228
|
+
chapter,
|
|
1229
|
+
onlyStale: Boolean(only_stale),
|
|
1230
|
+
});
|
|
1231
|
+
if (!targetResolution.ok) {
|
|
1232
|
+
return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const targetScenes = targetResolution.rows;
|
|
1236
|
+
const projectExists = targetResolution.project_exists !== false;
|
|
1237
|
+
if (targetScenes.length > max_scenes) {
|
|
1238
|
+
return errorResponse(
|
|
1239
|
+
"VALIDATION_ERROR",
|
|
1240
|
+
`Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
|
|
1241
|
+
{
|
|
1242
|
+
matched_scenes: targetScenes.length,
|
|
1243
|
+
max_scenes,
|
|
1244
|
+
project_id,
|
|
1245
|
+
}
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const job = startAsyncJob({
|
|
1250
|
+
kind: "enrich_scene_characters_batch",
|
|
1251
|
+
requestPayload: {
|
|
1252
|
+
kind: "enrich_scene_characters_batch",
|
|
1253
|
+
args: {
|
|
1254
|
+
project_id,
|
|
1255
|
+
dry_run: Boolean(dry_run),
|
|
1256
|
+
replace_mode,
|
|
1257
|
+
include_match_details: Boolean(include_match_details),
|
|
1258
|
+
project_exists: projectExists,
|
|
1259
|
+
target_scenes: targetScenes,
|
|
1260
|
+
character_rows: characterRows,
|
|
1261
|
+
},
|
|
1262
|
+
context: { sync_dir: SYNC_DIR },
|
|
1263
|
+
},
|
|
1264
|
+
onComplete: (completedJob) => {
|
|
1265
|
+
if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
|
|
1266
|
+
|
|
1267
|
+
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1268
|
+
|
|
1269
|
+
const changedScenes = (completedJob.result.results ?? [])
|
|
1270
|
+
.filter(row => row.status === "changed")
|
|
1271
|
+
.map(row => row.scene_id);
|
|
1272
|
+
|
|
1273
|
+
for (const sceneId of changedScenes) {
|
|
1274
|
+
db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
|
|
1275
|
+
.run(sceneId, project_id);
|
|
1276
|
+
}
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
return jsonResponse({
|
|
1281
|
+
ok: true,
|
|
1282
|
+
async: true,
|
|
1283
|
+
job: toPublicJob(job, false),
|
|
1284
|
+
next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1065
1289
|
s.tool(
|
|
1066
1290
|
"get_async_job_status",
|
|
1067
|
-
"Get status and result for an asynchronous job started by import_scrivener_sync_async or
|
|
1291
|
+
"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
1292
|
{
|
|
1069
1293
|
job_id: z.string().describe("Job ID returned by an async start tool."),
|
|
1070
1294
|
include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
|
|
@@ -1081,7 +1305,7 @@ function createMcpServer() {
|
|
|
1081
1305
|
|
|
1082
1306
|
s.tool(
|
|
1083
1307
|
"list_async_jobs",
|
|
1084
|
-
"List asynchronous
|
|
1308
|
+
"List asynchronous jobs currently known to this server.",
|
|
1085
1309
|
{
|
|
1086
1310
|
include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
|
|
1087
1311
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"start": "node --experimental-sqlite index.js",
|
|
25
25
|
"new:entity": "node scripts/new-world-entity.js",
|
|
26
|
+
"manual:realtest": "node scripts/manual-scrivener-realtest.mjs",
|
|
26
27
|
"setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
|
|
27
28
|
"release": "release-it",
|
|
28
29
|
"lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
|
|
@@ -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,
|
|
@@ -68,7 +80,14 @@ function normalizeMergeResult(mergeResult) {
|
|
|
68
80
|
};
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
function
|
|
83
|
+
function normalizeSceneCharacterBatchResult(batchResult) {
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
...batchResult,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
72
91
|
const requestPath = process.argv[2];
|
|
73
92
|
const resultPath = process.argv[3];
|
|
74
93
|
|
|
@@ -107,11 +126,37 @@ function main() {
|
|
|
107
126
|
return;
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
if (request.kind === "enrich_scene_characters_batch") {
|
|
130
|
+
let cancellationRequested = false;
|
|
131
|
+
const handleSigterm = () => {
|
|
132
|
+
cancellationRequested = true;
|
|
133
|
+
};
|
|
134
|
+
process.on("SIGTERM", handleSigterm);
|
|
135
|
+
|
|
136
|
+
const result = await runSceneCharacterBatch({
|
|
137
|
+
syncDir,
|
|
138
|
+
args: {
|
|
139
|
+
project_id: request.args?.project_id,
|
|
140
|
+
dry_run: Boolean(request.args?.dry_run),
|
|
141
|
+
replace_mode: request.args?.replace_mode ?? "merge",
|
|
142
|
+
include_match_details: Boolean(request.args?.include_match_details),
|
|
143
|
+
project_exists: request.args?.project_exists !== false,
|
|
144
|
+
target_scenes: request.args?.target_scenes ?? [],
|
|
145
|
+
character_rows: request.args?.character_rows ?? [],
|
|
146
|
+
},
|
|
147
|
+
onProgress: progress => writeProgress({ kind: request.kind, ...progress }),
|
|
148
|
+
shouldCancel: () => cancellationRequested,
|
|
149
|
+
});
|
|
150
|
+
process.off("SIGTERM", handleSigterm);
|
|
151
|
+
writeResult(resultPath, normalizeSceneCharacterBatchResult(result));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
110
155
|
throw new Error(`Unsupported async job kind '${request.kind}'.`);
|
|
111
156
|
}
|
|
112
157
|
|
|
113
158
|
try {
|
|
114
|
-
main();
|
|
159
|
+
await main();
|
|
115
160
|
} catch (error) {
|
|
116
161
|
const resultPath = process.argv[3];
|
|
117
162
|
if (resultPath) {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { importScrivenerSync, validateProjectId } from "../importer.js";
|
|
5
|
+
import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
return [
|
|
9
|
+
"Usage:",
|
|
10
|
+
" node scripts/manual-scrivener-realtest.mjs \\",
|
|
11
|
+
" --source-dir <external-sync-dir> \\",
|
|
12
|
+
" --scriv-path <copied-project.scriv> \\",
|
|
13
|
+
" --project-id <project|universe/project> [options]",
|
|
14
|
+
"",
|
|
15
|
+
"Keep large real-data test assets outside the repository so they cannot be",
|
|
16
|
+
"accidentally committed. Example external storage location:",
|
|
17
|
+
" $HOME/.mcp-writing-manual-data/",
|
|
18
|
+
"",
|
|
19
|
+
"Options:",
|
|
20
|
+
" --help Show this help message.",
|
|
21
|
+
" --sync-dir <path> Temp sync root to write into.",
|
|
22
|
+
" Default: ./tmp/manual-realtest-sync",
|
|
23
|
+
" --sample-count <n> Number of sample sidecars to include. Default: 5",
|
|
24
|
+
" --no-clean Reuse existing sync dir instead of recreating it.",
|
|
25
|
+
"",
|
|
26
|
+
"Example:",
|
|
27
|
+
" npm run manual:realtest -- \\",
|
|
28
|
+
" --source-dir <path-to-external-sync-source> \\",
|
|
29
|
+
" --scriv-path <path-to-external-test-data>/<project-name>.scriv \\",
|
|
30
|
+
" --project-id <universe>/<project-name> \\",
|
|
31
|
+
" --sync-dir <path-to-external-test-data>/manual-realtest-sync",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const options = {
|
|
37
|
+
syncDir: "./tmp/manual-realtest-sync",
|
|
38
|
+
sampleCount: 5,
|
|
39
|
+
clean: true,
|
|
40
|
+
help: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < argv.length; index++) {
|
|
44
|
+
const arg = argv[index];
|
|
45
|
+
if (arg === "--help") {
|
|
46
|
+
options.help = true;
|
|
47
|
+
} else if (arg === "--source-dir") {
|
|
48
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
49
|
+
options.sourceDir = argv[++index];
|
|
50
|
+
} else if (arg === "--scriv-path") {
|
|
51
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
52
|
+
options.scrivPath = argv[++index];
|
|
53
|
+
} else if (arg === "--project-id") {
|
|
54
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
55
|
+
options.projectId = argv[++index];
|
|
56
|
+
} else if (arg === "--sync-dir") {
|
|
57
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
58
|
+
options.syncDir = argv[++index];
|
|
59
|
+
} else if (arg === "--sample-count") {
|
|
60
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
61
|
+
const sampleCountValue = argv[++index];
|
|
62
|
+
if (!/^\d+$/.test(sampleCountValue)) {
|
|
63
|
+
throw new Error("--sample-count must be a positive integer.");
|
|
64
|
+
}
|
|
65
|
+
options.sampleCount = Number(sampleCountValue);
|
|
66
|
+
} else if (arg === "--no-clean") {
|
|
67
|
+
options.clean = false;
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return options;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateOptions(options) {
|
|
77
|
+
if (options.help) return;
|
|
78
|
+
|
|
79
|
+
if (!options.sourceDir || !options.scrivPath || !options.projectId) {
|
|
80
|
+
throw new Error("Missing required arguments: --source-dir, --scriv-path, --project-id are required.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!Number.isInteger(options.sampleCount) || options.sampleCount < 1) {
|
|
84
|
+
throw new Error("--sample-count must be a positive integer.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function walkSidecars(dir, out = []) {
|
|
89
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
90
|
+
const fullPath = path.join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) walkSidecars(fullPath, out);
|
|
92
|
+
else if (entry.name.endsWith(".meta.yaml")) out.push(fullPath);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function summarizeImport(result) {
|
|
98
|
+
return {
|
|
99
|
+
projectId: result.projectId,
|
|
100
|
+
scenesDir: result.scenesDir,
|
|
101
|
+
sourceFiles: result.sourceFiles,
|
|
102
|
+
created: result.created,
|
|
103
|
+
existing: result.existing,
|
|
104
|
+
skipped: result.skipped,
|
|
105
|
+
beatMarkersSeen: result.beatMarkersSeen,
|
|
106
|
+
ignoredFiles: result.ignoredFiles,
|
|
107
|
+
filesToProcess: result.filesToProcess,
|
|
108
|
+
existingSidecars: result.existingSidecars,
|
|
109
|
+
filePreviews: result.filePreviews,
|
|
110
|
+
dryRun: result.dryRun,
|
|
111
|
+
preflight: result.preflight,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function summarizeMerge(result) {
|
|
116
|
+
return {
|
|
117
|
+
projectId: result.projectId,
|
|
118
|
+
scenesDir: result.scenesDir,
|
|
119
|
+
sidecarFiles: result.sidecarFiles,
|
|
120
|
+
updated: result.updated,
|
|
121
|
+
unchanged: result.unchanged,
|
|
122
|
+
skippedNoBracketId: result.skippedNoBracketId,
|
|
123
|
+
noData: result.noData,
|
|
124
|
+
fieldAddCounts: result.fieldAddCounts,
|
|
125
|
+
previewChanges: result.previewChanges,
|
|
126
|
+
stats: result.stats,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runStep(name, fn) {
|
|
131
|
+
try {
|
|
132
|
+
return { name, ok: true, result: fn() };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
ok: false,
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isSafeToDeleteSync(syncDir) {
|
|
143
|
+
const resolvedPath = path.resolve(syncDir);
|
|
144
|
+
|
|
145
|
+
// Never delete root or home directory
|
|
146
|
+
const parsed = path.parse(resolvedPath);
|
|
147
|
+
if (resolvedPath === parsed.root || resolvedPath === path.resolve(process.env.HOME || os.homedir())) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Allow deletion only if path contains a manual-realtest marker or is in ./tmp or /tmp
|
|
152
|
+
const hasMarker = resolvedPath.includes("manual-realtest");
|
|
153
|
+
|
|
154
|
+
// Check if in tmp directories with proper path boundary checking
|
|
155
|
+
const localTmpDir = path.resolve("./tmp");
|
|
156
|
+
const isInLocalTmp =
|
|
157
|
+
resolvedPath === localTmpDir || resolvedPath.startsWith(localTmpDir + path.sep);
|
|
158
|
+
const isInSystemTmp =
|
|
159
|
+
resolvedPath === "/tmp" || resolvedPath.startsWith("/tmp" + path.sep);
|
|
160
|
+
const inTmpDir = isInLocalTmp || isInSystemTmp;
|
|
161
|
+
|
|
162
|
+
return hasMarker || inTmpDir;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
const options = parseArgs(process.argv.slice(2));
|
|
167
|
+
|
|
168
|
+
if (options.help) {
|
|
169
|
+
console.log(usage());
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
validateOptions(options);
|
|
174
|
+
|
|
175
|
+
const projectIdCheck = validateProjectId(options.projectId);
|
|
176
|
+
if (!projectIdCheck.ok) {
|
|
177
|
+
throw new Error(`Invalid --project-id '${options.projectId}': ${projectIdCheck.reason}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const syncDir = path.resolve(options.syncDir);
|
|
181
|
+
const sourceDir = path.resolve(options.sourceDir);
|
|
182
|
+
const scrivPath = path.resolve(options.scrivPath);
|
|
183
|
+
|
|
184
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
185
|
+
throw new Error(`--source-dir not found or not a directory: ${sourceDir}`);
|
|
186
|
+
}
|
|
187
|
+
if (!fs.existsSync(scrivPath) || !fs.statSync(scrivPath).isDirectory()) {
|
|
188
|
+
throw new Error(`--scriv-path not found or not a directory: ${scrivPath}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (options.clean) {
|
|
192
|
+
if (!isSafeToDeleteSync(syncDir)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Safety check failed: --sync-dir must contain 'manual-realtest' or be in ./tmp or /tmp. Got: ${syncDir}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
fs.rmSync(syncDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
fs.mkdirSync(syncDir, { recursive: true });
|
|
200
|
+
|
|
201
|
+
const report = {
|
|
202
|
+
syncDir,
|
|
203
|
+
sourceDir,
|
|
204
|
+
scrivPath,
|
|
205
|
+
projectId: options.projectId,
|
|
206
|
+
cleanStart: options.clean,
|
|
207
|
+
tests: [],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
report.tests.push(runStep("import_preflight", () => summarizeImport(importScrivenerSync({
|
|
211
|
+
scrivenerDir: sourceDir,
|
|
212
|
+
mcpSyncDir: syncDir,
|
|
213
|
+
projectId: options.projectId,
|
|
214
|
+
dryRun: true,
|
|
215
|
+
preflight: true,
|
|
216
|
+
}))));
|
|
217
|
+
|
|
218
|
+
report.tests.push(runStep("import_write", () => summarizeImport(importScrivenerSync({
|
|
219
|
+
scrivenerDir: sourceDir,
|
|
220
|
+
mcpSyncDir: syncDir,
|
|
221
|
+
projectId: options.projectId,
|
|
222
|
+
dryRun: false,
|
|
223
|
+
preflight: false,
|
|
224
|
+
}))));
|
|
225
|
+
|
|
226
|
+
report.tests.push(runStep("merge_dry_run", () => summarizeMerge(mergeScrivenerProjectMetadata({
|
|
227
|
+
scrivPath,
|
|
228
|
+
mcpSyncDir: syncDir,
|
|
229
|
+
projectId: options.projectId,
|
|
230
|
+
dryRun: true,
|
|
231
|
+
}))));
|
|
232
|
+
|
|
233
|
+
report.tests.push(runStep("merge_write", () => summarizeMerge(mergeScrivenerProjectMetadata({
|
|
234
|
+
scrivPath,
|
|
235
|
+
mcpSyncDir: syncDir,
|
|
236
|
+
projectId: options.projectId,
|
|
237
|
+
dryRun: false,
|
|
238
|
+
}))));
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
// Use scenesDir from import_write result (the actual written path) instead of re-deriving
|
|
242
|
+
const importWriteResult = report.tests.find((test) => test.name === "import_write" && test.ok)?.result;
|
|
243
|
+
const scenesDir = importWriteResult?.scenesDir;
|
|
244
|
+
|
|
245
|
+
const sidecars = scenesDir && fs.existsSync(scenesDir) ? walkSidecars(scenesDir) : [];
|
|
246
|
+
report.sidecarCount = sidecars.length;
|
|
247
|
+
report.sampleSidecars = sidecars
|
|
248
|
+
.slice(0, options.sampleCount)
|
|
249
|
+
.map((filePath) => path.relative(syncDir, filePath));
|
|
250
|
+
|
|
251
|
+
const failures = report.tests.filter((test) => !test.ok);
|
|
252
|
+
console.log(JSON.stringify(report, null, 2));
|
|
253
|
+
if (failures.length) process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
main();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
260
|
+
console.error(usage());
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|