Plugin Directory

Changeset 3783407


Ignore:
Timestamp:
11/04/2026 08:28:53 AM (6 hours ago)
Author:
frontmanai
Message:

Release 5.6.0

Location:
frontman-agentic-ai-editor
Files:
398 added
17 edited

Legend:

Unmodified
Added
Removed
  • frontman-agentic-ai-editor/trunk/frontman.php

    r3767236 r3783407  
    44 * Plugin URI:        https://frontman.sh
    55 * Description:       Edit WordPress with AI beside a live preview. Update pages, posts, Elementor layouts, WooCommerce data, menus, and settings faster.
    6  * Version:           5.5.0
     6 * Version:           5.6.0
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    4444}
    4545
    46 define( 'FRONTMAN_VERSION', '5.5.0' );
     46define( 'FRONTMAN_VERSION', '5.6.0' );
    4747define( 'FRONTMAN_PLUGIN_DIR', frontman_plugin_dir_path( __FILE__ ) );
    4848define( 'FRONTMAN_PLUGIN_URL', frontman_plugin_dir_url( __FILE__ ) );
    … …  
    5454require_once FRONTMAN_PLUGIN_DIR . 'includes/class-frontman-tools.php';
    5555require_once FRONTMAN_PLUGIN_DIR . 'includes/class-frontman-sentry.php';
     56require_once FRONTMAN_PLUGIN_DIR . 'includes/class-frontman-settings.php';
    5657require_once FRONTMAN_PLUGIN_DIR . 'includes/class-frontman-router.php';
    5758require_once FRONTMAN_PLUGIN_DIR . 'includes/class-frontman-ui.php';
    … …  
    6566require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-templates.php';
    6667require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-widgets.php';
     68add_action( 'widgets_init', static function(): void {
     69        add_filter( 'widget_display_callback', [ 'Frontman_Tool_Widgets', 'filter_visibility' ], 10, 3 );
     70} );
    6771require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-cache.php';
     72require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-polylang.php';
    6873require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-public-files.php';
    6974require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-seo.php';
    … …  
    7176require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-301-redirects.php';
    7277require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-angie.php';
     78require_once FRONTMAN_PLUGIN_DIR . 'tools/class-tool-directorist.php';
    7379
    7480/**
    … …  
    9399        ( new Frontman_Tool_Widgets() )->register( $tools );
    94100        ( new Frontman_Tool_Cache() )->register( $tools );
     101        ( new Frontman_Tool_Polylang() )->register( $tools );
    95102        ( new Frontman_Tool_Public_Files() )->register( $tools );
    96103
    97104        if ( Frontman_Tool_Angie::is_available() ) {
    98105                ( new Frontman_Tool_Angie() )->register( $tools );
     106        }
     107
     108        if ( Frontman_Tool_Directorist::is_available() ) {
     109                ( new Frontman_Tool_Directorist() )->register( $tools );
    99110        }
    100111
    … …  
    107118        }
    108119
    109         if ( null === Frontman_Tool_Seo::unavailable_reason() ) {
    110                 ( new Frontman_Tool_Seo() )->register( $tools );
    111         }
     120        ( new Frontman_Tool_Seo() )->register( $tools );
    112121
    113122        if ( Frontman_Plugin_Dependencies::is_available( 'elementor/elementor.php', '\Elementor\Plugin' ) ) {
    … …  
    127136        $router->register();
    128137        $ui->register();
    129         Frontman_Sentry::register();
     138        Frontman_Settings::register();
    130139}
    131140add_action( 'init', 'frontman_init' );
  • frontman-agentic-ai-editor/trunk/includes/class-frontman-elementor-data.php

    r3767236 r3783407  
    662662    }
    663663
     664    public static function get_element_context( array $elements, string $element_id ): ?array {
     665        $matches = self::element_contexts( $elements, $element_id );
     666        if ( count( $matches ) > 1 ) {
     667            throw new Frontman_Tool_Error( 'Context is ambiguous for element: ' . $element_id );
     668        }
     669        return $matches[0] ?? null;
     670    }
     671
     672    public static function wrap_at_context( array &$elements, array $context, array $wrapper ): bool {
     673        $id = (string) $context['element']['id'];
     674        $current = self::get_element_context( $elements, $id );
     675        if ( null === $current || $current !== $context ) {
     676            return false;
     677        }
     678        return self::replace_element_at_context( $elements, $id, $context['parent_id'], (int) $context['position'], $wrapper );
     679    }
     680
    664681    private static function element_contexts( array $elements, string $element_id, ?string $parent_id = null, ?array $parent = null ): array {
    665682        $matches = [];
  • frontman-agentic-ai-editor/trunk/includes/class-frontman-router.php

    r3767236 r3783407  
    4545        public function register(): void {
    4646                add_action( 'parse_request', [ $this, 'intercept' ], 1 );
     47                add_action( 'rest_api_init', static function(): void {
     48                        $permission = static function() {
     49                                wp_get_current_user();
     50                                global $wp_rest_application_password_uuid;
     51                                if ( empty( $wp_rest_application_password_uuid ) || ! current_user_can( 'manage_options' )
     52                                        || ! Frontman_Sentry::sanitize_enabled( get_option( 'frontman_mcp_enabled', false ) ) ) {
     53                                        return new WP_Error( 'frontman_mcp_forbidden', 'MCP access denied.', [ 'status' => 403 ] );
     54                                }
     55                                return true;
     56                        };
     57                        register_rest_route( 'frontman/v1', '/tools', [
     58                                'methods' => 'GET',
     59                                'permission_callback' => $permission,
     60                                'callback' => static function() {
     61                                        return [ 'tools' => Frontman_Tools::instance()->all_definitions( true ) ];
     62                                },
     63                        ] );
     64                        register_rest_route( 'frontman/v1', '/tools/call', [
     65                                'methods' => 'POST',
     66                                'permission_callback' => $permission,
     67                                'callback' => static function( WP_REST_Request $request ) {
     68                                        $tools = Frontman_Tools::instance();
     69                                        $name = $request->get_param( 'name' );
     70                                        $tool = is_string( $name ) ? $tools->get( $name ) : null;
     71                                        if ( ! $tool || null === $tool->remote_validator ) {
     72                                                return new WP_Error( 'frontman_mcp_forbidden', 'MCP access denied.', [ 'status' => 403 ] );
     73                                        }
     74                                        $body = json_decode( $request->get_body() );
     75                                        if ( ! is_object( $body ) || ! isset( $body->arguments ) || ! is_object( $body->arguments )
     76                                                || ! ( $tool->remote_validator )( $body->arguments ) ) {
     77                                                return new WP_Error( 'frontman_mcp_arguments', 'Invalid tool arguments.', [ 'status' => 400 ] );
     78                                        }
     79                                        try {
     80                                                return $tools->call( $name, $tools->sanitize_input( $name, $body->arguments, true ) );
     81                                        } catch ( Frontman_Tool_Error $e ) {
     82                                                return new WP_Error( 'frontman_mcp_arguments', $e->getMessage(), [ 'status' => 400 ] );
     83                                        }
     84                                },
     85                        ] );
     86                } );
    4787        }
    4888
    … …  
    279319                        }
    280320                }
    281                 if ( is_array( $data ) && in_array( sanitize_key( $data['name'] ?? '' ), [ 'wp_create_post', 'wp_update_post' ], true ) ) {
     321                if ( is_array( $data ) ) {
    282322                        $typed = json_decode( $raw );
    283323                        foreach ( [ 'arguments', 'input' ] as $key ) {
    284                                 if ( isset( $typed->$key ) && is_object( $typed->$key ) && isset( $data[ $key ] ) && is_array( $data[ $key ] ) ) {
    285                                         foreach ( [ 'categories', 'tags' ] as $field ) {
    286                                                 if ( property_exists( $typed->$key, $field ) ) {
    287                                                         $data[ $key ][ $field ] = $typed->$key->$field;
    288                                                 }
    289                                         }
     324                                if ( $typed instanceof \stdClass && property_exists( $typed, $key ) ) {
     325                                        $data[ $key ] = $typed->$key;
    290326                                }
    291327                        }
    … …  
    302338                ];
    303339
    304                 if ( isset( $data['arguments'] ) && is_array( $data['arguments'] ) ) {
     340                if ( array_key_exists( 'arguments', $data ) ) {
    305341                        $body['arguments'] = $data['arguments'];
    306342                }
    307343
    308                 if ( isset( $data['input'] ) && is_array( $data['input'] ) ) {
     344                if ( array_key_exists( 'input', $data ) ) {
    309345                        $body['input'] = $data['input'];
    310346                }
    … …  
    360396        private function handle_tool_call( array $body ): void {
    361397                $name      = sanitize_key( $body['name'] ?? '' );
    362                 $raw_input = $body['arguments'] ?? $body['input'] ?? [];
     398                $raw_input = array_key_exists( 'arguments', $body ) ? $body['arguments'] : ( array_key_exists( 'input', $body ) ? $body['input'] : (object) [] );
    363399
    364400                if ( empty( $name ) ) {
    … …  
    369405                if ( $this->tools->is_wp_tool( $name ) ) {
    370406                        try {
    371                                 $input  = is_array( $raw_input ) ? $this->tools->sanitize_input( $name, $raw_input ) : [];
     407                                $input  = $this->tools->sanitize_input( $name, $raw_input, true );
    372408                                $result = $this->tools->call( $name, $input );
    373409                                $this->send_sse_tool_result( $result );
  • frontman-agentic-ai-editor/trunk/includes/class-frontman-sentry.php

    r3767236 r3783407  
    3030        }
    3131
    32         public static function register(): void {
    33                 add_action( 'admin_init', [ self::class, 'register_setting' ] );
    34                 add_action( 'admin_menu', static function (): void {
    35                         self::boundary( 'diagnostics_admin_menu', static function (): void {
    36                                 add_options_page( 'Frontman', 'Frontman', 'manage_options', 'frontman-diagnostics', [ self::class, 'render_settings' ] );
    37                         } );
    38                 } );
    39         }
    40 
    4132        public static function sanitize_enabled( $value ): bool {
    4233                return in_array( $value, [ true, 1, '1' ], true );
    43         }
    44 
    45         public static function register_setting(): void {
    46                 self::boundary( 'admin_init', [ self::class, 'register_diagnostic_settings' ] );
    47         }
    48 
    49         private static function register_diagnostic_settings(): void {
    50                 register_setting( 'frontman_diagnostics', self::OPTION, [
    51                         'type' => 'boolean',
    52                         'default' => false,
    53                         'sanitize_callback' => [ self::class, 'sanitize_enabled' ],
    54                 ] );
    55                 register_setting( 'frontman_diagnostics', 'frontman_auth_diagnostics', [
    56                         'type' => 'boolean',
    57                         'default' => false,
    58                         'sanitize_callback' => [ self::class, 'sanitize_enabled' ],
    59                 ] );
    60         }
    61 
    62         public static function render_settings(): void {
    63                 if ( ! current_user_can( 'manage_options' ) ) {
    64                         wp_die( esc_html__( 'Administrator access required.', 'frontman-agentic-ai-editor' ) );
    65                         return;
    66                 }
    67                 self::boundary( 'diagnostics_settings', [ self::class, 'render_settings_form' ] );
    68         }
    69 
    70         private static function render_settings_form(): void {
    71                 ?>
    72                 <div class="wrap">
    73                         <h1><?php esc_html_e( 'Frontman PHP Diagnostics', 'frontman-agentic-ai-editor' ); ?></h1>
    74                         <form action="options.php" method="post">
    75                                 <?php settings_fields( 'frontman_diagnostics' ); ?>
    76                                 <input type="hidden" name="frontman_php_diagnostics" value="0">
    77                                 <label>
    78                                         <input type="checkbox" name="frontman_php_diagnostics" value="1" <?php checked( self::sanitize_enabled( get_option( self::OPTION, false ) ) ); ?>>
    79                                         <?php esc_html_e( 'Send unexpected Frontman PHP tool errors to Sentry.', 'frontman-agentic-ai-editor' ); ?>
    80                                 </label>
    81                                 <p><?php esc_html_e( 'Reports include exception types, sanitized stack traces, tool names, and software versions. They exclude exception messages, tool arguments, source code, site URLs, and user data. Sentry receives the server IP address when delivering reports. You can disable reporting here at any time.', 'frontman-agentic-ai-editor' ); ?></p>
    82                                 <p><a href="https://frontman.sh/privacy/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Frontman Privacy Policy', 'frontman-agentic-ai-editor' ); ?></a> · <a href="https://sentry.io/privacy/" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Sentry Privacy Policy', 'frontman-agentic-ai-editor' ); ?></a></p>
    83                                 <input type="hidden" name="frontman_auth_diagnostics" value="0">
    84                                 <label>
    85                                         <input type="checkbox" name="frontman_auth_diagnostics" value="1" <?php checked( self::sanitize_enabled( get_option( 'frontman_auth_diagnostics', false ) ) ); ?>>
    86                                         <?php esc_html_e( 'Enable authentication diagnostics in the server PHP error log.', 'frontman-agentic-ai-editor' ); ?>
    87                                 </label>
    88                                 <p><?php esc_html_e( 'Records contain only fixed authentication failure categories and WordPress, PHP, and plugin versions. They exclude credentials, request content, URLs, and user data. Each category is logged at most once every five minutes per site. These records are not sent to Sentry. You can disable logging here at any time.', 'frontman-agentic-ai-editor' ); ?></p>
    89                                 <?php submit_button(); ?>
    90                         </form>
    91                 </div>
    92                 <?php
    9334        }
    9435
  • frontman-agentic-ai-editor/trunk/includes/class-frontman-tools.php

    r3767236 r3783407  
    3535        /** @var callable(array): array */
    3636        public $handler;
     37        /** @var null|callable(\stdClass): bool Remote opt-in; validate native JSON before sanitization. */
     38        public $remote_validator;
    3739
    3840        /**
    … …  
    4446         * @param bool     $visible_to_agent       Whether the agent can see this tool.
    4547         * @param bool     $preserve_input_strings Whether schema sanitization should preserve raw string values for downstream API validation.
     48         * @param callable|null $remote_validator Remote opt-in. Return true for valid decoded JSON arguments, false for HTTP 400.
    4649         */
    4750        public function __construct(
    … …  
    5255                ?string $access = null,
    5356                bool $visible_to_agent = true,
    54                 bool $preserve_input_strings = false
     57                bool $preserve_input_strings = false,
     58                ?callable $remote_validator = null
    5559        ) {
    5660                $this->name                   = $name;
    … …  
    6165                $this->visible_to_agent       = $visible_to_agent;
    6266                $this->preserve_input_strings = $preserve_input_strings;
     67                $this->remote_validator       = $remote_validator;
    6368        }
    6469
    … …  
    138143
    139144        /**
    140          * Return all tool definitions as serializable arrays.
     145         * Return serializable tool definitions, optionally limited to remote contracts.
    141146         *
    142147         * @return array[]
    143148         */
    144         public function all_definitions(): array {
     149        public function all_definitions( bool $remote_only = false ): array {
    145150                return array_values(
    146151                        array_map(
    147152                                function( Frontman_Tool_Definition $t ) { return $t->to_array(); },
    148                                 $this->tools
     153                                array_filter( $this->tools, static fn( Frontman_Tool_Definition $t ) => ! $remote_only || null !== $t->remote_validator )
    149154                        )
    150155                );
    … …  
    154159         * Sanitize tool input against the registered JSON schema before dispatch.
    155160         */
    156         public function sanitize_input( string $name, array $input ): array {
     161        public function sanitize_input( string $name, $input, bool $json = false ): array {
    157162                $tool = $this->get( $name );
    158163                if ( ! $tool ) {
    159                         return $this->sanitize_untyped_array( $input, $name );
     164                        return $this->sanitize_untyped_array( (array) $input, $name );
     165                }
     166
     167                $this->validate_types( $input, $tool->input_schema, 'arguments', $json );
     168                $schema = json_decode( wp_json_encode( $tool->input_schema, JSON_PRESERVE_ZERO_FRACTION ), true );
     169                $valid = rest_validate_value_from_schema( $input, $schema, 'arguments' );
     170                if ( is_wp_error( $valid ) ) {
     171                        throw new Frontman_Tool_Error( $valid->get_error_message() );
     172                }
     173                if ( $input instanceof \stdClass ) {
     174                        $input = json_decode( wp_json_encode( $input, JSON_PRESERVE_ZERO_FRACTION ), true );
    160175                }
    161176
    162177                if ( class_exists( 'Frontman_Tool_Seo' ) && in_array( $name, [ 'wp_read_seo', 'wp_update_seo' ], true ) ) {
    163178                        return Frontman_Tool_Seo::validate_input( $name, $input );
     179                }
     180                if ( class_exists( 'Frontman_Tool_WooCommerce' ) && in_array( $name, [ 'wc_get_catalog_display', 'wc_update_catalog_display' ], true ) ) {
     181                        Frontman_Tool_WooCommerce::validate_catalog_display_input( $input, 'wc_update_catalog_display' === $name );
     182                        return array_intersect_key( $input, array_flip( [ 'name', 'value', 'confirm' ] ) );
    164183                }
    165184
    … …  
    181200                }
    182201
    183                 if ( class_exists( 'Frontman_Tool_Elementor' ) && in_array( $name, [ 'wp_elementor_add_element', 'wp_elementor_generate_element' ], true ) ) {
     202                if ( class_exists( 'Frontman_Tool_Elementor' ) && in_array( $name, [ 'wp_elementor_add_element', 'wp_elementor_generate_element', 'wp_elementor_wrap_element' ], true ) ) {
    184203                        Frontman_Tool_Elementor::validate_creation_input( $name, $input );
    185204                }
    … …  
    188207                $sanitized = $this->sanitize_value_for_schema( $input, $tool->input_schema, $name, '', $preserve_strings );
    189208                return is_array( $sanitized ) ? $sanitized : [];
     209        }
     210
     211        /**
     212         * Reject coercions before WordPress schema validation. PHP callers use arrays
     213         * for objects; HTTP callers retain native JSON types until validation ends.
     214         */
     215        private function validate_types( $value, array $schema, string $path, bool $json ): void {
     216                $types = (array) ( $schema['type'] ?? [] );
     217                $matches = [
     218                        'object' => $value instanceof \stdClass || ( ! $json && is_array( $value ) ),
     219                        'array' => is_array( $value ),
     220                        'string' => is_string( $value ),
     221                        'integer' => is_int( $value ),
     222                        'number' => is_int( $value ) || is_float( $value ),
     223                        'boolean' => is_bool( $value ),
     224                        'null' => null === $value,
     225                ];
     226                if ( $types && ! array_filter( $types, static fn( $type ) => $matches[ $type ] ?? false ) ) {
     227                        throw new Frontman_Tool_Error( $path . ' must be of type ' . implode( ' or ', $types ) . '.' );
     228                }
     229                if ( $value instanceof \stdClass || is_array( $value ) ) {
     230                        foreach ( $value as $key => $item ) {
     231                                $child = ( $value instanceof \stdClass || ( ! $json && in_array( 'object', $types, true ) ) )
     232                                        ? ( (array) ( $schema['properties'] ?? [] ) )[ $key ] ?? []
     233                                        : ( $schema['items'] ?? [] );
     234                                if ( is_array( $child ) && $child ) {
     235                                        $this->validate_types( $item, $child, $path . '[' . $key . ']', $json );
     236                                }
     237                        }
     238                }
    190239        }
    191240
    … …  
    304353                                        return $value;
    305354                                }
    306                                 if ( in_array( $field_name, [ 'slug', 'content', 'template' ], true ) && in_array( $tool_name, [ 'wp_create_post', 'wp_update_post' ], true ) ) {
     355                                if ( in_array( $field_name, [ 'slug', 'content', 'template' ], true ) && in_array( $tool_name, [ 'wp_create_post', 'wp_update_post', 'wp_save_post_preview' ], true ) ) {
    307356                                        return $value;
    308357                                }
  • frontman-agentic-ai-editor/trunk/includes/class-frontman-ui.php

    r3767236 r3783407  
    113113         *
    114114         * Called directly by the router — this outputs a complete HTML document
    115          * (no wp-admin chrome). The client is loaded from the production CDN.
     115         * (no wp-admin chrome). The client defaults to the production CDN.
    116116         *
    117117         * @param string|null $preview_path Path to load in the web preview iframe.
    … …  
    121121        public function render_page( ?string $preview_path = null ): void {
    122122                $logo_url    = FRONTMAN_PLUGIN_URL . 'assets/frontman-logo.svg';
    123                 $host        = 'api.frontman.sh';
    124                 $base_js_url = 'https://app.frontman.sh/frontman.es.js';
    125                 $client_css  = 'https://app.frontman.sh/frontman.css';
     123                $host        = defined( 'FRONTMAN_HOST' ) ? FRONTMAN_HOST : 'api.frontman.sh';
     124                $base_js_url = defined( 'FRONTMAN_CLIENT_URL' ) ? FRONTMAN_CLIENT_URL : 'https://app.frontman.sh/frontman.es.js';
     125                $client_css  = defined( 'FRONTMAN_CLIENT_CSS' ) ? FRONTMAN_CLIENT_CSS : 'https://app.frontman.sh/frontman.css';
    126126
    127127                $client_url = add_query_arg(
  • frontman-agentic-ai-editor/trunk/readme.txt

    r3767236 r3783407  
    55Tested up to: 7.0.2
    66Requires PHP: 7.4
    7 Stable tag: 5.5.0
     7Stable tag: 5.6.0
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    8787== Optional SEO Tools ==
    8888
    89 `wp_read_seo` reads stored SEO title and description overrides. `wp_update_seo` updates one or both overrides. Eligibility depends on Yoast SEO Free runtime capabilities, not an exact version list. Frontman does not install Yoast.
     89`wp_read_seo` reads stored SEO overrides from Yoast SEO Free or SureRank. `wp_update_seo` supports Yoast SEO Free only. Frontman does not install either plugin.
    9090
    9191Both tools also support one `focus_keyphrase`, stored through Yoast Free as `focuskw`. A focus-only update preserves title and description. Omission preserves the keyphrase; an empty string clears it. Reads and mutation snapshots include it. This is an analysis input, not a rendered keywords tag. Secondary keywords, synonyms, related keyphrases, and Premium support are not included.
    9292
    93 The route requires WordPress 6.9 or later, initialized Yoast metadata methods, and registered metadata sanitizers. The integration-test matrix includes Yoast 28.6 on WordPress 7.0.2 with PHP 8.4, Yoast 28.4 on WordPress 7.0.2 with PHP 7.4 and 8.4, and Yoast 19.9 on WordPress 6.9.9 with PHP 8.2. These pins define test coverage, not production eligibility. Future Yoast versions must pass the same runtime contract. Premium and active Rank Math, All in One SEO, or The SEO Framework disable these tools. Frontman cannot detect every custom SEO owner.
    94 
    95 `wp_get_site_info` returns `seo_unavailable_reason`. A nonempty reason explains why Frontman does not register the SEO tools. A null reason means the compatibility checks pass. Each post still requires edit permissions.
     93SureRank support is read-only. The reader returns stored `page_title`, `page_description`, and `focus_keyword` values through SureRank's raw metadata accessor. Missing values return empty strings, not inherited templates or generated text. Reads do not initialize or update metadata. SureRank 1.10.2 defines the integration-test contract. Runtime eligibility requires the active, loaded plugin and its raw metadata reader. Canonical URLs and social overrides are outside this tool's field set.
     94
     95Both providers require WordPress 6.9 or later. Yoast reads require its initialized metadata reader. Yoast updates also require its setters, deleter, and registered metadata sanitizers. The integration-test matrix includes Yoast 28.6 on WordPress 7.0.2 with PHP 8.4, Yoast 28.4 on WordPress 7.0.2 with PHP 7.4 and 8.4, and Yoast 19.9 on WordPress 6.9.9 with PHP 8.2. These pins define test coverage, not production eligibility. Future Yoast versions must pass the same runtime contract. Premium and active Rank Math, All in One SEO, or The SEO Framework disable these tools. Frontman cannot detect every custom SEO owner.
     96
     97`wp_get_site_info.seo_unavailable_reason` retains its existing meaning: the reason that SEO updates are unavailable. The additive `seo_capabilities` object reports `provider`, `read`, `write`, `supported_fields`, and separate read/write reasons. For SureRank, reads are available but writes are not. Each post still requires post and metadata edit permissions. Simultaneously active Yoast and SureRank disable both SEO tools because ownership is ambiguous.
    9698
    9799Supported objects are posts, pages, and editable non-attachment custom post types with an admin interface, including authorized drafts and private content. Revisions, autosaves, attachments, and internal WordPress object types are not supported.
    … …  
    218220== Changelog ==
    219221
     222= 5.6.0 =
     223* Sync the Frontman plugin release with Frontman v5.6.0
     224* See the GitHub release notes for the full cross-product changelog
     225
    220226= 5.5.0 =
    221227* Sync the Frontman plugin release with Frontman v5.5.0
  • frontman-agentic-ai-editor/trunk/tools/class-tool-cache.php

    r3642353 r3783407  
    121121                                case 'wp-super-cache':
    122122                                        if ( function_exists( 'wp_cache_clear_cache' ) ) {
    123                                                 wp_cache_clear_cache( '', true );
     123                                                wp_cache_clear_cache( is_multisite() ? get_current_blog_id() : 0 );
    124124                                                $cleared[] = $plugin['slug'];
    125125                                        }
  • frontman-agentic-ai-editor/trunk/tools/class-tool-elementor.php

    r3767236 r3783407  
    129129                $tools->add( new Frontman_Tool_Definition(
    130130                        'wp_elementor_add_element',
    131                         'Adds a new Elementor element at the root or inside a parent container after saving the previous page tree as a private rollback snapshot. Prefer wp_elementor_generate_element and pass its returned object as element. Do not call with element: {}.',
     131                        'Adds a new Elementor subtree in one save with a private rollback. Use after_element_id: "book-demo" to insert immediately after a unique sibling, excluding any explicit parent_id or position. Alternatively use parent_id and position. Prefer wp_elementor_generate_element; do not pass element: {}.',
    132132                        [
    133133                                'type'                 => 'object',
    … …  
    135135                                'properties'           => [
    136136                                        'post_id'    => [ 'type' => 'integer' ],
    137                                         'element'    => [
    138                                                 'type'                 => 'object',
    139                                                 'description'          => 'Complete Elementor subtree. IDs can be omitted at every depth and are generated automatically. Prefer wp_elementor_generate_element; never pass an empty object.',
    140                                                 'additionalProperties' => true,
    141                                                 'properties'           => [
    142                                                         'elType'     => [ 'type' => 'string', 'enum' => [ 'container', 'section', 'column', 'widget' ], 'description' => 'Elementor element type. Use widget for widgets and include widgetType.' ],
    143                                                         'widgetType' => [ 'type' => 'string', 'description' => 'Required when elType is widget.' ],
    144                                                         'settings'   => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass() ],
    145                                                         'elements'   => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass() ] ],
    146                                                 ],
    147                                                 'required'             => [ 'elType' ],
    148                                         ],
     137                                        'element'    => $this->new_element_schema(),
     138                                        'after_element_id' => [ 'type' => 'string' ],
    149139                                        'parent_id'  => [ 'type' => 'string' ],
    150                                         'position'   => [ 'type' => 'integer', 'default' => -1 ],
     140                                        'position'   => [ 'type' => 'integer' ],
    151141                                ],
    152142                                'required'             => [ 'post_id', 'element' ],
    153143                        ],
    154144                        [ $this, 'add_element' ]
     145                ) );
     146
     147                $tools->add( new Frontman_Tool_Definition(
     148                        'wp_elementor_wrap_element',
     149                        'Wraps a modern widget or container at its exact position in a new row, optionally adding element_after. Supports root or container parents only, not legacy layouts. Preserves the original subtree IDs; saves one rollback and one final page tree. settings override row defaults. Check rendered desktop/mobile layout after wrapping.',
     150                        [
     151                                'type' => 'object',
     152                                'additionalProperties' => false,
     153                                'properties' => [
     154                                        'post_id' => [ 'type' => 'integer' ],
     155                                        'element_id' => [ 'type' => 'string' ],
     156                                        'settings' => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass() ],
     157                                        'element_after' => $this->new_element_schema(),
     158                                ],
     159                                'required' => [ 'post_id', 'element_id' ],
     160                        ],
     161                        [ $this, 'wrap_element' ],
     162                        'read-write'
    155163                ) );
    156164
    … …  
    197205                $tools->add( new Frontman_Tool_Definition(
    198206                        'wp_elementor_duplicate_element',
    199                         'Duplicates an Elementor element next to the original after saving the previous page tree as a private rollback snapshot, assigning new IDs to the clone and its children.',
    200                         $this->element_id_schema(),
     207                        'Duplicates an Elementor element next to the original in one save with a private rollback. Optional settings override only the cloned root with a shallow merge: settings: {text: "Try It Now", link: {url: "/try/"}} replaces the complete link object. HTML widget settings.html overrides are forbidden.',
     208                        array_replace_recursive( $this->element_id_schema(), [
     209                                'properties' => [
     210                                        'settings' => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass(), 'minProperties' => 1 ],
     211                                ],
     212                        ] ),
    201213                        [ $this, 'duplicate_element' ]
    202214                ) );
    … …  
    684696        }
    685697
     698        private function new_element_schema(): array {
     699                return [
     700                        'type' => 'object',
     701                        'description' => 'Elementor subtree: elType is required, and widgets require widgetType. IDs can be omitted at every depth and are generated automatically.',
     702                        'additionalProperties' => true,
     703                        'properties' => [
     704                                'elType' => [ 'type' => 'string', 'enum' => [ 'container', 'section', 'column', 'widget' ] ],
     705                                'widgetType' => [ 'type' => 'string' ],
     706                                'settings' => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass() ],
     707                                'elements' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'additionalProperties' => true, 'properties' => new \stdClass() ] ],
     708                        ],
     709                        'required' => [ 'elType' ],
     710                ];
     711        }
     712
     713        public function wrap_element( array $input ): array {
     714                self::validate_creation_input( 'wp_elementor_wrap_element', $input );
     715                $post_id = $this->require_post_id( $input );
     716                $element_id = $this->require_element_id( $input );
     717                $data = $this->require_page_data( $post_id );
     718                $before = $data;
     719                $context = Frontman_Elementor_Data::get_element_context( $data, $element_id );
     720                if ( null === $context ) {
     721                        throw new Frontman_Tool_Error( 'Element not found: ' . $element_id );
     722                }
     723                if ( ! in_array( $context['element']['elType'] ?? '', [ 'widget', 'container' ], true ) ||
     724                        ( null !== $context['parent_id'] && 'container' !== $context['parent_el_type'] ) ) {
     725                        throw new Frontman_Tool_Error( 'Wrap supports widget/container targets with root/container parents only.' );
     726                }
     727                $companion = null;
     728                $reserved = $data;
     729                if ( array_key_exists( 'element_after', $input ) ) {
     730                        $companion = $this->prepare_new_subtree( $input['element_after'], $data );
     731                        $reserved[] = $companion;
     732                }
     733                $wrapper = Frontman_Elementor_Data::generate_element( [ 'type' => 'row', 'settings' => $input['settings'] ?? [] ] );
     734                unset( $wrapper['id'] );
     735                $wrapper = $this->prepare_new_subtree( $wrapper, $reserved );
     736                $wrapper['isInner'] = null !== $context['parent_id'];
     737                $wrapper['elements'] = [ $context['element'] ];
     738                if ( null !== $companion ) {
     739                        $wrapper['elements'][] = $companion;
     740                }
     741                if ( ! Frontman_Elementor_Data::wrap_at_context( $data, $context, $wrapper ) ) {
     742                        throw new Frontman_Tool_Error( 'Wrap target no longer occupies its resolved position.' );
     743                }
     744                $this->validate_full_element_tree( $data );
     745                $rollback = Frontman_Elementor_Data::make_page_rollback( 'wrapped_element', $before );
     746                Frontman_Elementor_Data::save_rollback( $post_id, $rollback );
     747                $save_result = $this->save_elementor_data( $post_id, $data );
     748                return $this->append_save_result( [
     749                        'success' => true, 'post_id' => $post_id, 'element_id' => $element_id,
     750                        'wrapper_id' => $wrapper['id'], 'added_element_id' => $companion['id'] ?? null,
     751                        'parent_id' => $context['parent_id'], 'position' => $context['position'],
     752                        'rollback_id' => $rollback['rollback_id'],
     753                ], $save_result );
     754        }
     755
    686756        public function add_element( array $input ): array {
    687757                self::validate_creation_input( 'wp_elementor_add_element', $input );
    … …  
    700770                $parent_id = isset( $input['parent_id'] ) ? sanitize_text_field( $input['parent_id'] ) : null;
    701771                $position  = (int) ( $input['position'] ?? -1 );
     772                if ( array_key_exists( 'after_element_id', $input ) ) {
     773                        if ( array_key_exists( 'parent_id', $input ) || array_key_exists( 'position', $input ) ) {
     774                                throw new Frontman_Tool_Error( 'after_element_id excludes explicit parent_id and position.' );
     775                        }
     776                        $anchor = Frontman_Elementor_Data::get_element_context( $data, (string) $input['after_element_id'] );
     777                        if ( null === $anchor ) {
     778                                throw new Frontman_Tool_Error( 'Anchor element not found.' );
     779                        }
     780                        $parent_id = $anchor['parent_id'];
     781                        $position = $anchor['position'] + 1;
     782                }
    702783                if ( ! Frontman_Elementor_Data::insert_element( $data, $element, $parent_id, $position ) ) {
    703784                        throw new Frontman_Tool_Error( 'Parent element not found: ' . $parent_id );
    … …  
    707788                Frontman_Elementor_Data::save_rollback( $post_id, $rollback );
    708789                $save_result = $this->save_elementor_data( $post_id, $data );
    709                 return $this->append_save_result( [ 'success' => true, 'post_id' => $post_id, 'element_id' => $element['id'] ?? '', 'rollback_id' => $rollback['rollback_id'] ], $save_result );
     790                $inserted = Frontman_Elementor_Data::get_element_context( $data, $element['id'] );
     791                return $this->append_save_result( [ 'success' => true, 'post_id' => $post_id, 'element_id' => $element['id'] ?? '', 'parent_id' => $inserted['parent_id'], 'position' => $inserted['position'], 'rollback_id' => $rollback['rollback_id'] ], $save_result );
    710792        }
    711793
    … …  
    762844                $data       = $this->require_page_data( $post_id );
    763845                $rollback   = Frontman_Elementor_Data::make_page_rollback( 'duplicated_element', $data );
     846                $context = Frontman_Elementor_Data::get_element_context( $data, $element_id );
     847                if ( null === $context ) {
     848                        throw new Frontman_Tool_Error( 'Element not found: ' . $element_id );
     849                }
     850                if ( array_key_exists( 'settings', $input ) ) {
     851                        if ( ! is_array( $input['settings'] ) || [] === $input['settings'] || array_values( $input['settings'] ) === $input['settings'] ) {
     852                                throw new Frontman_Tool_Error( 'settings must be a nonempty object.' );
     853                        }
     854                        if ( 'html' === ( $context['element']['widgetType'] ?? '' ) && array_key_exists( 'html', $input['settings'] ) ) {
     855                                throw new Frontman_Tool_Error( 'Use old_html and new_html on wp_elementor_update_element to edit an Elementor HTML widget settings.html fragment.' );
     856                        }
     857                }
    764858                $new_id     = Frontman_Elementor_Data::duplicate_element( $data, $element_id );
    765859                if ( null === $new_id ) {
    766860                        throw new Frontman_Tool_Error( 'Element not found: ' . $element_id );
    767861                }
     862                if ( isset( $input['settings'] ) ) {
     863                        Frontman_Elementor_Data::update_element_settings( $data, $new_id, $input['settings'] );
     864                }
     865                $this->validate_full_element_tree( $data );
    768866
    769867                Frontman_Elementor_Data::save_rollback( $post_id, $rollback );
    … …  
    792890                $element = Frontman_Elementor_Data::generate_element( $input );
    793891                unset( $element['id'] );
    794                 return $this->prepare_new_subtree( $element );
     892                $element = $this->prepare_new_subtree( $element );
     893                $wire_element = static function ( array $node ) use ( &$wire_element ): array {
     894                        if ( isset( $node['settings'] ) && [] === $node['settings'] ) {
     895                                $node['settings'] = (object) [];
     896                        }
     897                        foreach ( $node['elements'] ?? [] as $index => $child ) {
     898                                $node['elements'][ $index ] = $wire_element( $child );
     899                        }
     900                        return $node;
     901                };
     902                return $wire_element( $element );
    795903        }
    796904
    … …  
    803911        public static function validate_creation_input( string $name, array $input ): void {
    804912                $seen = [];
     913                if ( 'wp_elementor_wrap_element' === $name ) {
     914                        if ( array_key_exists( 'settings', $input ) ) {
     915                                self::validate_settings_object( $input['settings'], 'settings' );
     916                        }
     917                        if ( array_key_exists( 'element_after', $input ) ) {
     918                                self::validate_element_tree( [ $input['element_after'] ], 'element_after', $seen, true, true );
     919                                if ( ! in_array( $input['element_after']['elType'], [ 'widget', 'container' ], true ) ) {
     920                                        throw new Frontman_Tool_Error( 'element_after must be a widget or container.' );
     921                                }
     922                        }
     923                        return;
     924                }
    805925                if ( 'wp_elementor_add_element' === $name ) {
    806926                        self::validate_element_tree( [ $input['element'] ?? null ], 'element', $seen, true, true );
  • frontman-agentic-ai-editor/trunk/tools/class-tool-options.php

    r3767236 r3783407  
    215215                $tools->add( new Frontman_Tool_Definition(
    216216                        'wp_list_theme_mods',
    217                         'Lists active theme mods/customizer settings. Use this to inspect theme-rendered source state such as header images, page title options, and layout settings before choosing a mutation path.',
     217                        'Lists stored active theme mods, not every Customizer setting. For native Astra Header Builder or other registered settings missing here, use wp_list_customizer_settings. Inspect theme-rendered source state before choosing a mutation path.',
    218218                        [
    219219                                'type'                 => 'object',
    … …  
    226226                $tools->add( new Frontman_Tool_Definition(
    227227                        'wp_get_theme_mod',
    228                         'Reads one active theme mod/customizer setting by name. Use this before changing theme-rendered elements.',
     228                        'Reads one active theme mod by name, not option-backed Customizer settings. A null value does not mean an option-backed setting is absent. Use wp_list_customizer_settings and wp_get_customizer_setting for native Astra Header Builder settings.',
    229229                        [
    230230                                'type'                 => 'object',
    … …  
    257257                        true
    258258                ) );
     259
     260                $tools->add( new Frontman_Tool_Definition(
     261                        'wp_list_customizer_settings',
     262                        'Discover registered, accessible active-theme Customizer settings before using CSS to override native theme layout. For Astra Header Builder, search for header-desktop-items or header-mobile-items. Supports top-level theme mods and indexed astra-settings options when Astra is the active parent theme, not arbitrary plugin options. Results are bounded and contain IDs, not values; use wp_get_customizer_setting before editing.',
     263                        [
     264                                'type' => 'object', 'additionalProperties' => false,
     265                                'properties' => [
     266                                        'search' => [ 'type' => 'string', 'description' => 'Optional case-insensitive substring of the setting ID.' ],
     267                                        'offset' => [ 'type' => 'integer', 'minimum' => 0 ],
     268                                        'limit' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 100 ],
     269                                ],
     270                        ],
     271                        [ $this, 'list_customizer_settings' ], null, true, true
     272                ) );
     273                $tools->add( new Frontman_Tool_Definition(
     274                        'wp_get_customizer_setting',
     275                        'Read one supported registered Customizer setting, including its stored value or registered default, storage presence, and fingerprint. Preserve existing layout components, logo and menu assignments unless the user requests changes. Use the exact ID and fingerprint for wp_update_customizer_setting. Filters and Additional CSS may affect rendering; inspect them separately.',
     276                        [
     277                                'type' => 'object', 'additionalProperties' => false,
     278                                'properties' => [ 'id' => [ 'type' => 'string', 'description' => 'Exact ID from wp_list_customizer_settings.' ] ],
     279                                'required' => [ 'id' ],
     280                        ],
     281                        [ $this, 'get_customizer_setting' ], null, true, true
     282                ) );
     283                $tools->add( new Frontman_Tool_Definition(
     284                        'wp_update_customizer_setting',
     285                        'Save one confirmed supported registered Customizer setting using native validation, sanitization and save. Read it first and supply its fingerprint and active stylesheet. Conflict detection is best-effort, not atomic. Reread after any failure or lost response; never retry automatically. Verify the rendered site separately.',
     286                        [
     287                                'type' => 'object', 'additionalProperties' => false,
     288                                'properties' => [
     289                                        'id' => [ 'type' => 'string' ],
     290                                        'value' => [ 'type' => [ 'string', 'number', 'boolean', 'array', 'object' ] ],
     291                                        'stylesheet' => [ 'type' => 'string', 'description' => 'Active stylesheet from the read.' ],
     292                                        'expected_fingerprint' => [ 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ],
     293                                        'confirm' => [ 'type' => 'boolean', 'description' => 'Must be true after user approval.' ],
     294                                ],
     295                                'required' => [ 'id', 'value', 'stylesheet', 'expected_fingerprint', 'confirm' ],
     296                        ],
     297                        [ $this, 'update_customizer_setting' ], null, true, true
     298                ) );
    259299        }
    260300
    … …  
    620660                }
    621661
    622                 global $wp_customize;
    623                 if ( ! ( $wp_customize instanceof \WP_Customize_Manager ) ) {
    624                         if ( ! class_exists( '\WP_Customize_Manager' ) ) {
    625                                 require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
    626                         }
    627                         $wp_customize = new \WP_Customize_Manager( [ 'settings_previewed' => false ] );
    628                         remove_action( 'customize_register', [ $wp_customize, 'register_controls' ] );
    629                         $wp_customize->register_controls();
    630                         do_action( 'customize_register', $wp_customize );
    631                 }
     662                $wp_customize = $this->customizer_manager();
    632663                $setting = $wp_customize->get_setting( $current['name'] );
    633664                if ( ! $setting || 'theme_mod' !== $setting->type || ! empty( $setting->id_data()['keys'] ) ) {
    634                         throw new Frontman_Tool_Error( 'Only registered top-level Customizer theme mods can be updated.' );
     665                        throw new Frontman_Tool_Error( 'Only registered top-level Customizer theme mods can be updated. For supported indexed Astra settings use wp_list_customizer_settings and wp_update_customizer_setting.' );
    635666                }
    636667                if ( ! $setting->check_capabilities() ) {
    637668                        throw new Frontman_Tool_Error( 'You cannot edit this Customizer setting.' );
    638669                }
    639                 $valid = $setting->validate( $input['value'] );
    640                 if ( is_wp_error( $valid ) ) {
    641                         throw new Frontman_Tool_Error( $valid->get_error_message() );
    642                 }
    643                 $value = $setting->sanitize( $input['value'] );
    644                 if ( null === $value || is_wp_error( $value ) ) {
    645                         throw new Frontman_Tool_Error( 'Customizer setting rejected the value.' );
    646                 }
    647                 $mods   = get_theme_mods();
    648                 $before = $mods[ $current['name'] ] ?? null;
    649                 $wp_customize->set_post_value( $current['name'], $input['value'] );
    650                 $filter = 'pre_set_theme_mod_' . $current['name'];
    651                 $observe = static function( $filtered ) use ( &$value ) {
    652                         $value = $filtered;
    653                         return $filtered;
    654                 };
    655                 add_filter( $filter, $observe, PHP_INT_MAX );
    656                 try {
    657                         do_action( 'customize_save', $wp_customize );
    658                         if ( false === $setting->save() ) {
    659                                 throw new Frontman_Tool_Error( 'Customizer setting could not be saved. Read current state before retrying.' );
    660                         }
    661                         do_action( 'customize_save_after', $wp_customize );
    662                 } finally {
    663                         remove_filter( $filter, $observe, PHP_INT_MAX );
    664                 }
    665                 $mods  = get_theme_mods();
    666                 $after = $mods[ $current['name'] ] ?? null;
    667                 if ( $after !== $value ) {
    668                         throw new Frontman_Tool_Error( 'Theme mod write could not be verified. Read current state before retrying.' );
    669                 }
     670                $state = $this->customizer_state( $setting );
     671                $result = $this->save_customizer_setting( $wp_customize, $setting, $input['value'], $current['stylesheet'], $state );
    670672
    671673                return [
    672674                        'name'       => $current['name'],
    673675                        'stylesheet' => $current['stylesheet'],
    674                         'updated'    => $before !== $after,
    675                         'before'     => $before,
    676                         'after'      => $after,
    677                 ];
     676                        'updated'    => ( $state['present'] ? $result['before'] : null ) !== $result['after'],
     677                        'before'     => $state['present'] ? $result['before'] : null,
     678                        'after'      => $result['after'],
     679                ];
     680        }
     681
     682        /**
     683         * Initialize the same registration hooks used by the Customizer.
     684         *
     685         * Astra skips two registration hooks on frontend requests. Its public
     686         * callbacks provide the configuration and register the actual settings.
     687         */
     688        private function customizer_manager() {
     689                if ( ! current_user_can( 'edit_theme_options' ) ) {
     690                        throw new Frontman_Tool_Error( 'You cannot edit theme options.' );
     691                }
     692                global $wp_customize;
     693                if ( $wp_customize instanceof \WP_Customize_Manager ) {
     694                        if ( $wp_customize->settings_previewed() || $wp_customize->is_preview() ) {
     695                                throw new Frontman_Tool_Error( 'Customizer preview managers are not supported; use a non-preview request.' );
     696                        }
     697                        if ( $this->is_astra_theme() && ( ! class_exists( 'Astra_Customizer' ) || false === has_filter( 'customize_dynamic_setting_args', [ \Astra_Customizer::get_instance(), 'filter_dynamic_setting_args' ] ) ) ) {
     698                                throw new Frontman_Tool_Error( 'Astra Customizer was initialized without its native settings. Use a fresh request to discover settings.' );
     699                        }
     700                        $this->register_astra_dynamic_settings( $wp_customize );
     701                        return $wp_customize;
     702                }
     703                if ( ! class_exists( '\WP_Customize_Manager' ) ) {
     704                        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     705                }
     706                $wp_customize = new \WP_Customize_Manager( [ 'settings_previewed' => false ] );
     707                if ( $this->is_astra_theme() && class_exists( 'Astra_Customizer' ) ) {
     708                        $astra = \Astra_Customizer::get_instance();
     709                        foreach ( [ 'include_configurations' => 1, 'prepare_customizer_javascript_configs' => 10, 'prepare_group_configs' => 9 ] as $method => $priority ) {
     710                                if ( is_callable( [ $astra, $method ] ) && false === has_action( 'customize_register', [ $astra, $method ] ) ) {
     711                                        add_action( 'customize_register', [ $astra, $method ], $priority );
     712                                }
     713                        }
     714                        if ( is_callable( [ $astra, 'filter_dynamic_setting_args' ] ) && false === has_filter( 'customize_dynamic_setting_args', [ $astra, 'filter_dynamic_setting_args' ] ) ) {
     715                                add_filter( 'customize_dynamic_setting_args', [ $astra, 'filter_dynamic_setting_args' ], 10, 2 );
     716                        }
     717                }
     718                remove_action( 'customize_register', [ $wp_customize, 'register_controls' ] );
     719                $wp_customize->register_controls();
     720                do_action( 'customize_register', $wp_customize );
     721                $this->register_astra_dynamic_settings( $wp_customize );
     722                return $wp_customize;
     723        }
     724
     725        private function register_astra_dynamic_settings( $manager ): void {
     726                if ( ! $this->is_astra_theme() || ! class_exists( 'Astra_Customizer' ) ) {
     727                        return;
     728                }
     729                $cache = get_option( 'astra_partials_config_cache', [] );
     730                $entries = is_array( $cache ) && isset( $cache['settings'] ) && is_array( $cache['settings'] ) ? $cache['settings'] : [];
     731                $ids = [];
     732                foreach ( array_keys( $entries ) as $id ) {
     733                        if ( is_string( $id ) && preg_match( '/\Aastra-settings\[[^\[\]\/\\\\\x00-\x1f]+\]\z/', $id ) ) {
     734                                $ids[] = $id;
     735                        }
     736                }
     737                if ( count( $ids ) > 2000 ) {
     738                        throw new Frontman_Tool_Error( 'Astra Customizer registration exceeds the supported discovery bound.' );
     739                }
     740                $manager->add_dynamic_settings( $ids );
     741        }
     742
     743        private function is_astra_theme(): bool {
     744                return function_exists( 'get_template' ) && 'astra' === get_template();
     745        }
     746
     747        private function supported_customizer_setting( $setting ): bool {
     748                if ( ! $setting || ! $setting->check_capabilities() ) {
     749                        return false;
     750                }
     751                $data = $setting->id_data();
     752                if ( 'theme_mod' === $setting->type ) {
     753                        return empty( $data['keys'] );
     754                }
     755                return 'option' === $setting->type && $this->is_astra_theme()
     756                        && 'astra-settings' === $data['base'] && 1 === count( $data['keys'] )
     757                        && is_string( $data['keys'][0] ) && '' !== $data['keys'][0];
     758        }
     759
     760        private function customizer_setting( $manager, $id ) {
     761                if ( ! is_string( $id ) || '' === $id || strlen( $id ) > 256 || $this->has_control_or_path_separator( $id ) ) {
     762                        throw new Frontman_Tool_Error( 'id must be a valid registered Customizer setting ID.' );
     763                }
     764                $setting = $manager->get_setting( $id );
     765                if ( ! $this->supported_customizer_setting( $setting ) ) {
     766                        throw new Frontman_Tool_Error( 'Setting is not registered, accessible, or supported. Use wp_list_customizer_settings to discover supported IDs.' );
     767                }
     768                return $setting;
     769        }
     770
     771        private function customizer_state( $setting ): array {
     772                global $wpdb;
     773                $data = $setting->id_data();
     774                $name = 'option' === $setting->type ? $data['base'] : 'theme_mods_' . $this->active_stylesheet();
     775                $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
     776                if ( '' !== $wpdb->last_error ) {
     777                        throw new Frontman_Tool_Error( 'Could not read persisted Customizer state. Do not retry a save automatically.' );
     778                }
     779                $root = $row ? maybe_unserialize( $row->option_value ) : null;
     780                $key = 'option' === $setting->type ? $data['keys'][0] : $data['base'];
     781                $present = is_array( $root ) && array_key_exists( $key, $root );
     782                $stored = $present ? $root[ $key ] : null;
     783                $value = $present ? $stored : $setting->default;
     784                return [
     785                        'value' => $value,
     786                        'present' => $present,
     787                        'fingerprint' => hash( 'sha256', serialize( [ $this->active_stylesheet(), $setting->id, $present, $stored ] ) ),
     788                ];
     789        }
     790
     791        private function assert_astra_aggregate_current( $setting ): void {
     792                if ( 'option' !== $setting->type ) {
     793                        return;
     794                }
     795                global $wpdb;
     796                $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'astra-settings' ) );
     797                if ( '' !== $wpdb->last_error ) {
     798                        throw new \RuntimeException( 'Could not read the Astra option root.' );
     799                }
     800                $root = $row ? maybe_unserialize( $row->option_value ) : [];
     801                $property = new \ReflectionProperty( \WP_Customize_Setting::class, 'aggregated_multidimensionals' );
     802                $property->setAccessible( true );
     803                $aggregates = $property->getValue();
     804                if ( ! isset( $aggregates['option']['astra-settings']['root_value'] ) || $root !== $aggregates['option']['astra-settings']['root_value'] ) {
     805                        throw new \RuntimeException( 'Astra option root changed since Customizer registration; use a fresh request and reread settings.' );
     806                }
     807        }
     808
     809        public function list_customizer_settings( array $input ): array {
     810                $search = $input['search'] ?? '';
     811                $offset = $input['offset'] ?? 0;
     812                $limit = $input['limit'] ?? 50;
     813                if ( ! is_string( $search ) || strlen( $search ) > 256 || ! is_int( $offset ) || $offset < 0 || ! is_int( $limit ) || $limit < 1 || $limit > 100 ) {
     814                        throw new Frontman_Tool_Error( 'Invalid search, offset, or limit (1–100).' );
     815                }
     816                $manager = $this->customizer_manager();
     817                $settings = [];
     818                foreach ( $manager->settings() as $setting ) {
     819                        if ( $this->supported_customizer_setting( $setting ) && ( '' === $search || false !== stripos( $setting->id, $search ) ) ) {
     820                                $settings[] = [ 'id' => $setting->id, 'type' => $setting->type ];
     821                        }
     822                }
     823                usort( $settings, static function( $a, $b ) { return strcmp( $a['id'], $b['id'] ); } );
     824                return [
     825                        'stylesheet' => $this->active_stylesheet(),
     826                        'settings' => array_slice( $settings, $offset, $limit ),
     827                        'next_offset' => $offset + $limit < count( $settings ) ? $offset + $limit : null,
     828                ];
     829        }
     830
     831        public function get_customizer_setting( array $input ): array {
     832                $manager = $this->customizer_manager();
     833                $setting = $this->customizer_setting( $manager, $input['id'] ?? null );
     834                return array_merge(
     835                        [ 'stylesheet' => $this->active_stylesheet(), 'id' => $setting->id, 'type' => $setting->type ],
     836                        $this->customizer_state( $setting )
     837                );
     838        }
     839
     840        public function update_customizer_setting( array $input ): array {
     841                if ( true !== ( $input['confirm'] ?? false ) ) {
     842                        throw new Frontman_Tool_Error( 'Customizer update requires confirm=true after user approval.' );
     843                }
     844                $manager = $this->customizer_manager();
     845                $stylesheet = $this->active_stylesheet();
     846                if ( '' === $stylesheet || ( $input['stylesheet'] ?? null ) !== $stylesheet ) {
     847                        throw new Frontman_Tool_Error( 'stylesheet must match the active theme. Read the setting again.' );
     848                }
     849                if ( ! array_key_exists( 'value', $input ) || null === $input['value'] || is_object( $input['value'] ) ) {
     850                        throw new Frontman_Tool_Error( 'value is required and must be a JSON scalar, array or object decoded as an array.' );
     851                }
     852                $setting = $this->customizer_setting( $manager, $input['id'] ?? null );
     853                $before = $this->customizer_state( $setting );
     854                $expected = $input['expected_fingerprint'] ?? null;
     855                if ( ! is_string( $expected ) || ! preg_match( '/\A[a-f0-9]{64}\z/', $expected ) || ! hash_equals( $before['fingerprint'], $expected ) ) {
     856                        throw new Frontman_Tool_Error( "Setting {$setting->id} changed or has an invalid fingerprint. Read it again; no writes were attempted." );
     857                }
     858                return $this->save_customizer_setting( $manager, $setting, $input['value'], $stylesheet, $before );
     859        }
     860
     861        private function save_customizer_setting( $manager, $setting, $value, string $stylesheet, array $before ): array {
     862                $manager->set_post_value( $setting->id, $value );
     863                try {
     864                        $valid = $setting->validate( $value );
     865                        if ( is_wp_error( $valid ) ) {
     866                                throw new Frontman_Tool_Error( $valid->get_error_message() );
     867                        }
     868                        $expected = $setting->sanitize( $value );
     869                        if ( null === $expected || is_wp_error( $expected ) ) {
     870                                throw new Frontman_Tool_Error( 'Customizer setting rejected the value; no writes were attempted.' );
     871                        }
     872                        if ( $stylesheet !== $this->active_stylesheet() || $before['fingerprint'] !== $this->customizer_state( $setting )['fingerprint'] ) {
     873                                throw new Frontman_Tool_Error( 'Theme or setting changed during validation; no writes were attempted.' );
     874                        }
     875                        try {
     876                                $this->assert_astra_aggregate_current( $setting );
     877                        } catch ( \RuntimeException $e ) {
     878                                throw new Frontman_Tool_Error( $e->getMessage() );
     879                        }
     880
     881                        $data = $setting->id_data();
     882                        $filter = 'pre_set_theme_mod_' . $data['base'];
     883                        $observe = static function( $filtered ) use ( &$expected ) {
     884                                $expected = $filtered;
     885                                return $filtered;
     886                        };
     887                        if ( 'theme_mod' === $setting->type ) {
     888                                add_filter( $filter, $observe, PHP_INT_MAX );
     889                        }
     890                        try {
     891                                do_action( 'customize_save', $manager );
     892                                if ( $stylesheet !== $this->active_stylesheet() ) {
     893                                        throw new \RuntimeException( 'Active stylesheet changed during save.' );
     894                                }
     895                                $this->assert_astra_aggregate_current( $setting );
     896                                if ( false === $setting->save() ) {
     897                                        throw new \RuntimeException( 'Customizer setting save failed.' );
     898                                }
     899                                do_action( 'customize_save_after', $manager );
     900                                $after = $this->customizer_state( $setting );
     901                                if ( $stylesheet !== $this->active_stylesheet() || ! $after['present'] || $after['value'] !== $expected ) {
     902                                        throw new \RuntimeException( 'Persisted Customizer value could not be verified.' );
     903                                }
     904                        } catch ( \Throwable $e ) {
     905                                throw new Frontman_Tool_Error( $e->getMessage() . ' The setting may have been saved. Reread current state; do not retry automatically.' );
     906                        } finally {
     907                                if ( 'theme_mod' === $setting->type ) {
     908                                        remove_filter( $filter, $observe, PHP_INT_MAX );
     909                                }
     910                        }
     911                        return [
     912                                'id' => $setting->id,
     913                                'stylesheet' => $stylesheet,
     914                                'before' => $before['value'],
     915                                'after' => $after['value'],
     916                                'updated' => $before['value'] !== $after['value'],
     917                        ];
     918                } finally {
     919                        $manager->set_post_value( $setting->id, null );
     920                }
    678921        }
    679922
  • frontman-agentic-ai-editor/trunk/tools/class-tool-posts.php

    r3767236 r3783407  
    4949                                                'default'     => 'post',
    5050                                        ],
     51                                        'lang' => [
     52                                                'type' => 'string',
     53                                                'description' => 'Optional Polylang language slug to filter posts. Requires active Polylang and a configured language.',
     54                                        ],
    5155                                        'status'    => [
    5256                                                'type'        => 'string',
    … …  
    9599                                        'id' => [
    96100                                                'type'        => 'integer',
     101                                                'minimum'     => 1,
    97102                                                'description' => 'The post ID to read.',
    98103                                        ],
    … …  
    100105                                'required' => [ 'id' ],
    101106                        ],
    102                         [ $this, 'read_post' ]
     107                        [ $this, 'read_post' ],
     108                        null,
     109                        true,
     110                        false,
     111                        static fn( \stdClass $input ): bool => true
    103112                ) );
    104113
    … …  
    234243
    235244                $tools->add( new Frontman_Tool_Definition(
     245                        'wp_save_post_preview',
     246                        'Saves proposed content as a native autosave for a published post or page without changing the published content. Open the returned preview_url while signed in as the same user. Existing differing autosaves are preserved and cause an error. After approval use wp_update_post to publish; preview URLs are temporary.',
     247                        [
     248                                'type' => 'object',
     249                                'additionalProperties' => false,
     250                                'properties' => [
     251                                        'id' => [ 'type' => 'integer', 'minimum' => 1, 'description' => 'Published post or page ID.' ],
     252                                        'content' => [ 'type' => 'string', 'description' => 'Complete proposed HTML or block markup.' ],
     253                                ],
     254                                'required' => [ 'id', 'content' ],
     255                        ],
     256                        [ $this, 'save_post_preview' ],
     257                        'read-write',
     258                        true,
     259                        true
     260                ) );
     261
     262                $tools->add( new Frontman_Tool_Definition(
    236263                        'wp_delete_post',
    237264                        'Deletes a post or page. Ask the user for confirmation first and only call this tool with confirm=true after they approve. By default moves to trash; set force=true to permanently delete.',
    … …  
    258285                        [ $this, 'delete_post' ]
    259286                ) );
     287        }
     288
     289        /** Save only a revision: core's preview handler reads the latest autosave across authors. */
     290        public function save_post_preview( array $input ): array {
     291                if ( ! isset( $input['id'] ) || ! is_int( $input['id'] ) || $input['id'] <= 0 ) {
     292                        throw new Frontman_Tool_Error( 'id must be a positive integer.' );
     293                }
     294                if ( ! array_key_exists( 'content', $input ) ) {
     295                        throw new Frontman_Tool_Error( 'content is required and must be a string.' );
     296                }
     297                $post = get_post( $input['id'] );
     298                if ( ! $post ) {
     299                        throw new Frontman_Tool_Error( 'Post not found.' );
     300                }
     301                $this->check_write_permissions( [], $post->post_type, $post );
     302                $type = get_post_type_object( $post->post_type );
     303                if ( 'publish' !== $post->post_status || ! $type || ! is_post_type_viewable( $type ) || ! post_type_supports( $post->post_type, 'revisions' ) ) {
     304                        throw new Frontman_Tool_Error( 'Preview requires a published, publicly viewable post type with revision support.' );
     305                }
     306                $this->check_content( $input );
     307                if ( ( class_exists( 'Frontman_Elementor_Data' ) && Frontman_Elementor_Data::post_uses_elementor( $post->ID ) )
     308                        || get_post_meta( $post->ID, '_et_pb_use_builder', true ) === 'on'
     309                        || get_post_meta( $post->ID, '_fl_builder_enabled', true )
     310                        || get_post_meta( $post->ID, '_bricks_page_content_2', true ) ) {
     311                        throw new Frontman_Tool_Error( 'Builder-managed content cannot be previewed with this tool.' );
     312                }
     313
     314                $autosaves = get_posts( [
     315                        'post_type' => 'revision', 'post_status' => 'inherit', 'post_parent' => $post->ID,
     316                        'name' => $post->ID . '-autosave-v1', 'posts_per_page' => -1,
     317                ] );
     318                foreach ( $autosaves as $autosave ) {
     319                        if ( $autosave->post_content !== $input['content'] || $autosave->post_title !== $post->post_title || $autosave->post_excerpt !== $post->post_excerpt ) {
     320                                throw new Frontman_Tool_Error( 'A differing editor autosave exists. It was preserved; resolve it in WordPress before previewing.' );
     321                        }
     322                }
     323                $autosave = wp_get_post_autosave( $post->ID );
     324                if ( ! $autosave ) {
     325                        $data = get_post( $post->ID, ARRAY_A );
     326                        $data['post_content'] = $input['content'];
     327                        $id = _wp_put_post_revision( $data, true );
     328                        if ( is_wp_error( $id ) || ! $id ) {
     329                                throw new Frontman_Tool_Error( is_wp_error( $id ) ? $id->get_error_message() : 'Could not save the autosave.' );
     330                        }
     331                        $autosave = get_post( $id );
     332                        if ( ! $autosave || $autosave->post_content !== $input['content'] || $autosave->post_title !== $post->post_title || $autosave->post_excerpt !== $post->post_excerpt ) {
     333                                throw new Frontman_Tool_Error( "Autosave {$id} was saved, but readback differs. It was not rolled back; inspect it in WordPress." );
     334                        }
     335                }
     336                $autosaves = get_posts( [
     337                        'post_type' => 'revision', 'post_status' => 'inherit', 'post_parent' => $post->ID,
     338                        'name' => $post->ID . '-autosave-v1', 'posts_per_page' => -1,
     339                ] );
     340                $selected = wp_get_post_autosave( $post->ID );
     341                if ( ! $selected ) {
     342                        throw new Frontman_Tool_Error( 'The preview autosave is no longer available; inspect autosaves in WordPress.' );
     343                }
     344                $autosaves[] = $selected;
     345                foreach ( $autosaves as $candidate ) {
     346                        if ( $candidate->post_content !== $input['content'] || $candidate->post_title !== $post->post_title || $candidate->post_excerpt !== $post->post_excerpt ) {
     347                                throw new Frontman_Tool_Error( 'A differing editor autosave exists. It was preserved; a new autosave may also have been created. Resolve autosaves in WordPress before previewing.' );
     348                        }
     349                }
     350                $autosave = $selected;
     351                return [
     352                        'parent_id' => $post->ID,
     353                        'autosave_id' => $autosave->ID,
     354                        'preview_url' => get_preview_post_link( $post, [
     355                                'preview_id' => $post->ID,
     356                                'preview_nonce' => wp_create_nonce( 'post_preview_' . $post->ID ),
     357                        ] ),
     358                ];
    260359        }
    261360
    … …  
    273372                ];
    274373
     374                if ( array_key_exists( 'lang', $input ) ) {
     375                        if ( ! function_exists( 'pll_languages_list' ) || ! in_array( $input['lang'], pll_languages_list( [ 'hide_empty' => false, 'fields' => 'slug' ] ), true ) ) {
     376                                throw new Frontman_Tool_Error( 'lang must be a configured Polylang language slug; Polylang must be active.' );
     377                        }
     378                        if ( ! function_exists( 'pll_is_translated_post_type' ) || ! pll_is_translated_post_type( $args['post_type'] ) ) {
     379                                throw new Frontman_Tool_Error( 'Polylang does not manage the requested post type; language filtering is unavailable.' );
     380                        }
     381                        $args['lang'] = $input['lang'];
     382                }
    275383                if ( ! empty( $input['search'] ) ) {
    276384                        $args['s'] = sanitize_text_field( $input['search'] );
    … …  
    290398                                'modified' => $post->post_modified,
    291399                                'excerpt'  => wp_trim_words( $post->post_content, 30 ),
     400                                'lang'     => function_exists( 'pll_get_post_language' ) ? ( pll_get_post_language( $post->ID, 'slug' ) ?: null ) : null,
    292401                        ];
    293402                }
  • frontman-agentic-ai-editor/trunk/tools/class-tool-seo.php

    r3767236 r3783407  
    11<?php
    22/**
    3  * Provider-neutral SEO tools for the runtime-validated Yoast route.
     3 * Stored SEO inspection and runtime-validated Yoast updates.
    44 * @package Frontman
    55 */
    … …  
    1010class Frontman_Tool_Seo {
    1111        private const YOAST = 'wordpress-seo/wp-seo.php';
     12        private const SURERANK = 'surerank/surerank.php';
    1213        private const META = [ 'title' => 'title', 'description' => 'metadesc', 'focus_keyphrase' => 'focuskw' ];
     14        private const SURERANK_META = [
     15                'title' => 'surerank_settings_page_title',
     16                'description' => 'surerank_settings_page_description',
     17                'focus_keyphrase' => 'surerank_settings_focus_keyword',
     18        ];
    1319        public function register( Frontman_Tools $tools ): void {
     20                if ( null !== self::read_unavailable_reason() ) {
     21                        return;
     22                }
    1423                $schema = [
    1524                        'type'                 => 'object',
    … …  
    2029                $tools->add( new Frontman_Tool_Definition(
    2130                        'wp_read_seo',
    22                         'Reads stored SEO title, description, and focus keyphrase for an editable post.',
     31                        'Reads stored SEO title, description, and focus keyphrase for an editable post, not computed tags.',
    2332                        $schema,
    2433                        [ $this, 'read_seo' ],
    2534                        'read'
    2635                ) );
     36                if ( null !== self::unavailable_reason() ) {
     37                        return;
     38                }
    2739                $schema['properties'] += [
    2840                        'title'       => [ 'type' => 'string', 'description' => 'SEO title override.' ],
    … …  
    3042                        'focus_keyphrase' => [ 'type' => 'string', 'description' => 'Single Yoast focus keyphrase, not secondary keywords.' ],
    3143                ];
    32                 $schema['anyOf'] = array_map( static fn( string $field ): array => [ 'required' => [ $field ] ], array_keys( self::META ) );
    3344                $tools->add( new Frontman_Tool_Definition(
    3445                        'wp_update_seo',
    35                         'Updates SEO overrides or the focus keyphrase. Omitted fields stay unchanged; empty strings restore provider defaults.',
     46                        'Updates SEO overrides or the focus keyphrase. Provide at least one of title, description, or focus_keyphrase. Omitted fields stay unchanged; empty strings restore provider defaults.',
    3647                        $schema,
    3748                        [ $this, 'update_seo' ],
    … …  
    3950                ) );
    4051        }
     52        public static function read_unavailable_reason(): ?string {
     53                return self::compatibility_reason( false );
     54        }
    4155        public static function unavailable_reason(): ?string {
     56                return self::compatibility_reason( true );
     57        }
     58        public static function capabilities(): array {
     59                $read_reason = self::read_unavailable_reason();
     60                $write_reason = self::unavailable_reason();
     61                return [
     62                        'provider' => null === $read_reason ? self::provider() : null,
     63                        'read' => null === $read_reason,
     64                        'write' => null === $write_reason,
     65                        'supported_fields' => null === $read_reason ? array_keys( self::META ) : [],
     66                        'read_unavailable_reason' => $read_reason,
     67                        'write_unavailable_reason' => $write_reason,
     68                ];
     69        }
     70        private static function provider(): string {
     71                return Frontman_Plugin_Dependencies::is_available( self::SURERANK ) ? 'surerank' : 'yoast';
     72        }
     73        private static function compatibility_reason( bool $write ): ?string {
    4274                global $wp_version;
    43                 if ( ! Frontman_Plugin_Dependencies::is_available( self::YOAST ) || ! defined( 'WPSEO_VERSION' ) ) {
    44                         return __( 'Yoast SEO Free is absent or not loaded.', 'frontman-agentic-ai-editor' );
    45                 }
    46                 if ( Frontman_Plugin_Dependencies::is_available( 'wordpress-seo-premium/wp-seo-premium.php' )
     75                $yoast = Frontman_Plugin_Dependencies::is_available( self::YOAST );
     76                $surerank = Frontman_Plugin_Dependencies::is_available( self::SURERANK );
     77                if ( ! $yoast && ! $surerank ) {
     78                        return __( 'Yoast SEO Free and SureRank are absent or not loaded.', 'frontman-agentic-ai-editor' );
     79                }
     80                if ( ( $yoast && $surerank )
     81                        || Frontman_Plugin_Dependencies::is_available( 'wordpress-seo-premium/wp-seo-premium.php' )
    4782                        || Frontman_Plugin_Dependencies::is_available( 'seo-by-rank-math/rank-math.php', 'RankMath' )
    4883                        || Frontman_Plugin_Dependencies::is_available( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ) || function_exists( 'aioseo' )
    … …  
    5489                        return __( 'SEO tools require WordPress 6.9 or later.', 'frontman-agentic-ai-editor' );
    5590                }
     91                if ( $surerank ) {
     92                        return $write
     93                                ? __( 'SureRank SEO support is read-only; updates are not available.', 'frontman-agentic-ai-editor' )
     94                                : self::surerank_unavailable_reason();
     95                }
     96                if ( ! defined( 'WPSEO_VERSION' ) ) {
     97                        return __( 'Yoast SEO Free is absent or not loaded.', 'frontman-agentic-ai-editor' );
     98                }
    5699                $runtime_reason = __( 'Yoast SEO runtime methods, initialization, or metadata sanitizers are unavailable.', 'frontman-agentic-ai-editor' );
    57                 if ( did_action( 'wpseo_loaded' ) < 1 || ! function_exists( 'sanitize_meta' ) ) {
     100                if ( did_action( 'wpseo_loaded' ) < 1 || ( $write && ! function_exists( 'sanitize_meta' ) ) ) {
    58101                        return $runtime_reason;
    59102                }
    60                 foreach ( [ 'get_value', 'set_value', 'delete', 'sanitize_post_meta' ] as $method ) {
     103                foreach ( $write ? [ 'get_value', 'set_value', 'delete', 'sanitize_post_meta' ] : [ 'get_value' ] as $method ) {
    61104                        if ( ! is_callable( [ 'WPSEO_Meta', $method ] ) ) {
    62105                                return $runtime_reason;
    63106                        }
     107                }
     108                if ( ! $write ) {
     109                        return null;
    64110                }
    65111                $registered = get_registered_meta_keys( 'post' );
    … …  
    73119                return null;
    74120        }
     121        private static function surerank_unavailable_reason(): ?string {
     122                if ( ! defined( 'SURERANK_VERSION' )
     123                        || ! is_callable( [ '\SureRank\Inc\Functions\Get', 'post_meta' ] ) ) {
     124                        return __( 'SureRank SEO runtime metadata reader is unavailable.', 'frontman-agentic-ai-editor' );
     125                }
     126                return null;
     127        }
     128        private function surerank_snapshot( int $id ): array {
     129                $values = [];
     130                try {
     131                        foreach ( self::SURERANK_META as $field => $key ) {
     132                                $values[ $field ] = \SureRank\Inc\Functions\Get::post_meta( $id, $key, true );
     133                        }
     134                } catch ( \Throwable $error ) {
     135                        throw new Frontman_Tool_Error( __( 'SureRank could not read the stored SEO overrides.', 'frontman-agentic-ai-editor' ) );
     136                }
     137                foreach ( $values as $value ) {
     138                        if ( ! is_string( $value ) || 1 !== preg_match( '//u', $value ) ) {
     139                                throw new Frontman_Tool_Error( __( 'SureRank returned invalid SEO overrides.', 'frontman-agentic-ai-editor' ) );
     140                        }
     141                }
     142                return $values;
     143        }
    75144        public static function validate_input( string $tool, array $input ): array {
    76145                $allowed = 'wp_read_seo' === $tool ? [ 'id' ] : array_merge( [ 'id' ], array_keys( self::META ) );
    … …  
    93162        public function read_seo( array $input ): array {
    94163                $input = self::validate_input( 'wp_read_seo', $input );
    95                 $this->guard( $input['id'] );
    96                 return [ 'id' => $input['id'], 'provider' => 'yoast' ] + $this->snapshot( $input['id'] );
     164                $this->guard( $input['id'], false );
     165                return [ 'id' => $input['id'], 'provider' => self::provider() ] + $this->snapshot( $input['id'] );
    97166        }
    98167        public function update_seo( array $input ): array {
    … …  
    146215                ];
    147216        }
    148         private function guard( int $id ): string {
    149                 $reason = self::unavailable_reason();
     217        private function guard( int $id, bool $write = true ): string {
     218                $reason = $write ? self::unavailable_reason() : self::read_unavailable_reason();
    150219                if ( null !== $reason ) {
    151220                        throw new Frontman_Tool_Error( $reason );
    … …  
    161230                        throw new Frontman_Tool_Error( __( 'You cannot edit this post.', 'frontman-agentic-ai-editor' ) );
    162231                }
    163                 foreach ( self::META as $key ) {
    164                         if ( ! current_user_can( 'edit_post_meta', $id, '_yoast_wpseo_' . $key ) ) {
     232                $keys = 'surerank' === self::provider() ? self::SURERANK_META : array_map( static fn( string $key ): string => '_yoast_wpseo_' . $key, self::META );
     233                foreach ( $keys as $key ) {
     234                        if ( ! current_user_can( 'edit_post_meta', $id, $key ) ) {
    165235                                throw new Frontman_Tool_Error( __( 'You cannot edit this post SEO metadata.', 'frontman-agentic-ai-editor' ) );
    166236                        }
    … …  
    169239        }
    170240        private function snapshot( int $id ): array {
     241                if ( 'surerank' === self::provider() ) {
     242                        return $this->surerank_snapshot( $id );
     243                }
    171244                $values = [];
    172245                try {
  • frontman-agentic-ai-editor/trunk/tools/class-tool-templates.php

    r3767236 r3783407  
    225225                        ],
    226226                        'seo_unavailable_reason' => Frontman_Tool_Seo::unavailable_reason(),
     227                        'seo_capabilities' => Frontman_Tool_Seo::capabilities(),
    227228                        'plugins'      => $plugin_info,
    228229                        'post_types'   => $pt_list,
  • frontman-agentic-ai-editor/trunk/tools/class-tool-users.php

    r3686780 r3783407  
    2020                                'properties' => [
    2121                                        'search' => [
    22                                                 'type' => 'string', 'minLength' => 1, 'maxLength' => 100,
     22                                                'type' => 'string', 'minLength' => 1,
    2323                                                'description' => 'Trimmed nonempty literal substring, at most 100 Unicode characters. Must not start or end with * after whitespace trimming (including asterisk-only input). Interior *, %, _, and quotes are literal. Case sensitivity depends on database collation.',
    2424                                        ],
  • frontman-agentic-ai-editor/trunk/tools/class-tool-widgets.php

    r3758369 r3783407  
    2424        public function register( Frontman_Tools $tools ): void {
    2525                $tools->add( new Frontman_Tool_Definition(
     26                        'wp_set_widget_visibility',
     27                        'Sets a per-widget WooCommerce visibility rule: all clears the rule; exclude_checkout preserves other routes; cart_only shows only on cart, never checkout. Uses semantic WooCommerce predicates, not URLs. Read back and verify the storefront.',
     28                        [
     29                                'type' => 'object',
     30                                'additionalProperties' => false,
     31                                'properties' => [
     32                                        'widget_id' => [ 'type' => 'string' ],
     33                                        'visibility' => [ 'type' => 'string', 'enum' => [ 'all', 'exclude_checkout', 'cart_only' ] ],
     34                                ],
     35                                'required' => [ 'widget_id', 'visibility' ],
     36                        ],
     37                        [ $this, 'set_widget_visibility' ]
     38                ) );
     39                $tools->add( new Frontman_Tool_Definition(
    2640                        'wp_list_widget_areas',
    2741                        'Lists all registered widget areas (sidebars) and their active widget IDs.',
    … …  
    7084                $tools->add( new Frontman_Tool_Definition(
    7185                        'wp_update_widget',
    72                         'Updates a text, custom_html, or block widget without moving it. Text accepts title/text strings and filter/visual booleans; custom_html accepts title/content strings; block requires content containing the complete replacement markup, including all blocks. Omitted settings and plugin metadata are preserved. Read back and verify the rendered page.',
     86                        'Updates text, custom_html, block, or an existing woocommerce_recently_viewed_products widget without moving it. Recently viewed accepts title string and number integer (1–15); empty browsing history produces no output. Text accepts title/text strings and filter/visual booleans; custom_html accepts title/content strings; block requires complete replacement content. Omitted settings and metadata are preserved. Verify the rendered page in the same browsing session.',
    7387                        [
    7488                                'type'                 => 'object',
    … …  
    176190
    177191        private function sanitized_settings( string $widget_base, $raw ): array {
    178                 $this->assert_mutation_supported( $widget_base );
     192                if ( 'woocommerce_recently_viewed_products' !== $widget_base ) {
     193                        $this->assert_mutation_supported( $widget_base );
     194                }
    179195                $settings = is_string( $raw ) ? json_decode( $raw ) : null;
    180196                if ( ! $settings instanceof \stdClass ) {
    … …  
    182198                }
    183199                $settings = get_object_vars( $settings );
     200                if ( 'woocommerce_recently_viewed_products' === $widget_base ) {
     201                        foreach ( $settings as $key => $value ) {
     202                                if ( 'title' === $key && is_string( $value ) ) {
     203                                        $settings[ $key ] = sanitize_text_field( $value );
     204                                } elseif ( 'number' !== $key || ! is_int( $value ) || $value < 1 || $value > 15 ) {
     205                                        throw new Frontman_Tool_Error( 'Recently viewed accepts only title (string) and number (integer, 1–15).' );
     206                                }
     207                        }
     208                        return $settings;
     209                }
    184210                switch ( $widget_base ) {
    185211                        case 'text':
    … …  
    263289                        'sidebar_id' => $map[ $widget_id ]['sidebar_id'] ?? null,
    264290                        'position'   => $map[ $widget_id ]['position'] ?? null,
     291                        'visibility'=> get_option( 'frontman_widget_visibility', [] )[ $widget_id ] ?? 'all',
    265292                ];
    266293        }
    … …  
    273300                $sidebar_id  = sanitize_key( $input['sidebar_id'] ?? '' );
    274301                $widget_base = sanitize_key( $input['widget_base'] ?? '' );
     302                $this->assert_mutation_supported( $widget_base );
    275303                $settings    = $this->sanitized_settings( $widget_base, $input['settings'] ?? null );
    276304                $before      = $this->sidebar_snapshot( $sidebar_id );
    … …  
    331359                $all_settings = get_option( $option, [] );
    332360                $all_settings[ $parts['number'] ] = array_merge( $widget['settings'], $settings );
     361                if ( 'woocommerce_recently_viewed_products' === $parts['base'] ) {
     362                        global $wp_widget_factory;
     363                        $native = $wp_widget_factory->widgets['WC_Widget_Recently_Viewed'] ?? null;
     364                        if ( ! $native instanceof \WC_Widget_Recently_Viewed ) {
     365                                throw new Frontman_Tool_Error( 'The native WooCommerce recently viewed widget is not available.' );
     366                        }
     367                        $native->_set( $parts['number'] );
     368                        $new_instance = array_merge( [ 'title' => '', 'number' => 10 ], $all_settings[ $parts['number'] ] );
     369                        $instance = $native->update( $new_instance, $widget['settings'] );
     370                        $instance = apply_filters( 'widget_update_callback', $instance, $new_instance, $widget['settings'], $native );
     371                        if ( ! is_array( $instance ) ) {
     372                                throw new Frontman_Tool_Error( 'The native widget update was rejected.' );
     373                        }
     374                        $all_settings[ $parts['number'] ] = $instance;
     375                }
    333376                $this->save_settings( $option, $all_settings );
    334377
    … …  
    339382                        'settings'  => $this->read_widget( [ 'widget_id' => $widget_id ] )['settings'],
    340383                ];
     384        }
     385
     386        /**
     387         * Persist a bounded, reversible rule independently of native widget settings.
     388         */
     389        public function set_widget_visibility( array $input ): array {
     390                $this->assert_can_edit_widgets();
     391                $widget = $this->read_widget( $input );
     392                $rule = $input['visibility'] ?? null;
     393                if ( ! in_array( $rule, [ 'all', 'exclude_checkout', 'cart_only' ], true ) ) {
     394                        throw new Frontman_Tool_Error( 'Invalid widget visibility rule.' );
     395                }
     396                if ( 'all' !== $rule && ( ! function_exists( 'is_cart' ) || ! function_exists( 'is_checkout' ) ) ) {
     397                        throw new Frontman_Tool_Error( 'WooCommerce is required to set this visibility rule.' );
     398                }
     399                $rules = get_option( 'frontman_widget_visibility', [] );
     400                unset( $rules[ $widget['widget_id'] ] );
     401                if ( 'all' !== $rule ) {
     402                        $rules[ $widget['widget_id'] ] = $rule;
     403                }
     404                $this->save_settings( 'frontman_widget_visibility', $rules );
     405                return [ 'before' => $widget['visibility'], 'widget' => $this->read_widget( $input ) ];
     406        }
     407
     408        /**
     409         * Registered at plugin load, not during tool execution. Never resurrect a
     410         * widget suppressed by another filter. Without WooCommerce, cart-only
     411         * stays hidden and checkout exclusion leaves unrelated pages alone.
     412         */
     413        public static function filter_visibility( $instance, $widget, $args ) {
     414                if ( false === $instance ) {
     415                        return false;
     416                }
     417                $rule = get_option( 'frontman_widget_visibility', [] )[ $args['widget_id'] ?? $widget->id ] ?? 'all';
     418                $checkout = function_exists( 'is_checkout' ) && is_checkout();
     419                if ( ( 'exclude_checkout' === $rule && $checkout ) ||
     420                        ( 'cart_only' === $rule && ( $checkout || ! function_exists( 'is_cart' ) || ! is_cart() ) ) ) {
     421                        return false;
     422                }
     423                return $instance;
    341424        }
    342425
  • frontman-agentic-ai-editor/trunk/tools/class-tool-woocommerce.php

    r3642353 r3783407  
    213213
    214214        private function register_settings( Frontman_Tools $tools ): void {
     215                $catalog = $this->object_schema( [
     216                        'name' => [
     217                                'type' => 'string',
     218                                'description' => 'The shop page or product category archive display setting.',
     219                                'enum' => [ 'woocommerce_shop_page_display', 'woocommerce_category_archive_display' ],
     220                        ],
     221                ], [ 'name' ] );
     222                $this->add_custom_tool( $tools, 'get_catalog_display', 'Reads a WooCommerce catalog display setting. Empty string means products; subcategories means categories; both means categories and products.', $catalog,
     223                        function( array $input ) { return $this->catalog_display( $input, false ); }, false
     224                );
     225                $catalog['properties']['value'] = [
     226                        'type' => 'string',
     227                        'description' => 'Empty string shows products directly; subcategories shows categories; both shows both.',
     228                        'enum' => [ '', 'subcategories', 'both' ],
     229                ];
     230                $catalog['required'][] = 'value';
     231                $this->add_custom_tool( $tools, 'update_catalog_display', 'Updates only a WooCommerce catalog display setting through its native Customizer save path. Ask the user for confirmation before calling with confirm=true.', $this->with_confirm( $catalog ),
     232                        function( array $input ) { return $this->catalog_display( $input, true ); }, false
     233                );
    215234                $this->add_endpoint_tool( $tools, 'get_settings', 'Retrieves WooCommerce setting groups.', 'GET', '/settings', $this->empty_schema() );
    216235                $this->add_endpoint_tool( $tools, 'get_setting_options', 'Retrieves options in one WooCommerce setting group.', 'GET', '/settings/{group}', $this->object_schema( [ 'group' => $this->string_prop( 'The WooCommerce setting group ID.' ) ], [ 'group' ] ) );
    217236                $this->add_endpoint_tool( $tools, 'update_setting_option', 'Updates one WooCommerce setting option.', 'PUT', '/settings/{group}/{id}', $this->data_schema( 'settingData', 'Setting fields to update.', [ 'group', 'id', 'settingData' ], [ 'group' => $this->string_prop( 'The WooCommerce setting group ID.' ), 'id' => $this->string_prop( 'The WooCommerce setting ID.' ) ] ), [ 'body' => 'settingData' ] );
     237        }
     238
     239        public static function validate_catalog_display_input( array $input, bool $write ): void {
     240                $name = $input['name'] ?? null;
     241                if ( ! in_array( $name, [ 'woocommerce_shop_page_display', 'woocommerce_category_archive_display' ], true ) ) {
     242                        throw new Frontman_Tool_Error( 'Unsupported WooCommerce catalog display setting.' );
     243                }
     244                if ( $write && true !== ( $input['confirm'] ?? false ) ) {
     245                        throw new Frontman_Tool_Error( 'Catalog display update requires confirm=true after user approval.' );
     246                }
     247                if ( $write && ! in_array( $input['value'] ?? null, [ '', 'subcategories', 'both' ], true ) ) {
     248                        throw new Frontman_Tool_Error( 'value must be an empty string, subcategories, or both.' );
     249                }
     250        }
     251
     252        private function catalog_display( array $input, bool $write ): array {
     253                self::validate_catalog_display_input( $input, $write );
     254                $name = $input['name'];
     255                if ( ! class_exists( 'WooCommerce' ) || ! class_exists( 'WC_Shop_Customizer' ) ) {
     256                        throw new Frontman_Tool_Error( 'WooCommerce is not active.' );
     257                }
     258                if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_theme_options' ) ) {
     259                        throw new Frontman_Tool_Error( 'You cannot manage WooCommerce catalog display settings.' );
     260                }
     261
     262                global $wp_customize;
     263                if ( ! ( $wp_customize instanceof \WP_Customize_Manager ) ) {
     264                        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     265                        $wp_customize = new \WP_Customize_Manager( [ 'settings_previewed' => false ] );
     266                        remove_action( 'customize_register', [ $wp_customize, 'register_controls' ] );
     267                        $wp_customize->register_controls();
     268                }
     269                if ( ! $wp_customize->get_setting( $name ) ) {
     270                        $customizer = new \WC_Shop_Customizer();
     271                        $customizer->add_product_catalog_section( $wp_customize );
     272                }
     273                $setting = $wp_customize->get_setting( $name );
     274                if ( ! $setting || 'option' !== $setting->type || ! empty( $setting->id_data()['keys'] ) || ! $setting->check_capabilities() ) {
     275                        throw new Frontman_Tool_Error( 'Catalog display Customizer setting is unavailable or not permitted.' );
     276                }
     277                $before = get_option( $name, $setting->default );
     278                if ( ! $write ) {
     279                        return [ 'name' => $name, 'value' => $before, 'supported_values' => [ '', 'subcategories', 'both' ] ];
     280                }
     281                $valid = $setting->validate( $input['value'] );
     282                if ( is_wp_error( $valid ) ) {
     283                        throw new Frontman_Tool_Error( $valid->get_error_message() );
     284                }
     285                $value = $setting->sanitize( $input['value'] );
     286                if ( ! in_array( $value, [ '', 'subcategories', 'both' ], true ) ) {
     287                        throw new Frontman_Tool_Error( 'Catalog display Customizer sanitizer rejected the value.' );
     288                }
     289                $wp_customize->set_post_value( $name, $input['value'] );
     290                do_action( 'customize_save', $wp_customize );
     291                if ( false === $setting->save() ) {
     292                        throw new Frontman_Tool_Error( 'Catalog display setting could not be saved.' );
     293                }
     294                do_action( 'customize_save_after', $wp_customize );
     295                $after = get_option( $name, $setting->default );
     296                if ( $after !== $value ) {
     297                        throw new Frontman_Tool_Error( 'Catalog display write could not be verified. Read current state before retrying.' );
     298                }
     299                return [ 'name' => $name, 'before' => $before, 'after' => $after, 'updated' => $before !== $after ];
    218300        }
    219301
    … …  
    252334        }
    253335
    254         private function add_custom_tool( Frontman_Tools $tools, string $source_method, string $description, array $schema, callable $handler ): void {
     336        private function add_custom_tool( Frontman_Tools $tools, string $source_method, string $description, array $schema, callable $handler, bool $mirrors_rest = true ): void {
    255337                $tools->add( new Frontman_Tool_Definition(
    256338                        'wc_' . $source_method,
    257                         $description . ' Mirrors woocommerce-mcp-server method `' . $source_method . '` using local WordPress authentication; no WooCommerce REST API keys are required.',
     339                        $description . ( $mirrors_rest ? ' Mirrors woocommerce-mcp-server method `' . $source_method . '`' : ' Uses the native WooCommerce Customizer setting' ) . ' using local WordPress authentication; no WooCommerce REST API keys are required.',
    258340                        $schema,
    259341                        $handler,
    … …  
    281363                                $operation . '_' . $entity . '_meta',
    282364                                $entity_label . ' metadata upsert. Creates the key when missing and updates the first matching key when present. Ask the user for confirmation before calling with confirm=true.',
    283                                 $this->with_confirm( $this->object_schema( array_merge( $id_prop, [ 'metaKey' => $this->string_prop( 'The metadata key.' ), 'metaValue' => [ 'description' => 'The metadata value. Can be a scalar, object, array, or null.' ] ] ), [ $id_key, 'metaKey', 'metaValue' ] ) ),
     365                                $this->with_confirm( $this->object_schema( array_merge( $id_prop, [ 'metaKey' => $this->string_prop( 'The metadata key.' ), 'metaValue' => [ 'type' => [ 'string', 'number', 'boolean', 'object', 'array', 'null' ], 'properties' => (object) [], 'description' => 'The metadata value. Can be a scalar, object, array, or null.' ] ] ), [ $id_key, 'metaKey', 'metaValue' ] ) ),
    284366                                function( array $input ) use ( $path_template ) { return $this->upsert_meta( $path_template, $input ); }
    285367                        );
  • frontman-agentic-ai-editor/trunk/vendor/composer/installed.php

    r3767236 r3783407  
    33namespace FrontmanVendor;
    44
    5 return array('root' => array('name' => 'frontman-ai/wordpress-diagnostics', 'pretty_version' => 'v5.5.0', 'version' => '5.5.0.0', 'reference' => '095f90276bb7e7f29365860049793a8700a204ec', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => \false), 'versions' => array('frontman-ai/wordpress-diagnostics' => array('pretty_version' => 'v5.5.0', 'version' => '5.5.0.0', 'reference' => '095f90276bb7e7f29365860049793a8700a204ec', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => \false), 'guzzlehttp/psr7' => array('pretty_version' => '3.1.0', 'version' => '3.1.0.0', 'reference' => 'a3059ba1a84c9139c4ae03cf0f45bea276c97c74', 'type' => 'library', 'install_path' => __DIR__ . '/../guzzlehttp/psr7', 'aliases' => array(), 'dev_requirement' => \false), 'jean85/pretty-package-versions' => array('pretty_version' => '2.1.1', 'version' => '2.1.1.0', 'reference' => '4d7aa5dab42e2a76d99559706022885de0e18e1a', 'type' => 'library', 'install_path' => __DIR__ . '/../jean85/pretty-package-versions', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-factory' => array('pretty_version' => '1.1.0', 'version' => '1.1.0.0', 'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/http-factory', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-factory-implementation' => array('dev_requirement' => \false, 'provided' => array(0 => '1.1')), 'psr/http-message' => array('pretty_version' => '2.0', 'version' => '2.0.0.0', 'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/http-message', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-message-implementation' => array('dev_requirement' => \false, 'provided' => array(0 => '2.0')), 'psr/log' => array('pretty_version' => '1.1.4', 'version' => '1.1.4.0', 'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/log', 'aliases' => array(), 'dev_requirement' => \false), 'sentry/sentry' => array('pretty_version' => '4.31.0', 'version' => '4.31.0.0', 'reference' => '39d9f86bd56c6cc6ae7b64c2b849f7f046d38394', 'type' => 'library', 'install_path' => __DIR__ . '/../sentry/sentry', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/deprecation-contracts' => array('pretty_version' => 'v2.5.4', 'version' => '2.5.4.0', 'reference' => '605389f2a7e5625f273b53960dc46aeaf9c62918', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/deprecation-contracts', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/options-resolver' => array('pretty_version' => 'v5.4.45', 'version' => '5.4.45.0', 'reference' => '74e5b6f0db3e8589e6cfd5efb317a1fc2bb52fb6', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/options-resolver', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php73' => array('pretty_version' => 'v1.37.0', 'version' => '1.37.0.0', 'reference' => '0f68c03565dcaaf25a890667542e8bd75fe7e5bb', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php73', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php80' => array('pretty_version' => 'v1.37.0', 'version' => '1.37.0.0', 'reference' => 'dfb55726c3a76ea3b6459fcfda1ec2d80a682411', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php80', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php82' => array('pretty_version' => 'v1.38.1', 'version' => '1.38.1.0', 'reference' => '002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php82', 'aliases' => array(), 'dev_requirement' => \false)));
     5return array('root' => array('name' => 'frontman-ai/wordpress-diagnostics', 'pretty_version' => 'dev-main', 'version' => 'dev-main', 'reference' => '30e52ed22bde0643d9a005704c12d90fa1f185c9', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev' => \false), 'versions' => array('frontman-ai/wordpress-diagnostics' => array('pretty_version' => 'dev-main', 'version' => 'dev-main', 'reference' => '30e52ed22bde0643d9a005704c12d90fa1f185c9', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), 'dev_requirement' => \false), 'guzzlehttp/psr7' => array('pretty_version' => '3.1.0', 'version' => '3.1.0.0', 'reference' => 'a3059ba1a84c9139c4ae03cf0f45bea276c97c74', 'type' => 'library', 'install_path' => __DIR__ . '/../guzzlehttp/psr7', 'aliases' => array(), 'dev_requirement' => \false), 'jean85/pretty-package-versions' => array('pretty_version' => '2.1.1', 'version' => '2.1.1.0', 'reference' => '4d7aa5dab42e2a76d99559706022885de0e18e1a', 'type' => 'library', 'install_path' => __DIR__ . '/../jean85/pretty-package-versions', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-factory' => array('pretty_version' => '1.1.0', 'version' => '1.1.0.0', 'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/http-factory', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-factory-implementation' => array('dev_requirement' => \false, 'provided' => array(0 => '1.1')), 'psr/http-message' => array('pretty_version' => '2.0', 'version' => '2.0.0.0', 'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/http-message', 'aliases' => array(), 'dev_requirement' => \false), 'psr/http-message-implementation' => array('dev_requirement' => \false, 'provided' => array(0 => '2.0')), 'psr/log' => array('pretty_version' => '1.1.4', 'version' => '1.1.4.0', 'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11', 'type' => 'library', 'install_path' => __DIR__ . '/../psr/log', 'aliases' => array(), 'dev_requirement' => \false), 'sentry/sentry' => array('pretty_version' => '4.31.0', 'version' => '4.31.0.0', 'reference' => '39d9f86bd56c6cc6ae7b64c2b849f7f046d38394', 'type' => 'library', 'install_path' => __DIR__ . '/../sentry/sentry', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/deprecation-contracts' => array('pretty_version' => 'v2.5.4', 'version' => '2.5.4.0', 'reference' => '605389f2a7e5625f273b53960dc46aeaf9c62918', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/deprecation-contracts', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/options-resolver' => array('pretty_version' => 'v5.4.45', 'version' => '5.4.45.0', 'reference' => '74e5b6f0db3e8589e6cfd5efb317a1fc2bb52fb6', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/options-resolver', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php73' => array('pretty_version' => 'v1.37.0', 'version' => '1.37.0.0', 'reference' => '0f68c03565dcaaf25a890667542e8bd75fe7e5bb', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php73', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php80' => array('pretty_version' => 'v1.37.0', 'version' => '1.37.0.0', 'reference' => 'dfb55726c3a76ea3b6459fcfda1ec2d80a682411', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php80', 'aliases' => array(), 'dev_requirement' => \false), 'symfony/polyfill-php82' => array('pretty_version' => 'v1.38.1', 'version' => '1.38.1.0', 'reference' => '002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/polyfill-php82', 'aliases' => array(), 'dev_requirement' => \false)));
Note: See TracChangeset for help on using the changeset viewer.