PluginProbe
Extendify / trunk
Extendify vtrunk
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
← All changes | src/Agent/workflows/block-selector/tools/block-patching.js +82 -16 3.1.4 → trunk View file →
@@ -2,8 +2,9 @@
2 2 import { applyBlockPatch } from '@agent/lib/block-patch';
3 3 import { buildNewBlock } from '@agent/lib/insertable-blocks';
4 4 import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
5 5 import { swapBlockImage } from '@agent/lib/replace-image';
6 +import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
6 7 import { useQuickEditStore } from '@quick-edit/state/store';
7 8 import apiFetch from '@wordpress/api-fetch';
8 9
9 10 // clear lists attributes to reset — null in a patch means "no change", not "remove".
@@ -51,8 +52,39 @@
51 52 wrap: ({ blockId, container }) => ({ op: 'wrap', blockId, container }),
52 53 add: buildAdd,
53 54 };
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 +
55 87 export default async (input) => {
56 88 await ensureCoreBlocksRegistered();
57 89 const operations = Array.isArray(input?.operations) ? input.operations : [];
58 90 if (!operations.length) return { refused: true, reason: 'no-block' };
@@ -57,30 +89,64 @@
57 89 const operations = Array.isArray(input?.operations) ? input.operations : [];
58 90 if (!operations.length) return { refused: true, reason: 'no-block' };
59 91
60 92 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 -
93 + const source = agentBlock?.source;
65 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 + );
66 99 const results = await Promise.all(
67 - operations.map((operation) =>
68 - BUILDERS[operation?.op]?.(operation, agentBlock?.source, postId),
69 - ),
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 + }),
70 106 );
107 +
71 108 const built = results.filter(Boolean);
72 109 // A no-op build must surface, or the reply claims a change that never happened.
73 110 const dropped = operations
74 - .filter((operation, index) => operation && !results[index])
111 + .filter(
112 + (operation, index) => operation && !results[index] && !bridged[index],
113 + )
75 114 .map(({ blockId }) => ({ blockId, reason: 'no-change' }));
76 - if (!built.length) return { refused: true, reason: 'no-op' };
115 + if (!built.length && !bridged.some(Boolean)) {
116 + return { refused: true, reason: 'no-op' };
117 + }
77 118
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' };
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 +
84 146 // Not `refused`: Agent.jsx reads any truthy `refused` — even [] — as a refusal.
85 - return { ok: true, applied, refusedOperations: [...refused, ...dropped] };
147 + return {
148 + ok: true,
149 + applied: [...committed, ...applied],
150 + refusedOperations: [...refused, ...dropped],
151 + };
86 152 };