PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-gutenberg.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 1 week ago class-avcf-abilities-core.php 3 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 1 week ago class-avcf-abilities-metadata.php 1 week ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 days ago class-avcf-abilities-readonly.php 2 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 2 weeks ago class-avcf-abilities-themes.php 3 days ago class-avcf-abilities-users.php 3 weeks ago class-avcf-abilities-wp-cli.php 3 days ago
class-avcf-abilities-gutenberg.php
944 lines
1 <?php
2 /**
3 * Gutenberg — MCP abilities (native block editing) + shared helpers.
4 *
5 * Single-file cluster (matches the doit/abilities/ convention). Two classes:
6 * AVCF_Gutenberg_Helpers — block-tree round-trip (parse_blocks/serialize_blocks)
7 * with stable metadata.avcBlockId identity, content hashing, block-type +
8 * editability classification, and path/id addressing.
9 * AVCF_Abilities_Gutenberg — the abilities (reads now: read-page, read-block,
10 * list-block-types; the apply-operations write engine + markdown bridge land here).
11 *
12 * Page/post creation and revisions are intentionally NOT here — use the existing
13 * Content-cluster abilities (create-content, list-revisions, restore-revision).
14 * Built on core WP block functions; not runtime-tested here.
15 *
16 * @package atarim-visual-collaboration
17 */
18
19 if ( ! defined('ABSPATH') ) {
20 exit;
21 }
22
23 class AVCF_Gutenberg_Helpers {
24
25 /** Curated static blocks whose canonical markup the bridge can regenerate. */
26 public static function curated_blocks() {
27 return [
28 'core/paragraph', 'core/heading', 'core/list', 'core/list-item',
29 'core/quote', 'core/pullquote', 'core/code', 'core/preformatted',
30 'core/image', 'core/separator', 'core/spacer', 'core/buttons',
31 'core/button', 'core/group', 'core/columns', 'core/column', 'core/html',
32 ];
33 }
34
35 public static function uuid() { return (string) wp_generate_uuid4(); }
36
37 public static function content_hash( $content ) { return sha1( (string) $content ); }
38
39 /* ---------------------------- round-trip ---------------------------
40 *
41 * Read and write are deliberately ASYMMETRIC. Do not "tidy" this into a
42 * symmetric tree<->markup round-trip; that is the bug this replaced.
43 *
44 * READ parse_tree() projects the raw parse_blocks() array into a lean
45 * nested view for the model. It is LOSSY BY DESIGN: it drops the
46 * literal HTML chunks that live between a container's children
47 * (the <ul class="wp-block-list"> around list items, the
48 * <div class="wp-block-group"> around a group's children, ...)
49 * and the whitespace nodes between siblings. It must NEVER be
50 * serialized back into post_content.
51 *
52 * WRITE apply_operations() mutates the raw parse_blocks() array in
53 * place and hands it straight to serialize_blocks(). Nothing is
54 * translated, so nothing is lost. Same shape as do-it.php.
55 *
56 * Paths are chains of RAW parse_blocks indices, so a path taken off the
57 * read view addresses the same block in the raw array. The read view skips
58 * whitespace nodes, which means published paths are NOT contiguous
59 * (0, 2, 4, ...). Never renumber them.
60 *
61 * A block's innerContent is the interleave that serialize_block() walks:
62 * every string is emitted literally, every null consumes the next entry of
63 * innerBlocks. Adding or removing a child WITHOUT keeping the null count in
64 * step silently drops or duplicates content -- see splice_children() and
65 * unsplice_child(), which are the only two places allowed to touch it.
66 */
67
68 public static function parse_raw( $content ) {
69 return parse_blocks( (string) $content );
70 }
71
72 public static function serialize_raw( $blocks ) {
73 return serialize_blocks( (array) $blocks );
74 }
75
76 public static function parse_tree( $content ) {
77 return self::blocks_to_tree( parse_blocks( (string) $content ) );
78 }
79
80 /** Read-only projection. Each node carries its raw index so paths stay truthful. */
81 private static function blocks_to_tree( $blocks ) {
82 $tree = [];
83 foreach ( (array) $blocks as $i => $block ) {
84 $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
85 $inner_blocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
86 $inner_html = isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : '';
87 if ( $name === '' && trim( $inner_html ) === '' ) { continue; } // whitespace between blocks
88 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
89 $node = [ 'index' => (int) $i, 'block' => $name ];
90 $id = self::extract_id( $attrs );
91 if ( $id !== '' ) { $node['id'] = $id; }
92 if ( $attrs !== [] ) { $node['attrs'] = $attrs; }
93 if ( $inner_blocks ) { $node['children'] = self::blocks_to_tree( $inner_blocks ); }
94 elseif ( trim( $inner_html ) !== '' ) { $node['html'] = $inner_html; }
95 $tree[] = $node;
96 }
97 return $tree;
98 }
99
100 public static function extract_id( $attrs ) {
101 if ( is_array( $attrs ) && isset( $attrs['metadata'] ) && is_array( $attrs['metadata'] ) && isset( $attrs['metadata']['avcBlockId'] ) && is_string( $attrs['metadata']['avcBlockId'] ) ) {
102 return $attrs['metadata']['avcBlockId'];
103 }
104 return '';
105 }
106 public static function set_id( $attrs, $id ) {
107 if ( ! is_array( $attrs ) ) { $attrs = []; }
108 if ( ! isset( $attrs['metadata'] ) || ! is_array( $attrs['metadata'] ) ) { $attrs['metadata'] = []; }
109 $attrs['metadata']['avcBlockId'] = (string) $id;
110 return $attrs;
111 }
112
113 /** Is the post block-based (has block delimiters) vs classic HTML? */
114 public static function is_block_based( $content ) {
115 return strpos( (string) $content, '<!-- wp:' ) !== false;
116 }
117 /** Classic content carried as a freeform block with real HTML (edit would be lossy). */
118 public static function is_classic( $content ) {
119 $c = (string) $content;
120 return $c !== '' && ! self::is_block_based( $c );
121 }
122
123 /* --------------------------- block types --------------------------- */
124
125 public static function block_type_info( $name ) {
126 $info = [ 'name' => (string) $name, 'exists' => false, 'dynamic' => false, 'title' => '', 'category' => '' ];
127 if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return $info; }
128 $reg = \WP_Block_Type_Registry::get_instance();
129 $type = $reg ? $reg->get_registered( $name ) : null;
130 if ( $type === null ) { return $info; }
131 $info['exists'] = true;
132 $info['dynamic'] = ! empty( $type->render_callback );
133 $info['title'] = isset( $type->title ) ? (string) $type->title : '';
134 $info['category'] = isset( $type->category ) ? (string) $type->category : '';
135 return $info;
136 }
137
138 /**
139 * Editability class for a block type:
140 * 'attr' dynamic block — attrs editable, no innerHTML validation risk
141 * 'bridge' curated static block — canonical markup regenerable
142 * 'structural' other/unknown static — move/delete/duplicate only, or raw markup
143 */
144 public static function editability( $name ) {
145 $info = self::block_type_info( $name );
146 if ( $info['dynamic'] ) { return 'attr'; }
147 if ( in_array( $name, self::curated_blocks(), true ) ) { return 'bridge'; }
148 return 'structural';
149 }
150
151 /**
152 * Valid attribute keys for a block type: the block's declared attributes plus
153 * a conservative allowlist of universal / block-supports attributes WordPress
154 * permits broadly (className, style, align, colour/typography supports, ...).
155 * Returns null when validation is not meaningful (unknown block, or a block
156 * that declares no attributes) so callers SKIP validation rather than warn on
157 * everything. Advisory only — never used to block a write.
158 *
159 * @param string $name Block name, e.g. "core/paragraph".
160 * @return array|null
161 */
162 public static function block_attr_keys( $name ) {
163 if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return null; }
164 $reg = \WP_Block_Type_Registry::get_instance();
165 $type = $reg ? $reg->get_registered( $name ) : null;
166 if ( null === $type ) { return null; }
167 $declared = ( isset( $type->attributes ) && is_array( $type->attributes ) ) ? array_keys( $type->attributes ) : [];
168 if ( empty( $declared ) ) { return null; } // nothing to validate against
169 $universal = [ 'className', 'anchor', 'lock', 'metadata', 'align', 'style', 'backgroundColor', 'textColor', 'gradient', 'fontSize', 'fontFamily', 'layout', 'borderColor' ];
170 return array_values( array_unique( array_merge( $declared, $universal ) ) );
171 }
172
173 public static function list_block_types( $search = '' ) {
174 $out = [];
175 if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return $out; }
176 $reg = \WP_Block_Type_Registry::get_instance();
177 if ( ! $reg || ! method_exists( $reg, 'get_all_registered' ) ) { return $out; }
178 $search = strtolower( (string) $search );
179 foreach ( $reg->get_all_registered() as $name => $type ) {
180 $name = (string) $name;
181 $title = isset( $type->title ) ? (string) $type->title : '';
182 if ( $search !== '' && strpos( strtolower( $name ), $search ) === false && strpos( strtolower( $title ), $search ) === false ) { continue; }
183 $out[] = [
184 'name' => $name,
185 'title' => $title,
186 'category' => isset( $type->category ) ? (string) $type->category : '',
187 'dynamic' => ! empty( $type->render_callback ),
188 'editability' => self::editability( $name ),
189 ];
190 }
191 usort( $out, function( $a, $b ) { return strcmp( $a['name'], $b['name'] ); } );
192 return $out;
193 }
194
195 /* ----------------------------- address ----------------------------- */
196
197 public static function address_parts( $address ) {
198 $address = trim( (string) $address );
199 if ( $address === '' || $address === 'root' ) { return []; }
200 $parts = [];
201 foreach ( explode( '/', $address ) as $seg ) {
202 if ( $seg === '' || ! ctype_digit( $seg ) ) { return null; }
203 if ( $seg !== '0' && $seg[0] === '0' ) { return null; }
204 $parts[] = (int) $seg;
205 }
206 return $parts;
207 }
208
209 /** Walk a raw parse_blocks() array by index-path. Returns the raw block or null. */
210 public static function raw_node_at( $blocks, $address ) {
211 $parts = self::address_parts( $address );
212 if ( $parts === null ) { return null; }
213 $nodes = (array) $blocks; $found = null;
214 foreach ( $parts as $i ) {
215 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
216 $found = $nodes[ $i ];
217 $nodes = isset( $found['innerBlocks'] ) && is_array( $found['innerBlocks'] ) ? $found['innerBlocks'] : [];
218 }
219 return $found;
220 }
221
222 /** Find a raw block by its avcBlockId. Returns ['block'=>, 'path'=>] or null. */
223 public static function raw_find_by_id( $blocks, $id, $prefix = '' ) {
224 $id = (string) $id;
225 if ( $id === '' ) { return null; }
226 foreach ( (array) $blocks as $i => $block ) {
227 $path = $prefix === '' ? (string) $i : $prefix . '/' . $i;
228 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
229 if ( self::extract_id( $attrs ) === $id ) { return [ 'block' => $block, 'path' => $path ]; }
230 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
231 $hit = self::raw_find_by_id( $block['innerBlocks'], $id, $path );
232 if ( $hit !== null ) { return $hit; }
233 }
234 }
235 return null;
236 }
237
238 /* ----------------------------- views ------------------------------- */
239
240 /** Lean nested read model. include_attrs adds attrs/html; depth caps recursion. */
241 public static function tree_view( $tree, $include_attrs = false, $max_depth = 64, $prefix = '', $depth = 1 ) {
242 $out = [];
243 foreach ( $tree as $node ) {
244 $i = isset( $node['index'] ) ? (int) $node['index'] : 0;
245 $path = $prefix === '' ? (string) $i : $prefix . '/' . $i;
246 $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : [];
247 $row = [
248 'path' => $path,
249 'id' => isset( $node['id'] ) ? (string) $node['id'] : null,
250 'block' => isset( $node['block'] ) ? (string) $node['block'] : '',
251 'child_count' => count( $children ),
252 ];
253 if ( $include_attrs ) {
254 if ( isset( $node['attrs'] ) ) { $row['attrs'] = $node['attrs']; }
255 if ( isset( $node['html'] ) ) { $row['html'] = (string) $node['html']; }
256 }
257 if ( $children ) {
258 if ( $depth >= $max_depth ) { $row['children_truncated'] = true; }
259 else { $row['children'] = self::tree_view( $children, $include_attrs, $max_depth, $path, $depth + 1 ); }
260 }
261 $out[] = $row;
262 }
263 return $out;
264 }
265
266 public static function tree_count( $tree ) {
267 $n = 0;
268 foreach ( (array) $tree as $node ) {
269 $n++;
270 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) { $n += self::tree_count( $node['children'] ); }
271 }
272 return $n;
273 }
274
275 /** Detail view of one RAW block. `markup` is exact and safe to hand back to update.markup. */
276 public static function raw_full_node( $block, $path ) {
277 $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
278 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
279 $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
280 $id = self::extract_id( $attrs );
281 return [
282 'path' => $path,
283 'id' => $id !== '' ? $id : null,
284 'block' => $name,
285 'attrs' => $attrs,
286 'html' => isset( $block['innerHTML'] ) ? (string) $block['innerHTML'] : '',
287 'children' => $inner ? self::tree_view( self::blocks_to_tree( $inner ), false, 64, $path, 1 ) : [],
288 'markup' => serialize_block( $block ),
289 'editability' => self::editability( $name ),
290 ];
291 }
292
293 /* --------------------------- mutation ------------------------------ */
294
295 public static function split_address( $address ) {
296 $parts = self::address_parts( $address );
297 if ( $parts === null || $parts === [] ) { return [ '', null ]; }
298 $index = array_pop( $parts );
299 return [ implode( '/', $parts ), $index ];
300 }
301
302 /*
303 * The document root is not a block, so it has no innerContent. Wrapping it in
304 * a synthetic parent lets every address -- root included -- go through the same
305 * child-splice code, and the synthetic innerContent (all placeholders, no
306 * literals) is discarded on unwrap. Only innerBlocks survives.
307 */
308 private static function wrap_root( $blocks ) {
309 $blocks = array_values( (array) $blocks );
310 return [ 'blockName' => null, 'attrs' => [], 'innerBlocks' => $blocks, 'innerHTML' => '', 'innerContent' => array_fill( 0, count( $blocks ), null ) ];
311 }
312 private static function unwrap_root( $root ) {
313 return ( is_array( $root ) && isset( $root['innerBlocks'] ) && is_array( $root['innerBlocks'] ) ) ? array_values( $root['innerBlocks'] ) : [];
314 }
315
316 /** Run $fn on the block at $parts (relative to $block). $fn( array $block ): array|null. */
317 private static function raw_apply_at( $block, $parts, $fn ) {
318 if ( $parts === [] ) { return call_user_func( $fn, $block ); }
319 $i = array_shift( $parts );
320 $inner = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
321 if ( ! array_key_exists( $i, $inner ) ) { return null; }
322 $updated = self::raw_apply_at( $inner[ $i ], $parts, $fn );
323 if ( $updated === null ) { return null; }
324 $inner[ $i ] = $updated;
325 $block['innerBlocks'] = $inner;
326 return $block;
327 }
328
329 /** Offsets in innerContent that stand in for an inner block, in order. */
330 private static function placeholder_offsets( $inner_content ) {
331 $pos = [];
332 foreach ( (array) $inner_content as $i => $chunk ) { if ( ! is_string( $chunk ) ) { $pos[] = $i; } }
333 return $pos;
334 }
335
336 /**
337 * A container with no children yet has no placeholder to insert against, and its
338 * wrapper is one opaque literal ("<div class=\"wp-block-group\"></div>"). Split it
339 * just before its final closing tag so children land INSIDE the wrapper.
340 * Returns a new innerContent with exactly one placeholder, or null if the wrapper
341 * can't be opened safely -- in which case the caller refuses rather than guesses.
342 */
343 private static function open_empty_container( $inner_content ) {
344 $joined = '';
345 foreach ( (array) $inner_content as $chunk ) { if ( is_string( $chunk ) ) { $joined .= $chunk; } }
346 if ( $joined === '' ) { return [ null ]; }
347 if ( trim( $joined ) === '' ) { return [ $joined, null ]; }
348 if ( ! preg_match( '~</[A-Za-z][A-Za-z0-9:-]*>\s*$~', $joined, $m, PREG_OFFSET_CAPTURE ) ) { return null; }
349 $at = (int) $m[0][1];
350 return [ substr( $joined, 0, $at ), null, substr( $joined, $at ) ];
351 }
352
353 /** Insert raw blocks into $parent's children at $k, keeping innerContent in step. */
354 private static function splice_children( $parent, $k, $new ) {
355 $new = array_values( (array) $new );
356 if ( $new === [] ) { return $parent; }
357 $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : [];
358 $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : [];
359 $k = max( 0, min( (int) $k, count( $inner ) ) );
360 $slots = self::placeholder_offsets( $ic );
361
362 if ( $slots === [] ) {
363 $opened = self::open_empty_container( $ic );
364 if ( $opened === null ) { return null; }
365 $ic = $opened;
366 $at = self::placeholder_offsets( $ic );
367 $at = $at[0];
368 array_splice( $ic, $at, 1 ); // drop the vacant placeholder; real ones go in below
369 } else {
370 $at = ( $k < count( $slots ) ) ? $slots[ $k ] : ( $slots[ count( $slots ) - 1 ] + 1 );
371 }
372
373 array_splice( $inner, $k, 0, $new );
374 array_splice( $ic, $at, 0, array_fill( 0, count( $new ), null ) );
375 $parent['innerBlocks'] = $inner;
376 $parent['innerContent'] = $ic;
377 return $parent;
378 }
379
380 /** Remove $parent's child at $k, keeping innerContent in step. */
381 private static function unsplice_child( $parent, $k ) {
382 $inner = isset( $parent['innerBlocks'] ) && is_array( $parent['innerBlocks'] ) ? $parent['innerBlocks'] : [];
383 $ic = isset( $parent['innerContent'] ) && is_array( $parent['innerContent'] ) ? array_values( $parent['innerContent'] ) : [];
384 if ( ! array_key_exists( $k, $inner ) ) { return null; }
385 $slots = self::placeholder_offsets( $ic );
386 array_splice( $inner, $k, 1 );
387 if ( isset( $slots[ $k ] ) ) { array_splice( $ic, $slots[ $k ], 1 ); }
388 $parent['innerBlocks'] = $inner;
389 $parent['innerContent'] = $ic;
390 return $parent;
391 }
392
393 public static function raw_replace_at( $blocks, $address, $new_block ) {
394 list( $parent, $index ) = self::split_address( $address );
395 if ( $index === null ) { return null; }
396 $parts = self::address_parts( $parent );
397 if ( $parts === null ) { return null; }
398 $root = self::raw_apply_at( self::wrap_root( $blocks ), $parts, function( $p ) use ( $index, $new_block ) {
399 $inner = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? $p['innerBlocks'] : [];
400 if ( ! array_key_exists( $index, $inner ) ) { return null; }
401 $inner[ $index ] = $new_block; // placeholder count unchanged, innerContent untouched
402 $p['innerBlocks'] = $inner;
403 return $p;
404 } );
405 return $root === null ? null : self::unwrap_root( $root );
406 }
407
408 public static function raw_remove_at( $blocks, $address ) {
409 list( $parent, $index ) = self::split_address( $address );
410 if ( $index === null ) { return null; }
411 $parts = self::address_parts( $parent );
412 if ( $parts === null ) { return null; }
413 $root = self::raw_apply_at( self::wrap_root( $blocks ), $parts, function( $p ) use ( $index ) {
414 return self::unsplice_child( $p, $index );
415 } );
416 return $root === null ? null : self::unwrap_root( $root );
417 }
418
419 /** $new_blocks is a list of raw blocks. position null = append. */
420 public static function raw_insert_at( $blocks, $parent_address, $position, $new_blocks ) {
421 $parts = self::address_parts( $parent_address );
422 if ( $parts === null ) { return null; }
423 $root = self::raw_apply_at( self::wrap_root( $blocks ), $parts, function( $p ) use ( $position, $new_blocks ) {
424 $n = isset( $p['innerBlocks'] ) && is_array( $p['innerBlocks'] ) ? count( $p['innerBlocks'] ) : 0;
425 return self::splice_children( $p, $position === null ? $n : (int) $position, $new_blocks );
426 } );
427 return $root === null ? null : self::unwrap_root( $root );
428 }
429
430 /** Stamp avcBlockId on every named block lacking one (recursive, raw blocks). */
431 public static function raw_stamp_ids( $blocks ) {
432 $out = [];
433 foreach ( (array) $blocks as $b ) {
434 if ( ! empty( $b['blockName'] ) ) {
435 $attrs = isset( $b['attrs'] ) && is_array( $b['attrs'] ) ? $b['attrs'] : [];
436 if ( self::extract_id( $attrs ) === '' ) { $b['attrs'] = self::set_id( $attrs, self::uuid() ); }
437 }
438 if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { $b['innerBlocks'] = self::raw_stamp_ids( $b['innerBlocks'] ); }
439 $out[] = $b;
440 }
441 return $out;
442 }
443
444 /** Deep-copy a raw block, reassigning fresh avcBlockIds where present. */
445 public static function raw_clone( $block, $reassign_ids = true ) {
446 if ( $reassign_ids && ! empty( $block['blockName'] ) ) {
447 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
448 if ( self::extract_id( $attrs ) !== '' ) { $block['attrs'] = self::set_id( $attrs, self::uuid() ); }
449 }
450 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
451 $kids = [];
452 foreach ( $block['innerBlocks'] as $c ) { $kids[] = self::raw_clone( $c, $reassign_ids ); }
453 $block['innerBlocks'] = $kids;
454 }
455 return $block;
456 }
457
458 /* ----------------------- markdown / markup bridge ------------------ */
459
460 /** Parsed markup as RAW blocks, with leading/trailing whitespace nodes trimmed. */
461 public static function markup_to_blocks( $markup ) { return self::trim_edge_whitespace( parse_blocks( (string) $markup ) ); }
462 public static function markdown_to_blocks( $md ) { return self::markup_to_blocks( self::markdown_to_markup( $md ) ); }
463
464 private static function trim_edge_whitespace( $blocks ) {
465 $blocks = array_values( (array) $blocks );
466 while ( $blocks && self::is_whitespace_block( $blocks[0] ) ) { array_shift( $blocks ); }
467 while ( $blocks && self::is_whitespace_block( $blocks[ count( $blocks ) - 1 ] ) ) { array_pop( $blocks ); }
468 return $blocks;
469 }
470 private static function is_whitespace_block( $b ) {
471 return empty( $b['blockName'] ) && trim( isset( $b['innerHTML'] ) ? (string) $b['innerHTML'] : '' ) === '';
472 }
473
474 /**
475 * Build one raw block from the `block` channel ({block, attrs, html}).
476 * Deliberately leaf-only: a container's wrapper markup cannot be inferred from a
477 * name, and guessing it is what stripped the wrappers in the first place. Callers
478 * with children should use the markup channel.
479 */
480 public static function block_to_raw( $b ) {
481 $name = isset( $b['block'] ) ? (string) $b['block'] : '';
482 if ( $name === '' ) { return null; }
483 $attrs = isset( $b['attrs'] ) && is_array( $b['attrs'] ) ? $b['attrs'] : [];
484 $html = isset( $b['html'] ) ? (string) $b['html'] : '';
485 return [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => $html !== '' ? [ $html ] : [] ];
486 }
487
488 /** Convert a curated subset of Markdown to canonical core-block markup. */
489 public static function markdown_to_markup( $md ) {
490 $md = str_replace( [ "\r\n", "\r" ], "\n", (string) $md );
491 $lines = explode( "\n", $md );
492 $n = count( $lines ); $i = 0; $blocks = [];
493 while ( $i < $n ) {
494 $line = $lines[ $i ]; $t = trim( $line );
495 if ( $t === '' ) { $i++; continue; }
496 if ( preg_match( '/^```/', $t ) ) {
497 $code = []; $i++;
498 while ( $i < $n && ! preg_match( '/^```/', trim( $lines[ $i ] ) ) ) { $code[] = $lines[ $i ]; $i++; }
499 $i++;
500 $blocks[] = self::md_code( implode( "\n", $code ) ); continue;
501 }
502 if ( preg_match( '/^(#{1,6})\s+(.*)$/', $t, $m ) ) { $blocks[] = self::md_heading( strlen( $m[1] ), self::md_inline( $m[2] ) ); $i++; continue; }
503 if ( preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $t ) ) { $blocks[] = self::md_separator(); $i++; continue; }
504 if ( preg_match( '/^>\s?/', $t ) ) {
505 $q = [];
506 while ( $i < $n && preg_match( '/^>\s?(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $q[] = $mm[1]; $i++; }
507 $blocks[] = self::md_quote( $q ); continue;
508 }
509 if ( preg_match( '/^[-*+]\s+/', $t ) ) {
510 $items = [];
511 while ( $i < $n && preg_match( '/^[-*+]\s+(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $items[] = self::md_inline( $mm[1] ); $i++; }
512 $blocks[] = self::md_list( $items, false ); continue;
513 }
514 if ( preg_match( '/^\d+\.\s+/', $t ) ) {
515 $items = [];
516 while ( $i < $n && preg_match( '/^\d+\.\s+(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $items[] = self::md_inline( $mm[1] ); $i++; }
517 $blocks[] = self::md_list( $items, true ); continue;
518 }
519 if ( preg_match( '/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/', $t, $m ) ) { $blocks[] = self::md_image( $m[2], $m[1] ); $i++; continue; }
520 $para = [ $line ]; $i++;
521 while ( $i < $n && trim( $lines[ $i ] ) !== '' && ! self::md_is_block_start( trim( $lines[ $i ] ) ) ) { $para[] = $lines[ $i ]; $i++; }
522 $blocks[] = self::md_paragraph( self::md_inline( implode( '<br>', array_map( 'trim', $para ) ) ) );
523 }
524 return implode( "\n\n", $blocks );
525 }
526 private static function md_is_block_start( $t ) {
527 return (bool) ( preg_match( '/^(#{1,6}\s|>\s?|[-*+]\s|\d+\.\s|```|!\[)/', $t ) || preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $t ) );
528 }
529 public static function md_inline( $text ) {
530 $t = esc_html( (string) $text );
531 $t = preg_replace_callback( '/\[([^\]]+)\]\(([^)\s]+)\)/', function( $m ) { return '<a href="' . esc_url( $m[2] ) . '">' . $m[1] . '</a>'; }, $t );
532 $t = preg_replace( '/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $t );
533 $t = preg_replace( '/__([^_]+)__/', '<strong>$1</strong>', $t );
534 $t = preg_replace( '/(?<!\*)\*([^*]+)\*(?!\*)/', '<em>$1</em>', $t );
535 $t = preg_replace( '/(?<![\w_])_([^_]+)_(?![\w_])/', '<em>$1</em>', $t );
536 $t = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $t );
537 return $t;
538 }
539 private static function md_paragraph( $html ) { return "<!-- wp:paragraph -->\n<p>{$html}</p>\n<!-- /wp:paragraph -->"; }
540 private static function md_heading( $level, $html ) {
541 $level = max( 1, min( 6, (int) $level ) );
542 $attr = $level === 2 ? '' : ' {"level":' . $level . '}';
543 return "<!-- wp:heading{$attr} -->\n<h{$level}>{$html}</h{$level}>\n<!-- /wp:heading -->";
544 }
545 private static function md_separator() { return "<!-- wp:separator -->\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"/>\n<!-- /wp:separator -->"; }
546 private static function md_image( $url, $alt ) {
547 $u = esc_url( $url ); $a = esc_attr( $alt );
548 return "<!-- wp:image -->\n<figure class=\"wp-block-image\"><img src=\"{$u}\" alt=\"{$a}\"/></figure>\n<!-- /wp:image -->";
549 }
550 private static function md_code( $code ) {
551 $c = esc_html( $code );
552 return "<!-- wp:code -->\n<pre class=\"wp-block-code\"><code>{$c}</code></pre>\n<!-- /wp:code -->";
553 }
554 private static function md_quote( $lines ) {
555 $inner = []; $buf = [];
556 foreach ( $lines as $l ) {
557 if ( trim( $l ) === '' ) { if ( $buf ) { $inner[] = self::md_paragraph( self::md_inline( implode( '<br>', $buf ) ) ); $buf = []; } }
558 else { $buf[] = trim( $l ); }
559 }
560 if ( $buf ) { $inner[] = self::md_paragraph( self::md_inline( implode( '<br>', $buf ) ) ); }
561 $body = implode( "\n", $inner );
562 return "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\">{$body}</blockquote>\n<!-- /wp:quote -->";
563 }
564 private static function md_list( $items, $ordered ) {
565 $tag = $ordered ? 'ol' : 'ul';
566 $attr = $ordered ? ' {"ordered":true}' : '';
567 $lis = [];
568 foreach ( $items as $it ) { $lis[] = "<!-- wp:list-item -->\n<li>{$it}</li>\n<!-- /wp:list-item -->"; }
569 $body = implode( "\n", $lis );
570 return "<!-- wp:list{$attr} -->\n<{$tag} class=\"wp-block-list\">{$body}</{$tag}>\n<!-- /wp:list -->";
571 }
572
573 }
574
575 class AVCF_Abilities_Gutenberg extends AVCF_Abilities_Base {
576
577 public function register() {
578 $this->register_read_page();
579 $this->register_read_block();
580 $this->register_list_block_types();
581 $this->register_apply_operations();
582 }
583
584 /* ------------------------------ shared ----------------------------- */
585
586 private function ro_meta() {
587 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ] ];
588 }
589 private function guard( $input, $need_edit = false ) {
590 $pid = isset( $input['post_id'] ) ? (int) $input['post_id'] : ( isset( $input['post'] ) ? (int) $input['post'] : 0 );
591 if ( $pid <= 0 || ! get_post( $pid ) ) { return [ 'err' => [ 'success' => false, 'message' => 'A valid post_id is required.' ] ]; }
592 if ( $need_edit && ! ( current_user_can( 'edit_post', $pid ) || current_user_can( 'edit_posts' ) ) ) {
593 return [ 'err' => [ 'success' => false, 'message' => 'No permission to edit this post.' ] ];
594 }
595 return [ 'post_id' => $pid ];
596 }
597
598 /* ----------------------------- read-page --------------------------- */
599
600 private function register_read_page() {
601 $self = $this;
602 wp_register_ability( 'atarim/gutenberg-read-page', [
603 'label' => 'Read Gutenberg Page', 'category' => 'atarim',
604 'description' => 'Parse a post/page\'s Gutenberg blocks into a lean nested tree. Each node: path (index-path "0/1/2"), id (the stable avcBlockId, or null if not yet stamped), block (block name), child_count, and children. IMPORTANT: path is each block\'s real position in the post, not its position in this list. The list omits the whitespace between blocks, so paths are usually NOT contiguous — a page of three blocks typically reads 0, 2, 4. Copy paths verbatim into apply-operations; never renumber, count, or infer them. Pass include_attrs:true for each block\'s attrs + inner html. Returns content_hash — pass it to apply-operations as the stale-edit guard. block_based=false / classic=true means the post is classic HTML (no blocks) and edits would be lossy.',
605 'input_schema' => [ 'type' => 'object', 'properties' => [
606 'post_id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'Post/page id (also accepts "post").' ],
607 'include_attrs' => [ 'type' => 'boolean', 'default' => false ],
608 'max_depth' => [ 'type' => 'integer', 'minimum' => 1, 'default' => 64 ],
609 ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
610 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'post_id' => [ 'type' => 'integer' ], 'post_type' => [ 'type' => 'string' ], 'block_based' => [ 'type' => 'boolean' ], 'classic' => [ 'type' => 'boolean' ], 'content_hash' => [ 'type' => 'string' ], 'total_blocks' => [ 'type' => 'integer' ], 'tree' => [ 'type' => 'array' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
611 'execute_callback' => function( $input = [] ) use ( $self ) {
612 $g = $self->guard( $input ); if ( isset( $g['err'] ) ) { return $g['err']; }
613 $post = get_post( $g['post_id'] );
614 $content = $post->post_content;
615 $tree = AVCF_Gutenberg_Helpers::parse_tree( $content );
616 $include = ! empty( $input['include_attrs'] );
617 $max_depth = isset( $input['max_depth'] ) ? max( 1, (int) $input['max_depth'] ) : 64;
618 return [
619 'success' => true,
620 'post_id' => $g['post_id'],
621 'post_type' => $post->post_type,
622 'block_based' => AVCF_Gutenberg_Helpers::is_block_based( $content ),
623 'classic' => AVCF_Gutenberg_Helpers::is_classic( $content ),
624 'content_hash' => AVCF_Gutenberg_Helpers::content_hash( $content ),
625 'total_blocks' => AVCF_Gutenberg_Helpers::tree_count( $tree ),
626 'tree' => AVCF_Gutenberg_Helpers::tree_view( $tree, $include, $max_depth ),
627 'message' => 'OK.',
628 ];
629 },
630 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
631 'meta' => $this->ro_meta(),
632 ] );
633 }
634
635 /* ---------------------------- read-block --------------------------- */
636
637 private function register_read_block() {
638 $self = $this;
639 wp_register_ability( 'atarim/gutenberg-read-block', [
640 'label' => 'Read Gutenberg Block', 'category' => 'atarim',
641 'description' => 'Return one block in full — block name, attrs, its own inner html (for a container this is its wrapper markup, e.g. the <ul class="wp-block-list"> around list items), child summary, markup (the block\'s exact serialized markup, safe to hand straight back to update.markup), its path, its avcBlockId, and its editability class (attr = dynamic/attrs-safe, bridge = curated static/markdown-regenerable, structural = move/delete only or raw markup). Target by path OR block_id (avcBlockId).',
642 'input_schema' => [ 'type' => 'object', 'properties' => [
643 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
644 'path' => [ 'type' => 'string', 'description' => 'Index-path "0/1/2".' ],
645 'block_id' => [ 'type' => 'string', 'description' => 'The stable avcBlockId.' ],
646 ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
647 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'block' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
648 'execute_callback' => function( $input = [] ) use ( $self ) {
649 $g = $self->guard( $input ); if ( isset( $g['err'] ) ) { return $g['err']; }
650 $post = get_post( $g['post_id'] );
651 $blocks = AVCF_Gutenberg_Helpers::parse_raw( $post->post_content );
652 $has_path = isset( $input['path'] ) && $input['path'] !== '';
653 $has_id = isset( $input['block_id'] ) && $input['block_id'] !== '';
654 if ( ! $has_path && ! $has_id ) { return [ 'success' => false, 'message' => 'Provide path or block_id.' ]; }
655 if ( $has_id ) {
656 $hit = AVCF_Gutenberg_Helpers::raw_find_by_id( $blocks, (string) $input['block_id'] );
657 if ( $hit === null ) { return [ 'success' => false, 'message' => 'No block with that avcBlockId.' ]; }
658 return [ 'success' => true, 'block' => AVCF_Gutenberg_Helpers::raw_full_node( $hit['block'], $hit['path'] ), 'message' => 'OK.' ];
659 }
660 $block = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, (string) $input['path'] );
661 if ( $block === null ) { return [ 'success' => false, 'message' => sprintf( 'No block at path "%s".', $input['path'] ) ]; }
662 return [ 'success' => true, 'block' => AVCF_Gutenberg_Helpers::raw_full_node( $block, (string) $input['path'] ), 'message' => 'OK.' ];
663 },
664 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
665 'meta' => $this->ro_meta(),
666 ] );
667 }
668
669 /* ------------------------- list-block-types ------------------------ */
670
671 private function register_list_block_types() {
672 wp_register_ability( 'atarim/gutenberg-list-block-types', [
673 'label' => 'List Gutenberg Block Types', 'category' => 'atarim',
674 'description' => 'List registered block types: { name, title, category, dynamic, editability }. editability tells you how each can be written: attr (dynamic — set attrs freely), bridge (curated static — author via markdown/canonical markup), structural (other static — move/delete/duplicate, or supply raw block markup for content). Optionally filter by search, and/or by editability.',
675 'input_schema' => [ 'type' => 'object', 'properties' => [
676 'search' => [ 'type' => 'string' ],
677 'editability' => [ 'type' => 'string', 'enum' => [ 'attr', 'bridge', 'structural' ] ],
678 ], 'additionalProperties' => false ],
679 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'types' => [ 'type' => 'array' ], 'total' => [ 'type' => 'integer' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
680 'execute_callback' => function( $input = [] ) {
681 $types = AVCF_Gutenberg_Helpers::list_block_types( isset( $input['search'] ) ? (string) $input['search'] : '' );
682 if ( isset( $input['editability'] ) && $input['editability'] !== '' ) {
683 $f = (string) $input['editability'];
684 $types = array_values( array_filter( $types, function( $t ) use ( $f ) { return $t['editability'] === $f; } ) );
685 }
686 return [ 'success' => true, 'types' => $types, 'total' => count( $types ), 'message' => sprintf( '%d block type(s).', count( $types ) ) ];
687 },
688 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
689 'meta' => $this->ro_meta(),
690 ] );
691 }
692
693 /* -------------------------- write engine --------------------------- */
694
695 private function write_meta( $destructive = false ) {
696 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => false, 'destructive' => (bool) $destructive, 'idempotent' => false ] ];
697 }
698
699 private function register_apply_operations() {
700 $self = $this;
701 wp_register_ability( 'atarim/gutenberg-apply-operations', [
702 'label' => 'Apply Gutenberg Operations', 'category' => 'atarim',
703 'description' => 'Apply a batch of block edits to a post atomically (all-or-nothing). REQUIRES content_hash from read-page as a stale-edit guard — if the post changed since you read it, nothing is written. operations run in order; target blocks by their avcBlockId (preferred — stable across the batch) or index-path. Op types: '
704 . 'insert (channels: markdown | markup | block; into parent [id/path/root] at index), '
705 . 'update (markdown/markup regen a single block, or merge/replace attrs, or set html), '
706 . 'move (target -> parent [prefer id] + index; cannot move into its own subtree), '
707 . 'swap (exchange two non-nested blocks a/b), delete (target), duplicate (target -> clone after it with fresh ids). '
708 . 'Paths come from read-page and are real block positions, so they are usually not contiguous — pass them through unchanged and never renumber them. Any index-path is valid only against the snapshot you read; once an op inserts or deletes, later paths in the same batch shift, so prefer avcBlockId for anything after the first op. '
709 . 'To create a container (group, columns, list, quote, buttons) use the markup channel with its full markup including the wrapper element; the block channel is leaf-only, because a wrapper cannot be inferred from a block name. '
710 . 'Set stamp_ids:true to assign avcBlockIds to all blocks afterward. Static-block edits should use the markdown/markup channels (regenerates valid markup); raw attr edits on non-dynamic blocks can desync innerHTML and trip Gutenberg block validation. Classic (non-block) posts are refused unless force:true.',
711 'input_schema' => [ 'type' => 'object', 'properties' => [
712 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
713 'content_hash' => [ 'type' => 'string', 'description' => 'From read-page. Required stale-edit guard.' ],
714 'operations' => [ 'type' => 'array', 'items' => [ 'type' => 'object' ], 'minItems' => 1, 'description' => 'Each: { op: insert|update|move|swap|delete|duplicate, ... }. insert: parent?, index?, markdown|markup|block. update: target, markdown|markup|attrs(+replace_attrs)|html. move: target, parent?, index?. swap: a, b. delete: target. duplicate: target.' ],
715 'stamp_ids' => [ 'type' => 'boolean', 'default' => false ],
716 'force' => [ 'type' => 'boolean', 'default' => false ],
717 ], 'required' => [ 'post_id', 'content_hash', 'operations' ], 'additionalProperties' => false ],
718 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'applied' => [ 'type' => 'integer' ], 'content_hash' => [ 'type' => 'string' ], 'new_ids' => [ 'type' => 'array' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
719 'execute_callback' => function( $input = [] ) use ( $self ) { return $self->apply_operations( $input ); },
720 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
721 'meta' => $self->write_meta_public( true ),
722 ] );
723 }
724
725 /** public wrapper so the registration closure can read write meta */
726 public function write_meta_public( $destructive = false ) { return $this->write_meta( $destructive ); }
727
728 public function apply_operations( $input ) {
729 $g = $this->guard( $input, true );
730 if ( isset( $g['err'] ) ) { return $g['err']; }
731 $post_id = $g['post_id'];
732 $post = get_post( $post_id );
733 $content = $post->post_content;
734
735 if ( AVCF_Gutenberg_Helpers::is_classic( $content ) && empty( $input['force'] ) ) {
736 return [ 'success' => false, 'message' => 'This post is classic HTML (no blocks); editing it as blocks would be lossy. Pass force:true to proceed.' ];
737 }
738 $expected = isset( $input['content_hash'] ) ? (string) $input['content_hash'] : '';
739 if ( $expected === '' ) { return [ 'success' => false, 'message' => 'content_hash is required (get it from read-page).' ]; }
740 if ( $expected !== AVCF_Gutenberg_Helpers::content_hash( $content ) ) {
741 return [ 'success' => false, 'message' => 'Stale edit: the page changed since you read it. Re-read with read-page and retry.' ];
742 }
743 $ops = isset( $input['operations'] ) && is_array( $input['operations'] ) ? $input['operations'] : [];
744 if ( $ops === [] ) { return [ 'success' => false, 'message' => 'No operations provided.' ]; }
745
746 $blocks = AVCF_Gutenberg_Helpers::parse_raw( $content );
747 $applied = 0; $new_ids = []; $warnings = [];
748 foreach ( $ops as $idx => $op ) {
749 $res = $this->apply_one( $blocks, is_array( $op ) ? $op : [] );
750 if ( isset( $res['err'] ) ) {
751 return [ 'success' => false, 'message' => sprintf( 'Operation %d (%s) failed: %s — nothing was written.', (int) $idx, ( is_array( $op ) && isset( $op['op'] ) ) ? (string) $op['op'] : '?', $res['err'] ) ];
752 }
753 $blocks = $res['blocks'];
754 if ( isset( $res['new_id'] ) ) { $new_ids[] = $res['new_id']; }
755 if ( isset( $res['warn'] ) ) { $warnings[] = array_merge( [ 'op_index' => (int) $idx ], $res['warn'] ); }
756 $applied++;
757 }
758 if ( ! empty( $input['stamp_ids'] ) ) { $blocks = AVCF_Gutenberg_Helpers::raw_stamp_ids( $blocks ); }
759
760 $markup = AVCF_Gutenberg_Helpers::serialize_raw( $blocks );
761 $r = wp_update_post( [ 'ID' => $post_id, 'post_content' => $markup ], true );
762 if ( is_wp_error( $r ) ) { return [ 'success' => false, 'message' => $r->get_error_message() ]; }
763 $result = [ 'success' => true, 'applied' => $applied, 'content_hash' => AVCF_Gutenberg_Helpers::content_hash( $markup ), 'new_ids' => $new_ids ];
764 $message = sprintf( '%d operation(s) applied.', $applied );
765 if ( ! empty( $warnings ) ) {
766 $result['warnings'] = $warnings;
767 $bits = [];
768 foreach ( $warnings as $w ) { $bits[] = sprintf( 'op %d (%s): %s', $w['op_index'], $w['block'], implode( ', ', $w['unknown_attrs'] ) ); }
769 $message .= ' WARNING: some attributes are not defined by their block type and are likely ignored — ' . implode( '; ', $bits ) . '. Check names with gutenberg-list-block-types / the block\'s registered attributes.';
770 }
771 $result['message'] = $message;
772 return $result;
773 }
774
775 private function apply_one( $blocks, $op ) {
776 $type = isset( $op['op'] ) ? (string) $op['op'] : '';
777 switch ( $type ) {
778 case 'insert': return $this->op_insert( $blocks, $op );
779 case 'update': return $this->op_update( $blocks, $op );
780 case 'move': return $this->op_move( $blocks, $op );
781 case 'swap': return $this->op_swap( $blocks, $op );
782 case 'delete': return $this->op_delete( $blocks, $op );
783 case 'duplicate': return $this->op_duplicate( $blocks, $op );
784 default: return [ 'err' => 'unknown op "' . $type . '"' ];
785 }
786 }
787
788 /** '' / 'root' -> '' (root). id resolved first, then path. null = not found. */
789 private function resolve_to_path( $blocks, $ref ) {
790 $ref = (string) $ref;
791 if ( $ref === '' || $ref === 'root' ) { return ''; }
792 $hit = AVCF_Gutenberg_Helpers::raw_find_by_id( $blocks, $ref );
793 if ( $hit !== null ) { return $hit['path']; }
794 if ( AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $ref ) !== null ) { return $ref; }
795 return null;
796 }
797
798 private function blocks_from_op( $op ) {
799 if ( isset( $op['markdown'] ) && $op['markdown'] !== '' ) { return [ 'blocks' => AVCF_Gutenberg_Helpers::markdown_to_blocks( (string) $op['markdown'] ) ]; }
800 if ( isset( $op['markup'] ) && $op['markup'] !== '' ) { return [ 'blocks' => AVCF_Gutenberg_Helpers::markup_to_blocks( (string) $op['markup'] ) ]; }
801 if ( isset( $op['block'] ) && is_array( $op['block'] ) ) {
802 if ( isset( $op['block']['children'] ) && $op['block']['children'] !== [] ) {
803 return [ 'err' => 'block.children is not supported — a container\'s wrapper markup cannot be inferred from its name. Use the markup channel with the full block markup (e.g. <!-- wp:group --><div class="wp-block-group">…</div><!-- /wp:group -->), or markdown.' ];
804 }
805 $raw = AVCF_Gutenberg_Helpers::block_to_raw( $op['block'] );
806 if ( $raw === null ) { return [ 'err' => 'block.block (name) is required' ]; }
807 return [ 'blocks' => [ $raw ] ];
808 }
809 return [ 'err' => 'insert needs markdown, markup, or block' ];
810 }
811
812 private function op_insert( $blocks, $op ) {
813 $src = $this->blocks_from_op( $op );
814 if ( isset( $src['err'] ) ) { return $src; }
815 if ( $src['blocks'] === [] ) { return [ 'err' => 'nothing to insert (empty content)' ]; }
816 $parent_path = $this->resolve_to_path( $blocks, isset( $op['parent'] ) ? (string) $op['parent'] : '' );
817 if ( $parent_path === null ) { return [ 'err' => 'parent not found: ' . ( isset( $op['parent'] ) ? $op['parent'] : '' ) ]; }
818 $index = isset( $op['index'] ) ? (int) $op['index'] : null;
819 $new = AVCF_Gutenberg_Helpers::raw_insert_at( $blocks, $parent_path, $index, $src['blocks'] );
820 if ( $new === null ) { return [ 'err' => 'insert failed — the parent has no children yet and its wrapper markup could not be opened safely. Replace the whole container with update.markup instead.' ]; }
821 return [ 'blocks' => $new ];
822 }
823
824 private function op_update( $blocks, $op ) {
825 $path = $this->resolve_to_path( $blocks, isset( $op['target'] ) ? (string) $op['target'] : '' );
826 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
827 $block = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $path );
828 if ( $block === null ) { return [ 'err' => 'target not found' ]; }
829 $cur_attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
830 $cur_id = AVCF_Gutenberg_Helpers::extract_id( $cur_attrs );
831
832 if ( isset( $op['markup'] ) && $op['markup'] !== '' ) {
833 $parsed = AVCF_Gutenberg_Helpers::markup_to_blocks( (string) $op['markup'] );
834 if ( count( $parsed ) !== 1 ) { return [ 'err' => 'update markup must produce exactly one block' ]; }
835 $new = AVCF_Gutenberg_Helpers::raw_replace_at( $blocks, $path, $parsed[0] );
836 return $new === null ? [ 'err' => 'update failed' ] : [ 'blocks' => $new ];
837 }
838 if ( isset( $op['markdown'] ) && $op['markdown'] !== '' ) {
839 $parsed = AVCF_Gutenberg_Helpers::markdown_to_blocks( (string) $op['markdown'] );
840 if ( count( $parsed ) !== 1 ) { return [ 'err' => 'update markdown must produce exactly one block' ]; }
841 $repl = $parsed[0];
842 if ( $cur_id !== '' ) {
843 $repl['attrs'] = AVCF_Gutenberg_Helpers::set_id( isset( $repl['attrs'] ) && is_array( $repl['attrs'] ) ? $repl['attrs'] : [], $cur_id );
844 }
845 $new = AVCF_Gutenberg_Helpers::raw_replace_at( $blocks, $path, $repl );
846 return $new === null ? [ 'err' => 'update failed' ] : [ 'blocks' => $new ];
847 }
848 if ( isset( $op['attrs'] ) && is_array( $op['attrs'] ) ) {
849 $block['attrs'] = ! empty( $op['replace_attrs'] ) ? $op['attrs'] : array_merge( $cur_attrs, $op['attrs'] );
850 $new = AVCF_Gutenberg_Helpers::raw_replace_at( $blocks, $path, $block );
851 if ( $new === null ) { return [ 'err' => 'update failed' ]; }
852 $res = [ 'blocks' => $new ];
853 // Tier-2 validation (non-blocking): flag attrs the block type does not
854 // define so a silently-ignored attribute is visible in the receipt.
855 $bname = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
856 $valid = AVCF_Gutenberg_Helpers::block_attr_keys( $bname );
857 if ( is_array( $valid ) ) {
858 $unknown = array_values( array_filter(
859 array_keys( $op['attrs'] ),
860 function( $k ) use ( $valid ) { return ! in_array( $k, $valid, true ); }
861 ) );
862 if ( ! empty( $unknown ) ) {
863 $res['warn'] = [ 'block' => $bname, 'unknown_attrs' => $unknown ];
864 }
865 }
866 return $res;
867 }
868 if ( isset( $op['html'] ) ) {
869 $kids = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? count( $block['innerBlocks'] ) : 0;
870 if ( $kids > 0 ) {
871 return [ 'err' => sprintf( 'target has %d inner block(s); setting html would drop them and their wrapper markup. Use markup to replace the whole block, or edit the children individually.', $kids ) ];
872 }
873 $html = (string) $op['html'];
874 $block['innerHTML'] = $html;
875 $block['innerContent'] = $html !== '' ? [ $html ] : [];
876 $new = AVCF_Gutenberg_Helpers::raw_replace_at( $blocks, $path, $block );
877 return $new === null ? [ 'err' => 'update failed' ] : [ 'blocks' => $new ];
878 }
879 return [ 'err' => 'update needs markdown, markup, attrs, or html' ];
880 }
881
882 private function op_move( $blocks, $op ) {
883 $from = $this->resolve_to_path( $blocks, isset( $op['target'] ) ? (string) $op['target'] : '' );
884 if ( $from === null || $from === '' ) { return [ 'err' => 'target not found' ]; }
885 $block = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $from );
886 if ( $block === null ) { return [ 'err' => 'target not found' ]; }
887 $parent_ref = isset( $op['parent'] ) ? (string) $op['parent'] : '';
888 $to = $this->resolve_to_path( $blocks, $parent_ref );
889 if ( $to === null ) { return [ 'err' => 'parent not found: ' . $parent_ref ]; }
890 if ( $to === $from || strpos( $to . '/', $from . '/' ) === 0 ) { return [ 'err' => 'cannot move a block into itself or its descendant' ]; }
891 $removed = AVCF_Gutenberg_Helpers::raw_remove_at( $blocks, $from );
892 if ( $removed === null ) { return [ 'err' => 'move: removal failed' ]; }
893 $to2 = ( $parent_ref === '' || $parent_ref === 'root' ) ? '' : $this->resolve_to_path( $removed, $parent_ref );
894 if ( $to2 === null ) { return [ 'err' => 'move: parent shifted after removal — target it by avcBlockId' ]; }
895 $index = isset( $op['index'] ) ? (int) $op['index'] : null;
896 $new = AVCF_Gutenberg_Helpers::raw_insert_at( $removed, $to2, $index, [ $block ] );
897 return $new === null ? [ 'err' => 'move: insert failed' ] : [ 'blocks' => $new ];
898 }
899
900 private function op_swap( $blocks, $op ) {
901 $pa = $this->resolve_to_path( $blocks, isset( $op['a'] ) ? (string) $op['a'] : '' );
902 $pb = $this->resolve_to_path( $blocks, isset( $op['b'] ) ? (string) $op['b'] : '' );
903 if ( $pa === null || $pa === '' || $pb === null || $pb === '' ) { return [ 'err' => 'swap needs valid a and b' ]; }
904 if ( $pa === $pb ) { return [ 'err' => 'swap a and b are the same block' ]; }
905 if ( strpos( $pa . '/', $pb . '/' ) === 0 || strpos( $pb . '/', $pa . '/' ) === 0 ) { return [ 'err' => 'cannot swap nested blocks' ]; }
906 $ba = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $pa );
907 $bb = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $pb );
908 if ( $ba === null || $bb === null ) { return [ 'err' => 'swap target not found' ]; }
909 $t = AVCF_Gutenberg_Helpers::raw_replace_at( $blocks, $pa, $bb );
910 if ( $t === null ) { return [ 'err' => 'swap failed' ]; }
911 $t = AVCF_Gutenberg_Helpers::raw_replace_at( $t, $pb, $ba );
912 return $t === null ? [ 'err' => 'swap failed' ] : [ 'blocks' => $t ];
913 }
914
915 private function op_delete( $blocks, $op ) {
916 $path = $this->resolve_to_path( $blocks, isset( $op['target'] ) ? (string) $op['target'] : '' );
917 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
918 $new = AVCF_Gutenberg_Helpers::raw_remove_at( $blocks, $path );
919 return $new === null ? [ 'err' => 'delete failed' ] : [ 'blocks' => $new ];
920 }
921
922 private function op_duplicate( $blocks, $op ) {
923 $path = $this->resolve_to_path( $blocks, isset( $op['target'] ) ? (string) $op['target'] : '' );
924 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
925 $block = AVCF_Gutenberg_Helpers::raw_node_at( $blocks, $path );
926 if ( $block === null ) { return [ 'err' => 'target not found' ]; }
927 $clone = AVCF_Gutenberg_Helpers::raw_clone( $block, true );
928 $new_id = '';
929 if ( ! empty( $clone['blockName'] ) ) {
930 $cattrs = isset( $clone['attrs'] ) && is_array( $clone['attrs'] ) ? $clone['attrs'] : [];
931 $new_id = AVCF_Gutenberg_Helpers::extract_id( $cattrs );
932 if ( $new_id === '' ) {
933 $new_id = AVCF_Gutenberg_Helpers::uuid();
934 $clone['attrs'] = AVCF_Gutenberg_Helpers::set_id( $cattrs, $new_id );
935 }
936 }
937 list( $parent, $index ) = AVCF_Gutenberg_Helpers::split_address( $path );
938 $pos = $index === null ? null : ( (int) $index + 1 );
939 $new = AVCF_Gutenberg_Helpers::raw_insert_at( $blocks, $parent, $pos, [ $clone ] );
940 if ( $new === null ) { return [ 'err' => 'duplicate failed' ]; }
941 return $new_id !== '' ? [ 'blocks' => $new, 'new_id' => $new_id ] : [ 'blocks' => $new ];
942 }
943
944 }