drupal-mcp-connector 2.15.2 → 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-codegen-diff.md +17 -0
- package/.agents/commands/drupal-codegen-generate.md +17 -0
- package/.agents/commands/drupal-codegen-inspect.md +17 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +4 -3
- package/.agents/commands/drupal-create-translation.md +3 -3
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-get-media.md +4 -3
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- package/.agents/commands/drupal-report-translation-coverage.md +4 -5
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +2 -1
- package/.agents/commands/drupal-set-moderation-state.md +4 -3
- package/.agents/commands/drupal-update-media.md +5 -4
- package/.agents/commands/drupal-update-menu-link.md +2 -1
- package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
- package/CHANGELOG.md +57 -0
- package/README.md +3 -3
- package/bin/drupal-mcp-agent.js +2 -2
- package/package.json +3 -2
- package/scripts/generate-commands.js +2 -2
- package/src/index.js +2 -2
- package/src/lib/backends/jsonapi.js +85 -9
- package/src/lib/canonical.js +10 -5
- package/src/lib/config.js +18 -1
- package/src/lib/draft-write.js +5 -5
- package/src/lib/err-relationships.js +22 -0
- package/src/lib/mcp-server.js +1 -1
- package/src/lib/node-draft-inventory.js +3 -2
- package/src/lib/server-tools.js +2 -2
- package/src/lib/translation-rows.js +59 -0
- package/src/tools/codegen.js +150 -0
- package/src/tools/fields.js +12 -4
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +65 -8
- package/src/tools/moderation.js +82 -13
- package/src/tools/nodes.js +56 -6
- package/src/tools/reports-content.js +117 -28
- package/src/tools/structure.js +6 -3
- package/src/tools/taxonomy.js +15 -6
- package/src/tools/translations.js +31 -27
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
|
},
|
package/src/tools/moderation.js
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
redactCanonicalEntity,
|
|
22
22
|
} from "../lib/security.js";
|
|
23
23
|
import { collectEntities } from "../lib/reports-support.js";
|
|
24
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
25
|
+
import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
|
|
26
|
+
import { inventoryRowMatching } from "../lib/translation-rows.js";
|
|
24
27
|
|
|
25
28
|
/** Cap for the client-side scan when JSON:API cannot filter the field. */
|
|
26
29
|
const SAMPLE_CAP = 500;
|
|
@@ -54,7 +57,7 @@ function moderationStateOf(entity) {
|
|
|
54
57
|
* @returns {Promise<object>} The updated, redacted node.
|
|
55
58
|
* @throws {SecurityError} If writing node/type is not permitted.
|
|
56
59
|
*/
|
|
57
|
-
async function setModerationState({ site: siteName, type, id, state }) {
|
|
60
|
+
async function setModerationState({ site: siteName, type, id, state, langcode }) {
|
|
58
61
|
if (!state) throw new Error("A moderation 'state' is required (e.g. 'draft', 'published').");
|
|
59
62
|
const site = getSiteConfig(siteName);
|
|
60
63
|
const sec = resolveSecurityConfig(site);
|
|
@@ -62,6 +65,23 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
62
65
|
const attributes = { moderation_state: state };
|
|
63
66
|
assertPublishAllowed(sec, attributes);
|
|
64
67
|
const backend = await resolveBackend(site);
|
|
68
|
+
if (langcode) {
|
|
69
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
70
|
+
let existing = null;
|
|
71
|
+
try {
|
|
72
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
73
|
+
} catch {
|
|
74
|
+
existing = null;
|
|
75
|
+
}
|
|
76
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
77
|
+
entityType: "node", bundle: type, id, existing, attributes, langcode: targetLang,
|
|
78
|
+
});
|
|
79
|
+
const patched = await updateEntityGuarded(backend, {
|
|
80
|
+
entityType: "node", bundle: type, id, attributes, langcode: targetLang,
|
|
81
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
82
|
+
});
|
|
83
|
+
return redactCanonicalEntity(patched, sec, "node");
|
|
84
|
+
}
|
|
65
85
|
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
66
86
|
return redactCanonicalEntity(entity, sec, "node");
|
|
67
87
|
}
|
|
@@ -76,12 +96,59 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
76
96
|
*
|
|
77
97
|
* @param {object} args - { site?, type, state, limit?, offset? }.
|
|
78
98
|
*/
|
|
79
|
-
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
|
|
99
|
+
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0, langcode }) {
|
|
80
100
|
const site = getSiteConfig(siteName);
|
|
81
101
|
const sec = resolveSecurityConfig(site);
|
|
82
102
|
assertReadAllowed(sec, "node", type);
|
|
83
103
|
const backend = await resolveBackend(site);
|
|
84
104
|
const sort = [{ field: "changed", dir: "desc" }];
|
|
105
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
106
|
+
if (targetLang) {
|
|
107
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
108
|
+
return {
|
|
109
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
110
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const scanned = await collectEntities(
|
|
114
|
+
backend,
|
|
115
|
+
{ entityType: "node", bundle: type, sort },
|
|
116
|
+
SAMPLE_CAP,
|
|
117
|
+
);
|
|
118
|
+
const matches = [];
|
|
119
|
+
for (const entity of scanned) {
|
|
120
|
+
try {
|
|
121
|
+
const inventory = await readTranslationInventory(backend, {
|
|
122
|
+
entityType: "node", bundle: type, id: entity.id,
|
|
123
|
+
});
|
|
124
|
+
const row = inventoryRowMatching(inventory, { langcode: targetLang, state });
|
|
125
|
+
if (row) {
|
|
126
|
+
matches.push({
|
|
127
|
+
...entity,
|
|
128
|
+
langcode: targetLang,
|
|
129
|
+
fields: { ...entity.fields, moderation_state: row.moderation_state },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
|
|
134
|
+
return {
|
|
135
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
136
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const page = matches.slice(offset, offset + limit);
|
|
143
|
+
return {
|
|
144
|
+
type, state, langcode: targetLang, source: "inventory",
|
|
145
|
+
approximate: scanned.length >= SAMPLE_CAP,
|
|
146
|
+
scanned: scanned.length,
|
|
147
|
+
total: matches.length,
|
|
148
|
+
offset, nextOffset: offset + page.length,
|
|
149
|
+
nodes: page.map((e) => redactCanonicalEntity(e, sec, "node")),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
85
152
|
const canFilter = typeof backend.capabilities === "function"
|
|
86
153
|
? Boolean(backend.capabilities()?.filter)
|
|
87
154
|
: true;
|
|
@@ -167,28 +234,30 @@ async function listModerationStates({ site: siteName, type, sample = 50 }) {
|
|
|
167
234
|
export const definitions = [
|
|
168
235
|
{
|
|
169
236
|
name: "drupal_set_moderation_state",
|
|
170
|
-
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write.",
|
|
237
|
+
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write. Pass langcode to change one translation via Sentinel; omit it for the default-language / shared-state write. If moderation_state is not translatable, a langcode write is refused.",
|
|
171
238
|
inputSchema: {
|
|
172
239
|
type: "object", required: ["type", "id", "state"],
|
|
173
240
|
properties: {
|
|
174
|
-
site:
|
|
175
|
-
type:
|
|
176
|
-
id:
|
|
177
|
-
state:
|
|
241
|
+
site: { type: "string" },
|
|
242
|
+
type: { type: "string", description: "Content type machine name" },
|
|
243
|
+
id: { type: "string", description: "Node UUID" },
|
|
244
|
+
state: { type: "string", description: "Target moderation state machine name" },
|
|
245
|
+
langcode: { type: "string", description: "Target translation (e.g. 'es'). Omit for the default language. Requires Sentinel." },
|
|
178
246
|
},
|
|
179
247
|
},
|
|
180
248
|
},
|
|
181
249
|
{
|
|
182
250
|
name: "drupal_content_by_moderation_state",
|
|
183
|
-
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
251
|
+
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Pass langcode to match that translation via Sentinel inventory (the editorial work queue). Omit langcode for default-language JSON:API / sampled behavior. Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
184
252
|
inputSchema: {
|
|
185
253
|
type: "object", required: ["type", "state"],
|
|
186
254
|
properties: {
|
|
187
|
-
site:
|
|
188
|
-
type:
|
|
189
|
-
state:
|
|
190
|
-
|
|
191
|
-
|
|
255
|
+
site: { type: "string" },
|
|
256
|
+
type: { type: "string", description: "Content type machine name" },
|
|
257
|
+
state: { type: "string", description: "Moderation state machine name" },
|
|
258
|
+
langcode: { type: "string", description: "Match this translation (e.g. 'es'). Requires Sentinel. Omit for default-language listing." },
|
|
259
|
+
limit: { type: "number", default: 20 },
|
|
260
|
+
offset: { type: "number", default: 0 },
|
|
192
261
|
},
|
|
193
262
|
},
|
|
194
263
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
} from "../lib/security.js";
|
|
16
16
|
import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
|
|
17
17
|
import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
18
|
-
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
18
|
+
import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } 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
21
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
22
|
+
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
22
23
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
23
24
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
24
25
|
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
@@ -239,7 +240,51 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
239
240
|
* @param {object} args - { site?, type, id }.
|
|
240
241
|
* @returns {Promise<object|null>} The redacted node, or null if not found.
|
|
241
242
|
*/
|
|
242
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Load pinned paragraph translations (or default-language pins) onto a host.
|
|
245
|
+
* Per-component failures are recorded; they do not fail the host read.
|
|
246
|
+
* @param {object} backend
|
|
247
|
+
* @param {object} sec
|
|
248
|
+
* @param {object} entity
|
|
249
|
+
* @param {?string} langcode
|
|
250
|
+
* @returns {Promise<object>}
|
|
251
|
+
*/
|
|
252
|
+
async function withComponents(backend, sec, entity, langcode) {
|
|
253
|
+
const pins = paragraphPinsFromEntity(entity);
|
|
254
|
+
const components = [];
|
|
255
|
+
for (const pin of pins) {
|
|
256
|
+
try {
|
|
257
|
+
let para;
|
|
258
|
+
if (langcode && pin.revisionId) {
|
|
259
|
+
para = await readDraftTranslation(backend, {
|
|
260
|
+
entityType: "paragraph",
|
|
261
|
+
bundle: pin.paragraphType,
|
|
262
|
+
id: pin.id,
|
|
263
|
+
langcode,
|
|
264
|
+
draftRevision: { revisionId: pin.revisionId },
|
|
265
|
+
});
|
|
266
|
+
} else if (pin.revisionId) {
|
|
267
|
+
para = await backend.getEntity({
|
|
268
|
+
entityType: "paragraph",
|
|
269
|
+
bundle: pin.paragraphType,
|
|
270
|
+
id: pin.id,
|
|
271
|
+
resourceVersion: paragraphResourceVersion(pin.revisionId),
|
|
272
|
+
...(langcode ? { langcode } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
components.push({
|
|
276
|
+
...pin,
|
|
277
|
+
entity: para ? redactCanonicalEntity(para, sec, "paragraph") : null,
|
|
278
|
+
...(para ? {} : { note: "missing revision pin" }),
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
components.push({ ...pin, entity: null, error: String(error.message || error) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { ...entity, components };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function getNode({ site: siteName, type, id, langcode, resourceVersion, includeComponents = false }) {
|
|
243
288
|
const site = getSiteConfig(siteName);
|
|
244
289
|
const sec = resolveSecurityConfig(site);
|
|
245
290
|
assertReadAllowed(sec, "node", type);
|
|
@@ -253,7 +298,8 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
253
298
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
254
299
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
255
300
|
});
|
|
256
|
-
|
|
301
|
+
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
|
|
302
|
+
return includeComponents ? withComponents(backend, sec, redacted, targetLang) : redacted;
|
|
257
303
|
}
|
|
258
304
|
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
259
305
|
if (!liveHas) {
|
|
@@ -261,7 +307,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
261
307
|
}
|
|
262
308
|
if (targetLang !== inventory.defaultLangcode) {
|
|
263
309
|
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
264
|
-
|
|
310
|
+
const stub = {
|
|
265
311
|
id, entityType: "node", bundle: type, langcode: targetLang,
|
|
266
312
|
title: liveRow?.title ?? null,
|
|
267
313
|
status: liveRow?.status ?? null,
|
|
@@ -269,10 +315,13 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
|
|
|
269
315
|
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
270
316
|
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
317
|
};
|
|
318
|
+
return includeComponents ? { ...stub, components: [] } : stub;
|
|
272
319
|
}
|
|
273
320
|
}
|
|
274
321
|
const entity = await backend.getEntity({ entityType: "node", bundle: type, id, resourceVersion });
|
|
275
|
-
|
|
322
|
+
if (!entity) return null;
|
|
323
|
+
const redacted = redactCanonicalEntity(entity, sec, "node");
|
|
324
|
+
return includeComponents ? withComponents(backend, sec, redacted, langcode || null) : redacted;
|
|
276
325
|
}
|
|
277
326
|
|
|
278
327
|
/**
|
|
@@ -520,7 +569,7 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
520
569
|
export const definitions = [
|
|
521
570
|
{
|
|
522
571
|
name: "drupal_get_node",
|
|
523
|
-
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).",
|
|
572
|
+
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). Pass includeComponents true to attach pinned paragraph translations under `components` (default false).",
|
|
524
573
|
inputSchema: {
|
|
525
574
|
type: "object", required: ["type", "id"],
|
|
526
575
|
properties: {
|
|
@@ -528,6 +577,7 @@ export const definitions = [
|
|
|
528
577
|
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
529
578
|
id: { type: "string", description: "Node UUID" },
|
|
530
579
|
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
580
|
+
includeComponents: { type: "boolean", default: false, description: "If true, include pinned paragraph translations under `components` (empty array when the host has no ERR fields). Default false so existing callers are unchanged." },
|
|
531
581
|
},
|
|
532
582
|
},
|
|
533
583
|
},
|
|
@@ -17,6 +17,8 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
17
17
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
18
18
|
import { collectEntities, fieldValue, daysSince } from "../lib/reports-support.js";
|
|
19
19
|
import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
|
|
20
|
+
import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
|
|
21
|
+
import { inventoryRowMatching, inventoryTranslationRows, mapTranslationRow } from "../lib/translation-rows.js";
|
|
20
22
|
|
|
21
23
|
// ---------------------------------------------------------------------------
|
|
22
24
|
// Shared text helpers
|
|
@@ -99,13 +101,14 @@ async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
99
101
|
* @param {object} args - { site?, type?, days?, states?, sampleSize? }.
|
|
100
102
|
* @returns {Promise<object>} Stuck-content findings.
|
|
101
103
|
*/
|
|
102
|
-
async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200 }) {
|
|
104
|
+
async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200, langcode }) {
|
|
103
105
|
const site = getSiteConfig(siteName);
|
|
104
106
|
const sec = resolveSecurityConfig(site);
|
|
105
107
|
assertReadAllowed(sec, "node", type);
|
|
106
108
|
const backend = await resolveBackend(site);
|
|
107
109
|
const contentType = type || "article";
|
|
108
110
|
const targetStates = (states && states.length ? states : ["draft", "needs_review", "review"]).map((s) => s.toLowerCase());
|
|
111
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
109
112
|
|
|
110
113
|
const nodes = await collectEntities(
|
|
111
114
|
backend,
|
|
@@ -113,17 +116,56 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
113
116
|
sampleSize
|
|
114
117
|
);
|
|
115
118
|
|
|
119
|
+
if (targetLang && (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function")) {
|
|
120
|
+
return {
|
|
121
|
+
contentType,
|
|
122
|
+
unavailable: true,
|
|
123
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
116
127
|
let sawState = false;
|
|
117
128
|
const findings = [];
|
|
129
|
+
let inventoried = 0;
|
|
118
130
|
for (const n of nodes) {
|
|
119
|
-
|
|
131
|
+
let state = scalar(n, ["moderation_state"]);
|
|
132
|
+
if (targetLang) {
|
|
133
|
+
try {
|
|
134
|
+
const inventory = await readTranslationInventory(backend, {
|
|
135
|
+
entityType: "node", bundle: contentType, id: n.id,
|
|
136
|
+
});
|
|
137
|
+
inventoried += 1;
|
|
138
|
+
const row = inventoryRowMatching(inventory, { langcode: targetLang });
|
|
139
|
+
if (!row) continue;
|
|
140
|
+
state = row.moderation_state;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
|
|
143
|
+
return {
|
|
144
|
+
contentType,
|
|
145
|
+
unavailable: true,
|
|
146
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
120
152
|
if (state === undefined || state === null) continue;
|
|
121
153
|
sawState = true;
|
|
122
154
|
const age = daysSince(n.changed);
|
|
123
155
|
if (targetStates.includes(String(state).toLowerCase()) && age !== null && age > days) {
|
|
124
|
-
findings.push({
|
|
156
|
+
findings.push({
|
|
157
|
+
id: n.id, title: n.title, state, daysInState: age, path: n.url,
|
|
158
|
+
...(targetLang ? { langcode: targetLang } : {}),
|
|
159
|
+
});
|
|
125
160
|
}
|
|
126
161
|
}
|
|
162
|
+
if (targetLang && inventoried === 0 && nodes.length > 0) {
|
|
163
|
+
return {
|
|
164
|
+
contentType,
|
|
165
|
+
unavailable: true,
|
|
166
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
127
169
|
|
|
128
170
|
if (!sawState) {
|
|
129
171
|
return { contentType, gated: true, reason: "No moderation_state field exposed (content_moderation not enabled or not in the API).", scanned: nodes.length };
|
|
@@ -144,16 +186,16 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
144
186
|
// ---------------------------------------------------------------------------
|
|
145
187
|
|
|
146
188
|
/**
|
|
147
|
-
*
|
|
148
|
-
* that lag the most-populated language — a coverage signal for multilingual
|
|
149
|
-
* sites. (Exact per-node missing-translation detection requires translation
|
|
150
|
-
* metadata the canonical model doesn't carry; this aggregate is the best-effort
|
|
151
|
-
* stand-in.)
|
|
189
|
+
* Per-node translation coverage from Sentinel inventory.
|
|
152
190
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
191
|
+
* JSON:API serves one language per resource, so a histogram of `n.langcode`
|
|
192
|
+
* looks like 100% default language even when translations exist. Without
|
|
193
|
+
* Sentinel the report is `unavailable` rather than that misleading chart.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} args - { site?, type?, sampleSize? }.
|
|
196
|
+
* @returns {Promise<object>} Coverage, or unavailable.
|
|
155
197
|
*/
|
|
156
|
-
async function translationCoverage({ site: siteName, type,
|
|
198
|
+
async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
157
199
|
const site = getSiteConfig(siteName);
|
|
158
200
|
const sec = resolveSecurityConfig(site);
|
|
159
201
|
assertReadAllowed(sec, "node", type);
|
|
@@ -165,24 +207,71 @@ async function translationCoverage({ site: siteName, type, gapThreshold = 0.5, s
|
|
|
165
207
|
sampleSize
|
|
166
208
|
);
|
|
167
209
|
|
|
168
|
-
const
|
|
210
|
+
const missingEndpoint = /does not provide Sentinel's governed draft-translation endpoint/;
|
|
211
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
212
|
+
return {
|
|
213
|
+
contentType,
|
|
214
|
+
scanned: nodes.length,
|
|
215
|
+
unavailable: true,
|
|
216
|
+
reason:
|
|
217
|
+
"Translation coverage requires Sentinel's GET .../mcp-translations inventory. " +
|
|
218
|
+
"JSON:API only shows the default language; a language histogram would be misleading.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const findings = [];
|
|
223
|
+
let inventoried = 0;
|
|
169
224
|
for (const n of nodes) {
|
|
170
|
-
|
|
171
|
-
|
|
225
|
+
try {
|
|
226
|
+
const inventory = await readTranslationInventory(backend, {
|
|
227
|
+
entityType: "node", bundle: contentType, id: n.id,
|
|
228
|
+
});
|
|
229
|
+
inventoried += 1;
|
|
230
|
+
const rows = inventoryTranslationRows(inventory);
|
|
231
|
+
const defaultLangcode = inventory.defaultLangcode ?? n.langcode ?? null;
|
|
232
|
+
const nonDefault = rows.filter((row) => row.langcode !== defaultLangcode);
|
|
233
|
+
findings.push({
|
|
234
|
+
id: n.id,
|
|
235
|
+
title: n.title,
|
|
236
|
+
defaultLangcode,
|
|
237
|
+
languages: rows.map(mapTranslationRow),
|
|
238
|
+
missingNonDefault: nonDefault.length === 0,
|
|
239
|
+
outdated: rows.some((row) => row.outdated === true),
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!missingEndpoint.test(String(error?.message))) throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (inventoried === 0) {
|
|
247
|
+
return {
|
|
248
|
+
contentType,
|
|
249
|
+
scanned: nodes.length,
|
|
250
|
+
unavailable: true,
|
|
251
|
+
reason:
|
|
252
|
+
"Translation coverage requires Sentinel's GET .../mcp-translations inventory. " +
|
|
253
|
+
"JSON:API only shows the default language; a language histogram would be misleading.",
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const languageCounts = new Map();
|
|
258
|
+
for (const finding of findings) {
|
|
259
|
+
for (const row of finding.languages) {
|
|
260
|
+
languageCounts.set(row.langcode, (languageCounts.get(row.langcode) || 0) + 1);
|
|
261
|
+
}
|
|
172
262
|
}
|
|
173
|
-
const counts = [...byLang.entries()].map(([langcode, count]) => ({ langcode, count })).sort((a, b) => b.count - a.count);
|
|
174
|
-
const top = counts[0]?.count ?? 0;
|
|
175
|
-
const lagging = counts
|
|
176
|
-
.filter((c) => top > 0 && c.count / top < gapThreshold)
|
|
177
|
-
.map((c) => ({ langcode: c.langcode, count: c.count, coverage: Number((c.count / top).toFixed(2)) }));
|
|
178
263
|
|
|
179
264
|
return {
|
|
180
265
|
contentType,
|
|
181
266
|
scanned: nodes.length,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
267
|
+
inventoried,
|
|
268
|
+
approximate: nodes.length >= sampleSize || inventoried < nodes.length,
|
|
269
|
+
languages: [...languageCounts.entries()]
|
|
270
|
+
.map(([langcode, count]) => ({ langcode, count }))
|
|
271
|
+
.sort((a, b) => b.count - a.count),
|
|
272
|
+
missing: findings.filter((f) => f.missingNonDefault).map((f) => ({ id: f.id, title: f.title })),
|
|
273
|
+
outdated: findings.filter((f) => f.outdated).map((f) => ({ id: f.id, title: f.title })),
|
|
274
|
+
findings,
|
|
186
275
|
};
|
|
187
276
|
}
|
|
188
277
|
|
|
@@ -555,20 +644,20 @@ export const definitions = [
|
|
|
555
644
|
type: { type: "string", description: "Content type (default: article)" },
|
|
556
645
|
days: { type: "number", default: 30, description: "Days-in-state threshold" },
|
|
557
646
|
states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
|
|
647
|
+
langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
|
|
558
648
|
sampleSize: { type: "number", default: 200 },
|
|
559
649
|
},
|
|
560
650
|
},
|
|
561
651
|
},
|
|
562
652
|
{
|
|
563
653
|
name: "drupal_report_translation_coverage",
|
|
564
|
-
description: "
|
|
654
|
+
description: "Per-node translation coverage from Sentinel's inventory (missing non-default language, outdated core flag, language counts). Without Sentinel the report is unavailable — JSON:API only shows the default language, so a histogram would be misleading.",
|
|
565
655
|
inputSchema: {
|
|
566
656
|
type: "object",
|
|
567
657
|
properties: {
|
|
568
|
-
site:
|
|
569
|
-
type:
|
|
570
|
-
|
|
571
|
-
sampleSize: { type: "number", default: 500 },
|
|
658
|
+
site: { type: "string" },
|
|
659
|
+
type: { type: "string", description: "Content type (default: article)" },
|
|
660
|
+
sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
|
|
572
661
|
},
|
|
573
662
|
},
|
|
574
663
|
},
|
package/src/tools/structure.js
CHANGED
|
@@ -19,6 +19,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
19
19
|
import {
|
|
20
20
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
21
21
|
} from "../lib/security.js";
|
|
22
|
+
import { assertDraftLangcode } from "../lib/draft-write.js";
|
|
22
23
|
|
|
23
24
|
const MENU_LINK_TYPE = "menu_link_content";
|
|
24
25
|
const BLOCK_TYPE = "block_content";
|
|
@@ -150,12 +151,13 @@ async function createMenuLink({ site: siteName, title, link, menu, weight, paren
|
|
|
150
151
|
* @throws {Error} If id is missing.
|
|
151
152
|
* @throws {SecurityError} If updating menu_link_content is not permitted.
|
|
152
153
|
*/
|
|
153
|
-
async function updateMenuLink({ site: siteName, id, title, link, menu, weight, parent, enabled }) {
|
|
154
|
+
async function updateMenuLink({ site: siteName, id, title, link, menu, weight, parent, enabled, langcode }) {
|
|
154
155
|
if (!id) throw new Error("A menu link 'id' (UUID) is required to update an existing menu link.");
|
|
155
156
|
const site = getSiteConfig(siteName);
|
|
156
157
|
const sec = resolveSecurityConfig(site);
|
|
157
158
|
assertWriteAllowed(sec, "update", MENU_LINK_TYPE, MENU_LINK_TYPE);
|
|
158
159
|
const backend = await resolveBackend(site);
|
|
160
|
+
const language = langcode ? { langcode: assertDraftLangcode(langcode) } : {};
|
|
159
161
|
const attributes = {};
|
|
160
162
|
if (title !== undefined) attributes.title = title;
|
|
161
163
|
if (link !== undefined) attributes.link = { uri: link };
|
|
@@ -165,12 +167,12 @@ async function updateMenuLink({ site: siteName, id, title, link, menu, weight, p
|
|
|
165
167
|
if (enabled !== undefined) {
|
|
166
168
|
attributes.enabled = enabled;
|
|
167
169
|
} else {
|
|
168
|
-
const current = await backend.getEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id });
|
|
170
|
+
const current = await backend.getEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, ...language });
|
|
169
171
|
const currentEnabled = current?.fields?.enabled;
|
|
170
172
|
attributes.enabled = currentEnabled === undefined ? true : currentEnabled;
|
|
171
173
|
}
|
|
172
174
|
return writeMenuLinkWithRetry(() =>
|
|
173
|
-
backend.updateEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, attributes }));
|
|
175
|
+
backend.updateEntity({ entityType: MENU_LINK_TYPE, bundle: MENU_LINK_TYPE, id, attributes, ...language }));
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
// ---------------------------------------------------------------------------
|
|
@@ -273,6 +275,7 @@ export const definitions = [
|
|
|
273
275
|
weight: { type: "number", description: "New ordering weight. Omit to leave unchanged." },
|
|
274
276
|
parent: { type: "string", description: "New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged." },
|
|
275
277
|
enabled: { type: "boolean", description: "Enable/disable the link. Omit to preserve the current state." },
|
|
278
|
+
langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language. Does not create a missing translation." },
|
|
276
279
|
},
|
|
277
280
|
},
|
|
278
281
|
},
|