drupal-mcp-connector 2.14.2 → 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 +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-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 +52 -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 +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 +91 -21
- package/src/tools/paragraphs.js +51 -11
- package/src/tools/revisions.js +2 -1
- package/src/tools/translations.js +70 -41
package/src/tools/nodes.js
CHANGED
|
@@ -14,7 +14,7 @@ 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";
|
|
@@ -22,25 +22,11 @@ import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } f
|
|
|
22
22
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
23
23
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
24
24
|
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
25
|
+
import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
|
|
25
26
|
|
|
26
27
|
/** Fallback language for an alias when the node exposes none. */
|
|
27
28
|
const DEFAULT_ALIAS_LANGCODE = "en";
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
* Normalize a URL-alias path for storage/comparison: trim, ensure a single
|
|
31
|
-
* leading slash, drop a trailing slash (except root).
|
|
32
|
-
* @param {*} value A raw alias.
|
|
33
|
-
* @returns {?string} The normalized alias, or null when empty.
|
|
34
|
-
*/
|
|
35
|
-
function normalizeAlias(value) {
|
|
36
|
-
if (value === undefined || value === null) return null;
|
|
37
|
-
let s = String(value).trim();
|
|
38
|
-
if (!s) return null;
|
|
39
|
-
if (!s.startsWith("/")) s = `/${s}`;
|
|
40
|
-
if (s.length > 1) s = s.replace(/\/+$/, "");
|
|
41
|
-
return s;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
30
|
/**
|
|
45
31
|
* Resolve the `path` attribute to send on an alias-aware node write so the alias
|
|
46
32
|
* actually persists, and decide whether a rename redirect is needed.
|
|
@@ -58,6 +44,8 @@ function normalizeAlias(value) {
|
|
|
58
44
|
* run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
|
|
59
45
|
*/
|
|
60
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).
|
|
61
49
|
const info = id
|
|
62
50
|
? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
|
|
63
51
|
: {};
|
|
@@ -133,6 +121,80 @@ async function createRenameRedirect(backend, sec, redirect) {
|
|
|
133
121
|
}
|
|
134
122
|
}
|
|
135
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
|
+
|
|
136
198
|
/**
|
|
137
199
|
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
138
200
|
*
|
|
@@ -191,7 +253,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
191
253
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
192
254
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
193
255
|
});
|
|
194
|
-
return redactCanonicalEntity(entity, sec, "node");
|
|
256
|
+
return omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
|
|
195
257
|
}
|
|
196
258
|
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
197
259
|
if (!liveHas) {
|
|
@@ -399,16 +461,24 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
399
461
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
400
462
|
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
401
463
|
});
|
|
402
|
-
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
403
464
|
// #169: when relationships were sent, the canonical re-read is the published
|
|
404
465
|
// revision and is not proof an ERR field landed. Prefer rel:working-copy.
|
|
405
|
-
|
|
466
|
+
let fresh = await readWrittenRevision({
|
|
406
467
|
backend, entityType: "node", bundle: type, id,
|
|
407
468
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
408
469
|
patchResult: patched,
|
|
409
470
|
preferCanonical: true,
|
|
410
471
|
resourceVersion: patchTarget.resourceVersion,
|
|
411
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;
|
|
412
482
|
const withRevs = await attachWrittenRevisionPair({
|
|
413
483
|
backend, entityType: "node", bundle: type, id, entity: fresh, liveVid: patchTarget.liveVid,
|
|
414
484
|
});
|
|
@@ -524,8 +594,8 @@ export const definitions = [
|
|
|
524
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'." },
|
|
525
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." },
|
|
526
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." },
|
|
527
|
-
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." },
|
|
528
|
-
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." },
|
|
529
599
|
returning: RETURNING_SCHEMA,
|
|
530
600
|
},
|
|
531
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
|
},
|
package/src/tools/revisions.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
26
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
27
|
+
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
27
28
|
|
|
28
29
|
// Attributes that describe the entity's identity / revision bookkeeping / paths.
|
|
29
30
|
// These are read-only or server-managed and must NOT be replayed on a revert.
|
|
@@ -223,7 +224,7 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
|
|
|
223
224
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
224
225
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
225
226
|
});
|
|
226
|
-
const redacted = redactCanonicalEntity(entity, sec, "node");
|
|
227
|
+
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
|
|
227
228
|
return {
|
|
228
229
|
entityType: "node",
|
|
229
230
|
bundle: type,
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* with `langcode`. Reads of that draft are drupal_get_node / drupal_get_revision
|
|
13
13
|
* with `langcode`.
|
|
14
14
|
*
|
|
15
|
-
* Paragraph field-value translation
|
|
16
|
-
*
|
|
15
|
+
* Paragraph field-value translation uses the same surface with
|
|
16
|
+
* entityType "paragraph" and the pinned paragraph revision. Image alt is a
|
|
17
|
+
* node relationship (`meta.alt`) with the shared file target unchanged.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { getSiteConfig } from "../lib/config.js";
|
|
@@ -24,12 +25,14 @@ import {
|
|
|
24
25
|
} from "../lib/security.js";
|
|
25
26
|
import { validateUuid, validateMachineName } from "../lib/validate.js";
|
|
26
27
|
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
27
|
-
import {
|
|
28
|
+
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
28
29
|
import { entityRevisionId } from "../lib/write-revision.js";
|
|
30
|
+
import { paragraphRevisionId } from "../lib/err-relationships.js";
|
|
29
31
|
import {
|
|
30
32
|
assertDraftLangcode,
|
|
31
33
|
createTranslationDraft,
|
|
32
34
|
readTranslationInventory,
|
|
35
|
+
resolveNodeTranslationPair,
|
|
33
36
|
} from "../lib/draft-write.js";
|
|
34
37
|
|
|
35
38
|
const LIST_NOTE =
|
|
@@ -58,7 +61,7 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
58
61
|
assertReadAllowed(sec, entityType, type);
|
|
59
62
|
|
|
60
63
|
const backend = await resolveBackend(site);
|
|
61
|
-
if (entityType === "node" && typeof backend.rawQuery === "function") {
|
|
64
|
+
if ((entityType === "node" || entityType === "paragraph") && typeof backend.rawQuery === "function") {
|
|
62
65
|
try {
|
|
63
66
|
const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
|
|
64
67
|
const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
|
|
@@ -117,7 +120,8 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
117
120
|
* @returns {Promise<object>} The created translation, redacted.
|
|
118
121
|
*/
|
|
119
122
|
async function createTranslation({
|
|
120
|
-
site: siteName, entityType = "node", type, id, langcode, attributes = {},
|
|
123
|
+
site: siteName, entityType = "node", type, id, langcode, attributes = {},
|
|
124
|
+
relationships, revisionId, dryRun = false,
|
|
121
125
|
}) {
|
|
122
126
|
validateMachineName(entityType, "entityType");
|
|
123
127
|
validateMachineName(type, "type");
|
|
@@ -128,8 +132,8 @@ async function createTranslation({
|
|
|
128
132
|
const sec = resolveSecurityConfig(site);
|
|
129
133
|
assertWriteAllowed(sec, "update", entityType, type);
|
|
130
134
|
|
|
131
|
-
if (entityType !== "node") {
|
|
132
|
-
throw new Error("Governed translation create is implemented for nodes
|
|
135
|
+
if (entityType !== "node" && entityType !== "paragraph") {
|
|
136
|
+
throw new Error("Governed translation create is implemented for nodes and paragraphs.");
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
const backend = await resolveBackend(site);
|
|
@@ -137,46 +141,64 @@ async function createTranslation({
|
|
|
137
141
|
if (!existing) {
|
|
138
142
|
throw new Error("The entity was not found.");
|
|
139
143
|
}
|
|
140
|
-
const liveVid = entityRevisionId(existing);
|
|
141
|
-
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle: type, id });
|
|
142
|
-
const workingVid = entityRevisionId(workingCopy);
|
|
143
|
-
const sameWorking = workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid);
|
|
144
|
-
const draftRevision = {
|
|
145
|
-
liveVid,
|
|
146
|
-
workingVid: workingCopy && !sameWorking ? workingVid : undefined,
|
|
147
|
-
};
|
|
148
144
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
let draftRevision;
|
|
146
|
+
let drafted = { ...attributes };
|
|
147
|
+
delete drafted.langcode;
|
|
148
|
+
if (entityType === "paragraph") {
|
|
149
|
+
const pinned = revisionId ?? paragraphRevisionId(existing);
|
|
150
|
+
if (pinned === null || pinned === undefined || pinned === "") {
|
|
151
|
+
throw new Error("Paragraph translation create requires a paragraph revision ID (the host pin).");
|
|
152
|
+
}
|
|
153
|
+
draftRevision = { revisionId: pinned };
|
|
154
|
+
assertPublishAllowed(sec, drafted);
|
|
155
|
+
} else {
|
|
156
|
+
draftRevision = await resolveNodeTranslationPair(backend, {
|
|
157
|
+
entityType, bundle: type, id, existing,
|
|
158
|
+
});
|
|
159
|
+
if (drafted.status === undefined && drafted.moderation_state === undefined) {
|
|
160
|
+
drafted.moderation_state = "draft";
|
|
161
|
+
}
|
|
162
|
+
drafted = await applySafeDraftDefault({
|
|
163
|
+
backend, entityType, bundle: type, id, attributes: drafted, existingEntity: existing,
|
|
164
|
+
});
|
|
165
|
+
assertPublishAllowed(sec, drafted);
|
|
153
166
|
}
|
|
154
|
-
const drafted = await applySafeDraftDefault({
|
|
155
|
-
backend, entityType, bundle: type, id, attributes: safeAttributes, existingEntity: existing,
|
|
156
|
-
});
|
|
157
|
-
assertPublishAllowed(sec, drafted);
|
|
158
167
|
|
|
159
168
|
if (dryRun) {
|
|
160
169
|
await createTranslationDraft(backend, {
|
|
161
|
-
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, draftRevision,
|
|
170
|
+
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
|
|
162
171
|
}, true);
|
|
163
172
|
return {
|
|
164
173
|
dryRun: true, operation: "create_translation", entityType, bundle: type, id,
|
|
165
|
-
langcode: targetLang, attributes: drafted,
|
|
174
|
+
langcode: targetLang, attributes: drafted, ...(relationships ? { relationships } : {}),
|
|
166
175
|
};
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
const created = await createTranslationDraft(backend, {
|
|
170
|
-
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, draftRevision,
|
|
179
|
+
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
|
|
171
180
|
});
|
|
172
|
-
|
|
181
|
+
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
|
|
182
|
+
if (entityType !== "node") return redacted;
|
|
183
|
+
const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
|
|
184
|
+
const liveVid = draftRevision.liveVid;
|
|
185
|
+
if (liveVid === undefined || liveVid === null) {
|
|
186
|
+
if (workingVid === undefined || workingVid === null) return redacted;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
...redacted,
|
|
190
|
+
_revisions: {
|
|
191
|
+
...(liveVid !== undefined && liveVid !== null ? { live: liveVid } : {}),
|
|
192
|
+
...(workingVid !== undefined && workingVid !== null ? { working: workingVid } : {}),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
export const definitions = [
|
|
176
198
|
{
|
|
177
199
|
name: "drupal_list_translations",
|
|
178
200
|
description:
|
|
179
|
-
"List live and working translation langcodes for a Drupal node. Uses Sentinel's " +
|
|
201
|
+
"List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's " +
|
|
180
202
|
"translation inventory when available (live default revision vs unpublished working " +
|
|
181
203
|
"draft). Core JSON:API alone serves one language and cannot prove others are absent. " +
|
|
182
204
|
"Defaults to node.",
|
|
@@ -193,23 +215,30 @@ export const definitions = [
|
|
|
193
215
|
{
|
|
194
216
|
name: "drupal_create_translation",
|
|
195
217
|
description:
|
|
196
|
-
"Create a translation as an unpublished non-default draft
|
|
218
|
+
"Create a translation as an unpublished non-default draft (governed write). " +
|
|
197
219
|
"Adds the target language beside the default language; it does not PATCH langcode on " +
|
|
198
|
-
"the canonical entity. English
|
|
199
|
-
"
|
|
200
|
-
"draft
|
|
201
|
-
"
|
|
202
|
-
"
|
|
220
|
+
"the canonical entity. When an English working draft already exists, both live and " +
|
|
221
|
+
"working revision IDs are sent (If-Match) so Sentinel will add the language on that " +
|
|
222
|
+
"draft (#282). English live title, body, status, alias, default revision, and " +
|
|
223
|
+
"paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
|
|
224
|
+
"The response includes `_revisions.live` / `_revisions.working` when known. " +
|
|
225
|
+
"Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
|
|
226
|
+
"Continue a node draft with drupal_update_node and langcode; continue a paragraph with " +
|
|
227
|
+
"drupal_update_paragraph and langcode. Image alt is a relationship (same file UUID, " +
|
|
228
|
+
"meta.alt). For paragraphs pass revisionId as the host pin. Requires Sentinel's " +
|
|
229
|
+
"draft-translation endpoint. Publication stays denied for content-tier callers.",
|
|
203
230
|
inputSchema: {
|
|
204
231
|
type: "object", required: ["type", "id", "langcode"],
|
|
205
232
|
properties: {
|
|
206
|
-
site:
|
|
207
|
-
entityType:
|
|
208
|
-
type:
|
|
209
|
-
id:
|
|
210
|
-
langcode:
|
|
211
|
-
attributes:
|
|
212
|
-
|
|
233
|
+
site: { type: "string" },
|
|
234
|
+
entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values." },
|
|
235
|
+
type: { type: "string", description: "Bundle machine name, e.g. 'basic_page' or 'p_hero'" },
|
|
236
|
+
id: { type: "string", description: "Entity UUID" },
|
|
237
|
+
langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },
|
|
238
|
+
attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
|
|
239
|
+
relationships: { type: "object", description: "JSON:API relationships. Use for image alt (same file UUID, meta.alt)." },
|
|
240
|
+
revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required for Home-shaped non-default pins." },
|
|
241
|
+
dryRun: { type: "boolean", description: "Validate without saving" },
|
|
213
242
|
},
|
|
214
243
|
},
|
|
215
244
|
},
|