@hanna84/mcp-writing 1.0.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/README.md +193 -0
- package/db.js +127 -0
- package/index.js +892 -0
- package/metadata-lint.js +332 -0
- package/package.json +42 -0
- package/scripts/import.js +360 -0
- package/scripts/lint-metadata.mjs +28 -0
- package/scripts/merge-scrivx.js +319 -0
- package/scripts/split-versions.js +26 -0
- package/sync.js +431 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Merge Scrivener project metadata into mcp-writing sidecar files.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [options]
|
|
7
|
+
*
|
|
8
|
+
* <path-to.scriv> Path to the Scrivener .scriv bundle (the folder)
|
|
9
|
+
* <mcp-sync-dir> The WRITING_SYNC_DIR root (e.g. ./sync)
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --project <id> Project ID (default: derived from mcp-sync-dir name)
|
|
13
|
+
* --dry-run Show what would change without writing anything
|
|
14
|
+
*
|
|
15
|
+
* What it merges into scene sidecars:
|
|
16
|
+
* synopsis - from Files/Data/<UUID>/synopsis.txt
|
|
17
|
+
* characters - from Scrivener keywords (character names)
|
|
18
|
+
* save_the_cat_beat - from the savethecat! custom field (if present)
|
|
19
|
+
* causality - integer rating (0 = unset)
|
|
20
|
+
* stakes - integer rating
|
|
21
|
+
* change - integer rating
|
|
22
|
+
* scene_functions - array of active function flags: character, mood, theme
|
|
23
|
+
*
|
|
24
|
+
* Fields are only written if they have a meaningful value (non-empty, non-zero).
|
|
25
|
+
* Existing sidecar values are preserved and not overwritten by this script.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
31
|
+
import yaml from "js-yaml";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Args
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
if (args.length < 2 || args[0] === "--help") {
|
|
38
|
+
console.log("Usage: node scripts/merge-scrivx.js <path-to.scriv> <mcp-sync-dir> [--project <id>] [--dry-run]");
|
|
39
|
+
process.exit(args[0] === "--help" ? 0 : 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const scrivPath = path.resolve(args[0]);
|
|
43
|
+
const mcpSyncDir = path.resolve(args[1]);
|
|
44
|
+
const dryRun = args.includes("--dry-run");
|
|
45
|
+
const projectIdx = args.indexOf("--project");
|
|
46
|
+
const projectId = projectIdx !== -1
|
|
47
|
+
? args[projectIdx + 1]
|
|
48
|
+
: path.basename(mcpSyncDir).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(scrivPath)) {
|
|
51
|
+
console.error(`Scrivener bundle not found: ${scrivPath}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Find the .scrivx file inside the bundle
|
|
56
|
+
const scrivxFiles = fs.readdirSync(scrivPath).filter(f => f.endsWith(".scrivx"));
|
|
57
|
+
if (!scrivxFiles.length) {
|
|
58
|
+
console.error(`No .scrivx file found in ${scrivPath}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const scrivxPath = path.join(scrivPath, scrivxFiles[0]);
|
|
62
|
+
const dataDir = path.join(scrivPath, "Files", "Data");
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Parse scrivx with a full DOM parser (handles deep recursive BinderItem nesting)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
const xml = fs.readFileSync(scrivxPath, "utf8");
|
|
68
|
+
const dom = new DOMParser().parseFromString(xml, "text/xml");
|
|
69
|
+
|
|
70
|
+
function attr(el, name) { return el.getAttribute(name); }
|
|
71
|
+
function text(el) { return el.textContent?.trim() ?? null; }
|
|
72
|
+
function children(el, tag) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const child of el.childNodes) {
|
|
75
|
+
if (child.nodeType === 1 && child.tagName === tag) out.push(child);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Build ExternalSyncMap: fileNumber (string) → UUID
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const syncNumToUUID = {};
|
|
84
|
+
for (const el of dom.getElementsByTagName("SyncItem")) {
|
|
85
|
+
const uuid = attr(el, "ID");
|
|
86
|
+
const num = text(el);
|
|
87
|
+
if (uuid && num) syncNumToUUID[num] = uuid;
|
|
88
|
+
}
|
|
89
|
+
console.log(`Sync map: ${Object.keys(syncNumToUUID).length} entries`);
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Build keyword map: ID (string) → name
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const keywordMap = {};
|
|
95
|
+
for (const el of dom.getElementsByTagName("Keyword")) {
|
|
96
|
+
const id = attr(el, "ID");
|
|
97
|
+
const title = children(el, "Title")[0];
|
|
98
|
+
if (id && title) keywordMap[id] = text(title);
|
|
99
|
+
}
|
|
100
|
+
console.log(`Keyword map: ${Object.keys(keywordMap).length} entries`);
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Walk ALL BinderItems, collect metadata per UUID
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
const metaByUUID = {};
|
|
106
|
+
|
|
107
|
+
for (const item of dom.getElementsByTagName("BinderItem")) {
|
|
108
|
+
const uuid = attr(item, "UUID");
|
|
109
|
+
if (!uuid) continue;
|
|
110
|
+
|
|
111
|
+
// Custom metadata fields
|
|
112
|
+
const customFields = {};
|
|
113
|
+
for (const mdItem of item.getElementsByTagName("MetaDataItem")) {
|
|
114
|
+
const fieldId = text(children(mdItem, "FieldID")[0]);
|
|
115
|
+
const value = text(children(mdItem, "Value")[0]);
|
|
116
|
+
if (fieldId !== null) customFields[fieldId] = value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Keywords → character names vs version tags
|
|
120
|
+
const characters = [];
|
|
121
|
+
const versions = [];
|
|
122
|
+
const kwEl = children(item, "Keywords")[0];
|
|
123
|
+
if (kwEl) {
|
|
124
|
+
for (const kwId of children(kwEl, "KeywordID")) {
|
|
125
|
+
const name = keywordMap[text(kwId)];
|
|
126
|
+
if (!name) continue;
|
|
127
|
+
if (/^v\d[\d.a-z]*$/i.test(name)) versions.push(name);
|
|
128
|
+
else characters.push(name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Synopsis file
|
|
133
|
+
let synopsis = null;
|
|
134
|
+
const synopsisFile = path.join(dataDir, uuid, "synopsis.txt");
|
|
135
|
+
if (fs.existsSync(synopsisFile)) {
|
|
136
|
+
const t = fs.readFileSync(synopsisFile, "utf8").trim();
|
|
137
|
+
if (t) synopsis = t;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
metaByUUID[uuid] = { customFields, characters, versions, synopsis };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(`Binder items collected: ${Object.keys(metaByUUID).length}`);
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Walk binder hierarchy to assign part / chapter numbers to each UUID.
|
|
147
|
+
// Structure: DraftFolder → Part Folders → Chapter Folders → Scene Text items
|
|
148
|
+
// Chapters are numbered globally (don't reset per part).
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const partByUUID = {};
|
|
151
|
+
const chapterByUUID = {};
|
|
152
|
+
let partNum = 0;
|
|
153
|
+
let chapterNum = 0;
|
|
154
|
+
|
|
155
|
+
function walkHierarchy(containerEl, currentPart, currentChapter) {
|
|
156
|
+
for (const child of children(containerEl, "BinderItem")) {
|
|
157
|
+
const uuid = attr(child, "UUID");
|
|
158
|
+
const type = attr(child, "Type");
|
|
159
|
+
const childrenEl = children(child, "Children")[0];
|
|
160
|
+
|
|
161
|
+
if (type === "Folder" && currentPart === null) {
|
|
162
|
+
// Top-level folder under DraftFolder = Part
|
|
163
|
+
partNum++;
|
|
164
|
+
if (childrenEl) walkHierarchy(childrenEl, partNum, null);
|
|
165
|
+
} else if (type === "Folder") {
|
|
166
|
+
// Folder inside a Part = Chapter
|
|
167
|
+
chapterNum++;
|
|
168
|
+
if (uuid) { partByUUID[uuid] = currentPart; chapterByUUID[uuid] = chapterNum; }
|
|
169
|
+
if (childrenEl) walkHierarchy(childrenEl, currentPart, chapterNum);
|
|
170
|
+
} else if (type === "Text") {
|
|
171
|
+
// Scene or beat marker
|
|
172
|
+
if (uuid && currentChapter !== null) {
|
|
173
|
+
partByUUID[uuid] = currentPart;
|
|
174
|
+
chapterByUUID[uuid] = currentChapter;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Locate the DraftFolder as a direct child of Binder
|
|
181
|
+
const binderEl = dom.getElementsByTagName("Binder")[0];
|
|
182
|
+
if (binderEl) {
|
|
183
|
+
for (const el of children(binderEl, "BinderItem")) {
|
|
184
|
+
if (attr(el, "Type") === "DraftFolder") {
|
|
185
|
+
const draftChildrenEl = children(el, "Children")[0];
|
|
186
|
+
if (draftChildrenEl) walkHierarchy(draftChildrenEl, null, null);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log(`Part/chapter map: ${Object.keys(chapterByUUID).length} items assigned`);
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Build final lookup: syncNum (string) → enriched metadata
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
function buildMergeData(uuid) {
|
|
198
|
+
const { customFields, characters, versions, synopsis } = metaByUUID[uuid] ?? {};
|
|
199
|
+
const part = partByUUID[uuid] ?? null;
|
|
200
|
+
const chapter = chapterByUUID[uuid] ?? null;
|
|
201
|
+
|
|
202
|
+
if (!customFields && !characters && !versions && !synopsis && part === null && chapter === null) return null;
|
|
203
|
+
|
|
204
|
+
const out = {};
|
|
205
|
+
|
|
206
|
+
if (part !== null) out.part = part;
|
|
207
|
+
if (chapter !== null) out.chapter = chapter;
|
|
208
|
+
if (synopsis) out.synopsis = synopsis;
|
|
209
|
+
if (characters?.length) out.characters = characters;
|
|
210
|
+
if (versions?.length) out.versions = versions;
|
|
211
|
+
|
|
212
|
+
const stcBeat = customFields?.["savethecat!"];
|
|
213
|
+
if (stcBeat && typeof stcBeat === "string" && stcBeat.trim()) {
|
|
214
|
+
out.save_the_cat_beat = stcBeat.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const causality = Number(customFields?.["causality"] ?? 0);
|
|
218
|
+
const stakes = Number(customFields?.["stakes"] ?? 0);
|
|
219
|
+
if (causality) out.causality = causality;
|
|
220
|
+
if (stakes) out.stakes = stakes;
|
|
221
|
+
|
|
222
|
+
const change = customFields?.["change"];
|
|
223
|
+
if (change && String(change).trim()) out.scene_change = String(change).trim();
|
|
224
|
+
|
|
225
|
+
// Boolean function flags — collect the active ones into an array
|
|
226
|
+
const fnFlags = [];
|
|
227
|
+
if (customFields?.["f:character"] === "Yes" || customFields?.["f:character"] === true) fnFlags.push("character");
|
|
228
|
+
if (customFields?.["f:mood"] === "Yes" || customFields?.["f:mood"] === true) fnFlags.push("mood");
|
|
229
|
+
if (customFields?.["f:theme"] === "Yes" || customFields?.["f:theme"] === true) fnFlags.push("theme");
|
|
230
|
+
if (fnFlags.length) out.scene_functions = fnFlags;
|
|
231
|
+
|
|
232
|
+
return Object.keys(out).length ? out : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Walk existing sidecars in the scenes directory and merge
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
const scenesDir = path.join(mcpSyncDir, "projects", projectId, "scenes");
|
|
239
|
+
if (!fs.existsSync(scenesDir)) {
|
|
240
|
+
console.error(`Scenes directory not found: ${scenesDir}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Collect all .meta.yaml files recursively
|
|
245
|
+
function walkYamls(dir, list = []) {
|
|
246
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
247
|
+
const full = path.join(dir, entry.name);
|
|
248
|
+
if (entry.isDirectory()) walkYamls(full, list);
|
|
249
|
+
else if (entry.name.endsWith(".meta.yaml")) list.push(full);
|
|
250
|
+
}
|
|
251
|
+
return list;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sidecarFiles = walkYamls(scenesDir);
|
|
255
|
+
console.log(`\nScene sidecars to process: ${sidecarFiles.length}\n`);
|
|
256
|
+
|
|
257
|
+
let updated = 0;
|
|
258
|
+
let unchanged = 0;
|
|
259
|
+
let noData = 0;
|
|
260
|
+
|
|
261
|
+
for (const sidecarPath of sidecarFiles) {
|
|
262
|
+
const filename = path.basename(sidecarPath); // e.g. "001 Prologue [0].meta.yaml"
|
|
263
|
+
const m = filename.match(/\[(\d+)\]\.meta\.yaml$/);
|
|
264
|
+
if (!m) {
|
|
265
|
+
console.log(` SKIP (no bracket ID) ${filename}`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const syncNum = m[1];
|
|
270
|
+
const uuid = syncNumToUUID[syncNum];
|
|
271
|
+
if (!uuid) {
|
|
272
|
+
console.log(` SKIP (no UUID for [${syncNum}]) ${filename}`);
|
|
273
|
+
noData++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const mergeData = buildMergeData(uuid);
|
|
278
|
+
if (!mergeData) {
|
|
279
|
+
unchanged++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Read existing sidecar
|
|
284
|
+
const existing = yaml.load(fs.readFileSync(sidecarPath, "utf8")) ?? {};
|
|
285
|
+
|
|
286
|
+
// Merge: only add fields that don't already exist in the sidecar
|
|
287
|
+
let changed = false;
|
|
288
|
+
const merged = { ...existing };
|
|
289
|
+
for (const [key, value] of Object.entries(mergeData)) {
|
|
290
|
+
if (!(key in merged)) {
|
|
291
|
+
merged[key] = value;
|
|
292
|
+
changed = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!changed) {
|
|
297
|
+
unchanged++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (dryRun) {
|
|
302
|
+
const newKeys = Object.keys(mergeData).filter(k => !(k in existing));
|
|
303
|
+
console.log(` DRY ${filename}`);
|
|
304
|
+
for (const k of newKeys) {
|
|
305
|
+
const v = mergeData[k];
|
|
306
|
+
console.log(` + ${k}: ${JSON.stringify(v).slice(0, 80)}`);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
fs.writeFileSync(sidecarPath, yaml.dump(merged, { lineWidth: 120 }), "utf8");
|
|
310
|
+
const newKeys = Object.keys(mergeData).filter(k => !(k in existing));
|
|
311
|
+
console.log(` OK ${filename} [+${newKeys.join(", ")}]`);
|
|
312
|
+
}
|
|
313
|
+
updated++;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log(`\n${"─".repeat(50)}`);
|
|
317
|
+
console.log(`Updated: ${updated} sidecars${dryRun ? " (dry run)" : ""}`);
|
|
318
|
+
console.log(`Unchanged: ${unchanged} (already complete or no new data)`);
|
|
319
|
+
if (noData) console.log(`No data: ${noData} (no matching binder entry)`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
|
|
5
|
+
const scenesDir = "./sync/projects/the-lamb/scenes";
|
|
6
|
+
let fixed = 0;
|
|
7
|
+
|
|
8
|
+
for (const f of fs.readdirSync(scenesDir)) {
|
|
9
|
+
if (!f.endsWith(".meta.yaml")) continue;
|
|
10
|
+
const fp = path.join(scenesDir, f);
|
|
11
|
+
const meta = yaml.load(fs.readFileSync(fp, "utf8")) ?? {};
|
|
12
|
+
if (!meta.characters) continue;
|
|
13
|
+
|
|
14
|
+
const versions = meta.characters.filter(c => /^v\d[\d.a-z]*$/i.test(c));
|
|
15
|
+
const characters = meta.characters.filter(c => !/^v\d[\d.a-z]*$/i.test(c));
|
|
16
|
+
|
|
17
|
+
if (!versions.length) continue;
|
|
18
|
+
|
|
19
|
+
const updated = { ...meta, characters };
|
|
20
|
+
if (!characters.length) delete updated.characters;
|
|
21
|
+
updated.versions = versions;
|
|
22
|
+
fs.writeFileSync(fp, yaml.dump(updated, { lineWidth: 120 }), "utf8");
|
|
23
|
+
console.log("Fixed:", f, "-> chars:", characters.length, "versions:", versions);
|
|
24
|
+
fixed++;
|
|
25
|
+
}
|
|
26
|
+
console.log("Total fixed:", fixed);
|