PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
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 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 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 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-gutenberg.php
760 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 public static function parse_tree( $content ) {
42 return self::blocks_to_tree( parse_blocks( (string) $content ) );
43 }
44
45 private static function blocks_to_tree( $blocks ) {
46 $tree = [];
47 foreach ( (array) $blocks as $block ) {
48 $name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
49 $inner_blocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : [];
50 $inner_html = isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : '';
51 if ( $name === '' && trim( $inner_html ) === '' ) { continue; } // whitespace between blocks
52 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
53 $node = [ 'block' => $name ];
54 $id = self::extract_id( $attrs );
55 if ( $id !== '' ) { $node['id'] = $id; }
56 if ( $attrs !== [] ) { $node['attrs'] = $attrs; }
57 if ( $inner_blocks ) { $node['children'] = self::blocks_to_tree( $inner_blocks ); }
58 elseif ( trim( $inner_html ) !== '' ) { $node['html'] = $inner_html; }
59 $tree[] = $node;
60 }
61 return $tree;
62 }
63
64 public static function serialize_tree( $tree ) {
65 return serialize_blocks( self::tree_to_blocks( $tree ) );
66 }
67
68 private static function tree_to_blocks( $tree ) {
69 $blocks = [];
70 foreach ( (array) $tree as $node ) {
71 if ( ! is_array( $node ) ) { continue; }
72 $name = isset( $node['block'] ) ? (string) $node['block'] : '';
73 $attrs = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [];
74 $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : [];
75 $html = isset( $node['html'] ) ? (string) $node['html'] : '';
76 if ( $name === '' ) {
77 $blocks[] = [ 'blockName' => null, 'attrs' => [], 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => [ $html ] ];
78 continue;
79 }
80 if ( $children ) {
81 $inner = self::tree_to_blocks( $children );
82 $blocks[] = [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => $inner, 'innerHTML' => '', 'innerContent' => $inner ? array_fill( 0, count( $inner ), null ) : [] ];
83 } else {
84 $blocks[] = [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => [], 'innerHTML' => $html, 'innerContent' => $html !== '' ? [ $html ] : [] ];
85 }
86 }
87 return $blocks;
88 }
89
90 public static function extract_id( $attrs ) {
91 if ( is_array( $attrs ) && isset( $attrs['metadata'] ) && is_array( $attrs['metadata'] ) && isset( $attrs['metadata']['avcBlockId'] ) && is_string( $attrs['metadata']['avcBlockId'] ) ) {
92 return $attrs['metadata']['avcBlockId'];
93 }
94 return '';
95 }
96 public static function set_id( $attrs, $id ) {
97 if ( ! is_array( $attrs ) ) { $attrs = []; }
98 if ( ! isset( $attrs['metadata'] ) || ! is_array( $attrs['metadata'] ) ) { $attrs['metadata'] = []; }
99 $attrs['metadata']['avcBlockId'] = (string) $id;
100 return $attrs;
101 }
102
103 /** Is the post block-based (has block delimiters) vs classic HTML? */
104 public static function is_block_based( $content ) {
105 return strpos( (string) $content, '<!-- wp:' ) !== false;
106 }
107 /** Classic content carried as a freeform block with real HTML (edit would be lossy). */
108 public static function is_classic( $content ) {
109 $c = (string) $content;
110 return $c !== '' && ! self::is_block_based( $c );
111 }
112
113 /* --------------------------- block types --------------------------- */
114
115 public static function block_type_info( $name ) {
116 $info = [ 'name' => (string) $name, 'exists' => false, 'dynamic' => false, 'title' => '', 'category' => '' ];
117 if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return $info; }
118 $reg = \WP_Block_Type_Registry::get_instance();
119 $type = $reg ? $reg->get_registered( $name ) : null;
120 if ( $type === null ) { return $info; }
121 $info['exists'] = true;
122 $info['dynamic'] = ! empty( $type->render_callback );
123 $info['title'] = isset( $type->title ) ? (string) $type->title : '';
124 $info['category'] = isset( $type->category ) ? (string) $type->category : '';
125 return $info;
126 }
127
128 /**
129 * Editability class for a block type:
130 * 'attr' dynamic block — attrs editable, no innerHTML validation risk
131 * 'bridge' curated static block — canonical markup regenerable
132 * 'structural' other/unknown static — move/delete/duplicate only, or raw markup
133 */
134 public static function editability( $name ) {
135 $info = self::block_type_info( $name );
136 if ( $info['dynamic'] ) { return 'attr'; }
137 if ( in_array( $name, self::curated_blocks(), true ) ) { return 'bridge'; }
138 return 'structural';
139 }
140
141 public static function list_block_types( $search = '' ) {
142 $out = [];
143 if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return $out; }
144 $reg = \WP_Block_Type_Registry::get_instance();
145 if ( ! $reg || ! method_exists( $reg, 'get_all_registered' ) ) { return $out; }
146 $search = strtolower( (string) $search );
147 foreach ( $reg->get_all_registered() as $name => $type ) {
148 $name = (string) $name;
149 $title = isset( $type->title ) ? (string) $type->title : '';
150 if ( $search !== '' && strpos( strtolower( $name ), $search ) === false && strpos( strtolower( $title ), $search ) === false ) { continue; }
151 $out[] = [
152 'name' => $name,
153 'title' => $title,
154 'category' => isset( $type->category ) ? (string) $type->category : '',
155 'dynamic' => ! empty( $type->render_callback ),
156 'editability' => self::editability( $name ),
157 ];
158 }
159 usort( $out, function( $a, $b ) { return strcmp( $a['name'], $b['name'] ); } );
160 return $out;
161 }
162
163 /* ----------------------------- address ----------------------------- */
164
165 public static function address_parts( $address ) {
166 $address = trim( (string) $address );
167 if ( $address === '' || $address === 'root' ) { return []; }
168 $parts = [];
169 foreach ( explode( '/', $address ) as $seg ) {
170 if ( $seg === '' || ! ctype_digit( $seg ) ) { return null; }
171 if ( $seg !== '0' && $seg[0] === '0' ) { return null; }
172 $parts[] = (int) $seg;
173 }
174 return $parts;
175 }
176
177 public static function node_at( $tree, $address ) {
178 $parts = self::address_parts( $address );
179 if ( $parts === null ) { return null; }
180 $nodes = $tree; $found = null;
181 foreach ( $parts as $i ) {
182 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
183 $found = $nodes[ $i ];
184 $nodes = isset( $found['children'] ) && is_array( $found['children'] ) ? $found['children'] : [];
185 }
186 return $found;
187 }
188
189 /** Find a node by its avcBlockId. Returns ['node'=>, 'path'=>] or null. */
190 public static function find_by_id( $tree, $id, $prefix = '' ) {
191 foreach ( $tree as $i => $node ) {
192 $path = $prefix === '' ? (string) $i : $prefix . '/' . $i;
193 if ( isset( $node['id'] ) && (string) $node['id'] === (string) $id ) { return [ 'node' => $node, 'path' => $path ]; }
194 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
195 $hit = self::find_by_id( $node['children'], $id, $path );
196 if ( $hit !== null ) { return $hit; }
197 }
198 }
199 return null;
200 }
201
202 /* ----------------------------- views ------------------------------- */
203
204 /** Lean nested read model. include_attrs adds attrs/html; depth caps recursion. */
205 public static function tree_view( $tree, $include_attrs = false, $max_depth = 64, $prefix = '', $depth = 1 ) {
206 $out = [];
207 foreach ( $tree as $i => $node ) {
208 $path = $prefix === '' ? (string) $i : $prefix . '/' . $i;
209 $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : [];
210 $row = [
211 'path' => $path,
212 'id' => isset( $node['id'] ) ? (string) $node['id'] : null,
213 'block' => isset( $node['block'] ) ? (string) $node['block'] : '',
214 'child_count' => count( $children ),
215 ];
216 if ( $include_attrs ) {
217 if ( isset( $node['attrs'] ) ) { $row['attrs'] = $node['attrs']; }
218 if ( isset( $node['html'] ) ) { $row['html'] = (string) $node['html']; }
219 }
220 if ( $children ) {
221 if ( $depth >= $max_depth ) { $row['children_truncated'] = true; }
222 else { $row['children'] = self::tree_view( $children, $include_attrs, $max_depth, $path, $depth + 1 ); }
223 }
224 $out[] = $row;
225 }
226 return $out;
227 }
228
229 public static function tree_count( $tree ) {
230 $n = 0;
231 foreach ( (array) $tree as $node ) {
232 $n++;
233 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) { $n += self::tree_count( $node['children'] ); }
234 }
235 return $n;
236 }
237
238 public static function full_node( $node, $path ) {
239 return [
240 'path' => $path,
241 'id' => isset( $node['id'] ) ? (string) $node['id'] : null,
242 'block' => isset( $node['block'] ) ? (string) $node['block'] : '',
243 'attrs' => isset( $node['attrs'] ) ? $node['attrs'] : [],
244 'html' => isset( $node['html'] ) ? (string) $node['html'] : '',
245 'children' => isset( $node['children'] ) && is_array( $node['children'] ) ? self::tree_view( $node['children'], false, 64, $path, 1 ) : [],
246 'editability' => self::editability( isset( $node['block'] ) ? (string) $node['block'] : '' ),
247 ];
248 }
249
250 /* --------------------------- mutation ------------------------------ */
251
252 public static function split_address( $address ) {
253 $parts = self::address_parts( $address );
254 if ( $parts === null || $parts === [] ) { return [ '', null ]; }
255 $index = array_pop( $parts );
256 return [ implode( '/', $parts ), $index ];
257 }
258
259 public static function update_children( $tree, $parent_address, $op ) {
260 $parts = self::address_parts( $parent_address );
261 if ( $parts === null ) { return null; }
262 if ( $parts === [] ) { return call_user_func( $op, $tree ); }
263 return self::descend( $tree, $parts, $op );
264 }
265 private static function descend( $nodes, $parts, $op ) {
266 $i = array_shift( $parts );
267 if ( ! array_key_exists( $i, $nodes ) ) { return null; }
268 $children = isset( $nodes[ $i ]['children'] ) && is_array( $nodes[ $i ]['children'] ) ? $nodes[ $i ]['children'] : [];
269 $updated = empty( $parts ) ? call_user_func( $op, $children ) : self::descend( $children, $parts, $op );
270 if ( $updated === null ) { return null; }
271 $nodes[ $i ]['children'] = $updated;
272 return $nodes;
273 }
274
275 public static function replace_at( $tree, $address, $node ) {
276 list( $parent, $index ) = self::split_address( $address );
277 if ( $index === null ) { return null; }
278 return self::update_children( $tree, $parent, function( $children ) use ( $index, $node ) {
279 if ( ! array_key_exists( $index, $children ) ) { return null; }
280 $children[ $index ] = $node;
281 return $children;
282 } );
283 }
284 public static function remove_at( $tree, $address ) {
285 list( $parent, $index ) = self::split_address( $address );
286 if ( $index === null ) { return null; }
287 return self::update_children( $tree, $parent, function( $children ) use ( $index ) {
288 if ( ! array_key_exists( $index, $children ) ) { return null; }
289 array_splice( $children, $index, 1 );
290 return $children;
291 } );
292 }
293 /** $nodes is a list of nodes. position null = append. */
294 public static function insert_at( $tree, $parent_address, $position, $nodes ) {
295 return self::update_children( $tree, $parent_address, function( $children ) use ( $position, $nodes ) {
296 $pos = $position === null ? count( $children ) : max( 0, min( (int) $position, count( $children ) ) );
297 array_splice( $children, $pos, 0, $nodes );
298 return $children;
299 } );
300 }
301
302 /** Stamp avcBlockId on every named block lacking one (recursive). */
303 public static function stamp_ids( $tree ) {
304 $out = [];
305 foreach ( $tree as $node ) {
306 if ( isset( $node['block'] ) && $node['block'] !== '' ) {
307 $attrs = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [];
308 if ( self::extract_id( $attrs ) === '' ) {
309 $id = self::uuid();
310 $node['attrs'] = self::set_id( $attrs, $id );
311 $node['id'] = $id;
312 }
313 }
314 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) { $node['children'] = self::stamp_ids( $node['children'] ); }
315 $out[] = $node;
316 }
317 return $out;
318 }
319
320 /** Deep-clone a node, reassigning fresh avcBlockIds where present. */
321 public static function clone_node( $node, $reassign_ids = true ) {
322 if ( $reassign_ids && isset( $node['block'] ) && $node['block'] !== '' ) {
323 $attrs = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [];
324 if ( self::extract_id( $attrs ) !== '' ) {
325 $id = self::uuid();
326 $node['attrs'] = self::set_id( $attrs, $id );
327 $node['id'] = $id;
328 }
329 }
330 if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
331 $kids = [];
332 foreach ( $node['children'] as $c ) { $kids[] = self::clone_node( $c, $reassign_ids ); }
333 $node['children'] = $kids;
334 }
335 return $node;
336 }
337
338 /* ----------------------- markdown / markup bridge ------------------ */
339
340 public static function markup_to_nodes( $markup ) { return self::parse_tree( (string) $markup ); }
341 public static function markdown_to_nodes( $md ) { return self::parse_tree( self::markdown_to_markup( $md ) ); }
342
343 /** Convert a curated subset of Markdown to canonical core-block markup. */
344 public static function markdown_to_markup( $md ) {
345 $md = str_replace( [ "\r\n", "\r" ], "\n", (string) $md );
346 $lines = explode( "\n", $md );
347 $n = count( $lines ); $i = 0; $blocks = [];
348 while ( $i < $n ) {
349 $line = $lines[ $i ]; $t = trim( $line );
350 if ( $t === '' ) { $i++; continue; }
351 if ( preg_match( '/^```/', $t ) ) {
352 $code = []; $i++;
353 while ( $i < $n && ! preg_match( '/^```/', trim( $lines[ $i ] ) ) ) { $code[] = $lines[ $i ]; $i++; }
354 $i++;
355 $blocks[] = self::md_code( implode( "\n", $code ) ); continue;
356 }
357 if ( preg_match( '/^(#{1,6})\s+(.*)$/', $t, $m ) ) { $blocks[] = self::md_heading( strlen( $m[1] ), self::md_inline( $m[2] ) ); $i++; continue; }
358 if ( preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $t ) ) { $blocks[] = self::md_separator(); $i++; continue; }
359 if ( preg_match( '/^>\s?/', $t ) ) {
360 $q = [];
361 while ( $i < $n && preg_match( '/^>\s?(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $q[] = $mm[1]; $i++; }
362 $blocks[] = self::md_quote( $q ); continue;
363 }
364 if ( preg_match( '/^[-*+]\s+/', $t ) ) {
365 $items = [];
366 while ( $i < $n && preg_match( '/^[-*+]\s+(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $items[] = self::md_inline( $mm[1] ); $i++; }
367 $blocks[] = self::md_list( $items, false ); continue;
368 }
369 if ( preg_match( '/^\d+\.\s+/', $t ) ) {
370 $items = [];
371 while ( $i < $n && preg_match( '/^\d+\.\s+(.*)$/', trim( $lines[ $i ] ), $mm ) ) { $items[] = self::md_inline( $mm[1] ); $i++; }
372 $blocks[] = self::md_list( $items, true ); continue;
373 }
374 if ( preg_match( '/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/', $t, $m ) ) { $blocks[] = self::md_image( $m[2], $m[1] ); $i++; continue; }
375 $para = [ $line ]; $i++;
376 while ( $i < $n && trim( $lines[ $i ] ) !== '' && ! self::md_is_block_start( trim( $lines[ $i ] ) ) ) { $para[] = $lines[ $i ]; $i++; }
377 $blocks[] = self::md_paragraph( self::md_inline( implode( '<br>', array_map( 'trim', $para ) ) ) );
378 }
379 return implode( "\n\n", $blocks );
380 }
381 private static function md_is_block_start( $t ) {
382 return (bool) ( preg_match( '/^(#{1,6}\s|>\s?|[-*+]\s|\d+\.\s|```|!\[)/', $t ) || preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $t ) );
383 }
384 public static function md_inline( $text ) {
385 $t = esc_html( (string) $text );
386 $t = preg_replace_callback( '/\[([^\]]+)\]\(([^)\s]+)\)/', function( $m ) { return '<a href="' . esc_url( $m[2] ) . '">' . $m[1] . '</a>'; }, $t );
387 $t = preg_replace( '/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $t );
388 $t = preg_replace( '/__([^_]+)__/', '<strong>$1</strong>', $t );
389 $t = preg_replace( '/(?<!\*)\*([^*]+)\*(?!\*)/', '<em>$1</em>', $t );
390 $t = preg_replace( '/(?<![\w_])_([^_]+)_(?![\w_])/', '<em>$1</em>', $t );
391 $t = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $t );
392 return $t;
393 }
394 private static function md_paragraph( $html ) { return "<!-- wp:paragraph -->\n<p>{$html}</p>\n<!-- /wp:paragraph -->"; }
395 private static function md_heading( $level, $html ) {
396 $level = max( 1, min( 6, (int) $level ) );
397 $attr = $level === 2 ? '' : ' {"level":' . $level . '}';
398 return "<!-- wp:heading{$attr} -->\n<h{$level}>{$html}</h{$level}>\n<!-- /wp:heading -->";
399 }
400 private static function md_separator() { return "<!-- wp:separator -->\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"/>\n<!-- /wp:separator -->"; }
401 private static function md_image( $url, $alt ) {
402 $u = esc_url( $url ); $a = esc_attr( $alt );
403 return "<!-- wp:image -->\n<figure class=\"wp-block-image\"><img src=\"{$u}\" alt=\"{$a}\"/></figure>\n<!-- /wp:image -->";
404 }
405 private static function md_code( $code ) {
406 $c = esc_html( $code );
407 return "<!-- wp:code -->\n<pre class=\"wp-block-code\"><code>{$c}</code></pre>\n<!-- /wp:code -->";
408 }
409 private static function md_quote( $lines ) {
410 $inner = []; $buf = [];
411 foreach ( $lines as $l ) {
412 if ( trim( $l ) === '' ) { if ( $buf ) { $inner[] = self::md_paragraph( self::md_inline( implode( '<br>', $buf ) ) ); $buf = []; } }
413 else { $buf[] = trim( $l ); }
414 }
415 if ( $buf ) { $inner[] = self::md_paragraph( self::md_inline( implode( '<br>', $buf ) ) ); }
416 $body = implode( "\n", $inner );
417 return "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\">{$body}</blockquote>\n<!-- /wp:quote -->";
418 }
419 private static function md_list( $items, $ordered ) {
420 $tag = $ordered ? 'ol' : 'ul';
421 $attr = $ordered ? ' {"ordered":true}' : '';
422 $lis = [];
423 foreach ( $items as $it ) { $lis[] = "<!-- wp:list-item -->\n<li>{$it}</li>\n<!-- /wp:list-item -->"; }
424 $body = implode( "\n", $lis );
425 return "<!-- wp:list{$attr} -->\n<{$tag} class=\"wp-block-list\">{$body}</{$tag}>\n<!-- /wp:list -->";
426 }
427
428 }
429
430 class AVCF_Abilities_Gutenberg extends AVCF_Abilities_Base {
431
432 public function register() {
433 $this->register_read_page();
434 $this->register_read_block();
435 $this->register_list_block_types();
436 $this->register_apply_operations();
437 }
438
439 /* ------------------------------ shared ----------------------------- */
440
441 private function ro_meta() {
442 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ] ];
443 }
444 private function guard( $input, $need_edit = false ) {
445 $pid = isset( $input['post_id'] ) ? (int) $input['post_id'] : ( isset( $input['post'] ) ? (int) $input['post'] : 0 );
446 if ( $pid <= 0 || ! get_post( $pid ) ) { return [ 'err' => [ 'success' => false, 'message' => 'A valid post_id is required.' ] ]; }
447 if ( $need_edit && ! ( current_user_can( 'edit_post', $pid ) || current_user_can( 'edit_posts' ) ) ) {
448 return [ 'err' => [ 'success' => false, 'message' => 'No permission to edit this post.' ] ];
449 }
450 return [ 'post_id' => $pid ];
451 }
452
453 /* ----------------------------- read-page --------------------------- */
454
455 private function register_read_page() {
456 $self = $this;
457 wp_register_ability( 'atarim/gutenberg-read-page', [
458 'label' => 'Read Gutenberg Page', 'category' => 'atarim',
459 '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. 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.',
460 'input_schema' => [ 'type' => 'object', 'properties' => [
461 'post_id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'Post/page id (also accepts "post").' ],
462 'include_attrs' => [ 'type' => 'boolean', 'default' => false ],
463 'max_depth' => [ 'type' => 'integer', 'minimum' => 1, 'default' => 64 ],
464 ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
465 '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' ] ],
466 'execute_callback' => function( $input = [] ) use ( $self ) {
467 $g = $self->guard( $input ); if ( isset( $g['err'] ) ) { return $g['err']; }
468 $post = get_post( $g['post_id'] );
469 $content = $post->post_content;
470 $tree = AVCF_Gutenberg_Helpers::parse_tree( $content );
471 $include = ! empty( $input['include_attrs'] );
472 $max_depth = isset( $input['max_depth'] ) ? max( 1, (int) $input['max_depth'] ) : 64;
473 return [
474 'success' => true,
475 'post_id' => $g['post_id'],
476 'post_type' => $post->post_type,
477 'block_based' => AVCF_Gutenberg_Helpers::is_block_based( $content ),
478 'classic' => AVCF_Gutenberg_Helpers::is_classic( $content ),
479 'content_hash' => AVCF_Gutenberg_Helpers::content_hash( $content ),
480 'total_blocks' => AVCF_Gutenberg_Helpers::tree_count( $tree ),
481 'tree' => AVCF_Gutenberg_Helpers::tree_view( $tree, $include, $max_depth ),
482 'message' => 'OK.',
483 ];
484 },
485 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
486 'meta' => $this->ro_meta(),
487 ] );
488 }
489
490 /* ---------------------------- read-block --------------------------- */
491
492 private function register_read_block() {
493 $self = $this;
494 wp_register_ability( 'atarim/gutenberg-read-block', [
495 'label' => 'Read Gutenberg Block', 'category' => 'atarim',
496 'description' => 'Return one block in full — block name, attrs, inner html, child summary, 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).',
497 'input_schema' => [ 'type' => 'object', 'properties' => [
498 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
499 'path' => [ 'type' => 'string', 'description' => 'Index-path "0/1/2".' ],
500 'block_id' => [ 'type' => 'string', 'description' => 'The stable avcBlockId.' ],
501 ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
502 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'block' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
503 'execute_callback' => function( $input = [] ) use ( $self ) {
504 $g = $self->guard( $input ); if ( isset( $g['err'] ) ) { return $g['err']; }
505 $post = get_post( $g['post_id'] );
506 $tree = AVCF_Gutenberg_Helpers::parse_tree( $post->post_content );
507 $has_path = isset( $input['path'] ) && $input['path'] !== '';
508 $has_id = isset( $input['block_id'] ) && $input['block_id'] !== '';
509 if ( ! $has_path && ! $has_id ) { return [ 'success' => false, 'message' => 'Provide path or block_id.' ]; }
510 if ( $has_id ) {
511 $hit = AVCF_Gutenberg_Helpers::find_by_id( $tree, (string) $input['block_id'] );
512 if ( $hit === null ) { return [ 'success' => false, 'message' => 'No block with that avcBlockId.' ]; }
513 return [ 'success' => true, 'block' => AVCF_Gutenberg_Helpers::full_node( $hit['node'], $hit['path'] ), 'message' => 'OK.' ];
514 }
515 $node = AVCF_Gutenberg_Helpers::node_at( $tree, (string) $input['path'] );
516 if ( $node === null ) { return [ 'success' => false, 'message' => sprintf( 'No block at path "%s".', $input['path'] ) ]; }
517 return [ 'success' => true, 'block' => AVCF_Gutenberg_Helpers::full_node( $node, (string) $input['path'] ), 'message' => 'OK.' ];
518 },
519 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
520 'meta' => $this->ro_meta(),
521 ] );
522 }
523
524 /* ------------------------- list-block-types ------------------------ */
525
526 private function register_list_block_types() {
527 wp_register_ability( 'atarim/gutenberg-list-block-types', [
528 'label' => 'List Gutenberg Block Types', 'category' => 'atarim',
529 '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.',
530 'input_schema' => [ 'type' => 'object', 'properties' => [
531 'search' => [ 'type' => 'string' ],
532 'editability' => [ 'type' => 'string', 'enum' => [ 'attr', 'bridge', 'structural' ] ],
533 ], 'additionalProperties' => false ],
534 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'types' => [ 'type' => 'array' ], 'total' => [ 'type' => 'integer' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
535 'execute_callback' => function( $input = [] ) {
536 $types = AVCF_Gutenberg_Helpers::list_block_types( isset( $input['search'] ) ? (string) $input['search'] : '' );
537 if ( isset( $input['editability'] ) && $input['editability'] !== '' ) {
538 $f = (string) $input['editability'];
539 $types = array_values( array_filter( $types, function( $t ) use ( $f ) { return $t['editability'] === $f; } ) );
540 }
541 return [ 'success' => true, 'types' => $types, 'total' => count( $types ), 'message' => sprintf( '%d block type(s).', count( $types ) ) ];
542 },
543 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
544 'meta' => $this->ro_meta(),
545 ] );
546 }
547
548 /* -------------------------- write engine --------------------------- */
549
550 private function write_meta( $destructive = false ) {
551 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => false, 'destructive' => (bool) $destructive, 'idempotent' => false ] ];
552 }
553
554 private function register_apply_operations() {
555 $self = $this;
556 wp_register_ability( 'atarim/gutenberg-apply-operations', [
557 'label' => 'Apply Gutenberg Operations', 'category' => 'atarim',
558 '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: '
559 . 'insert (channels: markdown | markup | block; into parent [id/path/root] at index), '
560 . 'update (markdown/markup regen a single block, or merge/replace attrs, or set html), '
561 . 'move (target -> parent [prefer id] + index; cannot move into its own subtree), '
562 . 'swap (exchange two non-nested blocks a/b), delete (target), duplicate (target -> clone after it with fresh ids). '
563 . '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.',
564 'input_schema' => [ 'type' => 'object', 'properties' => [
565 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
566 'content_hash' => [ 'type' => 'string', 'description' => 'From read-page. Required stale-edit guard.' ],
567 '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.' ],
568 'stamp_ids' => [ 'type' => 'boolean', 'default' => false ],
569 'force' => [ 'type' => 'boolean', 'default' => false ],
570 ], 'required' => [ 'post_id', 'content_hash', 'operations' ], 'additionalProperties' => false ],
571 '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' ] ],
572 'execute_callback' => function( $input = [] ) use ( $self ) { return $self->apply_operations( $input ); },
573 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
574 'meta' => $self->write_meta_public( true ),
575 ] );
576 }
577
578 /** public wrapper so the registration closure can read write meta */
579 public function write_meta_public( $destructive = false ) { return $this->write_meta( $destructive ); }
580
581 public function apply_operations( $input ) {
582 $g = $this->guard( $input, true );
583 if ( isset( $g['err'] ) ) { return $g['err']; }
584 $post_id = $g['post_id'];
585 $post = get_post( $post_id );
586 $content = $post->post_content;
587
588 if ( AVCF_Gutenberg_Helpers::is_classic( $content ) && empty( $input['force'] ) ) {
589 return [ 'success' => false, 'message' => 'This post is classic HTML (no blocks); editing it as blocks would be lossy. Pass force:true to proceed.' ];
590 }
591 $expected = isset( $input['content_hash'] ) ? (string) $input['content_hash'] : '';
592 if ( $expected === '' ) { return [ 'success' => false, 'message' => 'content_hash is required (get it from read-page).' ]; }
593 if ( $expected !== AVCF_Gutenberg_Helpers::content_hash( $content ) ) {
594 return [ 'success' => false, 'message' => 'Stale edit: the page changed since you read it. Re-read with read-page and retry.' ];
595 }
596 $ops = isset( $input['operations'] ) && is_array( $input['operations'] ) ? $input['operations'] : [];
597 if ( $ops === [] ) { return [ 'success' => false, 'message' => 'No operations provided.' ]; }
598
599 $tree = AVCF_Gutenberg_Helpers::parse_tree( $content );
600 $applied = 0; $new_ids = [];
601 foreach ( $ops as $idx => $op ) {
602 $res = $this->apply_one( $tree, is_array( $op ) ? $op : [] );
603 if ( isset( $res['err'] ) ) {
604 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'] ) ];
605 }
606 $tree = $res['tree'];
607 if ( isset( $res['new_id'] ) ) { $new_ids[] = $res['new_id']; }
608 $applied++;
609 }
610 if ( ! empty( $input['stamp_ids'] ) ) { $tree = AVCF_Gutenberg_Helpers::stamp_ids( $tree ); }
611
612 $markup = AVCF_Gutenberg_Helpers::serialize_tree( $tree );
613 $r = wp_update_post( [ 'ID' => $post_id, 'post_content' => $markup ], true );
614 if ( is_wp_error( $r ) ) { return [ 'success' => false, 'message' => $r->get_error_message() ]; }
615 return [ 'success' => true, 'applied' => $applied, 'content_hash' => AVCF_Gutenberg_Helpers::content_hash( $markup ), 'new_ids' => $new_ids, 'message' => sprintf( '%d operation(s) applied.', $applied ) ];
616 }
617
618 private function apply_one( $tree, $op ) {
619 $type = isset( $op['op'] ) ? (string) $op['op'] : '';
620 switch ( $type ) {
621 case 'insert': return $this->op_insert( $tree, $op );
622 case 'update': return $this->op_update( $tree, $op );
623 case 'move': return $this->op_move( $tree, $op );
624 case 'swap': return $this->op_swap( $tree, $op );
625 case 'delete': return $this->op_delete( $tree, $op );
626 case 'duplicate': return $this->op_duplicate( $tree, $op );
627 default: return [ 'err' => 'unknown op "' . $type . '"' ];
628 }
629 }
630
631 /** '' / 'root' -> '' (root). id resolved first, then path. null = not found. */
632 private function resolve_to_path( $tree, $ref ) {
633 $ref = (string) $ref;
634 if ( $ref === '' || $ref === 'root' ) { return ''; }
635 $hit = AVCF_Gutenberg_Helpers::find_by_id( $tree, $ref );
636 if ( $hit !== null ) { return $hit['path']; }
637 if ( AVCF_Gutenberg_Helpers::node_at( $tree, $ref ) !== null ) { return $ref; }
638 return null;
639 }
640
641 private function nodes_from_op( $op ) {
642 if ( isset( $op['markdown'] ) && $op['markdown'] !== '' ) { return [ 'nodes' => AVCF_Gutenberg_Helpers::markdown_to_nodes( (string) $op['markdown'] ) ]; }
643 if ( isset( $op['markup'] ) && $op['markup'] !== '' ) { return [ 'nodes' => AVCF_Gutenberg_Helpers::markup_to_nodes( (string) $op['markup'] ) ]; }
644 if ( isset( $op['block'] ) && is_array( $op['block'] ) ) {
645 $b = $op['block'];
646 $name = isset( $b['block'] ) ? (string) $b['block'] : '';
647 if ( $name === '' ) { return [ 'err' => 'block.block (name) is required' ]; }
648 $node = [ 'block' => $name ];
649 if ( isset( $b['attrs'] ) && is_array( $b['attrs'] ) ) { $node['attrs'] = $b['attrs']; }
650 if ( isset( $b['html'] ) ) { $node['html'] = (string) $b['html']; }
651 if ( isset( $b['children'] ) && is_array( $b['children'] ) ) { $node['children'] = $b['children']; }
652 return [ 'nodes' => [ $node ] ];
653 }
654 return [ 'err' => 'insert needs markdown, markup, or block' ];
655 }
656
657 private function op_insert( $tree, $op ) {
658 $nodes = $this->nodes_from_op( $op );
659 if ( isset( $nodes['err'] ) ) { return $nodes; }
660 if ( $nodes['nodes'] === [] ) { return [ 'err' => 'nothing to insert (empty content)' ]; }
661 $parent_path = $this->resolve_to_path( $tree, isset( $op['parent'] ) ? (string) $op['parent'] : '' );
662 if ( $parent_path === null ) { return [ 'err' => 'parent not found: ' . ( isset( $op['parent'] ) ? $op['parent'] : '' ) ]; }
663 $index = isset( $op['index'] ) ? (int) $op['index'] : null;
664 $new = AVCF_Gutenberg_Helpers::insert_at( $tree, $parent_path, $index, $nodes['nodes'] );
665 return $new === null ? [ 'err' => 'insert failed' ] : [ 'tree' => $new ];
666 }
667
668 private function op_update( $tree, $op ) {
669 $path = $this->resolve_to_path( $tree, isset( $op['target'] ) ? (string) $op['target'] : '' );
670 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
671 $node = AVCF_Gutenberg_Helpers::node_at( $tree, $path );
672 if ( $node === null ) { return [ 'err' => 'target not found' ]; }
673
674 if ( isset( $op['markup'] ) && $op['markup'] !== '' ) {
675 $nodes = AVCF_Gutenberg_Helpers::markup_to_nodes( (string) $op['markup'] );
676 if ( count( $nodes ) !== 1 ) { return [ 'err' => 'update markup must produce exactly one block' ]; }
677 $new = AVCF_Gutenberg_Helpers::replace_at( $tree, $path, $nodes[0] );
678 return $new === null ? [ 'err' => 'update failed' ] : [ 'tree' => $new ];
679 }
680 if ( isset( $op['markdown'] ) && $op['markdown'] !== '' ) {
681 $nodes = AVCF_Gutenberg_Helpers::markdown_to_nodes( (string) $op['markdown'] );
682 if ( count( $nodes ) !== 1 ) { return [ 'err' => 'update markdown must produce exactly one block' ]; }
683 $repl = $nodes[0];
684 if ( isset( $node['id'] ) ) {
685 $repl['attrs'] = AVCF_Gutenberg_Helpers::set_id( isset( $repl['attrs'] ) ? $repl['attrs'] : [], $node['id'] );
686 $repl['id'] = $node['id'];
687 }
688 $new = AVCF_Gutenberg_Helpers::replace_at( $tree, $path, $repl );
689 return $new === null ? [ 'err' => 'update failed' ] : [ 'tree' => $new ];
690 }
691 if ( isset( $op['attrs'] ) && is_array( $op['attrs'] ) ) {
692 $cur = isset( $node['attrs'] ) && is_array( $node['attrs'] ) ? $node['attrs'] : [];
693 $node['attrs'] = ! empty( $op['replace_attrs'] ) ? $op['attrs'] : array_merge( $cur, $op['attrs'] );
694 $new = AVCF_Gutenberg_Helpers::replace_at( $tree, $path, $node );
695 return $new === null ? [ 'err' => 'update failed' ] : [ 'tree' => $new ];
696 }
697 if ( isset( $op['html'] ) ) {
698 $node['html'] = (string) $op['html'];
699 unset( $node['children'] );
700 $new = AVCF_Gutenberg_Helpers::replace_at( $tree, $path, $node );
701 return $new === null ? [ 'err' => 'update failed' ] : [ 'tree' => $new ];
702 }
703 return [ 'err' => 'update needs markdown, markup, attrs, or html' ];
704 }
705
706 private function op_move( $tree, $op ) {
707 $from = $this->resolve_to_path( $tree, isset( $op['target'] ) ? (string) $op['target'] : '' );
708 if ( $from === null || $from === '' ) { return [ 'err' => 'target not found' ]; }
709 $node = AVCF_Gutenberg_Helpers::node_at( $tree, $from );
710 if ( $node === null ) { return [ 'err' => 'target not found' ]; }
711 $parent_ref = isset( $op['parent'] ) ? (string) $op['parent'] : '';
712 $to = $this->resolve_to_path( $tree, $parent_ref );
713 if ( $to === null ) { return [ 'err' => 'parent not found: ' . $parent_ref ]; }
714 if ( $to === $from || strpos( $to . '/', $from . '/' ) === 0 ) { return [ 'err' => 'cannot move a block into itself or its descendant' ]; }
715 $removed = AVCF_Gutenberg_Helpers::remove_at( $tree, $from );
716 if ( $removed === null ) { return [ 'err' => 'move: removal failed' ]; }
717 $to2 = ( $parent_ref === '' || $parent_ref === 'root' ) ? '' : $this->resolve_to_path( $removed, $parent_ref );
718 if ( $to2 === null ) { return [ 'err' => 'move: parent shifted after removal — target it by avcBlockId' ]; }
719 $index = isset( $op['index'] ) ? (int) $op['index'] : null;
720 $new = AVCF_Gutenberg_Helpers::insert_at( $removed, $to2, $index, [ $node ] );
721 return $new === null ? [ 'err' => 'move: insert failed' ] : [ 'tree' => $new ];
722 }
723
724 private function op_swap( $tree, $op ) {
725 $pa = $this->resolve_to_path( $tree, isset( $op['a'] ) ? (string) $op['a'] : '' );
726 $pb = $this->resolve_to_path( $tree, isset( $op['b'] ) ? (string) $op['b'] : '' );
727 if ( $pa === null || $pa === '' || $pb === null || $pb === '' ) { return [ 'err' => 'swap needs valid a and b' ]; }
728 if ( $pa === $pb ) { return [ 'err' => 'swap a and b are the same block' ]; }
729 if ( strpos( $pa . '/', $pb . '/' ) === 0 || strpos( $pb . '/', $pa . '/' ) === 0 ) { return [ 'err' => 'cannot swap nested blocks' ]; }
730 $na = AVCF_Gutenberg_Helpers::node_at( $tree, $pa );
731 $nb = AVCF_Gutenberg_Helpers::node_at( $tree, $pb );
732 if ( $na === null || $nb === null ) { return [ 'err' => 'swap target not found' ]; }
733 $t = AVCF_Gutenberg_Helpers::replace_at( $tree, $pa, $nb );
734 if ( $t === null ) { return [ 'err' => 'swap failed' ]; }
735 $t = AVCF_Gutenberg_Helpers::replace_at( $t, $pb, $na );
736 return $t === null ? [ 'err' => 'swap failed' ] : [ 'tree' => $t ];
737 }
738
739 private function op_delete( $tree, $op ) {
740 $path = $this->resolve_to_path( $tree, isset( $op['target'] ) ? (string) $op['target'] : '' );
741 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
742 $new = AVCF_Gutenberg_Helpers::remove_at( $tree, $path );
743 return $new === null ? [ 'err' => 'delete failed' ] : [ 'tree' => $new ];
744 }
745
746 private function op_duplicate( $tree, $op ) {
747 $path = $this->resolve_to_path( $tree, isset( $op['target'] ) ? (string) $op['target'] : '' );
748 if ( $path === null || $path === '' ) { return [ 'err' => 'target not found' ]; }
749 $node = AVCF_Gutenberg_Helpers::node_at( $tree, $path );
750 if ( $node === null ) { return [ 'err' => 'target not found' ]; }
751 $clone = AVCF_Gutenberg_Helpers::clone_node( $node, true );
752 list( $parent, $index ) = AVCF_Gutenberg_Helpers::split_address( $path );
753 $pos = $index === null ? null : ( (int) $index + 1 );
754 $new = AVCF_Gutenberg_Helpers::insert_at( $tree, $parent, $pos, [ $clone ] );
755 if ( $new === null ) { return [ 'err' => 'duplicate failed' ]; }
756 return isset( $clone['id'] ) ? [ 'tree' => $new, 'new_id' => $clone['id'] ] : [ 'tree' => $new ];
757 }
758
759 }
760