PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 0.6.0 All 127 releases
extendify / src / Agent / workflows / block-selector / tools / block-patching.js

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

153 lines 5.3 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 { applyBlockPatch } from '@agent/lib/block-patch';
3 import { buildNewBlock } from '@agent/lib/insertable-blocks';
4 import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
5 import { swapBlockImage } from '@agent/lib/replace-image';
6 import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
7 import { useQuickEditStore } from '@quick-edit/state/store';
8 import apiFetch from '@wordpress/api-fetch';
9
10 // clear lists attributes to reset — null in a patch means "no change", not "remove".
11 const buildEdit = async ({ blockId, patch, clear }, source, postId) => {
12 const previousContent = await fetchBlockCodeById(blockId, source, postId);
13 if (!previousContent) return null;
14 const presetSlugs = window.extAgentData?.context?.presetSlugs ?? {};
15 const blockCode = applyBlockPatch(
16 previousContent,
17 patch,
18 clear ?? [],
19 presetSlugs,
20 );
21 if (blockCode === previousContent) return null;
22 return { op: 'edit', blockId, block: blockCode };
23 };
24
25 const buildAdd = ({ anchorId, position, blockType, patch, clear }) => {
26 const presetSlugs = window.extAgentData?.context?.presetSlugs ?? {};
27 const block = buildNewBlock(blockType, patch, clear ?? [], presetSlugs);
28 if (!block) return null;
29 return { op: 'add', anchorId, position, block };
30 };
31
32 // The backend op has no image — the confirm UI attaches it before the tool runs.
33 const buildReplaceImage = async ({ blockId, image }, source, postId) => {
34 if (!image) return null;
35 const previousContent = await fetchBlockCodeById(blockId, source, postId);
36 if (!previousContent) return null;
37 const blockCode = swapBlockImage(previousContent, image);
38 if (!blockCode || blockCode === previousContent) return null;
39 return { op: 'edit', blockId, block: blockCode };
40 };
41
42 const BUILDERS = {
43 edit: buildEdit,
44 'replace-image': buildReplaceImage,
45 delete: ({ blockId }) => ({ op: 'delete', blockId }),
46 move: ({ blockId, targetId, position }) => ({
47 op: 'move',
48 blockId,
49 targetId,
50 position,
51 }),
52 wrap: ({ blockId, container }) => ({ op: 'wrap', blockId, container }),
53 add: buildAdd,
54 };
55
56 const writeSetting = (data) =>
57 apiFetch({ path: '/wp/v2/settings', method: 'POST', data });
58
59 // The text belongs in the option row; the rest of the patch is block markup.
60 const settingTextBridge = (setting) => (operation) => {
61 const { text, ...attrs } = operation?.patch ?? {};
62 if (text == null) return null;
63 return {
64 commit: () => writeSetting({ [setting]: text }),
65 rest: Object.keys(attrs).length ? { ...operation, patch: attrs } : null,
66 };
67 };
68
69 // Content living outside the block markup gets a per-block-type bridge, which
70 // returns the part of the op it didn't consume — null when it handles nothing.
71 // An option row has no rollback, so `commit` waits for the block save.
72 const CONTENT_BRIDGES = {
73 'core/site-logo': {
74 'replace-image': ({ image }) =>
75 image?.id
76 ? { commit: () => writeSetting({ site_logo: image.id }), rest: null }
77 : null,
78 },
79 ...Object.fromEntries(
80 Object.entries(SETTING_TEXT_BLOCKS).map(([blockType, setting]) => [
81 blockType,
82 { edit: settingTextBridge(setting) },
83 ]),
84 ),
85 };
86
87 export default async (input) => {
88 await ensureCoreBlocksRegistered();
89 const operations = Array.isArray(input?.operations) ? input.operations : [];
90 if (!operations.length) return { refused: true, reason: 'no-block' };
91
92 const { agentBlock } = useQuickEditStore.getState();
93 const source = agentBlock?.source;
94 const { postId } = window.extAgentData?.context ?? {};
95 const bridges = CONTENT_BRIDGES[agentBlock?.blockType] ?? {};
96 const bridged = operations.map((operation) =>
97 bridges[operation?.op] ? bridges[operation.op](operation) : null,
98 );
99 const results = await Promise.all(
100 operations.map((operation, index) => {
101 const remaining = bridged[index] ? bridged[index].rest : operation;
102 return remaining
103 ? BUILDERS[remaining?.op]?.(remaining, source, postId)
104 : null;
105 }),
106 );
107
108 const built = results.filter(Boolean);
109 // A no-op build must surface, or the reply claims a change that never happened.
110 const dropped = operations
111 .filter(
112 (operation, index) => operation && !results[index] && !bridged[index],
113 )
114 .map(({ blockId }) => ({ blockId, reason: 'no-change' }));
115 if (!built.length && !bridged.some(Boolean)) {
116 return { refused: true, reason: 'no-op' };
117 }
118
119 let applied = [];
120 let refused = [];
121 if (built.length) {
122 const scope =
123 source?.kind === 'template-part'
124 ? { partSlug: source.partSlug }
125 : { postId };
126 ({ applied = [], refused = [] } = await apiFetch({
127 path: '/extendify/v1/agent/update-blocks',
128 method: 'POST',
129 data: { ...scope, operations: built },
130 }));
131 if (!applied.length) return { refused: true, reason: 'not-applied' };
132 }
133
134 // An option row has no rollback, so a bridge waits on its own op, not the batch.
135 const landed = new Set(applied.map(({ blockId }) => String(blockId)));
136 const committed = [];
137 for (const [index, entry] of bridged.entries()) {
138 if (!entry) continue;
139 if (results[index] && !landed.has(String(operations[index].blockId))) {
140 continue;
141 }
142 await entry.commit();
143 committed.push({ op: operations[index].op });
144 }
145
146 // Not `refused`: Agent.jsx reads any truthy `refused` — even [] — as a refusal.
147 return {
148 ok: true,
149 applied: [...committed, ...applied],
150 refusedOperations: [...refused, ...dropped],
151 };
152 };
153