Plugin Directory

Changeset 3798383


Ignore:
Timestamp:
11/12/2026 01:15:41 PM (less than one hour ago)
Author:
iatoai
Message:

Release 1.21.1: patch inserts, what was stored, font sources

Location:
iato-mcp
Files:
16 edited
1 copied

Legend:

Unmodified
Added
Removed
  • iato-mcp/tags/1.21.1/iato-mcp.php

    r3797770 r3798383  
    44 * Plugin URI:  https://iato.ai/wordpress-mcp
    55 * 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.0
     6 * Version:     1.21.1
    77 * Author:      IATO
    88 * Author URI:  https://iato.ai
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'IATO_MCP_VERSION', '1.21.0' );
     20define( 'IATO_MCP_VERSION', '1.21.1' );
    2121define( 'IATO_MCP_FILE', __FILE__ );
    2222define( 'IATO_MCP_DIR', plugin_dir_path( __FILE__ ) );
  • iato-mcp/tags/1.21.1/includes/class-elementor-adapter.php

    r3797770 r3798383  
    423423         * Implements: add, remove, replace, move, copy, test.
    424424         * 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.
    425433         *
    426434         * @return array|WP_Error Applied ops on success.
    … …  
    436444                                return $result;
    437445                        }
     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'] );
    438450                        $applied[] = $result;
    439451                }
    … …  
    449461                                        return new WP_Error( 'invalid_patch', "add op missing 'value' at {$path}" );
    450462                                }
    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 );
    456468
    457469                        case 'replace':
    … …  
    479491                                        return $prev;
    480492                                }
    481                                 $err = self::pointer_remove( $elements, $path );
     493                                $removed = self::list_position( $elements, $path );
     494                                $err     = self::pointer_remove( $elements, $path );
    482495                                if ( is_wp_error( $err ) ) {
    483496                                        return $err;
    484497                                }
    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;
    486503
    487504                        case 'move':
    … …  
    490507                                        return new WP_Error( 'invalid_patch', "move op missing 'from' at {$path}" );
    491508                                }
     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                                }
    492513                                $value = self::pointer_get( $elements, $from );
    493514                                if ( is_wp_error( $value ) ) {
    494515                                        return $value;
    495516                                }
    496                                 $err = self::pointer_remove( $elements, $from );
     517                                $taken = self::list_position( $elements, $from );
     518                                $err   = self::pointer_remove( $elements, $from );
    497519                                if ( is_wp_error( $err ) ) {
    498520                                        return $err;
    499521                                }
    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 );
    505535
    506536                        case 'copy':
    … …  
    513543                                        return $value;
    514544                                }
    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 );
    520550
    521551                        case 'test':
    … …  
    655685                        return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" );
    656686                }
     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 );
    657690                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 ) {
    660693                        $node = array_values( $node );
    661694                }
    662695                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                ];
    663849        }
    664850
    … …  
    21972383        }
    21982384
    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         */
    22002390        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'] ?? '' );
    22022497        }
    22032498
    … …  
    22052500         * Say for each op that set a value whether the tree as stored holds what was
    22062501         * 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
    22082504         * Elementor's own save, can change a value on the way in, and `value` is what
    22092505         * 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.
    22102507         *
    22112508         * @param array      $applied The ops, each with a JSON pointer `path` into the tree.
    … …  
    22132510         */
    22142511        public static function annotate_stored( array $applied, ?array $tree ): array {
    2215                 if ( null === $tree ) {
    2216                         return $applied;
    2217                 }
     2512                $ids = null;
    22182513                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 ) ) {
    22202515                                continue;
    22212516                        }
    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 ) ) {
    22242525                                continue;
    22252526                        }
    … …  
    22302531                                $applied[ $i ]['stored_bytes']  = $digest['bytes'];
    22312532                                $applied[ $i ]['stored_sha256'] = $digest['sha256'];
     2533                                $applied[ $i ]                  = self::with_difference( $applied[ $i ], $op['value'], $stored, $pointer );
    22322534                                $applied[ $i ]['stored_value']  = $stored;
    22332535                        }
    22342536                }
    22352537                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;
    22362557        }
    22372558
    … …  
    22562577                                $applied[ $i ]['stored_bytes']  = $digest['bytes'];
    22572578                                $applied[ $i ]['stored_sha256'] = $digest['sha256'];
     2579                                // No stored_difference: the difference here is Elementor's typed envelope.
    22582580                        }
    22592581                }
  • iato-mcp/tags/1.21.1/includes/class-page-fonts.php

    r3797770 r3798383  
    3131        private static $google_on = null;
    3232
     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
    3342        public static function init(): void {
    3443                // wp_enqueue_scripts runs inside wp_head at priority 1; Elementor prints the
    … …  
    6473        }
    6574
    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                }
    6786                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                ];
    68133        }
    69134
  • iato-mcp/tags/1.21.1/includes/class-sanitize-report.php

    r3797770 r3798383  
    77 * the output are compared: each is reduced to an inventory of tags and
    88 * 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.
    1011 *
    1112 * @package IATO_MCP
    … …  
    2829                'stripped_unsafe'        => 'Inline SVG and style blocks were removed: the markup did not pass the safety check.',
    2930        ];
     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        }
    3079
    3180        /**
    … …  
    240289                        $notes[] = self::POLICY_NOTES[ $policy ];
    241290                }
     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                }
    242298                // 1.21.0: where a page's CSS can go instead. set_page_css ships switched off, so
    243299                // 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  
    390390                // $bridge_gated reflects the key as it was when the form was rendered,
    391391                // 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(
    393394                        self::sanitize_tools( $tools_raw, false ),
    394395                        get_option( 'iato_mcp_tools', [] ),
    … …  
    396397                );
    397398                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;
    398403
    399404                // WordPress Abilities exposure toggle (hidden value=0 sibling pattern,
    … …  
    452457
    453458                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' ),
    455462                        'values'  => [
    456463                                'api_key_length'      => strlen( $api_key ),
    457464                                'crawl_id'            => $crawl_id,
    458465                                'tools_enabled'       => count( $tools ),
     466                                'tools_changed'       => $tools_changed,
    459467                                'abilities_enabled'   => $abilities_enabled,
    460468                                'cache_purge_enabled' => $cache_purge_enabled,
    … …  
    10721080                                                </span>
    10731081                                        </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>
    10751083
    10761084                                        <?php foreach ( self::TOOL_CATEGORIES as $category => $tools ) :
  • iato-mcp/tags/1.21.1/includes/tools/wp/tool-elementor-widgets.php

    r3797770 r3798383  
    216216        'update_elementor_patch',
    217217        [
    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.',
    219219                'inputSchema' => [
    220220                        'type'       => 'object',
    221221                        'properties' => [
    222222                                '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.' ],
    224224                                'dry_run'         => [ 'type' => 'boolean', 'description' => 'Preview applied_patch without writing (default: false).' ],
    225225                                'if_revision'     => [ 'type' => 'string',  'description' => 'Optional revision guard.' ],
  • iato-mcp/tags/1.21.1/includes/tools/wp/tool-page-assets.php

    r3797770 r3798383  
    115115        'get_page_fonts',
    116116        [
    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.',
    118118                'inputSchema' => [
    119119                        'type'       => 'object',
    … …  
    134134                        $types[ $font ] = IATO_MCP_Page_Fonts::type_of( $font );
    135135                }
    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 ) );
    137137        }
    138138);
    … …  
    141141        'set_page_fonts',
    142142        [
    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.',
    144144                'inputSchema' => [
    145145                        'type'       => 'object',
    … …  
    165165                }
    166166                $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'] );
    168168                $notes  = [];
    169169                foreach ( $plan['types'] as $font => $type ) {
  • iato-mcp/tags/1.21.1/readme.txt

    r3797770 r3798383  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.21.0
     7Stable tag: 1.21.1
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    186186== Changelog ==
    187187
     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
    188198= 1.21.0 =
    189199* 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.
    … …  
    577587== Upgrade Notice ==
    578588
     589= 1.21.1 =
     590Fixes 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
    579592= 1.21.0 =
    580593Adds 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  
    44 * Plugin URI:  https://iato.ai/wordpress-mcp
    55 * 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.0
     6 * Version:     1.21.1
    77 * Author:      IATO
    88 * Author URI:  https://iato.ai
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'IATO_MCP_VERSION', '1.21.0' );
     20define( 'IATO_MCP_VERSION', '1.21.1' );
    2121define( 'IATO_MCP_FILE', __FILE__ );
    2222define( 'IATO_MCP_DIR', plugin_dir_path( __FILE__ ) );
  • iato-mcp/trunk/includes/class-elementor-adapter.php

    r3797770 r3798383  
    423423         * Implements: add, remove, replace, move, copy, test.
    424424         * 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.
    425433         *
    426434         * @return array|WP_Error Applied ops on success.
    … …  
    436444                                return $result;
    437445                        }
     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'] );
    438450                        $applied[] = $result;
    439451                }
    … …  
    449461                                        return new WP_Error( 'invalid_patch', "add op missing 'value' at {$path}" );
    450462                                }
    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 );
    456468
    457469                        case 'replace':
    … …  
    479491                                        return $prev;
    480492                                }
    481                                 $err = self::pointer_remove( $elements, $path );
     493                                $removed = self::list_position( $elements, $path );
     494                                $err     = self::pointer_remove( $elements, $path );
    482495                                if ( is_wp_error( $err ) ) {
    483496                                        return $err;
    484497                                }
    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;
    486503
    487504                        case 'move':
    … …  
    490507                                        return new WP_Error( 'invalid_patch', "move op missing 'from' at {$path}" );
    491508                                }
     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                                }
    492513                                $value = self::pointer_get( $elements, $from );
    493514                                if ( is_wp_error( $value ) ) {
    494515                                        return $value;
    495516                                }
    496                                 $err = self::pointer_remove( $elements, $from );
     517                                $taken = self::list_position( $elements, $from );
     518                                $err   = self::pointer_remove( $elements, $from );
    497519                                if ( is_wp_error( $err ) ) {
    498520                                        return $err;
    499521                                }
    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 );
    505535
    506536                        case 'copy':
    … …  
    513543                                        return $value;
    514544                                }
    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 );
    520550
    521551                        case 'test':
    … …  
    655685                        return new WP_Error( 'invalid_patch_path', "Path not found: {$path}" );
    656686                }
     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 );
    657690                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 ) {
    660693                        $node = array_values( $node );
    661694                }
    662695                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                ];
    663849        }
    664850
    … …  
    21972383        }
    21982384
    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         */
    22002390        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'] ?? '' );
    22022497        }
    22032498
    … …  
    22052500         * Say for each op that set a value whether the tree as stored holds what was
    22062501         * 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
    22082504         * Elementor's own save, can change a value on the way in, and `value` is what
    22092505         * 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.
    22102507         *
    22112508         * @param array      $applied The ops, each with a JSON pointer `path` into the tree.
    … …  
    22132510         */
    22142511        public static function annotate_stored( array $applied, ?array $tree ): array {
    2215                 if ( null === $tree ) {
    2216                         return $applied;
    2217                 }
     2512                $ids = null;
    22182513                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 ) ) {
    22202515                                continue;
    22212516                        }
    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 ) ) {
    22242525                                continue;
    22252526                        }
    … …  
    22302531                                $applied[ $i ]['stored_bytes']  = $digest['bytes'];
    22312532                                $applied[ $i ]['stored_sha256'] = $digest['sha256'];
     2533                                $applied[ $i ]                  = self::with_difference( $applied[ $i ], $op['value'], $stored, $pointer );
    22322534                                $applied[ $i ]['stored_value']  = $stored;
    22332535                        }
    22342536                }
    22352537                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;
    22362557        }
    22372558
    … …  
    22562577                                $applied[ $i ]['stored_bytes']  = $digest['bytes'];
    22572578                                $applied[ $i ]['stored_sha256'] = $digest['sha256'];
     2579                                // No stored_difference: the difference here is Elementor's typed envelope.
    22582580                        }
    22592581                }
  • iato-mcp/trunk/includes/class-page-fonts.php

    r3797770 r3798383  
    3131        private static $google_on = null;
    3232
     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
    3342        public static function init(): void {
    3443                // wp_enqueue_scripts runs inside wp_head at priority 1; Elementor prints the
    … …  
    6473        }
    6574
    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                }
    6786                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                ];
    68133        }
    69134
  • iato-mcp/trunk/includes/class-sanitize-report.php

    r3797770 r3798383  
    77 * the output are compared: each is reduced to an inventory of tags and
    88 * 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.
    1011 *
    1112 * @package IATO_MCP
    … …  
    2829                'stripped_unsafe'        => 'Inline SVG and style blocks were removed: the markup did not pass the safety check.',
    2930        ];
     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        }
    3079
    3180        /**
    … …  
    240289                        $notes[] = self::POLICY_NOTES[ $policy ];
    241290                }
     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                }
    242298                // 1.21.0: where a page's CSS can go instead. set_page_css ships switched off, so
    243299                // a client cannot see it in tools/list until an administrator turns it on.
  • iato-mcp/trunk/includes/class-settings.php

    r3797770 r3798383  
    390390                // $bridge_gated reflects the key as it was when the form was rendered,
    391391                // 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(
    393394                        self::sanitize_tools( $tools_raw, false ),
    394395                        get_option( 'iato_mcp_tools', [] ),
    … …  
    396397                );
    397398                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;
    398403
    399404                // WordPress Abilities exposure toggle (hidden value=0 sibling pattern,
    … …  
    452457
    453458                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' ),
    455462                        'values'  => [
    456463                                'api_key_length'      => strlen( $api_key ),
    457464                                'crawl_id'            => $crawl_id,
    458465                                'tools_enabled'       => count( $tools ),
     466                                'tools_changed'       => $tools_changed,
    459467                                'abilities_enabled'   => $abilities_enabled,
    460468                                'cache_purge_enabled' => $cache_purge_enabled,
    … …  
    10721080                                                </span>
    10731081                                        </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>
    10751083
    10761084                                        <?php foreach ( self::TOOL_CATEGORIES as $category => $tools ) :
  • iato-mcp/trunk/includes/tools/wp/tool-elementor-widgets.php

    r3797770 r3798383  
    216216        'update_elementor_patch',
    217217        [
    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.',
    219219                'inputSchema' => [
    220220                        'type'       => 'object',
    221221                        'properties' => [
    222222                                '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.' ],
    224224                                'dry_run'         => [ 'type' => 'boolean', 'description' => 'Preview applied_patch without writing (default: false).' ],
    225225                                'if_revision'     => [ 'type' => 'string',  'description' => 'Optional revision guard.' ],
  • iato-mcp/trunk/includes/tools/wp/tool-page-assets.php

    r3797770 r3798383  
    115115        'get_page_fonts',
    116116        [
    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.',
    118118                'inputSchema' => [
    119119                        'type'       => 'object',
    … …  
    134134                        $types[ $font ] = IATO_MCP_Page_Fonts::type_of( $font );
    135135                }
    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 ) );
    137137        }
    138138);
    … …  
    141141        'set_page_fonts',
    142142        [
    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.',
    144144                'inputSchema' => [
    145145                        'type'       => 'object',
    … …  
    165165                }
    166166                $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'] );
    168168                $notes  = [];
    169169                foreach ( $plan['types'] as $font => $type ) {
  • iato-mcp/trunk/readme.txt

    r3797770 r3798383  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.21.0
     7Stable tag: 1.21.1
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    186186== Changelog ==
    187187
     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
    188198= 1.21.0 =
    189199* 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.
    … …  
    577587== Upgrade Notice ==
    578588
     589= 1.21.1 =
     590Fixes 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
    579592= 1.21.0 =
    580593Adds 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.