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 |