@hanna84/mcp-writing 1.11.8 → 1.13.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 +20 -0
- package/package.json +1 -1
- package/scrivener-direct.js +108 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,31 @@ 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.13.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.12.0...v1.13.0)
|
|
9
|
+
|
|
10
|
+
- feat(scrivener-direct): add ambiguity warning taxonomy for beta merge [`#69`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/69)
|
|
12
|
+
|
|
13
|
+
#### [v1.12.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.11.8...v1.12.0)
|
|
15
|
+
|
|
16
|
+
> 24 April 2026
|
|
17
|
+
|
|
18
|
+
- feat(scrivener-direct): ownership enforcement (PR-3a) [`#68`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/68)
|
|
20
|
+
- Release 1.12.0 [`e249edd`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/e249edd1df6b1f77253200296057fe1cb468d2d0)
|
|
22
|
+
|
|
7
23
|
#### [v1.11.8](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.11.7...v1.11.8)
|
|
9
25
|
|
|
26
|
+
> 24 April 2026
|
|
27
|
+
|
|
10
28
|
- chore(deps): remove unused fast-xml-parser and bump @xmldom/xmldom [`#67`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/67)
|
|
30
|
+
- Release 1.11.8 [`7329eb4`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/7329eb4107bb0ac1889d58446604381a47370133)
|
|
12
32
|
|
|
13
33
|
#### [v1.11.7](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.11.6...v1.11.7)
|
package/package.json
CHANGED
package/scrivener-direct.js
CHANGED
|
@@ -100,6 +100,21 @@ function moveFileIfNeeded(fromPath, toPath) {
|
|
|
100
100
|
return { moved: true };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Fields that are exclusively owned by the stable Scrivener sync-folder importer.
|
|
104
|
+
// The beta merge must never write these fields, even additively, because the
|
|
105
|
+
// importer derives them from the source filename and project structure in ways
|
|
106
|
+
// the beta merge path cannot reliably replicate (e.g. scene_id slug, timeline
|
|
107
|
+
// position from file sequence, external identity).
|
|
108
|
+
export const IMPORTER_AUTHORITATIVE_FIELDS = Object.freeze([
|
|
109
|
+
"scene_id",
|
|
110
|
+
"external_source",
|
|
111
|
+
"external_id",
|
|
112
|
+
"title",
|
|
113
|
+
"timeline_position",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const IMPORTER_AUTHORITATIVE_FIELD_SET = new Set(IMPORTER_AUTHORITATIVE_FIELDS);
|
|
117
|
+
|
|
103
118
|
const KNOWN_CUSTOM_FIELD_IDS = new Set([
|
|
104
119
|
"savethecat!",
|
|
105
120
|
"causality",
|
|
@@ -111,6 +126,73 @@ const KNOWN_CUSTOM_FIELD_IDS = new Set([
|
|
|
111
126
|
]);
|
|
112
127
|
|
|
113
128
|
const MAX_RETURNED_WARNINGS = 25;
|
|
129
|
+
const STRUCTURE_MAPPING_FIELDS = new Set(["part", "chapter", "chapter_title"]);
|
|
130
|
+
|
|
131
|
+
function normalizeComparisonValue(key, value) {
|
|
132
|
+
if ((key === "tags" || key === "scene_functions") && Array.isArray(value)) {
|
|
133
|
+
return [...new Set(value.map(item => String(item)))].sort();
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function valuesEquivalentForMerge(key, a, b) {
|
|
139
|
+
return JSON.stringify(normalizeComparisonValue(key, a)) === JSON.stringify(normalizeComparisonValue(key, b));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectAmbiguityWarnings(existing, mergeData, { file, uuid }) {
|
|
143
|
+
const out = [];
|
|
144
|
+
|
|
145
|
+
const externalSource = existing?.external_source;
|
|
146
|
+
if (externalSource !== undefined && externalSource !== null && externalSource !== "scrivener") {
|
|
147
|
+
out.push({
|
|
148
|
+
code: "ambiguous_identity_tie",
|
|
149
|
+
message: "Existing sidecar identity source conflicts with Scrivener mapping; keeping existing sidecar identity.",
|
|
150
|
+
reason: "external_source_conflict",
|
|
151
|
+
external_source: String(externalSource),
|
|
152
|
+
file,
|
|
153
|
+
uuid,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (externalSource === "scrivener" && (existing?.external_id === undefined || existing?.external_id === null || String(existing.external_id).trim() === "")) {
|
|
157
|
+
out.push({
|
|
158
|
+
code: "ambiguous_identity_tie",
|
|
159
|
+
message: "Existing sidecar identity is marked as Scrivener but missing external_id; keeping existing sidecar identity.",
|
|
160
|
+
reason: "missing_external_id",
|
|
161
|
+
file,
|
|
162
|
+
uuid,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const [key, scrivenerValue] of Object.entries(mergeData)) {
|
|
167
|
+
if (!Object.hasOwn(existing, key)) continue;
|
|
168
|
+
if (valuesEquivalentForMerge(key, existing[key], scrivenerValue)) continue;
|
|
169
|
+
|
|
170
|
+
if (STRUCTURE_MAPPING_FIELDS.has(key)) {
|
|
171
|
+
out.push({
|
|
172
|
+
code: "ambiguous_structure_mapping",
|
|
173
|
+
message: `Existing sidecar field '${key}' conflicts with Scrivener-derived structure; keeping existing value.`,
|
|
174
|
+
field: key,
|
|
175
|
+
existing_value: existing[key],
|
|
176
|
+
scrivener_value: scrivenerValue,
|
|
177
|
+
file,
|
|
178
|
+
uuid,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
out.push({
|
|
184
|
+
code: "ambiguous_metadata_mapping",
|
|
185
|
+
message: `Existing sidecar field '${key}' conflicts with Scrivener metadata; keeping existing value.`,
|
|
186
|
+
field: key,
|
|
187
|
+
existing_value: existing[key],
|
|
188
|
+
scrivener_value: scrivenerValue,
|
|
189
|
+
file,
|
|
190
|
+
uuid,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
114
196
|
|
|
115
197
|
function recordWarning(summary, warning) {
|
|
116
198
|
if (!summary[warning.code]) {
|
|
@@ -122,7 +204,21 @@ function recordWarning(summary, warning) {
|
|
|
122
204
|
|
|
123
205
|
if (entry.examples.length < 5) {
|
|
124
206
|
const example = { message: warning.message };
|
|
125
|
-
for (const key of [
|
|
207
|
+
for (const key of [
|
|
208
|
+
"file",
|
|
209
|
+
"sync_number",
|
|
210
|
+
"field",
|
|
211
|
+
"field_id",
|
|
212
|
+
"value",
|
|
213
|
+
"reason",
|
|
214
|
+
"external_source",
|
|
215
|
+
"existing_value",
|
|
216
|
+
"scrivener_value",
|
|
217
|
+
"uuid",
|
|
218
|
+
"from_path",
|
|
219
|
+
"to_path",
|
|
220
|
+
"moved_to",
|
|
221
|
+
]) {
|
|
126
222
|
if (warning[key] !== undefined && warning[key] !== null) {
|
|
127
223
|
example[key] = warning[key];
|
|
128
224
|
}
|
|
@@ -222,8 +318,13 @@ function buildMergeDataFromProject(projectData, uuid) {
|
|
|
222
318
|
export function mergeSidecarData(existing, mergeData) {
|
|
223
319
|
const merged = { ...existing };
|
|
224
320
|
const newKeys = [];
|
|
321
|
+
const blockedKeys = [];
|
|
225
322
|
|
|
226
323
|
for (const [key, value] of Object.entries(mergeData)) {
|
|
324
|
+
if (IMPORTER_AUTHORITATIVE_FIELD_SET.has(key)) {
|
|
325
|
+
blockedKeys.push(key);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
227
328
|
if (!(key in merged)) {
|
|
228
329
|
merged[key] = value;
|
|
229
330
|
newKeys.push(key);
|
|
@@ -234,6 +335,7 @@ export function mergeSidecarData(existing, mergeData) {
|
|
|
234
335
|
merged,
|
|
235
336
|
changed: newKeys.length > 0,
|
|
236
337
|
newKeys,
|
|
338
|
+
blockedKeys,
|
|
237
339
|
};
|
|
238
340
|
}
|
|
239
341
|
|
|
@@ -479,6 +581,11 @@ export function mergeScrivenerProjectMetadata({
|
|
|
479
581
|
throw new Error(`Invalid sidecar YAML mapping at ${sidecarPath}`);
|
|
480
582
|
}
|
|
481
583
|
const existing = existingRaw ?? {};
|
|
584
|
+
const ambiguityWarnings = collectAmbiguityWarnings(existing, mergeData, { file: filename, uuid });
|
|
585
|
+
for (const warning of ambiguityWarnings) {
|
|
586
|
+
warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
|
|
587
|
+
}
|
|
588
|
+
|
|
482
589
|
const { merged, changed, newKeys } = mergeSidecarData(existing, mergeData);
|
|
483
590
|
const effective = changed ? merged : existing;
|
|
484
591
|
const targetDir = sceneContainerDir(
|