@hanna84/mcp-writing 1.4.8 → 1.5.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 CHANGED
@@ -4,11 +4,21 @@ 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
+ #### [v1.5.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.4.8...v1.5.0)
9
+
10
+ - feat: add MCP tool for first-time Scrivener import [`#42`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/42)
12
+
7
13
  #### [v1.4.8](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.4.7...v1.4.8)
9
15
 
16
+ > 19 April 2026
17
+
10
18
  - docs: move release automation out of README [`#41`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/41)
20
+ - Release 1.4.8 [`cebc920`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/cebc920da6b2ce86a8758d17a9a089d06b5b3615)
12
22
 
13
23
  #### [v1.4.7](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.4.6...v1.4.7)
package/README.md CHANGED
@@ -36,14 +36,15 @@ git --version # should be installed
36
36
 
37
37
  ## First-time setup path (recommended)
38
38
 
39
- If this is your first time, use this path and skip the advanced/reference sections for now:
39
+ If this is your first time, follow these steps in order:
40
40
 
41
- 1. Follow either **Quick start with Scrivener** or **Running with Docker**.
42
- 2. Start the server with `npm start`.
43
- 3. Run **Verify your setup** (`/healthz` and `/sse`).
44
- 4. Use the MCP `sync` tool once to build the index.
41
+ 1. Start with **Quick start with Scrivener** (or use **Running with Docker** if that is your preferred setup).
42
+ 2. Start the server.
43
+ 3. Verify the server (`/healthz` and `/sse`).
44
+ 4. Run `import_scrivener_sync` with `dry_run: true` first to preview what will happen.
45
+ 5. Run it again with `dry_run: false` to write files. Keep `auto_sync: true` (default) so your scenes are indexed immediately.
45
46
 
46
- After that, come back to:
47
+ Once this is working, you can come back to:
47
48
 
48
49
  - **Advanced: Native sync format** for custom project layouts
49
50
  - **Reference: Available tools** for the full tool catalog
@@ -51,50 +52,83 @@ After that, come back to:
51
52
 
52
53
  ## Quick start with Scrivener
53
54
 
54
- If you write in [Scrivener](https://www.literatureandlatte.com/scrivener), you can seed `mcp-writing` from a Scrivener external-sync export for scene prose, then curate non-draft content directly into the target folder structure.
55
+ If you write in [Scrivener](https://www.literatureandlatte.com/scrivener), this gives you the smoothest path to get started.
55
56
 
56
57
  ### 1. Export from Scrivener
57
58
 
58
- In Scrivener: **File → Sync → With External Folder**. Set the format to **plain text** (`.txt`) and pick an output folder, for example `~/my-novel-txt/`. `mcp-writing` imports the `Draft/` folder automatically.
59
+ In Scrivener, go to **File → Sync → With External Folder**. Set the format to **plain text** (`.txt`) and choose an output folder, for example `~/my-novel-txt/`.
59
60
 
60
- ### 2. Import into mcp-writing
61
+ Only `Draft/` is imported automatically.
62
+
63
+ ### 2. Start mcp-writing
61
64
 
62
65
  ```sh
63
- node scripts/import.js ~/my-novel-txt /path/to/sync-dir --project my-novel
66
+ WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npm start
64
67
  ```
65
68
 
66
- The importer:
69
+ You should see:
67
70
 
68
- - Converts `Draft/` files to scene sidecars (`.meta.yaml`) with auto-generated `scene_id`, `title`, `part`, `chapter`, and `save_the_cat_beat` fields derived from the filename/structure.
69
- - Skips beat-marker files (`-Setup-`, `-Catalyst-`, etc.), chapter-intro files, epigraphs, and trashed files.
71
+ ```sh
72
+ Listening on port 3000
73
+ Sync dir: /path/to/sync-dir
74
+ Database: ./writing.db
75
+ ```
70
76
 
71
- Important: `sync` does not run this import step for you. If your source is a raw Scrivener `Draft/` export, run `scripts/import.js` first so scene files get `scene_id` metadata before indexing.
77
+ ### 3. Verify the server
72
78
 
73
- Non-draft content is not inferred from `Notes/`. Put it directly into the target sync dir using the `world/` folder conventions described below.
79
+ - Open `http://localhost:3000/healthz` and confirm it returns OK.
80
+ - Open `http://localhost:3000/sse` and confirm it opens an SSE stream.
74
81
 
75
- ### 3. Start the server
82
+ ### 4. Import Draft scenes through MCP (recommended)
76
83
 
77
- ```sh
78
- WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npm start
84
+ From your MCP client, call `import_scrivener_sync` with:
85
+
86
+ ```json
87
+ {
88
+ "source_dir": "/Users/yourname/my-novel-txt",
89
+ "project_id": "my-novel",
90
+ "dry_run": true
91
+ }
79
92
  ```
80
93
 
81
- You should see:
94
+ > **Note:** use a full absolute path for `source_dir`. Shell shortcuts like `~` are not expanded by Node.js.
95
+
96
+ If the preview looks right, run it again with writes enabled:
97
+
98
+ ```json
99
+ {
100
+ "source_dir": "/Users/yourname/my-novel-txt",
101
+ "project_id": "my-novel",
102
+ "dry_run": false,
103
+ "auto_sync": true
104
+ }
105
+ ```
106
+
107
+ The importer:
108
+
109
+ - Converts `Draft/` files to scene sidecars (`.meta.yaml`) with generated `scene_id`, `title`, `timeline_position`, `external_source`, `external_id`, and carried `save_the_cat_beat` where applicable.
110
+ - Skips beat-marker files (`-Setup-`, `-Catalyst-`, etc.), chapter-intro files, epigraphs, and trashed files.
111
+ - Reconciles updates by stable Scrivener binder ID (`[123]` in filenames) so reorder/move operations map to existing scenes.
112
+
113
+ Non-draft content is not inferred from `Notes/`. Put it directly into the target sync dir using the `world/` folder conventions described below.
114
+
115
+ ### 5. Optional: CLI fallback import
116
+
117
+ If you prefer to run the import from the command line, use:
82
118
 
83
119
  ```sh
84
- Listening on port 3000
85
- Sync dir: /path/to/sync-dir
86
- Database: ./writing.db
120
+ node scripts/import.js ~/my-novel-txt /path/to/sync-dir --project my-novel
87
121
  ```
88
122
 
89
- Then call the `sync` tool once to index everything.
123
+ Then call `sync` once.
90
124
 
91
- ### 4. Lint your metadata (optional)
125
+ ### 6. Lint your metadata (optional)
92
126
 
93
127
  ```sh
94
128
  node scripts/lint-metadata.mjs --sync-dir /path/to/sync-dir
95
129
  ```
96
130
 
97
- Exits non-zero if any errors are found. Warnings (e.g. `UNKNOWN_KEY`) are informational only.
131
+ This exits with a non-zero code if it finds errors. Warnings (for example `UNKNOWN_KEY`) are informational.
98
132
 
99
133
  ---
100
134
 
@@ -251,6 +285,7 @@ Outcome: you get AI speed with explicit approval and recoverable history for eve
251
285
  | Tool | Description |
252
286
  | --- | --- |
253
287
  | `sync` | Re-scan the sync folder and update the index |
288
+ | `import_scrivener_sync` | Import Scrivener Draft export into sidecars and optionally auto-run sync |
254
289
  | `find_scenes` | Filter scenes by character, beat, tag, part, chapter, or POV |
255
290
  | `get_scene_prose` | Load the full prose for a specific scene |
256
291
  | `get_chapter_prose` | Load all prose for a chapter |
package/importer.js ADDED
@@ -0,0 +1,299 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+
5
+ export function validateProjectId(projectId) {
6
+ if (typeof projectId !== "string" || projectId.trim().length === 0) {
7
+ return { ok: false, reason: "project_id must be a non-empty string." };
8
+ }
9
+
10
+ if (path.isAbsolute(projectId)) {
11
+ return { ok: false, reason: "project_id must not be an absolute path." };
12
+ }
13
+
14
+ if (projectId.includes("\\")) {
15
+ return { ok: false, reason: "project_id must not contain backslashes." };
16
+ }
17
+
18
+ const segments = projectId.split("/");
19
+ if (segments.length < 1 || segments.length > 2) {
20
+ return { ok: false, reason: "project_id must be '<project>' or '<universe>/<project>'." };
21
+ }
22
+
23
+ for (const segment of segments) {
24
+ if (!segment || segment === "." || segment === "..") {
25
+ return { ok: false, reason: "project_id must not contain '.' or '..' path segments." };
26
+ }
27
+ if (!/^[a-z0-9-]+$/.test(segment)) {
28
+ return { ok: false, reason: "project_id segments may contain only lowercase letters, numbers, and '-'." };
29
+ }
30
+ }
31
+
32
+ return { ok: true };
33
+ }
34
+
35
+ // Parse "NNN Title [binder_id].txt" -> { seq, rawTitle, binderId, ext } or null
36
+ function parseFilename(filename) {
37
+ const m = filename.match(/^(\d+)\s+(.+?)\s*\[(\d+)\]\.(txt|md)$/);
38
+ if (!m) return null;
39
+ return {
40
+ seq: parseInt(m[1], 10),
41
+ rawTitle: m[2].trim(),
42
+ binderId: m[3],
43
+ ext: m[4],
44
+ };
45
+ }
46
+
47
+ function isBeatMarker(rawTitle) {
48
+ return /^-[^-].+-$/.test(rawTitle.trim());
49
+ }
50
+
51
+ function parseBeat(rawTitle) {
52
+ return rawTitle.trim().replace(/^-/, "").replace(/-$/, "").trim();
53
+ }
54
+
55
+ function isEpigraph(rawTitle) {
56
+ return /^epigraph$/i.test(rawTitle.trim());
57
+ }
58
+
59
+ function cleanTitle(rawTitle) {
60
+ return rawTitle.replace(/^Scene\s+/i, "").trim();
61
+ }
62
+
63
+ function slugify(str) {
64
+ return str
65
+ .toLowerCase()
66
+ .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, "") // strip emoji
67
+ .replace(/[^a-z0-9]+/g, "-")
68
+ .replace(/^-+|-+$/g, "")
69
+ .slice(0, 50);
70
+ }
71
+
72
+ function makeSceneId(binderId, title) {
73
+ return `sc-${String(binderId).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
74
+ }
75
+
76
+ function walkSorted(dir) {
77
+ const files = [];
78
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
79
+ const full = path.join(dir, entry.name);
80
+ if (entry.isFile() && (entry.name.endsWith(".txt") || entry.name.endsWith(".md"))) {
81
+ files.push(full);
82
+ }
83
+ }
84
+ return files.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
85
+ }
86
+
87
+ function loadYamlFile(filePath) {
88
+ try {
89
+ return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {};
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
95
+ function buildExistingSceneIndex(dir) {
96
+ const byBinderId = new Map();
97
+ if (!fs.existsSync(dir)) return byBinderId;
98
+
99
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
100
+ if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
101
+
102
+ const sidecarPath = path.join(dir, entry.name);
103
+ const proseCandidates = [
104
+ sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
105
+ sidecarPath.replace(/\.meta\.yaml$/, ".md"),
106
+ ];
107
+ const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
108
+ const proseName = prosePath ? path.basename(prosePath) : entry.name.replace(/\.meta\.yaml$/, ".txt");
109
+ const parsedName = parseFilename(proseName);
110
+ const meta = loadYamlFile(sidecarPath);
111
+ const binderId = meta.external_source === "scrivener" && meta.external_id
112
+ ? String(meta.external_id)
113
+ : parsedName?.binderId ?? null;
114
+
115
+ if (!binderId) continue;
116
+
117
+ byBinderId.set(String(binderId), {
118
+ binderId: String(binderId),
119
+ prosePath,
120
+ sidecarPath,
121
+ meta,
122
+ });
123
+ }
124
+
125
+ return byBinderId;
126
+ }
127
+
128
+ function removeIfExists(filePath) {
129
+ if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
130
+ }
131
+
132
+ export function importScrivenerSync({
133
+ scrivenerDir,
134
+ mcpSyncDir,
135
+ projectId,
136
+ dryRun = false,
137
+ logger = () => {},
138
+ }) {
139
+ const scrivenerDirAbs = path.resolve(scrivenerDir);
140
+ const mcpSyncDirAbs = path.resolve(mcpSyncDir);
141
+ const resolvedProjectId = projectId
142
+ ? projectId
143
+ : path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
144
+
145
+ const projectIdCheck = validateProjectId(resolvedProjectId);
146
+ if (!projectIdCheck.ok) {
147
+ throw new Error(`Invalid project_id '${resolvedProjectId}': ${projectIdCheck.reason}`);
148
+ }
149
+
150
+ if (!fs.existsSync(scrivenerDirAbs)) {
151
+ throw new Error(`Scrivener sync dir not found: ${scrivenerDirAbs}`);
152
+ }
153
+
154
+ // Route universe/project IDs to universes/<universe>/<project>/scenes,
155
+ // matching the convention used by inferProjectAndUniverse in sync.js.
156
+ const segments = resolvedProjectId.split("/");
157
+ let scenesDir;
158
+ let scenesBoundaryRoot;
159
+ if (segments.length === 2) {
160
+ const [universeId, projectSlug] = segments;
161
+ scenesBoundaryRoot = path.join(mcpSyncDirAbs, "universes");
162
+ scenesDir = path.resolve(scenesBoundaryRoot, universeId, projectSlug, "scenes");
163
+ } else {
164
+ scenesBoundaryRoot = path.join(mcpSyncDirAbs, "projects");
165
+ scenesDir = path.resolve(scenesBoundaryRoot, resolvedProjectId, "scenes");
166
+ }
167
+
168
+ const relFromBoundary = path.relative(scenesBoundaryRoot, scenesDir);
169
+ if (relFromBoundary.startsWith("..") || path.isAbsolute(relFromBoundary)) {
170
+ throw new Error(`Invalid project_id '${resolvedProjectId}': resolved path escapes expected sync root.`);
171
+ }
172
+ const draftDir = path.join(scrivenerDirAbs, "Draft");
173
+ const hasDraft = fs.existsSync(draftDir);
174
+ const draftRoot = hasDraft ? draftDir : scrivenerDirAbs;
175
+
176
+ const files = walkSorted(draftRoot);
177
+ const existingScenes = buildExistingSceneIndex(scenesDir);
178
+
179
+ let created = 0;
180
+ let skipped = 0;
181
+ let existing = 0;
182
+ let beatMarkersSeen = 0;
183
+ let beatCarry = null;
184
+
185
+ if (!dryRun) {
186
+ fs.mkdirSync(scenesDir, { recursive: true });
187
+ }
188
+
189
+ logger(`Project: ${resolvedProjectId}`);
190
+ logger(`Scenes to: ${scenesDir}`);
191
+ logger(`Files: ${files.length}`);
192
+ logger("");
193
+
194
+ for (const file of files) {
195
+ const filename = path.basename(file);
196
+ const parsed = parseFilename(filename);
197
+
198
+ if (!parsed) {
199
+ logger(` SKIP (unrecognised pattern) ${filename}`);
200
+ skipped++;
201
+ continue;
202
+ }
203
+
204
+ const { seq, rawTitle, binderId, ext } = parsed;
205
+ const isEmpty = fs.statSync(file).size === 0;
206
+
207
+ if (isBeatMarker(rawTitle)) {
208
+ beatCarry = parseBeat(rawTitle);
209
+ beatMarkersSeen++;
210
+ logger(` BEAT "${beatCarry}"`);
211
+ continue;
212
+ }
213
+
214
+ if (isEmpty) {
215
+ logger(` SKIP (empty) ${filename}`);
216
+ skipped++;
217
+ continue;
218
+ }
219
+
220
+ if (isEpigraph(rawTitle)) {
221
+ logger(` SKIP (epigraph) ${filename}`);
222
+ skipped++;
223
+ continue;
224
+ }
225
+
226
+ const title = cleanTitle(rawTitle);
227
+ const existingScene = existingScenes.get(String(binderId)) ?? null;
228
+ const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
229
+ const destFile = path.join(scenesDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
230
+ const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
231
+
232
+ const meta = {
233
+ ...(existingScene?.meta ?? {}),
234
+ scene_id: sceneId,
235
+ external_source: "scrivener",
236
+ external_id: String(binderId),
237
+ title,
238
+ timeline_position: seq,
239
+ ...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
240
+ };
241
+
242
+ if (!beatCarry && existingScene?.meta && Object.hasOwn(existingScene.meta, "save_the_cat_beat")) {
243
+ delete meta.save_the_cat_beat;
244
+ }
245
+
246
+ if (dryRun) {
247
+ logger(` DRY ${path.basename(sidecar)}`);
248
+ if (existingScene) {
249
+ logger(` reconcile: binder ${binderId} -> existing scene_id ${sceneId}`);
250
+ }
251
+ logger(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
252
+ } else {
253
+ fs.copyFileSync(file, destFile);
254
+ fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
255
+
256
+ if (existingScene) {
257
+ if (existingScene.prosePath && existingScene.prosePath !== destFile) removeIfExists(existingScene.prosePath);
258
+ if (existingScene.sidecarPath && existingScene.sidecarPath !== sidecar) removeIfExists(existingScene.sidecarPath);
259
+ logger(` OK ${path.basename(sidecar)} [reconciled binder ${binderId}, beat: ${beatCarry ?? "-"}]`);
260
+ } else {
261
+ logger(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "-"}]`);
262
+ }
263
+
264
+ existingScenes.set(String(binderId), {
265
+ binderId: String(binderId),
266
+ prosePath: destFile,
267
+ sidecarPath: sidecar,
268
+ meta,
269
+ });
270
+ }
271
+
272
+ beatCarry = null;
273
+ if (existingScene) existing++;
274
+ else created++;
275
+ }
276
+
277
+ logger("");
278
+ logger(`${"-".repeat(50)}`);
279
+ logger(`Created: ${created} sidecars${dryRun ? " (dry run)" : ""}`);
280
+ logger(`Skipped: ${skipped} (empty / epigraph / pattern)`);
281
+ if (existing) logger(`Existing: ${existing} already had sidecars`);
282
+ logger(`Beat markers seen: ${beatMarkersSeen}`);
283
+
284
+ logger(`Non-draft content: manual`);
285
+ logger(` Place character/place/reference files directly in the target sync dir using the world/ folder conventions.`);
286
+
287
+ return {
288
+ projectId: resolvedProjectId,
289
+ scrivenerDir: scrivenerDirAbs,
290
+ mcpSyncDir: mcpSyncDirAbs,
291
+ scenesDir,
292
+ sourceFiles: files.length,
293
+ created,
294
+ skipped,
295
+ existing,
296
+ beatMarkersSeen,
297
+ dryRun,
298
+ };
299
+ }
package/index.js CHANGED
@@ -10,6 +10,7 @@ import { openDb } from "./db.js";
10
10
  import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
11
11
  import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
12
12
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
13
+ import { importScrivenerSync, validateProjectId } from "./importer.js";
13
14
 
14
15
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
15
16
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -309,6 +310,89 @@ function createMcpServer() {
309
310
  return { content: [{ type: "text", text: parts.join(" ") }] };
310
311
  });
311
312
 
313
+ // ---- import_scrivener_sync ----------------------------------------------
314
+ s.tool(
315
+ "import_scrivener_sync",
316
+ "Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. Use this for first-time setup before sync().",
317
+ {
318
+ source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
319
+ project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
320
+ dry_run: z.boolean().optional().describe("If true, reports planned writes without changing files."),
321
+ auto_sync: z.boolean().optional().describe("If true (default), runs sync() after import when not dry-run."),
322
+ },
323
+ async ({ source_dir, project_id, dry_run = false, auto_sync = true }) => {
324
+ if (project_id !== undefined) {
325
+ const projectIdCheck = validateProjectId(project_id);
326
+ if (!projectIdCheck.ok) {
327
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
328
+ }
329
+ }
330
+
331
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
332
+ return errorResponse(
333
+ "SYNC_DIR_NOT_WRITABLE",
334
+ "Cannot import because WRITING_SYNC_DIR is not writable in this runtime.",
335
+ { sync_dir: SYNC_DIR_ABS }
336
+ );
337
+ }
338
+
339
+ let importResult;
340
+ try {
341
+ importResult = importScrivenerSync({
342
+ scrivenerDir: source_dir,
343
+ mcpSyncDir: SYNC_DIR,
344
+ projectId: project_id,
345
+ dryRun: Boolean(dry_run),
346
+ });
347
+ } catch (error) {
348
+ return errorResponse(
349
+ "IMPORT_FAILED",
350
+ error instanceof Error ? error.message : "Import failed.",
351
+ {
352
+ source_dir,
353
+ sync_dir: SYNC_DIR_ABS,
354
+ project_id: project_id ?? null,
355
+ }
356
+ );
357
+ }
358
+
359
+ let syncResult = null;
360
+ if (!dry_run && auto_sync) {
361
+ syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
362
+ }
363
+
364
+ return jsonResponse({
365
+ ok: true,
366
+ import: {
367
+ source_dir: importResult.scrivenerDir,
368
+ sync_dir: importResult.mcpSyncDir,
369
+ scenes_dir: importResult.scenesDir,
370
+ project_id: importResult.projectId,
371
+ source_files: importResult.sourceFiles,
372
+ created: importResult.created,
373
+ existing: importResult.existing,
374
+ skipped: importResult.skipped,
375
+ beat_markers_seen: importResult.beatMarkersSeen,
376
+ dry_run: importResult.dryRun,
377
+ },
378
+ sync: syncResult
379
+ ? {
380
+ indexed: syncResult.indexed,
381
+ stale_marked: syncResult.staleMarked,
382
+ sidecars_migrated: syncResult.sidecarsMigrated,
383
+ skipped: syncResult.skipped,
384
+ warnings: syncResult.warnings,
385
+ }
386
+ : null,
387
+ next_step: dry_run
388
+ ? "Dry run complete. Re-run with dry_run=false to write files."
389
+ : auto_sync
390
+ ? "Import and sync complete."
391
+ : "Import complete. Run sync() to index imported scenes.",
392
+ });
393
+ }
394
+ );
395
+
312
396
  // ---- get_runtime_config --------------------------------------------------
313
397
  s.tool(
314
398
  "get_runtime_config",
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.4.8",
3
+ "version": "1.5.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",
7
7
  "files": [
8
8
  "index.js",
9
+ "importer.js",
9
10
  "db.js",
10
11
  "sync.js",
11
12
  "git.js",
@@ -21,7 +22,7 @@
21
22
  "start": "node --experimental-sqlite index.js",
22
23
  "new:entity": "node scripts/new-world-entity.js",
23
24
  "release": "release-it",
24
- "lint": "eslint index.js db.js sync.js metadata-lint.js scripts/",
25
+ "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
25
26
  "lint:metadata": "node scripts/lint-metadata.mjs",
26
27
  "test:unit": "node --experimental-sqlite --test test/unit.test.mjs",
27
28
  "test:integration": "node --experimental-sqlite --test test/integration.test.mjs",
package/scripts/import.js CHANGED
@@ -3,287 +3,60 @@
3
3
  * Import a Scrivener External Folder Sync output into mcp-writing sidecar format.
4
4
  *
5
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
- * Only Draft/ is imported automatically.
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 = current 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
- * - Reconciles existing imports by stable Scrivener binder ID (`[123]` in the filename)
23
- * - Writes a .meta.yaml sidecar for each scene while preserving existing editorial metadata
24
- *
25
- * What it does not do:
26
- * - It does not infer structure from Scrivener Notes/
27
- * - Non-draft content should be placed manually into the target sync dir
28
- * using the world/characters, world/places, and world/reference conventions
6
+ * node scripts/import.js <scrivener-sync-dir> <mcp-sync-dir> [--project <id>] [--dry-run]
29
7
  */
30
8
 
31
- import fs from "node:fs";
32
9
  import path from "node:path";
33
- import yaml from "js-yaml";
10
+ import { importScrivenerSync, validateProjectId } from "../importer.js";
11
+
12
+ function printUsage() {
13
+ console.log("Usage: node scripts/import.js <scrivener-sync-dir> <mcp-sync-dir> [--project <id>] [--dry-run]");
14
+ }
34
15
 
35
- // ---------------------------------------------------------------------------
36
- // Args
37
- // ---------------------------------------------------------------------------
38
16
  const args = process.argv.slice(2);
39
17
  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]");
18
+ printUsage();
41
19
  process.exit(args[0] === "--help" ? 0 : 1);
42
20
  }
43
21
 
44
22
  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}`);
23
+ const mcpSyncDir = path.resolve(args[1]);
24
+ const dryRun = args.includes("--dry-run");
25
+ const projectIdx = args.indexOf("--project");
26
+ let projectId;
27
+ if (projectIdx !== -1) {
28
+ const candidate = args[projectIdx + 1];
29
+ if (!candidate || candidate.startsWith("--")) {
30
+ console.error("Invalid --project value: expected a project id after --project.");
31
+ printUsage();
32
+ process.exit(1);
33
+ }
34
+ const projectIdCheck = validateProjectId(candidate);
35
+ if (!projectIdCheck.ok) {
36
+ console.error(`Invalid --project value '${candidate}': ${projectIdCheck.reason}`);
37
+ printUsage();
38
+ process.exit(1);
39
+ }
40
+ projectId = candidate;
41
+ }
42
+
43
+ try {
44
+ const result = importScrivenerSync({
45
+ scrivenerDir,
46
+ mcpSyncDir,
47
+ projectId,
48
+ dryRun,
49
+ logger: line => console.log(line),
50
+ });
51
+
52
+ if (!dryRun) {
53
+ console.log("\nNext steps:");
54
+ console.log(" 1. Start the service:");
55
+ console.log(` WRITING_SYNC_DIR=${result.mcpSyncDir} DB_PATH=./writing.db npm start`);
56
+ console.log(" 2. Call the sync tool to index everything");
57
+ console.log(" 3. Review part/chapter/pov fields in sidecars as needed");
58
+ }
59
+ } catch (error) {
60
+ console.error(error instanceof Error ? error.message : String(error));
54
61
  process.exit(1);
55
62
  }
56
-
57
- const scenesDir = path.join(mcpSyncDir, "projects", projectId, "scenes");
58
- // ---------------------------------------------------------------------------
59
- // Helpers
60
- // ---------------------------------------------------------------------------
61
-
62
- // Parse "NNN Title [binder_id].txt" → { seq, rawTitle, binderId, ext } or null
63
- function parseFilename(filename) {
64
- const m = filename.match(/^(\d+)\s+(.+?)\s*\[(\d+)\]\.(txt|md)$/);
65
- if (!m) return null;
66
- return {
67
- seq: parseInt(m[1], 10),
68
- rawTitle: m[2].trim(),
69
- binderId: m[3],
70
- ext: m[4],
71
- };
72
- }
73
-
74
- function isBeatMarker(rawTitle) {
75
- return /^-[^-].+-$/.test(rawTitle.trim());
76
- }
77
-
78
- function parseBeat(rawTitle) {
79
- return rawTitle.trim().replace(/^-/, "").replace(/-$/, "").trim();
80
- }
81
-
82
- function isEpigraph(rawTitle) {
83
- return /^epigraph$/i.test(rawTitle.trim());
84
- }
85
-
86
- function cleanTitle(rawTitle) {
87
- return rawTitle.replace(/^Scene\s+/i, "").trim();
88
- }
89
-
90
- function slugify(str) {
91
- return str
92
- .toLowerCase()
93
- .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, "") // strip emoji
94
- .replace(/[^a-z0-9]+/g, "-")
95
- .replace(/^-+|-+$/g, "")
96
- .slice(0, 50);
97
- }
98
-
99
- function makeSceneId(binderId, title) {
100
- return `sc-${String(binderId).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // Walk a directory (sorted by filename = binder order, non-recursive)
105
- // ---------------------------------------------------------------------------
106
- function walkSorted(dir) {
107
- const files = [];
108
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
109
- const full = path.join(dir, entry.name);
110
- if (entry.isFile() && (entry.name.endsWith(".txt") || entry.name.endsWith(".md"))) {
111
- files.push(full);
112
- }
113
- }
114
- return files.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
115
- }
116
-
117
- function loadYamlFile(filePath) {
118
- try {
119
- return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {};
120
- } catch {
121
- return {};
122
- }
123
- }
124
-
125
- function buildExistingSceneIndex(dir) {
126
- const byBinderId = new Map();
127
- if (!fs.existsSync(dir)) return byBinderId;
128
-
129
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
130
- if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
131
-
132
- const sidecarPath = path.join(dir, entry.name);
133
- const proseCandidates = [
134
- sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
135
- sidecarPath.replace(/\.meta\.yaml$/, ".md"),
136
- ];
137
- const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
138
- const proseName = prosePath ? path.basename(prosePath) : entry.name.replace(/\.meta\.yaml$/, ".txt");
139
- const parsedName = parseFilename(proseName);
140
- const meta = loadYamlFile(sidecarPath);
141
- const binderId = meta.external_source === "scrivener" && meta.external_id
142
- ? String(meta.external_id)
143
- : parsedName?.binderId ?? null;
144
-
145
- if (!binderId) continue;
146
-
147
- byBinderId.set(String(binderId), {
148
- binderId: String(binderId),
149
- prosePath,
150
- sidecarPath,
151
- meta,
152
- });
153
- }
154
-
155
- return byBinderId;
156
- }
157
-
158
- function removeIfExists(filePath) {
159
- if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // Main
164
- // ---------------------------------------------------------------------------
165
- const draftDir = path.join(scrivenerDir, "Draft");
166
- const hasDraft = fs.existsSync(draftDir);
167
-
168
- // If there's a Draft/ subdir use that; otherwise treat scrivenerDir as Draft directly
169
- const draftRoot = hasDraft ? draftDir : scrivenerDir;
170
-
171
- const files = walkSorted(draftRoot);
172
- const existingScenes = buildExistingSceneIndex(scenesDir);
173
- let created = 0;
174
- let skipped = 0;
175
- let alreadyDone = 0;
176
- let beatCarry = null; // last seen beat marker
177
-
178
- if (!dryRun) {
179
- fs.mkdirSync(scenesDir, { recursive: true });
180
- }
181
-
182
- console.log(`Project: ${projectId}`);
183
- console.log(`Scenes to: ${scenesDir}`);
184
- console.log(`Files: ${files.length}\n`);
185
-
186
- for (const file of files) {
187
- const filename = path.basename(file);
188
- const parsed = parseFilename(filename);
189
-
190
- if (!parsed) {
191
- console.log(` SKIP (unrecognised pattern) ${filename}`);
192
- skipped++;
193
- continue;
194
- }
195
-
196
- const { seq, rawTitle, binderId, ext } = parsed;
197
- const isEmpty = fs.statSync(file).size === 0;
198
-
199
- // Beat markers: always empty, carry beat name forward
200
- if (isBeatMarker(rawTitle)) {
201
- beatCarry = parseBeat(rawTitle);
202
- console.log(` BEAT "${beatCarry}"`);
203
- continue;
204
- }
205
-
206
- // Empty non-beat files: title cards, chapter headers excluded from compilation
207
- if (isEmpty) {
208
- console.log(` SKIP (empty) ${filename}`);
209
- skipped++;
210
- continue;
211
- }
212
-
213
- // Epigraphs: have content but aren't scenes
214
- if (isEpigraph(rawTitle)) {
215
- console.log(` SKIP (epigraph) ${filename}`);
216
- skipped++;
217
- continue;
218
- }
219
-
220
- // Scene file — create sidecar
221
- const title = cleanTitle(rawTitle);
222
- const existing = existingScenes.get(String(binderId)) ?? null;
223
- const sceneId = existing?.meta?.scene_id ?? makeSceneId(binderId, title);
224
- const destFile = path.join(scenesDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
225
- const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
226
-
227
- const meta = {
228
- ...(existing?.meta ?? {}),
229
- scene_id: sceneId,
230
- external_source: "scrivener",
231
- external_id: String(binderId),
232
- title,
233
- timeline_position: seq,
234
- ...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
235
- // Placeholders — fill in after reviewing
236
- // part: null,
237
- // chapter: null,
238
- // pov: null,
239
- // logline: null,
240
- // characters: [],
241
- // places: [],
242
- // tags: [],
243
- };
244
-
245
- if (!beatCarry && existing?.meta && Object.hasOwn(existing.meta, "save_the_cat_beat")) {
246
- delete meta.save_the_cat_beat;
247
- }
248
-
249
- if (dryRun) {
250
- console.log(` DRY ${path.basename(sidecar)}`);
251
- if (existing) {
252
- console.log(` reconcile: binder ${binderId} -> existing scene_id ${sceneId}`);
253
- }
254
- console.log(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
255
- } else {
256
- // Copy/update prose file in mcp-sync-dir scenes folder
257
- fs.copyFileSync(file, destFile);
258
- fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
259
-
260
- if (existing) {
261
- if (existing.prosePath && existing.prosePath !== destFile) removeIfExists(existing.prosePath);
262
- if (existing.sidecarPath && existing.sidecarPath !== sidecar) removeIfExists(existing.sidecarPath);
263
- console.log(` OK ${path.basename(sidecar)} [reconciled binder ${binderId}, beat: ${beatCarry ?? "—"}]`);
264
- } else {
265
- console.log(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "—"}]`);
266
- }
267
- existingScenes.set(String(binderId), { binderId: String(binderId), prosePath: destFile, sidecarPath: sidecar, meta });
268
- }
269
-
270
- beatCarry = null; // consumed
271
- if (existing) alreadyDone++;
272
- else created++;
273
- }
274
-
275
- console.log(`\n${"─".repeat(50)}`);
276
- console.log(`Created: ${created} sidecars${dryRun ? " (dry run)" : ""}`);
277
- console.log(`Skipped: ${skipped} (empty / epigraph / pattern)`);
278
- if (alreadyDone) console.log(`Existing: ${alreadyDone} already had sidecars`);
279
-
280
- console.log(`Non-draft content: manual`);
281
- console.log(` Place character/place/reference files directly in the target sync dir using the world/ folder conventions.`);
282
-
283
- if (!dryRun && created > 0) {
284
- console.log(`\nNext steps:`);
285
- console.log(` 1. Start the service:`);
286
- console.log(` WRITING_SYNC_DIR=${mcpSyncDir} DB_PATH=./writing.db npm start`);
287
- console.log(` 2. Call the sync tool to index everything`);
288
- console.log(` 3. Review part/chapter/pov fields in sidecars as needed`);
289
- }