@hanna84/mcp-writing 2.9.9 → 2.10.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/db.js +73 -0
- package/index.js +37 -1
- package/package.json +4 -1
- package/review-bundles-planner.js +325 -0
- package/review-bundles-renderer.js +498 -0
- package/review-bundles-writer.js +163 -0
- package/review-bundles.js +14 -998
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
|
+
#### [v2.10.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.10.0...v2.10.1)
|
|
9
|
+
|
|
10
|
+
- refactor(review-bundles): split 997-line module into planner/renderer/writer [`#105`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/105)
|
|
12
|
+
|
|
13
|
+
#### [v2.10.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.9.9...v2.10.0)
|
|
15
|
+
|
|
16
|
+
> 26 April 2026
|
|
17
|
+
|
|
18
|
+
- feat(db): persist async job state to SQLite for restart recovery [`#104`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/104)
|
|
20
|
+
- Release 2.10.0 [`0ec1fcb`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/0ec1fcb77dc112270940b6f2ea56cd58f4da1c6a)
|
|
22
|
+
|
|
7
23
|
#### [v2.9.9](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.9.8...v2.9.9)
|
|
9
25
|
|
|
26
|
+
> 26 April 2026
|
|
27
|
+
|
|
10
28
|
- refactor(db): replace ad-hoc migration checks with numbered migration… [`#102`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/102)
|
|
30
|
+
- Release 2.9.9 [`22babc1`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/22babc1e857a3401240c28b5ac59164e01fd3784)
|
|
12
32
|
|
|
13
33
|
#### [v2.9.8](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.9.7...v2.9.8)
|
package/db.js
CHANGED
|
@@ -124,6 +124,17 @@ export const SCHEMA = `
|
|
|
124
124
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
125
125
|
version INTEGER NOT NULL
|
|
126
126
|
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS async_jobs (
|
|
129
|
+
job_id TEXT NOT NULL PRIMARY KEY,
|
|
130
|
+
kind TEXT NOT NULL,
|
|
131
|
+
status TEXT NOT NULL,
|
|
132
|
+
created_at TEXT NOT NULL,
|
|
133
|
+
started_at TEXT,
|
|
134
|
+
finished_at TEXT,
|
|
135
|
+
error TEXT,
|
|
136
|
+
result_json TEXT
|
|
137
|
+
);
|
|
127
138
|
`;
|
|
128
139
|
|
|
129
140
|
// Each function is applied exactly once, in order, when version < its index+1.
|
|
@@ -193,3 +204,65 @@ export function openDb(dbPath) {
|
|
|
193
204
|
applyMigrations(db);
|
|
194
205
|
return db;
|
|
195
206
|
}
|
|
207
|
+
|
|
208
|
+
export function checkpointJobCreate(db, job) {
|
|
209
|
+
db.prepare(`
|
|
210
|
+
INSERT OR IGNORE INTO async_jobs (job_id, kind, status, created_at, started_at)
|
|
211
|
+
VALUES (?, ?, ?, ?, ?)
|
|
212
|
+
`).run(job.id, job.kind, job.status, job.createdAt, job.startedAt ?? null);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function checkpointJobFinish(db, job) {
|
|
216
|
+
// UPSERT so a terminal state is always recorded even if checkpointJobCreate
|
|
217
|
+
// was skipped due to a best-effort failure.
|
|
218
|
+
db.prepare(`
|
|
219
|
+
INSERT INTO async_jobs
|
|
220
|
+
(job_id, kind, status, created_at, started_at, finished_at, error, result_json)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
222
|
+
ON CONFLICT(job_id) DO UPDATE SET
|
|
223
|
+
status = excluded.status,
|
|
224
|
+
finished_at = excluded.finished_at,
|
|
225
|
+
error = excluded.error,
|
|
226
|
+
result_json = excluded.result_json
|
|
227
|
+
`).run(
|
|
228
|
+
job.id,
|
|
229
|
+
job.kind,
|
|
230
|
+
job.status,
|
|
231
|
+
job.createdAt,
|
|
232
|
+
job.startedAt ?? null,
|
|
233
|
+
job.finishedAt ?? null,
|
|
234
|
+
job.error ?? null,
|
|
235
|
+
job.result != null ? JSON.stringify(job.result) : null
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function pruneJobCheckpoints(db, ttlMs) {
|
|
240
|
+
const cutoff = new Date(Date.now() - ttlMs).toISOString();
|
|
241
|
+
db.prepare(`
|
|
242
|
+
DELETE FROM async_jobs WHERE finished_at IS NOT NULL AND finished_at < ?
|
|
243
|
+
`).run(cutoff);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function loadStalledJobs(db) {
|
|
247
|
+
// 'cancelling' included defensively; in practice only 'running' rows exist
|
|
248
|
+
// since we never write a 'cancelling' checkpoint between create and finish.
|
|
249
|
+
return db.prepare(`
|
|
250
|
+
SELECT job_id, kind, status, created_at, started_at
|
|
251
|
+
FROM async_jobs WHERE status IN ('running', 'cancelling')
|
|
252
|
+
`).all().map(row => ({
|
|
253
|
+
id: row.job_id,
|
|
254
|
+
kind: row.kind,
|
|
255
|
+
status: row.status,
|
|
256
|
+
createdAt: row.created_at,
|
|
257
|
+
startedAt: row.started_at ?? null,
|
|
258
|
+
finishedAt: null,
|
|
259
|
+
error: null,
|
|
260
|
+
result: null,
|
|
261
|
+
progress: null,
|
|
262
|
+
child: null,
|
|
263
|
+
onComplete: null,
|
|
264
|
+
tmpDir: null,
|
|
265
|
+
requestPath: null,
|
|
266
|
+
resultPath: null,
|
|
267
|
+
}));
|
|
268
|
+
}
|
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import matter from "gray-matter";
|
|
12
12
|
import yaml from "js-yaml";
|
|
13
|
-
import { openDb } from "./db.js";
|
|
13
|
+
import { openDb, checkpointJobCreate, checkpointJobFinish, loadStalledJobs, pruneJobCheckpoints } from "./db.js";
|
|
14
14
|
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, sidecarPath, isStructuralProjectId } from "./sync.js";
|
|
15
15
|
import { isGitAvailable, isGitRepository, initGitRepository, getSceneProseAtCommit } from "./git.js";
|
|
16
16
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
@@ -158,6 +158,7 @@ const asyncJobs = new Map();
|
|
|
158
158
|
|
|
159
159
|
function pruneAsyncJobs() {
|
|
160
160
|
const now = Date.now();
|
|
161
|
+
let anyPruned = false;
|
|
161
162
|
for (const [id, job] of asyncJobs.entries()) {
|
|
162
163
|
if (!job.finishedAt) continue;
|
|
163
164
|
if (now - Date.parse(job.finishedAt) > ASYNC_JOB_TTL_MS) {
|
|
@@ -172,8 +173,12 @@ function pruneAsyncJobs() {
|
|
|
172
173
|
// best effort cleanup
|
|
173
174
|
}
|
|
174
175
|
asyncJobs.delete(id);
|
|
176
|
+
anyPruned = true;
|
|
175
177
|
}
|
|
176
178
|
}
|
|
179
|
+
if (anyPruned) {
|
|
180
|
+
try { pruneJobCheckpoints(db, ASYNC_JOB_TTL_MS); } catch { /* best effort */ }
|
|
181
|
+
}
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
function readJsonIfExists(filePath) {
|
|
@@ -240,6 +245,11 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
240
245
|
child,
|
|
241
246
|
};
|
|
242
247
|
asyncJobs.set(id, job);
|
|
248
|
+
try {
|
|
249
|
+
checkpointJobCreate(db, job);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
process.stderr.write(`[mcp-writing] WARNING: failed to checkpoint job ${id}: ${err.message}\n`);
|
|
252
|
+
}
|
|
243
253
|
|
|
244
254
|
let stdoutBuffer = "";
|
|
245
255
|
child.stdout.on("data", (chunk) => {
|
|
@@ -276,12 +286,14 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
276
286
|
job.status = "cancelled";
|
|
277
287
|
job.error = error.message;
|
|
278
288
|
job.finishedAt = new Date().toISOString();
|
|
289
|
+
try { checkpointJobFinish(db, job); } catch { /* best effort */ }
|
|
279
290
|
pruneAsyncJobs();
|
|
280
291
|
return;
|
|
281
292
|
}
|
|
282
293
|
job.status = "failed";
|
|
283
294
|
job.error = error.message;
|
|
284
295
|
job.finishedAt = new Date().toISOString();
|
|
296
|
+
try { checkpointJobFinish(db, job); } catch { /* best effort */ }
|
|
285
297
|
pruneAsyncJobs();
|
|
286
298
|
});
|
|
287
299
|
|
|
@@ -322,6 +334,7 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
322
334
|
job.error = cancelledBySignal
|
|
323
335
|
? `Async job cancelled by signal ${signal}.`
|
|
324
336
|
: payload?.error?.message ?? payload?.error ?? "Async job cancelled.";
|
|
337
|
+
try { checkpointJobFinish(db, job); } catch { /* best effort */ }
|
|
325
338
|
pruneAsyncJobs();
|
|
326
339
|
return;
|
|
327
340
|
}
|
|
@@ -344,6 +357,7 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
|
|
|
344
357
|
job.error = error instanceof Error ? error.message : String(error);
|
|
345
358
|
}
|
|
346
359
|
}
|
|
360
|
+
try { checkpointJobFinish(db, job); } catch { /* best effort */ }
|
|
347
361
|
pruneAsyncJobs();
|
|
348
362
|
});
|
|
349
363
|
|
|
@@ -646,6 +660,28 @@ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId,
|
|
|
646
660
|
// ---------------------------------------------------------------------------
|
|
647
661
|
const db = openDb(DB_PATH);
|
|
648
662
|
|
|
663
|
+
// Recover jobs that were in-flight when the server last exited.
|
|
664
|
+
const stalledJobs = loadStalledJobs(db);
|
|
665
|
+
for (const job of stalledJobs) {
|
|
666
|
+
job.status = "failed";
|
|
667
|
+
job.error = "server restarted while job was running";
|
|
668
|
+
job.finishedAt = new Date().toISOString();
|
|
669
|
+
try {
|
|
670
|
+
checkpointJobFinish(db, job);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
process.stderr.write(`[mcp-writing] WARNING: failed to checkpoint recovered stalled job ${job.id}: ${err.message}\n`);
|
|
673
|
+
}
|
|
674
|
+
asyncJobs.set(job.id, job);
|
|
675
|
+
}
|
|
676
|
+
// Prune expired rows from previous sessions unconditionally — completed/failed
|
|
677
|
+
// jobs from prior runs are never loaded into asyncJobs, so anyPruned in
|
|
678
|
+
// pruneAsyncJobs() would never be true for them.
|
|
679
|
+
try { pruneJobCheckpoints(db, ASYNC_JOB_TTL_MS); } catch { /* best effort */ }
|
|
680
|
+
|
|
681
|
+
if (stalledJobs.length > 0) {
|
|
682
|
+
process.stderr.write(`[mcp-writing] Marked ${stalledJobs.length} stalled job(s) as failed after restart.\n`);
|
|
683
|
+
}
|
|
684
|
+
|
|
649
685
|
process.stderr.write(`[mcp-writing] Sync dir: ${SYNC_DIR_ABS}\n`);
|
|
650
686
|
process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
|
|
651
687
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.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",
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"metadata-lint.js",
|
|
18
18
|
"scene-character-normalization.js",
|
|
19
19
|
"review-bundles.js",
|
|
20
|
+
"review-bundles-planner.js",
|
|
21
|
+
"review-bundles-renderer.js",
|
|
22
|
+
"review-bundles-writer.js",
|
|
20
23
|
"prose-styleguide.js",
|
|
21
24
|
"prose-styleguide-drift.js",
|
|
22
25
|
"prose-styleguide-skill.js",
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
|
|
2
|
+
|
|
3
|
+
export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
|
|
4
|
+
export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
|
|
5
|
+
export const REVIEW_BUNDLE_FORMATS = ["pdf", "markdown", "both"];
|
|
6
|
+
|
|
7
|
+
export class ReviewBundlePlanError extends Error {
|
|
8
|
+
constructor(code, message, details) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ReviewBundlePlanError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeRecipientDisplayName(recipientName) {
|
|
17
|
+
const normalized = String(recipientName ?? "")
|
|
18
|
+
.replace(/[\x00-\x1f\x7f]+/g, " ")
|
|
19
|
+
.replace(/\s+/g, " ")
|
|
20
|
+
.trim()
|
|
21
|
+
.slice(0, 100);
|
|
22
|
+
|
|
23
|
+
return normalized || "Beta Reader";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeSortNumber(value) {
|
|
27
|
+
return Number.isInteger(value) ? value : MAX_SORT_VALUE;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sceneSort(a, b) {
|
|
31
|
+
const partDiff = normalizeSortNumber(a.part) - normalizeSortNumber(b.part);
|
|
32
|
+
if (partDiff !== 0) return partDiff;
|
|
33
|
+
|
|
34
|
+
const chapterDiff = normalizeSortNumber(a.chapter) - normalizeSortNumber(b.chapter);
|
|
35
|
+
if (chapterDiff !== 0) return chapterDiff;
|
|
36
|
+
|
|
37
|
+
const timelineDiff = normalizeSortNumber(a.timeline_position) - normalizeSortNumber(b.timeline_position);
|
|
38
|
+
if (timelineDiff !== 0) return timelineDiff;
|
|
39
|
+
|
|
40
|
+
return String(a.scene_id).localeCompare(String(b.scene_id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildWarningSummary(warnings) {
|
|
44
|
+
const summary = {};
|
|
45
|
+
for (const warning of warnings) {
|
|
46
|
+
const type = warning.type ?? "unknown";
|
|
47
|
+
if (!summary[type]) {
|
|
48
|
+
summary[type] = { count: 0, examples: [] };
|
|
49
|
+
}
|
|
50
|
+
summary[type].count += 1;
|
|
51
|
+
if (summary[type].examples.length < 5) {
|
|
52
|
+
summary[type].examples.push(warning.message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return summary;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function slugifyBundleName(value) {
|
|
59
|
+
const slug = String(value ?? "")
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/^-+|-+$/g, "");
|
|
64
|
+
return slug || "review-bundle";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertProfile(profile) {
|
|
68
|
+
if (!REVIEW_BUNDLE_PROFILES.includes(profile)) {
|
|
69
|
+
throw new ReviewBundlePlanError(
|
|
70
|
+
"INVALID_PROFILE",
|
|
71
|
+
`Unsupported review bundle profile '${profile}'.`,
|
|
72
|
+
{ supported_profiles: REVIEW_BUNDLE_PROFILES }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertStrictness(strictness) {
|
|
78
|
+
if (!REVIEW_BUNDLE_STRICTNESS.includes(strictness)) {
|
|
79
|
+
throw new ReviewBundlePlanError(
|
|
80
|
+
"INVALID_STRICTNESS",
|
|
81
|
+
`Unsupported strictness '${strictness}'.`,
|
|
82
|
+
{ supported_strictness: REVIEW_BUNDLE_STRICTNESS }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertFormat(format) {
|
|
88
|
+
if (!REVIEW_BUNDLE_FORMATS.includes(format)) {
|
|
89
|
+
throw new ReviewBundlePlanError(
|
|
90
|
+
"INVALID_FORMAT",
|
|
91
|
+
`Unsupported format '${format}'.`,
|
|
92
|
+
{ supported_formats: REVIEW_BUNDLE_FORMATS }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
|
|
98
|
+
if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
|
|
99
|
+
return { requested: [], existing: new Set() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const placeholders = sceneIds.map(() => "?").join(",");
|
|
103
|
+
const rows = dbHandle.prepare(
|
|
104
|
+
`SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
|
|
105
|
+
).all(projectId, ...sceneIds);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
requested: sceneIds,
|
|
109
|
+
existing: new Set(rows.map(row => row.scene_id)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildReviewBundlePlan(dbHandle, {
|
|
114
|
+
project_id,
|
|
115
|
+
profile,
|
|
116
|
+
part,
|
|
117
|
+
chapter,
|
|
118
|
+
tag,
|
|
119
|
+
scene_ids,
|
|
120
|
+
strictness = "warn",
|
|
121
|
+
include_scene_ids = true,
|
|
122
|
+
include_metadata_sidebar = false,
|
|
123
|
+
include_paragraph_anchors = false,
|
|
124
|
+
bundle_name,
|
|
125
|
+
recipient_name,
|
|
126
|
+
format = "pdf",
|
|
127
|
+
} = {}) {
|
|
128
|
+
if (!project_id) {
|
|
129
|
+
throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
assertProfile(profile);
|
|
133
|
+
assertStrictness(strictness);
|
|
134
|
+
assertFormat(format);
|
|
135
|
+
|
|
136
|
+
const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
|
|
137
|
+
if (!projectRow) {
|
|
138
|
+
throw new ReviewBundlePlanError("NOT_FOUND", `Project '${project_id}' not found.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
|
|
142
|
+
const conditions = ["s.project_id = ?"];
|
|
143
|
+
const params = [project_id];
|
|
144
|
+
const joins = [];
|
|
145
|
+
|
|
146
|
+
if (tag) {
|
|
147
|
+
joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?");
|
|
148
|
+
params.push(tag);
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(scene_ids) && scene_ids.length > 0) {
|
|
151
|
+
const placeholders = scene_ids.map(() => "?").join(",");
|
|
152
|
+
conditions.push(`s.scene_id IN (${placeholders})`);
|
|
153
|
+
params.push(...scene_ids);
|
|
154
|
+
}
|
|
155
|
+
if (part !== undefined) {
|
|
156
|
+
conditions.push("s.part = ?");
|
|
157
|
+
params.push(part);
|
|
158
|
+
}
|
|
159
|
+
if (chapter !== undefined) {
|
|
160
|
+
conditions.push("s.chapter = ?");
|
|
161
|
+
params.push(chapter);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let query = `
|
|
165
|
+
SELECT DISTINCT
|
|
166
|
+
s.scene_id,
|
|
167
|
+
s.project_id,
|
|
168
|
+
s.title,
|
|
169
|
+
s.part,
|
|
170
|
+
s.chapter,
|
|
171
|
+
s.timeline_position,
|
|
172
|
+
s.word_count,
|
|
173
|
+
s.logline,
|
|
174
|
+
s.pov,
|
|
175
|
+
s.save_the_cat_beat,
|
|
176
|
+
s.metadata_stale
|
|
177
|
+
FROM scenes s
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
if (joins.length > 0) {
|
|
181
|
+
query += ` ${joins.join(" ")}`;
|
|
182
|
+
}
|
|
183
|
+
query += ` WHERE ${conditions.join(" AND ")}`;
|
|
184
|
+
|
|
185
|
+
const rows = dbHandle.prepare(query).all(...params).sort(sceneSort);
|
|
186
|
+
if (rows.length === 0) {
|
|
187
|
+
throw new ReviewBundlePlanError(
|
|
188
|
+
"NO_RESULTS",
|
|
189
|
+
"No scenes matched the requested review bundle scope.",
|
|
190
|
+
{
|
|
191
|
+
project_id,
|
|
192
|
+
filters: {
|
|
193
|
+
...(part !== undefined ? { part } : {}),
|
|
194
|
+
...(chapter !== undefined ? { chapter } : {}),
|
|
195
|
+
...(tag ? { tag } : {}),
|
|
196
|
+
...(Array.isArray(scene_ids) ? { scene_ids } : {}),
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const includedSceneIds = new Set(rows.map(row => row.scene_id));
|
|
203
|
+
const excludedSceneIds = requestedSceneIds.requested.filter(sceneId => !includedSceneIds.has(sceneId));
|
|
204
|
+
const notFoundSceneIds = requestedSceneIds.requested.filter(sceneId => !requestedSceneIds.existing.has(sceneId));
|
|
205
|
+
const filteredOutSceneIds = excludedSceneIds.filter(sceneId => requestedSceneIds.existing.has(sceneId));
|
|
206
|
+
|
|
207
|
+
const warnings = [];
|
|
208
|
+
|
|
209
|
+
if (notFoundSceneIds.length > 0) {
|
|
210
|
+
warnings.push({
|
|
211
|
+
type: "requested_scene_ids_not_found",
|
|
212
|
+
message: `${notFoundSceneIds.length} requested scene_id value(s) do not exist in project '${project_id}'.`,
|
|
213
|
+
scene_ids: notFoundSceneIds,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (filteredOutSceneIds.length > 0) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
type: "requested_scene_ids_filtered_out",
|
|
220
|
+
message: `${filteredOutSceneIds.length} requested scene_id value(s) were excluded by additional filters.`,
|
|
221
|
+
scene_ids: filteredOutSceneIds,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const staleRows = rows.filter(row => Number(row.metadata_stale) === 1);
|
|
226
|
+
if (staleRows.length > 0) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
type: "metadata_stale",
|
|
229
|
+
message: `${staleRows.length} scene(s) have stale metadata and may need re-enrichment before editorial use.`,
|
|
230
|
+
count: staleRows.length,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const missingOrderingRows = rows.filter(
|
|
235
|
+
row => row.part == null || row.chapter == null || row.timeline_position == null
|
|
236
|
+
);
|
|
237
|
+
if (missingOrderingRows.length > 0) {
|
|
238
|
+
warnings.push({
|
|
239
|
+
type: "missing_ordering_fields",
|
|
240
|
+
message: `${missingOrderingRows.length} scene(s) are missing part/chapter/timeline_position metadata; fallback ordering was applied.`,
|
|
241
|
+
count: missingOrderingRows.length,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const missingWordCountRows = rows.filter(row => row.word_count == null);
|
|
246
|
+
if (missingWordCountRows.length > 0) {
|
|
247
|
+
warnings.push({
|
|
248
|
+
type: "missing_word_count",
|
|
249
|
+
message: `${missingWordCountRows.length} scene(s) are missing word_count; estimated_word_count may be low.`,
|
|
250
|
+
count: missingWordCountRows.length,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const blockers = [];
|
|
255
|
+
if (strictness === "fail" && staleRows.length > 0) {
|
|
256
|
+
blockers.push({
|
|
257
|
+
code: "STALE_METADATA",
|
|
258
|
+
message: `${staleRows.length} scene(s) are marked metadata_stale.`,
|
|
259
|
+
scene_ids: staleRows.map(row => row.scene_id),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const estimatedWordCount = rows.reduce((sum, row) => {
|
|
264
|
+
const count = Number(row.word_count);
|
|
265
|
+
return sum + (Number.isFinite(count) ? count : 0);
|
|
266
|
+
}, 0);
|
|
267
|
+
const resolvedRecipientName = profile === "beta_reader_personalized"
|
|
268
|
+
? normalizeRecipientDisplayName(recipient_name)
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
|
|
272
|
+
const appliedFilters = {
|
|
273
|
+
...(part !== undefined ? { part } : {}),
|
|
274
|
+
...(chapter !== undefined ? { chapter } : {}),
|
|
275
|
+
...(tag ? { tag } : {}),
|
|
276
|
+
...(Array.isArray(scene_ids) ? { scene_ids } : {}),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
profile,
|
|
282
|
+
resolved_scope: {
|
|
283
|
+
project_id,
|
|
284
|
+
filters: appliedFilters,
|
|
285
|
+
options: {
|
|
286
|
+
include_scene_ids: Boolean(include_scene_ids),
|
|
287
|
+
include_metadata_sidebar: Boolean(include_metadata_sidebar),
|
|
288
|
+
include_paragraph_anchors: Boolean(include_paragraph_anchors),
|
|
289
|
+
...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
ordering: rows.map(row => ({
|
|
293
|
+
scene_id: row.scene_id,
|
|
294
|
+
project_id: row.project_id,
|
|
295
|
+
title: row.title,
|
|
296
|
+
part: row.part,
|
|
297
|
+
chapter: row.chapter,
|
|
298
|
+
timeline_position: row.timeline_position,
|
|
299
|
+
metadata_stale: Number(row.metadata_stale) === 1,
|
|
300
|
+
})),
|
|
301
|
+
summary: {
|
|
302
|
+
scene_count: rows.length,
|
|
303
|
+
estimated_word_count: estimatedWordCount,
|
|
304
|
+
excluded_scene_ids: excludedSceneIds,
|
|
305
|
+
},
|
|
306
|
+
warnings,
|
|
307
|
+
warning_summary: buildWarningSummary(warnings),
|
|
308
|
+
strictness_result: {
|
|
309
|
+
strictness,
|
|
310
|
+
can_proceed: blockers.length === 0,
|
|
311
|
+
blockers,
|
|
312
|
+
},
|
|
313
|
+
planned_outputs: [
|
|
314
|
+
...(format === "markdown" || format === "both" ? [`${safeBundleName}.md`] : []),
|
|
315
|
+
...(format === "pdf" || format === "both" ? [`${safeBundleName}.pdf`] : []),
|
|
316
|
+
...(profile === "beta_reader_personalized"
|
|
317
|
+
? [
|
|
318
|
+
`${safeBundleName}.notice.md`,
|
|
319
|
+
`${safeBundleName}.feedback-form.md`,
|
|
320
|
+
]
|
|
321
|
+
: []),
|
|
322
|
+
`${safeBundleName}.manifest.json`,
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|