PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
← All changes | src/Agent/workflows/block-selector/tools/block-patching.js +124 -18 3.1.6 → 3.2.2 View file →
@@ -1,9 +1,13 @@
1 1 import { fetchBlockCodeById } from '@agent/lib/block-code';
2 +import { findBlockEl, scopeOf } from '@agent/lib/block-el';
2 3 import { applyBlockPatch } from '@agent/lib/block-patch';
4 +import { duplicateMarkup } from '@agent/lib/duplicate-block';
3 5 import { buildNewBlock } from '@agent/lib/insertable-blocks';
6 +import { fetchLinkablePages } from '@agent/lib/linkable-pages';
4 7 import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
5 8 import { swapBlockImage } from '@agent/lib/replace-image';
9 +import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
6 10 import { useQuickEditStore } from '@quick-edit/state/store';
7 11 import apiFetch from '@wordpress/api-fetch';
8 12
9 13 // clear lists attributes to reset — null in a patch means "no change", not "remove".
@@ -20,15 +24,51 @@
20 24 if (blockCode === previousContent) return null;
21 25 return { op: 'edit', blockId, block: blockCode };
22 26 };
23 27
24 -const buildAdd = ({ anchorId, position, blockType, patch, clear }) => {
28 +const ANCHOR_ROW_CLASS = { 'core/social-link': 'wp-block-social-link' };
29 +
30 +const joinsAnchorRow = (anchorId, blockType, source) => {
31 + const className = ANCHOR_ROW_CLASS[blockType];
32 + if (!className || anchorId == null) return false;
33 + const anchor = findBlockEl(String(anchorId), document, scopeOf({ source }));
34 + return Boolean(anchor?.classList?.contains(className));
35 +};
36 +
37 +// Unbound, the editor shows a bare custom URL and WP keeps linking a deleted page.
38 +const bindPage = async (patch) => {
39 + const pages = await fetchLinkablePages();
40 + const page = pages.find(({ url, link }) => [url, link].includes(patch?.url));
41 + return page
42 + ? { ...patch, id: page.id, type: 'page', kind: 'post-type' }
43 + : patch;
44 +};
45 +
46 +const buildAdd = async (
47 + { anchorId, position, blockType, patch, clear },
48 + source,
49 +) => {
25 50 const presetSlugs = window.extAgentData?.context?.presetSlugs ?? {};
26 - const block = buildNewBlock(blockType, patch, clear ?? [], presetSlugs);
51 + const linked =
52 + blockType === 'core/navigation-link' ? await bindPage(patch) : patch;
53 + const block = buildNewBlock(blockType, linked, clear ?? [], presetSlugs, {
54 + joinsRow: joinsAnchorRow(anchorId, blockType, source),
55 + });
27 56 if (!block) return null;
28 57 return { op: 'add', anchorId, position, block };
29 58 };
30 59
60 +const buildDuplicate = async (operation, source, postId) => {
61 + const { blockId, targetId, position } = operation;
62 + const block = await duplicateMarkup(operation, source, postId);
63 + return {
64 + op: 'duplicate',
65 + blockId,
66 + ...(targetId && { targetId, position: position ?? 'after' }),
67 + ...(block && { block }),
68 + };
69 +};
70 +
31 71 // The backend op has no image — the confirm UI attaches it before the tool runs.
32 72 const buildReplaceImage = async ({ blockId, image }, source, postId) => {
33 73 if (!image) return null;
34 74 const previousContent = await fetchBlockCodeById(blockId, source, postId);
@@ -48,11 +88,43 @@
48 88 targetId,
49 89 position,
50 90 }),
51 91 wrap: ({ blockId, container }) => ({ op: 'wrap', blockId, container }),
92 + duplicate: buildDuplicate,
52 93 add: buildAdd,
53 94 };
54 95
96 +const writeSetting = (data) =>
97 + apiFetch({ path: '/wp/v2/settings', method: 'POST', data });
98 +
99 +// The text belongs in the option row; the rest of the patch is block markup.
100 +const settingTextBridge = (setting) => (operation) => {
101 + const { text, ...attrs } = operation?.patch ?? {};
102 + if (text == null) return null;
103 + return {
104 + commit: () => writeSetting({ [setting]: text }),
105 + rest: Object.keys(attrs).length ? { ...operation, patch: attrs } : null,
106 + };
107 +};
108 +
109 +// Content living outside the block markup gets a per-block-type bridge, which
110 +// returns the part of the op it didn't consume — null when it handles nothing.
111 +// An option row has no rollback, so `commit` waits for the block save.
112 +const CONTENT_BRIDGES = {
113 + 'core/site-logo': {
114 + 'replace-image': ({ image }) =>
115 + image?.id
116 + ? { commit: () => writeSetting({ site_logo: image.id }), rest: null }
117 + : null,
118 + },
119 + ...Object.fromEntries(
120 + Object.entries(SETTING_TEXT_BLOCKS).map(([blockType, setting]) => [
121 + blockType,
122 + { edit: settingTextBridge(setting) },
123 + ]),
124 + ),
125 +};
126 +
55 127 export default async (input) => {
56 128 await ensureCoreBlocksRegistered();
57 129 const operations = Array.isArray(input?.operations) ? input.operations : [];
58 130 if (!operations.length) return { refused: true, reason: 'no-block' };
@@ -57,30 +129,64 @@
57 129 const operations = Array.isArray(input?.operations) ? input.operations : [];
58 130 if (!operations.length) return { refused: true, reason: 'no-block' };
59 131
60 132 const { agentBlock } = useQuickEditStore.getState();
61 - // Template parts have a separate id space; refusing beats a silent no-op.
62 - if (agentBlock?.source?.kind === 'template-part')
63 - return { refused: true, reason: 'template-part' };
64 -
133 + const source = agentBlock?.source;
65 134 const { postId } = window.extAgentData?.context ?? {};
135 + const bridges = CONTENT_BRIDGES[agentBlock?.blockType] ?? {};
136 + const bridged = operations.map((operation) =>
137 + bridges[operation?.op] ? bridges[operation.op](operation) : null,
138 + );
66 139 const results = await Promise.all(
67 - operations.map((operation) =>
68 - BUILDERS[operation?.op]?.(operation, agentBlock?.source, postId),
69 - ),
140 + operations.map((operation, index) => {
141 + const remaining = bridged[index] ? bridged[index].rest : operation;
142 + return remaining
143 + ? BUILDERS[remaining?.op]?.(remaining, source, postId)
144 + : null;
145 + }),
70 146 );
147 +
71 148 const built = results.filter(Boolean);
72 149 // A no-op build must surface, or the reply claims a change that never happened.
73 150 const dropped = operations
74 - .filter((operation, index) => operation && !results[index])
151 + .filter(
152 + (operation, index) => operation && !results[index] && !bridged[index],
153 + )
75 154 .map(({ blockId }) => ({ blockId, reason: 'no-change' }));
76 - if (!built.length) return { refused: true, reason: 'no-op' };
155 + if (!built.length && !bridged.some(Boolean)) {
156 + return { refused: true, reason: 'no-op' };
157 + }
77 158
78 - const { applied = [], refused = [] } = await apiFetch({
79 - path: '/extendify/v1/agent/update-blocks',
80 - method: 'POST',
81 - data: { postId, operations: built },
82 - });
83 - if (!applied.length) return { refused: true, reason: 'not-applied' };
159 + let applied = [];
160 + let refused = [];
161 + if (built.length) {
162 + const scope =
163 + source?.kind === 'template-part'
164 + ? { partSlug: source.partSlug }
165 + : { postId };
166 + ({ applied = [], refused = [] } = await apiFetch({
167 + path: '/extendify/v1/agent/update-blocks',
168 + method: 'POST',
169 + data: { ...scope, operations: built },
170 + }));
171 + if (!applied.length) return { refused: true, reason: 'not-applied' };
172 + }
173 +
174 + // An option row has no rollback, so a bridge waits on its own op, not the batch.
175 + const landed = new Set(applied.map(({ blockId }) => String(blockId)));
176 + const committed = [];
177 + for (const [index, entry] of bridged.entries()) {
178 + if (!entry) continue;
179 + if (results[index] && !landed.has(String(operations[index].blockId))) {
180 + continue;
181 + }
182 + await entry.commit();
183 + committed.push({ op: operations[index].op });
184 + }
185 +
84 186 // Not `refused`: Agent.jsx reads any truthy `refused` — even [] — as a refusal.
85 - return { ok: true, applied, refusedOperations: [...refused, ...dropped] };
187 + return {
188 + ok: true,
189 + applied: [...committed, ...applied],
190 + refusedOperations: [...refused, ...dropped],
191 + };
86 192 };