@hanna84/mcp-writing 2.12.4 → 2.12.6

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/async-jobs.js +1 -218
  3. package/async-progress.js +1 -1
  4. package/helpers.js +2 -2
  5. package/importer.js +1 -448
  6. package/index.js +1 -501
  7. package/metadata-lint.js +1 -468
  8. package/package.json +32 -2
  9. package/runtime-diagnostics.js +1 -97
  10. package/scene-character-batch.js +1 -246
  11. package/scene-character-normalization.js +1 -199
  12. package/scripts/async-job-runner.mjs +3 -3
  13. package/scripts/generate-tool-docs.mjs +21 -3
  14. package/scripts/import.js +1 -1
  15. package/scripts/lint-metadata.mjs +1 -1
  16. package/scripts/manual-scrivener-realtest.mjs +3 -3
  17. package/scripts/merge-scrivx.js +2 -2
  18. package/scripts/new-world-entity.js +2 -2
  19. package/scripts/normalize-scene-characters.mjs +3 -3
  20. package/scripts/profile-review-bundles.mjs +1 -1
  21. package/scrivener-direct.js +1 -843
  22. package/src/index.js +502 -0
  23. package/src/runtime/async-jobs.js +218 -0
  24. package/src/runtime/async-progress.js +1 -0
  25. package/src/runtime/runtime-diagnostics.js +97 -0
  26. package/src/sync/importer.js +448 -0
  27. package/src/sync/metadata-lint.js +468 -0
  28. package/src/sync/scene-character-batch.js +246 -0
  29. package/src/sync/scene-character-normalization.js +199 -0
  30. package/src/sync/scrivener-direct.js +843 -0
  31. package/src/sync/sync.js +755 -0
  32. package/src/world/world-entity-templates.js +116 -0
  33. package/sync.js +1 -755
  34. package/tools/editing.js +1 -1
  35. package/tools/metadata.js +2 -2
  36. package/tools/review-bundles.js +1 -1
  37. package/tools/styleguide.js +1 -1
  38. package/tools/sync.js +2 -2
  39. package/world-entity-templates.js +1 -116
@@ -1,843 +1 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { DOMParser } from "@xmldom/xmldom";
4
- import yaml from "js-yaml";
5
- import { validateProjectId } from "./importer.js";
6
- import { createSnapshot, isGitRepository, isGitAvailable } from "./git.js";
7
-
8
- function attr(el, name) {
9
- return el?.getAttribute?.(name) ?? null;
10
- }
11
-
12
- function text(el) {
13
- return el?.textContent?.trim?.() ?? null;
14
- }
15
-
16
- function children(el, tag) {
17
- const out = [];
18
- if (!el?.childNodes) return out;
19
- for (const child of el.childNodes) {
20
- if (child.nodeType === 1 && child.tagName === tag) out.push(child);
21
- }
22
- return out;
23
- }
24
-
25
- function walkYamls(dir, list = []) {
26
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27
- if (entry.isDirectory() && /^(projects|universes)$/i.test(entry.name)) {
28
- continue;
29
- }
30
- const full = path.join(dir, entry.name);
31
- if (entry.isDirectory()) walkYamls(full, list);
32
- else if (entry.name.endsWith(".meta.yaml")) list.push(full);
33
- }
34
- return list;
35
- }
36
-
37
- function isPlainObject(value) {
38
- return value !== null && typeof value === "object" && !Array.isArray(value);
39
- }
40
-
41
- function slugifyPathSegment(value) {
42
- return String(value ?? "")
43
- .normalize("NFKD")
44
- .replace(/[\u0300-\u036f]/g, "")
45
- .toLowerCase()
46
- .replace(/[^a-z0-9]+/g, "-")
47
- .replace(/^-+|-+$/g, "")
48
- .slice(0, 50);
49
- }
50
-
51
- function chapterFolderName(chapter, chapterTitle) {
52
- if (chapter === null || chapter === undefined) return null;
53
- const suffix = slugifyPathSegment(chapterTitle);
54
- return suffix ? `chapter-${chapter}-${suffix}` : `chapter-${chapter}`;
55
- }
56
-
57
- function sceneContainerDir(scenesDir, part, chapter, chapterTitle, organizeByChapters = true) {
58
- const segments = [scenesDir];
59
- if (!organizeByChapters) {
60
- return path.join(...segments);
61
- }
62
- if (part !== null && part !== undefined) segments.push(`part-${part}`);
63
- const chapterDir = chapterFolderName(chapter, chapterTitle);
64
- if (chapterDir) segments.push(chapterDir);
65
- return path.join(...segments);
66
- }
67
-
68
- function findProsePathForSidecar(sidecarPath) {
69
- const proseCandidates = [
70
- sidecarPath.replace(/\.meta\.yaml$/, ".md"),
71
- sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
72
- ];
73
- return proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
74
- }
75
-
76
- function moveFileIfNeeded(fromPath, toPath) {
77
- if (!fromPath || fromPath === toPath) return;
78
- fs.mkdirSync(path.dirname(toPath), { recursive: true });
79
- if (fs.existsSync(toPath)) {
80
- return {
81
- moved: false,
82
- warning: {
83
- code: "relocate_destination_exists",
84
- message: "Skipped moving prose file because destination already exists.",
85
- from_path: fromPath,
86
- to_path: toPath,
87
- },
88
- };
89
- }
90
-
91
- try {
92
- fs.renameSync(fromPath, toPath);
93
- } catch (error) {
94
- if (!error || typeof error !== "object" || error.code !== "EXDEV") {
95
- throw error;
96
- }
97
- // Cross-filesystem: copy then verify before unlink
98
- try {
99
- fs.copyFileSync(fromPath, toPath);
100
- // Verify copy succeeded before deleting source
101
- if (!fs.existsSync(toPath)) {
102
- return {
103
- moved: false,
104
- warning: {
105
- code: "relocate_copy_verification_failed",
106
- message: "Failed to verify file copy to destination; source file preserved.",
107
- from_path: fromPath,
108
- to_path: toPath,
109
- },
110
- };
111
- }
112
- try {
113
- fs.unlinkSync(fromPath);
114
- } catch (unlinkErr) {
115
- if (fs.existsSync(toPath)) {
116
- try {
117
- fs.unlinkSync(toPath);
118
- } catch {
119
- // Best effort; already in error state
120
- }
121
- }
122
- return {
123
- moved: false,
124
- warning: {
125
- code: "relocate_cross_filesystem_unlink_failed",
126
- message: `Copied prose file but failed to remove source file: ${unlinkErr.message}. Source file preserved.`,
127
- from_path: fromPath,
128
- to_path: toPath,
129
- },
130
- };
131
- }
132
- } catch (copyErr) {
133
- // Copy failed; ensure we don't leave partial destination files
134
- if (fs.existsSync(toPath)) {
135
- try {
136
- fs.unlinkSync(toPath);
137
- } catch {
138
- // Best effort; already in error state
139
- }
140
- }
141
- return {
142
- moved: false,
143
- warning: {
144
- code: "relocate_cross_filesystem_copy_failed",
145
- message: `Failed to copy prose file across filesystem: ${copyErr.message}. Source file preserved.`,
146
- from_path: fromPath,
147
- to_path: toPath,
148
- },
149
- };
150
- }
151
- }
152
-
153
- return { moved: true };
154
- }
155
-
156
- // Fields that are exclusively owned by the stable Scrivener sync-folder importer.
157
- // The beta merge must never write these fields, even additively, because the
158
- // importer derives them from the source filename and project structure in ways
159
- // the beta merge path cannot reliably replicate (e.g. scene_id slug, timeline
160
- // position from file sequence, external identity).
161
- export const IMPORTER_AUTHORITATIVE_FIELDS = Object.freeze([
162
- "scene_id",
163
- "external_source",
164
- "external_id",
165
- "title",
166
- "timeline_position",
167
- ]);
168
-
169
- const IMPORTER_AUTHORITATIVE_FIELD_SET = new Set(IMPORTER_AUTHORITATIVE_FIELDS);
170
-
171
- const KNOWN_CUSTOM_FIELD_IDS = new Set([
172
- "savethecat!",
173
- "causality",
174
- "stakes",
175
- "change",
176
- "f:character",
177
- "f:mood",
178
- "f:theme",
179
- ]);
180
-
181
- const MAX_RETURNED_WARNINGS = 25;
182
- const STRUCTURE_MAPPING_FIELDS = new Set(["part", "chapter", "chapter_title"]);
183
-
184
- function normalizeComparisonValue(key, value) {
185
- if ((key === "tags" || key === "scene_functions") && Array.isArray(value)) {
186
- return [...new Set(value.map(item => String(item)))].sort();
187
- }
188
- return value;
189
- }
190
-
191
- function valuesEquivalentForMerge(key, a, b) {
192
- return JSON.stringify(normalizeComparisonValue(key, a)) === JSON.stringify(normalizeComparisonValue(key, b));
193
- }
194
-
195
- function collectAmbiguityWarnings(existing, mergeData, { file, uuid }) {
196
- const out = [];
197
-
198
- const externalSource = existing?.external_source;
199
- if (externalSource !== undefined && externalSource !== null && externalSource !== "scrivener") {
200
- out.push({
201
- code: "ambiguous_identity_tie",
202
- message: "Existing sidecar identity source conflicts with Scrivener mapping; keeping existing sidecar identity.",
203
- reason: "external_source_conflict",
204
- external_source: String(externalSource),
205
- file,
206
- uuid,
207
- });
208
- }
209
- if (externalSource === "scrivener" && (existing?.external_id === undefined || existing?.external_id === null || String(existing.external_id).trim() === "")) {
210
- out.push({
211
- code: "ambiguous_identity_tie",
212
- message: "Existing sidecar identity is marked as Scrivener but missing external_id; keeping existing sidecar identity.",
213
- reason: "missing_external_id",
214
- file,
215
- uuid,
216
- });
217
- }
218
-
219
- for (const [key, scrivenerValue] of Object.entries(mergeData)) {
220
- if (!Object.hasOwn(existing, key)) continue;
221
- if (valuesEquivalentForMerge(key, existing[key], scrivenerValue)) continue;
222
-
223
- if (STRUCTURE_MAPPING_FIELDS.has(key)) {
224
- out.push({
225
- code: "ambiguous_structure_mapping",
226
- message: `Existing sidecar field '${key}' conflicts with Scrivener-derived structure; keeping existing value.`,
227
- field: key,
228
- existing_value: existing[key],
229
- scrivener_value: scrivenerValue,
230
- file,
231
- uuid,
232
- });
233
- continue;
234
- }
235
-
236
- out.push({
237
- code: "ambiguous_metadata_mapping",
238
- message: `Existing sidecar field '${key}' conflicts with Scrivener metadata; keeping existing value.`,
239
- field: key,
240
- existing_value: existing[key],
241
- scrivener_value: scrivenerValue,
242
- file,
243
- uuid,
244
- });
245
- }
246
-
247
- return out;
248
- }
249
-
250
- function recordWarning(summary, warning) {
251
- if (!summary[warning.code]) {
252
- summary[warning.code] = { count: 0, examples: [] };
253
- }
254
-
255
- const entry = summary[warning.code];
256
- entry.count++;
257
-
258
- if (entry.examples.length < 5) {
259
- const example = { message: warning.message };
260
- for (const key of [
261
- "file",
262
- "sync_number",
263
- "field",
264
- "field_id",
265
- "value",
266
- "reason",
267
- "external_source",
268
- "existing_value",
269
- "scrivener_value",
270
- "uuid",
271
- "from_path",
272
- "to_path",
273
- "moved_to",
274
- ]) {
275
- if (warning[key] !== undefined && warning[key] !== null) {
276
- example[key] = warning[key];
277
- }
278
- }
279
- entry.examples.push(example);
280
- }
281
- }
282
-
283
- function pushWarning(warnings, warningSummary, warning) {
284
- recordWarning(warningSummary, warning);
285
-
286
- if (warnings.length < MAX_RETURNED_WARNINGS) {
287
- warnings.push(warning);
288
- return false;
289
- }
290
-
291
- return true;
292
- }
293
-
294
- function buildMergeDataFromProject(projectData, uuid) {
295
- const { metaByUUID, partByUUID, chapterByUUID, chapterTitleByUUID } = projectData;
296
- const { customFields, tags, synopsis } = metaByUUID[uuid] ?? {};
297
- const part = partByUUID[uuid] ?? null;
298
- const chapter = chapterByUUID[uuid] ?? null;
299
- const chapterTitle = chapterTitleByUUID[uuid] ?? null;
300
- const warnings = [];
301
-
302
- if (!customFields && !tags && !synopsis && part === null && chapter === null && !chapterTitle) {
303
- return { mergeData: null, warnings };
304
- }
305
-
306
- const out = {};
307
-
308
- if (part !== null) out.part = part;
309
- if (chapter !== null) out.chapter = chapter;
310
- if (chapterTitle) out.chapter_title = chapterTitle;
311
- if (synopsis) out.synopsis = synopsis;
312
- if (tags?.length) out.tags = tags;
313
-
314
- for (const [fieldId, value] of Object.entries(customFields ?? {})) {
315
- if (!KNOWN_CUSTOM_FIELD_IDS.has(fieldId) && String(value ?? "").trim()) {
316
- warnings.push({
317
- code: "ignored_custom_field",
318
- message: `Ignored unsupported Scrivener custom field '${fieldId}'.`,
319
- field_id: fieldId,
320
- value: String(value),
321
- uuid,
322
- });
323
- }
324
- }
325
-
326
- const stcBeat = customFields?.["savethecat!"];
327
- if (stcBeat && typeof stcBeat === "string" && stcBeat.trim()) {
328
- out.save_the_cat_beat = stcBeat.trim();
329
- }
330
-
331
- const causalityRaw = customFields?.["causality"];
332
- const stakesRaw = customFields?.["stakes"];
333
- const causality = Number(causalityRaw ?? 0);
334
- const stakes = Number(stakesRaw ?? 0);
335
- if (causalityRaw !== undefined && String(causalityRaw).trim() && Number.isNaN(causality)) {
336
- warnings.push({
337
- code: "invalid_custom_field_value",
338
- message: "Ignored non-numeric Scrivener custom field value for 'causality'.",
339
- field_id: "causality",
340
- value: String(causalityRaw),
341
- uuid,
342
- });
343
- }
344
- if (stakesRaw !== undefined && String(stakesRaw).trim() && Number.isNaN(stakes)) {
345
- warnings.push({
346
- code: "invalid_custom_field_value",
347
- message: "Ignored non-numeric Scrivener custom field value for 'stakes'.",
348
- field_id: "stakes",
349
- value: String(stakesRaw),
350
- uuid,
351
- });
352
- }
353
- if (causality) out.causality = causality;
354
- if (stakes) out.stakes = stakes;
355
-
356
- const change = customFields?.["change"];
357
- if (change && String(change).trim()) out.scene_change = String(change).trim();
358
-
359
- const fnFlags = [];
360
- if (customFields?.["f:character"] === "Yes" || customFields?.["f:character"] === true) fnFlags.push("character");
361
- if (customFields?.["f:mood"] === "Yes" || customFields?.["f:mood"] === true) fnFlags.push("mood");
362
- if (customFields?.["f:theme"] === "Yes" || customFields?.["f:theme"] === true) fnFlags.push("theme");
363
- if (fnFlags.length) out.scene_functions = fnFlags;
364
-
365
- return {
366
- mergeData: Object.keys(out).length ? out : null,
367
- warnings,
368
- };
369
- }
370
-
371
- export function mergeSidecarData(existing, mergeData) {
372
- const merged = { ...existing };
373
- const newKeys = [];
374
- const blockedKeys = [];
375
-
376
- for (const [key, value] of Object.entries(mergeData)) {
377
- if (IMPORTER_AUTHORITATIVE_FIELD_SET.has(key)) {
378
- blockedKeys.push(key);
379
- continue;
380
- }
381
- if (!(key in merged)) {
382
- merged[key] = value;
383
- newKeys.push(key);
384
- }
385
- }
386
-
387
- return {
388
- merged,
389
- changed: newKeys.length > 0,
390
- newKeys,
391
- blockedKeys,
392
- };
393
- }
394
-
395
- export function loadScrivenerProjectData(scrivPath, options = {}) {
396
- const { onWarning } = options;
397
- const scrivPathAbs = path.resolve(scrivPath);
398
- if (!fs.existsSync(scrivPathAbs)) {
399
- throw new Error(`Scrivener bundle not found: ${scrivPathAbs}`);
400
- }
401
- if (!fs.statSync(scrivPathAbs).isDirectory()) {
402
- throw new Error(`Scrivener bundle must be a directory: ${scrivPathAbs}`);
403
- }
404
-
405
- const scrivxFiles = fs.readdirSync(scrivPathAbs).filter(f => f.endsWith(".scrivx"));
406
- const scrivxFilesSorted = scrivxFiles.sort((a, b) => a.localeCompare(b));
407
- if (!scrivxFilesSorted.length) {
408
- throw new Error(`No .scrivx file found in ${scrivPathAbs}`);
409
- }
410
-
411
- const bundleName = path.parse(scrivPathAbs).name;
412
- const preferredScrivx =
413
- scrivxFilesSorted.find(f => path.parse(f).name.toLowerCase() === bundleName.toLowerCase())
414
- ?? scrivxFilesSorted[0];
415
- const scrivxPath = path.join(scrivPathAbs, preferredScrivx);
416
- const dataDir = path.join(scrivPathAbs, "Files", "Data");
417
-
418
- // XML size guardrail: surface warning if .scrivx is very large.
419
- const scrivxStat = fs.statSync(scrivxPath);
420
- const MAX_SCRIVX_SIZE = 50 * 1024 * 1024; // 50MB
421
- if (scrivxStat.size > MAX_SCRIVX_SIZE) {
422
- onWarning?.({
423
- code: "large_scrivx_file",
424
- message: `Scrivener project .scrivx file is unusually large (${Math.round(scrivxStat.size / 1024 / 1024)}MB). This is an advisory warning; parsing will continue but may take longer and use significantly more memory because XML is parsed in-memory.`,
425
- scrivx_path: scrivxPath,
426
- size_bytes: scrivxStat.size,
427
- threshold_bytes: MAX_SCRIVX_SIZE,
428
- });
429
- }
430
-
431
- const xml = fs.readFileSync(scrivxPath, "utf8");
432
- const dom = new DOMParser().parseFromString(xml, "text/xml");
433
-
434
- const syncNumToUUID = {};
435
- for (const el of dom.getElementsByTagName("SyncItem")) {
436
- const uuid = attr(el, "ID");
437
- const num = text(el);
438
- if (uuid && num) syncNumToUUID[num] = uuid;
439
- }
440
-
441
- const keywordMap = {};
442
- for (const el of dom.getElementsByTagName("Keyword")) {
443
- const id = attr(el, "ID");
444
- const title = children(el, "Title")[0];
445
- if (id && title) keywordMap[id] = text(title);
446
- }
447
-
448
- const metaByUUID = {};
449
- for (const item of dom.getElementsByTagName("BinderItem")) {
450
- const uuid = attr(item, "UUID");
451
- if (!uuid) continue;
452
-
453
- const customFields = {};
454
- const metaEl = children(item, "MetaData")[0];
455
- if (metaEl) {
456
- for (const mdItem of children(metaEl, "MetaDataItem")) {
457
- const fieldId = text(children(mdItem, "FieldID")[0]);
458
- const value = text(children(mdItem, "Value")[0]);
459
- if (fieldId !== null) customFields[fieldId] = value;
460
- }
461
- }
462
-
463
- const tags = [];
464
- const kwEl = children(item, "Keywords")[0];
465
- if (kwEl) {
466
- for (const kwId of children(kwEl, "KeywordID")) {
467
- const name = keywordMap[text(kwId)];
468
- if (!name) continue;
469
- tags.push(name);
470
- }
471
- }
472
-
473
- let synopsis = null;
474
- const synopsisFile = path.join(dataDir, uuid, "synopsis.txt");
475
- if (fs.existsSync(synopsisFile)) {
476
- const candidate = fs.readFileSync(synopsisFile, "utf8").trim();
477
- if (candidate) synopsis = candidate;
478
- }
479
-
480
- metaByUUID[uuid] = {
481
- customFields,
482
- tags: [...new Set(tags)],
483
- synopsis,
484
- };
485
- }
486
-
487
- const partByUUID = {};
488
- const chapterByUUID = {};
489
- const chapterTitleByUUID = {};
490
- let partNum = 0;
491
- let chapterNum = 0;
492
-
493
- function walkHierarchy(containerEl, currentPart, currentChapter) {
494
- for (const child of children(containerEl, "BinderItem")) {
495
- const uuid = attr(child, "UUID");
496
- const type = attr(child, "Type");
497
- const childrenEl = children(child, "Children")[0];
498
- const title = text(children(child, "Title")[0]);
499
-
500
- if (type === "Folder" && currentPart === null) {
501
- partNum++;
502
- if (childrenEl) walkHierarchy(childrenEl, { number: partNum, title }, null);
503
- } else if (type === "Folder") {
504
- chapterNum++;
505
- const nextChapter = { number: chapterNum, title };
506
- if (uuid) {
507
- if (currentPart?.number !== null && currentPart?.number !== undefined) {
508
- partByUUID[uuid] = currentPart.number;
509
- }
510
- chapterByUUID[uuid] = chapterNum;
511
- if (title) chapterTitleByUUID[uuid] = title;
512
- }
513
- if (childrenEl) walkHierarchy(childrenEl, currentPart, nextChapter);
514
- } else if (type === "Text") {
515
- if (uuid && currentChapter?.number !== null && currentChapter?.number !== undefined) {
516
- if (currentPart?.number !== null && currentPart?.number !== undefined) {
517
- partByUUID[uuid] = currentPart.number;
518
- }
519
- chapterByUUID[uuid] = currentChapter.number;
520
- if (currentChapter.title) chapterTitleByUUID[uuid] = currentChapter.title;
521
- }
522
- }
523
- }
524
- }
525
-
526
- const binderEl = dom.getElementsByTagName("Binder")[0];
527
- if (binderEl) {
528
- for (const el of children(binderEl, "BinderItem")) {
529
- if (attr(el, "Type") === "DraftFolder") {
530
- const draftChildrenEl = children(el, "Children")[0];
531
- if (draftChildrenEl) walkHierarchy(draftChildrenEl, null, null);
532
- break;
533
- }
534
- }
535
- }
536
-
537
- return {
538
- scrivPath: scrivPathAbs,
539
- syncNumToUUID,
540
- keywordMap,
541
- metaByUUID,
542
- partByUUID,
543
- chapterByUUID,
544
- chapterTitleByUUID,
545
- };
546
- }
547
-
548
- export function mergeScrivenerProjectMetadata({
549
- scrivPath,
550
- mcpSyncDir,
551
- projectId,
552
- scenesDir: scenesDirOverride,
553
- dryRun = false,
554
- organizeByChapters = false,
555
- logger = () => {},
556
- }) {
557
- const mcpSyncDirAbs = path.resolve(mcpSyncDir);
558
- const resolvedProjectId = projectId
559
- ?? path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
560
-
561
- const projectIdCheck = validateProjectId(resolvedProjectId);
562
- if (!projectIdCheck.ok) {
563
- throw new Error(`Invalid project_id '${resolvedProjectId}': ${projectIdCheck.reason}`);
564
- }
565
-
566
- function deriveProjectRoot(pid) {
567
- if (pid.includes("/")) {
568
- const [universeId, projectSlug] = pid.split("/");
569
- return path.join(mcpSyncDirAbs, "universes", universeId, projectSlug);
570
- }
571
- return path.join(mcpSyncDirAbs, "projects", pid);
572
- }
573
-
574
- const scenesDir = scenesDirOverride
575
- ?? path.join(deriveProjectRoot(resolvedProjectId), "scenes");
576
-
577
- let scenesDirStat;
578
- try {
579
- scenesDirStat = fs.statSync(scenesDir);
580
- } catch {
581
- throw new Error(`Scenes directory not found or not a directory: ${scenesDir}`);
582
- }
583
- if (!scenesDirStat.isDirectory()) {
584
- throw new Error(`Scenes directory not found or not a directory: ${scenesDir}`);
585
- }
586
-
587
- const warnings = [];
588
- const warningSummary = {};
589
- let warningsTruncated = false;
590
-
591
- const projectData = loadScrivenerProjectData(scrivPath, {
592
- onWarning: (warning) => {
593
- warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
594
- logger(` WARN ${warning.message}`);
595
- },
596
- });
597
- logger(`Sync map: ${Object.keys(projectData.syncNumToUUID).length} entries`);
598
- logger(`Keyword map: ${Object.keys(projectData.keywordMap).length} entries`);
599
- logger(`Binder items collected: ${Object.keys(projectData.metaByUUID).length}`);
600
- logger(`Part/chapter map: ${Object.keys(projectData.chapterByUUID).length} items assigned`);
601
-
602
- const sidecarFiles = walkYamls(scenesDir);
603
- logger(`\nScene sidecars to process: ${sidecarFiles.length}\n`);
604
-
605
- let updated = 0;
606
- let unchanged = 0;
607
- let noData = 0;
608
- let skippedNoBracketId = 0;
609
- let relocated = 0;
610
- const fieldAddCounts = {};
611
- const previewChanges = [];
612
- const canCreateSnapshots = !dryRun && isGitAvailable() && isGitRepository(mcpSyncDirAbs);
613
- for (const sidecarPath of sidecarFiles) {
614
- const filename = path.basename(sidecarPath);
615
- const prosePath = findProsePathForSidecar(sidecarPath);
616
- const match = filename.match(/\[(\d+)\]\.meta\.yaml$/);
617
- if (!match) {
618
- logger(` SKIP (no bracket ID) ${filename}`);
619
- skippedNoBracketId++;
620
- warningsTruncated = pushWarning(warnings, warningSummary, {
621
- code: "missing_bracket_id",
622
- message: "Skipped sidecar because filename does not include a Scrivener sync number in brackets.",
623
- file: filename,
624
- }) || warningsTruncated;
625
- continue;
626
- }
627
-
628
- const syncNum = match[1];
629
- const uuid = projectData.syncNumToUUID[syncNum];
630
- if (!uuid) {
631
- logger(` SKIP (no UUID for [${syncNum}]) ${filename}`);
632
- noData++;
633
- warningsTruncated = pushWarning(warnings, warningSummary, {
634
- code: "missing_uuid_mapping",
635
- message: `Skipped sidecar because Scrivener sync number [${syncNum}] has no UUID mapping in the project.`,
636
- file: filename,
637
- sync_number: syncNum,
638
- }) || warningsTruncated;
639
- continue;
640
- }
641
-
642
- const { mergeData, warnings: mergeWarnings } = buildMergeDataFromProject(projectData, uuid);
643
- for (const warning of mergeWarnings) {
644
- warningsTruncated = pushWarning(warnings, warningSummary, { ...warning, file: filename }) || warningsTruncated;
645
- }
646
-
647
- if (!mergeData) {
648
- unchanged++;
649
- continue;
650
- }
651
-
652
- const existingRaw = yaml.load(fs.readFileSync(sidecarPath, "utf8"));
653
- if (existingRaw !== null && existingRaw !== undefined && !isPlainObject(existingRaw)) {
654
- throw new Error(`Invalid sidecar YAML mapping at ${sidecarPath}`);
655
- }
656
- const existing = existingRaw ?? {};
657
- const ambiguityWarnings = collectAmbiguityWarnings(existing, mergeData, { file: filename, uuid });
658
- for (const warning of ambiguityWarnings) {
659
- warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
660
- }
661
-
662
- const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
663
- const effective = changed ? merged : existing;
664
- const targetDir = sceneContainerDir(
665
- scenesDir,
666
- effective.part ?? null,
667
- effective.chapter ?? null,
668
- effective.chapter_title ?? null,
669
- organizeByChapters,
670
- );
671
- const targetSidecarPath = organizeByChapters ? path.join(targetDir, filename) : sidecarPath;
672
- const targetProsePath = prosePath
673
- ? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
674
- : null;
675
- const desiredSidecarRelocation = organizeByChapters
676
- && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath);
677
-
678
- // Orphan detection: warn once if sidecar has no matching prose and relocation would be attempted.
679
- if (!prosePath && desiredSidecarRelocation) {
680
- warningsTruncated = pushWarning(warnings, warningSummary, {
681
- code: "sidecar_missing_prose",
682
- message: "Sidecar has no matching prose file (.md or .txt); relocation skipped to preserve consistency.",
683
- file: filename,
684
- uuid,
685
- }) || warningsTruncated;
686
- }
687
-
688
- const needsMove = desiredSidecarRelocation
689
- || (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
690
-
691
- if (!changed && !needsMove) {
692
- unchanged++;
693
- continue;
694
- }
695
-
696
- for (const key of newKeys) {
697
- fieldAddCounts[key] = (fieldAddCounts[key] ?? 0) + 1;
698
- }
699
-
700
- if (previewChanges.length < 25) {
701
- previewChanges.push({
702
- file: filename,
703
- added_keys: [...newKeys],
704
- ...(needsMove ? { moved_to: path.relative(scenesDir, targetSidecarPath) || filename } : {}),
705
- });
706
- }
707
-
708
- let didRelocate;
709
-
710
- if (dryRun) {
711
- logger(` DRY ${filename}`);
712
- for (const key of newKeys) {
713
- logger(` + ${key}: ${JSON.stringify(mergeData[key]).slice(0, 80)}`);
714
- }
715
- if (needsMove) {
716
- logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
717
- }
718
- const dryRunRelocateEligible = desiredSidecarRelocation
719
- && prosePath
720
- && (!fs.existsSync(targetSidecarPath))
721
- && (!targetProsePath || path.resolve(prosePath) === path.resolve(targetProsePath) || !fs.existsSync(targetProsePath));
722
- didRelocate = needsMove && dryRunRelocateEligible;
723
- } else {
724
- let proseMoveWarning = null;
725
- let shouldRelocateSidecar = desiredSidecarRelocation;
726
-
727
- if (
728
- shouldRelocateSidecar
729
- && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
730
- && fs.existsSync(targetSidecarPath)
731
- ) {
732
- shouldRelocateSidecar = false;
733
- warningsTruncated = pushWarning(
734
- warnings,
735
- warningSummary,
736
- {
737
- code: "relocate_sidecar_destination_exists",
738
- message: "Skipped relocating sidecar because destination already exists.",
739
- from_path: sidecarPath,
740
- to_path: targetSidecarPath,
741
- file: filename,
742
- }
743
- ) || warningsTruncated;
744
- }
745
-
746
- // Prevent relocation if sidecar is orphaned (no matching prose)
747
- if (shouldRelocateSidecar && !prosePath) {
748
- shouldRelocateSidecar = false;
749
- }
750
-
751
- if (shouldRelocateSidecar && prosePath && targetProsePath) {
752
- const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
753
- if (moveResult?.warning) {
754
- proseMoveWarning = moveResult.warning;
755
- shouldRelocateSidecar = false;
756
- warningsTruncated = pushWarning(
757
- warnings,
758
- warningSummary,
759
- {
760
- ...moveResult.warning,
761
- file: filename,
762
- }
763
- ) || warningsTruncated;
764
- }
765
- }
766
-
767
- const finalSidecarPath = shouldRelocateSidecar ? targetSidecarPath : sidecarPath;
768
- fs.mkdirSync(path.dirname(finalSidecarPath), { recursive: true });
769
- fs.writeFileSync(finalSidecarPath, yaml.dump(effective, { lineWidth: 120 }), "utf8");
770
- if (
771
- shouldRelocateSidecar
772
- && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
773
- && fs.existsSync(sidecarPath)
774
- ) {
775
- fs.unlinkSync(sidecarPath);
776
- }
777
-
778
- // Create git snapshot for audit trail (only on actual writes, not dry-run)
779
- if (canCreateSnapshots) {
780
- try {
781
- const sceneId = existing?.scene_id ?? `[${syncNum}]`;
782
- const commitMessage = `beta merge Scrivener project metadata [${uuid}]`;
783
- let snapshotPaths = finalSidecarPath;
784
- if (shouldRelocateSidecar) {
785
- snapshotPaths = [finalSidecarPath, sidecarPath];
786
- if (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath)) {
787
- snapshotPaths.push(prosePath, targetProsePath);
788
- }
789
- }
790
- createSnapshot(mcpSyncDirAbs, snapshotPaths, sceneId, commitMessage, { messagePrefix: null });
791
- } catch (err) {
792
- // Snapshot creation errors should not block the merge; log and continue
793
- logger(` WARN git snapshot creation failed for ${filename}: ${err.message}`);
794
- }
795
- }
796
-
797
- const changes = [];
798
- if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
799
- if (needsMove && shouldRelocateSidecar) {
800
- changes.push(`moved to ${path.relative(scenesDir, targetSidecarPath) || filename}`);
801
- }
802
- if (proseMoveWarning) {
803
- changes.push("sidecar kept in place (prose move skipped)");
804
- }
805
- logger(` OK ${filename}${changes.length ? ` [${changes.join("; ")}]` : ""}`);
806
- didRelocate = needsMove && shouldRelocateSidecar;
807
- }
808
- if (didRelocate) relocated++;
809
- updated++;
810
- }
811
-
812
- logger(`\n${"─".repeat(50)}`);
813
- logger(`Updated: ${updated} sidecars${dryRun ? " (dry run)" : ""}`);
814
- if (relocated) logger(`Relocated: ${relocated} scene file pair(s)`);
815
- logger(`Unchanged: ${unchanged} (already complete or no new data)`);
816
- if (skippedNoBracketId) logger(`Skipped: ${skippedNoBracketId} (no bracket ID in filename)`);
817
- if (noData) logger(`No data: ${noData} (no matching binder entry)`);
818
-
819
- return {
820
- scrivPath: projectData.scrivPath,
821
- mcpSyncDir: mcpSyncDirAbs,
822
- projectId: resolvedProjectId,
823
- scenesDir,
824
- dryRun: Boolean(dryRun),
825
- sidecarFiles: sidecarFiles.length,
826
- updated,
827
- relocated,
828
- unchanged,
829
- skippedNoBracketId,
830
- noData,
831
- fieldAddCounts,
832
- previewChanges,
833
- warnings,
834
- warningsTruncated,
835
- warningSummary,
836
- stats: {
837
- syncMapEntries: Object.keys(projectData.syncNumToUUID).length,
838
- keywordMapEntries: Object.keys(projectData.keywordMap).length,
839
- binderItems: Object.keys(projectData.metaByUUID).length,
840
- partChapterAssignments: Object.keys(projectData.chapterByUUID).length,
841
- },
842
- };
843
- }
1
+ export * from "./src/sync/scrivener-direct.js";