@hanna84/mcp-writing 2.12.5 → 2.12.7
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 +20 -0
- package/db.js +1 -268
- package/git.js +1 -209
- package/helpers.js +2 -2
- package/importer.js +1 -448
- package/metadata-lint.js +1 -468
- package/package.json +1 -1
- package/scene-character-batch.js +1 -246
- package/scene-character-normalization.js +1 -199
- package/scripts/async-job-runner.mjs +3 -3
- package/scripts/import.js +1 -1
- package/scripts/lint-metadata.mjs +1 -1
- package/scripts/manual-scrivener-realtest.mjs +3 -3
- package/scripts/merge-scrivx.js +2 -2
- package/scripts/new-world-entity.js +2 -2
- package/scripts/normalize-scene-characters.mjs +4 -4
- package/scripts/profile-review-bundles.mjs +3 -3
- package/scrivener-direct.js +1 -843
- package/src/core/db.js +268 -0
- package/src/core/git.js +209 -0
- package/src/index.js +3 -3
- package/src/runtime/async-jobs.js +1 -1
- package/src/sync/importer.js +448 -0
- package/src/sync/metadata-lint.js +468 -0
- package/src/sync/scene-character-batch.js +246 -0
- package/src/sync/scene-character-normalization.js +199 -0
- package/src/sync/scrivener-direct.js +843 -0
- package/src/sync/sync.js +755 -0
- package/src/world/world-entity-templates.js +116 -0
- package/sync.js +1 -755
- package/tools/editing.js +2 -2
- package/tools/metadata.js +2 -2
- package/tools/review-bundles.js +2 -2
- package/tools/styleguide.js +1 -1
- package/tools/sync.js +2 -2
- package/world-entity-templates.js +1 -116
package/importer.js
CHANGED
|
@@ -1,448 +1 @@
|
|
|
1
|
-
|
|
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
|
-
export function validateUniverseId(universeId) {
|
|
36
|
-
if (typeof universeId !== "string" || universeId.trim().length === 0) {
|
|
37
|
-
return { ok: false, reason: "universe_id must be a non-empty string." };
|
|
38
|
-
}
|
|
39
|
-
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(universeId)) {
|
|
40
|
-
return { ok: false, reason: "universe_id may contain only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen." };
|
|
41
|
-
}
|
|
42
|
-
return { ok: true };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Parse "NNN Title [binder_id].txt" -> { seq, rawTitle, binderId, ext } or null
|
|
46
|
-
function parseFilename(filename) {
|
|
47
|
-
const m = filename.match(/^(\d+)\s+(.+?)\s*\[(\d+)\]\.(txt|md)$/);
|
|
48
|
-
if (!m) return null;
|
|
49
|
-
return {
|
|
50
|
-
seq: parseInt(m[1], 10),
|
|
51
|
-
rawTitle: m[2].trim(),
|
|
52
|
-
binderId: m[3],
|
|
53
|
-
ext: m[4],
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isBeatMarker(rawTitle) {
|
|
58
|
-
return /^-[^-].+-$/.test(rawTitle.trim());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function parseBeat(rawTitle) {
|
|
62
|
-
return rawTitle.trim().replace(/^-/, "").replace(/-$/, "").trim();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isEpigraph(rawTitle) {
|
|
66
|
-
return /^epigraph$/i.test(rawTitle.trim());
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function cleanTitle(rawTitle) {
|
|
70
|
-
return rawTitle.replace(/^Scene\s+/i, "").trim();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function slugify(str) {
|
|
74
|
-
return str
|
|
75
|
-
.toLowerCase()
|
|
76
|
-
.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, "") // strip emoji
|
|
77
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
78
|
-
.replace(/^-+|-+$/g, "")
|
|
79
|
-
.slice(0, 50);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function makeSceneId(binderId, title) {
|
|
83
|
-
return `sc-${String(binderId).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function walkSorted(dir) {
|
|
87
|
-
const files = [];
|
|
88
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
89
|
-
const full = path.join(dir, entry.name);
|
|
90
|
-
if (entry.isFile() && (entry.name.endsWith(".txt") || entry.name.endsWith(".md"))) {
|
|
91
|
-
files.push(full);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return files.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function compileIgnorePatterns(patterns) {
|
|
98
|
-
return patterns.map((pattern) => {
|
|
99
|
-
try {
|
|
100
|
-
return new RegExp(pattern);
|
|
101
|
-
} catch (err) {
|
|
102
|
-
const error = new Error(
|
|
103
|
-
`Invalid ignore pattern '${pattern}': ${err instanceof Error ? err.message : String(err)}`
|
|
104
|
-
);
|
|
105
|
-
error.code = "INVALID_IGNORE_PATTERN";
|
|
106
|
-
error.pattern = pattern;
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function loadYamlFile(filePath) {
|
|
113
|
-
try {
|
|
114
|
-
return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {};
|
|
115
|
-
} catch {
|
|
116
|
-
return {};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function walkSidecarFiles(dir, fileList = []) {
|
|
121
|
-
if (!fs.existsSync(dir)) return fileList;
|
|
122
|
-
|
|
123
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
-
const full = path.join(dir, entry.name);
|
|
125
|
-
if (entry.isDirectory()) {
|
|
126
|
-
// Skip nested mirror trees under scenes/ (e.g. scenes/projects/... or scenes/universes/...)
|
|
127
|
-
// to avoid accidental reconciliation against duplicated sidecars.
|
|
128
|
-
if (/^(projects|universes)$/i.test(entry.name)) continue;
|
|
129
|
-
walkSidecarFiles(full, fileList);
|
|
130
|
-
} else if (entry.isFile() && entry.name.endsWith(".meta.yaml")) {
|
|
131
|
-
fileList.push(full);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return fileList;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function buildExistingSceneIndex(dir) {
|
|
139
|
-
const byBinderId = new Map();
|
|
140
|
-
if (!fs.existsSync(dir)) return byBinderId;
|
|
141
|
-
|
|
142
|
-
for (const sidecarPath of walkSidecarFiles(dir)) {
|
|
143
|
-
const proseCandidates = [
|
|
144
|
-
sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
|
|
145
|
-
sidecarPath.replace(/\.meta\.yaml$/, ".md"),
|
|
146
|
-
];
|
|
147
|
-
const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
|
|
148
|
-
const proseName = prosePath ? path.basename(prosePath) : path.basename(sidecarPath).replace(/\.meta\.yaml$/, ".txt");
|
|
149
|
-
const parsedName = parseFilename(proseName);
|
|
150
|
-
const meta = loadYamlFile(sidecarPath);
|
|
151
|
-
const binderId = meta.external_source === "scrivener" && meta.external_id
|
|
152
|
-
? String(meta.external_id)
|
|
153
|
-
: parsedName?.binderId ?? null;
|
|
154
|
-
|
|
155
|
-
if (!binderId) continue;
|
|
156
|
-
|
|
157
|
-
byBinderId.set(String(binderId), {
|
|
158
|
-
binderId: String(binderId),
|
|
159
|
-
prosePath,
|
|
160
|
-
sidecarPath,
|
|
161
|
-
meta,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return byBinderId;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function removeIfExists(filePath) {
|
|
169
|
-
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function resolveSyncRootFromPrefix(prefix, syncDirAbs) {
|
|
173
|
-
const parsedRoot = path.parse(syncDirAbs).root;
|
|
174
|
-
|
|
175
|
-
if (!prefix) {
|
|
176
|
-
return parsedRoot ? path.resolve(parsedRoot) : path.resolve(syncDirAbs);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// On Windows, a regex prefix like "C:" would resolve relative to cwd on drive C.
|
|
180
|
-
// Use the true drive root instead (e.g., "C:\\").
|
|
181
|
-
if (/^[a-zA-Z]:$/.test(prefix)) {
|
|
182
|
-
return parsedRoot || `${prefix}${path.sep}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return path.resolve(prefix);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function detectScopedSyncDir(syncDirAbs) {
|
|
189
|
-
const normalized = syncDirAbs.split(path.sep).join("/");
|
|
190
|
-
|
|
191
|
-
const universeMatch = normalized.match(/^(.*)\/universes\/([^/]+)\/([^/]+)(?:\/scenes)?$/);
|
|
192
|
-
if (universeMatch) {
|
|
193
|
-
const prefix = universeMatch[1];
|
|
194
|
-
const universeId = universeMatch[2];
|
|
195
|
-
const projectSlug = universeMatch[3];
|
|
196
|
-
const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
|
|
197
|
-
const projectRoot = path.join(syncRoot, "universes", universeId, projectSlug);
|
|
198
|
-
return {
|
|
199
|
-
projectId: `${universeId}/${projectSlug}`,
|
|
200
|
-
scope: "universe",
|
|
201
|
-
syncRoot,
|
|
202
|
-
projectRoot,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const projectMatch = normalized.match(/^(.*)\/projects\/([^/]+)(?:\/scenes)?$/);
|
|
207
|
-
if (projectMatch) {
|
|
208
|
-
const prefix = projectMatch[1];
|
|
209
|
-
const projectSlug = projectMatch[2];
|
|
210
|
-
const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
|
|
211
|
-
const projectRoot = path.join(syncRoot, "projects", projectSlug);
|
|
212
|
-
return {
|
|
213
|
-
projectId: projectSlug,
|
|
214
|
-
scope: "project",
|
|
215
|
-
syncRoot,
|
|
216
|
-
projectRoot,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function importScrivenerSync({
|
|
224
|
-
scrivenerDir,
|
|
225
|
-
mcpSyncDir,
|
|
226
|
-
projectId,
|
|
227
|
-
dryRun = false,
|
|
228
|
-
preflight = false,
|
|
229
|
-
ignorePatterns = [],
|
|
230
|
-
logger = () => {},
|
|
231
|
-
}) {
|
|
232
|
-
const scrivenerDirAbs = path.resolve(scrivenerDir);
|
|
233
|
-
const mcpSyncDirAbs = path.resolve(mcpSyncDir);
|
|
234
|
-
const scopedSyncDir = detectScopedSyncDir(mcpSyncDirAbs);
|
|
235
|
-
const fallbackProjectId = path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
236
|
-
const resolvedProjectId = projectId
|
|
237
|
-
? projectId
|
|
238
|
-
: scopedSyncDir?.projectId ?? fallbackProjectId;
|
|
239
|
-
|
|
240
|
-
const projectIdCheck = validateProjectId(resolvedProjectId);
|
|
241
|
-
if (!projectIdCheck.ok) {
|
|
242
|
-
throw new Error(`Invalid project_id '${resolvedProjectId}': ${projectIdCheck.reason}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (scopedSyncDir && projectId && projectId !== scopedSyncDir.projectId) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
`project_id '${projectId}' does not match WRITING_SYNC_DIR scope '${scopedSyncDir.projectId}'. `
|
|
248
|
-
+ "Set WRITING_SYNC_DIR to the sync root or use the matching project_id."
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (!fs.existsSync(scrivenerDirAbs)) {
|
|
253
|
-
throw new Error(`Scrivener sync dir not found: ${scrivenerDirAbs}`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
let scenesDir;
|
|
257
|
-
let scenesBoundaryRoot;
|
|
258
|
-
if (scopedSyncDir) {
|
|
259
|
-
scenesBoundaryRoot = path.join(
|
|
260
|
-
scopedSyncDir.syncRoot,
|
|
261
|
-
scopedSyncDir.scope === "universe" ? "universes" : "projects"
|
|
262
|
-
);
|
|
263
|
-
scenesDir = path.join(scopedSyncDir.projectRoot, "scenes");
|
|
264
|
-
} else {
|
|
265
|
-
// Route universe/project IDs to universes/<universe>/<project>/scenes,
|
|
266
|
-
// matching the convention used by inferProjectAndUniverse in sync.js.
|
|
267
|
-
const segments = resolvedProjectId.split("/");
|
|
268
|
-
if (segments.length === 2) {
|
|
269
|
-
const [universeId, projectSlug] = segments;
|
|
270
|
-
scenesBoundaryRoot = path.join(mcpSyncDirAbs, "universes");
|
|
271
|
-
scenesDir = path.resolve(scenesBoundaryRoot, universeId, projectSlug, "scenes");
|
|
272
|
-
} else {
|
|
273
|
-
scenesBoundaryRoot = path.join(mcpSyncDirAbs, "projects");
|
|
274
|
-
scenesDir = path.resolve(scenesBoundaryRoot, resolvedProjectId, "scenes");
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const relFromBoundary = path.relative(scenesBoundaryRoot, scenesDir);
|
|
279
|
-
if (relFromBoundary.startsWith("..") || path.isAbsolute(relFromBoundary)) {
|
|
280
|
-
throw new Error(`Invalid project_id '${resolvedProjectId}': resolved path escapes expected sync root.`);
|
|
281
|
-
}
|
|
282
|
-
const draftDir = path.join(scrivenerDirAbs, "Draft");
|
|
283
|
-
const hasDraft = fs.existsSync(draftDir);
|
|
284
|
-
const draftRoot = hasDraft ? draftDir : scrivenerDirAbs;
|
|
285
|
-
|
|
286
|
-
const compiledIgnorePatterns = compileIgnorePatterns(ignorePatterns);
|
|
287
|
-
|
|
288
|
-
function isIgnored(filename) {
|
|
289
|
-
return compiledIgnorePatterns.some(re => re.test(filename));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const rawFiles = walkSorted(draftRoot);
|
|
293
|
-
const ignoredFiles = rawFiles.filter(f => isIgnored(path.basename(f)));
|
|
294
|
-
const files = rawFiles.filter(f => !isIgnored(path.basename(f)));
|
|
295
|
-
|
|
296
|
-
const existingScenes = buildExistingSceneIndex(scenesDir);
|
|
297
|
-
|
|
298
|
-
let created = 0;
|
|
299
|
-
let skipped = 0;
|
|
300
|
-
let existing = 0;
|
|
301
|
-
let beatMarkersSeen = 0;
|
|
302
|
-
let beatCarry = null;
|
|
303
|
-
|
|
304
|
-
if (preflight) {
|
|
305
|
-
const previewFiles = files.map(f => path.relative(draftRoot, f));
|
|
306
|
-
return {
|
|
307
|
-
projectId: resolvedProjectId,
|
|
308
|
-
scrivenerDir: scrivenerDirAbs,
|
|
309
|
-
mcpSyncDir: mcpSyncDirAbs,
|
|
310
|
-
scenesDir,
|
|
311
|
-
preflight: true,
|
|
312
|
-
dryRun: true,
|
|
313
|
-
sourceFiles: rawFiles.length,
|
|
314
|
-
ignoredFiles: ignoredFiles.length,
|
|
315
|
-
ignoredFilenames: ignoredFiles.map(f => path.basename(f)),
|
|
316
|
-
filesToProcess: files.length,
|
|
317
|
-
filePreviews: previewFiles,
|
|
318
|
-
existingSidecars: existingScenes.size,
|
|
319
|
-
created: 0,
|
|
320
|
-
existing: 0,
|
|
321
|
-
skipped: 0,
|
|
322
|
-
beatMarkersSeen: 0,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (!dryRun) {
|
|
327
|
-
fs.mkdirSync(scenesDir, { recursive: true });
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
logger(`Project: ${resolvedProjectId}`);
|
|
331
|
-
logger(`Scenes to: ${scenesDir}`);
|
|
332
|
-
logger(`Files: ${files.length} (${ignoredFiles.length} ignored)`);
|
|
333
|
-
logger("");
|
|
334
|
-
|
|
335
|
-
for (const file of files) {
|
|
336
|
-
const filename = path.basename(file);
|
|
337
|
-
const parsed = parseFilename(filename);
|
|
338
|
-
|
|
339
|
-
if (!parsed) {
|
|
340
|
-
logger(` SKIP (unrecognised pattern) ${filename}`);
|
|
341
|
-
skipped++;
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const { seq, rawTitle, binderId, ext } = parsed;
|
|
346
|
-
const isEmpty = fs.statSync(file).size === 0;
|
|
347
|
-
|
|
348
|
-
if (isBeatMarker(rawTitle)) {
|
|
349
|
-
beatCarry = parseBeat(rawTitle);
|
|
350
|
-
beatMarkersSeen++;
|
|
351
|
-
logger(` BEAT "${beatCarry}"`);
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (isEmpty) {
|
|
356
|
-
logger(` SKIP (empty) ${filename}`);
|
|
357
|
-
skipped++;
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (isEpigraph(rawTitle)) {
|
|
362
|
-
logger(` SKIP (epigraph) ${filename}`);
|
|
363
|
-
skipped++;
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const title = cleanTitle(rawTitle);
|
|
368
|
-
const existingScene = existingScenes.get(String(binderId)) ?? null;
|
|
369
|
-
const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
|
|
370
|
-
const targetDir = existingScene?.prosePath
|
|
371
|
-
? path.dirname(existingScene.prosePath)
|
|
372
|
-
: existingScene?.sidecarPath
|
|
373
|
-
? path.dirname(existingScene.sidecarPath)
|
|
374
|
-
: scenesDir;
|
|
375
|
-
const destFile = path.join(targetDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
|
|
376
|
-
const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
|
|
377
|
-
|
|
378
|
-
const meta = {
|
|
379
|
-
...(existingScene?.meta ?? {}),
|
|
380
|
-
scene_id: sceneId,
|
|
381
|
-
external_source: "scrivener",
|
|
382
|
-
external_id: String(binderId),
|
|
383
|
-
title,
|
|
384
|
-
timeline_position: seq,
|
|
385
|
-
...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
if (!beatCarry && existingScene?.meta && Object.hasOwn(existingScene.meta, "save_the_cat_beat")) {
|
|
389
|
-
delete meta.save_the_cat_beat;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (dryRun) {
|
|
393
|
-
logger(` DRY ${path.basename(sidecar)}`);
|
|
394
|
-
if (existingScene) {
|
|
395
|
-
logger(` reconcile: binder ${binderId} -> existing scene_id ${sceneId}`);
|
|
396
|
-
}
|
|
397
|
-
logger(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
|
|
398
|
-
} else {
|
|
399
|
-
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
|
400
|
-
fs.copyFileSync(file, destFile);
|
|
401
|
-
fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
|
|
402
|
-
|
|
403
|
-
if (existingScene) {
|
|
404
|
-
if (existingScene.prosePath && existingScene.prosePath !== destFile) removeIfExists(existingScene.prosePath);
|
|
405
|
-
if (existingScene.sidecarPath && existingScene.sidecarPath !== sidecar) removeIfExists(existingScene.sidecarPath);
|
|
406
|
-
logger(` OK ${path.basename(sidecar)} [reconciled binder ${binderId}, beat: ${beatCarry ?? "-"}]`);
|
|
407
|
-
} else {
|
|
408
|
-
logger(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "-"}]`);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
existingScenes.set(String(binderId), {
|
|
412
|
-
binderId: String(binderId),
|
|
413
|
-
prosePath: destFile,
|
|
414
|
-
sidecarPath: sidecar,
|
|
415
|
-
meta,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
beatCarry = null;
|
|
420
|
-
if (existingScene) existing++;
|
|
421
|
-
else created++;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
logger("");
|
|
425
|
-
logger(`${"-".repeat(50)}`);
|
|
426
|
-
logger(`Created: ${created} sidecars${dryRun ? " (dry run)" : ""}`);
|
|
427
|
-
logger(`Skipped: ${skipped} (empty / epigraph / pattern)`);
|
|
428
|
-
if (existing) logger(`Existing: ${existing} already had sidecars`);
|
|
429
|
-
logger(`Beat markers seen: ${beatMarkersSeen}`);
|
|
430
|
-
|
|
431
|
-
logger(`Non-draft content: manual`);
|
|
432
|
-
logger(` Place character/place/reference files directly in the target sync dir using the world/ folder conventions.`);
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
projectId: resolvedProjectId,
|
|
436
|
-
scrivenerDir: scrivenerDirAbs,
|
|
437
|
-
mcpSyncDir: mcpSyncDirAbs,
|
|
438
|
-
scenesDir,
|
|
439
|
-
preflight: false,
|
|
440
|
-
sourceFiles: rawFiles.length,
|
|
441
|
-
ignoredFiles: ignoredFiles.length,
|
|
442
|
-
created,
|
|
443
|
-
skipped,
|
|
444
|
-
existing,
|
|
445
|
-
beatMarkersSeen,
|
|
446
|
-
dryRun,
|
|
447
|
-
};
|
|
448
|
-
}
|
|
1
|
+
export * from "./src/sync/importer.js";
|