drupal-mcp-connector 2.14.2 → 2.15.1
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 +7 -5
- package/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-get-paragraph.md +5 -3
- package/.agents/commands/drupal-list-nodes.md +2 -2
- package/.agents/commands/drupal-list-translations.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/.agents/commands/drupal-update-paragraph.md +6 -3
- package/CHANGELOG.md +72 -0
- package/README.md +6 -4
- package/package.json +2 -2
- 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 +160 -26
- package/src/lib/draft-write.js +220 -11
- package/src/lib/entity-response.js +25 -0
- package/src/lib/patch-preflight.js +122 -11
- package/src/lib/path-alias.js +37 -0
- package/src/tools/entities.js +1 -1
- package/src/tools/nodes.js +97 -24
- package/src/tools/paragraphs.js +120 -13
- package/src/tools/revisions.js +2 -1
- package/src/tools/translations.js +70 -41
|
@@ -17,20 +17,37 @@ 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
|
|
23
24
|
// governed read/write workflows explicitly need.
|
|
24
25
|
const INTERNAL_ATTR_RE = /^drupal_internal__/;
|
|
25
26
|
|
|
26
|
-
// countEntities() pagination. Drupal core JSON:API returns no
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
|
|
27
|
+
// countEntities() / listEntities() pagination. Drupal core JSON:API returns no
|
|
28
|
+
// total in `meta` and silently caps `page[limit]` at OffsetPage::SIZE_MAX
|
|
29
|
+
// (50 by default). COUNT_PAGE_SIZE matches that default; COUNT_MAX_RECORDS
|
|
30
|
+
// bounds a walk so a huge collection can't issue unbounded requests (mirrors
|
|
31
|
+
// the GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
|
|
31
32
|
const COUNT_PAGE_SIZE = 50;
|
|
32
33
|
const COUNT_MAX_RECORDS = 1000;
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether a JSON:API collection document advertises another page.
|
|
37
|
+
* `links.next` may be a string href or a `{ href }` link object.
|
|
38
|
+
* @param {?object} data JSON:API document.
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function jsonApiHasNext(data) {
|
|
42
|
+
const next = data?.links?.next;
|
|
43
|
+
if (next === undefined || next === null || next === false) return false;
|
|
44
|
+
if (typeof next === "string") return next.length > 0;
|
|
45
|
+
if (typeof next === "object" && next.href !== undefined && next.href !== null) {
|
|
46
|
+
return String(next.href).length > 0;
|
|
47
|
+
}
|
|
48
|
+
return Boolean(next);
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/**
|
|
35
52
|
* Detect the JSON:API error Drupal returns when a write attempts to set the
|
|
36
53
|
* `status` (published) field on a content_moderation-governed entity. Such
|
|
@@ -267,24 +284,72 @@ export class JsonApiBackend extends Backend {
|
|
|
267
284
|
}
|
|
268
285
|
|
|
269
286
|
/**
|
|
270
|
-
* List entities for a descriptor.
|
|
271
|
-
*
|
|
287
|
+
* List entities for a descriptor.
|
|
288
|
+
*
|
|
289
|
+
* Drupal core JSON:API has no `meta.count` (jsonapi_extras can add it) and
|
|
290
|
+
* silently caps `page[limit]` at OffsetPage::SIZE_MAX (50 by default). When
|
|
291
|
+
* the caller asks for more rows than one Drupal page returns and
|
|
292
|
+
* `links.next` is present, this method follows that link until the requested
|
|
293
|
+
* window is filled, the collection ends, or COUNT_MAX_RECORDS is hit.
|
|
294
|
+
* `page.total` is exact when `meta.count` is present or this window reached
|
|
295
|
+
* the end; otherwise it is the number of rows seen so far and `approximate`
|
|
296
|
+
* is true. Never report a single page's length as an exact collection total.
|
|
297
|
+
*
|
|
272
298
|
* @param {import("../canonical.js").QueryDescriptor} descriptor
|
|
273
299
|
* @returns {Promise<import("./backend-interface.js").ListResult>}
|
|
274
300
|
*/
|
|
275
301
|
async listEntities(descriptor) {
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
302
|
+
const requestedLimit = descriptor.page?.limit;
|
|
303
|
+
const startOffset = descriptor.page?.offset ?? 0;
|
|
304
|
+
const fillTo = typeof requestedLimit === "number"
|
|
305
|
+
? Math.min(Math.max(0, requestedLimit), COUNT_MAX_RECORDS)
|
|
306
|
+
: null;
|
|
307
|
+
|
|
308
|
+
const entities = [];
|
|
309
|
+
let offset = startOffset;
|
|
310
|
+
let hasNext = false;
|
|
311
|
+
let metaCount = null;
|
|
312
|
+
|
|
313
|
+
for (;;) {
|
|
314
|
+
const remaining = fillTo === null ? requestedLimit : fillTo - entities.length;
|
|
315
|
+
const page = {
|
|
316
|
+
...descriptor.page,
|
|
317
|
+
offset,
|
|
318
|
+
...(typeof remaining === "number" ? { limit: remaining } : {}),
|
|
319
|
+
};
|
|
320
|
+
const params = this.compileQuery({ ...descriptor, page });
|
|
321
|
+
const qs = params.toString();
|
|
322
|
+
const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
|
|
323
|
+
const path = qs ? `${base}?${qs}` : base;
|
|
324
|
+
const data = await drupalFetch(this.site, path);
|
|
325
|
+
if (metaCount === null && typeof data?.meta?.count === "number") {
|
|
326
|
+
metaCount = data.meta.count;
|
|
327
|
+
}
|
|
328
|
+
const pageEntities = (data.data || []).map((r) => this.toCanonical(r));
|
|
329
|
+
entities.push(...pageEntities);
|
|
330
|
+
hasNext = jsonApiHasNext(data);
|
|
331
|
+
|
|
332
|
+
if (fillTo === null) break;
|
|
333
|
+
if (!hasNext || pageEntities.length === 0) break;
|
|
334
|
+
if (entities.length >= fillTo) break;
|
|
335
|
+
offset += pageEntities.length;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const truncated = fillTo !== null
|
|
339
|
+
&& typeof requestedLimit === "number"
|
|
340
|
+
&& entities.length < requestedLimit
|
|
341
|
+
&& hasNext;
|
|
342
|
+
const seen = startOffset + entities.length;
|
|
343
|
+
const exact = typeof metaCount === "number" || !hasNext;
|
|
283
344
|
return {
|
|
284
345
|
entities,
|
|
285
|
-
page: {
|
|
286
|
-
|
|
287
|
-
|
|
346
|
+
page: {
|
|
347
|
+
total: typeof metaCount === "number" ? metaCount : seen,
|
|
348
|
+
hasNext,
|
|
349
|
+
cursor: null,
|
|
350
|
+
},
|
|
351
|
+
approximate: !exact,
|
|
352
|
+
truncated,
|
|
288
353
|
};
|
|
289
354
|
}
|
|
290
355
|
|
|
@@ -310,19 +375,88 @@ export class JsonApiBackend extends Backend {
|
|
|
310
375
|
* (Drupal `PathItem::postSave` creates a duplicate alias when `pid` is absent)
|
|
311
376
|
* — so this method exposes it. Returns nulls for entities/backends without a
|
|
312
377
|
* path field. See the 1.5.1 alias fix.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
378
|
+
*
|
|
379
|
+
* Unpublished default / forward revisions often omit `pid` on the computed
|
|
380
|
+
* `path` field even when a `path_alias` row exists. When the node numeric id
|
|
381
|
+
* is known, this method also looks up that row (aliases are not revisioned)
|
|
382
|
+
* so title-only edits can pin the live alias (#274).
|
|
383
|
+
* @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
|
|
384
|
+
* @returns {Promise<{alias: ?string, pid: ?(number|string), langcode: ?string, drupalId: ?(number|string), aliasId: ?string}>}
|
|
315
385
|
*/
|
|
316
|
-
async getPathInfo({ entityType, bundle, id }) {
|
|
386
|
+
async getPathInfo({ entityType, bundle, id, resourceVersion }) {
|
|
317
387
|
validateUuid(id);
|
|
318
|
-
|
|
388
|
+
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
389
|
+
if (resourceVersion) {
|
|
390
|
+
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
391
|
+
}
|
|
392
|
+
const data = await drupalFetch(this.site, path);
|
|
319
393
|
const attrs = data?.data?.attributes ?? {};
|
|
320
|
-
const
|
|
394
|
+
const nodePath = attrs.path ?? null;
|
|
395
|
+
const drupalId = attrs.drupal_internal__nid ?? attrs.drupal_internal__id ?? null;
|
|
396
|
+
const langcode = nodePath?.langcode ?? attrs.langcode ?? null;
|
|
397
|
+
let alias = nodePath?.alias ?? null;
|
|
398
|
+
let pid = nodePath?.pid ?? null;
|
|
399
|
+
let aliasId = null;
|
|
400
|
+
|
|
401
|
+
if (entityType === "node" && isPositiveNid(drupalId)) {
|
|
402
|
+
const row = await this.lookupPathAliasRow(`/node/${Number(drupalId)}`, {
|
|
403
|
+
langcode,
|
|
404
|
+
preferredAlias: alias,
|
|
405
|
+
});
|
|
406
|
+
if (row) {
|
|
407
|
+
aliasId = row.id;
|
|
408
|
+
if (pid === undefined || pid === null) pid = row.pid;
|
|
409
|
+
// Pathauto / unpublished computed fields can omit alias; the row is
|
|
410
|
+
// the router-visible value.
|
|
411
|
+
if (!alias) alias = row.alias;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { alias, pid, langcode, drupalId, aliasId };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Load the path_alias row for a node source path. Best-effort: missing
|
|
420
|
+
* JSON:API exposure or an empty collection returns null.
|
|
421
|
+
* @param {string} sourcePath Drupal system path, e.g. `/node/44`.
|
|
422
|
+
* @param {{langcode?: ?string, preferredAlias?: ?string}} [opts]
|
|
423
|
+
* @returns {Promise<?{id: string, alias: ?string, pid: ?(number|string), langcode: ?string}>}
|
|
424
|
+
*/
|
|
425
|
+
async lookupPathAliasRow(sourcePath, opts = {}) {
|
|
426
|
+
if (!/^\/node\/[1-9]\d*$/.test(sourcePath)) return null;
|
|
427
|
+
const params = new URLSearchParams();
|
|
428
|
+
params.set("filter[path]", sourcePath);
|
|
429
|
+
if (opts.langcode) params.set("filter[langcode]", String(opts.langcode));
|
|
430
|
+
let data;
|
|
431
|
+
try {
|
|
432
|
+
data = await drupalFetch(
|
|
433
|
+
this.site,
|
|
434
|
+
`${this.resourcePath(PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE)}?${params}`,
|
|
435
|
+
);
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const rows = Array.isArray(data?.data) ? data.data : [];
|
|
440
|
+
if (!rows.length) return null;
|
|
441
|
+
const preferred = normalizeAlias(opts.preferredAlias);
|
|
442
|
+
let picked = rows[0];
|
|
443
|
+
if (preferred) {
|
|
444
|
+
for (const row of rows) {
|
|
445
|
+
const attrs = row && typeof row === "object" ? row.attributes : null;
|
|
446
|
+
const rowAlias = attrs && typeof attrs === "object" ? attrs.alias : null;
|
|
447
|
+
if (normalizeAlias(rowAlias) === preferred) {
|
|
448
|
+
picked = row;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!picked?.id) return null;
|
|
454
|
+
const a = picked.attributes && typeof picked.attributes === "object" ? picked.attributes : {};
|
|
321
455
|
return {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
456
|
+
id: picked.id,
|
|
457
|
+
alias: a.alias ?? null,
|
|
458
|
+
pid: a.drupal_internal__id ?? a.pid ?? null,
|
|
459
|
+
langcode: a.langcode ?? null,
|
|
326
460
|
};
|
|
327
461
|
}
|
|
328
462
|
|
package/src/lib/draft-write.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Translation create/update uses the same surface with X-MCP-Draft-Langcode.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
8
|
+
|
|
7
9
|
const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
|
|
8
10
|
const MISSING_DRAFT_ENDPOINT =
|
|
9
11
|
"The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
|
|
@@ -56,9 +58,9 @@ function requireWorkingPair(draftRevision) {
|
|
|
56
58
|
* @param {string} id
|
|
57
59
|
* @returns {string}
|
|
58
60
|
*/
|
|
59
|
-
function
|
|
60
|
-
if (entityType !== "node") {
|
|
61
|
-
throw new Error("Governed draft translation is implemented for nodes.");
|
|
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.");
|
|
62
64
|
}
|
|
63
65
|
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
64
66
|
throw new Error("This backend does not support governed draft continuation.");
|
|
@@ -66,6 +68,18 @@ function nodeResource(backend, entityType, bundle, id) {
|
|
|
66
68
|
return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
67
69
|
}
|
|
68
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
|
+
|
|
69
83
|
/**
|
|
70
84
|
* Validate or continue a draft, using the same payload and revision precondition.
|
|
71
85
|
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
@@ -76,8 +90,11 @@ function nodeResource(backend, entityType, bundle, id) {
|
|
|
76
90
|
*/
|
|
77
91
|
export async function writeDraft(backend, input, preflight = false) {
|
|
78
92
|
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
93
|
+
if (entityType === "paragraph") {
|
|
94
|
+
return writeParagraphDraft(backend, input, preflight);
|
|
95
|
+
}
|
|
79
96
|
const { live, working } = requireWorkingPair(draftRevision);
|
|
80
|
-
const base =
|
|
97
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
81
98
|
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
82
99
|
if (relationships) data.relationships = relationships;
|
|
83
100
|
const headers = {
|
|
@@ -123,6 +140,11 @@ export async function writeDraft(backend, input, preflight = false) {
|
|
|
123
140
|
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
124
141
|
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
125
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
|
+
}
|
|
126
148
|
const live = String(draftRevision?.liveVid ?? "");
|
|
127
149
|
const workingRaw = draftRevision?.workingVid;
|
|
128
150
|
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
@@ -134,7 +156,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
134
156
|
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
135
157
|
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
136
158
|
}
|
|
137
|
-
const base =
|
|
159
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
138
160
|
const safeAttributes = { ...attributes };
|
|
139
161
|
delete safeAttributes.langcode;
|
|
140
162
|
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
@@ -155,7 +177,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
155
177
|
},
|
|
156
178
|
});
|
|
157
179
|
} catch (error) {
|
|
158
|
-
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
180
|
+
throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
|
|
159
181
|
}
|
|
160
182
|
if (preflight) {
|
|
161
183
|
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
@@ -169,16 +191,77 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
169
191
|
return backend.toCanonical(result.data);
|
|
170
192
|
}
|
|
171
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
|
+
|
|
172
248
|
/**
|
|
173
249
|
* Read live/working translation inventory from Sentinel.
|
|
174
250
|
* @param {object} backend
|
|
175
|
-
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
251
|
+
* @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
|
|
176
252
|
* @returns {Promise<object>}
|
|
177
253
|
*/
|
|
178
|
-
export async function readTranslationInventory(backend, { entityType, bundle, id }) {
|
|
179
|
-
const base =
|
|
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
|
+
}
|
|
180
260
|
try {
|
|
181
|
-
const result = await backend.rawQuery({
|
|
261
|
+
const result = await backend.rawQuery({
|
|
262
|
+
path: `${base}/mcp-translations`,
|
|
263
|
+
options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
|
|
264
|
+
});
|
|
182
265
|
if (!result?.meta?.live) {
|
|
183
266
|
throw new Error("The site did not return a translation inventory.");
|
|
184
267
|
}
|
|
@@ -197,8 +280,11 @@ export async function readTranslationInventory(backend, { entityType, bundle, id
|
|
|
197
280
|
export async function readDraftTranslation(backend, input) {
|
|
198
281
|
const { entityType, bundle, id, draftRevision } = input;
|
|
199
282
|
const langcode = assertDraftLangcode(input.langcode);
|
|
283
|
+
if (entityType === "paragraph") {
|
|
284
|
+
return readParagraphDraftTranslation(backend, input);
|
|
285
|
+
}
|
|
200
286
|
const { live, working } = requireWorkingPair(draftRevision);
|
|
201
|
-
const base =
|
|
287
|
+
const base = draftResource(backend, entityType, bundle, id);
|
|
202
288
|
let result;
|
|
203
289
|
try {
|
|
204
290
|
result = await backend.rawQuery({
|
|
@@ -219,3 +305,126 @@ export async function readDraftTranslation(backend, input) {
|
|
|
219
305
|
}
|
|
220
306
|
return backend.toCanonical(result.data);
|
|
221
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);
|
|
341
|
+
}
|
|
342
|
+
if (preflight) {
|
|
343
|
+
if (result?.meta?.draft_preflight !== true
|
|
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.");
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
351
|
+
throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
352
|
+
}
|
|
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 };
|
|
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",
|