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
extendify / src / Agent / workflows / block-selector / tools / block-patching.js

block-patching.js in Extendify 3.2.2, at src/Agent/workflows/block-selector/tools/block-patching.js

193 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { fetchBlockCodeById } from '@agent/lib/block-code';
2 import { findBlockEl, scopeOf } from '@agent/lib/block-el';
3 import { applyBlockPatch } from '@agent/lib/block-patch';
4 import { duplicateMarkup } from '@agent/lib/duplicate-block';
5 import { buildNewBlock } from '@agent/lib/insertable-blocks';
6 import { fetchLinkablePages } from '@agent/lib/linkable-pages';
7 import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
8 import { swapBlockImage } from '@agent/lib/replace-image';
9 import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
10 import { useQuickEditStore } from '@quick-edit/state/store';
11 import apiFetch from '@wordpress/api-fetch';
12
13 // clear lists attributes to reset — null in a patch means "no change", not "remove".
14 const buildEdit = async ({ blockId, patch, clear }, source, postId) => {
15 const previousContent = await fetchBlockCodeById(blockId, source, postId);
16 if (!previousContent) return null;
17 const presetSlugs = window.extAgentData?.context?.presetSlugs ?? {};
18 const blockCode = applyBlockPatch(
19 previousContent,
20 patch,
21 clear ?? [],
22 presetSlugs,
23 );
24 if (blockCode === previousContent) return null;
25 return { op: 'edit', blockId, block: blockCode };
26 };
27
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 ) => {
50 const presetSlugs = window.extAgentData?.context?.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 });
56 if (!block) return null;
57 return { op: 'add', anchorId, position, block };
58 };
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
71 // The backend op has no image — the confirm UI attaches it before the tool runs.
72 const buildReplaceImage = async ({ blockId, image }, source, postId) => {
73 if (!image) return null;
74 const previousContent = await fetchBlockCodeById(blockId, source, postId);
75 if (!previousContent) return null;
76 const blockCode = swapBlockImage(previousContent, image);
77 if (!blockCode || blockCode === previousContent) return null;
78 return { op: 'edit', blockId, block: blockCode };
79 };
80
81 const BUILDERS = {
82 edit: buildEdit,
83 'replace-image': buildReplaceImage,
84 delete: ({ blockId }) => ({ op: 'delete', blockId }),
85 move: ({ blockId, targetId, position }) => ({
86 op: 'move',
87 blockId,
88 targetId,
89 position,
90 }),
91 wrap: ({ blockId, container }) => ({ op: 'wrap', blockId, container }),
92 duplicate: buildDuplicate,
93 add: buildAdd,
94 };
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
127 export default async (input) => {
128 await ensureCoreBlocksRegistered();
129 const operations = Array.isArray(input?.operations) ? input.operations : [];
130 if (!operations.length) return { refused: true, reason: 'no-block' };
131
132 const { agentBlock } = useQuickEditStore.getState();
133 const source = agentBlock?.source;
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 );
139 const results = await Promise.all(
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 }),
146 );
147
148 const built = results.filter(Boolean);
149 // A no-op build must surface, or the reply claims a change that never happened.
150 const dropped = operations
151 .filter(
152 (operation, index) => operation && !results[index] && !bridged[index],
153 )
154 .map(({ blockId }) => ({ blockId, reason: 'no-change' }));
155 if (!built.length && !bridged.some(Boolean)) {
156 return { refused: true, reason: 'no-op' };
157 }
158
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
186 // Not `refused`: Agent.jsx reads any truthy `refused` — even [] — as a refusal.
187 return {
188 ok: true,
189 applied: [...committed, ...applied],
190 refusedOperations: [...refused, ...dropped],
191 };
192 };
193