@hanna84/mcp-writing 3.21.2 → 3.21.3
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 +7 -0
- package/package.json +1 -1
- package/src/scripts/normalize-scene-characters.mjs +5 -5
- package/src/sync/scene-character-batch.js +4 -4
- package/src/sync/sync.js +29 -9
- package/src/tools/metadata.js +15 -13
- package/src/tools/reference-link-persistence.js +2 -2
- package/src/tools/sync.js +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,16 @@ 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
|
+
#### [v3.21.3](https://github.com/hannasdev/mcp-writing/compare/v3.21.2...v3.21.3)
|
|
8
|
+
|
|
9
|
+
- fix(metadata): preserve sidecar structure fields on generic writes [`#224`](https://github.com/hannasdev/mcp-writing/pull/224)
|
|
10
|
+
|
|
7
11
|
#### [v3.21.2](https://github.com/hannasdev/mcp-writing/compare/v3.21.1...v3.21.2)
|
|
8
12
|
|
|
13
|
+
> 28 May 2026
|
|
14
|
+
|
|
9
15
|
- docs: accept architecture snapshot milestone [`#223`](https://github.com/hannasdev/mcp-writing/pull/223)
|
|
16
|
+
- Release 3.21.2 [`7f0e627`](https://github.com/hannasdev/mcp-writing/commit/7f0e62741059ad487577e7295ea75564fd64d33a)
|
|
10
17
|
|
|
11
18
|
#### [v3.21.1](https://github.com/hannasdev/mcp-writing/compare/v3.21.0...v3.21.1)
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { openDb } from "../core/db.js";
|
|
4
4
|
import { buildCharacterNormalizationContext, normalizeSceneCharacters } from "../sync/scene-character-normalization.js";
|
|
5
|
-
import {
|
|
5
|
+
import { readMeta, readSourceMeta, syncAll, writeMeta } from "../sync/sync.js";
|
|
6
6
|
|
|
7
7
|
function readRequiredValue(argv, index, option) {
|
|
8
8
|
const value = argv[index + 1];
|
|
@@ -138,11 +138,11 @@ function runNormalization({ syncDir, projectId, write, limit }) {
|
|
|
138
138
|
if (!normalized.changed) continue;
|
|
139
139
|
|
|
140
140
|
if (write) {
|
|
141
|
-
const
|
|
142
|
-
|
|
141
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, syncDir, { writable: true });
|
|
142
|
+
writeMeta(scene.file_path, {
|
|
143
|
+
...sourceMeta,
|
|
143
144
|
characters: normalized.after,
|
|
144
|
-
})
|
|
145
|
-
writeMeta(scene.file_path, updatedMeta, { syncDir });
|
|
145
|
+
}, { syncDir });
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
changed.push({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import matter from "gray-matter";
|
|
3
3
|
import { buildCharacterNormalizationContext, escapeRegex, resolveCharacterReference } from "./scene-character-normalization.js";
|
|
4
|
-
import {
|
|
4
|
+
import { readSourceMeta, writeMeta } from "./sync.js";
|
|
5
5
|
|
|
6
6
|
function normalizeCharacterRows(rows) {
|
|
7
7
|
return buildCharacterNormalizationContext(rows);
|
|
@@ -138,7 +138,7 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
|
|
|
138
138
|
try {
|
|
139
139
|
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
140
140
|
const { content: prose } = matter(raw);
|
|
141
|
-
const { meta } =
|
|
141
|
+
const { sourceMeta: meta } = readSourceMeta(scene.file_path, syncDir, { writable: !dry_run });
|
|
142
142
|
|
|
143
143
|
const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
|
|
144
144
|
const normalized_before_characters = [...new Set(
|
|
@@ -169,10 +169,10 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
|
|
|
169
169
|
const changed = added.length > 0 || removed.length > 0;
|
|
170
170
|
|
|
171
171
|
if (!dry_run && changed) {
|
|
172
|
-
const updatedMeta =
|
|
172
|
+
const updatedMeta = {
|
|
173
173
|
...meta,
|
|
174
174
|
characters: after_characters,
|
|
175
|
-
}
|
|
175
|
+
};
|
|
176
176
|
|
|
177
177
|
writeMeta(scene.file_path, updatedMeta, { syncDir });
|
|
178
178
|
}
|
package/src/sync/sync.js
CHANGED
|
@@ -494,11 +494,12 @@ export function parseFile(filePath) {
|
|
|
494
494
|
}
|
|
495
495
|
|
|
496
496
|
/**
|
|
497
|
-
* Read metadata for a scene file. Priority: sidecar > frontmatter.
|
|
498
|
-
*
|
|
499
|
-
*
|
|
497
|
+
* Read source metadata for a scene file. Priority: sidecar > frontmatter.
|
|
498
|
+
* Does not apply path-derived structural normalization or auto-generate sidecars.
|
|
499
|
+
* Generic metadata writers use this so non-structural updates do not mirror
|
|
500
|
+
* path-derived structure fields into sidecars.
|
|
500
501
|
*/
|
|
501
|
-
export function
|
|
502
|
+
export function readSourceMeta(filePath, syncDir, { writable = false } = {}) {
|
|
502
503
|
const sidecar = sidecarPath(filePath);
|
|
503
504
|
const syncDirAbs = path.resolve(syncDir);
|
|
504
505
|
|
|
@@ -517,26 +518,45 @@ export function readMeta(filePath, syncDir, { writable = false } = {}) {
|
|
|
517
518
|
}
|
|
518
519
|
const raw = fs.readFileSync(sidecar, "utf8");
|
|
519
520
|
const parsed = parseYaml(raw) ?? {};
|
|
520
|
-
return {
|
|
521
|
+
return { meta: parsed, sourceMeta: parsed, source: "sidecar", sidecarGenerated: false };
|
|
521
522
|
}
|
|
522
523
|
|
|
523
524
|
// Fall back to frontmatter
|
|
524
525
|
const { data: frontmatter } = parseFile(filePath);
|
|
525
526
|
if (!Object.keys(frontmatter).length) {
|
|
526
|
-
return {
|
|
527
|
+
return { meta: {}, sourceMeta: {}, source: "empty", sidecarGenerated: false };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { meta: frontmatter, sourceMeta: frontmatter, source: "frontmatter", sidecarGenerated: false };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Read metadata for a scene file. Priority: sidecar > frontmatter.
|
|
535
|
+
* If sidecar doesn't exist but frontmatter does, auto-generates the sidecar.
|
|
536
|
+
* Returns { meta, sidecarGenerated }.
|
|
537
|
+
*/
|
|
538
|
+
export function readMeta(filePath, syncDir, { writable = false } = {}) {
|
|
539
|
+
const sourceRead = readSourceMeta(filePath, syncDir, { writable });
|
|
540
|
+
|
|
541
|
+
if (sourceRead.source === "sidecar" || sourceRead.source === "empty") {
|
|
542
|
+
return {
|
|
543
|
+
...normalizeSceneMetaForPath(syncDir, filePath, sourceRead.sourceMeta),
|
|
544
|
+
sourceMeta: sourceRead.sourceMeta,
|
|
545
|
+
sidecarGenerated: false,
|
|
546
|
+
};
|
|
527
547
|
}
|
|
528
548
|
|
|
529
|
-
const normalized = normalizeSceneMetaForPath(syncDir, filePath,
|
|
549
|
+
const normalized = normalizeSceneMetaForPath(syncDir, filePath, sourceRead.sourceMeta);
|
|
530
550
|
|
|
531
551
|
// Auto-migrate: write sidecar from frontmatter (only if writable)
|
|
532
552
|
if (writable) {
|
|
533
553
|
try {
|
|
534
554
|
writeMeta(filePath, normalized.meta, { syncDir });
|
|
535
|
-
return { ...normalized, sourceMeta:
|
|
555
|
+
return { ...normalized, sourceMeta: sourceRead.sourceMeta, sidecarGenerated: true };
|
|
536
556
|
} catch { /* empty */ }
|
|
537
557
|
}
|
|
538
558
|
|
|
539
|
-
return { ...normalized, sourceMeta:
|
|
559
|
+
return { ...normalized, sourceMeta: sourceRead.sourceMeta, sidecarGenerated: false };
|
|
540
560
|
}
|
|
541
561
|
|
|
542
562
|
/**
|
package/src/tools/metadata.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import matter from "gray-matter";
|
|
5
|
-
import { readMeta, writeMeta, indexSceneFile, isManagedStructureProject } from "../sync/sync.js";
|
|
5
|
+
import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
6
6
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
7
7
|
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
8
8
|
import {
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
refreshProjectBackupAfterMutation,
|
|
34
34
|
} from "../structure/project-backup-refresh.js";
|
|
35
35
|
|
|
36
|
-
const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "timeline_position"];
|
|
36
|
+
const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "chapter_title", "timeline_position"];
|
|
37
37
|
|
|
38
38
|
function emptyBackupMutationResult() {
|
|
39
39
|
return {
|
|
@@ -1641,7 +1641,7 @@ export function registerMetadataTools(s, {
|
|
|
1641
1641
|
// ---- update_scene_metadata -----------------------------------------------
|
|
1642
1642
|
s.tool(
|
|
1643
1643
|
"update_scene_metadata",
|
|
1644
|
-
"Update one or more non-structural metadata fields for a scene. Writes to the .meta.yaml sidecar
|
|
1644
|
+
"Update one or more non-structural metadata fields for a scene. Writes only supplied non-structural fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose or mirrors path-derived structure. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
|
|
1645
1645
|
{
|
|
1646
1646
|
scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
|
|
1647
1647
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
@@ -1654,6 +1654,7 @@ export function registerMetadataTools(s, {
|
|
|
1654
1654
|
part: z.number().int().optional().describe("Rejected by update_scene_metadata. Structural placement must use explicit structure workflows."),
|
|
1655
1655
|
chapter: z.number().int().optional().describe("Rejected by update_scene_metadata. Use assign_scene_to_chapter or move_scene with canonical chapter_id."),
|
|
1656
1656
|
chapter_id: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use list_chapters, then assign_scene_to_chapter or move_scene."),
|
|
1657
|
+
chapter_title: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use rename_chapter for canonical chapter title changes."),
|
|
1657
1658
|
timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
|
|
1658
1659
|
story_time: z.string().optional(),
|
|
1659
1660
|
tags: z.array(z.string()).optional(),
|
|
@@ -1674,22 +1675,23 @@ export function registerMetadataTools(s, {
|
|
|
1674
1675
|
if (structuralFields.length > 0) {
|
|
1675
1676
|
return errorResponse(
|
|
1676
1677
|
"VALIDATION_ERROR",
|
|
1677
|
-
"update_scene_metadata cannot change structural fields. Use assign_scene_to_chapter
|
|
1678
|
+
"update_scene_metadata cannot change structural fields. Use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structural changes.",
|
|
1678
1679
|
{
|
|
1679
1680
|
project_id,
|
|
1680
1681
|
scene_id,
|
|
1681
1682
|
blocked_fields: structuralFields,
|
|
1682
|
-
allowed_structure_tools: ["assign_scene_to_chapter", "move_scene"],
|
|
1683
|
+
allowed_structure_tools: ["list_chapters", "assign_scene_to_chapter", "move_scene", "rename_chapter", "reorder_chapter"],
|
|
1683
1684
|
}
|
|
1684
1685
|
);
|
|
1685
1686
|
}
|
|
1686
1687
|
try {
|
|
1687
|
-
const {
|
|
1688
|
-
const updated = { ...
|
|
1688
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
1689
|
+
const updated = { ...sourceMeta, ...fields };
|
|
1689
1690
|
writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
|
|
1691
|
+
const normalizedUpdated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, updated).meta;
|
|
1690
1692
|
|
|
1691
1693
|
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|
|
1692
|
-
indexSceneFile(db, SYNC_DIR, scene.file_path,
|
|
1694
|
+
indexSceneFile(db, SYNC_DIR, scene.file_path, normalizedUpdated, prose, {
|
|
1693
1695
|
managedStructure: isManagedStructureProject(db, project_id),
|
|
1694
1696
|
});
|
|
1695
1697
|
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
@@ -1707,7 +1709,7 @@ export function registerMetadataTools(s, {
|
|
|
1707
1709
|
scene_id,
|
|
1708
1710
|
project_id,
|
|
1709
1711
|
fields: Object.keys(fields).sort().reduce((acc, key) => {
|
|
1710
|
-
acc[key] =
|
|
1712
|
+
acc[key] = sourceMeta[key] ?? null;
|
|
1711
1713
|
return acc;
|
|
1712
1714
|
}, {}),
|
|
1713
1715
|
},
|
|
@@ -1717,7 +1719,7 @@ export function registerMetadataTools(s, {
|
|
|
1717
1719
|
scene_id,
|
|
1718
1720
|
project_id,
|
|
1719
1721
|
fields: Object.keys(fields).sort().reduce((acc, key) => {
|
|
1720
|
-
acc[key] =
|
|
1722
|
+
acc[key] = normalizedUpdated[key] ?? null;
|
|
1721
1723
|
return acc;
|
|
1722
1724
|
}, {}),
|
|
1723
1725
|
},
|
|
@@ -1928,10 +1930,10 @@ export function registerMetadataTools(s, {
|
|
|
1928
1930
|
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
1929
1931
|
}
|
|
1930
1932
|
try {
|
|
1931
|
-
const {
|
|
1932
|
-
const flags =
|
|
1933
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
1934
|
+
const flags = sourceMeta.flags ?? [];
|
|
1933
1935
|
flags.push({ note, flagged_at: new Date().toISOString() });
|
|
1934
|
-
writeMeta(scene.file_path, { ...
|
|
1936
|
+
writeMeta(scene.file_path, { ...sourceMeta, flags }, { syncDir: SYNC_DIR });
|
|
1935
1937
|
return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
|
|
1936
1938
|
} catch (err) {
|
|
1937
1939
|
if (err?.name === "CoreValidationError") {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readSourceMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
|
|
2
2
|
|
|
3
3
|
let savepointCounter = 0;
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ export function upsertSerializedReferenceLinks(existing, targetDocId, relation,
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
|
|
16
|
-
const { meta } =
|
|
16
|
+
const { sourceMeta: meta } = readSourceMeta(scenePath, syncDir, { writable: true });
|
|
17
17
|
const existingExplicit = [
|
|
18
18
|
...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
|
|
19
19
|
...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
|
package/src/tools/sync.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import matter from "gray-matter";
|
|
5
|
-
import { syncAll, writeMeta,
|
|
5
|
+
import { syncAll, writeMeta, readSourceMeta, indexSceneFile, normalizeSceneMetaForPath, isManagedStructureProject } from "../sync/sync.js";
|
|
6
6
|
import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
|
|
7
7
|
import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
|
|
8
8
|
import {
|
|
@@ -951,20 +951,21 @@ export function registerSyncTools(s, {
|
|
|
951
951
|
try {
|
|
952
952
|
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
953
953
|
const { content: prose } = matter(raw);
|
|
954
|
-
const { meta } =
|
|
954
|
+
const { sourceMeta: meta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
955
955
|
|
|
956
956
|
const inferredLogline = deriveLoglineFromProse(prose);
|
|
957
957
|
const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
|
|
958
958
|
|
|
959
|
-
const
|
|
959
|
+
const sourceUpdatedMeta = {
|
|
960
960
|
...meta,
|
|
961
961
|
...(inferredLogline ? { logline: inferredLogline } : {}),
|
|
962
962
|
...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
|
|
963
963
|
? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
|
|
964
964
|
: {}),
|
|
965
|
-
}
|
|
965
|
+
};
|
|
966
|
+
const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, sourceUpdatedMeta).meta;
|
|
966
967
|
|
|
967
|
-
writeMeta(scene.file_path,
|
|
968
|
+
writeMeta(scene.file_path, sourceUpdatedMeta, { syncDir: SYNC_DIR });
|
|
968
969
|
indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose, {
|
|
969
970
|
managedStructure: isManagedStructureProject(db, scene.project_id),
|
|
970
971
|
});
|