drupal-mcp-connector 2.15.1 → 2.16.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-describe-fields.md +2 -2
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
- package/.agents/commands/drupal-list-revisions.md +2 -2
- package/.agents/commands/drupal-list-translations.md +2 -2
- 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-menu-link.md +2 -1
- package/.agents/commands/drupal-update-node.md +2 -2
- package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
- package/CHANGELOG.md +51 -0
- package/README.md +3 -3
- package/bin/drupal-mcp-agent.js +2 -2
- package/package.json +2 -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/config.js +18 -1
- package/src/lib/err-relationships.js +22 -0
- package/src/lib/mcp-server.js +1 -1
- package/src/lib/node-draft-inventory.js +47 -0
- package/src/lib/patch-preflight.js +23 -6
- package/src/lib/server-tools.js +2 -2
- package/src/lib/translation-rows.js +59 -0
- package/src/lib/write-revision.js +9 -2
- package/src/tools/codegen.js +150 -0
- package/src/tools/entities.js +1 -0
- package/src/tools/fields.js +12 -4
- package/src/tools/index.js +2 -1
- package/src/tools/moderation.js +82 -13
- package/src/tools/nodes.js +60 -8
- package/src/tools/reports-content.js +117 -28
- package/src/tools/revisions.js +18 -2
- package/src/tools/structure.js +6 -3
- package/src/tools/taxonomy.js +15 -6
- package/src/tools/translations.js +4 -8
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
|
/**
|
|
@@ -469,16 +518,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
469
518
|
let fresh = await readWrittenRevision({
|
|
470
519
|
backend, entityType: "node", bundle: type, id,
|
|
471
520
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
521
|
+
langcode,
|
|
472
522
|
patchResult: patched,
|
|
473
523
|
preferCanonical: true,
|
|
474
524
|
resourceVersion: patchTarget.resourceVersion,
|
|
475
525
|
});
|
|
476
526
|
const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
|
|
477
527
|
if (intendedAlias) {
|
|
478
|
-
|
|
528
|
+
const aliasChecked = await assertAliasPersisted({
|
|
479
529
|
backend, sec, type, id, intendedAlias,
|
|
480
530
|
resourceVersion: patchTarget.resourceVersion, written: fresh,
|
|
481
531
|
});
|
|
532
|
+
fresh = langcode ? { ...fresh, url: aliasChecked.url } : aliasChecked;
|
|
482
533
|
}
|
|
483
534
|
// Redirect only after the new alias is what re-reads show (#274).
|
|
484
535
|
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
@@ -518,7 +569,7 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
518
569
|
export const definitions = [
|
|
519
570
|
{
|
|
520
571
|
name: "drupal_get_node",
|
|
521
|
-
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).",
|
|
522
573
|
inputSchema: {
|
|
523
574
|
type: "object", required: ["type", "id"],
|
|
524
575
|
properties: {
|
|
@@ -526,6 +577,7 @@ export const definitions = [
|
|
|
526
577
|
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
527
578
|
id: { type: "string", description: "Node UUID" },
|
|
528
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." },
|
|
529
581
|
},
|
|
530
582
|
},
|
|
531
583
|
},
|
|
@@ -582,7 +634,7 @@ export const definitions = [
|
|
|
582
634
|
},
|
|
583
635
|
{
|
|
584
636
|
name: "drupal_update_node",
|
|
585
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit.
|
|
637
|
+
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
|
|
586
638
|
inputSchema: {
|
|
587
639
|
type: "object", required: ["type", "id"],
|
|
588
640
|
properties: {
|
|
@@ -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/revisions.js
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
23
23
|
import {
|
|
24
24
|
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
|
+
import { readNodeDraftInventory } from "../lib/node-draft-inventory.js";
|
|
26
27
|
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
27
28
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
28
29
|
|
|
@@ -177,13 +178,28 @@ async function listRevisions({ site: siteName, type, id }) {
|
|
|
177
178
|
|
|
178
179
|
// The working-copy alias only resolves on entities under a content_moderation
|
|
179
180
|
// workflow with a pending forward revision; absence is expected, not an error.
|
|
180
|
-
|
|
181
|
+
let workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
|
|
181
182
|
.then(summarizeRevision)
|
|
182
183
|
.catch(() => null);
|
|
183
184
|
|
|
185
|
+
if (!workingCopy || String(workingCopy.vid) === String(latestVersion?.vid)) {
|
|
186
|
+
const inventory = await readNodeDraftInventory(backend, { entityType: "node", bundle: type, id });
|
|
187
|
+
if (inventory?.working && String(inventory.working.vid) !== String(inventory.live.vid)) {
|
|
188
|
+
if (latestVersion && String(latestVersion.vid) !== String(inventory.live.vid)) {
|
|
189
|
+
throw new Error("The live revision changed during discovery. Re-read the revision inventory.");
|
|
190
|
+
}
|
|
191
|
+
// No aggregate publication flag: English may be published while Spanish is draft.
|
|
192
|
+
workingCopy = { vid: Number(inventory.working.vid), source: "sentinel-inventory",
|
|
193
|
+
translations: inventory.working.translations.map(({ langcode, status, moderation_state, default: isDefault }) => ({
|
|
194
|
+
langcode, status, moderation_state, default: isDefault,
|
|
195
|
+
})) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
184
199
|
const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
|
|
185
200
|
let note = LIST_REVISIONS_BASE_NOTE;
|
|
186
201
|
if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
|
|
202
|
+
if (workingCopy?.source === "sentinel-inventory") note += " Working revision discovered through Sentinel; publication state is language-specific. Continue an unpublished translation with explicit langcode.";
|
|
187
203
|
if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
|
|
188
204
|
|
|
189
205
|
return {
|
|
@@ -332,7 +348,7 @@ export const definitions = [
|
|
|
332
348
|
{
|
|
333
349
|
name: "drupal_list_revisions",
|
|
334
350
|
description:
|
|
335
|
-
"Surface the
|
|
351
|
+
"Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
|
|
336
352
|
inputSchema: {
|
|
337
353
|
type: "object", required: ["type", "id"],
|
|
338
354
|
properties: {
|
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
|
},
|
package/src/tools/taxonomy.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
13
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
|
|
14
14
|
} from "../lib/security.js";
|
|
15
|
+
import { assertDraftLangcode } from "../lib/draft-write.js";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Implementations
|
|
@@ -58,12 +59,15 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
|
|
|
58
59
|
* @param {object} args - { site?, vocabulary, id }.
|
|
59
60
|
* @returns {Promise<object|null>} The redacted term, or null if not found.
|
|
60
61
|
*/
|
|
61
|
-
async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
62
|
+
async function getTaxonomyTerm({ site: siteName, vocabulary, id, langcode }) {
|
|
62
63
|
const site = getSiteConfig(siteName);
|
|
63
64
|
const sec = resolveSecurityConfig(site);
|
|
64
65
|
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
65
66
|
const backend = await resolveBackend(site);
|
|
66
|
-
const entity = await backend.getEntity({
|
|
67
|
+
const entity = await backend.getEntity({
|
|
68
|
+
entityType: "taxonomy_term", bundle: vocabulary, id,
|
|
69
|
+
...(langcode ? { langcode: assertDraftLangcode(langcode) } : {}),
|
|
70
|
+
});
|
|
67
71
|
return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -93,7 +97,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
|
|
|
93
97
|
* @param {object} args - { site?, vocabulary, id, name?, description?, weight? }.
|
|
94
98
|
* @returns {Promise<object>} The updated term descriptor.
|
|
95
99
|
*/
|
|
96
|
-
async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
|
|
100
|
+
async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight, langcode }) {
|
|
97
101
|
const site = getSiteConfig(siteName);
|
|
98
102
|
assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
|
|
99
103
|
const backend = await resolveBackend(site);
|
|
@@ -101,7 +105,10 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
|
|
|
101
105
|
if (name !== undefined) attributes.name = name;
|
|
102
106
|
if (weight !== undefined) attributes.weight = weight;
|
|
103
107
|
if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
|
|
104
|
-
return backend.updateEntity({
|
|
108
|
+
return backend.updateEntity({
|
|
109
|
+
entityType: "taxonomy_term", bundle: vocabulary, id, attributes,
|
|
110
|
+
...(langcode ? { langcode: assertDraftLangcode(langcode) } : {}),
|
|
111
|
+
});
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
/**
|
|
@@ -147,13 +154,14 @@ export const definitions = [
|
|
|
147
154
|
},
|
|
148
155
|
{
|
|
149
156
|
name: "drupal_get_taxonomy_term",
|
|
150
|
-
description: "Fetch a single taxonomy term by UUID.",
|
|
157
|
+
description: "Fetch a single taxonomy term by UUID. Pass langcode to request that translation; omit for the default language. JSON:API must negotiate language or the tool errors if a different language is served.",
|
|
151
158
|
inputSchema: {
|
|
152
159
|
type: "object", required: ["vocabulary", "id"],
|
|
153
160
|
properties: {
|
|
154
161
|
site: { type: "string" },
|
|
155
162
|
vocabulary: { type: "string" },
|
|
156
163
|
id: { type: "string", description: "Term UUID" },
|
|
164
|
+
langcode: { type: "string", description: "Translation language (e.g. 'es'). Omit for the default language." },
|
|
157
165
|
},
|
|
158
166
|
},
|
|
159
167
|
},
|
|
@@ -174,7 +182,7 @@ export const definitions = [
|
|
|
174
182
|
},
|
|
175
183
|
{
|
|
176
184
|
name: "drupal_update_taxonomy_term",
|
|
177
|
-
description: "Update an existing taxonomy term's name, description, or weight.",
|
|
185
|
+
description: "Update an existing taxonomy term's name, description, or weight. Pass langcode to update an existing translation; this does not create a missing translation.",
|
|
178
186
|
inputSchema: {
|
|
179
187
|
type: "object", required: ["vocabulary", "id"],
|
|
180
188
|
properties: {
|
|
@@ -184,6 +192,7 @@ export const definitions = [
|
|
|
184
192
|
name: { type: "string" },
|
|
185
193
|
description: { type: "string" },
|
|
186
194
|
weight: { type: "number" },
|
|
195
|
+
langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language." },
|
|
187
196
|
},
|
|
188
197
|
},
|
|
189
198
|
},
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readTranslationInventory,
|
|
35
35
|
resolveNodeTranslationPair,
|
|
36
36
|
} from "../lib/draft-write.js";
|
|
37
|
+
import { mapTranslationRow } from "../lib/translation-rows.js";
|
|
37
38
|
|
|
38
39
|
const LIST_NOTE =
|
|
39
40
|
"Live languages are those on the default revision. Working languages are the " +
|
|
@@ -75,13 +76,7 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
75
76
|
langcodes,
|
|
76
77
|
live: meta.live ?? null,
|
|
77
78
|
working: meta.working ?? null,
|
|
78
|
-
translations: (meta.working?.translations ?? meta.live?.translations ?? []).map(
|
|
79
|
-
langcode: row.langcode,
|
|
80
|
-
default: Boolean(row.default),
|
|
81
|
-
status: row.status,
|
|
82
|
-
title: row.title,
|
|
83
|
-
moderation_state: row.moderation_state,
|
|
84
|
-
})),
|
|
79
|
+
translations: (meta.working?.translations ?? meta.live?.translations ?? []).map(mapTranslationRow),
|
|
85
80
|
note: LIST_NOTE,
|
|
86
81
|
};
|
|
87
82
|
} catch (error) {
|
|
@@ -200,7 +195,8 @@ export const definitions = [
|
|
|
200
195
|
description:
|
|
201
196
|
"List live and working translation langcodes for a Drupal node or paragraph. Uses Sentinel's " +
|
|
202
197
|
"translation inventory when available (live default revision vs unpublished working " +
|
|
203
|
-
"draft)
|
|
198
|
+
"draft), including core content_translation_outdated and source when the server sends them. " +
|
|
199
|
+
"Core JSON:API alone serves one language and cannot prove others are absent. " +
|
|
204
200
|
"Defaults to node.",
|
|
205
201
|
inputSchema: {
|
|
206
202
|
type: "object", required: ["type", "id"],
|