drupal-mcp-connector 2.14.1 → 2.15.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/.agents/commands/drupal-create-translation.md +9 -6
- package/.agents/commands/drupal-entity-update.md +3 -2
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-paragraph.md +5 -3
- package/.agents/commands/drupal-get-revision.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- package/.agents/commands/drupal-update-node.md +6 -5
- package/.agents/commands/drupal-update-paragraph.md +6 -3
- package/CHANGELOG.md +73 -0
- package/README.md +5 -3
- package/package.json +1 -1
- package/scripts/generate-commands.js +63 -4
- package/scripts/install-commands.js +33 -10
- package/src/lib/backends/backend-interface.js +5 -3
- package/src/lib/backends/jsonapi.js +79 -9
- package/src/lib/draft-write.js +391 -18
- package/src/lib/entity-response.js +25 -0
- package/src/lib/patch-preflight.js +133 -8
- package/src/lib/path-alias.js +37 -0
- package/src/tools/entities.js +5 -2
- package/src/tools/nodes.js +128 -25
- package/src/tools/paragraphs.js +51 -11
- package/src/tools/revisions.js +35 -3
- package/src/tools/translations.js +156 -82
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
normalizeRelationship,
|
|
18
18
|
BASE_ATTRIBUTE_FIELDS,
|
|
19
19
|
} from "../canonical.js";
|
|
20
|
+
import { isPositiveNid, normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../path-alias.js";
|
|
20
21
|
|
|
21
22
|
// Drupal exposes internal identifiers under drupal_internal__* attributes.
|
|
22
23
|
// They are dropped from canonical `fields` except for the identifiers that
|
|
@@ -310,19 +311,88 @@ export class JsonApiBackend extends Backend {
|
|
|
310
311
|
* (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
|
|
311
312
|
* — so this method exposes it. Returns nulls for entities/backends without a
|
|
312
313
|
* path field. See the 1.5.1 alias fix.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
314
|
+
*
|
|
315
|
+
* Unpublished default / forward revisions often omit `pid` on the computed
|
|
316
|
+
* `path` field even when a `path_alias` row exists. When the node numeric id
|
|
317
|
+
* is known, this method also looks up that row (aliases are not revisioned)
|
|
318
|
+
* so title-only edits can pin the live alias (#274).
|
|
319
|
+
* @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
|
|
320
|
+
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
|
|
315
321
|
*/
|
|
316
|
-
async getPathInfo({ entityType, bundle, id }) {
|
|
322
|
+
async getPathInfo({ entityType, bundle, id, resourceVersion }) {
|
|
317
323
|
validateUuid(id);
|
|
318
|
-
|
|
324
|
+
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
325
|
+
if (resourceVersion) {
|
|
326
|
+
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
327
|
+
}
|
|
328
|
+
const data = await drupalFetch(this.site, path);
|
|
319
329
|
const attrs = data?.data?.attributes ?? {};
|
|
320
|
-
const
|
|
330
|
+
const nodePath = attrs.path ?? null;
|
|
331
|
+
const drupalId = attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null;
|
|
332
|
+
const langcode = nodePath?.langcode ?? attrs.langcode ?? null;
|
|
333
|
+
let alias = nodePath?.alias ?? null;
|
|
334
|
+
let pid = nodePath?.pid ?? null;
|
|
335
|
+
let aliasId = null;
|
|
336
|
+
|
|
337
|
+
if (entityType === "node" && isPositiveNid(drupalId)) {
|
|
338
|
+
const row = await this.lookupPathAliasRow(`/node/${Number(drupalId)}`, {
|
|
339
|
+
langcode,
|
|
340
|
+
preferredAlias: alias,
|
|
341
|
+
});
|
|
342
|
+
if (row) {
|
|
343
|
+
aliasId = row.id;
|
|
344
|
+
if (pid === undefined || pid === null) pid = row.pid;
|
|
345
|
+
// Pathauto / unpublished computed fields can omit alias; the row is
|
|
346
|
+
// the router-visible value.
|
|
347
|
+
if (!alias) alias = row.alias;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { alias, pid, langcode, drupalId, aliasId };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Load the path_alias row for a node source path. Best-effort: missing
|
|
356
|
+
* JSON:API exposure or an empty collection returns null.
|
|
357
|
+
* @param {string} sourcePath Drupal system path, e.g. `/node/44`.
|
|
358
|
+
* @param {{langcode?: ?string, preferredAlias?: ?string}} [opts]
|
|
359
|
+
* @returns {Promise<?{id: string, alias: ?string, pid: ?(number|string), langcode: ?string}>}
|
|
360
|
+
*/
|
|
361
|
+
async lookupPathAliasRow(sourcePath, opts = {}) {
|
|
362
|
+
if (!/^\/node\/[1-9]\d*$/.test(sourcePath)) return null;
|
|
363
|
+
const params = new URLSearchParams();
|
|
364
|
+
params.set("filter[path]", sourcePath);
|
|
365
|
+
if (opts.langcode) params.set("filter[langcode]", String(opts.langcode));
|
|
366
|
+
let data;
|
|
367
|
+
try {
|
|
368
|
+
data = await drupalFetch(
|
|
369
|
+
this.site,
|
|
370
|
+
`${this.resourcePath(PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE)}?${params}`,
|
|
371
|
+
);
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const rows = Array.isArray(data?.data) ? data.data : [];
|
|
376
|
+
if (!rows.length) return null;
|
|
377
|
+
const preferred = normalizeAlias(opts.preferredAlias);
|
|
378
|
+
let picked = rows[0];
|
|
379
|
+
if (preferred) {
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
const attrs = row && typeof row === "object" ? row.attributes : null;
|
|
382
|
+
const rowAlias = attrs && typeof attrs === "object" ? attrs.alias : null;
|
|
383
|
+
if (normalizeAlias(rowAlias) === preferred) {
|
|
384
|
+
picked = row;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!picked?.id) return null;
|
|
390
|
+
const a = picked.attributes && typeof picked.attributes === "object" ? picked.attributes : {};
|
|
321
391
|
return {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
392
|
+
id: picked.id,
|
|
393
|
+
alias: a.alias ?? null,
|
|
394
|
+
pid: a.drupal_internal__id ?? a.pid ?? null,
|
|
395
|
+
langcode: a.langcode ?? null,
|
|
326
396
|
};
|
|
327
397
|
}
|
|
328
398
|
|
package/src/lib/draft-write.js
CHANGED
|
@@ -1,8 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sentinel's governed draft-continuation contract (d.o #3621022).
|
|
2
|
+
* Sentinel's governed draft-continuation contract (d.o #3621022 / GitHub #176).
|
|
3
3
|
* Core JSON:API revision selectors support reads, not PATCH requests.
|
|
4
|
+
* Translation create/update uses the same surface with X-MCP-Draft-Langcode.
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
8
|
+
|
|
9
|
+
const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
|
|
10
|
+
const MISSING_DRAFT_ENDPOINT =
|
|
11
|
+
"The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
|
|
12
|
+
"Update the server-side module; the draft was not discarded and no canonical fallback was attempted.";
|
|
13
|
+
const MISSING_TRANSLATION_ENDPOINT =
|
|
14
|
+
"The site does not provide Sentinel's governed draft-translation endpoint. " +
|
|
15
|
+
"Update MCP Sentinel; no canonical langcode PATCH was attempted.";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} error
|
|
19
|
+
* @param {string} message
|
|
20
|
+
* @returns {Error}
|
|
21
|
+
*/
|
|
22
|
+
function missingEndpointError(error, message) {
|
|
23
|
+
if (/Drupal (404|405)\b/.test(String(error?.message))) {
|
|
24
|
+
return new Error(message, { cause: error });
|
|
25
|
+
}
|
|
26
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} langcode
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function assertDraftLangcode(langcode) {
|
|
34
|
+
const value = String(langcode || "").trim();
|
|
35
|
+
if (!LANGCODE_RE.test(value)) {
|
|
36
|
+
throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} [draftRevision]
|
|
43
|
+
* @returns {{live: string, working: string}}
|
|
44
|
+
*/
|
|
45
|
+
function requireWorkingPair(draftRevision) {
|
|
46
|
+
const live = String(draftRevision?.liveVid ?? "");
|
|
47
|
+
const working = String(draftRevision?.workingVid ?? "");
|
|
48
|
+
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
49
|
+
throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
|
|
50
|
+
}
|
|
51
|
+
return { live, working };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} backend
|
|
56
|
+
* @param {string} entityType
|
|
57
|
+
* @param {string} bundle
|
|
58
|
+
* @param {string} id
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function draftResource(backend, entityType, bundle, id) {
|
|
62
|
+
if (entityType !== "node" && entityType !== "paragraph") {
|
|
63
|
+
throw new Error("Governed draft translation is implemented for nodes and paragraphs.");
|
|
64
|
+
}
|
|
65
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
66
|
+
throw new Error("This backend does not support governed draft continuation.");
|
|
67
|
+
}
|
|
68
|
+
return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} [draftRevision]
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function requireParagraphRevisionId(draftRevision) {
|
|
76
|
+
const revisionId = String(draftRevision?.revisionId ?? draftRevision?.workingVid ?? "");
|
|
77
|
+
if (!/^[1-9]\d*$/.test(revisionId)) {
|
|
78
|
+
throw new Error("Paragraph translation requires a verified paragraph revision ID.");
|
|
79
|
+
}
|
|
80
|
+
return revisionId;
|
|
81
|
+
}
|
|
82
|
+
|
|
6
83
|
/**
|
|
7
84
|
* Validate or continue a draft, using the same payload and revision precondition.
|
|
8
85
|
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
@@ -12,46 +89,342 @@
|
|
|
12
89
|
* @returns {Promise<object>} Preflight metadata or the written canonical entity.
|
|
13
90
|
*/
|
|
14
91
|
export async function writeDraft(backend, input, preflight = false) {
|
|
92
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
93
|
+
if (entityType === "paragraph") {
|
|
94
|
+
return writeParagraphDraft(backend, input, preflight);
|
|
95
|
+
}
|
|
96
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
97
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
98
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
99
|
+
if (relationships) data.relationships = relationships;
|
|
100
|
+
const headers = {
|
|
101
|
+
"If-Match": `"${live}:${working}"`,
|
|
102
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
103
|
+
};
|
|
104
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
105
|
+
if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await backend.rawQuery({
|
|
109
|
+
path: `${base}/mcp-draft`,
|
|
110
|
+
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
|
|
114
|
+
}
|
|
115
|
+
if (preflight) {
|
|
116
|
+
if (result?.meta?.draft_preflight !== true
|
|
117
|
+
|| String(result.meta.live) !== live || String(result.meta.working) !== working) {
|
|
118
|
+
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
119
|
+
}
|
|
120
|
+
if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
|
|
121
|
+
throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
126
|
+
throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
127
|
+
}
|
|
128
|
+
return backend.toCanonical(result.data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create a target-language translation as an unpublished forward revision.
|
|
133
|
+
* If-Match is `"live"` when there is no working copy, or `"live:working"` when
|
|
134
|
+
* adding the language onto an existing unpublished English draft.
|
|
135
|
+
* @param {object} backend
|
|
136
|
+
* @param {object} input
|
|
137
|
+
* @param {boolean} [preflight]
|
|
138
|
+
* @returns {Promise<object>}
|
|
139
|
+
*/
|
|
140
|
+
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
15
141
|
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
142
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
143
|
+
if (entityType === "paragraph") {
|
|
144
|
+
return createParagraphTranslationDraft(backend, {
|
|
145
|
+
entityType, bundle, id, attributes, relationships, draftRevision, langcode,
|
|
146
|
+
}, preflight);
|
|
147
|
+
}
|
|
16
148
|
const live = String(draftRevision?.liveVid ?? "");
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
149
|
+
const workingRaw = draftRevision?.workingVid;
|
|
150
|
+
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
151
|
+
? ""
|
|
152
|
+
: String(workingRaw);
|
|
153
|
+
if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
+
throw new Error("Translation create requires a verified live node revision ID.");
|
|
21
155
|
}
|
|
22
|
-
if (
|
|
23
|
-
throw new Error("
|
|
156
|
+
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
157
|
+
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
24
158
|
}
|
|
25
|
-
const
|
|
159
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
160
|
+
const safeAttributes = { ...attributes };
|
|
161
|
+
delete safeAttributes.langcode;
|
|
162
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
26
163
|
if (relationships) data.relationships = relationships;
|
|
164
|
+
const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
|
|
27
165
|
let result;
|
|
28
166
|
try {
|
|
29
167
|
result = await backend.rawQuery({
|
|
30
|
-
path: `${
|
|
168
|
+
path: `${base}/mcp-draft/translations`,
|
|
31
169
|
options: {
|
|
32
|
-
method: "
|
|
170
|
+
method: "POST",
|
|
33
171
|
headers: {
|
|
34
|
-
"If-Match":
|
|
172
|
+
"If-Match": ifMatch,
|
|
35
173
|
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
174
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
36
175
|
},
|
|
37
176
|
body: JSON.stringify({ data }),
|
|
38
177
|
},
|
|
39
178
|
});
|
|
40
179
|
} catch (error) {
|
|
41
|
-
|
|
42
|
-
|
|
180
|
+
throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
|
|
181
|
+
}
|
|
182
|
+
if (preflight) {
|
|
183
|
+
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
184
|
+
throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
189
|
+
throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
190
|
+
}
|
|
191
|
+
return backend.toCanonical(result.data);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Live + working revision ids for a node translation create.
|
|
196
|
+
* Prefers Sentinel's mcp-translations inventory over JSON:API
|
|
197
|
+
* `rel:working-copy`, which 403s on some unpublished drafts even when a
|
|
198
|
+
* working revision exists (#282).
|
|
199
|
+
* @param {object} backend
|
|
200
|
+
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
201
|
+
* @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
|
|
202
|
+
*/
|
|
203
|
+
export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
|
|
204
|
+
const fromEntity = existing ? entityRevisionId(existing) : null;
|
|
205
|
+
try {
|
|
206
|
+
const meta = await readTranslationInventory(backend, { entityType, bundle, id });
|
|
207
|
+
const liveVid = meta.live?.vid ?? fromEntity;
|
|
208
|
+
const workingVid = meta.working?.vid;
|
|
209
|
+
const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
|
|
210
|
+
&& liveVid !== undefined && liveVid !== null
|
|
211
|
+
&& String(workingVid) !== String(liveVid);
|
|
212
|
+
return { liveVid, workingVid: distinct ? workingVid : undefined };
|
|
213
|
+
} catch {
|
|
214
|
+
if (typeof backend.getEntity !== "function") {
|
|
215
|
+
return { liveVid: fromEntity, workingVid: undefined };
|
|
216
|
+
}
|
|
217
|
+
let workingCopy = null;
|
|
218
|
+
try {
|
|
219
|
+
workingCopy = await backend.getEntity({
|
|
220
|
+
entityType, bundle, id, resourceVersion: "rel:working-copy",
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
workingCopy = null;
|
|
224
|
+
}
|
|
225
|
+
const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
|
|
226
|
+
const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
|
|
227
|
+
return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Rewrite Sentinel's live-only 409 into an actionable connector error.
|
|
233
|
+
* @param {unknown} error
|
|
234
|
+
* @returns {Error}
|
|
235
|
+
*/
|
|
236
|
+
export function rewriteTranslationWorkingRevisionError(error) {
|
|
237
|
+
if (/A working revision exists\. Reload and send both revision IDs/i.test(String(error?.message || ""))) {
|
|
238
|
+
return new Error(
|
|
239
|
+
"Translation create sent only the live revision, but a working draft exists. " +
|
|
240
|
+
"The connector should have sent both live and working revision IDs (If-Match). " +
|
|
241
|
+
"Reload with drupal_list_translations and retry. See connector #282.",
|
|
242
|
+
{ cause: error instanceof Error ? error : undefined },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read live/working translation inventory from Sentinel.
|
|
250
|
+
* @param {object} backend
|
|
251
|
+
* @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
|
|
252
|
+
* @returns {Promise<object>}
|
|
253
|
+
*/
|
|
254
|
+
export async function readTranslationInventory(backend, { entityType, bundle, id, revisionId }) {
|
|
255
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
256
|
+
const headers = {};
|
|
257
|
+
if (revisionId !== undefined && revisionId !== null && revisionId !== "") {
|
|
258
|
+
headers["If-Match"] = `"${requireParagraphRevisionId({ revisionId })}"`;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const result = await backend.rawQuery({
|
|
262
|
+
path: `${base}/mcp-translations`,
|
|
263
|
+
options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
|
|
264
|
+
});
|
|
265
|
+
if (!result?.meta?.live) {
|
|
266
|
+
throw new Error("The site did not return a translation inventory.");
|
|
43
267
|
}
|
|
44
|
-
|
|
268
|
+
return result.meta;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Read one unpublished working translation.
|
|
276
|
+
* @param {object} backend
|
|
277
|
+
* @param {object} input
|
|
278
|
+
* @returns {Promise<object>}
|
|
279
|
+
*/
|
|
280
|
+
export async function readDraftTranslation(backend, input) {
|
|
281
|
+
const { entityType, bundle, id, draftRevision } = input;
|
|
282
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
283
|
+
if (entityType === "paragraph") {
|
|
284
|
+
return readParagraphDraftTranslation(backend, input);
|
|
285
|
+
}
|
|
286
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
287
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
288
|
+
let result;
|
|
289
|
+
try {
|
|
290
|
+
result = await backend.rawQuery({
|
|
291
|
+
path: `${base}/mcp-draft`,
|
|
292
|
+
options: {
|
|
293
|
+
method: "GET",
|
|
294
|
+
headers: {
|
|
295
|
+
"If-Match": `"${live}:${working}"`,
|
|
296
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
302
|
+
}
|
|
303
|
+
if (!result?.data || result.data.id !== id) {
|
|
304
|
+
throw new Error("Draft translation read did not identify the requested entity.");
|
|
305
|
+
}
|
|
306
|
+
return backend.toCanonical(result.data);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Continue an unpublished paragraph translation on a pinned revision.
|
|
311
|
+
* @param {object} backend
|
|
312
|
+
* @param {object} input
|
|
313
|
+
* @param {boolean} [preflight]
|
|
314
|
+
* @returns {Promise<object>}
|
|
315
|
+
*/
|
|
316
|
+
async function writeParagraphDraft(backend, input, preflight = false) {
|
|
317
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
318
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
319
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
320
|
+
const draftState = input.draftState;
|
|
321
|
+
if (typeof draftState !== "string" || !/^[a-f0-9]{64}$/.test(draftState)) {
|
|
322
|
+
throw new Error("Paragraph translation update requires draftState from the previous draft read. Re-read the draft; do not retry old copy with a refreshed token.");
|
|
323
|
+
}
|
|
324
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
325
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
326
|
+
if (relationships) data.relationships = relationships;
|
|
327
|
+
const headers = {
|
|
328
|
+
"If-Match": `"${revisionId}"`,
|
|
329
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
330
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
331
|
+
"X-MCP-Draft-State": draftState,
|
|
332
|
+
};
|
|
333
|
+
let result;
|
|
334
|
+
try {
|
|
335
|
+
result = await backend.rawQuery({
|
|
336
|
+
path: `${base}/mcp-draft`,
|
|
337
|
+
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
|
|
45
341
|
}
|
|
46
342
|
if (preflight) {
|
|
47
343
|
if (result?.meta?.draft_preflight !== true
|
|
48
|
-
|| String(result.meta.live) !==
|
|
49
|
-
|
|
344
|
+
|| String(result.meta.live) !== revisionId
|
|
345
|
+
|| (result.meta.langcode && String(result.meta.langcode) !== langcode)) {
|
|
346
|
+
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
50
347
|
}
|
|
51
348
|
return result;
|
|
52
349
|
}
|
|
53
350
|
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
54
|
-
throw new Error("
|
|
351
|
+
throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
55
352
|
}
|
|
56
|
-
return backend.toCanonical(result.data);
|
|
353
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Create an unpublished paragraph translation on a pinned revision.
|
|
358
|
+
* @param {object} backend
|
|
359
|
+
* @param {object} input
|
|
360
|
+
* @param {boolean} [preflight]
|
|
361
|
+
* @returns {Promise<object>}
|
|
362
|
+
*/
|
|
363
|
+
async function createParagraphTranslationDraft(backend, input, preflight = false) {
|
|
364
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
365
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
366
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
367
|
+
const safeAttributes = { ...attributes };
|
|
368
|
+
delete safeAttributes.langcode;
|
|
369
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
370
|
+
if (relationships) data.relationships = relationships;
|
|
371
|
+
let result;
|
|
372
|
+
try {
|
|
373
|
+
result = await backend.rawQuery({
|
|
374
|
+
path: `${base}/mcp-draft/translations`,
|
|
375
|
+
options: {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: {
|
|
378
|
+
"If-Match": `"${revisionId}"`,
|
|
379
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
380
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify({ data }),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
} catch (error) {
|
|
386
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
387
|
+
}
|
|
388
|
+
if (preflight) {
|
|
389
|
+
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== revisionId) {
|
|
390
|
+
throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
395
|
+
throw new Error("Paragraph translation create did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
396
|
+
}
|
|
397
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Read one unpublished paragraph translation of a pinned revision.
|
|
402
|
+
* @param {object} backend
|
|
403
|
+
* @param {object} input
|
|
404
|
+
* @returns {Promise<object>}
|
|
405
|
+
*/
|
|
406
|
+
async function readParagraphDraftTranslation(backend, input) {
|
|
407
|
+
const { entityType, bundle, id, draftRevision } = input;
|
|
408
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
409
|
+
const revisionId = requireParagraphRevisionId(draftRevision);
|
|
410
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
411
|
+
let result;
|
|
412
|
+
try {
|
|
413
|
+
result = await backend.rawQuery({
|
|
414
|
+
path: `${base}/mcp-draft`,
|
|
415
|
+
options: {
|
|
416
|
+
method: "GET",
|
|
417
|
+
headers: {
|
|
418
|
+
"If-Match": `"${revisionId}"`,
|
|
419
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
} catch (error) {
|
|
424
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
425
|
+
}
|
|
426
|
+
if (!result?.data || result.data.id !== id) {
|
|
427
|
+
throw new Error("Paragraph draft translation read did not identify the requested entity.");
|
|
428
|
+
}
|
|
429
|
+
return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
|
|
57
430
|
}
|
|
@@ -75,6 +75,31 @@ export function flagUnrequestedStatusChange(result, existing, sentAttributes) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Why computed `metatag` is dropped on unpublished working-translation bodies. */
|
|
79
|
+
export const METATAG_OMITTED_NOTE =
|
|
80
|
+
"The computed metatag array is omitted: JSON:API resolves it from the live " +
|
|
81
|
+
"default revision, not the unpublished working translation. Use the stored " +
|
|
82
|
+
"field (field_metatags / field_metatag) to verify the draft. See connector #283.";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Drop the computed `metatag` array from a working-translation body.
|
|
86
|
+
* The stored override field is left intact. No-op when `metatag` is absent.
|
|
87
|
+
* @param {?object} entity Canonical entity.
|
|
88
|
+
* @returns {?object}
|
|
89
|
+
*/
|
|
90
|
+
export function omitLiveComputedMetatag(entity) {
|
|
91
|
+
if (!entity || typeof entity !== "object") return entity;
|
|
92
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : null;
|
|
93
|
+
if (!fields || !Object.prototype.hasOwnProperty.call(fields, "metatag")) return entity;
|
|
94
|
+
const nextFields = { ...fields };
|
|
95
|
+
delete nextFields.metatag;
|
|
96
|
+
return {
|
|
97
|
+
...entity,
|
|
98
|
+
fields: nextFields,
|
|
99
|
+
_metatagOmitted: { reason: METATAG_OMITTED_NOTE },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/** JSON Schema fragment for the shared `returning` parameter. */
|
|
79
104
|
export const RETURNING_SCHEMA = {
|
|
80
105
|
type: "string",
|