drupal-mcp-connector 2.16.0 → 2.17.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 +3 -3
- package/.agents/commands/drupal-get-media.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- package/.agents/commands/drupal-update-media.md +5 -4
- package/CHANGELOG.md +19 -0
- package/package.json +2 -1
- package/src/lib/canonical.js +10 -5
- package/src/lib/draft-write.js +5 -5
- package/src/lib/node-draft-inventory.js +3 -2
- package/src/tools/media.js +65 -8
- package/src/tools/translations.js +27 -19
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode;
|
|
2
|
+
description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
|
|
3
3
|
argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [relationships] [revisionId] [dryRun]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_create_translation`.
|
|
7
7
|
|
|
8
|
-
Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode;
|
|
8
|
+
Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -16,7 +16,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
19
|
-
- `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values.
|
|
19
|
+
- `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values, 'media' for media (name, caption, image alt).
|
|
20
20
|
- `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
|
|
21
21
|
- `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
|
|
22
22
|
- `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Fetch a single media entity by UUID and media type."
|
|
3
|
-
argument-hint: "<type> <id> [site]"
|
|
2
|
+
description: "Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel."
|
|
3
|
+
argument-hint: "<type> <id> [site] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_get_media`.
|
|
7
7
|
|
|
8
|
-
Fetch a single media entity by UUID and media type.
|
|
8
|
+
Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -15,5 +15,6 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
15
15
|
|
|
16
16
|
**Optional:**
|
|
17
17
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
18
|
+
- `langcode` (string): Target language (e.g. 'es') to read the unpublished working translation instead of the default language.
|
|
18
19
|
|
|
19
20
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "List live and working translation langcodes for a Drupal node or
|
|
2
|
+
description: "List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node."
|
|
3
3
|
argument-hint: "<type> <id> [site] [entityType]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_list_translations`.
|
|
7
7
|
|
|
8
|
-
List live and working translation langcodes for a Drupal node or
|
|
8
|
+
List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft), including core content_translation_outdated and source when the server sends them. Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -15,6 +15,6 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
15
15
|
|
|
16
16
|
**Optional:**
|
|
17
17
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
18
|
-
- `entityType` (string): Entity type machine name. Default: 'node'.
|
|
18
|
+
- `entityType` (string): Entity type machine name. Default: 'node'. Use 'paragraph' or 'media' when listing those.
|
|
19
19
|
|
|
20
20
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched."
|
|
3
|
-
argument-hint: "<type> <id> [site] [name] [status] [fields]"
|
|
2
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first."
|
|
3
|
+
argument-hint: "<type> <id> [site] [name] [status] [fields] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_media`.
|
|
7
7
|
|
|
8
|
-
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.
|
|
8
|
+
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -17,6 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
17
17
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
18
18
|
- `name` (string)
|
|
19
19
|
- `status` (boolean (true/false)): Published flag. Only sent when provided; requires allowPublish when true.
|
|
20
|
-
- `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
|
|
20
|
+
- `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically. Image alt is meta.alt on field_media_image with the existing file UUID.
|
|
21
|
+
- `langcode` (string): 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.
|
|
21
22
|
|
|
22
23
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.17.0] - 2026-09-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Governed media draft translation (#296).** `drupal_create_translation` and
|
|
14
|
+
`drupal_list_translations` accept `entityType: "media"`. Continue with
|
|
15
|
+
`drupal_update_media` + `langcode`; read with `drupal_get_media` + `langcode`.
|
|
16
|
+
Same Sentinel `/mcp-draft` live/working pair as nodes (d.o #3622412). Image
|
|
17
|
+
alt/title are kept on the file relationship (`meta.alt` / `meta.title`) so a
|
|
18
|
+
translator can read the source. Canonical media writes without `langcode` are
|
|
19
|
+
unchanged. Requires MCP Sentinel with media draft routes.
|
|
20
|
+
|
|
21
|
+
### Security
|
|
22
|
+
- Bump transitive `hono` 4.13.1 → 4.13.7 (via `@modelcontextprotocol/node`)
|
|
23
|
+
and override it to `^4.13.5`, clearing three moderate advisories:
|
|
24
|
+
GHSA-gqvv-2mrq-wpjv (`toSSG()` path traversal), GHSA-g6gw-c38x-mqfc
|
|
25
|
+
(`parseBody()` unbounded nesting), and GHSA-crvj-82cr-hjcx (query parser
|
|
26
|
+
after URL fragment). `npm audit` reports 0 vulnerabilities (#299).
|
|
27
|
+
|
|
10
28
|
## [2.16.0] - 2026-09-11
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -1620,6 +1638,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1620
1638
|
- User tools gained explicit PII-access assertions.
|
|
1621
1639
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1622
1640
|
|
|
1641
|
+
[2.17.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.17.0
|
|
1623
1642
|
[2.16.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.16.0
|
|
1624
1643
|
[2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2
|
|
1625
1644
|
[2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"overrides": {
|
|
85
85
|
"@hono/node-server": "^2.0.5",
|
|
86
|
+
"hono": "^4.13.5",
|
|
86
87
|
"postcss": "^8.5.18"
|
|
87
88
|
}
|
|
88
89
|
}
|
package/src/lib/canonical.js
CHANGED
|
@@ -65,14 +65,19 @@ export function normalizeRelationship(ref) {
|
|
|
65
65
|
// JSON:API encodes type as "entityType--bundle"; split into the two parts.
|
|
66
66
|
const [entityType = null, bundle = null] = (ref.type || "").split("--");
|
|
67
67
|
const out = { id: ref.id, entityType, bundle };
|
|
68
|
-
// ERR identifiers carry the revision id in JSON:API `meta` (#192).
|
|
69
|
-
//
|
|
70
|
-
//
|
|
68
|
+
// ERR identifiers carry the revision id in JSON:API `meta` (#192). Image
|
|
69
|
+
// fields carry alt/title the same way (#296). Dropping either made a
|
|
70
|
+
// canonical re-read look like a plain {id, type}.
|
|
71
71
|
if (ref.meta && typeof ref.meta === "object") {
|
|
72
|
-
const
|
|
72
|
+
const entries = new Map(Object.entries(ref.meta));
|
|
73
|
+
const meta = {};
|
|
74
|
+
const vid = entries.get("target_revision_id");
|
|
73
75
|
if (vid !== undefined && vid !== null && vid !== "") {
|
|
74
|
-
|
|
76
|
+
meta.target_revision_id = vid;
|
|
75
77
|
}
|
|
78
|
+
if (typeof entries.get("alt") === "string") meta.alt = entries.get("alt");
|
|
79
|
+
if (typeof entries.get("title") === "string") meta.title = entries.get("title");
|
|
80
|
+
if (Object.keys(meta).length > 0) out.meta = meta;
|
|
76
81
|
}
|
|
77
82
|
return out;
|
|
78
83
|
}
|
package/src/lib/draft-write.js
CHANGED
|
@@ -46,7 +46,7 @@ function requireWorkingPair(draftRevision) {
|
|
|
46
46
|
const live = String(draftRevision?.liveVid ?? "");
|
|
47
47
|
const working = String(draftRevision?.workingVid ?? "");
|
|
48
48
|
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
49
|
-
throw new Error("Draft continuation requires distinct, verified live and working
|
|
49
|
+
throw new Error("Draft continuation requires distinct, verified live and working revision IDs.");
|
|
50
50
|
}
|
|
51
51
|
return { live, working };
|
|
52
52
|
}
|
|
@@ -59,8 +59,8 @@ function requireWorkingPair(draftRevision) {
|
|
|
59
59
|
* @returns {string}
|
|
60
60
|
*/
|
|
61
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
|
|
62
|
+
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
63
|
+
throw new Error("Governed draft translation is implemented for nodes, paragraphs, and media.");
|
|
64
64
|
}
|
|
65
65
|
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
66
66
|
throw new Error("This backend does not support governed draft continuation.");
|
|
@@ -150,8 +150,8 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
150
150
|
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
151
151
|
? ""
|
|
152
152
|
: String(workingRaw);
|
|
153
|
-
if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
-
throw new Error("Translation create requires a verified live
|
|
153
|
+
if ((entityType !== "node" && entityType !== "media") || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
+
throw new Error("Translation create requires a verified live revision ID.");
|
|
155
155
|
}
|
|
156
156
|
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
157
157
|
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
@@ -9,7 +9,8 @@ import { readTranslationInventory } from "./draft-write.js";
|
|
|
9
9
|
* @returns {Promise<object|null>}
|
|
10
10
|
*/
|
|
11
11
|
export async function readNodeDraftInventory(backend, ref) {
|
|
12
|
-
if (ref.entityType !== "node"
|
|
12
|
+
if ((ref.entityType !== "node" && ref.entityType !== "media")
|
|
13
|
+
|| typeof backend.rawQuery !== "function"
|
|
13
14
|
|| typeof backend.resourcePath !== "function") return null;
|
|
14
15
|
let inventory;
|
|
15
16
|
try {
|
|
@@ -24,7 +25,7 @@ export async function readNodeDraftInventory(backend, ref) {
|
|
|
24
25
|
|| !Array.isArray(inventory.working.translations)
|
|
25
26
|
|| inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
|
|
26
27
|
|| typeof row.status !== "boolean")))) {
|
|
27
|
-
throw new Error("Sentinel returned an invalid
|
|
28
|
+
throw new Error("Sentinel returned an invalid revision inventory. Re-read before updating.");
|
|
28
29
|
}
|
|
29
30
|
return inventory;
|
|
30
31
|
}
|
package/src/tools/media.js
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
15
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
16
16
|
} from "../lib/security.js";
|
|
17
|
+
import {
|
|
18
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory, writeDraft,
|
|
19
|
+
} from "../lib/draft-write.js";
|
|
20
|
+
import { assertInventoryDraftLanguage } from "../lib/node-draft-inventory.js";
|
|
21
|
+
import { entityRevisionId } from "../lib/write-revision.js";
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* List all media types (bundles of the media entity type).
|
|
@@ -51,14 +56,39 @@ async function listMedia({ site: siteName, type, status, name, limit = 20, offse
|
|
|
51
56
|
|
|
52
57
|
/**
|
|
53
58
|
* Fetch a single media entity by UUID, redacted per policy.
|
|
54
|
-
*
|
|
59
|
+
* Pass langcode to read an unpublished working translation via Sentinel.
|
|
60
|
+
* @param {object} args - { site?, type, id, langcode? }.
|
|
55
61
|
* @returns {Promise<object|null>} The redacted media entity, or null.
|
|
56
62
|
*/
|
|
57
|
-
async function getMedia({ site: siteName, type, id }) {
|
|
63
|
+
async function getMedia({ site: siteName, type, id, langcode }) {
|
|
58
64
|
const site = getSiteConfig(siteName);
|
|
59
65
|
const sec = resolveSecurityConfig(site);
|
|
60
66
|
assertReadAllowed(sec, "media", type);
|
|
61
67
|
const backend = await resolveBackend(site);
|
|
68
|
+
if (langcode) {
|
|
69
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
70
|
+
const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
|
|
71
|
+
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
72
|
+
if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
|
|
73
|
+
const entity = await readDraftTranslation(backend, {
|
|
74
|
+
entityType: "media", bundle: type, id, langcode: targetLang,
|
|
75
|
+
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
76
|
+
});
|
|
77
|
+
return redactCanonicalEntity(entity, sec, "media");
|
|
78
|
+
}
|
|
79
|
+
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
80
|
+
if (!liveHas) return null;
|
|
81
|
+
if (targetLang !== inventory.defaultLangcode) {
|
|
82
|
+
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
83
|
+
return {
|
|
84
|
+
id, entityType: "media", bundle: type, langcode: targetLang,
|
|
85
|
+
status: liveRow?.status ?? null,
|
|
86
|
+
fields: { name: liveRow?.name ?? null },
|
|
87
|
+
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
88
|
+
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.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
62
92
|
const entity = await backend.getEntity({ entityType: "media", bundle: type, id });
|
|
63
93
|
return entity ? redactCanonicalEntity(entity, sec, "media") : null;
|
|
64
94
|
}
|
|
@@ -91,12 +121,13 @@ async function createMedia({ site: siteName, type, name, status = false, fields
|
|
|
91
121
|
/**
|
|
92
122
|
* Update a media entity (partial — omitted fields are left untouched; `status`
|
|
93
123
|
* is strictly opt-in, #171). Reference-shaped `fields` values are routed to
|
|
94
|
-
* relationships — see splitReferenceFields.
|
|
124
|
+
* relationships — see splitReferenceFields. Pass langcode to continue an
|
|
125
|
+
* unpublished working translation via Sentinel (not a canonical live PATCH).
|
|
95
126
|
*
|
|
96
|
-
* @param {object} args - { site?, type, id, name?, status?, fields? }.
|
|
127
|
+
* @param {object} args - { site?, type, id, name?, status?, fields?, langcode? }.
|
|
97
128
|
* @returns {Promise<object>} The updated media descriptor.
|
|
98
129
|
*/
|
|
99
|
-
async function updateMedia({ site: siteName, type, id, name, status, fields = {} }) {
|
|
130
|
+
async function updateMedia({ site: siteName, type, id, name, status, fields = {}, langcode }) {
|
|
100
131
|
const site = getSiteConfig(siteName);
|
|
101
132
|
const sec = resolveSecurityConfig(site);
|
|
102
133
|
assertWriteAllowed(sec, "update", "media", type);
|
|
@@ -105,6 +136,30 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
|
|
|
105
136
|
if (status !== undefined) attributes.status = status;
|
|
106
137
|
assertPublishAllowed(sec, attributes);
|
|
107
138
|
const backend = await resolveBackend(site);
|
|
139
|
+
if (langcode) {
|
|
140
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
141
|
+
const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
|
|
142
|
+
assertInventoryDraftLanguage(inventory, targetLang);
|
|
143
|
+
const liveVid = inventory.live?.vid;
|
|
144
|
+
const workingVid = inventory.working?.vid;
|
|
145
|
+
if (!liveVid || !workingVid || String(workingVid) === String(liveVid)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"No unpublished working translation for this language. " +
|
|
148
|
+
"Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const result = await writeDraft(backend, {
|
|
152
|
+
entityType: "media", bundle: type, id, attributes, langcode: targetLang,
|
|
153
|
+
...(relationships ? { relationships } : {}),
|
|
154
|
+
draftRevision: { liveVid, workingVid },
|
|
155
|
+
});
|
|
156
|
+
const redacted = redactCanonicalEntity(result, sec, "media");
|
|
157
|
+
const working = entityRevisionId(result) ?? workingVid;
|
|
158
|
+
return {
|
|
159
|
+
...redacted,
|
|
160
|
+
_revisions: { live: liveVid, working },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
108
163
|
// #171: pre-read so an unrequested published-state flip is reported, not silent.
|
|
109
164
|
let existing = null;
|
|
110
165
|
if (status === undefined) {
|
|
@@ -241,13 +296,14 @@ export const definitions = [
|
|
|
241
296
|
},
|
|
242
297
|
{
|
|
243
298
|
name: "drupal_get_media",
|
|
244
|
-
description: "Fetch a single media entity by UUID and media type.",
|
|
299
|
+
description: "Fetch a single media entity by UUID and media type. Image alt/title are on the file relationship (meta.alt / meta.title). Pass langcode to read an unpublished working translation via Sentinel.",
|
|
245
300
|
inputSchema: {
|
|
246
301
|
type: "object", required: ["type", "id"],
|
|
247
302
|
properties: {
|
|
248
303
|
site: { type: "string" },
|
|
249
304
|
type: { type: "string" },
|
|
250
305
|
id: { type: "string", description: "Media entity UUID" },
|
|
306
|
+
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
251
307
|
},
|
|
252
308
|
},
|
|
253
309
|
},
|
|
@@ -267,7 +323,7 @@ export const definitions = [
|
|
|
267
323
|
},
|
|
268
324
|
{
|
|
269
325
|
name: "drupal_update_media",
|
|
270
|
-
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.",
|
|
326
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.",
|
|
271
327
|
inputSchema: {
|
|
272
328
|
type: "object", required: ["type", "id"],
|
|
273
329
|
properties: {
|
|
@@ -276,7 +332,8 @@ export const definitions = [
|
|
|
276
332
|
id: { type: "string" },
|
|
277
333
|
name: { type: "string" },
|
|
278
334
|
status: { type: "boolean", description: "Published flag. Only sent when provided; requires allowPublish when true." },
|
|
279
|
-
fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
|
|
335
|
+
fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically. Image alt is meta.alt on field_media_image with the existing file UUID." },
|
|
336
|
+
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." },
|
|
280
337
|
},
|
|
281
338
|
},
|
|
282
339
|
},
|
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
* - drupal_create_translation — add a language as an unpublished forward draft
|
|
10
10
|
*
|
|
11
11
|
* Continuation of an existing unpublished translation is drupal_update_node
|
|
12
|
-
* with `langcode
|
|
12
|
+
* with `langcode` (media: drupal_update_media; paragraph: drupal_update_paragraph).
|
|
13
|
+
* Reads of that draft are drupal_get_node / drupal_get_media / drupal_get_paragraph
|
|
13
14
|
* with `langcode`.
|
|
14
15
|
*
|
|
15
16
|
* Paragraph field-value translation uses the same surface with
|
|
16
|
-
* entityType "paragraph" and the pinned paragraph revision.
|
|
17
|
-
* node
|
|
17
|
+
* entityType "paragraph" and the pinned paragraph revision. Media uses the
|
|
18
|
+
* node live/working pair (`entityType: "media"`). Image alt on a media entity
|
|
19
|
+
* is the file relationship (`field_media_image` + `meta.alt`) with the shared
|
|
20
|
+
* file target unchanged.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { getSiteConfig } from "../lib/config.js";
|
|
@@ -62,7 +65,8 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
62
65
|
assertReadAllowed(sec, entityType, type);
|
|
63
66
|
|
|
64
67
|
const backend = await resolveBackend(site);
|
|
65
|
-
if ((entityType === "node" || entityType === "paragraph"
|
|
68
|
+
if ((entityType === "node" || entityType === "paragraph" || entityType === "media")
|
|
69
|
+
&& typeof backend.rawQuery === "function") {
|
|
66
70
|
try {
|
|
67
71
|
const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
|
|
68
72
|
const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
|
|
@@ -127,8 +131,8 @@ async function createTranslation({
|
|
|
127
131
|
const sec = resolveSecurityConfig(site);
|
|
128
132
|
assertWriteAllowed(sec, "update", entityType, type);
|
|
129
133
|
|
|
130
|
-
if (entityType !== "node" && entityType !== "paragraph") {
|
|
131
|
-
throw new Error("Governed translation create is implemented for nodes and
|
|
134
|
+
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
135
|
+
throw new Error("Governed translation create is implemented for nodes, paragraphs, and media.");
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
const backend = await resolveBackend(site);
|
|
@@ -151,12 +155,14 @@ async function createTranslation({
|
|
|
151
155
|
draftRevision = await resolveNodeTranslationPair(backend, {
|
|
152
156
|
entityType, bundle: type, id, existing,
|
|
153
157
|
});
|
|
154
|
-
if (
|
|
155
|
-
drafted.moderation_state
|
|
158
|
+
if (entityType === "node") {
|
|
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
|
+
});
|
|
156
165
|
}
|
|
157
|
-
drafted = await applySafeDraftDefault({
|
|
158
|
-
backend, entityType, bundle: type, id, attributes: drafted, existingEntity: existing,
|
|
159
|
-
});
|
|
160
166
|
assertPublishAllowed(sec, drafted);
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -174,7 +180,7 @@ async function createTranslation({
|
|
|
174
180
|
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
|
|
175
181
|
});
|
|
176
182
|
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
|
|
177
|
-
if (entityType
|
|
183
|
+
if (entityType === "paragraph") return redacted;
|
|
178
184
|
const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
|
|
179
185
|
const liveVid = draftRevision.liveVid;
|
|
180
186
|
if (liveVid === undefined || liveVid === null) {
|
|
@@ -193,7 +199,7 @@ export const definitions = [
|
|
|
193
199
|
{
|
|
194
200
|
name: "drupal_list_translations",
|
|
195
201
|
description:
|
|
196
|
-
"List live and working translation langcodes for a Drupal node or
|
|
202
|
+
"List live and working translation langcodes for a Drupal node, paragraph, or media entity. Uses Sentinel's " +
|
|
197
203
|
"translation inventory when available (live default revision vs unpublished working " +
|
|
198
204
|
"draft), including core content_translation_outdated and source when the server sends them. " +
|
|
199
205
|
"Core JSON:API alone serves one language and cannot prove others are absent. " +
|
|
@@ -202,7 +208,7 @@ export const definitions = [
|
|
|
202
208
|
type: "object", required: ["type", "id"],
|
|
203
209
|
properties: {
|
|
204
210
|
site: { type: "string", description: "Named site (omit for default)" },
|
|
205
|
-
entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
|
|
211
|
+
entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' or 'media' when listing those." },
|
|
206
212
|
type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
|
|
207
213
|
id: { type: "string", description: "Entity UUID" },
|
|
208
214
|
},
|
|
@@ -219,15 +225,17 @@ export const definitions = [
|
|
|
219
225
|
"paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
|
|
220
226
|
"The response includes `_revisions.live` / `_revisions.working` when known. " +
|
|
221
227
|
"Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
|
|
222
|
-
"Continue a node draft with drupal_update_node and langcode;
|
|
223
|
-
"drupal_update_paragraph and langcode
|
|
224
|
-
"
|
|
225
|
-
"
|
|
228
|
+
"Continue a node draft with drupal_update_node and langcode; a paragraph with " +
|
|
229
|
+
"drupal_update_paragraph and langcode; media with drupal_update_media and langcode. " +
|
|
230
|
+
"Image alt is a relationship (same file UUID, meta.alt) — on media that is " +
|
|
231
|
+
"field_media_image, not a node image field. For paragraphs pass revisionId as the " +
|
|
232
|
+
"host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied " +
|
|
233
|
+
"for content-tier callers.",
|
|
226
234
|
inputSchema: {
|
|
227
235
|
type: "object", required: ["type", "id", "langcode"],
|
|
228
236
|
properties: {
|
|
229
237
|
site: { type: "string" },
|
|
230
|
-
entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values." },
|
|
238
|
+
entityType: { type: "string", description: "Entity type machine name. Default: 'node'. Use 'paragraph' for paragraph field values, 'media' for media (name, caption, image alt)." },
|
|
231
239
|
type: { type: "string", description: "Bundle machine name, e.g. 'basic_page' or 'p_hero'" },
|
|
232
240
|
id: { type: "string", description: "Entity UUID" },
|
|
233
241
|
langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },
|