@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,360 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Import a Scrivener External Folder Sync output into mcp-writing sidecar format.
4
+ *
5
+ * Usage:
6
+ * node scripts/import.js <scrivener-sync-dir> <mcp-sync-dir> [options]
7
+ *
8
+ * <scrivener-sync-dir> The folder Scrivener syncs into (e.g. ./txt)
9
+ * If it contains Draft/ and Notes/ subdirs, both are processed.
10
+ * <mcp-sync-dir> The WRITING_SYNC_DIR root (e.g. ./my-project-sync)
11
+ *
12
+ * Options:
13
+ * --project <id> Project ID to assign (default: derived from mcp-sync-dir name)
14
+ * --dry-run Show what would be created without writing anything
15
+ *
16
+ * What it does (Draft folder):
17
+ * - Walks the Draft dir in filename order (NNN prefix = binder sequence)
18
+ * - Skips empty files (non-compilation title cards) and Epigraphs
19
+ * - Detects Save the Cat beat markers ("-Beat Name-" empty files) and carries
20
+ * the beat name forward to the next prose scene's sidecar
21
+ * - Creates mcp-sync-dir/projects/<project>/scenes/ structure
22
+ * - Writes a .meta.yaml sidecar for each scene (skips files that already have one)
23
+ *
24
+ * What it does (Notes folder):
25
+ * - Tracks section mode via empty top-level folder markers (Characters, Places, World...)
26
+ * - Routes character sheets → world/characters/ with character_id sidecar
27
+ * - Routes place sheets → world/places/ with place_id sidecar
28
+ * - Skips World, misc, Writing, Publishing, and other non-character/place sections
29
+ */
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import yaml from "js-yaml";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Args
37
+ // ---------------------------------------------------------------------------
38
+ const args = process.argv.slice(2);
39
+ if (args.length < 2 || args[0] === "--help") {
40
+ console.log("Usage: node scripts/import.js <scrivener-sync-dir> <mcp-sync-dir> [--project <id>] [--dry-run]");
41
+ process.exit(args[0] === "--help" ? 0 : 1);
42
+ }
43
+
44
+ const scrivenerDir = path.resolve(args[0]);
45
+ const mcpSyncDir = path.resolve(args[1]);
46
+ const dryRun = args.includes("--dry-run");
47
+ const projectIdx = args.indexOf("--project");
48
+ const projectId = projectIdx !== -1
49
+ ? args[projectIdx + 1]
50
+ : path.basename(mcpSyncDir).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
51
+
52
+ if (!fs.existsSync(scrivenerDir)) {
53
+ console.error(`Scrivener sync dir not found: ${scrivenerDir}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ const scenesDir = path.join(mcpSyncDir, "projects", projectId, "scenes");
58
+ const charsDir = path.join(mcpSyncDir, "projects", projectId, "world", "characters");
59
+ const placesDir = path.join(mcpSyncDir, "projects", projectId, "world", "places");
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ // Parse "NNN Title [binder_id].txt" → { seq, rawTitle } or null
66
+ function parseFilename(filename) {
67
+ const m = filename.match(/^(\d+)\s+(.+?)\s*\[\d+\]\.(txt|md)$/);
68
+ if (!m) return null;
69
+ return { seq: parseInt(m[1], 10), rawTitle: m[2].trim() };
70
+ }
71
+
72
+ function isBeatMarker(rawTitle) {
73
+ return /^-[^-].+-$/.test(rawTitle.trim());
74
+ }
75
+
76
+ function parseBeat(rawTitle) {
77
+ return rawTitle.trim().replace(/^-/, "").replace(/-$/, "").trim();
78
+ }
79
+
80
+ function isEpigraph(rawTitle) {
81
+ return /^epigraph$/i.test(rawTitle.trim());
82
+ }
83
+
84
+ function cleanTitle(rawTitle) {
85
+ return rawTitle.replace(/^Scene\s+/i, "").trim();
86
+ }
87
+
88
+ function slugify(str) {
89
+ return str
90
+ .toLowerCase()
91
+ .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, "") // strip emoji
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-+|-+$/g, "")
94
+ .slice(0, 50);
95
+ }
96
+
97
+ function makeSceneId(seq, title) {
98
+ return `sc-${String(seq).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
99
+ }
100
+
101
+ function makeCharacterId(rawTitle) {
102
+ return "char-" + slugify(rawTitle);
103
+ }
104
+
105
+ function makePlaceId(rawTitle) {
106
+ return "place-" + slugify(rawTitle);
107
+ }
108
+
109
+ // Section mode detection for Notes folder.
110
+ // Returns the new mode string, or null if the title doesn't trigger a mode change.
111
+ const SECTION_MODES = {
112
+ "characters": "characters",
113
+ "places": "places",
114
+ "world": "skip",
115
+ "misc": "skip",
116
+ "writing": "skip",
117
+ "publishing": "skip",
118
+ "novel format": "skip",
119
+ "template sheets": "skip",
120
+ };
121
+
122
+ function notesSection(title) {
123
+ return SECTION_MODES[title.toLowerCase().trim()] ?? null;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Walk a directory (sorted by filename = binder order, non-recursive)
128
+ // ---------------------------------------------------------------------------
129
+ function walkSorted(dir) {
130
+ const files = [];
131
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
132
+ const full = path.join(dir, entry.name);
133
+ if (entry.isFile() && (entry.name.endsWith(".txt") || entry.name.endsWith(".md"))) {
134
+ files.push(full);
135
+ }
136
+ }
137
+ return files.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Main
142
+ // ---------------------------------------------------------------------------
143
+ const draftDir = path.join(scrivenerDir, "Draft");
144
+ const notesDir = path.join(scrivenerDir, "Notes");
145
+ const hasDraft = fs.existsSync(draftDir);
146
+ const hasNotes = fs.existsSync(notesDir);
147
+
148
+ // If there's a Draft/ subdir use that; otherwise treat scrivenerDir as Draft directly
149
+ const draftRoot = hasDraft ? draftDir : scrivenerDir;
150
+
151
+ const files = walkSorted(draftRoot);
152
+ let created = 0;
153
+ let skipped = 0;
154
+ let alreadyDone = 0;
155
+ let beatCarry = null; // last seen beat marker
156
+
157
+ if (!dryRun) {
158
+ fs.mkdirSync(scenesDir, { recursive: true });
159
+ }
160
+
161
+ console.log(`Project: ${projectId}`);
162
+ console.log(`Scenes to: ${scenesDir}`);
163
+ console.log(`Files: ${files.length}\n`);
164
+
165
+ for (const file of files) {
166
+ const filename = path.basename(file);
167
+ const parsed = parseFilename(filename);
168
+
169
+ if (!parsed) {
170
+ console.log(` SKIP (unrecognised pattern) ${filename}`);
171
+ skipped++;
172
+ continue;
173
+ }
174
+
175
+ const { seq, rawTitle } = parsed;
176
+ const isEmpty = fs.statSync(file).size === 0;
177
+
178
+ // Beat markers: always empty, carry beat name forward
179
+ if (isBeatMarker(rawTitle)) {
180
+ beatCarry = parseBeat(rawTitle);
181
+ console.log(` BEAT "${beatCarry}"`);
182
+ continue;
183
+ }
184
+
185
+ // Empty non-beat files: title cards, chapter headers excluded from compilation
186
+ if (isEmpty) {
187
+ console.log(` SKIP (empty) ${filename}`);
188
+ skipped++;
189
+ continue;
190
+ }
191
+
192
+ // Epigraphs: have content but aren't scenes
193
+ if (isEpigraph(rawTitle)) {
194
+ console.log(` SKIP (epigraph) ${filename}`);
195
+ skipped++;
196
+ continue;
197
+ }
198
+
199
+ // Scene file — create sidecar
200
+ const title = cleanTitle(rawTitle);
201
+ const sceneId = makeSceneId(seq, title);
202
+ const destFile = path.join(scenesDir, filename);
203
+ const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
204
+
205
+ if (fs.existsSync(sidecar)) {
206
+ console.log(` SKIP (sidecar exists) ${filename}`);
207
+ alreadyDone++;
208
+ beatCarry = null; // beat was consumed by an existing scene
209
+ continue;
210
+ }
211
+
212
+ const meta = {
213
+ scene_id: sceneId,
214
+ title,
215
+ timeline_position: seq,
216
+ ...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
217
+ // Placeholders — fill in after reviewing
218
+ // part: null,
219
+ // chapter: null,
220
+ // pov: null,
221
+ // logline: null,
222
+ // characters: [],
223
+ // places: [],
224
+ // tags: [],
225
+ };
226
+
227
+ if (dryRun) {
228
+ console.log(` DRY ${path.basename(sidecar)}`);
229
+ console.log(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
230
+ } else {
231
+ // Copy prose file into mcp-sync-dir scenes folder
232
+ if (!fs.existsSync(destFile)) {
233
+ fs.copyFileSync(file, destFile);
234
+ }
235
+ fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
236
+ console.log(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "—"}]`);
237
+ }
238
+
239
+ beatCarry = null; // consumed
240
+ created++;
241
+ }
242
+
243
+ console.log(`\n${"─".repeat(50)}`);
244
+ console.log(`Created: ${created} sidecars${dryRun ? " (dry run)" : ""}`);
245
+ console.log(`Skipped: ${skipped} (empty / epigraph / pattern)`);
246
+ if (alreadyDone) console.log(`Existing: ${alreadyDone} already had sidecars`);
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Notes pass — characters and places
250
+ // ---------------------------------------------------------------------------
251
+ if (hasNotes) {
252
+ const noteFiles = walkSorted(notesDir);
253
+ let mode = "skip"; // current routing mode
254
+ let currentGroup = null; // current subsection name (last empty file title)
255
+ let worldCreated = 0;
256
+ let worldSkipped = 0;
257
+ let worldExisting = 0;
258
+
259
+ if (!dryRun) {
260
+ fs.mkdirSync(charsDir, { recursive: true });
261
+ fs.mkdirSync(placesDir, { recursive: true });
262
+ }
263
+
264
+ console.log(`\nNotes: ${notesDir}`);
265
+ console.log(`Files: ${noteFiles.length}\n`);
266
+
267
+ for (const file of noteFiles) {
268
+ const filename = path.basename(file);
269
+ const parsed = parseFilename(filename);
270
+
271
+ if (!parsed) {
272
+ console.log(` SKIP (pattern) ${filename}`);
273
+ worldSkipped++;
274
+ continue;
275
+ }
276
+
277
+ const { rawTitle } = parsed;
278
+ const isEmpty = fs.statSync(file).size === 0;
279
+
280
+ // Check if this title is a top-level section marker
281
+ const newMode = notesSection(rawTitle);
282
+ if (newMode !== null) {
283
+ mode = newMode;
284
+ currentGroup = null;
285
+ if (mode !== "skip") {
286
+ console.log(` MODE → ${mode} ("${rawTitle}")`);
287
+ } else {
288
+ console.log(` SKIP (section) "${rawTitle}"`);
289
+ }
290
+ continue; // skip the section marker file itself
291
+ }
292
+
293
+ // Empty file within a mode = subsection header, just update group tracking
294
+ if (isEmpty) {
295
+ if (mode !== "skip") {
296
+ currentGroup = rawTitle;
297
+ console.log(` GROUP "${rawTitle}" [${mode}]`);
298
+ }
299
+ worldSkipped++;
300
+ continue;
301
+ }
302
+
303
+ if (mode === "skip") {
304
+ worldSkipped++;
305
+ continue;
306
+ }
307
+
308
+ const targetDir = mode === "characters" ? charsDir : placesDir;
309
+ const destFile = path.join(targetDir, filename);
310
+ const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
311
+
312
+ if (fs.existsSync(sidecar)) {
313
+ console.log(` SKIP (exists) ${filename}`);
314
+ worldExisting++;
315
+ continue;
316
+ }
317
+
318
+ if (mode === "characters") {
319
+ const meta = {
320
+ character_id: makeCharacterId(rawTitle),
321
+ name: rawTitle,
322
+ ...(currentGroup ? { group: currentGroup } : {}),
323
+ };
324
+ if (dryRun) {
325
+ console.log(` DRY [char] "${rawTitle}" → ${meta.character_id}`);
326
+ } else {
327
+ if (!fs.existsSync(destFile)) fs.copyFileSync(file, destFile);
328
+ fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
329
+ console.log(` OK [char] "${rawTitle}"`);
330
+ }
331
+ } else {
332
+ const meta = {
333
+ place_id: makePlaceId(rawTitle),
334
+ name: rawTitle,
335
+ ...(currentGroup ? { group: currentGroup } : {}),
336
+ };
337
+ if (dryRun) {
338
+ console.log(` DRY [place] "${rawTitle}" → ${meta.place_id}`);
339
+ } else {
340
+ if (!fs.existsSync(destFile)) fs.copyFileSync(file, destFile);
341
+ fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
342
+ console.log(` OK [place] "${rawTitle}"`);
343
+ }
344
+ }
345
+ worldCreated++;
346
+ }
347
+
348
+ console.log(`\n${"─".repeat(50)}`);
349
+ console.log(`World: ${worldCreated} created${dryRun ? " (dry run)" : ""}`);
350
+ console.log(`Skipped: ${worldSkipped}`);
351
+ if (worldExisting) console.log(`Existing: ${worldExisting} already had sidecars`);
352
+ }
353
+
354
+ if (!dryRun && created > 0) {
355
+ console.log(`\nNext steps:`);
356
+ console.log(` 1. Start the service:`);
357
+ console.log(` WRITING_SYNC_DIR=${mcpSyncDir} DB_PATH=./writing.db npm start`);
358
+ console.log(` 2. Call the sync tool to index everything`);
359
+ console.log(` 3. Review part/chapter/pov fields in sidecars as needed`);
360
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { lintMetadataInSyncDir } from "../metadata-lint.js";
4
+
5
+ function parseArgs(argv) {
6
+ const args = { syncDir: process.env.WRITING_SYNC_DIR ?? "./sync" };
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const cur = argv[i];
9
+ if ((cur === "--sync-dir" || cur === "-d") && argv[i + 1]) {
10
+ args.syncDir = argv[i + 1];
11
+ i++;
12
+ }
13
+ }
14
+ return args;
15
+ }
16
+
17
+ const { syncDir } = parseArgs(process.argv.slice(2));
18
+ const result = lintMetadataInSyncDir(syncDir);
19
+
20
+ process.stdout.write(`metadata lint: ${path.resolve(syncDir)}\n`);
21
+ process.stdout.write(`files checked: ${result.files_checked}\n`);
22
+ process.stdout.write(`errors: ${result.error_count}, warnings: ${result.warning_count}\n`);
23
+
24
+ for (const issue of [...result.errors, ...result.warnings]) {
25
+ process.stdout.write(`[${issue.level}] ${issue.code} ${issue.file} :: ${issue.message}\n`);
26
+ }
27
+
28
+ if (!result.ok) process.exit(1);