drupal-mcp-connector 3.1.0 → 3.2.1
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-draft-nested-components.md +26 -0
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +21 -2
- package/package.json +7 -7
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +22 -6
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +24 -2
- package/src/lib/dispatch.js +7 -0
- package/src/lib/field-definition.js +146 -41
- package/src/lib/nested-draft.js +1420 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/text-format-context.js +248 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +17 -12
- package/src/tools/entities.js +16 -14
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +9 -2
- package/src/tools/nested.js +69 -0
- package/src/tools/nodes.js +2 -2
- package/src/tools/paragraphs.js +12 -3
- package/src/tools/translations.js +5 -1
|
@@ -0,0 +1,1420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed nested-paragraph draft (#421).
|
|
3
|
+
*
|
|
4
|
+
* Sentinel component drafts edit only paragraphs the host pins directly.
|
|
5
|
+
* A nested child is replaced by new unpublished paragraphs. The host draft
|
|
6
|
+
* then points at a new direct parent. The published host and the paragraphs
|
|
7
|
+
* it already pins are not patched.
|
|
8
|
+
*
|
|
9
|
+
* The first host draft needs Sentinel open_draft (2.29.0). An existing
|
|
10
|
+
* working copy continues through /mcp-draft. There is no mcp_components
|
|
11
|
+
* payload and no direct paragraph update.
|
|
12
|
+
*
|
|
13
|
+
* When the host inventory advertises nested_replacement, one /mcp-draft
|
|
14
|
+
* carries meta.mcp_nested_replacement and this module creates no paragraphs.
|
|
15
|
+
* parentId is sent as parent. The host paragraph field is not also sent.
|
|
16
|
+
* resumeParentId stays on the client sequence: those paragraphs already exist.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readNodeDraftInventory, readTranslationInventory, isMissingTranslationEndpoint, createTranslationDraft } from "./sentinel-draft.js";
|
|
20
|
+
import { entityRevisionId } from "./write-revision.js";
|
|
21
|
+
import { embedParagraphRef, paragraphRevisionId } from "./err-relationships.js";
|
|
22
|
+
import { httpStatusOf } from "./error-status.js";
|
|
23
|
+
import { OPEN_DRAFT_OPERATION, MIN_SENTINEL_COMPONENTS_VERSION, valueMatches } from "./component-draft.js";
|
|
24
|
+
import { applyParagraphTextFormats } from "./field-definition.js";
|
|
25
|
+
import { PREFLIGHT_NONE, PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
|
|
26
|
+
|
|
27
|
+
export const NESTED_DRAFT_PARTIAL_CODE = "NESTED_DRAFT_PARTIAL";
|
|
28
|
+
|
|
29
|
+
/** Inventory operation: one atomic nested paragraph replacement. */
|
|
30
|
+
export const NESTED_REPLACEMENT_OPERATION = "nested_replacement";
|
|
31
|
+
|
|
32
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
const NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
34
|
+
const SKIP_RELATIONSHIPS = new Set(["uid", "revision_uid"]);
|
|
35
|
+
const SKIPPED_FIELDS = new Set([
|
|
36
|
+
"status", "moderation_state", "langcode", "default_langcode", "created", "changed",
|
|
37
|
+
"parent_id", "parent_type", "parent_field_name", "behavior_settings",
|
|
38
|
+
"revision_translation_affected", "content_translation_source",
|
|
39
|
+
"content_translation_outdated", "content_translation_uid", "content_translation_created",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} detail
|
|
44
|
+
* @param {object[]} prepared
|
|
45
|
+
* @returns {Error}
|
|
46
|
+
*/
|
|
47
|
+
export function nestedDraftPartial(detail, prepared) {
|
|
48
|
+
const ids = prepared.map((row) => row.id).filter(Boolean).join(", ") || "none";
|
|
49
|
+
const hasParent = prepared.some((row) => row.role === "parent" && row.id);
|
|
50
|
+
const recovery = hasParent
|
|
51
|
+
? "Delete them or retry the host pin with resumeParentId."
|
|
52
|
+
: "Delete them. There is no prepared parent to pin.";
|
|
53
|
+
const err = new Error(
|
|
54
|
+
`The nested draft did not finish (${detail}). Prepared paragraphs (${ids}) are not ` +
|
|
55
|
+
`referenced by the published host. ${recovery} ` +
|
|
56
|
+
"Do not edit the published child paragraphs."
|
|
57
|
+
);
|
|
58
|
+
err.name = "NestedDraftPartialError";
|
|
59
|
+
err.code = NESTED_DRAFT_PARTIAL_CODE;
|
|
60
|
+
err.prepared = prepared;
|
|
61
|
+
return err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The host request was sent, or a preflight failed after creates, and the
|
|
66
|
+
* follow-up read did not prove what the host now pins.
|
|
67
|
+
* @param {string} detail
|
|
68
|
+
* @param {object[]} prepared
|
|
69
|
+
* @returns {Error}
|
|
70
|
+
*/
|
|
71
|
+
export function nestedDraftUncertain(detail, prepared) {
|
|
72
|
+
const ids = prepared.map((row) => row.id).join(", ");
|
|
73
|
+
const err = new Error(
|
|
74
|
+
`The nested draft outcome is uncertain (${detail}). Prepared paragraphs (${ids}) may or may not be pinned. ` +
|
|
75
|
+
"Re-read the published revision and rel:working-copy before retrying. " +
|
|
76
|
+
"Do not edit the published child paragraphs, and do not assume the draft succeeded."
|
|
77
|
+
);
|
|
78
|
+
err.name = "NestedDraftPartialError";
|
|
79
|
+
err.code = NESTED_DRAFT_PARTIAL_CODE;
|
|
80
|
+
err.prepared = prepared;
|
|
81
|
+
err.uncertain = true;
|
|
82
|
+
return err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} backend
|
|
87
|
+
* @param {object} input
|
|
88
|
+
* @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void, site?: object}} [options]
|
|
89
|
+
* @returns {Promise<object>}
|
|
90
|
+
*/
|
|
91
|
+
export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate, site = {} } = {}) {
|
|
92
|
+
const spec = normalizeNestedInput(input);
|
|
93
|
+
// Caller-supplied children and translations only. Copied parent fields are
|
|
94
|
+
// the stored paragraph, not a new format choice, and are left unchanged.
|
|
95
|
+
await applyCallerParagraphFormats(backend, site, spec.children);
|
|
96
|
+
const inventory = await readNodeDraftInventory(backend, spec);
|
|
97
|
+
if (!inventory) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"This host has no governed draft inventory. Nested edits require a node draft. " +
|
|
100
|
+
"No write was attempted, and no paragraph was created or edited."
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const operations = Array.isArray(inventory.operations) ? inventory.operations : [];
|
|
104
|
+
const defaultLang = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : "";
|
|
105
|
+
if (!defaultLang) throw new Error("Sentinel did not report the default language. No write was attempted.");
|
|
106
|
+
const live = String(inventory.live.vid);
|
|
107
|
+
if (spec.liveRevisionId && String(spec.liveRevisionId) !== live) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`The host revision is stale (requested ${spec.liveRevisionId}, live revision ${live}). No write was attempted.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const working = inventory.working?.vid && String(inventory.working.vid) !== live
|
|
113
|
+
? String(inventory.working.vid)
|
|
114
|
+
: "";
|
|
115
|
+
if (!working && !operations.includes(OPEN_DRAFT_OPERATION)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`This Sentinel host cannot open the first draft (missing ${OPEN_DRAFT_OPERATION}). ` +
|
|
118
|
+
`Update MCP Sentinel to ${MIN_SENTINEL_COMPONENTS_VERSION} or later. ` +
|
|
119
|
+
"No write was attempted, and no paragraph was created."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (working) assertWorkingLanguageDraft(inventory, defaultLang);
|
|
123
|
+
|
|
124
|
+
const publishedHost = await readHost(backend, spec, "");
|
|
125
|
+
const publishedVid = entityRevisionId(publishedHost);
|
|
126
|
+
if (publishedVid === null || String(publishedVid) !== live) {
|
|
127
|
+
throw new Error("The host revision changed while reading it. No write was attempted.");
|
|
128
|
+
}
|
|
129
|
+
const publishedPins = fieldList(publishedHost, spec.field);
|
|
130
|
+
const publishedSlot = publishedPins.findIndex((ref) => sameId(ref?.id, spec.parentId));
|
|
131
|
+
if (publishedSlot < 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"This paragraph is not a direct component of the published host. " +
|
|
134
|
+
"Nested paragraphs are not edited in place. No write was attempted."
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (isReusable(publishedPins[publishedSlot])) throw reusableError();
|
|
138
|
+
for (const ref of publishedPins) requirePinRevision(ref, "A direct component pin");
|
|
139
|
+
let draftPins = publishedPins;
|
|
140
|
+
if (working) {
|
|
141
|
+
const workingHost = await readHost(backend, spec, working);
|
|
142
|
+
const workingVid = entityRevisionId(workingHost);
|
|
143
|
+
if (workingVid === null || String(workingVid) !== working) {
|
|
144
|
+
throw new Error("The working copy revision changed while reading it. No write was attempted.");
|
|
145
|
+
}
|
|
146
|
+
draftPins = fieldList(workingHost, spec.field);
|
|
147
|
+
for (const ref of draftPins) requirePinRevision(ref, "A working-copy component pin");
|
|
148
|
+
if (draftPins.length !== publishedPins.length) {
|
|
149
|
+
throw new Error("The working copy's component list does not match the published host. No write was attempted.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
let slot = draftPins.findIndex((ref) => sameId(ref?.id, spec.parentId));
|
|
153
|
+
if (slot < 0 && spec.resumeParentId) {
|
|
154
|
+
slot = draftPins.findIndex((ref) => sameId(ref?.id, spec.resumeParentId));
|
|
155
|
+
}
|
|
156
|
+
if (slot < 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"A forward draft already replaces this component. The published host still pins it. " +
|
|
159
|
+
"No further nested draft was started. Continue that draft in Drupal, or retry the host pin with resumeParentId."
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (slot !== publishedSlot) {
|
|
163
|
+
throw new Error("The working copy does not keep this component in the published slot. No write was attempted.");
|
|
164
|
+
}
|
|
165
|
+
const parentRef = publishedPins[publishedSlot];
|
|
166
|
+
const parentBundle = paragraphBundle(parentRef);
|
|
167
|
+
if (!parentBundle) throw new Error("The direct component has no paragraph type. No write was attempted.");
|
|
168
|
+
const parentRevision = revisionOf(parentRef);
|
|
169
|
+
const parent = await readParagraph(backend, parentBundle, spec.parentId, parentRevision);
|
|
170
|
+
if (!sameId(parent?.id, spec.parentId)) {
|
|
171
|
+
throw new Error("The component read did not match the requested paragraph. No write was attempted.");
|
|
172
|
+
}
|
|
173
|
+
const childPins = fieldList(parent, spec.childField);
|
|
174
|
+
if (childPins.some(isReusable)) throw reusableError();
|
|
175
|
+
for (const ref of childPins) requirePinRevision(ref, ref?.id || "A nested component");
|
|
176
|
+
if (spec.resumeParentId && liveParagraphIds(publishedPins, childPins).has(spec.resumeParentId.toLowerCase())) {
|
|
177
|
+
throw new Error("resumeParentId is a paragraph the published host already pins. No write was attempted.");
|
|
178
|
+
}
|
|
179
|
+
const requiredLangs = await requiredTranslationLangs(
|
|
180
|
+
backend, parentBundle, spec.parentId, defaultLang, parentRevision,
|
|
181
|
+
);
|
|
182
|
+
assertChildren(spec.children, childPins, requiredLangs);
|
|
183
|
+
if (!spec.resumeParentId) {
|
|
184
|
+
const bundles = [
|
|
185
|
+
...spec.children.filter((child) => child.op !== "keep").map((child) => child.type),
|
|
186
|
+
parentBundle,
|
|
187
|
+
];
|
|
188
|
+
for (const bundle of bundles) assertParagraphCreate?.(bundle);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!spec.resumeParentId && operations.includes(NESTED_REPLACEMENT_OPERATION)) {
|
|
192
|
+
return writeAtomicNestedReplacement(backend, {
|
|
193
|
+
spec, dryRun, live, working, defaultLang, publishedPins, slot, childPins,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
return {
|
|
199
|
+
dryRun: true,
|
|
200
|
+
operation: "update",
|
|
201
|
+
entityType: spec.entityType,
|
|
202
|
+
bundle: spec.bundle,
|
|
203
|
+
id: spec.id,
|
|
204
|
+
hostPayloadEvaluated: false,
|
|
205
|
+
createsParagraphs: !spec.resumeParentId,
|
|
206
|
+
opensWorkingCopy: !working,
|
|
207
|
+
replacesParentId: spec.parentId,
|
|
208
|
+
note: "No paragraphs were created. The host draft payload was not evaluated because the replacement paragraphs do not exist yet.",
|
|
209
|
+
...dryRunChecks({ operation: "update", preflight: PREFLIGHT_NONE }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const prepared = [];
|
|
214
|
+
const childRefs = [];
|
|
215
|
+
if (spec.resumeParentId) {
|
|
216
|
+
const resumed = await readParagraph(backend, parentBundle, spec.resumeParentId);
|
|
217
|
+
const revisionId = await assertResumeMatches(
|
|
218
|
+
backend, spec, parent, childPins, resumed, requiredLangs, defaultLang,
|
|
219
|
+
);
|
|
220
|
+
prepared.push({ id: spec.resumeParentId, bundle: parentBundle, role: "parent", revisionId });
|
|
221
|
+
} else {
|
|
222
|
+
for (const child of spec.children) {
|
|
223
|
+
if (child.op === "keep") {
|
|
224
|
+
const existing = childPins.find((ref) => sameId(ref?.id, child.id));
|
|
225
|
+
childRefs.push(pinRef(existing));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
childRefs.push(await createParagraph(backend, child, prepared, assertParagraphCreate, defaultLang));
|
|
229
|
+
}
|
|
230
|
+
await createParagraph(backend, {
|
|
231
|
+
op: "insert",
|
|
232
|
+
type: parentBundle,
|
|
233
|
+
attributes: copiedAttributes(parent),
|
|
234
|
+
relationships: copiedRelationships(parent, spec.childField, childRefs),
|
|
235
|
+
translations: [],
|
|
236
|
+
}, prepared, assertParagraphCreate, defaultLang, "parent");
|
|
237
|
+
await copyParentTranslations(backend, parent, prepared.find((row) => row.role === "parent"), requiredLangs, prepared);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const draftParent = spec.resumeParentId
|
|
241
|
+
? prepared[0]
|
|
242
|
+
: prepared.find((row) => row.role === "parent");
|
|
243
|
+
const nextPins = draftPins.map((ref, index) => (
|
|
244
|
+
index === slot ? embedParagraphRef(draftParent.bundle, draftParent.id, draftParent.revisionId) : pinRef(ref)
|
|
245
|
+
));
|
|
246
|
+
if (working && sameId(draftPins[slot]?.id, draftParent.id)) {
|
|
247
|
+
const confirmed = await confirmLanded(backend, spec, live, nextPins);
|
|
248
|
+
if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, false);
|
|
249
|
+
throw recoveryError(confirmed, "the existing draft pin could not be confirmed", prepared);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const data = {
|
|
253
|
+
type: `${spec.entityType}--${spec.bundle}`,
|
|
254
|
+
id: spec.id,
|
|
255
|
+
attributes: { moderation_state: "draft" },
|
|
256
|
+
relationships: { [spec.field]: { data: nextPins } },
|
|
257
|
+
};
|
|
258
|
+
const request = { ...spec, data, live, working, langcode: defaultLang };
|
|
259
|
+
try {
|
|
260
|
+
await confirmHostPreflight(backend, request);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const confirmed = await confirmLanded(backend, spec, live, nextPins);
|
|
263
|
+
if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
|
|
264
|
+
throw recoveryError(confirmed, messageOf(err), prepared);
|
|
265
|
+
}
|
|
266
|
+
let result;
|
|
267
|
+
try {
|
|
268
|
+
result = await hostDraftRequest(backend, { ...request, preflight: false });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const confirmed = await confirmLanded(backend, spec, live, nextPins);
|
|
271
|
+
if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
|
|
272
|
+
throw recoveryError(confirmed, messageOf(err), prepared);
|
|
273
|
+
}
|
|
274
|
+
if (!result?.data || result.data.id !== spec.id) {
|
|
275
|
+
const confirmed = await confirmLanded(backend, spec, live, nextPins);
|
|
276
|
+
if (confirmed.ok) return finished(spec, live, confirmed.workingVid, draftParent, prepared, !working);
|
|
277
|
+
throw recoveryError(confirmed, "the host draft response did not identify the node", prepared);
|
|
278
|
+
}
|
|
279
|
+
const landed = await confirmLanded(backend, spec, live, nextPins);
|
|
280
|
+
if (!landed.ok) {
|
|
281
|
+
throw recoveryError(landed, "the working copy did not pin the new component", prepared);
|
|
282
|
+
}
|
|
283
|
+
return finished(spec, live, landed.workingVid, draftParent, prepared, !working);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One server-side nested replacement. The connector creates nothing.
|
|
288
|
+
*
|
|
289
|
+
* @param {object} backend
|
|
290
|
+
* @param {object} args
|
|
291
|
+
* @returns {Promise<object>}
|
|
292
|
+
*/
|
|
293
|
+
async function writeAtomicNestedReplacement(backend, {
|
|
294
|
+
spec, dryRun, live, working, defaultLang, publishedPins, slot, childPins,
|
|
295
|
+
}) {
|
|
296
|
+
const data = {
|
|
297
|
+
type: `${spec.entityType}--${spec.bundle}`,
|
|
298
|
+
id: spec.id,
|
|
299
|
+
attributes: { moderation_state: "draft" },
|
|
300
|
+
};
|
|
301
|
+
const request = {
|
|
302
|
+
...spec,
|
|
303
|
+
data,
|
|
304
|
+
meta: {
|
|
305
|
+
mcp_nested_replacement: {
|
|
306
|
+
field: spec.field,
|
|
307
|
+
parent: spec.parentId,
|
|
308
|
+
childField: spec.childField,
|
|
309
|
+
children: spec.children.map(atomicChild),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
live,
|
|
313
|
+
working,
|
|
314
|
+
langcode: defaultLang,
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
await confirmAtomicPreflight(backend, request);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
throw noWriteError(err);
|
|
320
|
+
}
|
|
321
|
+
if (dryRun) {
|
|
322
|
+
return {
|
|
323
|
+
dryRun: true,
|
|
324
|
+
operation: "update",
|
|
325
|
+
entityType: spec.entityType,
|
|
326
|
+
bundle: spec.bundle,
|
|
327
|
+
id: spec.id,
|
|
328
|
+
hostPayloadEvaluated: true,
|
|
329
|
+
createsParagraphs: false,
|
|
330
|
+
opensWorkingCopy: !working,
|
|
331
|
+
replacesParentId: spec.parentId,
|
|
332
|
+
atomic: true,
|
|
333
|
+
...dryRunChecks({ operation: "update", preflight: PREFLIGHT_SENTINEL_DRAFT }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
let result;
|
|
337
|
+
try {
|
|
338
|
+
result = await atomicDraftRequest(backend, { ...request, preflight: false });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const confirmed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
|
|
341
|
+
if (confirmed.ok) return atomicFinished(spec, live, confirmed, !working);
|
|
342
|
+
throw atomicFailure(confirmed, messageOf(err));
|
|
343
|
+
}
|
|
344
|
+
if (!result?.data || result.data.id !== spec.id) {
|
|
345
|
+
const confirmed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
|
|
346
|
+
if (confirmed.ok) return atomicFinished(spec, live, confirmed, !working);
|
|
347
|
+
throw atomicFailure(confirmed, "the host draft response did not identify the node");
|
|
348
|
+
}
|
|
349
|
+
const landed = await confirmAtomicLanded(backend, spec, live, working, publishedPins, slot, childPins);
|
|
350
|
+
if (!landed.ok) throw atomicFailure(landed, "the working copy did not show the nested replacement");
|
|
351
|
+
return atomicFinished(spec, live, landed, !working);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {object} child
|
|
356
|
+
* @returns {object}
|
|
357
|
+
*/
|
|
358
|
+
function atomicChild(child) {
|
|
359
|
+
if (child.op === "keep") return { op: "keep", id: child.id };
|
|
360
|
+
const entry = {
|
|
361
|
+
op: child.op,
|
|
362
|
+
type: `paragraph--${child.type}`,
|
|
363
|
+
attributes: child.attributes,
|
|
364
|
+
};
|
|
365
|
+
if (child.op === "replace") entry.id = child.id;
|
|
366
|
+
if (child.translations?.length) entry.translations = child.translations;
|
|
367
|
+
return entry;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* @param {object} backend
|
|
372
|
+
* @param {object} request
|
|
373
|
+
* @returns {Promise<object>}
|
|
374
|
+
*/
|
|
375
|
+
function atomicDraftRequest(backend, { entityType, bundle, id, data, meta, live, working, langcode, preflight }) {
|
|
376
|
+
return backend.rawQuery({
|
|
377
|
+
path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
|
|
378
|
+
options: {
|
|
379
|
+
method: "PATCH",
|
|
380
|
+
headers: {
|
|
381
|
+
"If-Match": working ? `"${live}:${working}"` : `"${live}"`,
|
|
382
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
383
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({ data, meta }),
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {object} backend
|
|
392
|
+
* @param {object} request
|
|
393
|
+
* @returns {Promise<void>}
|
|
394
|
+
*/
|
|
395
|
+
async function confirmAtomicPreflight(backend, request) {
|
|
396
|
+
const checked = await atomicDraftRequest(backend, { ...request, preflight: true });
|
|
397
|
+
const meta = checked?.meta;
|
|
398
|
+
if (meta?.draft_preflight !== true || String(meta.live) !== request.live
|
|
399
|
+
|| String(meta.working ?? "") !== request.working
|
|
400
|
+
|| meta.operation !== NESTED_REPLACEMENT_OPERATION) {
|
|
401
|
+
throw new Error("The site did not confirm a non-saving nested replacement preflight. No write was attempted.");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @param {object} backend
|
|
407
|
+
* @param {object} spec
|
|
408
|
+
* @param {string} live
|
|
409
|
+
* @param {string} previousWorking
|
|
410
|
+
* @param {object[]} publishedPins
|
|
411
|
+
* @param {number} slot
|
|
412
|
+
* @param {object[]} childPins
|
|
413
|
+
* @returns {Promise<object>}
|
|
414
|
+
*/
|
|
415
|
+
async function confirmAtomicLanded(backend, spec, live, previousWorking, publishedPins, slot, childPins) {
|
|
416
|
+
let published;
|
|
417
|
+
let workingEntity;
|
|
418
|
+
try {
|
|
419
|
+
published = await backend.getEntity({ entityType: spec.entityType, bundle: spec.bundle, id: spec.id });
|
|
420
|
+
workingEntity = await backend.getEntity({
|
|
421
|
+
entityType: spec.entityType, bundle: spec.bundle, id: spec.id, resourceVersion: "rel:working-copy",
|
|
422
|
+
});
|
|
423
|
+
} catch (err) {
|
|
424
|
+
return { ok: false, uncertain: true, publishedIntact: false, detail: messageOf(err) };
|
|
425
|
+
}
|
|
426
|
+
if (!published || !workingEntity) {
|
|
427
|
+
return { ok: false, uncertain: true, publishedIntact: false, detail: "a host revision was not returned" };
|
|
428
|
+
}
|
|
429
|
+
const publishedVid = entityRevisionId(published);
|
|
430
|
+
const publishedNow = fieldList(published, spec.field);
|
|
431
|
+
const publishedIntact = publishedVid !== null && String(publishedVid) === String(live)
|
|
432
|
+
&& samePinList(publishedNow, publishedPins);
|
|
433
|
+
if (!publishedIntact) {
|
|
434
|
+
return { ok: false, uncertain: true, publishedIntact: false, detail: "the published revision changed" };
|
|
435
|
+
}
|
|
436
|
+
const workingPins = fieldList(workingEntity, spec.field);
|
|
437
|
+
const newParentRef = workingPins[slot];
|
|
438
|
+
const stillOriginal = !newParentRef || sameId(newParentRef.id, spec.parentId);
|
|
439
|
+
const workingVid = entityRevisionId(workingEntity);
|
|
440
|
+
const advanced = workingVid !== null && String(workingVid) !== String(live)
|
|
441
|
+
&& (!previousWorking || String(workingVid) !== String(previousWorking));
|
|
442
|
+
if (stillOriginal) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false, uncertain: false, publishedIntact: true,
|
|
445
|
+
detail: "the published host still pins the original component",
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (!advanced || workingPins.length !== publishedPins.length
|
|
449
|
+
|| workingPins.some((ref, index) => index !== slot && pinKey(ref) !== pinKey(publishedPins[index]))) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false, uncertain: true, publishedIntact: true,
|
|
452
|
+
detail: advanced
|
|
453
|
+
? "a new pin appeared without a verifiable component list"
|
|
454
|
+
: "a new pin appeared but the working revision did not advance",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const bundle = paragraphBundle(newParentRef);
|
|
458
|
+
const revisionId = revisionOf(newParentRef);
|
|
459
|
+
let parent;
|
|
460
|
+
try {
|
|
461
|
+
parent = await backend.getEntity({
|
|
462
|
+
entityType: "paragraph",
|
|
463
|
+
bundle,
|
|
464
|
+
id: newParentRef.id,
|
|
465
|
+
...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
|
|
466
|
+
});
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return { ok: false, uncertain: true, publishedIntact: true, detail: messageOf(err) };
|
|
469
|
+
}
|
|
470
|
+
if (!isUnpublishedParagraph(parent)) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false, uncertain: true, publishedIntact: true,
|
|
473
|
+
detail: "the new parent was not an unpublished paragraph",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const nextChildren = fieldList(parent, spec.childField);
|
|
477
|
+
const childDetail = await verifyAtomicChildren(backend, spec, childPins, nextChildren);
|
|
478
|
+
if (childDetail) {
|
|
479
|
+
return { ok: false, uncertain: true, publishedIntact: true, detail: childDetail };
|
|
480
|
+
}
|
|
481
|
+
const prepared = [{
|
|
482
|
+
id: newParentRef.id, bundle, role: "parent", revisionId, atomic: true,
|
|
483
|
+
}];
|
|
484
|
+
spec.children.forEach((child, index) => {
|
|
485
|
+
if (child.op === "keep") return;
|
|
486
|
+
const pin = nextChildren[index];
|
|
487
|
+
prepared.push({
|
|
488
|
+
id: pin.id, bundle: paragraphBundle(pin), role: "child", revisionId: revisionOf(pin), atomic: true,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
workingVid: String(workingVid),
|
|
494
|
+
parent: { id: newParentRef.id, bundle, revisionId },
|
|
495
|
+
prepared,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* @param {object} backend
|
|
501
|
+
* @param {object} spec
|
|
502
|
+
* @param {object[]} originalPins
|
|
503
|
+
* @param {object[]} nextPins
|
|
504
|
+
* @returns {Promise<?string>}
|
|
505
|
+
*/
|
|
506
|
+
async function verifyAtomicChildren(backend, spec, originalPins, nextPins) {
|
|
507
|
+
if (nextPins.length !== spec.children.length) {
|
|
508
|
+
return "the new parent child list does not match the request";
|
|
509
|
+
}
|
|
510
|
+
for (let index = 0; index < spec.children.length; index += 1) {
|
|
511
|
+
const child = spec.children[index];
|
|
512
|
+
const pin = nextPins[index];
|
|
513
|
+
if (child.op === "keep") {
|
|
514
|
+
const original = originalPins.find((ref) => sameId(ref?.id, child.id));
|
|
515
|
+
if (!sameId(pin?.id, child.id) || String(revisionOf(pin)) !== String(revisionOf(original))) {
|
|
516
|
+
return `kept child ${child.id} was not pinned at its original revision`;
|
|
517
|
+
}
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (child.op === "replace" && sameId(pin?.id, child.id)) {
|
|
521
|
+
return `replaced child ${child.id} was not a new paragraph`;
|
|
522
|
+
}
|
|
523
|
+
if (paragraphBundle(pin) !== child.type) return "a new child has the wrong paragraph type";
|
|
524
|
+
let entity;
|
|
525
|
+
try {
|
|
526
|
+
const revisionId = revisionOf(pin);
|
|
527
|
+
entity = await backend.getEntity({
|
|
528
|
+
entityType: "paragraph",
|
|
529
|
+
bundle: child.type,
|
|
530
|
+
id: pin.id,
|
|
531
|
+
...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
|
|
532
|
+
});
|
|
533
|
+
} catch (err) {
|
|
534
|
+
return messageOf(err);
|
|
535
|
+
}
|
|
536
|
+
if (!isUnpublishedParagraph(entity)) return "a new child was not unpublished";
|
|
537
|
+
const stored = entity.fields ?? entity.attributes ?? {};
|
|
538
|
+
const mismatch = Object.entries(child.attributes ?? {}).find(([key, value]) => !valueMatches(value, stored[key]));
|
|
539
|
+
if (mismatch) return `child field ${mismatch[0]} did not match`;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* @param {?object} entity
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
function isUnpublishedParagraph(entity) {
|
|
549
|
+
if (!entity || typeof entity !== "object") return false;
|
|
550
|
+
const state = String(entity.fields?.moderation_state ?? entity.moderation_state ?? "").toLowerCase();
|
|
551
|
+
if (entity.status === true || state === "published") return false;
|
|
552
|
+
return entity.status === false || state === "draft" || state === "review";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {object[]} left
|
|
557
|
+
* @param {object[]} right
|
|
558
|
+
* @returns {boolean}
|
|
559
|
+
*/
|
|
560
|
+
function samePinList(left, right) {
|
|
561
|
+
return left.length === right.length && left.every((ref, index) => pinKey(ref) === pinKey(right[index]));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* @param {unknown} err
|
|
566
|
+
* @returns {Error}
|
|
567
|
+
*/
|
|
568
|
+
function noWriteError(err) {
|
|
569
|
+
const message = messageOf(err);
|
|
570
|
+
if (/no write was attempted/i.test(message)) return err instanceof Error ? err : new Error(message);
|
|
571
|
+
return new Error(`${message} No write was attempted.`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @param {object} confirmed
|
|
576
|
+
* @param {string} fallback
|
|
577
|
+
* @returns {Error}
|
|
578
|
+
*/
|
|
579
|
+
function atomicFailure(confirmed, fallback) {
|
|
580
|
+
const detail = confirmed.detail && confirmed.detail !== fallback
|
|
581
|
+
? `${fallback} (${confirmed.detail})`
|
|
582
|
+
: (confirmed.detail || fallback);
|
|
583
|
+
if (confirmed.uncertain || !confirmed.publishedIntact) return atomicUncertain(detail);
|
|
584
|
+
return atomicPartial(detail);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {string} detail
|
|
589
|
+
* @returns {Error}
|
|
590
|
+
*/
|
|
591
|
+
function atomicPartial(detail) {
|
|
592
|
+
const err = new Error(
|
|
593
|
+
`The nested replacement did not finish (${detail}). The published host still pins the original component. ` +
|
|
594
|
+
"No paragraph was prepared by the connector. Do not edit the published child paragraphs."
|
|
595
|
+
);
|
|
596
|
+
err.name = "NestedDraftPartialError";
|
|
597
|
+
err.code = NESTED_DRAFT_PARTIAL_CODE;
|
|
598
|
+
err.prepared = [];
|
|
599
|
+
err.atomic = true;
|
|
600
|
+
return err;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @param {string} detail
|
|
605
|
+
* @returns {Error}
|
|
606
|
+
*/
|
|
607
|
+
function atomicUncertain(detail) {
|
|
608
|
+
const err = new Error(
|
|
609
|
+
`The nested replacement outcome is uncertain (${detail}). ` +
|
|
610
|
+
"Re-read the published revision and rel:working-copy before retrying. " +
|
|
611
|
+
"Do not assume the published revision is unchanged. No paragraph was prepared by the connector."
|
|
612
|
+
);
|
|
613
|
+
err.name = "NestedDraftPartialError";
|
|
614
|
+
err.code = NESTED_DRAFT_PARTIAL_CODE;
|
|
615
|
+
err.prepared = [];
|
|
616
|
+
err.uncertain = true;
|
|
617
|
+
err.atomic = true;
|
|
618
|
+
return err;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* @param {object} spec
|
|
623
|
+
* @param {string} live
|
|
624
|
+
* @param {object} confirmed
|
|
625
|
+
* @param {boolean} opened
|
|
626
|
+
* @returns {object}
|
|
627
|
+
*/
|
|
628
|
+
function atomicFinished(spec, live, confirmed, opened) {
|
|
629
|
+
return {
|
|
630
|
+
id: spec.id,
|
|
631
|
+
entityType: spec.entityType,
|
|
632
|
+
bundle: spec.bundle,
|
|
633
|
+
publishedParentId: spec.parentId,
|
|
634
|
+
draftParentId: confirmed.parent.id,
|
|
635
|
+
publishedPinsUnchanged: true,
|
|
636
|
+
opensWorkingCopy: opened,
|
|
637
|
+
atomic: true,
|
|
638
|
+
prepared: confirmed.prepared,
|
|
639
|
+
_revisions: { live, working: confirmed.workingVid },
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* @param {object} input
|
|
645
|
+
* @returns {object}
|
|
646
|
+
*/
|
|
647
|
+
function normalizeNestedInput(input) {
|
|
648
|
+
const entityType = input?.entityType;
|
|
649
|
+
if (entityType !== "node") {
|
|
650
|
+
throw new Error("Nested draft edits are supported on nodes. No write was attempted.");
|
|
651
|
+
}
|
|
652
|
+
const bundle = String(input.bundle ?? "");
|
|
653
|
+
const id = String(input.id ?? "");
|
|
654
|
+
const field = String(input.field ?? "");
|
|
655
|
+
const parentId = String(input.parentId ?? "");
|
|
656
|
+
const childField = String(input.childField ?? "");
|
|
657
|
+
if (!NAME_RE.test(bundle) || !NAME_RE.test(field) || !NAME_RE.test(childField)) {
|
|
658
|
+
throw new Error("bundle, field, and childField must be Drupal machine names.");
|
|
659
|
+
}
|
|
660
|
+
if (!UUID_RE.test(id) || !UUID_RE.test(parentId)) {
|
|
661
|
+
throw new Error("The host id and parentId must be UUIDs.");
|
|
662
|
+
}
|
|
663
|
+
const resumeParentId = input.resumeParentId ? String(input.resumeParentId) : "";
|
|
664
|
+
if (resumeParentId && (!UUID_RE.test(resumeParentId) || resumeParentId === parentId)) {
|
|
665
|
+
throw new Error("resumeParentId must be the prepared parent UUID, not the published component.");
|
|
666
|
+
}
|
|
667
|
+
const children = Array.isArray(input.children) ? input.children : null;
|
|
668
|
+
if (!children) throw new Error("children must be the full new child list.");
|
|
669
|
+
return {
|
|
670
|
+
entityType, bundle, id, field, parentId, childField, resumeParentId,
|
|
671
|
+
liveRevisionId: input.liveRevisionId,
|
|
672
|
+
children: children.map(normalizeChild),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {object} child
|
|
678
|
+
* @param {number} index
|
|
679
|
+
* @returns {object}
|
|
680
|
+
*/
|
|
681
|
+
function normalizeChild(child, index) {
|
|
682
|
+
const where = `children[${index}]`;
|
|
683
|
+
if (!child || typeof child !== "object" || Array.isArray(child)) {
|
|
684
|
+
throw new Error(`${where} must be an object.`);
|
|
685
|
+
}
|
|
686
|
+
if (Object.hasOwn(child, "relationships") && child.op !== undefined) {
|
|
687
|
+
throw new Error(`${where}: a nested edit cannot change references inside the child. Create a new child instead.`);
|
|
688
|
+
}
|
|
689
|
+
const op = child.op;
|
|
690
|
+
if (op !== "keep" && op !== "replace" && op !== "insert") {
|
|
691
|
+
throw new Error(`${where}.op must be keep, replace, or insert. Omit a child to remove it from the draft.`);
|
|
692
|
+
}
|
|
693
|
+
if ((op === "keep" || op === "replace") && !UUID_RE.test(String(child.id ?? ""))) {
|
|
694
|
+
throw new Error(`${where}.id must be the existing child UUID.`);
|
|
695
|
+
}
|
|
696
|
+
if (op === "keep") return { op, id: String(child.id) };
|
|
697
|
+
const bundle = paragraphType(child.type, where);
|
|
698
|
+
const attributes = child.attributes;
|
|
699
|
+
if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) {
|
|
700
|
+
throw new Error(`${where}.attributes must be the field values for the new paragraph.`);
|
|
701
|
+
}
|
|
702
|
+
const translations = Array.isArray(child.translations) ? child.translations.map((row, translationIndex) => {
|
|
703
|
+
if (!row || typeof row.langcode !== "string" || !row.attributes || typeof row.attributes !== "object") {
|
|
704
|
+
throw new Error(`${where}.translations[${translationIndex}] must have langcode and attributes.`);
|
|
705
|
+
}
|
|
706
|
+
return { langcode: row.langcode, attributes: row.attributes };
|
|
707
|
+
}) : [];
|
|
708
|
+
return { op, id: child.id ? String(child.id) : "", type: bundle, attributes, translations };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* @param {object[]} children
|
|
713
|
+
* @param {object[]} childPins
|
|
714
|
+
* @param {Set<string>} requiredLangs
|
|
715
|
+
*/
|
|
716
|
+
function assertChildren(children, childPins, requiredLangs) {
|
|
717
|
+
const known = new Set(childPins.map((ref) => ref?.id?.toLowerCase()).filter(Boolean));
|
|
718
|
+
const seen = new Set();
|
|
719
|
+
for (const child of children) {
|
|
720
|
+
if (child.op === "insert") {
|
|
721
|
+
assertLangs(child, requiredLangs);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const key = child.id.toLowerCase();
|
|
725
|
+
if (!known.has(key)) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`${child.id} is not a child of this component. No write was attempted.`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (seen.has(key)) throw new Error(`${child.id} is listed more than once.`);
|
|
731
|
+
seen.add(key);
|
|
732
|
+
if (child.op === "replace") assertLangs(child, requiredLangs);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* @param {object} backend
|
|
738
|
+
* @param {object} site
|
|
739
|
+
* @param {object[]} children
|
|
740
|
+
* @returns {Promise<void>}
|
|
741
|
+
*/
|
|
742
|
+
async function applyCallerParagraphFormats(backend, site, children) {
|
|
743
|
+
for (const child of children) {
|
|
744
|
+
if (child.op === "keep") continue;
|
|
745
|
+
await applyParagraphTextFormats(backend, site, child.type, child.attributes);
|
|
746
|
+
for (const translation of child.translations ?? []) {
|
|
747
|
+
await applyParagraphTextFormats(backend, site, child.type, translation.attributes);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function assertLangs(child, requiredLangs) {
|
|
753
|
+
const supplied = new Set(child.translations.map((row) => row.langcode));
|
|
754
|
+
for (const lang of requiredLangs) {
|
|
755
|
+
if (!supplied.has(lang)) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
`This component has a ${lang} translation that was not included. No write was attempted.`
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @param {object} backend
|
|
765
|
+
* @param {object} child
|
|
766
|
+
* @param {object[]} prepared
|
|
767
|
+
* @param {(bundle: string) => void} [assertParagraphCreate]
|
|
768
|
+
* @param {string} defaultLang
|
|
769
|
+
* @param {"child"|"parent"} [role]
|
|
770
|
+
* @returns {Promise<object>}
|
|
771
|
+
*/
|
|
772
|
+
async function createParagraph(backend, child, prepared, assertParagraphCreate, defaultLang, role = "child") {
|
|
773
|
+
try {
|
|
774
|
+
assertParagraphCreate?.(child.type);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
if (prepared.length) throw nestedDraftPartial(messageOf(err), prepared);
|
|
777
|
+
throw err;
|
|
778
|
+
}
|
|
779
|
+
let created;
|
|
780
|
+
try {
|
|
781
|
+
created = await backend.createEntity({
|
|
782
|
+
entityType: "paragraph",
|
|
783
|
+
bundle: child.type,
|
|
784
|
+
langcode: defaultLang,
|
|
785
|
+
attributes: child.attributes,
|
|
786
|
+
...(child.relationships ? { relationships: child.relationships } : {}),
|
|
787
|
+
});
|
|
788
|
+
} catch (err) {
|
|
789
|
+
const orphan = err?.entity;
|
|
790
|
+
if (orphan?.id) {
|
|
791
|
+
prepared.push({
|
|
792
|
+
id: orphan.id, bundle: child.type, role, revisionId: paragraphRevisionId(orphan),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (prepared.length) throw nestedDraftPartial(messageOf(err), prepared);
|
|
796
|
+
throw err;
|
|
797
|
+
}
|
|
798
|
+
const servedLang = created?.langcode ?? created?.fields?.langcode ?? "";
|
|
799
|
+
if (servedLang !== defaultLang) {
|
|
800
|
+
prepared.push({
|
|
801
|
+
id: created?.id, bundle: child.type, role, revisionId: paragraphRevisionId(created),
|
|
802
|
+
});
|
|
803
|
+
const reported = servedLang || "an unreported language";
|
|
804
|
+
throw nestedDraftPartial(
|
|
805
|
+
`paragraph ${created?.id} was created in "${reported}", not ${defaultLang}`,
|
|
806
|
+
prepared,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
const revisionId = paragraphRevisionId(created);
|
|
810
|
+
if (revisionId === null) {
|
|
811
|
+
prepared.push({ id: created?.id, bundle: child.type, role, revisionId: null });
|
|
812
|
+
throw nestedDraftPartial(`paragraph ${created?.id} has no revision id`, prepared);
|
|
813
|
+
}
|
|
814
|
+
const row = { id: created.id, bundle: child.type, role, revisionId };
|
|
815
|
+
prepared.push(row);
|
|
816
|
+
for (const translation of child.translations ?? []) {
|
|
817
|
+
if (translation.langcode === defaultLang) continue;
|
|
818
|
+
try {
|
|
819
|
+
await createTranslationDraft(backend, {
|
|
820
|
+
entityType: "paragraph",
|
|
821
|
+
bundle: child.type,
|
|
822
|
+
id: created.id,
|
|
823
|
+
langcode: translation.langcode,
|
|
824
|
+
attributes: translation.attributes,
|
|
825
|
+
draftRevision: { revisionId },
|
|
826
|
+
});
|
|
827
|
+
} catch (err) {
|
|
828
|
+
throw nestedDraftPartial(messageOf(err), prepared);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return embedParagraphRef(child.type, created.id, revisionId);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* @param {object} backend
|
|
836
|
+
* @param {object} spec
|
|
837
|
+
* @returns {Promise<object>}
|
|
838
|
+
*/
|
|
839
|
+
function hostDraftRequest(backend, { entityType, bundle, id, data, live, working, langcode, preflight }) {
|
|
840
|
+
return backend.rawQuery({
|
|
841
|
+
path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
|
|
842
|
+
options: {
|
|
843
|
+
method: "PATCH",
|
|
844
|
+
headers: {
|
|
845
|
+
"If-Match": working ? `"${live}:${working}"` : `"${live}"`,
|
|
846
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
847
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
848
|
+
},
|
|
849
|
+
body: JSON.stringify({ data }),
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* @param {object} backend
|
|
856
|
+
* @param {object} request
|
|
857
|
+
* @returns {Promise<void>}
|
|
858
|
+
*/
|
|
859
|
+
async function confirmHostPreflight(backend, request) {
|
|
860
|
+
const checked = await hostDraftRequest(backend, { ...request, preflight: true });
|
|
861
|
+
const meta = checked?.meta;
|
|
862
|
+
if (meta?.draft_preflight !== true || String(meta.live) !== request.live
|
|
863
|
+
|| String(meta.working ?? "") !== request.working
|
|
864
|
+
|| (!request.working && meta.operation !== OPEN_DRAFT_OPERATION)) {
|
|
865
|
+
throw new Error("The site did not confirm a non-saving host draft preflight.");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* @param {object} backend
|
|
871
|
+
* @param {object} spec
|
|
872
|
+
* @param {string} live
|
|
873
|
+
* @param {object[]} expectedWorkingPins
|
|
874
|
+
* @returns {Promise<?string>} Working revision id when the draft pin landed.
|
|
875
|
+
*/
|
|
876
|
+
async function confirmLanded(backend, spec, live, expectedWorkingPins) {
|
|
877
|
+
let published;
|
|
878
|
+
let workingEntity;
|
|
879
|
+
try {
|
|
880
|
+
published = await backend.getEntity({ entityType: spec.entityType, bundle: spec.bundle, id: spec.id });
|
|
881
|
+
workingEntity = await backend.getEntity({
|
|
882
|
+
entityType: spec.entityType, bundle: spec.bundle, id: spec.id, resourceVersion: "rel:working-copy",
|
|
883
|
+
});
|
|
884
|
+
} catch (err) {
|
|
885
|
+
return { ok: false, uncertain: true, detail: messageOf(err) };
|
|
886
|
+
}
|
|
887
|
+
if (!published || !workingEntity) {
|
|
888
|
+
return { ok: false, uncertain: true, detail: "a host revision was not returned" };
|
|
889
|
+
}
|
|
890
|
+
const publishedVid = entityRevisionId(published);
|
|
891
|
+
if (publishedVid === null || String(publishedVid) !== String(live)) {
|
|
892
|
+
return { ok: false, uncertain: false, detail: "the published revision changed" };
|
|
893
|
+
}
|
|
894
|
+
const publishedIds = fieldList(published, spec.field).map((ref) => ref?.id?.toLowerCase());
|
|
895
|
+
if (!publishedIds.includes(spec.parentId.toLowerCase())) {
|
|
896
|
+
return { ok: false, uncertain: false, detail: "the published host no longer pins the original component" };
|
|
897
|
+
}
|
|
898
|
+
const workingVid = entityRevisionId(workingEntity);
|
|
899
|
+
if (workingVid === null || String(workingVid) === String(live)) {
|
|
900
|
+
return { ok: false, uncertain: false, detail: "no forward revision was found" };
|
|
901
|
+
}
|
|
902
|
+
const workingKeys = fieldList(workingEntity, spec.field).map(pinKey);
|
|
903
|
+
const expectedKeys = expectedWorkingPins.map(pinKey);
|
|
904
|
+
if (workingKeys.length !== expectedKeys.length || workingKeys.some((key, index) => key !== expectedKeys[index])) {
|
|
905
|
+
return { ok: false, uncertain: false, detail: "the working copy did not pin the prepared revision" };
|
|
906
|
+
}
|
|
907
|
+
return { ok: true, workingVid: String(workingVid) };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* @param {{ok: boolean, uncertain?: boolean, detail?: string}} confirmed
|
|
912
|
+
* @param {string} fallback
|
|
913
|
+
* @param {object[]} prepared
|
|
914
|
+
* @returns {Error}
|
|
915
|
+
*/
|
|
916
|
+
function recoveryError(confirmed, fallback, prepared) {
|
|
917
|
+
if (confirmed.uncertain
|
|
918
|
+
|| confirmed.detail === "the published revision changed"
|
|
919
|
+
|| confirmed.detail === "the published host no longer pins the original component") {
|
|
920
|
+
const why = confirmed.uncertain ? `re-read failed: ${confirmed.detail}` : confirmed.detail;
|
|
921
|
+
return nestedDraftUncertain(`${fallback}; ${why}`, prepared);
|
|
922
|
+
}
|
|
923
|
+
const reason = confirmed.detail && confirmed.detail !== fallback
|
|
924
|
+
? `${fallback} (${confirmed.detail})`
|
|
925
|
+
: fallback;
|
|
926
|
+
return nestedDraftPartial(reason, prepared);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Copy the published component's non-default translations onto the new parent.
|
|
931
|
+
* The new paragraph is not the one the published host pins.
|
|
932
|
+
* @param {object} backend
|
|
933
|
+
* @param {object} source Published parent paragraph.
|
|
934
|
+
* @param {object} created Prepared parent row.
|
|
935
|
+
* @param {Set<string>} langs
|
|
936
|
+
* @param {object[]} prepared
|
|
937
|
+
* @returns {Promise<void>}
|
|
938
|
+
*/
|
|
939
|
+
async function copyParentTranslations(backend, source, created, langs, prepared) {
|
|
940
|
+
for (const lang of langs) {
|
|
941
|
+
let translated;
|
|
942
|
+
try {
|
|
943
|
+
translated = await backend.getEntity({
|
|
944
|
+
entityType: "paragraph",
|
|
945
|
+
bundle: created.bundle,
|
|
946
|
+
id: source.id,
|
|
947
|
+
langcode: lang,
|
|
948
|
+
resourceVersion: `id:${paragraphRevisionId(source)}`,
|
|
949
|
+
});
|
|
950
|
+
} catch (err) {
|
|
951
|
+
throw nestedDraftPartial(`the ${lang} translation could not be copied (${messageOf(err)})`, prepared);
|
|
952
|
+
}
|
|
953
|
+
if (!translated) {
|
|
954
|
+
throw nestedDraftPartial(`the ${lang} translation of the component could not be read`, prepared);
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
await createTranslationDraft(backend, {
|
|
958
|
+
entityType: "paragraph",
|
|
959
|
+
bundle: created.bundle,
|
|
960
|
+
id: created.id,
|
|
961
|
+
langcode: lang,
|
|
962
|
+
attributes: copiedAttributes(translated),
|
|
963
|
+
draftRevision: { revisionId: created.revisionId },
|
|
964
|
+
});
|
|
965
|
+
} catch (err) {
|
|
966
|
+
throw nestedDraftPartial(messageOf(err), prepared);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* @param {object} spec
|
|
973
|
+
* @param {string} live
|
|
974
|
+
* @param {string} workingVid
|
|
975
|
+
* @param {object} draftParent
|
|
976
|
+
* @param {object[]} prepared
|
|
977
|
+
* @param {boolean} opened
|
|
978
|
+
* @returns {object}
|
|
979
|
+
*/
|
|
980
|
+
function finished(spec, live, workingVid, draftParent, prepared, opened) {
|
|
981
|
+
return {
|
|
982
|
+
id: spec.id,
|
|
983
|
+
entityType: spec.entityType,
|
|
984
|
+
bundle: spec.bundle,
|
|
985
|
+
publishedParentId: spec.parentId,
|
|
986
|
+
draftParentId: draftParent.id,
|
|
987
|
+
publishedPinsUnchanged: true,
|
|
988
|
+
opensWorkingCopy: opened,
|
|
989
|
+
prepared,
|
|
990
|
+
_revisions: { live, working: workingVid },
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* @param {object} inventory
|
|
996
|
+
* @param {string} defaultLang
|
|
997
|
+
*/
|
|
998
|
+
function assertWorkingLanguageDraft(inventory, defaultLang) {
|
|
999
|
+
const row = (inventory.working.translations ?? []).find((item) => item?.langcode === defaultLang);
|
|
1000
|
+
const state = typeof row?.moderation_state === "string" ? row.moderation_state : "";
|
|
1001
|
+
if (!row || row.status === true || state === "published") {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`The default language (${defaultLang}) is still published on the working copy, so it cannot be continued. ` +
|
|
1004
|
+
"No write was attempted."
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* @param {object} backend
|
|
1011
|
+
* @param {object} spec
|
|
1012
|
+
* @param {string} working
|
|
1013
|
+
* @returns {Promise<object>}
|
|
1014
|
+
*/
|
|
1015
|
+
async function readHost(backend, spec, working) {
|
|
1016
|
+
try {
|
|
1017
|
+
const entity = await backend.getEntity({
|
|
1018
|
+
entityType: spec.entityType,
|
|
1019
|
+
bundle: spec.bundle,
|
|
1020
|
+
id: spec.id,
|
|
1021
|
+
...(working ? { resourceVersion: `id:${working}` } : {}),
|
|
1022
|
+
});
|
|
1023
|
+
if (!entity) throw new Error("The host revision was not returned.");
|
|
1024
|
+
return entity;
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) {
|
|
1027
|
+
throw new Error("Reading the host was denied. No write was attempted.");
|
|
1028
|
+
}
|
|
1029
|
+
throw err;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* @param {object} backend
|
|
1035
|
+
* @param {string} bundle
|
|
1036
|
+
* @param {string} id
|
|
1037
|
+
* @param {?number|string} [revisionId]
|
|
1038
|
+
* @returns {Promise<object>}
|
|
1039
|
+
*/
|
|
1040
|
+
async function readParagraph(backend, bundle, id, revisionId, langcode) {
|
|
1041
|
+
try {
|
|
1042
|
+
const entity = await backend.getEntity({
|
|
1043
|
+
entityType: "paragraph",
|
|
1044
|
+
bundle,
|
|
1045
|
+
id,
|
|
1046
|
+
...(revisionId ? { resourceVersion: `id:${revisionId}` } : {}),
|
|
1047
|
+
...(langcode ? { langcode } : {}),
|
|
1048
|
+
});
|
|
1049
|
+
if (!entity) throw new Error(`Paragraph ${id} was not returned.`);
|
|
1050
|
+
return entity;
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) {
|
|
1053
|
+
throw new Error("Reading a nested component was denied. No write was attempted.");
|
|
1054
|
+
}
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* @param {object} backend
|
|
1061
|
+
* @param {string} bundle
|
|
1062
|
+
* @param {string} id
|
|
1063
|
+
* @param {string} defaultLang
|
|
1064
|
+
* @param {number|string|null} revisionId Pinned paragraph revision, not a later canonical revision.
|
|
1065
|
+
* @returns {Promise<Set<string>>}
|
|
1066
|
+
*/
|
|
1067
|
+
async function requiredTranslationLangs(backend, bundle, id, defaultLang, revisionId) {
|
|
1068
|
+
try {
|
|
1069
|
+
const inventory = await readTranslationInventory(backend, {
|
|
1070
|
+
entityType: "paragraph", bundle, id, revisionId,
|
|
1071
|
+
});
|
|
1072
|
+
const langs = new Set();
|
|
1073
|
+
for (const row of inventory?.live?.translations ?? []) {
|
|
1074
|
+
if (row?.langcode && row.langcode !== defaultLang) langs.add(row.langcode);
|
|
1075
|
+
}
|
|
1076
|
+
return langs;
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
if (isMissingTranslationEndpoint(err)) return new Set();
|
|
1079
|
+
throw err;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* @param {?object} entity
|
|
1085
|
+
* @returns {object}
|
|
1086
|
+
*/
|
|
1087
|
+
function copiedAttributes(entity) {
|
|
1088
|
+
const fields = entity?.fields && typeof entity.fields === "object" ? entity.fields : {};
|
|
1089
|
+
const attributes = {};
|
|
1090
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
1091
|
+
if (key.startsWith("drupal_internal__") || SKIPPED_FIELDS.has(key)) continue;
|
|
1092
|
+
attributes[key] = value;
|
|
1093
|
+
}
|
|
1094
|
+
return attributes;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Copy every content relationship except the child field being replaced.
|
|
1099
|
+
* Authorship relationships are left for the create policy to stamp.
|
|
1100
|
+
* @param {?object} entity
|
|
1101
|
+
* @param {string} childField
|
|
1102
|
+
* @param {object[]} childData
|
|
1103
|
+
* @returns {object}
|
|
1104
|
+
*/
|
|
1105
|
+
function copiedRelationships(entity, childField, childData) {
|
|
1106
|
+
const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
|
|
1107
|
+
const out = {};
|
|
1108
|
+
for (const [key, value] of Object.entries(rels)) {
|
|
1109
|
+
if (key === childField || SKIP_RELATIONSHIPS.has(key)) continue;
|
|
1110
|
+
const payload = relationshipPayload(value);
|
|
1111
|
+
if (payload) out[key] = payload;
|
|
1112
|
+
}
|
|
1113
|
+
out[childField] = { data: childData };
|
|
1114
|
+
return out;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* The resumed paragraph must be the one this request would have created:
|
|
1119
|
+
* same copied fields, same other references, and the requested child pins.
|
|
1120
|
+
* @param {object} backend
|
|
1121
|
+
* @param {object} spec
|
|
1122
|
+
* @param {object} parent
|
|
1123
|
+
* @param {object[]} childPins
|
|
1124
|
+
* @param {object} resumed
|
|
1125
|
+
* @param {Set<string>} requiredLangs
|
|
1126
|
+
* @param {string} defaultLang
|
|
1127
|
+
* @returns {Promise<number|string>}
|
|
1128
|
+
*/
|
|
1129
|
+
async function assertResumeMatches(backend, spec, parent, childPins, resumed, requiredLangs, defaultLang) {
|
|
1130
|
+
const revisionId = paragraphRevisionId(resumed);
|
|
1131
|
+
if (revisionId === null || !sameId(resumed?.id, spec.resumeParentId)) {
|
|
1132
|
+
throw new Error("resumeParentId has no verified revision. No host draft was written.");
|
|
1133
|
+
}
|
|
1134
|
+
if (!sameAttributes(copiedAttributes(parent), copiedAttributes(resumed))
|
|
1135
|
+
|| !sameRelationships(parent, resumed, spec.childField)) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
"resumeParentId does not match the published component's fields and references. No host draft was written."
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
const pins = fieldList(resumed, spec.childField);
|
|
1141
|
+
if (pins.length !== spec.children.length) {
|
|
1142
|
+
throw new Error("resumeParentId does not match the requested child list. No host draft was written.");
|
|
1143
|
+
}
|
|
1144
|
+
const publishedChildIds = new Set(childPins.map((ref) => ref?.id?.toLowerCase()).filter(Boolean));
|
|
1145
|
+
for (let index = 0; index < spec.children.length; index += 1) {
|
|
1146
|
+
const child = spec.children[index];
|
|
1147
|
+
const pin = pins[index];
|
|
1148
|
+
requirePinRevision(pin, "A resumed child pin");
|
|
1149
|
+
if (child.op === "keep") {
|
|
1150
|
+
const live = childPins.find((ref) => sameId(ref?.id, child.id));
|
|
1151
|
+
if (!sameId(pin?.id, child.id) || String(revisionOf(pin)) !== String(revisionOf(live))) {
|
|
1152
|
+
throw new Error("resumeParentId does not keep the requested child revision. No host draft was written.");
|
|
1153
|
+
}
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (publishedChildIds.has(String(pin?.id ?? "").toLowerCase()) || sameId(pin?.id, child.id)) {
|
|
1157
|
+
throw new Error("resumeParentId still pins a published child. No host draft was written.");
|
|
1158
|
+
}
|
|
1159
|
+
const created = await readParagraph(backend, child.type, pin.id, revisionOf(pin));
|
|
1160
|
+
if (!sameId(created?.id, pin.id)
|
|
1161
|
+
|| created?.bundle !== child.type
|
|
1162
|
+
|| String(paragraphRevisionId(created)) !== String(revisionOf(pin))
|
|
1163
|
+
|| !sameAttributes(child.attributes, copiedAttributes(created))) {
|
|
1164
|
+
throw new Error("resumeParentId does not match the requested child fields. No host draft was written.");
|
|
1165
|
+
}
|
|
1166
|
+
for (const translation of child.translations) {
|
|
1167
|
+
if (translation.langcode === defaultLang) continue;
|
|
1168
|
+
const translated = await readParagraph(backend, child.type, pin.id, revisionOf(pin), translation.langcode);
|
|
1169
|
+
const served = translated?.langcode ?? translated?.fields?.langcode ?? "";
|
|
1170
|
+
if (served !== translation.langcode || !sameAttributes(translation.attributes, copiedAttributes(translated))) {
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
`resumeParentId is missing the ${translation.langcode} child translation. No host draft was written.`
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
for (const lang of requiredLangs) {
|
|
1178
|
+
const source = await readParagraph(backend, parent.bundle, parent.id, paragraphRevisionId(parent), lang);
|
|
1179
|
+
const copy = await readParagraph(backend, resumed.bundle || parent.bundle, resumed.id, revisionId, lang);
|
|
1180
|
+
const sourceLang = source?.langcode ?? source?.fields?.langcode ?? "";
|
|
1181
|
+
const copyLang = copy?.langcode ?? copy?.fields?.langcode ?? "";
|
|
1182
|
+
if (sourceLang !== lang || copyLang !== lang || !sameAttributes(copiedAttributes(source), copiedAttributes(copy))) {
|
|
1183
|
+
throw new Error(`resumeParentId is missing the ${lang} translation. No host draft was written.`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return revisionId;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* @param {object} expected
|
|
1191
|
+
* @param {object} actual
|
|
1192
|
+
* @returns {boolean}
|
|
1193
|
+
*/
|
|
1194
|
+
function sameAttributes(expected, actual) {
|
|
1195
|
+
const left = Object.keys(expected).sort();
|
|
1196
|
+
const right = Object.keys(actual).sort();
|
|
1197
|
+
if (left.length !== right.length || left.some((key, index) => key !== right[index])) return false;
|
|
1198
|
+
return left.every((key) => JSON.stringify(expected[key]) === JSON.stringify(actual[key]));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* @param {?object} left
|
|
1203
|
+
* @param {?object} right
|
|
1204
|
+
* @param {string} childField
|
|
1205
|
+
* @returns {boolean}
|
|
1206
|
+
*/
|
|
1207
|
+
function sameRelationships(left, right, childField) {
|
|
1208
|
+
const a = contentRelationshipKeys(left, childField);
|
|
1209
|
+
const b = contentRelationshipKeys(right, childField);
|
|
1210
|
+
const keys = Object.keys(a).sort();
|
|
1211
|
+
const other = Object.keys(b).sort();
|
|
1212
|
+
if (keys.length !== other.length || keys.some((key, index) => key !== other[index])) return false;
|
|
1213
|
+
return keys.every((key) => a[key] === b[key]);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* @param {?object} entity
|
|
1218
|
+
* @param {string} childField
|
|
1219
|
+
* @returns {object}
|
|
1220
|
+
*/
|
|
1221
|
+
function contentRelationshipKeys(entity, childField) {
|
|
1222
|
+
const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
|
|
1223
|
+
const out = {};
|
|
1224
|
+
for (const [key, value] of Object.entries(rels)) {
|
|
1225
|
+
if (key === childField || SKIP_RELATIONSHIPS.has(key)) continue;
|
|
1226
|
+
out[key] = linkageKey(value);
|
|
1227
|
+
}
|
|
1228
|
+
return out;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* @param {unknown} value
|
|
1233
|
+
* @returns {string}
|
|
1234
|
+
*/
|
|
1235
|
+
function linkageKey(value) {
|
|
1236
|
+
return asList(value).map((ref) => {
|
|
1237
|
+
const type = typeof ref?.type === "string" && ref.type
|
|
1238
|
+
? ref.type
|
|
1239
|
+
: (ref?.entityType && ref?.bundle ? `${ref.entityType}--${ref.bundle}` : "");
|
|
1240
|
+
const alt = typeof ref?.meta?.alt === "string" ? ref.meta.alt : "";
|
|
1241
|
+
return `${type}:${String(ref?.id ?? "").toLowerCase()}:${revisionOf(ref) ?? ""}:${alt}`;
|
|
1242
|
+
}).join("|");
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* @param {unknown} value
|
|
1247
|
+
* @returns {?object}
|
|
1248
|
+
*/
|
|
1249
|
+
function relationshipPayload(value) {
|
|
1250
|
+
const wrapped = value && typeof value === "object" && Object.hasOwn(value, "data") ? value.data : value;
|
|
1251
|
+
if (Array.isArray(wrapped)) return { data: wrapped.map(toLinkage).filter(Boolean) };
|
|
1252
|
+
if (wrapped && typeof wrapped === "object") {
|
|
1253
|
+
const one = toLinkage(wrapped);
|
|
1254
|
+
return one ? { data: one } : null;
|
|
1255
|
+
}
|
|
1256
|
+
if (wrapped === null) return { data: null };
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* @param {?object} ref
|
|
1262
|
+
* @returns {?object}
|
|
1263
|
+
*/
|
|
1264
|
+
function toLinkage(ref) {
|
|
1265
|
+
if (!ref || typeof ref !== "object") return null;
|
|
1266
|
+
const type = typeof ref.type === "string" && ref.type.includes("--")
|
|
1267
|
+
? ref.type
|
|
1268
|
+
: (ref.entityType && ref.bundle ? `${ref.entityType}--${ref.bundle}` : "");
|
|
1269
|
+
if (!type || !ref.id) return null;
|
|
1270
|
+
const out = { type, id: ref.id };
|
|
1271
|
+
const meta = {};
|
|
1272
|
+
const revisionId = ref.meta?.target_revision_id;
|
|
1273
|
+
if (revisionId !== undefined && revisionId !== null && revisionId !== "" && Number.isFinite(Number(revisionId))) {
|
|
1274
|
+
meta.target_revision_id = Number(revisionId);
|
|
1275
|
+
}
|
|
1276
|
+
if (typeof ref.meta?.alt === "string") meta.alt = ref.meta.alt;
|
|
1277
|
+
if (typeof ref.meta?.title === "string") meta.title = ref.meta.title;
|
|
1278
|
+
if (Object.keys(meta).length > 0) out.meta = meta;
|
|
1279
|
+
return out;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* @param {?object} entity
|
|
1284
|
+
* @param {string} field
|
|
1285
|
+
* @returns {object[]}
|
|
1286
|
+
*/
|
|
1287
|
+
function fieldList(entity, field) {
|
|
1288
|
+
const rels = entity?.relationships;
|
|
1289
|
+
if (!rels || typeof rels !== "object" || !Object.hasOwn(rels, field)) return [];
|
|
1290
|
+
return asList(rels[field]);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* @param {unknown} value
|
|
1295
|
+
* @returns {object[]}
|
|
1296
|
+
*/
|
|
1297
|
+
function asList(value) {
|
|
1298
|
+
if (Array.isArray(value)) return value;
|
|
1299
|
+
if (value && typeof value === "object" && Object.hasOwn(value, "data")) {
|
|
1300
|
+
if (value.data === null || value.data === undefined) return [];
|
|
1301
|
+
return Array.isArray(value.data) ? value.data : [value.data];
|
|
1302
|
+
}
|
|
1303
|
+
if (value && typeof value === "object") return [value];
|
|
1304
|
+
return [];
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* @param {?object} ref
|
|
1309
|
+
* @returns {string}
|
|
1310
|
+
*/
|
|
1311
|
+
function paragraphBundle(ref) {
|
|
1312
|
+
const type = typeof ref?.type === "string" ? ref.type : "";
|
|
1313
|
+
if (type.startsWith("paragraph--")) return type.slice("paragraph--".length);
|
|
1314
|
+
if (ref?.entityType === "paragraph" && typeof ref.bundle === "string") return ref.bundle;
|
|
1315
|
+
return "";
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* @param {string} type
|
|
1320
|
+
* @param {string} where
|
|
1321
|
+
* @returns {string}
|
|
1322
|
+
*/
|
|
1323
|
+
function paragraphType(type, where) {
|
|
1324
|
+
const raw = String(type ?? "");
|
|
1325
|
+
const bundle = raw.startsWith("paragraph--") ? raw.slice("paragraph--".length) : raw;
|
|
1326
|
+
if (!NAME_RE.test(bundle) || (raw.includes("--") && !raw.startsWith("paragraph--"))) {
|
|
1327
|
+
throw new Error(`${where}.type must be a paragraph bundle.`);
|
|
1328
|
+
}
|
|
1329
|
+
return bundle;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* @param {?object} ref
|
|
1334
|
+
* @returns {boolean}
|
|
1335
|
+
*/
|
|
1336
|
+
function isReusable(ref) {
|
|
1337
|
+
if (ref?.entityType === "paragraphs_library_item") return true;
|
|
1338
|
+
const type = typeof ref?.type === "string" ? ref.type : "";
|
|
1339
|
+
return type.startsWith("paragraphs_library_item") || paragraphBundle(ref) === "from_library";
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* @returns {Error}
|
|
1344
|
+
*/
|
|
1345
|
+
function reusableError() {
|
|
1346
|
+
return new Error(
|
|
1347
|
+
"This component is a reusable library item. Update it with drupal_entity_update. " +
|
|
1348
|
+
"No nested draft was started, and the shared paragraph was not edited."
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* @param {?object} ref
|
|
1354
|
+
* @returns {?number|string}
|
|
1355
|
+
*/
|
|
1356
|
+
function revisionOf(ref) {
|
|
1357
|
+
const revisionId = ref?.meta?.target_revision_id;
|
|
1358
|
+
return revisionId === undefined || revisionId === null || revisionId === "" ? null : revisionId;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* @param {?object} ref
|
|
1363
|
+
* @returns {object}
|
|
1364
|
+
*/
|
|
1365
|
+
function pinRef(ref) {
|
|
1366
|
+
const bundle = paragraphBundle(ref);
|
|
1367
|
+
return embedParagraphRef(bundle, ref.id, revisionOf(ref));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* UUID plus the pinned paragraph revision. The same UUID at another revision
|
|
1372
|
+
* is not the prepared draft.
|
|
1373
|
+
* @param {?object} ref
|
|
1374
|
+
* @returns {string}
|
|
1375
|
+
*/
|
|
1376
|
+
function pinKey(ref) {
|
|
1377
|
+
return `${String(ref?.id ?? "").toLowerCase()}#${String(revisionOf(ref) ?? "")}`;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* @param {unknown} err
|
|
1382
|
+
* @returns {string}
|
|
1383
|
+
*/
|
|
1384
|
+
function messageOf(err) {
|
|
1385
|
+
return err instanceof Error ? err.message : String(err);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* @param {?string} left
|
|
1390
|
+
* @param {?string} right
|
|
1391
|
+
* @returns {boolean}
|
|
1392
|
+
*/
|
|
1393
|
+
function sameId(left, right) {
|
|
1394
|
+
return String(left ?? "").toLowerCase() === String(right ?? "").toLowerCase();
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* @param {?object} ref
|
|
1399
|
+
* @param {string} label
|
|
1400
|
+
* @returns {number|string}
|
|
1401
|
+
*/
|
|
1402
|
+
function requirePinRevision(ref, label) {
|
|
1403
|
+
const revisionId = revisionOf(ref);
|
|
1404
|
+
const numeric = Number(revisionId);
|
|
1405
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
1406
|
+
throw new Error(`${label} has no paragraph revision id. No write was attempted.`);
|
|
1407
|
+
}
|
|
1408
|
+
return revisionId;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* @param {object[]} hostPins
|
|
1413
|
+
* @param {object[]} childPins
|
|
1414
|
+
* @returns {Set<string>}
|
|
1415
|
+
*/
|
|
1416
|
+
function liveParagraphIds(hostPins, childPins) {
|
|
1417
|
+
return new Set(
|
|
1418
|
+
[...hostPins, ...childPins].map((ref) => ref?.id?.toLowerCase()).filter(Boolean),
|
|
1419
|
+
);
|
|
1420
|
+
}
|