@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.
@@ -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);