← All changes
|
src/Agent/workflows/block-selector/tools/block-patching.js
+124
-18
3.1.5
→
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 | }; |