@hanna84/mcp-writing 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/db.js +127 -0
- package/index.js +892 -0
- package/metadata-lint.js +332 -0
- package/package.json +42 -0
- package/scripts/import.js +360 -0
- package/scripts/lint-metadata.mjs +28 -0
- package/scripts/merge-scrivx.js +319 -0
- package/scripts/split-versions.js +26 -0
- package/sync.js +431 -0
|
@@ -0,0 +1,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);
|