@hanna84/mcp-writing 2.0.3 → 2.1.0
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/package.json +3 -1
- package/scene-character-batch.js +3 -87
- package/scene-character-normalization.js +199 -0
- package/scripts/normalize-scene-characters.mjs +225 -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
|
+
#### [v2.1.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.0.4...v2.1.0)
|
|
9
|
+
|
|
10
|
+
- feat(tools): add scene character normalization utility [`#83`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/83)
|
|
12
|
+
|
|
13
|
+
#### [v2.0.4](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.0.3...v2.0.4)
|
|
15
|
+
|
|
16
|
+
> 25 April 2026
|
|
17
|
+
|
|
18
|
+
- docs(prd): mark review-bundles and scrivener-direct as done [`#82`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/82)
|
|
20
|
+
- Release 2.0.4 [`775bbcf`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/775bbcfa0fb93e2b9828a94fa60ad53b29c7313c)
|
|
22
|
+
|
|
7
23
|
#### [v2.0.3](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.0.2...v2.0.3)
|
|
9
25
|
|
|
26
|
+
> 25 April 2026
|
|
27
|
+
|
|
10
28
|
- fix(metadata-lint): warn on mixed scene character reference styles [`#81`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/81)
|
|
30
|
+
- Release 2.0.3 [`a820668`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/a8206680db2faeeaa498edd2e112780b1d176be7)
|
|
12
32
|
|
|
13
33
|
#### [v2.0.2](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.0.1...v2.0.2)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"git.js",
|
|
16
16
|
"world-entity-templates.js",
|
|
17
17
|
"metadata-lint.js",
|
|
18
|
+
"scene-character-normalization.js",
|
|
18
19
|
"review-bundles.js",
|
|
19
20
|
"scripts/",
|
|
20
21
|
"README.md",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"manual:scenarios": "node scripts/manual/test-scenarios.mjs",
|
|
32
33
|
"manual:merge-beta-test": "node scripts/manual/run_mcp_test.js",
|
|
33
34
|
"manual:review-bundle": "node scripts/manual/run_create_review_bundle.js",
|
|
35
|
+
"normalize:scene-characters": "node --experimental-sqlite scripts/normalize-scene-characters.mjs",
|
|
34
36
|
"setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
|
|
35
37
|
"release": "release-it",
|
|
36
38
|
"lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
|
package/scene-character-batch.js
CHANGED
|
@@ -1,69 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import matter from "gray-matter";
|
|
3
|
+
import { buildCharacterNormalizationContext, escapeRegex, resolveCharacterReference } from "./scene-character-normalization.js";
|
|
3
4
|
import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
|
|
4
5
|
|
|
5
|
-
const NON_DISTINCTIVE_TOKENS = new Set([
|
|
6
|
-
"the",
|
|
7
|
-
"and",
|
|
8
|
-
"for",
|
|
9
|
-
"with",
|
|
10
|
-
"from",
|
|
11
|
-
"into",
|
|
12
|
-
"onto",
|
|
13
|
-
"over",
|
|
14
|
-
"under",
|
|
15
|
-
"after",
|
|
16
|
-
"before",
|
|
17
|
-
"about",
|
|
18
|
-
"around",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
function escapeRegex(text) {
|
|
22
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isDistinctiveToken(token) {
|
|
26
|
-
return Boolean(token) && token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
6
|
function normalizeCharacterRows(rows) {
|
|
30
|
-
|
|
31
|
-
.filter(row => row?.character_id && row?.name)
|
|
32
|
-
.map(row => {
|
|
33
|
-
const phrase_tokens = String(row.name).toLowerCase().split(/\s+/).filter(Boolean);
|
|
34
|
-
const tokens = [...new Set(phrase_tokens)];
|
|
35
|
-
return {
|
|
36
|
-
character_id: row.character_id,
|
|
37
|
-
name: String(row.name).trim(),
|
|
38
|
-
phrase_tokens,
|
|
39
|
-
tokens,
|
|
40
|
-
informative_tokens: tokens.filter(isDistinctiveToken),
|
|
41
|
-
full_name_regex: phrase_tokens.length > 1
|
|
42
|
-
? new RegExp(`\\b${phrase_tokens.map(escapeRegex).join("\\s+")}\\b`, "i")
|
|
43
|
-
: null,
|
|
44
|
-
};
|
|
45
|
-
})
|
|
46
|
-
.filter(row => row.name.length > 0);
|
|
47
|
-
|
|
48
|
-
const tokenMap = new Map();
|
|
49
|
-
const byId = new Map();
|
|
50
|
-
const nameMap = new Map();
|
|
51
|
-
for (const row of clean) {
|
|
52
|
-
byId.set(row.character_id, row);
|
|
53
|
-
|
|
54
|
-
const normalizedName = row.name.toLowerCase();
|
|
55
|
-
const exactNameIds = nameMap.get(normalizedName) ?? [];
|
|
56
|
-
exactNameIds.push(row.character_id);
|
|
57
|
-
nameMap.set(normalizedName, exactNameIds);
|
|
58
|
-
|
|
59
|
-
for (const token of row.informative_tokens) {
|
|
60
|
-
const ids = tokenMap.get(token) ?? [];
|
|
61
|
-
ids.push(row.character_id);
|
|
62
|
-
tokenMap.set(token, ids);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { clean, tokenMap, byId, nameMap };
|
|
7
|
+
return buildCharacterNormalizationContext(rows);
|
|
67
8
|
}
|
|
68
9
|
|
|
69
10
|
function inferCharactersFromProse(prose, characterRows) {
|
|
@@ -113,32 +54,7 @@ function inferCharactersFromProse(prose, characterRows) {
|
|
|
113
54
|
}
|
|
114
55
|
|
|
115
56
|
function resolveCharacterEntry(entry, characterRows) {
|
|
116
|
-
|
|
117
|
-
if (!value) return null;
|
|
118
|
-
|
|
119
|
-
if (characterRows.byId.has(value)) {
|
|
120
|
-
return value;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const exactNameIds = characterRows.nameMap.get(value.toLowerCase());
|
|
124
|
-
if (exactNameIds?.length === 1) {
|
|
125
|
-
return exactNameIds[0];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const words = value.toLowerCase().split(/\s+/).filter(isDistinctiveToken);
|
|
129
|
-
if (words.length === 0) {
|
|
130
|
-
return value;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const matches = characterRows.clean.filter(row =>
|
|
134
|
-
words.every(word => row.informative_tokens.includes(word))
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
if (matches.length === 1) {
|
|
138
|
-
return matches[0].character_id;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return value;
|
|
57
|
+
return resolveCharacterReference(entry, characterRows);
|
|
142
58
|
}
|
|
143
59
|
|
|
144
60
|
function pruneLessSpecificCharacters(characterIds, fullNameMatches, characterRows) {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
export const NON_DISTINCTIVE_TOKENS = new Set([
|
|
2
|
+
"the",
|
|
3
|
+
"and",
|
|
4
|
+
"for",
|
|
5
|
+
"with",
|
|
6
|
+
"from",
|
|
7
|
+
"into",
|
|
8
|
+
"onto",
|
|
9
|
+
"over",
|
|
10
|
+
"under",
|
|
11
|
+
"after",
|
|
12
|
+
"before",
|
|
13
|
+
"about",
|
|
14
|
+
"around",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function escapeRegex(text) {
|
|
18
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isDistinctiveToken(token) {
|
|
22
|
+
const normalized = String(token ?? "").trim().toLowerCase();
|
|
23
|
+
return Boolean(normalized) && normalized.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeRawCharacterValues(values) {
|
|
27
|
+
const raw = Array.isArray(values) ? values : [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const normalized = [];
|
|
30
|
+
|
|
31
|
+
for (const value of raw) {
|
|
32
|
+
const text = String(value ?? "").trim();
|
|
33
|
+
if (!text || seen.has(text)) continue;
|
|
34
|
+
seen.add(text);
|
|
35
|
+
normalized.push(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tokenizeValue(value) {
|
|
42
|
+
return [...new Set(String(value ?? "").toLowerCase().split(/\s+/).filter(isDistinctiveToken))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildCharacterNormalizationContext(rows) {
|
|
46
|
+
const clean = (Array.isArray(rows) ? rows : [])
|
|
47
|
+
.filter(row => row?.character_id && row?.name)
|
|
48
|
+
.map(row => {
|
|
49
|
+
const character_id = String(row.character_id).trim();
|
|
50
|
+
const name = String(row.name).trim();
|
|
51
|
+
const phrase_tokens = name.toLowerCase().split(/\s+/).filter(Boolean);
|
|
52
|
+
const tokens = [...new Set(phrase_tokens)];
|
|
53
|
+
return {
|
|
54
|
+
character_id,
|
|
55
|
+
name,
|
|
56
|
+
phrase_tokens,
|
|
57
|
+
tokens,
|
|
58
|
+
informative_tokens: tokens.filter(isDistinctiveToken),
|
|
59
|
+
full_name_regex: phrase_tokens.length > 1
|
|
60
|
+
? new RegExp(`\\b${phrase_tokens.map(escapeRegex).join("\\s+")}\\b`, "i")
|
|
61
|
+
: null,
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
.filter(row => row.character_id.length > 0 && row.name.length > 0);
|
|
65
|
+
|
|
66
|
+
const byId = new Map();
|
|
67
|
+
const nameMap = new Map();
|
|
68
|
+
const tokenMap = new Map();
|
|
69
|
+
for (const row of clean) {
|
|
70
|
+
byId.set(row.character_id, row);
|
|
71
|
+
const normalizedName = row.name.toLowerCase();
|
|
72
|
+
const ids = nameMap.get(normalizedName) ?? [];
|
|
73
|
+
ids.push(row.character_id);
|
|
74
|
+
nameMap.set(normalizedName, ids);
|
|
75
|
+
|
|
76
|
+
for (const token of row.informative_tokens) {
|
|
77
|
+
const tokenIds = tokenMap.get(token) ?? [];
|
|
78
|
+
tokenIds.push(row.character_id);
|
|
79
|
+
tokenMap.set(token, tokenIds);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { clean, byId, nameMap, tokenMap };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveCharacterReference(value, context) {
|
|
87
|
+
const text = String(value ?? "").trim();
|
|
88
|
+
if (!text) return null;
|
|
89
|
+
|
|
90
|
+
if (context.byId.has(text)) {
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const exactNameIds = context.nameMap.get(text.toLowerCase());
|
|
95
|
+
if (exactNameIds?.length === 1) {
|
|
96
|
+
return exactNameIds[0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const words = text.toLowerCase().split(/\s+/).filter(isDistinctiveToken);
|
|
100
|
+
if (words.length === 0) {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const matches = context.clean.filter(row =>
|
|
105
|
+
words.every(word => row.informative_tokens.includes(word))
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (matches.length === 1) {
|
|
109
|
+
return matches[0].character_id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isProperSubset(subsetTokens, supersetTokens) {
|
|
116
|
+
if (subsetTokens.length < 2 || subsetTokens.length >= supersetTokens.length) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return subsetTokens.every(token => supersetTokens.includes(token));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasMoreSpecificNonCanonicalSource(candidate, sourceInfo) {
|
|
123
|
+
if (!sourceInfo || sourceInfo.hadCanonicalSource || sourceInfo.nonCanonicalTokens.length === 0) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sourceInfo.nonCanonicalTokens.some(tokens => isProperSubset(candidate.informative_tokens, tokens));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pruneLessSpecificCanonicalIds(values, context, sourceMap) {
|
|
131
|
+
return values.filter((value, idx) => {
|
|
132
|
+
const row = context.byId.get(value);
|
|
133
|
+
if (!row || row.informative_tokens.length === 0) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rowSource = sourceMap.get(value);
|
|
138
|
+
if (!rowSource?.hadCanonicalSource) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < values.length; i++) {
|
|
143
|
+
if (i === idx) continue;
|
|
144
|
+
|
|
145
|
+
const otherId = values[i];
|
|
146
|
+
const other = context.byId.get(otherId);
|
|
147
|
+
if (!other || other.informative_tokens.length === 0) continue;
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
isProperSubset(row.informative_tokens, other.informative_tokens)
|
|
151
|
+
&& hasMoreSpecificNonCanonicalSource(row, sourceMap.get(otherId))
|
|
152
|
+
) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function normalizeSceneCharacters(values, context) {
|
|
162
|
+
const before = normalizeRawCharacterValues(values);
|
|
163
|
+
const resolved = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const sourceMap = new Map();
|
|
166
|
+
|
|
167
|
+
for (const value of before) {
|
|
168
|
+
const normalized = resolveCharacterReference(value, context);
|
|
169
|
+
if (!normalized) continue;
|
|
170
|
+
|
|
171
|
+
const source = sourceMap.get(normalized) ?? {
|
|
172
|
+
hadCanonicalSource: false,
|
|
173
|
+
nonCanonicalTokens: [],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (context.byId.has(value)) {
|
|
177
|
+
source.hadCanonicalSource = true;
|
|
178
|
+
} else {
|
|
179
|
+
source.nonCanonicalTokens.push(tokenizeValue(value));
|
|
180
|
+
}
|
|
181
|
+
sourceMap.set(normalized, source);
|
|
182
|
+
|
|
183
|
+
if (seen.has(normalized)) continue;
|
|
184
|
+
seen.add(normalized);
|
|
185
|
+
resolved.push(normalized);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const after = pruneLessSpecificCanonicalIds(resolved, context, sourceMap);
|
|
189
|
+
const beforeSet = new Set(before);
|
|
190
|
+
const afterSet = new Set(after);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
before,
|
|
194
|
+
after,
|
|
195
|
+
changed: before.length !== after.length || before.some((value, idx) => after[idx] !== value),
|
|
196
|
+
added: after.filter(value => !beforeSet.has(value)),
|
|
197
|
+
removed: before.filter(value => !afterSet.has(value)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { openDb } from "../db.js";
|
|
4
|
+
import { buildCharacterNormalizationContext, normalizeSceneCharacters } from "../scene-character-normalization.js";
|
|
5
|
+
import { normalizeSceneMetaForPath, readMeta, syncAll, writeMeta } from "../sync.js";
|
|
6
|
+
|
|
7
|
+
function readRequiredValue(argv, index, option) {
|
|
8
|
+
const value = argv[index + 1];
|
|
9
|
+
if (value === undefined || value.startsWith("-")) {
|
|
10
|
+
throw new Error(`${option} requires a value.`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const opts = {
|
|
17
|
+
syncDir: process.env.WRITING_SYNC_DIR ?? "./sync",
|
|
18
|
+
projectId: null,
|
|
19
|
+
write: false,
|
|
20
|
+
json: false,
|
|
21
|
+
limit: null,
|
|
22
|
+
help: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const arg = argv[i];
|
|
27
|
+
if (arg === "--sync-dir" || arg === "-d") {
|
|
28
|
+
opts.syncDir = readRequiredValue(argv, i, arg);
|
|
29
|
+
i++;
|
|
30
|
+
} else if (arg === "--project-id" || arg === "-p") {
|
|
31
|
+
opts.projectId = readRequiredValue(argv, i, arg);
|
|
32
|
+
i++;
|
|
33
|
+
} else if (arg === "--limit" || arg === "-n") {
|
|
34
|
+
opts.limit = Number.parseInt(readRequiredValue(argv, i, arg), 10);
|
|
35
|
+
i++;
|
|
36
|
+
} else if (arg === "--write") {
|
|
37
|
+
opts.write = true;
|
|
38
|
+
} else if (arg === "--json") {
|
|
39
|
+
opts.json = true;
|
|
40
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
41
|
+
opts.help = true;
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (opts.limit !== null && (!Number.isInteger(opts.limit) || opts.limit <= 0)) {
|
|
48
|
+
throw new Error("--limit must be a positive integer.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return opts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function usage() {
|
|
55
|
+
return [
|
|
56
|
+
"Usage:",
|
|
57
|
+
" node --experimental-sqlite scripts/normalize-scene-characters.mjs [--sync-dir <dir>] [--project-id <id>] [--limit <n>] [--write] [--json]",
|
|
58
|
+
"",
|
|
59
|
+
"Options:",
|
|
60
|
+
" --sync-dir, -d WRITING_SYNC_DIR root (default: env WRITING_SYNC_DIR or ./sync)",
|
|
61
|
+
" --project-id, -p Restrict to one project_id",
|
|
62
|
+
" --limit, -n Process at most N scenes",
|
|
63
|
+
" --write Apply changes (default: dry-run)",
|
|
64
|
+
" --json Emit machine-readable JSON summary",
|
|
65
|
+
"",
|
|
66
|
+
"Note: Uses an in-memory sqlite index for analysis; no mcp.sqlite file is created in sync_dir.",
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function queryRows(db, sql, ...params) {
|
|
71
|
+
return db.prepare(sql).all(...params);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveCharacterRows(db, projectId) {
|
|
75
|
+
return queryRows(
|
|
76
|
+
db,
|
|
77
|
+
`SELECT character_id, name
|
|
78
|
+
FROM characters
|
|
79
|
+
WHERE project_id = ?
|
|
80
|
+
OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
|
|
81
|
+
ORDER BY length(name) DESC`,
|
|
82
|
+
projectId,
|
|
83
|
+
projectId
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveScenes(db, projectId, limit) {
|
|
88
|
+
const limitClause = Number.isInteger(limit) ? ` LIMIT ${limit}` : "";
|
|
89
|
+
if (!projectId) {
|
|
90
|
+
return queryRows(
|
|
91
|
+
db,
|
|
92
|
+
`SELECT scene_id, project_id, file_path
|
|
93
|
+
FROM scenes
|
|
94
|
+
ORDER BY project_id, part, chapter, timeline_position, scene_id${limitClause}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return queryRows(
|
|
98
|
+
db,
|
|
99
|
+
`SELECT scene_id, project_id, file_path
|
|
100
|
+
FROM scenes
|
|
101
|
+
WHERE project_id = ?
|
|
102
|
+
ORDER BY part, chapter, timeline_position, scene_id${limitClause}`,
|
|
103
|
+
projectId
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runNormalization({ syncDir, projectId, write, limit }) {
|
|
108
|
+
const db = openDb(":memory:");
|
|
109
|
+
try {
|
|
110
|
+
// Refresh index so character/name resolution uses current canonical sheets and sidecars.
|
|
111
|
+
syncAll(db, syncDir, { quiet: true, writable: false });
|
|
112
|
+
|
|
113
|
+
const scenes = resolveScenes(db, projectId, limit);
|
|
114
|
+
const contextCache = new Map();
|
|
115
|
+
|
|
116
|
+
const getContextForProject = (sceneProjectId) => {
|
|
117
|
+
const key = sceneProjectId ?? "__none__";
|
|
118
|
+
if (contextCache.has(key)) return contextCache.get(key);
|
|
119
|
+
|
|
120
|
+
const context = buildCharacterNormalizationContext(resolveCharacterRows(db, sceneProjectId));
|
|
121
|
+
contextCache.set(key, context);
|
|
122
|
+
return context;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const changed = [];
|
|
126
|
+
let processedScenes = 0;
|
|
127
|
+
|
|
128
|
+
for (const scene of scenes) {
|
|
129
|
+
const { meta } = readMeta(scene.file_path, syncDir, { writable: false });
|
|
130
|
+
if (!Array.isArray(meta.characters) || meta.characters.length === 0) {
|
|
131
|
+
processedScenes++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const normalized = normalizeSceneCharacters(meta.characters, getContextForProject(scene.project_id));
|
|
136
|
+
processedScenes++;
|
|
137
|
+
|
|
138
|
+
if (!normalized.changed) continue;
|
|
139
|
+
|
|
140
|
+
if (write) {
|
|
141
|
+
const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
|
|
142
|
+
...meta,
|
|
143
|
+
characters: normalized.after,
|
|
144
|
+
}).meta;
|
|
145
|
+
writeMeta(scene.file_path, updatedMeta);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
changed.push({
|
|
149
|
+
scene_id: scene.scene_id,
|
|
150
|
+
project_id: scene.project_id,
|
|
151
|
+
file_path: scene.file_path,
|
|
152
|
+
before_characters: normalized.before,
|
|
153
|
+
after_characters: normalized.after,
|
|
154
|
+
added: normalized.added,
|
|
155
|
+
removed: normalized.removed,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
mode: write ? "write" : "dry_run",
|
|
162
|
+
sync_dir: path.resolve(syncDir),
|
|
163
|
+
project_id: projectId,
|
|
164
|
+
processed_scenes: processedScenes,
|
|
165
|
+
scenes_changed: changed.length,
|
|
166
|
+
character_reference_count: [...contextCache.values()].reduce((sum, ctx) => sum + ctx.clean.length, 0),
|
|
167
|
+
changes: changed,
|
|
168
|
+
};
|
|
169
|
+
} finally {
|
|
170
|
+
db.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function printTextSummary(result) {
|
|
175
|
+
process.stdout.write(`normalize-scene-characters (${result.mode})\n`);
|
|
176
|
+
process.stdout.write(`sync_dir: ${result.sync_dir}\n`);
|
|
177
|
+
process.stdout.write(`project_id: ${result.project_id ?? "(all projects)"}\n`);
|
|
178
|
+
process.stdout.write(`processed_scenes: ${result.processed_scenes}\n`);
|
|
179
|
+
process.stdout.write(`scenes_changed: ${result.scenes_changed}\n`);
|
|
180
|
+
process.stdout.write(`character_reference_count: ${result.character_reference_count}\n`);
|
|
181
|
+
|
|
182
|
+
const preview = result.changes.slice(0, 20);
|
|
183
|
+
for (const row of preview) {
|
|
184
|
+
process.stdout.write(`- ${row.scene_id} (${row.project_id})\n`);
|
|
185
|
+
process.stdout.write(` added: ${row.added.join(", ") || "(none)"}\n`);
|
|
186
|
+
process.stdout.write(` removed: ${row.removed.join(", ") || "(none)"}\n`);
|
|
187
|
+
}
|
|
188
|
+
if (result.changes.length > preview.length) {
|
|
189
|
+
process.stdout.write(`... ${result.changes.length - preview.length} more changed scene(s)\n`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (result.mode === "write") {
|
|
193
|
+
process.stdout.write("next_step: run sync() to refresh DB indexes from updated sidecars\n");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function main() {
|
|
198
|
+
try {
|
|
199
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
200
|
+
if (opts.help) {
|
|
201
|
+
process.stdout.write(`${usage()}\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = runNormalization({
|
|
206
|
+
syncDir: path.resolve(opts.syncDir),
|
|
207
|
+
projectId: opts.projectId,
|
|
208
|
+
write: opts.write,
|
|
209
|
+
limit: opts.limit,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (opts.json) {
|
|
213
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
printTextSummary(result);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
219
|
+
process.stderr.write(`${message}\n`);
|
|
220
|
+
process.stderr.write(`${usage()}\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
main();
|