Changeset 3798383
- Timestamp:
- 11/12/2026 01:15:41 PM (less than one hour ago)
- Location:
- iato-mcp
- Files:
-
- 16 edited
- 1 copied
-
tags/1.21.1 (copied) (copied from iato-mcp/trunk )
-
tags/1.21.1/iato-mcp.php (modified) (2 diffs)
-
tags/1.21.1/includes/class-elementor-adapter.php (modified) (12 diffs)
-
tags/1.21.1/includes/class-page-fonts.php (modified) (2 diffs)
-
tags/1.21.1/includes/class-sanitize-report.php (modified) (3 diffs)
-
tags/1.21.1/includes/class-settings.php (modified) (4 diffs)
-
tags/1.21.1/includes/tools/wp/tool-elementor-widgets.php (modified) (1 diff)
-
tags/1.21.1/includes/tools/wp/tool-page-assets.php (modified) (4 diffs)
-
tags/1.21.1/readme.txt (modified) (3 diffs)
-
trunk/iato-mcp.php (modified) (2 diffs)
-
trunk/includes/class-elementor-adapter.php (modified) (12 diffs)
-
trunk/includes/class-page-fonts.php (modified) (2 diffs)
-
trunk/includes/class-sanitize-report.php (modified) (3 diffs)
-
trunk/includes/class-settings.php (modified) (4 diffs)
-
trunk/includes/tools/wp/tool-elementor-widgets.php (modified) (1 diff)
-
trunk/includes/tools/wp/tool-page-assets.php (modified) (4 diffs)
-
trunk/readme.txt (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
iato-mcp/tags/1.21.1/iato-mcp.php
r3797770 r3798383 4 4 * Plugin URI: https://iato.ai/wordpress-mcp 5 5 * Description: Exposes an MCP server from any self-hosted WordPress install, enabling IATO analyze-and-fix workflows via Claude Desktop and other AI clients. 6 * Version: 1.21. 06 * Version: 1.21.1 7 7 * Author: IATO 8 8 * Author URI: https://iato.ai … … 18 18 defined( 'ABSPATH' ) || exit; 19 19 20 define( 'IATO_MCP_VERSION', '1.21. 0' );20 define( 'IATO_MCP_VERSION', '1.21.1' ); 21 21 define( 'IATO_MCP_FILE', __FILE__ ); 22 22 define( 'IATO_MCP_DIR', plugin_dir_path( __FILE__ ) ); -
iato-mcp/tags/1.21.1/includes/class-elementor-adapter.php
r3797770 r3798383 423 423 * Implements: add, remove, replace, move, copy, test. 424 424 * Strict path semantics: replace/remove/move/copy fail on missing paths. 425 * add, copy and move insert into a list at an index, as RFC 6902 says (1.21.1: 426 * they replaced the element there until then), and each op on a list item 427 * reports the list as it is afterwards (`list`, `from_list`). 428 * 429 * Each op that sets a value also carries `_stored_at`, where annotate_stored() 430 * finds it in the stored tree: its element's id and the path inside it, so a 431 * later op that shifts the list does not send it to another element. 432 * annotate_stored() removes the key. 425 433 * 426 434 * @return array|WP_Error Applied ops on success. … … 436 444 return $result; 437 445 } 446 if ( in_array( $result['op'], [ 'add', 'replace' ], true ) ) { 447 $result['_stored_at'] = self::stored_at( $elements, $result['_resolved_path'] ?? $result['path'] ); 448 } 449 unset( $result['_resolved_path'] ); 438 450 $applied[] = $result; 439 451 } … … 449 461 return new WP_Error( 'invalid_patch', "add op missing 'value' at {$path}" ); 450 462 } 451 $ err = self::pointer_set( $elements, $path, $op['value'], true);452 if ( is_wp_error( $ err) ) {453 return $ err;454 } 455 return [ 'op' => 'add', 'path' => $path, 'value' => $op['value'] ];463 $added = self::pointer_add( $elements, $path, $op['value'] ); 464 if ( is_wp_error( $added ) ) { 465 return $added; 466 } 467 return self::with_list( [ 'op' => 'add', 'path' => $path, 'value' => $op['value'] ], $elements, $path, $added ); 456 468 457 469 case 'replace': … … 479 491 return $prev; 480 492 } 481 $err = self::pointer_remove( $elements, $path ); 493 $removed = self::list_position( $elements, $path ); 494 $err = self::pointer_remove( $elements, $path ); 482 495 if ( is_wp_error( $err ) ) { 483 496 return $err; 484 497 } 485 return [ 'op' => 'remove', 'path' => $path, 'previous_value' => $prev ]; 498 $out = [ 'op' => 'remove', 'path' => $path, 'previous_value' => $prev ]; 499 if ( null !== $removed ) { 500 $out['list'] = self::list_report( $elements, self::parent_pointer( $path ), $removed['index'], $removed['count_before'] ); 501 } 502 return $out; 486 503 487 504 case 'move': … … 490 507 return new WP_Error( 'invalid_patch', "move op missing 'from' at {$path}" ); 491 508 } 509 // RFC 6902 4.4: an element cannot move into its own children. 510 if ( str_starts_with( $path, $from . '/' ) ) { 511 return new WP_Error( 'invalid_patch', "move op cannot move {$from} into itself ({$path})." ); 512 } 492 513 $value = self::pointer_get( $elements, $from ); 493 514 if ( is_wp_error( $value ) ) { 494 515 return $value; 495 516 } 496 $err = self::pointer_remove( $elements, $from ); 517 $taken = self::list_position( $elements, $from ); 518 $err = self::pointer_remove( $elements, $from ); 497 519 if ( is_wp_error( $err ) ) { 498 520 return $err; 499 521 } 500 $err = self::pointer_set( $elements, $path, $value, true ); 501 if ( is_wp_error( $err ) ) { 502 return $err; 503 } 504 return [ 'op' => 'move', 'from' => $from, 'path' => $path, 'value' => $value ]; 522 $added = self::pointer_add( $elements, $path, $value ); 523 if ( is_wp_error( $added ) ) { 524 return $added; 525 } 526 $out = [ 'op' => 'move', 'from' => $from, 'path' => $path, 'value' => $value ]; 527 $same_parent = self::parent_pointer( $from ) === self::parent_pointer( $path ); 528 if ( $same_parent && null !== $taken && null !== $added['index'] ) { 529 // One list: its length before the move, not between the two halves of it. 530 $added['count_before'] = $taken['count_before']; 531 } elseif ( null !== $taken ) { 532 $out['from_list'] = self::list_report( $elements, self::parent_pointer( $from ), $taken['index'], $taken['count_before'] ); 533 } 534 return self::with_list( $out, $elements, $path, $added ); 505 535 506 536 case 'copy': … … 513 543 return $value; 514 544 } 515 $ err = self::pointer_set( $elements, $path, $value, true );516 if ( is_wp_error( $ err) ) {517 return $ err;518 } 519 return [ 'op' => 'copy', 'from' => $from, 'path' => $path, 'value' => $value ];545 $added = self::pointer_add( $elements, $path, $value ); 546 if ( is_wp_error( $added ) ) { 547 return $added; 548 } 549 return self::with_list( [ 'op' => 'copy', 'from' => $from, 'path' => $path, 'value' => $value ], $elements, $path, $added ); 520 550 521 551 case 'test': … … 655 685 return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" ); 656 686 } 687 // Asked before the unset (1.21.1): afterwards a list missing its middle item 688 // has keys 0 and 2, was never re-indexed, and was stored as a JSON object. 689 $was_list = self::is_list( $node ); 657 690 unset( $node[ $key ] ); 658 // Re-index numeric arrays so subsequent path resolution doesn't break.659 if ( array_keys( $node ) === range( 0, count( $node ) - 1 )) {691 // Re-index lists so subsequent path resolution doesn't break. 692 if ( $was_list ) { 660 693 $node = array_values( $node ); 661 694 } 662 695 return null; 696 } 697 698 /** 699 * RFC 6902 add: insert into a list at an index (`-` or the list's length 700 * appends), set a member of an object. Mutates by reference. 701 * 702 * A list is an array keyed 0..n-1, or empty. Anything else is an object, 703 * including a stored page whose elements have gaps in their numbering, which 704 * keeps the behaviour it had: the key is set, and `-` appends. 705 * 706 * @return array|WP_Error `index` and `count_before` for a list insert, both null otherwise. 707 */ 708 public static function pointer_add( array &$root, string $path, mixed $value ): array|WP_Error { 709 if ( '' === $path ) { 710 return new WP_Error( 'invalid_patch_path', 'Cannot replace root document.' ); 711 } 712 $segs = self::pointer_segments( $path ); 713 $last = array_pop( $segs ); 714 $node = &$root; 715 foreach ( $segs as $seg ) { 716 if ( ! is_array( $node ) ) { 717 return new WP_Error( 'invalid_patch_path', "Cannot traverse non-array at {$path}" ); 718 } 719 if ( array_key_exists( $seg, $node ) ) { 720 $node = &$node[ $seg ]; 721 continue; 722 } 723 if ( ctype_digit( $seg ) && array_key_exists( (int) $seg, $node ) ) { 724 $node = &$node[ (int) $seg ]; 725 continue; 726 } 727 return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" ); 728 } 729 if ( ! is_array( $node ) ) { 730 return new WP_Error( 'invalid_patch_path', "Cannot set on non-array at {$path}" ); 731 } 732 if ( self::is_list( $node ) && ( '-' === $last || ctype_digit( $last ) ) ) { 733 $count = count( $node ); 734 if ( '-' === $last ) { 735 $index = $count; 736 } else { 737 if ( strlen( $last ) > 1 && '0' === $last[0] ) { 738 return new WP_Error( 'invalid_patch_path', "Index {$last} has a leading zero at {$path}." ); 739 } 740 $index = (int) $last; 741 if ( $index > $count ) { 742 return new WP_Error( 'invalid_patch_path', "Index {$index} is past the end of a list of {$count} at {$path}. Use {$count} or - to append." ); 743 } 744 } 745 array_splice( $node, $index, 0, [ $value ] ); 746 return [ 'index' => $index, 'count_before' => $count ]; 747 } 748 if ( '-' === $last ) { 749 $node[] = $value; 750 } else { 751 $node[ ctype_digit( $last ) ? (int) $last : $last ] = $value; 752 } 753 return [ 'index' => null, 'count_before' => null ]; 754 } 755 756 /** Keyed 0..n-1, or empty: a JSON array. (array_is_list() needs PHP 8.1.) */ 757 private static function is_list( array $value ): bool { 758 return [] === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ); 759 } 760 761 /** The pointer of the array that holds $path's target ('' for the root). */ 762 private static function parent_pointer( string $path ): string { 763 $at = strrpos( $path, '/' ); 764 return false === $at ? '' : substr( $path, 0, $at ); 765 } 766 767 /** Where $path's target sits in its list, or null when its parent is not a list. */ 768 private static function list_position( array $root, string $path ): ?array { 769 $parent = self::pointer_get( $root, self::parent_pointer( $path ) ); 770 $last = (string) substr( (string) strrchr( $path, '/' ), 1 ); 771 if ( ! is_array( $parent ) || ! self::is_list( $parent ) || ! ctype_digit( $last ) ) { 772 return null; 773 } 774 return [ 'index' => (int) $last, 'count_before' => count( $parent ) ]; 775 } 776 777 /** At most this many ids in a `list` report. */ 778 private const LIST_IDS_MAX = 100; 779 780 /** 781 * The list at $parent after an op: where the op acted, its length before and 782 * after, and each item's `id` (elements) or `_id` (repeater rows), null for 783 * an item with neither. Shows a misplaced insert before it is written. 784 */ 785 private static function list_report( array $root, string $parent, int $index, int $count_before ): array { 786 $out = [ 'path' => $parent, 'index' => $index, 'count_before' => $count_before ]; 787 $list = self::pointer_get( $root, $parent ); 788 if ( ! is_array( $list ) ) { 789 return $out; 790 } 791 $items = array_values( $list ); 792 $out['count_after'] = count( $items ); 793 $ids = []; 794 foreach ( array_slice( $items, 0, self::LIST_IDS_MAX ) as $item ) { 795 $id = is_array( $item ) ? ( $item['id'] ?? $item['_id'] ?? null ) : null; 796 $ids[] = is_scalar( $id ) ? (string) $id : null; 797 } 798 $out['ids_after'] = $ids; 799 if ( count( $items ) > self::LIST_IDS_MAX ) { 800 $out['ids_truncated'] = true; 801 } 802 return $out; 803 } 804 805 /** Add an insert's `list` report to its op, and the index `-` stood for. */ 806 private static function with_list( array $op, array $root, string $path, array $added ): array { 807 if ( null === $added['index'] ) { 808 return $op; 809 } 810 $parent = self::parent_pointer( $path ); 811 $op['list'] = self::list_report( $root, $parent, $added['index'], $added['count_before'] ); 812 $op['_resolved_path'] = $parent . '/' . $added['index']; 813 return $op; 814 } 815 816 /** 817 * Where annotate_stored() finds an op's value: the deepest element (an object 818 * with `id` and `elType`) on its path, by id, and the path inside it. The whole 819 * path when no element lies on it. 820 */ 821 private static function stored_at( array $root, string $path ): array { 822 $segs = self::pointer_segments( $path ); 823 $node = $root; 824 $anchor = null; 825 foreach ( $segs as $i => $seg ) { 826 if ( ! is_array( $node ) ) { 827 break; 828 } 829 if ( array_key_exists( $seg, $node ) ) { 830 $node = $node[ $seg ]; 831 } elseif ( ctype_digit( $seg ) && array_key_exists( (int) $seg, $node ) ) { 832 $node = $node[ (int) $seg ]; 833 } else { 834 break; 835 } 836 if ( is_array( $node ) && isset( $node['elType'] ) && isset( $node['id'] ) && is_scalar( $node['id'] ) && '' !== (string) $node['id'] ) { 837 $anchor = [ 'id' => (string) $node['id'], 'rest' => $i + 1 ]; 838 } 839 } 840 if ( null === $anchor ) { 841 return [ 'path' => $path ]; 842 } 843 $rest = array_slice( $segs, $anchor['rest'] ); 844 return [ 845 'id' => $anchor['id'], 846 'rest' => [] === $rest ? '' : '/' . implode( '/', array_map( fn( $seg ) => self::escape_pointer_segment( (string) $seg ), $rest ) ), 847 'path' => $path, 848 ]; 663 849 } 664 850 … … 2197 2383 } 2198 2384 2199 /** The same value, read the same way get_elementor_widget would return it? */ 2385 /** 2386 * The same data? Strings byte for byte, objects whatever the order of their 2387 * keys (1.21.1: Elementor's save writes an element's keys in its own order, so 2388 * a real write read back as different where the dry run did not), lists in order. 2389 */ 2200 2390 private static function same_stored_value( mixed $a, mixed $b ): bool { 2201 return $a === $b || ( ! is_string( $a ) && ! is_string( $b ) && wp_json_encode( $a ) === wp_json_encode( $b ) ); 2391 if ( $a === $b ) { 2392 return true; 2393 } 2394 if ( is_string( $a ) || is_string( $b ) ) { 2395 return false; 2396 } 2397 return wp_json_encode( self::canonical( $a ) ) === wp_json_encode( self::canonical( $b ) ); 2398 } 2399 2400 /** Objects with their keys sorted, recursively; lists keep their order. */ 2401 private static function canonical( mixed $value ): mixed { 2402 if ( ! is_array( $value ) ) { 2403 return $value; 2404 } 2405 $out = []; 2406 foreach ( $value as $key => $item ) { 2407 $out[ $key ] = self::canonical( $item ); 2408 } 2409 if ( ! self::is_list( $out ) ) { 2410 ksort( $out, SORT_STRING ); 2411 } 2412 return $out; 2413 } 2414 2415 /** At most this many paths in `added_paths` and in `removed_paths`. */ 2416 private const ADDED_PATHS_MAX = 20; 2417 2418 /** 2419 * Why a stored value differs from the one sent, as `stored_difference`: 2420 * - `markup_normalized`: a string holds the same HTML written differently 2421 * (quotes, character references, attribute order or case). Needs WordPress 2422 * 6.5; below it such a string is `changed`. 2423 * - `keys_added`: the save added keys, listed in `added_paths`. 2424 * - `keys_removed`: the save dropped keys that were sent, listed in 2425 * `removed_paths` (Elementor's save drops `isInner` from a widget). 2426 * - `changed`: a value differs otherwise; sanitized_report names what the 2427 * filters removed. 2428 * 2429 * @param array $kinds Set of the kinds found, by reference. 2430 * @param array $added Paths of added keys, by reference. 2431 * @param array $removed Paths of dropped keys, by reference. 2432 */ 2433 private static function stored_difference( mixed $sent, mixed $stored, string $path, array &$kinds, array &$added, array &$removed ): void { 2434 if ( self::same_stored_value( $sent, $stored ) ) { 2435 return; 2436 } 2437 if ( is_string( $sent ) && is_string( $stored ) ) { 2438 $kinds[ true === IATO_MCP_Sanitize_Report::same_markup( $sent, $stored ) ? 'markup_normalized' : 'changed' ] = true; 2439 return; 2440 } 2441 if ( ! is_array( $sent ) || ! is_array( $stored ) ) { 2442 $kinds['changed'] = true; 2443 return; 2444 } 2445 $lists = self::is_list( $sent ) && self::is_list( $stored ) && [] !== $sent && [] !== $stored; 2446 if ( $lists && count( $sent ) !== count( $stored ) ) { 2447 $kinds['changed'] = true; 2448 return; 2449 } 2450 foreach ( $sent as $key => $item ) { 2451 $at = $path . '/' . self::escape_pointer_segment( (string) $key ); 2452 if ( ! array_key_exists( $key, $stored ) ) { 2453 $kinds[ $lists ? 'changed' : 'keys_removed' ] = true; 2454 $removed[] = $at; 2455 continue; 2456 } 2457 self::stored_difference( $item, $stored[ $key ], $at, $kinds, $added, $removed ); 2458 } 2459 foreach ( array_diff_key( $stored, $sent ) as $key => $unused ) { 2460 $kinds[ $lists ? 'changed' : 'keys_added' ] = true; 2461 $added[] = $path . '/' . self::escape_pointer_segment( (string) $key ); 2462 } 2463 } 2464 2465 /** Every element's pointer, by id: [id => [pointer, ...]]. */ 2466 private static function element_paths( array $nodes, string $prefix, array &$out ): void { 2467 foreach ( $nodes as $key => $node ) { 2468 if ( ! is_array( $node ) ) { 2469 continue; 2470 } 2471 $pointer = $prefix . '/' . self::escape_pointer_segment( (string) $key ); 2472 if ( isset( $node['id'], $node['elType'] ) && is_scalar( $node['id'] ) ) { 2473 $out[ (string) $node['id'] ][] = $pointer; 2474 } 2475 if ( isset( $node['elements'] ) && is_array( $node['elements'] ) ) { 2476 self::element_paths( $node['elements'], $pointer . '/elements', $out ); 2477 } 2478 } 2479 } 2480 2481 /** 2482 * Where an op's value is stored: through its element's id when apply_rfc6902() 2483 * noted one (exactly one element with that id), else at its path. 2484 * 2485 * @return string|null The pointer, or null when it cannot be found. 2486 */ 2487 private static function stored_pointer( array $tree, array $op, ?array $at, ?array &$ids ): ?string { 2488 if ( is_array( $at ) && isset( $at['id'] ) ) { 2489 if ( null === $ids ) { 2490 $ids = []; 2491 self::element_paths( $tree, '', $ids ); 2492 } 2493 $paths = $ids[ $at['id'] ] ?? []; 2494 return 1 === count( $paths ) ? $paths[0] . (string) ( $at['rest'] ?? '' ) : null; 2495 } 2496 return (string) ( $at['path'] ?? $op['path'] ?? '' ); 2202 2497 } 2203 2498 … … 2205 2500 * Say for each op that set a value whether the tree as stored holds what was 2206 2501 * sent (`stored_matches_input`), and when it does not, what it holds instead: 2207 * `stored_bytes`, `stored_sha256` and `stored_value`. The sanitising pass, or 2502 * `stored_bytes`, `stored_sha256` and `stored_value`, and why it differs 2503 * (`stored_difference`, see stored_difference()). The sanitising pass, or 2208 2504 * Elementor's own save, can change a value on the way in, and `value` is what 2209 2505 * the client sent. An op whose target cannot be read back is left as it is. 2506 * Removes apply_rfc6902()'s `_stored_at` from every op. 2210 2507 * 2211 2508 * @param array $applied The ops, each with a JSON pointer `path` into the tree. … … 2213 2510 */ 2214 2511 public static function annotate_stored( array $applied, ?array $tree ): array { 2215 if ( null === $tree ) { 2216 return $applied; 2217 } 2512 $ids = null; 2218 2513 foreach ( $applied as $i => $op ) { 2219 if ( ! is_array( $op ) || ! array_key_exists( 'value', $op ) || ! in_array( $op['op'] ?? '', [ 'add', 'replace' ], true )) {2514 if ( ! is_array( $op ) ) { 2220 2515 continue; 2221 2516 } 2222 $stored = self::pointer_get( $tree, (string) ( $op['path'] ?? '' ) ); 2223 if ( is_wp_error( $stored ) ) { 2517 $at = isset( $op['_stored_at'] ) && is_array( $op['_stored_at'] ) ? $op['_stored_at'] : null; 2518 unset( $applied[ $i ]['_stored_at'] ); 2519 if ( null === $tree || ! array_key_exists( 'value', $op ) || ! in_array( $op['op'] ?? '', [ 'add', 'replace' ], true ) ) { 2520 continue; 2521 } 2522 $pointer = self::stored_pointer( $tree, $op, $at, $ids ); 2523 $stored = null === $pointer ? null : self::pointer_get( $tree, $pointer ); 2524 if ( null === $pointer || is_wp_error( $stored ) ) { 2224 2525 continue; 2225 2526 } … … 2230 2531 $applied[ $i ]['stored_bytes'] = $digest['bytes']; 2231 2532 $applied[ $i ]['stored_sha256'] = $digest['sha256']; 2533 $applied[ $i ] = self::with_difference( $applied[ $i ], $op['value'], $stored, $pointer ); 2232 2534 $applied[ $i ]['stored_value'] = $stored; 2233 2535 } 2234 2536 } 2235 2537 return $applied; 2538 } 2539 2540 /** Add `stored_difference` (and `added_paths`) to an op whose stored value differs. */ 2541 private static function with_difference( array $op, mixed $sent, mixed $stored, string $path ): array { 2542 $kinds = []; 2543 $added = []; 2544 $removed = []; 2545 self::stored_difference( $sent, $stored, $path, $kinds, $added, $removed ); 2546 if ( [] === $kinds ) { 2547 $kinds['changed'] = true; // differs, and no part of it says how 2548 } 2549 $op['stored_difference'] = array_values( array_intersect( [ 'changed', 'keys_added', 'keys_removed', 'markup_normalized' ], array_keys( $kinds ) ) ); 2550 if ( isset( $kinds['keys_added'] ) ) { 2551 $op['added_paths'] = array_slice( $added, 0, self::ADDED_PATHS_MAX ); 2552 } 2553 if ( isset( $kinds['keys_removed'] ) ) { 2554 $op['removed_paths'] = array_slice( $removed, 0, self::ADDED_PATHS_MAX ); 2555 } 2556 return $op; 2236 2557 } 2237 2558 … … 2256 2577 $applied[ $i ]['stored_bytes'] = $digest['bytes']; 2257 2578 $applied[ $i ]['stored_sha256'] = $digest['sha256']; 2579 // No stored_difference: the difference here is Elementor's typed envelope. 2258 2580 } 2259 2581 } -
iato-mcp/tags/1.21.1/includes/class-page-fonts.php
r3797770 r3798383 31 31 private static $google_on = null; 32 32 33 /** @var callable|null Test seam: fn(): bool, Elementor's "Load Google Fonts Locally". */ 34 private static $local_setting = null; 35 36 /** @var callable|null Test seam: fn(): array, the local copies Elementor holds (_elementor_local_google_fonts). */ 37 private static $local_copies = null; 38 39 /** Elementor's font types that Google_Font serves, from Google or from a local copy. */ 40 private const GOOGLE_TYPES = [ 'googlefonts', 'earlyaccess' ]; 41 33 42 public static function init(): void { 34 43 // wp_enqueue_scripts runs inside wp_head at priority 1; Elementor prints the … … 64 73 } 65 74 66 public static function local_google_fonts(): bool { 75 /** Replace where Google fonts come from, for unit tests; nulls restore it. */ 76 public static function set_font_sources( ?callable $local_setting, ?callable $local_copies ): void { 77 self::$local_setting = $local_setting; 78 self::$local_copies = $local_copies; 79 } 80 81 /** Elementor's "Load Google Fonts Locally" setting, read as Google_Font::enqueue() reads it. */ 82 public static function local_google_fonts_setting(): bool { 83 if ( null !== self::$local_setting ) { 84 return (bool) ( self::$local_setting )(); 85 } 67 86 return (bool) get_option( 'elementor_local_google_fonts', '0' ); 87 } 88 89 /** 90 * Where Elementor serves a family from (1.21.1), or null for one that is not a 91 * Google font. Google_Font::enqueue() first serves a local copy it already holds, 92 * whatever the setting says (turning the setting off does not delete them), and 93 * only then reads the setting: on, it downloads a copy on the first page view 94 * and uses Google's when that fails; off, Google's. The same in Elementor 4.2.2 95 * and 4.3.4. Until 1.21.1 the plugin read only the setting. 96 * 97 * @return string|null 'local', 'local_after_first_view' or 'google'. 98 */ 99 public static function served_from( string $family, ?string $type ): ?string { 100 if ( ! in_array( $type, self::GOOGLE_TYPES, true ) ) { 101 return null; 102 } 103 $copies = null !== self::$local_copies ? ( self::$local_copies )() : get_option( '_elementor_local_google_fonts', [] ); 104 $copy = is_array( $copies ) ? ( $copies[ sanitize_key( $family ) ] ?? null ) : null; 105 if ( is_array( $copy ) && ! empty( $copy['url'] ) ) { 106 return 'local'; 107 } 108 return self::local_google_fonts_setting() ? 'local_after_first_view' : 'google'; 109 } 110 111 /** 112 * The fields get_page_fonts and set_page_fonts report about where fonts come from: 113 * `served_from` for each Google family, `google_fonts_local` (true when every 114 * Google family on the page is served from this site; with none, the setting, as 115 * before 1.21.1) and `local_google_fonts_setting`. 116 * 117 * @param array<string,string|null> $types Family => Elementor's type. 118 */ 119 public static function source_report( array $types ): array { 120 $served = []; 121 foreach ( $types as $family => $type ) { 122 $from = self::served_from( (string) $family, $type ); 123 if ( null !== $from ) { 124 $served[ (string) $family ] = $from; 125 } 126 } 127 $setting = self::local_google_fonts_setting(); 128 return [ 129 'served_from' => (object) $served, 130 'google_fonts_local' => [] === $served ? $setting : ! in_array( 'google', $served, true ), 131 'local_google_fonts_setting' => $setting, 132 ]; 68 133 } 69 134 -
iato-mcp/tags/1.21.1/includes/class-sanitize-report.php
r3797770 r3798383 7 7 * the output are compared: each is reduced to an inventory of tags and 8 8 * attributes, and whatever the output has fewer of was removed. Pure PHP, no 9 * WordPress calls. 9 * WordPress calls, except same_markup(), which uses WordPress's tokenizer when 10 * there is one. 10 11 * 11 12 * @package IATO_MCP … … 28 29 'stripped_unsafe' => 'Inline SVG and style blocks were removed: the markup did not pass the safety check.', 29 30 ]; 31 32 /** HTML's void elements: a self-closing slash on one changes nothing. */ 33 private const VOID = [ 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' ]; 34 35 /** 36 * The same HTML written differently? Same tags in the same order, the same 37 * attributes with the same decoded values, the same decoded text; quotes, 38 * character references, attribute order and case may differ (kses rewrites 39 * single-quoted attributes with double quotes). Null when it cannot tell: 40 * WordPress below 6.5 (no WP_HTML_Tag_Processor::next_token()), or markup 41 * the tokenizer stops in. 1.21.1, for `stored_difference`. 42 */ 43 public static function same_markup( string $a, string $b ): ?bool { 44 $left = self::markup_tokens( $a ); 45 if ( null === $left ) { 46 return null; 47 } 48 $right = self::markup_tokens( $b ); 49 return null === $right ? null : $left === $right; 50 } 51 52 /** @return array|null Each token as data, or null (see same_markup()). */ 53 private static function markup_tokens( string $html ): ?array { 54 if ( ! class_exists( 'WP_HTML_Tag_Processor' ) || ! method_exists( 'WP_HTML_Tag_Processor', 'next_token' ) ) { 55 return null; 56 } 57 $processor = new WP_HTML_Tag_Processor( $html ); 58 $tokens = []; 59 while ( $processor->next_token() ) { 60 $type = $processor->get_token_type(); 61 if ( '#tag' !== $type ) { 62 $tokens[] = [ $type, $processor->get_modifiable_text() ]; 63 continue; 64 } 65 $tag = (string) $processor->get_tag(); 66 $attributes = []; 67 foreach ( (array) $processor->get_attribute_names_with_prefix( '' ) as $name ) { 68 $attributes[ $name ] = $processor->get_attribute( $name ); 69 } 70 ksort( $attributes, SORT_STRING ); 71 $self_closing = in_array( $tag, self::VOID, true ) ? false : $processor->has_self_closing_flag(); 72 $tokens[] = [ '#tag', $tag, $processor->is_tag_closer(), $attributes, $self_closing ]; 73 } 74 if ( method_exists( $processor, 'paused_at_incomplete_token' ) && $processor->paused_at_incomplete_token() ) { 75 return null; 76 } 77 return $tokens; 78 } 30 79 31 80 /** … … 240 289 $notes[] = self::POLICY_NOTES[ $policy ]; 241 290 } 291 // 1.21.1: a value kses rewrote without removing anything (single quotes become 292 // double quotes) was reported with nothing in it. 293 if ( 'html' === $rule && '' === $policy && [] === $removed['removed_tags'] && [] === $removed['removed_attributes'] && empty( $entry['removed_css'] ) ) { 294 $notes[] = true === self::same_markup( $before, $after ) 295 ? 'Nothing was removed: WordPress wrote the same HTML differently (attribute quotes, character references, attribute order or case).' 296 : 'No tag or attribute was removed: WordPress rewrote part of the value, such as an attribute value or character references.'; 297 } 242 298 // 1.21.0: where a page's CSS can go instead. set_page_css ships switched off, so 243 299 // a client cannot see it in tools/list until an administrator turns it on. -
iato-mcp/tags/1.21.1/includes/class-settings.php
r3797770 r3798383 390 390 // $bridge_gated reflects the key as it was when the form was rendered, 391 391 // i.e. before this request saved it. 392 $tools = iato_mcp_merge_gated_tools( 392 $tools_before = array_values( array_filter( self::TOOL_NAMES, [ self::class, 'is_tool_enabled' ] ) ); 393 $tools = iato_mcp_merge_gated_tools( 393 394 self::sanitize_tools( $tools_raw, false ), 394 395 get_option( 'iato_mcp_tools', [] ), … … 396 397 ); 397 398 update_option( 'iato_mcp_tools', $tools ); 399 // 1.21.1: clients read the tool list when they connect, and this server has 400 // no stream on which to tell them it changed (notifications/tools/list_changed). 401 $tools_after = array_values( array_filter( self::TOOL_NAMES, [ self::class, 'is_tool_enabled' ] ) ); 402 $tools_changed = $tools_before !== $tools_after; 398 403 399 404 // WordPress Abilities exposure toggle (hidden value=0 sibling pattern, … … 452 457 453 458 wp_send_json_success( [ 454 'message' => __( 'Settings saved.', 'iato-mcp' ), 459 'message' => $tools_changed 460 ? __( 'Settings saved. AI clients read the tool list when they connect: reconnect the connector in your AI client to see the tools you turned on or off.', 'iato-mcp' ) 461 : __( 'Settings saved.', 'iato-mcp' ), 455 462 'values' => [ 456 463 'api_key_length' => strlen( $api_key ), 457 464 'crawl_id' => $crawl_id, 458 465 'tools_enabled' => count( $tools ), 466 'tools_changed' => $tools_changed, 459 467 'abilities_enabled' => $abilities_enabled, 460 468 'cache_purge_enabled' => $cache_purge_enabled, … … 1072 1080 </span> 1073 1081 </div> 1074 <p class="iato-card-desc"><?php esc_html_e( 'Choose which MCP tools are available to AI clients. ', 'iato-mcp' ); ?></p>1082 <p class="iato-card-desc"><?php esc_html_e( 'Choose which MCP tools are available to AI clients. AI clients read this list when they connect: after a change, reconnect the connector in your AI client to see it.', 'iato-mcp' ); ?></p> 1075 1083 1076 1084 <?php foreach ( self::TOOL_CATEGORIES as $category => $tools ) : -
iato-mcp/tags/1.21.1/includes/tools/wp/tool-elementor-widgets.php
r3797770 r3798383 216 216 'update_elementor_patch', 217 217 [ 218 'description' => 'Apply an RFC 6902 JSON Patch to the entire Elementor document. Use this for surgical array entry edits (repeater rows, indexed inserts) where update_elementor_widget\'s replace-only array semantics are too coarse. Each op may include op, path, value, from. Supports dry_run, if_revision, idempotency_key. Requires edit_posts. Plain values set on Elementor Atomic Editor (V4) elements (Elementor 4.3.0 or later) are converted and validated with Elementor\'s own code; elsewhere atomic elements accept only $$type envelope values.',218 'description' => 'Apply an RFC 6902 JSON Patch to the entire Elementor document. Use this for surgical array entry edits (repeater rows, indexed inserts) where update_elementor_widget\'s replace-only array semantics are too coarse. Each op may include op, path, value, from. add at a list index inserts before the item there (RFC 6902); replace overwrites it; /- appends. Each op on a list item returns list (count_before, count_after, ids_after) so a dry run shows where it lands. Supports dry_run, if_revision, idempotency_key. Requires edit_posts. Plain values set on Elementor Atomic Editor (V4) elements (Elementor 4.3.0 or later) are converted and validated with Elementor\'s own code; elsewhere atomic elements accept only $$type envelope values.', 219 219 'inputSchema' => [ 220 220 'type' => 'object', 221 221 'properties' => [ 222 222 'id' => [ 'type' => 'integer', 'description' => 'WordPress post/page ID (required).' ], 223 'ops' => [ 'type' => 'array', 'description' => 'RFC 6902 op array (required). Supports add, remove, replace, move, copy, test. ' ],223 'ops' => [ 'type' => 'array', 'description' => 'RFC 6902 op array (required). Supports add, remove, replace, move, copy, test. add, copy and move insert at a list index; an index past the end is refused.' ], 224 224 'dry_run' => [ 'type' => 'boolean', 'description' => 'Preview applied_patch without writing (default: false).' ], 225 225 'if_revision' => [ 'type' => 'string', 'description' => 'Optional revision guard.' ], -
iato-mcp/tags/1.21.1/includes/tools/wp/tool-page-assets.php
r3797770 r3798383 115 115 'get_page_fonts', 116 116 [ 117 'description' => 'Returns the font families set_page_fonts loads on one post, each with Elementor\'s type for it, and whether Elementor loads Google fonts from this site. Requires edit_post on the post.',117 'description' => 'Returns the font families set_page_fonts loads on one post, each with Elementor\'s type for it, and for each Google font where Elementor serves it from (served_from: local, local_after_first_view or google). Requires edit_post on the post.', 118 118 'inputSchema' => [ 119 119 'type' => 'object', … … 134 134 $types[ $font ] = IATO_MCP_Page_Fonts::type_of( $font ); 135 135 } 136 return IATO_MCP_Server::ok( [ 'id' => $post->ID, 'fonts' => $fonts, 'types' => (object) $types , 'google_fonts_local' => IATO_MCP_Page_Fonts::local_google_fonts() ]);136 return IATO_MCP_Server::ok( [ 'id' => $post->ID, 'fonts' => $fonts, 'types' => (object) $types ] + IATO_MCP_Page_Fonts::source_report( $types ) ); 137 137 } 138 138 ); … … 141 141 'set_page_fonts', 142 142 [ 143 'description' => 'Loads font families on one post or page only, through Elementor\'s own font loader, so a page\'s CSS can use a font the site does not load elsewhere. Each family must be one Elementor knows (its Google fonts, system fonts, or Elementor Pro custom and Adobe fonts), spelled as Elementor lists it: unknown_font otherwise. With Elementor\'s "Load Google Fonts Locally" on, visitors fetch them from this site, not Google. Elementor loads every weight of a family, so there is no weights option. fonts replaces the list (at most 10; [] removes it). This plugin loads them, so the page loses them while the plugin is deactivated. Accepts dry_run. Returns the list with each family\'s type, a change_receipt and a cache object (that page is purged). Requires Elementor and edit_post on the post.',143 'description' => 'Loads font families on one post or page only, through Elementor\'s own font loader, so a page\'s CSS can use a font the site does not load elsewhere. Each family must be one Elementor knows (its Google fonts, system fonts, or Elementor Pro custom and Adobe fonts), spelled as Elementor lists it: unknown_font otherwise. Elementor serves a Google font from this site when it already holds a copy, or when its "Load Google Fonts Locally" is on; served_from says which for each. Elementor loads every weight of a family, so there is no weights option. fonts replaces the list (at most 10; [] removes it). This plugin loads them, so the page loses them while the plugin is deactivated. Accepts dry_run. Returns the list with each family\'s type and served_from, a change_receipt and a cache object (that page is purged). Requires Elementor and edit_post on the post.', 144 144 'inputSchema' => [ 145 145 'type' => 'object', … … 165 165 } 166 166 $stored = IATO_MCP_Page_Fonts::stored( $post->ID ); 167 $data = [ 'id' => $post->ID, 'fonts' => $plan['fonts'], 'types' => (object) $plan['types'] , 'google_fonts_local' => IATO_MCP_Page_Fonts::local_google_fonts() ];167 $data = [ 'id' => $post->ID, 'fonts' => $plan['fonts'], 'types' => (object) $plan['types'] ] + IATO_MCP_Page_Fonts::source_report( $plan['types'] ); 168 168 $notes = []; 169 169 foreach ( $plan['types'] as $font => $type ) { -
iato-mcp/tags/1.21.1/readme.txt
r3797770 r3798383 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.21. 07 Stable tag: 1.21.1 8 8 License: GPL-2.0-or-later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 186 186 == Changelog == 187 187 188 = 1.21.1 = 189 * Fix: `update_elementor_patch`: `add` at a list index now inserts before the item there, as RFC 6902 says and the tool's description promised. Since 1.3.0 it replaced the item, so a widget added at an index deleted the widget that stood there. `copy` to an index overwrote the item there and `move` to an index deleted one; both now insert. An index past the end of a list is refused instead of leaving a gap, and an element cannot be moved into its own children. Use `replace` to overwrite. A page that lost an element this way can be put back by rolling back that write's receipt within 90 days, or from Elementor's revisions. 190 * Fix: `update_elementor_patch`: `remove` of an item in the middle of a list left a gap in its numbering, so a later op in the same patch could not find the next item, and the list could be stored as a JSON object. 191 * New: each `update_elementor_patch` op on a list item returns `list` (where it acted, the list's length before and after, and its items' ids afterwards), on dry runs too, so a misplaced insert shows before it is written. 192 * Fix: `stored_matches_input` no longer reads a different key order as a different value. Elementor's save writes an element's keys in its own order, so a real write said false where its dry run said true. 193 * Fix: an `add` to `/-`, and an op whose position a later op in the same patch shifted, now get `stored_matches_input`. They used to get nothing, or be compared with whatever stood at the old position. 194 * New: when the stored value differs, `stored_difference` says how: `markup_normalized` (the same HTML written differently, such as single quotes rewritten as double quotes), `keys_added` or `keys_removed` (with `added_paths` and `removed_paths`; Elementor's save drops `isInner` from a widget), or `changed`. A `sanitized_report` entry for a value WordPress rewrote without removing anything now says so; it used to be empty. 195 * Fix: `get_page_fonts` and `set_page_fonts` report where each Google font is served from (`served_from`: `local`, `local_after_first_view` or `google`). Elementor serves a local copy it already holds even with "Load Google Fonts Locally" off, so `google_fonts_local` was false on sites that served fonts locally. It now means every Google font on the page comes from this site; `local_google_fonts_setting` gives the setting itself. 196 * New: Settings > IATO MCP > Tools says that AI clients read the tool list when they connect, and saving a change to the tools says to reconnect the connector. This server cannot tell a connected client that the list changed. 197 188 198 = 1.21.0 = 189 199 * New: `set_page_css` and `get_page_css` give one page its own CSS, printed in that page's head only. They follow the same rules as Additional CSS: nothing loaded from another site, at most 200 KB, and anything that would close the style element removed. The CSS is checked again when it is printed. Needs a WordPress user who may edit CSS (administrators and editors) and `edit_post` on the page. … … 577 587 == Upgrade Notice == 578 588 589 = 1.21.1 = 590 Fixes update_elementor_patch: add at an index now inserts, as RFC 6902 says, instead of replacing (deleting) the element there. Use replace to overwrite. Also clearer stored-value and font-source reports. 591 579 592 = 1.21.0 = 580 593 Adds page CSS, fonts, approved page scripts and draft preview links (all switched off until you turn them on), keeps ARIA, form attributes and more SVG in Elementor HTML, and says what Elementor stored. -
iato-mcp/trunk/iato-mcp.php
r3797770 r3798383 4 4 * Plugin URI: https://iato.ai/wordpress-mcp 5 5 * Description: Exposes an MCP server from any self-hosted WordPress install, enabling IATO analyze-and-fix workflows via Claude Desktop and other AI clients. 6 * Version: 1.21. 06 * Version: 1.21.1 7 7 * Author: IATO 8 8 * Author URI: https://iato.ai … … 18 18 defined( 'ABSPATH' ) || exit; 19 19 20 define( 'IATO_MCP_VERSION', '1.21. 0' );20 define( 'IATO_MCP_VERSION', '1.21.1' ); 21 21 define( 'IATO_MCP_FILE', __FILE__ ); 22 22 define( 'IATO_MCP_DIR', plugin_dir_path( __FILE__ ) ); -
iato-mcp/trunk/includes/class-elementor-adapter.php
r3797770 r3798383 423 423 * Implements: add, remove, replace, move, copy, test. 424 424 * Strict path semantics: replace/remove/move/copy fail on missing paths. 425 * add, copy and move insert into a list at an index, as RFC 6902 says (1.21.1: 426 * they replaced the element there until then), and each op on a list item 427 * reports the list as it is afterwards (`list`, `from_list`). 428 * 429 * Each op that sets a value also carries `_stored_at`, where annotate_stored() 430 * finds it in the stored tree: its element's id and the path inside it, so a 431 * later op that shifts the list does not send it to another element. 432 * annotate_stored() removes the key. 425 433 * 426 434 * @return array|WP_Error Applied ops on success. … … 436 444 return $result; 437 445 } 446 if ( in_array( $result['op'], [ 'add', 'replace' ], true ) ) { 447 $result['_stored_at'] = self::stored_at( $elements, $result['_resolved_path'] ?? $result['path'] ); 448 } 449 unset( $result['_resolved_path'] ); 438 450 $applied[] = $result; 439 451 } … … 449 461 return new WP_Error( 'invalid_patch', "add op missing 'value' at {$path}" ); 450 462 } 451 $ err = self::pointer_set( $elements, $path, $op['value'], true);452 if ( is_wp_error( $ err) ) {453 return $ err;454 } 455 return [ 'op' => 'add', 'path' => $path, 'value' => $op['value'] ];463 $added = self::pointer_add( $elements, $path, $op['value'] ); 464 if ( is_wp_error( $added ) ) { 465 return $added; 466 } 467 return self::with_list( [ 'op' => 'add', 'path' => $path, 'value' => $op['value'] ], $elements, $path, $added ); 456 468 457 469 case 'replace': … … 479 491 return $prev; 480 492 } 481 $err = self::pointer_remove( $elements, $path ); 493 $removed = self::list_position( $elements, $path ); 494 $err = self::pointer_remove( $elements, $path ); 482 495 if ( is_wp_error( $err ) ) { 483 496 return $err; 484 497 } 485 return [ 'op' => 'remove', 'path' => $path, 'previous_value' => $prev ]; 498 $out = [ 'op' => 'remove', 'path' => $path, 'previous_value' => $prev ]; 499 if ( null !== $removed ) { 500 $out['list'] = self::list_report( $elements, self::parent_pointer( $path ), $removed['index'], $removed['count_before'] ); 501 } 502 return $out; 486 503 487 504 case 'move': … … 490 507 return new WP_Error( 'invalid_patch', "move op missing 'from' at {$path}" ); 491 508 } 509 // RFC 6902 4.4: an element cannot move into its own children. 510 if ( str_starts_with( $path, $from . '/' ) ) { 511 return new WP_Error( 'invalid_patch', "move op cannot move {$from} into itself ({$path})." ); 512 } 492 513 $value = self::pointer_get( $elements, $from ); 493 514 if ( is_wp_error( $value ) ) { 494 515 return $value; 495 516 } 496 $err = self::pointer_remove( $elements, $from ); 517 $taken = self::list_position( $elements, $from ); 518 $err = self::pointer_remove( $elements, $from ); 497 519 if ( is_wp_error( $err ) ) { 498 520 return $err; 499 521 } 500 $err = self::pointer_set( $elements, $path, $value, true ); 501 if ( is_wp_error( $err ) ) { 502 return $err; 503 } 504 return [ 'op' => 'move', 'from' => $from, 'path' => $path, 'value' => $value ]; 522 $added = self::pointer_add( $elements, $path, $value ); 523 if ( is_wp_error( $added ) ) { 524 return $added; 525 } 526 $out = [ 'op' => 'move', 'from' => $from, 'path' => $path, 'value' => $value ]; 527 $same_parent = self::parent_pointer( $from ) === self::parent_pointer( $path ); 528 if ( $same_parent && null !== $taken && null !== $added['index'] ) { 529 // One list: its length before the move, not between the two halves of it. 530 $added['count_before'] = $taken['count_before']; 531 } elseif ( null !== $taken ) { 532 $out['from_list'] = self::list_report( $elements, self::parent_pointer( $from ), $taken['index'], $taken['count_before'] ); 533 } 534 return self::with_list( $out, $elements, $path, $added ); 505 535 506 536 case 'copy': … … 513 543 return $value; 514 544 } 515 $ err = self::pointer_set( $elements, $path, $value, true );516 if ( is_wp_error( $ err) ) {517 return $ err;518 } 519 return [ 'op' => 'copy', 'from' => $from, 'path' => $path, 'value' => $value ];545 $added = self::pointer_add( $elements, $path, $value ); 546 if ( is_wp_error( $added ) ) { 547 return $added; 548 } 549 return self::with_list( [ 'op' => 'copy', 'from' => $from, 'path' => $path, 'value' => $value ], $elements, $path, $added ); 520 550 521 551 case 'test': … … 655 685 return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" ); 656 686 } 687 // Asked before the unset (1.21.1): afterwards a list missing its middle item 688 // has keys 0 and 2, was never re-indexed, and was stored as a JSON object. 689 $was_list = self::is_list( $node ); 657 690 unset( $node[ $key ] ); 658 // Re-index numeric arrays so subsequent path resolution doesn't break.659 if ( array_keys( $node ) === range( 0, count( $node ) - 1 )) {691 // Re-index lists so subsequent path resolution doesn't break. 692 if ( $was_list ) { 660 693 $node = array_values( $node ); 661 694 } 662 695 return null; 696 } 697 698 /** 699 * RFC 6902 add: insert into a list at an index (`-` or the list's length 700 * appends), set a member of an object. Mutates by reference. 701 * 702 * A list is an array keyed 0..n-1, or empty. Anything else is an object, 703 * including a stored page whose elements have gaps in their numbering, which 704 * keeps the behaviour it had: the key is set, and `-` appends. 705 * 706 * @return array|WP_Error `index` and `count_before` for a list insert, both null otherwise. 707 */ 708 public static function pointer_add( array &$root, string $path, mixed $value ): array|WP_Error { 709 if ( '' === $path ) { 710 return new WP_Error( 'invalid_patch_path', 'Cannot replace root document.' ); 711 } 712 $segs = self::pointer_segments( $path ); 713 $last = array_pop( $segs ); 714 $node = &$root; 715 foreach ( $segs as $seg ) { 716 if ( ! is_array( $node ) ) { 717 return new WP_Error( 'invalid_patch_path', "Cannot traverse non-array at {$path}" ); 718 } 719 if ( array_key_exists( $seg, $node ) ) { 720 $node = &$node[ $seg ]; 721 continue; 722 } 723 if ( ctype_digit( $seg ) && array_key_exists( (int) $seg, $node ) ) { 724 $node = &$node[ (int) $seg ]; 725 continue; 726 } 727 return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" ); 728 } 729 if ( ! is_array( $node ) ) { 730 return new WP_Error( 'invalid_patch_path', "Cannot set on non-array at {$path}" ); 731 } 732 if ( self::is_list( $node ) && ( '-' === $last || ctype_digit( $last ) ) ) { 733 $count = count( $node ); 734 if ( '-' === $last ) { 735 $index = $count; 736 } else { 737 if ( strlen( $last ) > 1 && '0' === $last[0] ) { 738 return new WP_Error( 'invalid_patch_path', "Index {$last} has a leading zero at {$path}." ); 739 } 740 $index = (int) $last; 741 if ( $index > $count ) { 742 return new WP_Error( 'invalid_patch_path', "Index {$index} is past the end of a list of {$count} at {$path}. Use {$count} or - to append." ); 743 } 744 } 745 array_splice( $node, $index, 0, [ $value ] ); 746 return [ 'index' => $index, 'count_before' => $count ]; 747 } 748 if ( '-' === $last ) { 749 $node[] = $value; 750 } else { 751 $node[ ctype_digit( $last ) ? (int) $last : $last ] = $value; 752 } 753 return [ 'index' => null, 'count_before' => null ]; 754 } 755 756 /** Keyed 0..n-1, or empty: a JSON array. (array_is_list() needs PHP 8.1.) */ 757 private static function is_list( array $value ): bool { 758 return [] === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ); 759 } 760 761 /** The pointer of the array that holds $path's target ('' for the root). */ 762 private static function parent_pointer( string $path ): string { 763 $at = strrpos( $path, '/' ); 764 return false === $at ? '' : substr( $path, 0, $at ); 765 } 766 767 /** Where $path's target sits in its list, or null when its parent is not a list. */ 768 private static function list_position( array $root, string $path ): ?array { 769 $parent = self::pointer_get( $root, self::parent_pointer( $path ) ); 770 $last = (string) substr( (string) strrchr( $path, '/' ), 1 ); 771 if ( ! is_array( $parent ) || ! self::is_list( $parent ) || ! ctype_digit( $last ) ) { 772 return null; 773 } 774 return [ 'index' => (int) $last, 'count_before' => count( $parent ) ]; 775 } 776 777 /** At most this many ids in a `list` report. */ 778 private const LIST_IDS_MAX = 100; 779 780 /** 781 * The list at $parent after an op: where the op acted, its length before and 782 * after, and each item's `id` (elements) or `_id` (repeater rows), null for 783 * an item with neither. Shows a misplaced insert before it is written. 784 */ 785 private static function list_report( array $root, string $parent, int $index, int $count_before ): array { 786 $out = [ 'path' => $parent, 'index' => $index, 'count_before' => $count_before ]; 787 $list = self::pointer_get( $root, $parent ); 788 if ( ! is_array( $list ) ) { 789 return $out; 790 } 791 $items = array_values( $list ); 792 $out['count_after'] = count( $items ); 793 $ids = []; 794 foreach ( array_slice( $items, 0, self::LIST_IDS_MAX ) as $item ) { 795 $id = is_array( $item ) ? ( $item['id'] ?? $item['_id'] ?? null ) : null; 796 $ids[] = is_scalar( $id ) ? (string) $id : null; 797 } 798 $out['ids_after'] = $ids; 799 if ( count( $items ) > self::LIST_IDS_MAX ) { 800 $out['ids_truncated'] = true; 801 } 802 return $out; 803 } 804 805 /** Add an insert's `list` report to its op, and the index `-` stood for. */ 806 private static function with_list( array $op, array $root, string $path, array $added ): array { 807 if ( null === $added['index'] ) { 808 return $op; 809 } 810 $parent = self::parent_pointer( $path ); 811 $op['list'] = self::list_report( $root, $parent, $added['index'], $added['count_before'] ); 812 $op['_resolved_path'] = $parent . '/' . $added['index']; 813 return $op; 814 } 815 816 /** 817 * Where annotate_stored() finds an op's value: the deepest element (an object 818 * with `id` and `elType`) on its path, by id, and the path inside it. The whole 819 * path when no element lies on it. 820 */ 821 private static function stored_at( array $root, string $path ): array { 822 $segs = self::pointer_segments( $path ); 823 $node = $root; 824 $anchor = null; 825 foreach ( $segs as $i => $seg ) { 826 if ( ! is_array( $node ) ) { 827 break; 828 } 829 if ( array_key_exists( $seg, $node ) ) { 830 $node = $node[ $seg ]; 831 } elseif ( ctype_digit( $seg ) && array_key_exists( (int) $seg, $node ) ) { 832 $node = $node[ (int) $seg ]; 833 } else { 834 break; 835 } 836 if ( is_array( $node ) && isset( $node['elType'] ) && isset( $node['id'] ) && is_scalar( $node['id'] ) && '' !== (string) $node['id'] ) { 837 $anchor = [ 'id' => (string) $node['id'], 'rest' => $i + 1 ]; 838 } 839 } 840 if ( null === $anchor ) { 841 return [ 'path' => $path ]; 842 } 843 $rest = array_slice( $segs, $anchor['rest'] ); 844 return [ 845 'id' => $anchor['id'], 846 'rest' => [] === $rest ? '' : '/' . implode( '/', array_map( fn( $seg ) => self::escape_pointer_segment( (string) $seg ), $rest ) ), 847 'path' => $path, 848 ]; 663 849 } 664 850 … … 2197 2383 } 2198 2384 2199 /** The same value, read the same way get_elementor_widget would return it? */ 2385 /** 2386 * The same data? Strings byte for byte, objects whatever the order of their 2387 * keys (1.21.1: Elementor's save writes an element's keys in its own order, so 2388 * a real write read back as different where the dry run did not), lists in order. 2389 */ 2200 2390 private static function same_stored_value( mixed $a, mixed $b ): bool { 2201 return $a === $b || ( ! is_string( $a ) && ! is_string( $b ) && wp_json_encode( $a ) === wp_json_encode( $b ) ); 2391 if ( $a === $b ) { 2392 return true; 2393 } 2394 if ( is_string( $a ) || is_string( $b ) ) { 2395 return false; 2396 } 2397 return wp_json_encode( self::canonical( $a ) ) === wp_json_encode( self::canonical( $b ) ); 2398 } 2399 2400 /** Objects with their keys sorted, recursively; lists keep their order. */ 2401 private static function canonical( mixed $value ): mixed { 2402 if ( ! is_array( $value ) ) { 2403 return $value; 2404 } 2405 $out = []; 2406 foreach ( $value as $key => $item ) { 2407 $out[ $key ] = self::canonical( $item ); 2408 } 2409 if ( ! self::is_list( $out ) ) { 2410 ksort( $out, SORT_STRING ); 2411 } 2412 return $out; 2413 } 2414 2415 /** At most this many paths in `added_paths` and in `removed_paths`. */ 2416 private const ADDED_PATHS_MAX = 20; 2417 2418 /** 2419 * Why a stored value differs from the one sent, as `stored_difference`: 2420 * - `markup_normalized`: a string holds the same HTML written differently 2421 * (quotes, character references, attribute order or case). Needs WordPress 2422 * 6.5; below it such a string is `changed`. 2423 * - `keys_added`: the save added keys, listed in `added_paths`. 2424 * - `keys_removed`: the save dropped keys that were sent, listed in 2425 * `removed_paths` (Elementor's save drops `isInner` from a widget). 2426 * - `changed`: a value differs otherwise; sanitized_report names what the 2427 * filters removed. 2428 * 2429 * @param array $kinds Set of the kinds found, by reference. 2430 * @param array $added Paths of added keys, by reference. 2431 * @param array $removed Paths of dropped keys, by reference. 2432 */ 2433 private static function stored_difference( mixed $sent, mixed $stored, string $path, array &$kinds, array &$added, array &$removed ): void { 2434 if ( self::same_stored_value( $sent, $stored ) ) { 2435 return; 2436 } 2437 if ( is_string( $sent ) && is_string( $stored ) ) { 2438 $kinds[ true === IATO_MCP_Sanitize_Report::same_markup( $sent, $stored ) ? 'markup_normalized' : 'changed' ] = true; 2439 return; 2440 } 2441 if ( ! is_array( $sent ) || ! is_array( $stored ) ) { 2442 $kinds['changed'] = true; 2443 return; 2444 } 2445 $lists = self::is_list( $sent ) && self::is_list( $stored ) && [] !== $sent && [] !== $stored; 2446 if ( $lists && count( $sent ) !== count( $stored ) ) { 2447 $kinds['changed'] = true; 2448 return; 2449 } 2450 foreach ( $sent as $key => $item ) { 2451 $at = $path . '/' . self::escape_pointer_segment( (string) $key ); 2452 if ( ! array_key_exists( $key, $stored ) ) { 2453 $kinds[ $lists ? 'changed' : 'keys_removed' ] = true; 2454 $removed[] = $at; 2455 continue; 2456 } 2457 self::stored_difference( $item, $stored[ $key ], $at, $kinds, $added, $removed ); 2458 } 2459 foreach ( array_diff_key( $stored, $sent ) as $key => $unused ) { 2460 $kinds[ $lists ? 'changed' : 'keys_added' ] = true; 2461 $added[] = $path . '/' . self::escape_pointer_segment( (string) $key ); 2462 } 2463 } 2464 2465 /** Every element's pointer, by id: [id => [pointer, ...]]. */ 2466 private static function element_paths( array $nodes, string $prefix, array &$out ): void { 2467 foreach ( $nodes as $key => $node ) { 2468 if ( ! is_array( $node ) ) { 2469 continue; 2470 } 2471 $pointer = $prefix . '/' . self::escape_pointer_segment( (string) $key ); 2472 if ( isset( $node['id'], $node['elType'] ) && is_scalar( $node['id'] ) ) { 2473 $out[ (string) $node['id'] ][] = $pointer; 2474 } 2475 if ( isset( $node['elements'] ) && is_array( $node['elements'] ) ) { 2476 self::element_paths( $node['elements'], $pointer . '/elements', $out ); 2477 } 2478 } 2479 } 2480 2481 /** 2482 * Where an op's value is stored: through its element's id when apply_rfc6902() 2483 * noted one (exactly one element with that id), else at its path. 2484 * 2485 * @return string|null The pointer, or null when it cannot be found. 2486 */ 2487 private static function stored_pointer( array $tree, array $op, ?array $at, ?array &$ids ): ?string { 2488 if ( is_array( $at ) && isset( $at['id'] ) ) { 2489 if ( null === $ids ) { 2490 $ids = []; 2491 self::element_paths( $tree, '', $ids ); 2492 } 2493 $paths = $ids[ $at['id'] ] ?? []; 2494 return 1 === count( $paths ) ? $paths[0] . (string) ( $at['rest'] ?? '' ) : null; 2495 } 2496 return (string) ( $at['path'] ?? $op['path'] ?? '' ); 2202 2497 } 2203 2498 … … 2205 2500 * Say for each op that set a value whether the tree as stored holds what was 2206 2501 * sent (`stored_matches_input`), and when it does not, what it holds instead: 2207 * `stored_bytes`, `stored_sha256` and `stored_value`. The sanitising pass, or 2502 * `stored_bytes`, `stored_sha256` and `stored_value`, and why it differs 2503 * (`stored_difference`, see stored_difference()). The sanitising pass, or 2208 2504 * Elementor's own save, can change a value on the way in, and `value` is what 2209 2505 * the client sent. An op whose target cannot be read back is left as it is. 2506 * Removes apply_rfc6902()'s `_stored_at` from every op. 2210 2507 * 2211 2508 * @param array $applied The ops, each with a JSON pointer `path` into the tree. … … 2213 2510 */ 2214 2511 public static function annotate_stored( array $applied, ?array $tree ): array { 2215 if ( null === $tree ) { 2216 return $applied; 2217 } 2512 $ids = null; 2218 2513 foreach ( $applied as $i => $op ) { 2219 if ( ! is_array( $op ) || ! array_key_exists( 'value', $op ) || ! in_array( $op['op'] ?? '', [ 'add', 'replace' ], true )) {2514 if ( ! is_array( $op ) ) { 2220 2515 continue; 2221 2516 } 2222 $stored = self::pointer_get( $tree, (string) ( $op['path'] ?? '' ) ); 2223 if ( is_wp_error( $stored ) ) { 2517 $at = isset( $op['_stored_at'] ) && is_array( $op['_stored_at'] ) ? $op['_stored_at'] : null; 2518 unset( $applied[ $i ]['_stored_at'] ); 2519 if ( null === $tree || ! array_key_exists( 'value', $op ) || ! in_array( $op['op'] ?? '', [ 'add', 'replace' ], true ) ) { 2520 continue; 2521 } 2522 $pointer = self::stored_pointer( $tree, $op, $at, $ids ); 2523 $stored = null === $pointer ? null : self::pointer_get( $tree, $pointer ); 2524 if ( null === $pointer || is_wp_error( $stored ) ) { 2224 2525 continue; 2225 2526 } … … 2230 2531 $applied[ $i ]['stored_bytes'] = $digest['bytes']; 2231 2532 $applied[ $i ]['stored_sha256'] = $digest['sha256']; 2533 $applied[ $i ] = self::with_difference( $applied[ $i ], $op['value'], $stored, $pointer ); 2232 2534 $applied[ $i ]['stored_value'] = $stored; 2233 2535 } 2234 2536 } 2235 2537 return $applied; 2538 } 2539 2540 /** Add `stored_difference` (and `added_paths`) to an op whose stored value differs. */ 2541 private static function with_difference( array $op, mixed $sent, mixed $stored, string $path ): array { 2542 $kinds = []; 2543 $added = []; 2544 $removed = []; 2545 self::stored_difference( $sent, $stored, $path, $kinds, $added, $removed ); 2546 if ( [] === $kinds ) { 2547 $kinds['changed'] = true; // differs, and no part of it says how 2548 } 2549 $op['stored_difference'] = array_values( array_intersect( [ 'changed', 'keys_added', 'keys_removed', 'markup_normalized' ], array_keys( $kinds ) ) ); 2550 if ( isset( $kinds['keys_added'] ) ) { 2551 $op['added_paths'] = array_slice( $added, 0, self::ADDED_PATHS_MAX ); 2552 } 2553 if ( isset( $kinds['keys_removed'] ) ) { 2554 $op['removed_paths'] = array_slice( $removed, 0, self::ADDED_PATHS_MAX ); 2555 } 2556 return $op; 2236 2557 } 2237 2558 … … 2256 2577 $applied[ $i ]['stored_bytes'] = $digest['bytes']; 2257 2578 $applied[ $i ]['stored_sha256'] = $digest['sha256']; 2579 // No stored_difference: the difference here is Elementor's typed envelope. 2258 2580 } 2259 2581 } -
iato-mcp/trunk/includes/class-page-fonts.php
r3797770 r3798383 31 31 private static $google_on = null; 32 32 33 /** @var callable|null Test seam: fn(): bool, Elementor's "Load Google Fonts Locally". */ 34 private static $local_setting = null; 35 36 /** @var callable|null Test seam: fn(): array, the local copies Elementor holds (_elementor_local_google_fonts). */ 37 private static $local_copies = null; 38 39 /** Elementor's font types that Google_Font serves, from Google or from a local copy. */ 40 private const GOOGLE_TYPES = [ 'googlefonts', 'earlyaccess' ]; 41 33 42 public static function init(): void { 34 43 // wp_enqueue_scripts runs inside wp_head at priority 1; Elementor prints the … … 64 73 } 65 74 66 public static function local_google_fonts(): bool { 75 /** Replace where Google fonts come from, for unit tests; nulls restore it. */ 76 public static function set_font_sources( ?callable $local_setting, ?callable $local_copies ): void { 77 self::$local_setting = $local_setting; 78 self::$local_copies = $local_copies; 79 } 80 81 /** Elementor's "Load Google Fonts Locally" setting, read as Google_Font::enqueue() reads it. */ 82 public static function local_google_fonts_setting(): bool { 83 if ( null !== self::$local_setting ) { 84 return (bool) ( self::$local_setting )(); 85 } 67 86 return (bool) get_option( 'elementor_local_google_fonts', '0' ); 87 } 88 89 /** 90 * Where Elementor serves a family from (1.21.1), or null for one that is not a 91 * Google font. Google_Font::enqueue() first serves a local copy it already holds, 92 * whatever the setting says (turning the setting off does not delete them), and 93 * only then reads the setting: on, it downloads a copy on the first page view 94 * and uses Google's when that fails; off, Google's. The same in Elementor 4.2.2 95 * and 4.3.4. Until 1.21.1 the plugin read only the setting. 96 * 97 * @return string|null 'local', 'local_after_first_view' or 'google'. 98 */ 99 public static function served_from( string $family, ?string $type ): ?string { 100 if ( ! in_array( $type, self::GOOGLE_TYPES, true ) ) { 101 return null; 102 } 103 $copies = null !== self::$local_copies ? ( self::$local_copies )() : get_option( '_elementor_local_google_fonts', [] ); 104 $copy = is_array( $copies ) ? ( $copies[ sanitize_key( $family ) ] ?? null ) : null; 105 if ( is_array( $copy ) && ! empty( $copy['url'] ) ) { 106 return 'local'; 107 } 108 return self::local_google_fonts_setting() ? 'local_after_first_view' : 'google'; 109 } 110 111 /** 112 * The fields get_page_fonts and set_page_fonts report about where fonts come from: 113 * `served_from` for each Google family, `google_fonts_local` (true when every 114 * Google family on the page is served from this site; with none, the setting, as 115 * before 1.21.1) and `local_google_fonts_setting`. 116 * 117 * @param array<string,string|null> $types Family => Elementor's type. 118 */ 119 public static function source_report( array $types ): array { 120 $served = []; 121 foreach ( $types as $family => $type ) { 122 $from = self::served_from( (string) $family, $type ); 123 if ( null !== $from ) { 124 $served[ (string) $family ] = $from; 125 } 126 } 127 $setting = self::local_google_fonts_setting(); 128 return [ 129 'served_from' => (object) $served, 130 'google_fonts_local' => [] === $served ? $setting : ! in_array( 'google', $served, true ), 131 'local_google_fonts_setting' => $setting, 132 ]; 68 133 } 69 134 -
iato-mcp/trunk/includes/class-sanitize-report.php
r3797770 r3798383 7 7 * the output are compared: each is reduced to an inventory of tags and 8 8 * attributes, and whatever the output has fewer of was removed. Pure PHP, no 9 * WordPress calls. 9 * WordPress calls, except same_markup(), which uses WordPress's tokenizer when 10 * there is one. 10 11 * 11 12 * @package IATO_MCP … … 28 29 'stripped_unsafe' => 'Inline SVG and style blocks were removed: the markup did not pass the safety check.', 29 30 ]; 31 32 /** HTML's void elements: a self-closing slash on one changes nothing. */ 33 private const VOID = [ 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' ]; 34 35 /** 36 * The same HTML written differently? Same tags in the same order, the same 37 * attributes with the same decoded values, the same decoded text; quotes, 38 * character references, attribute order and case may differ (kses rewrites 39 * single-quoted attributes with double quotes). Null when it cannot tell: 40 * WordPress below 6.5 (no WP_HTML_Tag_Processor::next_token()), or markup 41 * the tokenizer stops in. 1.21.1, for `stored_difference`. 42 */ 43 public static function same_markup( string $a, string $b ): ?bool { 44 $left = self::markup_tokens( $a ); 45 if ( null === $left ) { 46 return null; 47 } 48 $right = self::markup_tokens( $b ); 49 return null === $right ? null : $left === $right; 50 } 51 52 /** @return array|null Each token as data, or null (see same_markup()). */ 53 private static function markup_tokens( string $html ): ?array { 54 if ( ! class_exists( 'WP_HTML_Tag_Processor' ) || ! method_exists( 'WP_HTML_Tag_Processor', 'next_token' ) ) { 55 return null; 56 } 57 $processor = new WP_HTML_Tag_Processor( $html ); 58 $tokens = []; 59 while ( $processor->next_token() ) { 60 $type = $processor->get_token_type(); 61 if ( '#tag' !== $type ) { 62 $tokens[] = [ $type, $processor->get_modifiable_text() ]; 63 continue; 64 } 65 $tag = (string) $processor->get_tag(); 66 $attributes = []; 67 foreach ( (array) $processor->get_attribute_names_with_prefix( '' ) as $name ) { 68 $attributes[ $name ] = $processor->get_attribute( $name ); 69 } 70 ksort( $attributes, SORT_STRING ); 71 $self_closing = in_array( $tag, self::VOID, true ) ? false : $processor->has_self_closing_flag(); 72 $tokens[] = [ '#tag', $tag, $processor->is_tag_closer(), $attributes, $self_closing ]; 73 } 74 if ( method_exists( $processor, 'paused_at_incomplete_token' ) && $processor->paused_at_incomplete_token() ) { 75 return null; 76 } 77 return $tokens; 78 } 30 79 31 80 /** … … 240 289 $notes[] = self::POLICY_NOTES[ $policy ]; 241 290 } 291 // 1.21.1: a value kses rewrote without removing anything (single quotes become 292 // double quotes) was reported with nothing in it. 293 if ( 'html' === $rule && '' === $policy && [] === $removed['removed_tags'] && [] === $removed['removed_attributes'] && empty( $entry['removed_css'] ) ) { 294 $notes[] = true === self::same_markup( $before, $after ) 295 ? 'Nothing was removed: WordPress wrote the same HTML differently (attribute quotes, character references, attribute order or case).' 296 : 'No tag or attribute was removed: WordPress rewrote part of the value, such as an attribute value or character references.'; 297 } 242 298 // 1.21.0: where a page's CSS can go instead. set_page_css ships switched off, so 243 299 // a client cannot see it in tools/list until an administrator turns it on. -
iato-mcp/trunk/includes/class-settings.php
r3797770 r3798383 390 390 // $bridge_gated reflects the key as it was when the form was rendered, 391 391 // i.e. before this request saved it. 392 $tools = iato_mcp_merge_gated_tools( 392 $tools_before = array_values( array_filter( self::TOOL_NAMES, [ self::class, 'is_tool_enabled' ] ) ); 393 $tools = iato_mcp_merge_gated_tools( 393 394 self::sanitize_tools( $tools_raw, false ), 394 395 get_option( 'iato_mcp_tools', [] ), … … 396 397 ); 397 398 update_option( 'iato_mcp_tools', $tools ); 399 // 1.21.1: clients read the tool list when they connect, and this server has 400 // no stream on which to tell them it changed (notifications/tools/list_changed). 401 $tools_after = array_values( array_filter( self::TOOL_NAMES, [ self::class, 'is_tool_enabled' ] ) ); 402 $tools_changed = $tools_before !== $tools_after; 398 403 399 404 // WordPress Abilities exposure toggle (hidden value=0 sibling pattern, … … 452 457 453 458 wp_send_json_success( [ 454 'message' => __( 'Settings saved.', 'iato-mcp' ), 459 'message' => $tools_changed 460 ? __( 'Settings saved. AI clients read the tool list when they connect: reconnect the connector in your AI client to see the tools you turned on or off.', 'iato-mcp' ) 461 : __( 'Settings saved.', 'iato-mcp' ), 455 462 'values' => [ 456 463 'api_key_length' => strlen( $api_key ), 457 464 'crawl_id' => $crawl_id, 458 465 'tools_enabled' => count( $tools ), 466 'tools_changed' => $tools_changed, 459 467 'abilities_enabled' => $abilities_enabled, 460 468 'cache_purge_enabled' => $cache_purge_enabled, … … 1072 1080 </span> 1073 1081 </div> 1074 <p class="iato-card-desc"><?php esc_html_e( 'Choose which MCP tools are available to AI clients. ', 'iato-mcp' ); ?></p>1082 <p class="iato-card-desc"><?php esc_html_e( 'Choose which MCP tools are available to AI clients. AI clients read this list when they connect: after a change, reconnect the connector in your AI client to see it.', 'iato-mcp' ); ?></p> 1075 1083 1076 1084 <?php foreach ( self::TOOL_CATEGORIES as $category => $tools ) : -
iato-mcp/trunk/includes/tools/wp/tool-elementor-widgets.php
r3797770 r3798383 216 216 'update_elementor_patch', 217 217 [ 218 'description' => 'Apply an RFC 6902 JSON Patch to the entire Elementor document. Use this for surgical array entry edits (repeater rows, indexed inserts) where update_elementor_widget\'s replace-only array semantics are too coarse. Each op may include op, path, value, from. Supports dry_run, if_revision, idempotency_key. Requires edit_posts. Plain values set on Elementor Atomic Editor (V4) elements (Elementor 4.3.0 or later) are converted and validated with Elementor\'s own code; elsewhere atomic elements accept only $$type envelope values.',218 'description' => 'Apply an RFC 6902 JSON Patch to the entire Elementor document. Use this for surgical array entry edits (repeater rows, indexed inserts) where update_elementor_widget\'s replace-only array semantics are too coarse. Each op may include op, path, value, from. add at a list index inserts before the item there (RFC 6902); replace overwrites it; /- appends. Each op on a list item returns list (count_before, count_after, ids_after) so a dry run shows where it lands. Supports dry_run, if_revision, idempotency_key. Requires edit_posts. Plain values set on Elementor Atomic Editor (V4) elements (Elementor 4.3.0 or later) are converted and validated with Elementor\'s own code; elsewhere atomic elements accept only $$type envelope values.', 219 219 'inputSchema' => [ 220 220 'type' => 'object', 221 221 'properties' => [ 222 222 'id' => [ 'type' => 'integer', 'description' => 'WordPress post/page ID (required).' ], 223 'ops' => [ 'type' => 'array', 'description' => 'RFC 6902 op array (required). Supports add, remove, replace, move, copy, test. ' ],223 'ops' => [ 'type' => 'array', 'description' => 'RFC 6902 op array (required). Supports add, remove, replace, move, copy, test. add, copy and move insert at a list index; an index past the end is refused.' ], 224 224 'dry_run' => [ 'type' => 'boolean', 'description' => 'Preview applied_patch without writing (default: false).' ], 225 225 'if_revision' => [ 'type' => 'string', 'description' => 'Optional revision guard.' ], -
iato-mcp/trunk/includes/tools/wp/tool-page-assets.php
r3797770 r3798383 115 115 'get_page_fonts', 116 116 [ 117 'description' => 'Returns the font families set_page_fonts loads on one post, each with Elementor\'s type for it, and whether Elementor loads Google fonts from this site. Requires edit_post on the post.',117 'description' => 'Returns the font families set_page_fonts loads on one post, each with Elementor\'s type for it, and for each Google font where Elementor serves it from (served_from: local, local_after_first_view or google). Requires edit_post on the post.', 118 118 'inputSchema' => [ 119 119 'type' => 'object', … … 134 134 $types[ $font ] = IATO_MCP_Page_Fonts::type_of( $font ); 135 135 } 136 return IATO_MCP_Server::ok( [ 'id' => $post->ID, 'fonts' => $fonts, 'types' => (object) $types , 'google_fonts_local' => IATO_MCP_Page_Fonts::local_google_fonts() ]);136 return IATO_MCP_Server::ok( [ 'id' => $post->ID, 'fonts' => $fonts, 'types' => (object) $types ] + IATO_MCP_Page_Fonts::source_report( $types ) ); 137 137 } 138 138 ); … … 141 141 'set_page_fonts', 142 142 [ 143 'description' => 'Loads font families on one post or page only, through Elementor\'s own font loader, so a page\'s CSS can use a font the site does not load elsewhere. Each family must be one Elementor knows (its Google fonts, system fonts, or Elementor Pro custom and Adobe fonts), spelled as Elementor lists it: unknown_font otherwise. With Elementor\'s "Load Google Fonts Locally" on, visitors fetch them from this site, not Google. Elementor loads every weight of a family, so there is no weights option. fonts replaces the list (at most 10; [] removes it). This plugin loads them, so the page loses them while the plugin is deactivated. Accepts dry_run. Returns the list with each family\'s type, a change_receipt and a cache object (that page is purged). Requires Elementor and edit_post on the post.',143 'description' => 'Loads font families on one post or page only, through Elementor\'s own font loader, so a page\'s CSS can use a font the site does not load elsewhere. Each family must be one Elementor knows (its Google fonts, system fonts, or Elementor Pro custom and Adobe fonts), spelled as Elementor lists it: unknown_font otherwise. Elementor serves a Google font from this site when it already holds a copy, or when its "Load Google Fonts Locally" is on; served_from says which for each. Elementor loads every weight of a family, so there is no weights option. fonts replaces the list (at most 10; [] removes it). This plugin loads them, so the page loses them while the plugin is deactivated. Accepts dry_run. Returns the list with each family\'s type and served_from, a change_receipt and a cache object (that page is purged). Requires Elementor and edit_post on the post.', 144 144 'inputSchema' => [ 145 145 'type' => 'object', … … 165 165 } 166 166 $stored = IATO_MCP_Page_Fonts::stored( $post->ID ); 167 $data = [ 'id' => $post->ID, 'fonts' => $plan['fonts'], 'types' => (object) $plan['types'] , 'google_fonts_local' => IATO_MCP_Page_Fonts::local_google_fonts() ];167 $data = [ 'id' => $post->ID, 'fonts' => $plan['fonts'], 'types' => (object) $plan['types'] ] + IATO_MCP_Page_Fonts::source_report( $plan['types'] ); 168 168 $notes = []; 169 169 foreach ( $plan['types'] as $font => $type ) { -
iato-mcp/trunk/readme.txt
r3797770 r3798383 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 1.21. 07 Stable tag: 1.21.1 8 8 License: GPL-2.0-or-later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 186 186 == Changelog == 187 187 188 = 1.21.1 = 189 * Fix: `update_elementor_patch`: `add` at a list index now inserts before the item there, as RFC 6902 says and the tool's description promised. Since 1.3.0 it replaced the item, so a widget added at an index deleted the widget that stood there. `copy` to an index overwrote the item there and `move` to an index deleted one; both now insert. An index past the end of a list is refused instead of leaving a gap, and an element cannot be moved into its own children. Use `replace` to overwrite. A page that lost an element this way can be put back by rolling back that write's receipt within 90 days, or from Elementor's revisions. 190 * Fix: `update_elementor_patch`: `remove` of an item in the middle of a list left a gap in its numbering, so a later op in the same patch could not find the next item, and the list could be stored as a JSON object. 191 * New: each `update_elementor_patch` op on a list item returns `list` (where it acted, the list's length before and after, and its items' ids afterwards), on dry runs too, so a misplaced insert shows before it is written. 192 * Fix: `stored_matches_input` no longer reads a different key order as a different value. Elementor's save writes an element's keys in its own order, so a real write said false where its dry run said true. 193 * Fix: an `add` to `/-`, and an op whose position a later op in the same patch shifted, now get `stored_matches_input`. They used to get nothing, or be compared with whatever stood at the old position. 194 * New: when the stored value differs, `stored_difference` says how: `markup_normalized` (the same HTML written differently, such as single quotes rewritten as double quotes), `keys_added` or `keys_removed` (with `added_paths` and `removed_paths`; Elementor's save drops `isInner` from a widget), or `changed`. A `sanitized_report` entry for a value WordPress rewrote without removing anything now says so; it used to be empty. 195 * Fix: `get_page_fonts` and `set_page_fonts` report where each Google font is served from (`served_from`: `local`, `local_after_first_view` or `google`). Elementor serves a local copy it already holds even with "Load Google Fonts Locally" off, so `google_fonts_local` was false on sites that served fonts locally. It now means every Google font on the page comes from this site; `local_google_fonts_setting` gives the setting itself. 196 * New: Settings > IATO MCP > Tools says that AI clients read the tool list when they connect, and saving a change to the tools says to reconnect the connector. This server cannot tell a connected client that the list changed. 197 188 198 = 1.21.0 = 189 199 * New: `set_page_css` and `get_page_css` give one page its own CSS, printed in that page's head only. They follow the same rules as Additional CSS: nothing loaded from another site, at most 200 KB, and anything that would close the style element removed. The CSS is checked again when it is printed. Needs a WordPress user who may edit CSS (administrators and editors) and `edit_post` on the page. … … 577 587 == Upgrade Notice == 578 588 589 = 1.21.1 = 590 Fixes update_elementor_patch: add at an index now inserts, as RFC 6902 says, instead of replacing (deleting) the element there. Use replace to overwrite. Also clearer stored-value and font-source reports. 591 579 592 = 1.21.0 = 580 593 Adds page CSS, fonts, approved page scripts and draft preview links (all switched off until you turn them on), keeps ARIA, form attributes and more SVG in Elementor HTML, and says what Elementor stored.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)