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 | } |