@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 +10 -0
- package/README.md +60 -25
- package/importer.js +299 -0
- package/index.js +84 -0
- package/package.json +3 -2
- package/scripts/import.js +45 -272
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,
|
|
39
|
+
If this is your first time, follow these steps in order:
|
|
40
40
|
|
|
41
|
-
1.
|
|
42
|
-
2. Start the server
|
|
43
|
-
3.
|
|
44
|
-
4.
|
|
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
|
-
|
|
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),
|
|
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
|
|
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
|
-
|
|
61
|
+
Only `Draft/` is imported automatically.
|
|
62
|
+
|
|
63
|
+
### 2. Start mcp-writing
|
|
61
64
|
|
|
62
65
|
```sh
|
|
63
|
-
|
|
66
|
+
WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npm start
|
|
64
67
|
```
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
You should see:
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
```sh
|
|
72
|
+
Listening on port 3000
|
|
73
|
+
Sync dir: /path/to/sync-dir
|
|
74
|
+
Database: ./writing.db
|
|
75
|
+
```
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
### 3. Verify the server
|
|
72
78
|
|
|
73
|
-
|
|
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
|
-
###
|
|
82
|
+
### 4. Import Draft scenes through MCP (recommended)
|
|
76
83
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
+
Then call `sync` once.
|
|
90
124
|
|
|
91
|
-
###
|
|
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
|
-
|
|
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.
|
|
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> [
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
const dryRun
|
|
47
|
-
const projectIdx
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
}
|