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
|
@@ -35,6 +35,9 @@ export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
|
|
|
35
35
|
/** Stable error code when the working-copy resource does not match the target. */
|
|
36
36
|
export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
|
|
37
37
|
|
|
38
|
+
/** Stable error code for Sentinel's save-time stale-default-revision refusal. */
|
|
39
|
+
export const STALE_COPY_CODE = "STALE_COPY";
|
|
40
|
+
|
|
38
41
|
const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -98,6 +101,81 @@ export class WorkingCopyStaleError extends Error {
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Sentinel's save-time stale-default check (McpWritePreconditions) is not
|
|
106
|
+
* exercised by the id-mismatch PATCH probe — that probe fails before
|
|
107
|
+
* entity validation / presave. A published node with no distinct working
|
|
108
|
+
* copy and a changed timestamp later than its own revision_timestamp is
|
|
109
|
+
* the readable fingerprint (`possiblyPatchBlocked`). dryRun and the real
|
|
110
|
+
* write must refuse the same way; reloading and retrying does not help.
|
|
111
|
+
* Do not bypass the draft or publish gate. See connector #273.
|
|
112
|
+
*/
|
|
113
|
+
export const STALE_COPY_MESSAGE =
|
|
114
|
+
"This entity cannot be updated: MCP Sentinel refused a stale default-revision " +
|
|
115
|
+
"write (the content changed after this copy was loaded). dryRun and the real " +
|
|
116
|
+
"write share this check. rel:latest-version and rel:working-copy report the " +
|
|
117
|
+
"same vid, but the default revision's changed timestamp is later than its " +
|
|
118
|
+
"revision_timestamp (possiblyPatchBlocked). Reloading and retrying the same " +
|
|
119
|
+
"canonical PATCH will not help. Do not bypass the draft or publish gate. " +
|
|
120
|
+
"See connector #273 / #201.";
|
|
121
|
+
|
|
122
|
+
const STALE_COPY_RE = /changed after this copy was loaded/i;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Thrown when a canonical write (or its dryRun) would hit Sentinel's
|
|
126
|
+
* stale-default-revision check (#273).
|
|
127
|
+
*/
|
|
128
|
+
export class StaleCopyError extends Error {
|
|
129
|
+
/**
|
|
130
|
+
* @param {?Error} [cause]
|
|
131
|
+
*/
|
|
132
|
+
constructor(cause) {
|
|
133
|
+
super(STALE_COPY_MESSAGE);
|
|
134
|
+
this.name = "StaleCopyError";
|
|
135
|
+
this.code = STALE_COPY_CODE;
|
|
136
|
+
if (cause) this.cause = cause;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether an error is Sentinel's stale-version refusal (or our rewrite).
|
|
142
|
+
* @param {unknown} err
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isStaleCopyError(err) {
|
|
146
|
+
if (err instanceof StaleCopyError) return true;
|
|
147
|
+
return STALE_COPY_RE.test(String(err?.message || ""));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a canonical entity's `changed` is later than its own
|
|
152
|
+
* `revision_timestamp` — the same fingerprint `drupal_list_revisions`
|
|
153
|
+
* reports as `possiblyPatchBlocked`.
|
|
154
|
+
* @param {?object} entity Canonical entity or revision summary.
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function changedAheadOfRevision(entity) {
|
|
158
|
+
if (!entity || typeof entity !== "object") return false;
|
|
159
|
+
const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
160
|
+
const changed = entity.changed;
|
|
161
|
+
const rev = entity.revisionTimestamp ?? fields.revision_timestamp;
|
|
162
|
+
if (!changed || !rev) return false;
|
|
163
|
+
const changedMs = Date.parse(changed);
|
|
164
|
+
const revMs = Date.parse(rev);
|
|
165
|
+
if (!Number.isFinite(changedMs) || !Number.isFinite(revMs)) return false;
|
|
166
|
+
return changedMs > revMs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewrite Sentinel's save-time stale-version refusal into {@link StaleCopyError}.
|
|
171
|
+
* @param {unknown} err
|
|
172
|
+
* @returns {unknown}
|
|
173
|
+
*/
|
|
174
|
+
export function rewriteStaleCopyError(err) {
|
|
175
|
+
if (!isStaleCopyError(err) || err instanceof StaleCopyError) return err;
|
|
176
|
+
return new StaleCopyError(err instanceof Error ? err : new Error(String(err)));
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
/**
|
|
102
180
|
* Thrown when `rel:working-copy` resolves to a different UUID than the
|
|
103
181
|
* entity being updated.
|
|
@@ -300,22 +378,67 @@ export async function preflightPatchWritable({
|
|
|
300
378
|
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
301
379
|
*/
|
|
302
380
|
export async function prepareGuardedPatch(backend, {
|
|
303
|
-
entityType, bundle, id, existing, attributes, relationships,
|
|
381
|
+
entityType, bundle, id, existing, attributes, relationships, langcode,
|
|
304
382
|
}) {
|
|
305
383
|
const target = shouldPreflightPatch({ existing, attributes })
|
|
306
384
|
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
307
385
|
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
386
|
+
if (shouldPreflightPatch({ existing, attributes }) && !target.resourceVersion) {
|
|
387
|
+
// Canonical path (no distinct working copy). The id-mismatch probe never
|
|
388
|
+
// reaches Sentinel's save-time stale-default check; refuse here when the
|
|
389
|
+
// possiblyPatchBlocked fingerprint is already readable (#273).
|
|
390
|
+
let fingerprint = existing;
|
|
391
|
+
const fields = fingerprint?.fields && typeof fingerprint.fields === "object" ? fingerprint.fields : {};
|
|
392
|
+
const hasTimestamps = Boolean(
|
|
393
|
+
fingerprint?.changed && (fingerprint.revisionTimestamp || fields.revision_timestamp),
|
|
394
|
+
);
|
|
395
|
+
if (!hasTimestamps && typeof backend?.getEntity === "function") {
|
|
396
|
+
const latest = await backend.getEntity({
|
|
397
|
+
entityType, bundle, id, resourceVersion: "rel:latest-version",
|
|
398
|
+
}).catch(() => null);
|
|
399
|
+
if (latest) fingerprint = latest;
|
|
400
|
+
}
|
|
401
|
+
if (changedAheadOfRevision(fingerprint)) {
|
|
402
|
+
throw new StaleCopyError();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (langcode) {
|
|
406
|
+
if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
"No unpublished working translation for this language. " +
|
|
409
|
+
"Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted."
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
413
|
+
try {
|
|
414
|
+
await writeDraft(backend, {
|
|
415
|
+
entityType, bundle, id, attributes, relationships, langcode,
|
|
416
|
+
draftRevision: target.draftRevision,
|
|
417
|
+
}, true);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw rewriteStaleCopyError(err);
|
|
420
|
+
}
|
|
421
|
+
return target;
|
|
422
|
+
}
|
|
308
423
|
if (target.resourceVersion) {
|
|
309
424
|
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
425
|
+
try {
|
|
426
|
+
await writeDraft(backend, {
|
|
427
|
+
entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
|
|
428
|
+
}, true);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
throw rewriteStaleCopyError(err);
|
|
431
|
+
}
|
|
313
432
|
return target;
|
|
314
433
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
434
|
+
try {
|
|
435
|
+
await preflightPatchWritable({
|
|
436
|
+
backend, entityType, bundle, id, existing, attributes,
|
|
437
|
+
resourceVersion: target.resourceVersion,
|
|
438
|
+
});
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw rewriteStaleCopyError(err);
|
|
441
|
+
}
|
|
319
442
|
return target;
|
|
320
443
|
}
|
|
321
444
|
|
|
@@ -336,6 +459,8 @@ export async function updateEntityGuarded(backend, input) {
|
|
|
336
459
|
}
|
|
337
460
|
return await backend.updateEntity(input);
|
|
338
461
|
} catch (err) {
|
|
462
|
+
const stale = rewriteStaleCopyError(err);
|
|
463
|
+
if (stale !== err) throw stale;
|
|
339
464
|
if (!isWorkingCopyPatchError(err)) throw err;
|
|
340
465
|
const cause = err instanceof Error ? err : new Error(String(err));
|
|
341
466
|
if (input?.resourceVersion === "rel:working-copy") {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-alias helpers shared by node writes and the JSON:API path reader.
|
|
3
|
+
*
|
|
4
|
+
* Path aliases are not revisioned. The node's `path` field is a computed
|
|
5
|
+
* view of `path_alias` rows; unpublished / forward revisions often omit
|
|
6
|
+
* `pid` even when a row exists. Round-tripping that pid (and verifying
|
|
7
|
+
* after save) is how title-only edits keep the existing alias (#274).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** JSON:API entity type + bundle for a path alias row. */
|
|
11
|
+
export const PATH_ALIAS_ENTITY_TYPE = "path_alias";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a URL-alias path for storage/comparison: trim, ensure a single
|
|
15
|
+
* leading slash, drop a trailing slash (except root).
|
|
16
|
+
* @param {*} value A raw alias.
|
|
17
|
+
* @returns {?string} The normalized alias, or null when empty.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeAlias(value) {
|
|
20
|
+
if (value === undefined || value === null) return null;
|
|
21
|
+
let s = String(value).trim();
|
|
22
|
+
if (!s) return null;
|
|
23
|
+
if (!s.startsWith("/")) s = `/${s}`;
|
|
24
|
+
if (s.length > 1) s = s.replace(/\/+$/, "");
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a value is a positive integer node id suitable for `/node/{nid}`.
|
|
30
|
+
* @param {*} value Raw drupal internal id.
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isPositiveNid(value) {
|
|
34
|
+
if (value === undefined || value === null || value === "") return false;
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
return Number.isInteger(n) && n > 0;
|
|
37
|
+
}
|
package/src/tools/entities.js
CHANGED
|
@@ -96,7 +96,7 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
96
96
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
97
97
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
98
98
|
*/
|
|
99
|
-
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
99
|
+
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, langcode, dryRun = false, returning = "full" }) {
|
|
100
100
|
const site = getSiteConfig(siteName);
|
|
101
101
|
const sec = resolveSecurityConfig(site);
|
|
102
102
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
@@ -119,6 +119,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
119
119
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
120
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
121
121
|
entityType, bundle, id, existing, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
122
|
+
langcode,
|
|
122
123
|
});
|
|
123
124
|
if (dryRun) {
|
|
124
125
|
return {
|
|
@@ -128,6 +129,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
128
129
|
}
|
|
129
130
|
const result = await updateEntityGuarded(backend, {
|
|
130
131
|
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
132
|
+
...(langcode ? { langcode } : {}),
|
|
131
133
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
132
134
|
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
133
135
|
});
|
|
@@ -289,9 +291,10 @@ export const definitions = [
|
|
|
289
291
|
entityType: { type: "string" },
|
|
290
292
|
bundle: { type: "string" },
|
|
291
293
|
id: { type: "string" },
|
|
294
|
+
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
292
295
|
attributes: { type: "object" },
|
|
293
296
|
relationships: { type: "object" },
|
|
294
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
297
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
295
298
|
returning: RETURNING_SCHEMA,
|
|
296
299
|
},
|
|
297
300
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -14,32 +14,19 @@ import {
|
|
|
14
14
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
15
15
|
} from "../lib/security.js";
|
|
16
16
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
|
-
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
17
|
+
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
18
18
|
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
21
22
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
22
23
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
23
24
|
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
25
|
+
import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
|
|
24
26
|
|
|
25
27
|
/** Fallback language for an alias when the node exposes none. */
|
|
26
28
|
const DEFAULT_ALIAS_LANGCODE = "en";
|
|
27
29
|
|
|
28
|
-
/**
|
|
29
|
-
* Normalize a URL-alias path for storage/comparison: trim, ensure a single
|
|
30
|
-
* leading slash, drop a trailing slash (except root).
|
|
31
|
-
* @param {*} value A raw alias.
|
|
32
|
-
* @returns {?string} The normalized alias, or null when empty.
|
|
33
|
-
*/
|
|
34
|
-
function normalizeAlias(value) {
|
|
35
|
-
if (value === undefined || value === null) return null;
|
|
36
|
-
let s = String(value).trim();
|
|
37
|
-
if (!s) return null;
|
|
38
|
-
if (!s.startsWith("/")) s = `/${s}`;
|
|
39
|
-
if (s.length > 1) s = s.replace(/\/+$/, "");
|
|
40
|
-
return s;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
30
|
/**
|
|
44
31
|
* Resolve the `path` attribute to send on an alias-aware node write so the alias
|
|
45
32
|
* actually persists, and decide whether a rename redirect is needed.
|
|
@@ -57,6 +44,8 @@ function normalizeAlias(value) {
|
|
|
57
44
|
* run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
|
|
58
45
|
*/
|
|
59
46
|
async function resolvePathWrite({ backend, type, id, providedPath, isCreate }) {
|
|
47
|
+
// Always read the default revision + path_alias row. Aliases are not
|
|
48
|
+
// revisioned; a working-copy GET often omits pid on unpublished drafts (#274).
|
|
60
49
|
const info = id
|
|
61
50
|
? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
|
|
62
51
|
: {};
|
|
@@ -132,6 +121,80 @@ async function createRenameRedirect(backend, sec, redirect) {
|
|
|
132
121
|
}
|
|
133
122
|
}
|
|
134
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Update the existing path_alias row in place when a node PATCH did not stick
|
|
126
|
+
* the intended alias (Pathauto regenerating on title save, missing pid on
|
|
127
|
+
* unpublished drafts).
|
|
128
|
+
* @param {object} backend
|
|
129
|
+
* @param {object} sec
|
|
130
|
+
* @param {{aliasId?: ?string}} info
|
|
131
|
+
* @param {string} intendedAlias
|
|
132
|
+
* @returns {Promise<{repaired: boolean, reason?: string}>}
|
|
133
|
+
*/
|
|
134
|
+
async function repairAliasViaPathAlias(backend, sec, info, intendedAlias) {
|
|
135
|
+
if (!info?.aliasId) return { repaired: false, reason: "no path_alias id to update" };
|
|
136
|
+
try {
|
|
137
|
+
assertWriteAllowed(sec, "update", PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE);
|
|
138
|
+
} catch {
|
|
139
|
+
return { repaired: false, reason: "path_alias update not permitted by policy" };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await backend.updateEntity({
|
|
143
|
+
entityType: PATH_ALIAS_ENTITY_TYPE,
|
|
144
|
+
bundle: PATH_ALIAS_ENTITY_TYPE,
|
|
145
|
+
id: info.aliasId,
|
|
146
|
+
attributes: { alias: intendedAlias },
|
|
147
|
+
});
|
|
148
|
+
return { repaired: true };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { repaired: false, reason: err?.message || String(err) };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Confirm the intended alias is what canonical / working-copy reads return.
|
|
156
|
+
* If the node PATCH drifted (Pathauto), restore the path_alias row and
|
|
157
|
+
* re-read. Still-wrong reads throw — never report success for a failed
|
|
158
|
+
* restoration (#274).
|
|
159
|
+
* @param {object} args
|
|
160
|
+
* @returns {Promise<object>} The entity to return (possibly a post-repair re-read).
|
|
161
|
+
*/
|
|
162
|
+
async function assertAliasPersisted({
|
|
163
|
+
backend, sec, type, id, intendedAlias, resourceVersion, written,
|
|
164
|
+
}) {
|
|
165
|
+
const shown = normalizeAlias(written?.url);
|
|
166
|
+
if (shown === intendedAlias) return written;
|
|
167
|
+
|
|
168
|
+
const info = await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}));
|
|
169
|
+
if (normalizeAlias(info.alias) === intendedAlias && shown === null) {
|
|
170
|
+
// Re-read omitted url but the alias row matches; treat as persisted.
|
|
171
|
+
return written;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const repair = await repairAliasViaPathAlias(backend, sec, info, intendedAlias);
|
|
175
|
+
const fresh = await backend.getEntity({
|
|
176
|
+
entityType: "node", bundle: type, id,
|
|
177
|
+
...(resourceVersion ? { resourceVersion } : {}),
|
|
178
|
+
}).catch(() => null);
|
|
179
|
+
const afterInfo = await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}));
|
|
180
|
+
const entityUrl = normalizeAlias(fresh?.url);
|
|
181
|
+
const aliasUrl = normalizeAlias(afterInfo.alias);
|
|
182
|
+
// The path_alias row is router-visible; the node's computed path field can
|
|
183
|
+
// lag. Persist if either re-read matches. Never return a drifted url.
|
|
184
|
+
if (entityUrl === intendedAlias || aliasUrl === intendedAlias) {
|
|
185
|
+
const entity = fresh ?? written;
|
|
186
|
+
if (normalizeAlias(entity?.url) === intendedAlias) return entity;
|
|
187
|
+
return { ...(entity && typeof entity === "object" ? entity : { id }), url: intendedAlias };
|
|
188
|
+
}
|
|
189
|
+
const reported = entityUrl || aliasUrl || shown || "(none)";
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Alias restoration failed: requested "${intendedAlias}" but canonical ` +
|
|
192
|
+
`and working-copy reads still show "${reported}"` +
|
|
193
|
+
(repair.reason ? ` (${repair.reason})` : "") +
|
|
194
|
+
". Update the path_alias entity directly. See connector #274."
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
135
198
|
/**
|
|
136
199
|
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
137
200
|
*
|
|
@@ -176,12 +239,39 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
176
239
|
* @param {object} args - { site?, type, id }.
|
|
177
240
|
* @returns {Promise<object|null>} The redacted node, or null if not found.
|
|
178
241
|
*/
|
|
179
|
-
async function getNode({ site: siteName, type, id }) {
|
|
242
|
+
async function getNode({ site: siteName, type, id, langcode, resourceVersion }) {
|
|
180
243
|
const site = getSiteConfig(siteName);
|
|
181
244
|
const sec = resolveSecurityConfig(site);
|
|
182
245
|
assertReadAllowed(sec, "node", type);
|
|
183
246
|
const backend = await resolveBackend(site);
|
|
184
|
-
|
|
247
|
+
if (langcode) {
|
|
248
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
249
|
+
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
250
|
+
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
251
|
+
if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
|
|
252
|
+
const entity = await readDraftTranslation(backend, {
|
|
253
|
+
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
254
|
+
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
255
|
+
});
|
|
256
|
+
return omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
|
|
257
|
+
}
|
|
258
|
+
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
259
|
+
if (!liveHas) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (targetLang !== inventory.defaultLangcode) {
|
|
263
|
+
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
264
|
+
return {
|
|
265
|
+
id, entityType: "node", bundle: type, langcode: targetLang,
|
|
266
|
+
title: liveRow?.title ?? null,
|
|
267
|
+
status: liveRow?.status ?? null,
|
|
268
|
+
fields: { moderation_state: liveRow?.moderation_state },
|
|
269
|
+
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
270
|
+
note: "Published non-default translations are listed on the live revision; full field reads of an unpublished working translation use langcode against the working draft.",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const entity = await backend.getEntity({ entityType: "node", bundle: type, id, resourceVersion });
|
|
185
275
|
return entity ? redactCanonicalEntity(entity, sec, "node") : null;
|
|
186
276
|
}
|
|
187
277
|
|
|
@@ -310,13 +400,14 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
310
400
|
* @param {object} args - { site?, type, id, title?, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
|
|
311
401
|
* @returns {Promise<object>} The updated node descriptor.
|
|
312
402
|
*/
|
|
313
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
403
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, langcode, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
314
404
|
const site = getSiteConfig(siteName);
|
|
315
405
|
const sec = resolveSecurityConfig(site);
|
|
316
406
|
assertWriteAllowed(sec, "update", "node", type);
|
|
317
407
|
const backend = await resolveBackend(site);
|
|
318
408
|
const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
|
|
319
409
|
let attributes = { ...fields };
|
|
410
|
+
delete attributes.langcode;
|
|
320
411
|
if (title !== undefined) attributes.title = title;
|
|
321
412
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
322
413
|
else if (status !== undefined) attributes.status = status;
|
|
@@ -348,6 +439,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
348
439
|
// governed continuation endpoint; stray revisions still fail the probe.
|
|
349
440
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
350
441
|
entityType: "node", bundle: type, id, existing, attributes, relationships: resolvedRelationships,
|
|
442
|
+
langcode,
|
|
351
443
|
});
|
|
352
444
|
if (dryRun) {
|
|
353
445
|
const preview = {
|
|
@@ -365,19 +457,28 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
365
457
|
else attributes.path = pathAttr;
|
|
366
458
|
const patched = await updateEntityGuarded(backend, {
|
|
367
459
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
460
|
+
...(langcode ? { langcode } : {}),
|
|
368
461
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
369
462
|
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
370
463
|
});
|
|
371
|
-
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
372
464
|
// #169: when relationships were sent, the canonical re-read is the published
|
|
373
465
|
// revision and is not proof an ERR field landed. Prefer rel:working-copy.
|
|
374
|
-
|
|
466
|
+
let fresh = await readWrittenRevision({
|
|
375
467
|
backend, entityType: "node", bundle: type, id,
|
|
376
468
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
377
469
|
patchResult: patched,
|
|
378
470
|
preferCanonical: true,
|
|
379
471
|
resourceVersion: patchTarget.resourceVersion,
|
|
380
472
|
});
|
|
473
|
+
const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
|
|
474
|
+
if (intendedAlias) {
|
|
475
|
+
fresh = await assertAliasPersisted({
|
|
476
|
+
backend, sec, type, id, intendedAlias,
|
|
477
|
+
resourceVersion: patchTarget.resourceVersion, written: fresh,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
// Redirect only after the new alias is what re-reads show (#274).
|
|
481
|
+
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
381
482
|
const withRevs = await attachWrittenRevisionPair({
|
|
382
483
|
backend, entityType: "node", bundle: type, id, entity: fresh, liveVid: patchTarget.liveVid,
|
|
383
484
|
});
|
|
@@ -414,13 +515,14 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
414
515
|
export const definitions = [
|
|
415
516
|
{
|
|
416
517
|
name: "drupal_get_node",
|
|
417
|
-
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes.",
|
|
518
|
+
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English).",
|
|
418
519
|
inputSchema: {
|
|
419
520
|
type: "object", required: ["type", "id"],
|
|
420
521
|
properties: {
|
|
421
522
|
site: { type: "string", description: "Named site (omit for default)" },
|
|
422
523
|
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
423
524
|
id: { type: "string", description: "Node UUID" },
|
|
525
|
+
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
424
526
|
},
|
|
425
527
|
},
|
|
426
528
|
},
|
|
@@ -477,7 +579,7 @@ export const definitions = [
|
|
|
477
579
|
},
|
|
478
580
|
{
|
|
479
581
|
name: "drupal_update_node",
|
|
480
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
582
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
481
583
|
inputSchema: {
|
|
482
584
|
type: "object", required: ["type", "id"],
|
|
483
585
|
properties: {
|
|
@@ -490,9 +592,10 @@ export const definitions = [
|
|
|
490
592
|
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
|
|
491
593
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
492
594
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
|
|
595
|
+
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
493
596
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
494
|
-
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot." },
|
|
495
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
|
597
|
+
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
|
|
598
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
|
|
496
599
|
returning: RETURNING_SCHEMA,
|
|
497
600
|
},
|
|
498
601
|
},
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
import {
|
|
43
43
|
embedParagraphRef, paragraphRevisionId, resolveParagraphRevisionId, missingParagraphRevisionError,
|
|
44
44
|
} from "../lib/err-relationships.js";
|
|
45
|
+
import {
|
|
46
|
+
assertDraftLangcode,
|
|
47
|
+
readDraftTranslation,
|
|
48
|
+
writeDraft,
|
|
49
|
+
} from "../lib/draft-write.js";
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
52
|
* Build the resource-identifier ref used to embed a paragraph in a host ERR /
|
|
@@ -111,17 +116,32 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
|
|
|
111
116
|
* @throws {Error} If id is missing or the revision id cannot be read.
|
|
112
117
|
* @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
|
|
113
118
|
*/
|
|
114
|
-
async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
|
|
119
|
+
async function updateParagraph({ site: siteName, paragraphType, id, attributes = {}, langcode, revisionId, draftState }) {
|
|
115
120
|
if (!id) throw new Error("A paragraph 'id' (UUID) is required to update an existing paragraph.");
|
|
116
121
|
const site = getSiteConfig(siteName);
|
|
117
122
|
const sec = resolveSecurityConfig(site);
|
|
118
123
|
assertWriteAllowed(sec, "update", "paragraph", paragraphType);
|
|
119
124
|
const backend = await resolveBackend(site);
|
|
120
|
-
|
|
125
|
+
let paragraph;
|
|
126
|
+
if (langcode) {
|
|
127
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
128
|
+
const pinned = revisionId ?? paragraphRevisionId(
|
|
129
|
+
await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
|
|
130
|
+
);
|
|
131
|
+
if (pinned === null || pinned === undefined || pinned === "") {
|
|
132
|
+
throw new Error("Paragraph translation update requires a paragraph revision ID (the host pin).");
|
|
133
|
+
}
|
|
134
|
+
paragraph = await writeDraft(backend, {
|
|
135
|
+
entityType: "paragraph", bundle: paragraphType, id, attributes, langcode: targetLang, draftState,
|
|
136
|
+
draftRevision: { revisionId: pinned },
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
|
|
140
|
+
}
|
|
121
141
|
const bundle = paragraph.bundle || paragraphType;
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
const ref = embedRef(bundle, paragraph.id,
|
|
142
|
+
const resolvedRevisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
|
|
143
|
+
if (resolvedRevisionId === null) throw missingParagraphRevisionError(id, "Updated");
|
|
144
|
+
const ref = embedRef(bundle, paragraph.id, resolvedRevisionId);
|
|
125
145
|
return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
|
|
126
146
|
}
|
|
127
147
|
|
|
@@ -134,16 +154,31 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
|
|
|
134
154
|
* The redacted paragraph with an embedding `ref`, or null if not found.
|
|
135
155
|
* @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
|
|
136
156
|
*/
|
|
137
|
-
async function getParagraph({ site: siteName, paragraphType, id }) {
|
|
157
|
+
async function getParagraph({ site: siteName, paragraphType, id, langcode, revisionId }) {
|
|
138
158
|
const site = getSiteConfig(siteName);
|
|
139
159
|
const sec = resolveSecurityConfig(site);
|
|
140
160
|
assertReadAllowed(sec, "paragraph", paragraphType);
|
|
141
161
|
const backend = await resolveBackend(site);
|
|
142
|
-
|
|
162
|
+
let entity;
|
|
163
|
+
if (langcode) {
|
|
164
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
165
|
+
const pinned = revisionId ?? paragraphRevisionId(
|
|
166
|
+
await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
|
|
167
|
+
);
|
|
168
|
+
if (pinned === null || pinned === undefined || pinned === "") {
|
|
169
|
+
throw new Error("Paragraph translation read requires a paragraph revision ID (the host pin).");
|
|
170
|
+
}
|
|
171
|
+
entity = await readDraftTranslation(backend, {
|
|
172
|
+
entityType: "paragraph", bundle: paragraphType, id, langcode: targetLang,
|
|
173
|
+
draftRevision: { revisionId: pinned },
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
|
|
177
|
+
}
|
|
143
178
|
if (!entity) return null;
|
|
144
179
|
const redacted = redactCanonicalEntity(entity, sec, "paragraph");
|
|
145
|
-
const
|
|
146
|
-
return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id,
|
|
180
|
+
const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted) ?? revisionId;
|
|
181
|
+
return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, resolvedRevisionId) };
|
|
147
182
|
}
|
|
148
183
|
|
|
149
184
|
// ---------------------------------------------------------------------------
|
|
@@ -167,7 +202,7 @@ export const definitions = [
|
|
|
167
202
|
{
|
|
168
203
|
name: "drupal_update_paragraph",
|
|
169
204
|
description:
|
|
170
|
-
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
|
|
205
|
+
"Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Pass langcode to continue an unpublished paragraph translation via Sentinel (the host pin, not a live English mutation). Omitting langcode still uses canonical JSON:API and remains gated on published-host children. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
|
|
171
206
|
inputSchema: {
|
|
172
207
|
type: "object", required: ["paragraphType", "id"],
|
|
173
208
|
properties: {
|
|
@@ -175,19 +210,24 @@ export const definitions = [
|
|
|
175
210
|
paragraphType: { type: "string", description: "Paragraph type / bundle machine name, e.g. 'text', 'image', 'cta'" },
|
|
176
211
|
id: { type: "string", description: "Paragraph UUID" },
|
|
177
212
|
attributes: { type: "object", description: "Paragraph field values to change, keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }" },
|
|
213
|
+
langcode: { type: "string", description: "Target language for an unpublished paragraph translation (e.g. 'es'). Continues Sentinel /mcp-draft; does not create a missing translation." },
|
|
214
|
+
revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required when that pin is not the default revision." },
|
|
215
|
+
draftState: { type: "string", pattern: "^[a-f0-9]{64}$", description: "Required with langcode. Opaque draftState from the previous paragraph draft read/create/update; prevents overwriting newer edits." },
|
|
178
216
|
},
|
|
179
217
|
},
|
|
180
218
|
},
|
|
181
219
|
{
|
|
182
220
|
name: "drupal_get_paragraph",
|
|
183
221
|
description:
|
|
184
|
-
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
|
|
222
|
+
"Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
|
|
185
223
|
inputSchema: {
|
|
186
224
|
type: "object", required: ["paragraphType", "id"],
|
|
187
225
|
properties: {
|
|
188
226
|
site: { type: "string" },
|
|
189
227
|
paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
|
|
190
228
|
id: { type: "string", description: "Paragraph UUID" },
|
|
229
|
+
langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
|
|
230
|
+
revisionId: { type: "string", description: "Paragraph revision id the host already pins." },
|
|
191
231
|
},
|
|
192
232
|
},
|
|
193
233
|
},
|