From 983d48a624479f1b90d826be61b82a67237f4868 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 12:26:59 +0300 Subject: [PATCH 01/13] feat: register starter sites abilities with the Abilities API Co-Authored-By: Claude Fable 5.1 --- includes/Abilities/Starter_Sites.php | 880 +++++++++++++++++++++++++++ templates-patterns-collection.php | 3 + 2 files changed, 883 insertions(+) create mode 100644 includes/Abilities/Starter_Sites.php diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php new file mode 100644 index 00000000..1c7f8e90 --- /dev/null +++ b/includes/Abilities/Starter_Sites.php @@ -0,0 +1,880 @@ + __( 'Starter Sites', 'templates-patterns-collection' ), + 'description' => __( 'List, import and revert starter sites.', 'templates-patterns-collection' ), + ) + ); + } + + /** + * Register the abilities. + * + * @return void + */ + public function register_abilities() { + foreach ( $this->get_definitions() as $definition ) { + wp_register_ability( $definition['id'], $definition['args'] ); + } + } + + /** + * Register the legacy `neve/*` ids as aliases of the abilities above. + * + * @return void + */ + public function register_aliases() { + foreach ( $this->get_definitions() as $definition ) { + if ( empty( $definition['alias'] ) ) { + continue; + } + if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $definition['alias'] ) ) { + continue; + } + wp_register_ability( $definition['alias'], $definition['args'] ); + } + } + + /** + * Ability definitions. + * + * @return array + */ + private function get_definitions() { + $builders = array( 'gutenberg', 'elementor', 'beaver', 'brizy' ); + + return array( + array( + 'id' => 'starter-sites/list', + 'alias' => 'neve/starter-site-list', + 'args' => array( + 'label' => __( 'List starter sites', 'templates-patterns-collection' ), + 'description' => __( 'List the available starter sites with their slug, builder, required plugins, preview and license tier. Optionally filter by search query or builder.', 'templates-patterns-collection' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'query' => array( + 'type' => 'string', + 'description' => 'Filter by title/slug substring.', + ), + 'builder' => array( + 'type' => 'string', + 'enum' => $builders, + 'description' => 'Filter by page builder.', + ), + 'noop' => array( + 'type' => 'boolean', + 'description' => 'Optional compatibility flag; ignored.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'sites' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'slug' => array( 'type' => 'string' ), + 'title' => array( 'type' => 'string' ), + 'builder' => array( 'type' => 'string' ), + 'required_plugins' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'preview_url' => array( 'type' => 'string' ), + 'license_tier' => array( 'type' => 'string' ), + 'locked' => array( 'type' => 'boolean' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( $this, 'list_sites' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => $this->get_meta( true, false, true ), + ), + ), + array( + 'id' => 'starter-sites/import', + 'alias' => 'neve/starter-site-import', + 'args' => array( + 'label' => __( 'Import a starter site', 'templates-patterns-collection' ), + 'description' => __( 'Import a starter site by slug: installs required plugins, imports content, theme settings and widgets. This replaces significant parts of the site. Set confirm=true to proceed, or dry_run=true to inspect the import plan first.', 'templates-patterns-collection' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'slug' ), + 'properties' => array( + 'slug' => array( + 'type' => 'string', + 'description' => 'The starter site slug (see starter-sites/list).', + ), + 'builder' => array( + 'type' => 'string', + 'enum' => $builders, + 'description' => 'Constrain to a builder when slugs collide.', + ), + 'with_plugins' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => 'Install the required and recommended plugins of the site.', + ), + 'confirm' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => 'Must be true to run the import.', + ), + 'dry_run' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => 'Return the resolved site and import plan without changing the site.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'imported' => array( 'type' => 'boolean' ), + 'slug' => array( 'type' => 'string' ), + 'builder' => array( 'type' => 'string' ), + 'plugins_installed' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'plugins_planned' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'front_page_url' => array( 'type' => 'string' ), + 'steps' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ), + ), + 'execute_callback' => array( $this, 'import_site' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => $this->get_meta( false, true, false ), + ), + ), + array( + 'id' => 'starter-sites/cleanup', + 'alias' => 'neve/starter-site-cleanup', + 'args' => array( + 'label' => __( 'Clean up a starter site import', 'templates-patterns-collection' ), + 'description' => __( 'Revert the recorded starter site import: removes the imported content, attachments, terms and plugins, and restores the previous theme settings, menus and widgets. Only resources recorded during the import are touched.', 'templates-patterns-collection' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'confirm' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => 'Must be true to proceed.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'reverted' => array( 'type' => 'boolean' ), + ), + ), + 'execute_callback' => array( $this, 'cleanup' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => $this->get_meta( false, true, false ), + ), + ), + array( + 'id' => 'starter-sites/import-status', + 'args' => array( + 'label' => __( 'Get the starter site import status', 'templates-patterns-collection' ), + 'description' => __( 'Report the recorded state of the last starter site import: which resources were created or changed, whether a cleanup is available, and the recent importer log with its errors.', 'templates-patterns-collection' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'log_lines' => array( + 'type' => 'integer', + 'default' => 50, + 'minimum' => 0, + 'maximum' => self::MAX_LOG_LINES, + 'description' => 'How many of the most recent importer log lines to return.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'has_import' => array( 'type' => 'boolean' ), + 'cleanup_available' => array( 'type' => 'boolean' ), + 'content_imported' => array( 'type' => 'boolean' ), + 'plugins_installed' => array( 'type' => 'boolean' ), + 'created' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + 'changed' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + 'log' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'errors' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => array( $this, 'import_status' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'meta' => $this->get_meta( true, false, true ), + ), + ), + ); + } + + /** + * Build the ability meta. + * + * @param bool $readonly Whether the ability only reads data. + * @param bool $destructive Whether the ability may remove or replace data. + * @param bool $idempotent Whether repeated calls have the same effect. + * + * @return array + */ + private function get_meta( $readonly, $destructive, $idempotent ) { + return array( + 'annotations' => array( + 'readonly' => $readonly, + 'destructive' => $destructive, + 'idempotent' => $idempotent, + ), + 'show_in_rest' => true, + ); + } + + /** + * Permission callback. Mirrors the `ti-sites-lib/v1` REST routes. + * + * @return bool + */ + public function check_permission() { + return current_user_can( self::CAPABILITY ); + } + + /** + * List the starter sites. + * + * @param mixed $input Ability input. + * + * @return array|WP_Error + */ + public function list_sites( $input = array() ) { + $disabled = $this->get_disabled_error(); + if ( $disabled ) { + return $disabled; + } + + $input = is_array( $input ) ? $input : array(); + $query = isset( $input['query'] ) ? strtolower( sanitize_text_field( $input['query'] ) ) : ''; + $builder = isset( $input['builder'] ) ? sanitize_key( $input['builder'] ) : ''; + $sites = array(); + + foreach ( $this->get_sites() as $builder_key => $builder_sites ) { + if ( $builder !== '' && $builder !== $builder_key ) { + continue; + } + if ( ! is_array( $builder_sites ) ) { + continue; + } + + foreach ( $builder_sites as $slug => $data ) { + $title = isset( $data['title'] ) ? (string) $data['title'] : (string) $slug; + + if ( $query !== '' && strpos( strtolower( $title . ' ' . $slug ), $query ) === false ) { + continue; + } + + $required = array(); + if ( isset( $data['mandatory_plugins'] ) && is_array( $data['mandatory_plugins'] ) ) { + $required = array_map( 'strval', array_keys( $data['mandatory_plugins'] ) ); + } + + $sites[] = array( + 'slug' => (string) $slug, + 'title' => $title, + 'builder' => (string) $builder_key, + 'required_plugins' => $required, + 'preview_url' => isset( $data['screenshot'] ) ? (string) $data['screenshot'] : '', + 'license_tier' => isset( $data['upsell'] ) ? 'pro' : 'free', + 'locked' => isset( $data['upsell'] ) && $data['upsell'] === true, + ); + } + } + + return array( 'sites' => $sites ); + } + + /** + * Import a starter site. + * + * @param mixed $input Ability input. + * + * @return array|WP_Error + */ + public function import_site( $input = array() ) { + $disabled = $this->get_disabled_error(); + if ( $disabled ) { + return $disabled; + } + + $input = is_array( $input ) ? $input : array(); + $slug = isset( $input['slug'] ) ? sanitize_text_field( $input['slug'] ) : ''; + $builder = isset( $input['builder'] ) ? sanitize_key( $input['builder'] ) : ''; + + if ( $slug === '' ) { + return new WP_Error( 'tpc_ability_missing_slug', __( 'A starter site slug is required.', 'templates-patterns-collection' ), array( 'status' => 400 ) ); + } + + $found = $this->find_site( $slug, $builder ); + if ( $found === null ) { + return new WP_Error( 'tpc_ability_unknown_site', __( 'No starter site with that slug.', 'templates-patterns-collection' ), array( 'status' => 404 ) ); + } + + $site = $found['data']; + + // The catalogue flags premium sites the current license cannot import. + if ( isset( $site['upsell'] ) && $site['upsell'] === true ) { + return new WP_Error( 'tpc_ability_premium_site', __( 'This starter site requires an active premium license.', 'templates-patterns-collection' ), array( 'status' => 403 ) ); + } + + $json = $this->fetch_site_json( $site ); + if ( is_wp_error( $json ) ) { + return $json; + } + + $with_plugins = ! isset( $input['with_plugins'] ) || ! empty( $input['with_plugins'] ); + $plugins = array(); + if ( $with_plugins ) { + foreach ( array( 'recommended_plugins', 'mandatory_plugins' ) as $group ) { + if ( isset( $json[ $group ] ) && is_array( $json[ $group ] ) ) { + foreach ( array_keys( $json[ $group ] ) as $plugin_slug ) { + $plugins[ (string) $plugin_slug ] = true; + } + } + } + } + + if ( ! empty( $input['dry_run'] ) ) { + return array( + 'success' => true, + 'dry_run' => true, + 'imported' => false, + 'slug' => $slug, + 'builder' => $found['builder'], + 'plugins_planned' => array_keys( $plugins ), + 'steps' => array( + 'plugins' => ! empty( $plugins ), + 'content' => ! empty( $json['content_file'] ), + 'theme_mods' => ! empty( $json['theme_mods'] ), + 'widgets' => ! empty( $json['widgets'] ), + ), + ); + } + + if ( empty( $input['confirm'] ) ) { + return new WP_Error( 'tpc_ability_confirm_required', __( 'Set confirm=true to import this starter site, or use dry_run=true to inspect the plan.', 'templates-patterns-collection' ), array( 'status' => 400 ) ); + } + + $source = $this->get_site_url( $site ); + $editor = isset( $site['editor'] ) ? $site['editor'] : $found['builder']; + + if ( $source !== '' ) { + Slug_Mapping::register_source_url( $source ); + } + $this->ensure_active_state(); + + $rest = new Rest_Server(); + $steps = array(); + $plugins_installed = array(); + + if ( ! empty( $plugins ) ) { + $steps['plugins'] = $this->get_step_result( $rest->run_plugin_importer( $this->build_request( $plugins ) ) ); + if ( $steps['plugins']['success'] ) { + $plugins_installed = array_keys( $plugins ); + } + } + + if ( ! empty( $json['content_file'] ) ) { + $payload = array( + 'contentFile' => $json['content_file'], + 'source' => 'remote', + 'demoSlug' => $slug, + 'editor' => $editor, + ); + $mapping = array( + 'front_page' => 'frontPage', + 'shop_pages' => 'shopPages', + 'payment_forms' => 'paymentForms', + 'masteriyo_data' => 'masteriyoData', + ); + foreach ( $mapping as $json_key => $payload_key ) { + if ( isset( $json[ $json_key ] ) ) { + $payload[ $payload_key ] = $json[ $json_key ]; + } + } + $steps['content'] = $this->get_step_result( $rest->run_xml_importer( $this->build_request( $payload ) ) ); + } + + if ( ! empty( $json['theme_mods'] ) ) { + $steps['theme_mods'] = $this->get_step_result( + $rest->run_theme_mods_importer( + $this->build_request( + array( + 'source_url' => $source, + 'theme_mods' => $json['theme_mods'], + 'wp_options' => isset( $json['wp_options'] ) ? $json['wp_options'] : array(), + ) + ) + ) + ); + } + + if ( ! empty( $json['widgets'] ) ) { + $steps['widgets'] = $this->get_step_result( + $rest->run_widgets_importer( + $this->build_request( + array( + 'source_url' => $source, + 'widgets' => $json['widgets'], + ) + ) + ) + ); + } + + $imported = true; + foreach ( $steps as $step ) { + if ( empty( $step['success'] ) ) { + $imported = false; + } + } + + return array( + 'success' => $imported, + 'imported' => $imported, + 'slug' => $slug, + 'builder' => $found['builder'], + 'plugins_installed' => $plugins_installed, + 'plugins_planned' => array_keys( $plugins ), + 'front_page_url' => home_url( '/' ), + 'steps' => $steps, + ); + } + + /** + * Revert the recorded import. + * + * @param mixed $input Ability input. + * + * @return array|WP_Error + */ + public function cleanup( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + + if ( empty( $input['confirm'] ) ) { + return new WP_Error( 'tpc_ability_confirm_required', __( 'Set confirm=true to revert the last import.', 'templates-patterns-collection' ), array( 'status' => 400 ) ); + } + + // Same gate the import UI uses before offering the cleanup. + if ( empty( get_transient( Active_State::STATE_NAME ) ) ) { + return new WP_Error( 'tpc_ability_nothing_to_cleanup', __( 'There is no recorded starter site import to revert.', 'templates-patterns-collection' ), array( 'status' => 404 ) ); + } + + $rest = new Rest_Server(); + $rest->run_cleanup(); + + return array( + 'success' => true, + 'reverted' => true, + ); + } + + /** + * Report the recorded state of the last import. + * + * @param mixed $input Ability input. + * + * @return array + */ + public function import_status( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $log_lines = isset( $input['log_lines'] ) ? absint( $input['log_lines'] ) : 50; + $log_lines = min( $log_lines, self::MAX_LOG_LINES ); + + $state = get_transient( Active_State::STATE_NAME ); + $state = is_array( $state ) ? $state : array(); + + $created = array(); + $lists = array( + 'posts' => Active_State::POSTS_NSP, + 'attachments' => Active_State::ATTACHMENT_NSP, + 'comments' => Active_State::COMMENTS_NSP, + 'categories' => Active_State::CATEGORY_NSP, + 'tags' => Active_State::TAGS_NSP, + ); + foreach ( $lists as $key => $namespace ) { + $ids = isset( $state[ $namespace ] ) && is_array( $state[ $namespace ] ) ? array_values( array_map( 'absint', $state[ $namespace ] ) ) : array(); + $created[ $key ] = array( + 'count' => count( $ids ), + 'ids' => $ids, + ); + } + + $terms = array(); + if ( isset( $state[ Active_State::TERMS_NSP ] ) && is_array( $state[ Active_State::TERMS_NSP ] ) ) { + foreach ( $state[ Active_State::TERMS_NSP ] as $term ) { + if ( isset( $term['id'], $term['taxonomy'] ) ) { + $terms[] = array( + 'id' => absint( $term['id'] ), + 'taxonomy' => (string) $term['taxonomy'], + ); + } + } + } + $created['terms'] = array( + 'count' => count( $terms ), + 'items' => $terms, + ); + + $created['plugins'] = isset( $state[ Active_State::PLUGINS_NSP ] ) && is_array( $state[ Active_State::PLUGINS_NSP ] ) ? array_map( 'strval', array_keys( $state[ Active_State::PLUGINS_NSP ] ) ) : array(); + + $changed = array( + 'theme_mods' => $this->pluck_names( $state, Active_State::THEME_MODS_NSP, 'mod' ), + 'widgets' => $this->pluck_names( $state, Active_State::WIDGETS_NSP, 'id' ), + 'menus' => isset( $state[ Active_State::MENUS_NSP ] ), + 'options' => array(), + ); + foreach ( array( Active_State::FRONT_PAGE_NSP, Active_State::SHOP_PAGE_NSP ) as $namespace ) { + if ( isset( $state[ $namespace ] ) && is_array( $state[ $namespace ] ) ) { + $changed['options'] = array_merge( $changed['options'], array_map( 'strval', array_keys( $state[ $namespace ] ) ) ); + } + } + + $log = array(); + $errors = array(); + $raw = get_transient( Logger::$log_transient_name ); + if ( is_string( $raw ) && $raw !== '' && $log_lines > 0 ) { + $lines = array_values( array_filter( array_map( 'trim', explode( PHP_EOL, $raw ) ) ) ); + $log = array_slice( $lines, -1 * $log_lines ); + foreach ( $log as $line ) { + if ( strpos( $line, '(E):' ) !== false ) { + $errors[] = $line; + } + } + } + + return array( + 'has_import' => ! empty( $state ), + 'cleanup_available' => ! empty( $state ), + 'content_imported' => get_theme_mod( 'ti_content_imported' ) === 'yes', + 'plugins_installed' => get_option( 'themeisle_ob_plugins_installed' ) === 'yes', + 'created' => $created, + 'changed' => $changed, + 'log' => $log, + 'errors' => $errors, + ); + } + + /** + * Get one field from each item of a state namespace. + * + * @param array $state The cleanup state. + * @param string $namespace The state namespace. + * @param string $field The field to read. + * + * @return array + */ + private function pluck_names( $state, $namespace, $field ) { + $names = array(); + if ( ! isset( $state[ $namespace ] ) || ! is_array( $state[ $namespace ] ) ) { + return $names; + } + foreach ( $state[ $namespace ] as $item ) { + if ( is_array( $item ) && isset( $item[ $field ] ) && is_scalar( $item[ $field ] ) ) { + $names[] = (string) $item[ $field ]; + } + } + + return array_values( array_unique( $names ) ); + } + + /** + * Error returned when starter sites are disabled through white label. + * + * @return WP_Error|null + */ + private function get_disabled_error() { + $this->setup_white_label(); + if ( $this->is_starter_sites_disabled() ) { + return new WP_Error( 'tpc_ability_starter_sites_disabled', __( 'Starter sites are disabled on this site.', 'templates-patterns-collection' ), array( 'status' => 403 ) ); + } + + return null; + } + + /** + * Get the starter sites catalogue as builder => slug => data. + * + * @return array + */ + private function get_sites() { + $sites = $this->read_theme_support(); + + // The listing is only set up for users resolved before `init`; do it now otherwise. + if ( empty( $sites ) ) { + $listing = new Sites_Listing(); + $listing->init(); + $sites = $this->read_theme_support(); + } + + return $sites; + } + + /** + * Read the catalogue from the `themeisle-demo-import` theme support. + * + * @return array + */ + private function read_theme_support() { + $support = get_theme_support( 'themeisle-demo-import' ); + + if ( is_array( $support ) && isset( $support[0]['remote'] ) && is_array( $support[0]['remote'] ) ) { + return $support[0]['remote']; + } + + return array(); + } + + /** + * Find a site by slug, optionally within a builder. + * + * @param string $slug Site slug. + * @param string $builder Builder key or empty string. + * + * @return array|null + */ + private function find_site( $slug, $builder ) { + foreach ( $this->get_sites() as $builder_key => $sites ) { + if ( $builder !== '' && $builder !== $builder_key ) { + continue; + } + if ( isset( $sites[ $slug ] ) && is_array( $sites[ $slug ] ) ) { + return array( + 'builder' => (string) $builder_key, + 'data' => $sites[ $slug ], + ); + } + } + + return null; + } + + /** + * The demo site URL. + * + * @param array $site Site data. + * + * @return string + */ + private function get_site_url( $site ) { + if ( ! empty( $site['url'] ) ) { + return (string) $site['url']; + } + + return ! empty( $site['remote_url'] ) ? (string) $site['remote_url'] : ''; + } + + /** + * Fetch the import data of a site, the same way the import modal does. + * + * @param array $site Site data. + * + * @return array|WP_Error + */ + private function fetch_site_json( $site ) { + $address = ! empty( $site['remote_url'] ) ? (string) $site['remote_url'] : $this->get_site_url( $site ); + if ( $address === '' ) { + return new WP_Error( 'tpc_ability_no_source', __( 'The starter site has no source URL.', 'templates-patterns-collection' ), array( 'status' => 500 ) ); + } + + $url = add_query_arg( + array( + 'license' => rawurlencode( (string) apply_filters( 'product_neve_license_key', 'free' ) ), + 'ti_downloads' => 'yes', + ), + trailingslashit( $address ) . 'wp-json/ti-demo-data/data' + ); + + $response = wp_remote_get( esc_url_raw( $url ), array( 'timeout' => 30 ) ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( 'tpc_ability_site_data', $response->get_error_message(), array( 'status' => 502 ) ); + } + + if ( (int) wp_remote_retrieve_response_code( $response ) !== 200 ) { + return new WP_Error( 'tpc_ability_site_data', __( 'Could not fetch the starter site data.', 'templates-patterns-collection' ), array( 'status' => 502 ) ); + } + + $json = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $json ) ) { + return new WP_Error( 'tpc_ability_site_data', __( 'The starter site data was malformed.', 'templates-patterns-collection' ), array( 'status' => 502 ) ); + } + + return $json; + } + + /** + * Make sure the cleanup state is being recorded during the import. + * + * Main only wires it for users resolved before `init`. + * + * @return void + */ + private function ensure_active_state() { + if ( has_action( 'themeisle_cl_add_property_state' ) ) { + return; + } + + $active_state = new Active_State(); + $active_state->init(); + } + + /** + * Build the JSON request the importers read their parameters from. + * + * @param array $payload Request body. + * + * @return WP_REST_Request + */ + private function build_request( $payload ) { + $request = new WP_REST_Request( 'POST' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $payload ) ); + + return $request; + } + + /** + * Normalize an importer response into { success, error? }. + * + * @param mixed $response Importer response. + * + * @return array + */ + private function get_step_result( $response ) { + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'error' => $response->get_error_message(), + ); + } + + $data = $response instanceof WP_REST_Response ? $response->get_data() : $response; + + if ( is_array( $data ) && isset( $data['success'] ) && $data['success'] === false ) { + $error = 'import_step_failed'; + if ( isset( $data['data'] ) && is_wp_error( $data['data'] ) ) { + $error = $data['data']->get_error_message(); + } elseif ( isset( $data['data'] ) && is_string( $data['data'] ) ) { + $error = $data['data']; + } + + return array( + 'success' => false, + 'error' => $error, + ); + } + + return array( 'success' => true ); + } +} diff --git a/templates-patterns-collection.php b/templates-patterns-collection.php index 028b533f..f2af5e06 100755 --- a/templates-patterns-collection.php +++ b/templates-patterns-collection.php @@ -63,6 +63,9 @@ function ti_tpc_load_textdomain() { if ( is_file( $autoload_path ) ) { require_once $autoload_path; } +if ( class_exists( '\\TIOB\\Abilities\\Starter_Sites' ) ) { + ( new \TIOB\Abilities\Starter_Sites() )->init(); +} add_action( 'init', 'ti_tpc_run', 999 ); function ti_tpc_run() { From a62a73d7694bb35773ed6adb29d212df0c3968dd Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 12:56:23 +0300 Subject: [PATCH 02/13] feat: make long-running abilities resumable Co-Authored-By: Claude Fable 5.1 --- includes/Abilities/Starter_Sites.php | 202 ++++++++++++++++++++------- 1 file changed, 151 insertions(+), 51 deletions(-) diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php index 1c7f8e90..638fe956 100644 --- a/includes/Abilities/Starter_Sites.php +++ b/includes/Abilities/Starter_Sites.php @@ -42,6 +42,11 @@ class Starter_Sites { */ const MAX_LOG_LINES = 500; + /** + * Import steps, in the order the import modal runs them. + */ + const IMPORT_STEPS = array( 'plugins', 'content', 'theme_mods', 'widgets' ); + /** * Hook the registration callbacks. * @@ -172,7 +177,7 @@ private function get_definitions() { 'alias' => 'neve/starter-site-import', 'args' => array( 'label' => __( 'Import a starter site', 'templates-patterns-collection' ), - 'description' => __( 'Import a starter site by slug: installs required plugins, imports content, theme settings and widgets. This replaces significant parts of the site. Set confirm=true to proceed, or dry_run=true to inspect the import plan first.', 'templates-patterns-collection' ), + 'description' => __( 'Import a starter site by slug: installs required plugins, imports content, theme settings and widgets. This replaces significant parts of the site. Set confirm=true to proceed, or dry_run=true to inspect the import plan first. Each call runs one import step: while done is false, call again with the same input plus the returned cursor.', 'templates-patterns-collection' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', @@ -202,6 +207,17 @@ private function get_definitions() { 'default' => false, 'description' => 'Return the resolved site and import plan without changing the site.', ), + 'cursor' => array( + 'type' => 'string', + 'description' => 'Cursor returned by the previous call; the import continues from that step. Leave empty to start.', + ), + 'time_budget' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 60, + 'default' => 20, + 'description' => 'Seconds to spend per call. An import step cannot be interrupted, so every call runs exactly one step.', + ), ), ), 'output_schema' => array( @@ -225,11 +241,29 @@ private function get_definitions() { 'type' => 'object', 'additionalProperties' => true, ), + 'done' => array( 'type' => 'boolean' ), + 'cursor' => array( 'type' => 'string' ), + 'progress' => array( + 'type' => 'object', + 'properties' => array( + 'current' => array( 'type' => 'integer' ), + 'total' => array( 'type' => 'integer' ), + 'message' => array( 'type' => 'string' ), + ), + ), ), ), 'execute_callback' => array( $this, 'import_site' ), 'permission_callback' => array( $this, 'check_permission' ), - 'meta' => $this->get_meta( false, true, false ), + 'meta' => array_merge( + $this->get_meta( false, true, false ), + array( + 'task' => array( + 'mode' => 'cursor', + 'results_key' => 'steps', + ), + ) + ), ), ), array( @@ -444,6 +478,15 @@ public function import_site( $input = array() ) { } } + $pending = array(); + foreach ( self::IMPORT_STEPS as $step ) { + $key = $step === 'content' ? 'content_file' : $step; + if ( $step === 'plugins' ? ! empty( $plugins ) : ! empty( $json[ $key ] ) ) { + $pending[] = $step; + } + } + $total = count( $pending ); + if ( ! empty( $input['dry_run'] ) ) { return array( 'success' => true, @@ -452,11 +495,13 @@ public function import_site( $input = array() ) { 'slug' => $slug, 'builder' => $found['builder'], 'plugins_planned' => array_keys( $plugins ), - 'steps' => array( - 'plugins' => ! empty( $plugins ), - 'content' => ! empty( $json['content_file'] ), - 'theme_mods' => ! empty( $json['theme_mods'] ), - 'widgets' => ! empty( $json['widgets'] ), + 'steps' => array_merge( array_fill_keys( self::IMPORT_STEPS, false ), array_fill_keys( $pending, true ) ), + 'done' => true, + 'cursor' => '', + 'progress' => array( + 'current' => 0, + 'total' => $total, + 'message' => __( 'Dry run, nothing was imported.', 'templates-patterns-collection' ), ), ); } @@ -465,26 +510,100 @@ public function import_site( $input = array() ) { return new WP_Error( 'tpc_ability_confirm_required', __( 'Set confirm=true to import this starter site, or use dry_run=true to inspect the plan.', 'templates-patterns-collection' ), array( 'status' => 400 ) ); } - $source = $this->get_site_url( $site ); - $editor = isset( $site['editor'] ) ? $site['editor'] : $found['builder']; - - if ( $source !== '' ) { - Slug_Mapping::register_source_url( $source ); + $cursor = isset( $input['cursor'] ) && is_string( $input['cursor'] ) ? $input['cursor'] : ''; + $index = 0; + if ( $cursor !== '' ) { + $index = array_search( $cursor, $pending, true ); + if ( $index === false ) { + return new WP_Error( 'tpc_ability_invalid_cursor', __( 'The cursor is not a pending step of this import.', 'templates-patterns-collection' ), array( 'status' => 400 ) ); + } } - $this->ensure_active_state(); - $rest = new Rest_Server(); - $steps = array(); - $plugins_installed = array(); + $result = array( + 'success' => true, + 'imported' => false, + 'slug' => $slug, + 'builder' => $found['builder'], + 'plugins_installed' => array(), + 'plugins_planned' => array_keys( $plugins ), + 'steps' => array(), + 'done' => false, + 'cursor' => '', + 'progress' => array( + 'current' => 0, + 'total' => $total, + 'message' => __( 'Nothing to import.', 'templates-patterns-collection' ), + ), + ); + + if ( $total > 0 ) { + $step = $pending[ $index ]; + $source = $this->get_site_url( $site ); + + if ( $source !== '' ) { + Slug_Mapping::register_source_url( $source ); + } + $this->ensure_active_state(); + + $outcome = $this->run_import_step( $step, $json, $plugins, $slug, isset( $site['editor'] ) ? $site['editor'] : $found['builder'], $source ); + + if ( empty( $outcome['success'] ) ) { + return new WP_Error( + 'tpc_ability_import_step_failed', + /* translators: 1: import step name, 2: error message. */ + sprintf( __( 'The "%1$s" import step failed: %2$s', 'templates-patterns-collection' ), $step, $outcome['error'] ), + array( + 'status' => 500, + 'step' => $step, + 'cursor' => $step, + ) + ); + } - if ( ! empty( $plugins ) ) { - $steps['plugins'] = $this->get_step_result( $rest->run_plugin_importer( $this->build_request( $plugins ) ) ); - if ( $steps['plugins']['success'] ) { - $plugins_installed = array_keys( $plugins ); + $result['steps'][ $step ] = $outcome; + if ( $step === 'plugins' ) { + $result['plugins_installed'] = array_keys( $plugins ); } + + ++$index; + $result['progress']['current'] = $index; + /* translators: 1: import step name, 2: finished steps, 3: total steps. */ + $result['progress']['message'] = sprintf( __( 'Finished the "%1$s" step (%2$d of %3$d).', 'templates-patterns-collection' ), $step, $index, $total ); } - if ( ! empty( $json['content_file'] ) ) { + if ( $index < $total ) { + $result['cursor'] = $pending[ $index ]; + + return $result; + } + + $result['done'] = true; + $result['imported'] = true; + $result['front_page_url'] = home_url( '/' ); + + return $result; + } + + /** + * Run one import step through the importer the matching REST route uses. + * + * @param string $step Step name, one of IMPORT_STEPS. + * @param array $json Import data of the site. + * @param array $plugins Plugins to install, as slug => true. + * @param string $slug Site slug. + * @param string $editor Site editor. + * @param string $source Demo site URL. + * + * @return array + */ + private function run_import_step( $step, $json, $plugins, $slug, $editor, $source ) { + $rest = new Rest_Server(); + + if ( $step === 'plugins' ) { + return $this->get_step_result( $rest->run_plugin_importer( $this->build_request( $plugins ) ) ); + } + + if ( $step === 'content' ) { $payload = array( 'contentFile' => $json['content_file'], 'source' => 'remote', @@ -502,11 +621,12 @@ public function import_site( $input = array() ) { $payload[ $payload_key ] = $json[ $json_key ]; } } - $steps['content'] = $this->get_step_result( $rest->run_xml_importer( $this->build_request( $payload ) ) ); + + return $this->get_step_result( $rest->run_xml_importer( $this->build_request( $payload ) ) ); } - if ( ! empty( $json['theme_mods'] ) ) { - $steps['theme_mods'] = $this->get_step_result( + if ( $step === 'theme_mods' ) { + return $this->get_step_result( $rest->run_theme_mods_importer( $this->build_request( array( @@ -519,35 +639,15 @@ public function import_site( $input = array() ) { ); } - if ( ! empty( $json['widgets'] ) ) { - $steps['widgets'] = $this->get_step_result( - $rest->run_widgets_importer( - $this->build_request( - array( - 'source_url' => $source, - 'widgets' => $json['widgets'], - ) + return $this->get_step_result( + $rest->run_widgets_importer( + $this->build_request( + array( + 'source_url' => $source, + 'widgets' => $json['widgets'], ) ) - ); - } - - $imported = true; - foreach ( $steps as $step ) { - if ( empty( $step['success'] ) ) { - $imported = false; - } - } - - return array( - 'success' => $imported, - 'imported' => $imported, - 'slug' => $slug, - 'builder' => $found['builder'], - 'plugins_installed' => $plugins_installed, - 'plugins_planned' => array_keys( $plugins ), - 'front_page_url' => home_url( '/' ), - 'steps' => $steps, + ) ); } From 95e2c9eda0469569992a91f38ebda1880fefa5d6 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 17:16:15 +0300 Subject: [PATCH 03/13] feat: opt in to the AI Connect module Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Admin.php | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/includes/Admin.php b/includes/Admin.php index 780cd29d..f2806fb8 100755 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -76,6 +76,7 @@ public function init() { add_filter( 'ti_tpc_editor_data', array( $this, 'add_tpc_editor_data' ), 20 ); add_action( 'admin_init', array( $this, 'activation_redirect' ) ); add_filter( 'themeisle_sdk_blackfriday_data', array( $this, 'add_black_friday_data' ) ); + add_filter( 'templates_patterns_collection_ai_connect_metadata', array( $this, 'add_ai_connect_metadata' ) ); $this->setup_white_label(); @@ -755,7 +756,7 @@ public function enqueue() { } } - do_action( 'themeisle_internal_page', TIOB_BASENAME, 'onboarding' ); + do_action( 'themeisle_internal_page', 'templates-patterns-collection', 'onboarding' ); } $is_tiob_page = strpos( $screen->id, '_page_tiob-plugin' ) !== false; @@ -784,7 +785,7 @@ public function enqueue() { wp_set_script_translations( 'tiob', 'templates-patterns-collection' ); - do_action( 'themeisle_internal_page', TIOB_BASENAME, 'onboarding' ); + do_action( 'themeisle_internal_page', 'templates-patterns-collection', 'onboarding' ); } /** @@ -1461,4 +1462,33 @@ public function add_black_friday_data( $configs ) { return $configs; } + + /** + * Opt in to the SDK "Connect your AI agent" module. + * + * Importing or reverting a starter site replaces large parts of a site, so + * those abilities are left for the site owner to switch on. + * + * @return array + */ + public function add_ai_connect_metadata() { + return array( + 'name' => 'Starter Sites & Templates by Neve', + 'notice_cases' => array( + __( 'find a starter site for your niche', 'templates-patterns-collection' ), + __( 'compare the plugins each starter site needs', 'templates-patterns-collection' ), + __( 'review your last starter site import', 'templates-patterns-collection' ), + ), + 'prompts' => array( + __( 'List the Starter Sites & Templates by Neve starter sites that would suit a restaurant, with the page builder and plugins each one needs.', 'templates-patterns-collection' ), + __( 'Which free starter sites are available for Elementor? Give me their preview links.', 'templates-patterns-collection' ), + __( 'Check the status of my last starter site import and tell me what it created or changed and whether any errors were logged.', 'templates-patterns-collection' ), + ), + 'abilities' => array( + 'starter-sites/list', + 'neve/starter-site-list', + 'starter-sites/import-status', + ), + ); + } } From b1f3666faad3642011dd0deef2fd8cd802a9e8d0 Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 18 Sep 2026 21:11:50 +0300 Subject: [PATCH 04/13] feat: add the upgrade link to Pro-required ability errors Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Abilities/Starter_Sites.php | 42 ++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php index 638fe956..7ee41f40 100644 --- a/includes/Abilities/Starter_Sites.php +++ b/includes/Abilities/Starter_Sites.php @@ -147,7 +147,7 @@ private function get_definitions() { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'sites' => array( + 'sites' => array( 'type' => 'array', 'items' => array( 'type' => 'object', @@ -165,6 +165,10 @@ private function get_definitions() { ), ), ), + 'upgrade_url' => array( + 'type' => 'string', + 'description' => 'Where to upgrade to unlock the locked sites. Present only when at least one listed site is locked.', + ), ), ), 'execute_callback' => array( $this, 'list_sites' ), @@ -425,7 +429,14 @@ public function list_sites( $input = array() ) { } } - return array( 'sites' => $sites ); + $result = array( 'sites' => $sites ); + + // Locked sites need Neve Pro; tell the agent where to get it. + if ( in_array( true, array_column( $sites, 'locked' ), true ) ) { + $result['upgrade_url'] = $this->get_upgrade_url( 'locked-starter-sites' ); + } + + return $result; } /** @@ -458,7 +469,21 @@ public function import_site( $input = array() ) { // The catalogue flags premium sites the current license cannot import. if ( isset( $site['upsell'] ) && $site['upsell'] === true ) { - return new WP_Error( 'tpc_ability_premium_site', __( 'This starter site requires an active premium license.', 'templates-patterns-collection' ), array( 'status' => 403 ) ); + $upgrade_url = $this->get_upgrade_url( 'premium-starter-site' ); + + return new WP_Error( + 'tpc_ability_premium_site', + sprintf( + /* translators: 1: error message, 2: upgrade URL. */ + __( '%1$s Upgrade: %2$s', 'templates-patterns-collection' ), + __( 'This starter site requires an active premium license.', 'templates-patterns-collection' ), + $upgrade_url + ), + array( + 'status' => 403, + 'upgrade_url' => $upgrade_url, + ) + ); } $json = $this->fetch_site_json( $site ); @@ -788,6 +813,17 @@ private function pluck_names( $state, $namespace, $field ) { return array_values( array_unique( $names ) ); } + /** + * Neve Pro upgrade link, the one the starter sites screen uses, tagged for MCP. + * + * @param string $area Gated feature key, used as the campaign. + * + * @return string + */ + private function get_upgrade_url( $area ) { + return tsdk_translate_link( tsdk_utmify( 'https://themeisle.com/themes/neve/upgrade/', $area, 'mcp' ), 'query' ); + } + /** * Error returned when starter sites are disabled through white label. * From f17aa42b4c4acb998af3252ce7ee2124506c1a56 Mon Sep 17 00:00:00 2001 From: selul Date: Mon, 21 Sep 2026 10:33:37 +0300 Subject: [PATCH 05/13] chore: let AI Connect collect the abilities by prefix Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Abilities/Starter_Sites.php | 5 +++-- includes/Admin.php | 13 +++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php index 7ee41f40..4d2caed6 100644 --- a/includes/Abilities/Starter_Sites.php +++ b/includes/Abilities/Starter_Sites.php @@ -262,7 +262,8 @@ private function get_definitions() { 'meta' => array_merge( $this->get_meta( false, true, false ), array( - 'task' => array( + 'ai_connect' => false, + 'task' => array( 'mode' => 'cursor', 'results_key' => 'steps', ), @@ -296,7 +297,7 @@ private function get_definitions() { ), 'execute_callback' => array( $this, 'cleanup' ), 'permission_callback' => array( $this, 'check_permission' ), - 'meta' => $this->get_meta( false, true, false ), + 'meta' => array_merge( $this->get_meta( false, true, false ), array( 'ai_connect' => false ) ), ), ), array( diff --git a/includes/Admin.php b/includes/Admin.php index f2806fb8..a098cc64 100755 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -1473,22 +1473,19 @@ public function add_black_friday_data( $configs ) { */ public function add_ai_connect_metadata() { return array( - 'name' => 'Starter Sites & Templates by Neve', - 'notice_cases' => array( + 'name' => 'Starter Sites & Templates by Neve', + 'notice_cases' => array( __( 'find a starter site for your niche', 'templates-patterns-collection' ), __( 'compare the plugins each starter site needs', 'templates-patterns-collection' ), __( 'review your last starter site import', 'templates-patterns-collection' ), ), - 'prompts' => array( + 'prompts' => array( __( 'List the Starter Sites & Templates by Neve starter sites that would suit a restaurant, with the page builder and plugins each one needs.', 'templates-patterns-collection' ), __( 'Which free starter sites are available for Elementor? Give me their preview links.', 'templates-patterns-collection' ), __( 'Check the status of my last starter site import and tell me what it created or changed and whether any errors were logged.', 'templates-patterns-collection' ), ), - 'abilities' => array( - 'starter-sites/list', - 'neve/starter-site-list', - 'starter-sites/import-status', - ), + 'ability_prefix' => 'starter-sites', + 'abilities' => array( 'neve/starter-site-list' ), ); } } From 9da9cd1b8fc03fc8d76ec285a8c1f1482364f67f Mon Sep 17 00:00:00 2001 From: selul Date: Mon, 21 Sep 2026 13:11:34 +0300 Subject: [PATCH 06/13] chore: make the AI Connect prompts value-driven Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Admin.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/Admin.php b/includes/Admin.php index a098cc64..59f42323 100755 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -1480,9 +1480,9 @@ public function add_ai_connect_metadata() { __( 'review your last starter site import', 'templates-patterns-collection' ), ), 'prompts' => array( - __( 'List the Starter Sites & Templates by Neve starter sites that would suit a restaurant, with the page builder and plugins each one needs.', 'templates-patterns-collection' ), - __( 'Which free starter sites are available for Elementor? Give me their preview links.', 'templates-patterns-collection' ), - __( 'Check the status of my last starter site import and tell me what it created or changed and whether any errors were logged.', 'templates-patterns-collection' ), + __( 'Find a Starter Sites & Templates by Neve starter site for a restaurant, built for Elementor.', 'templates-patterns-collection' ), + __( 'Which free starter sites suit a photography portfolio? Give me their previews.', 'templates-patterns-collection' ), + __( 'Find me a bakery starter site that works with the block editor and comes with a shop.', 'templates-patterns-collection' ), ), 'ability_prefix' => 'starter-sites', 'abilities' => array( 'neve/starter-site-list' ), From a22db50254079a607999744a631d56656c86c5ac Mon Sep 17 00:00:00 2001 From: selul Date: Mon, 21 Sep 2026 15:26:07 +0300 Subject: [PATCH 07/13] fix: match the product's permission checks in starter-sites/import and starter-sites/cleanup The abilities checked `manage_options`, the capability of the `ti-sites-lib/v1` REST routes. The starter sites screen that triggers those routes is registered with `install_plugins` (Appearance page) or `activate_plugins` (Neve dedicated menu), so on multisite a site administrator could import and revert starter sites through the abilities without being able to open the screen. The abilities now check the capability the screen is registered with, through a shared helper. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Abilities/Starter_Sites.php | 14 +++--- includes/Admin.php | 24 ++++++++-- tests/abilities-test.php | 68 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 tests/abilities-test.php diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php index 4d2caed6..97f912a2 100644 --- a/includes/Abilities/Starter_Sites.php +++ b/includes/Abilities/Starter_Sites.php @@ -11,6 +11,7 @@ namespace TIOB\Abilities; +use TIOB\Admin; use TIOB\Importers\Cleanup\Active_State; use TIOB\Importers\Helpers\Slug_Mapping; use TIOB\Logger; @@ -32,11 +33,6 @@ class Starter_Sites { */ const CATEGORY = 'starter-sites'; - /** - * Capability checked by the starter sites REST routes (see Rest_Server). - */ - const CAPABILITY = 'manage_options'; - /** * Maximum number of log lines returned by the import status. */ @@ -372,12 +368,16 @@ private function get_meta( $readonly, $destructive, $idempotent ) { } /** - * Permission callback. Mirrors the `ti-sites-lib/v1` REST routes. + * Permission callback. Same capability the starter sites screen is + * registered with (see Admin::get_starter_sites_capability()), which is + * stricter than the `manage_options` of the `ti-sites-lib/v1` routes: + * on multisite a site administrator has `manage_options` but cannot + * open the screen. * * @return bool */ public function check_permission() { - return current_user_can( self::CAPABILITY ); + return current_user_can( Admin::get_starter_sites_capability() ); } /** diff --git a/includes/Admin.php b/includes/Admin.php index 59f42323..405f6a7c 100755 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -449,7 +449,7 @@ public function activation_redirect() { * * @return bool */ - private function neve_theme_has_support( $feature ) { + private static function neve_theme_has_support( $feature ) { if ( defined( 'NEVE_COMPATIBILITY_FEATURES' ) ) { $features = NEVE_COMPATIBILITY_FEATURES; return isset( $features[ $feature ] ); @@ -457,6 +457,22 @@ private function neve_theme_has_support( $feature ) { return false; } + /** + * Capability required to open the starter sites screen. + * + * The screen is a Neve dashboard sub-page when the theme provides the + * dedicated menu, and an Appearance page otherwise. + * + * @return string + */ + public static function get_starter_sites_capability() { + if ( self::neve_theme_has_support( 'theme_dedicated_menu' ) ) { + return 'activate_plugins'; + } + + return 'install_plugins'; + } + /** * Use the features defined in the TIOB plugin to check for specific support. * @@ -482,11 +498,11 @@ private function tiob_has_support( $feature ) { */ private function add_theme_page_for_tiob( $page_data, $offset = 2 ) { - if ( $this->neve_theme_has_support( 'theme_dedicated_menu' ) ) { + if ( self::neve_theme_has_support( 'theme_dedicated_menu' ) ) { global $submenu; $theme_page = 'neve-welcome'; - $capability = 'activate_plugins'; + $capability = self::get_starter_sites_capability(); add_submenu_page( $theme_page, $page_data['page_title'], @@ -567,7 +583,7 @@ private function register_starter_sites_page( $in_appearance = false ) { $starter_site_data = array( 'page_title' => __( 'Starter Sites', 'templates-patterns-collection' ), 'menu_title' => $this->get_prefix_for_menu_item() . __( 'Onboarding', 'templates-patterns-collection' ), - 'capability' => 'install_plugins', + 'capability' => self::get_starter_sites_capability(), 'menu_slug' => 'neve-onboarding', 'callback' => array( $this, diff --git a/tests/abilities-test.php b/tests/abilities-test.php new file mode 100644 index 00000000..b275beeb --- /dev/null +++ b/tests/abilities-test.php @@ -0,0 +1,68 @@ +abilities = new Starter_Sites(); + } + + public function tear_down(): void { + wp_set_current_user( 0 ); + parent::tear_down(); + } + + /** + * The abilities grant what the starter sites screen grants, no more. + * + * @covers \TIOB\Abilities\Starter_Sites::check_permission + */ + public function test_permission_matches_the_starter_sites_screen() { + $capability = Admin::get_starter_sites_capability(); + $this->assertContains( $capability, array( 'install_plugins', 'activate_plugins' ) ); + + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $this->assertTrue( current_user_can( $capability ) ); + $this->assertTrue( $this->abilities->check_permission() ); + + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + $this->assertFalse( $this->abilities->check_permission() ); + } + + /** + * A user who can manage options but cannot open the starter sites + * screen (a multisite site administrator, for instance) is denied. + * + * @covers \TIOB\Abilities\Starter_Sites::check_permission + */ + public function test_manage_options_alone_is_denied() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $user = get_user_by( 'id', $user_id ); + $user->add_cap( 'install_plugins', false ); + $user->add_cap( 'activate_plugins', false ); + wp_set_current_user( $user_id ); + + $this->assertTrue( current_user_can( 'manage_options' ) ); + $this->assertFalse( current_user_can( 'install_plugins' ) ); + $this->assertFalse( current_user_can( 'activate_plugins' ) ); + + $this->assertFalse( $this->abilities->check_permission() ); + } +} From 444dba0fab2e742a06bbca3001f02c71e2ba4d87 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 22 Sep 2026 12:11:45 +0300 Subject: [PATCH 08/13] fix: require manage_options as well as the screen capability in the starter-sites abilities The previous fix swapped manage_options for the capability the starter sites screen is registered with; the product's own import and cleanup routes still require manage_options, so a custom role holding activate_plugins alone could run a cleanup the routes would refuse. Both checks apply now. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- includes/Abilities/Starter_Sites.php | 14 ++++++++------ tests/abilities-test.php | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/includes/Abilities/Starter_Sites.php b/includes/Abilities/Starter_Sites.php index 97f912a2..7caffc1f 100644 --- a/includes/Abilities/Starter_Sites.php +++ b/includes/Abilities/Starter_Sites.php @@ -368,16 +368,18 @@ private function get_meta( $readonly, $destructive, $idempotent ) { } /** - * Permission callback. Same capability the starter sites screen is - * registered with (see Admin::get_starter_sites_capability()), which is - * stricter than the `manage_options` of the `ti-sites-lib/v1` routes: - * on multisite a site administrator has `manage_options` but cannot - * open the screen. + * Permission callback. Both checks the product applies on the way to an + * import or a cleanup: `manage_options`, which every `ti-sites-lib/v1` + * route requires, and the capability the starter sites screen is + * registered with (see Admin::get_starter_sites_capability()). Either + * alone is not enough: on multisite a site administrator has + * `manage_options` but cannot open the screen, and a custom role holding + * `activate_plugins` without `manage_options` is refused by the routes. * * @return bool */ public function check_permission() { - return current_user_can( Admin::get_starter_sites_capability() ); + return current_user_can( 'manage_options' ) && current_user_can( Admin::get_starter_sites_capability() ); } /** diff --git a/tests/abilities-test.php b/tests/abilities-test.php index b275beeb..de106024 100644 --- a/tests/abilities-test.php +++ b/tests/abilities-test.php @@ -65,4 +65,30 @@ public function test_manage_options_alone_is_denied() { $this->assertFalse( $this->abilities->check_permission() ); } + + /** + * The screen capability alone is not enough either: the product's own + * import and cleanup routes require `manage_options`. + * + * @covers \TIOB\Abilities\Starter_Sites::check_permission + */ + public function test_the_screen_capability_without_manage_options_is_denied() { + add_role( + 'tiob_installer', + 'Installer', + array( + 'read' => true, + 'install_plugins' => true, + 'activate_plugins' => true, + ) + ); + $user_id = self::factory()->user->create( array( 'role' => 'tiob_installer' ) ); + wp_set_current_user( $user_id ); + + $this->assertTrue( current_user_can( Admin::get_starter_sites_capability() ) ); + $this->assertFalse( current_user_can( 'manage_options' ) ); + $this->assertFalse( $this->abilities->check_permission() ); + + remove_role( 'tiob_installer' ); + } } From 3b716a5412dd11eb9c4286f943542a024173487b Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 11:59:26 +0300 Subject: [PATCH 09/13] chore: require Themeisle SDK 3.3.64 The first SDK release that carries the AI Connect module this PR opts into. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 03d34add..450ec08b 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,6 @@ "issues": "https://github.com/Codeinwp/templates-patterns-collection/issues" }, "require": { - "codeinwp/themeisle-sdk": "^3.2" + "codeinwp/themeisle-sdk": "^3.3.64" } } diff --git a/composer.lock b/composer.lock index e1ee0226..af01c24a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9b0fdb6b420e85ee594f91616356c353", + "content-hash": "0f1639b1b99667b1d6889fcf81cdba94", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.62", + "version": "3.3.64", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8" + "reference": "53c6f4faca201298748d6319f25a1de2767b5254" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/8363c9cab1a233095a76cd48e96fb64ce1b29ef8", - "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/53c6f4faca201298748d6319f25a1de2767b5254", + "reference": "53c6f4faca201298748d6319f25a1de2767b5254", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.62" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.64" }, - "time": "2026-09-17T17:08:21+00:00" + "time": "2026-09-29T08:42:21+00:00" } ], "packages-dev": [ From eb3c066acfcd7ac595438ccd15ae1a3451f521c9 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 12:43:45 +0300 Subject: [PATCH 10/13] chore: require Themeisle SDK 3.3.65 Picks up the AI Connect fixes since 3.3.64: the Enable button hides once the connector is active, the notice matches core's height, and the "enabled" event for products with their own notification UI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018GJfggt4S4RuAfF24E93EK --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 450ec08b..2564b427 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,6 @@ "issues": "https://github.com/Codeinwp/templates-patterns-collection/issues" }, "require": { - "codeinwp/themeisle-sdk": "^3.3.64" + "codeinwp/themeisle-sdk": "^3.3.65" } } diff --git a/composer.lock b/composer.lock index af01c24a..2299f957 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0f1639b1b99667b1d6889fcf81cdba94", + "content-hash": "daf458282e8a6aabe7e6b51fae94b2e2", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.64", + "version": "3.3.65", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "53c6f4faca201298748d6319f25a1de2767b5254" + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/53c6f4faca201298748d6319f25a1de2767b5254", - "reference": "53c6f4faca201298748d6319f25a1de2767b5254", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.64" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" }, - "time": "2026-09-29T08:42:21+00:00" + "time": "2026-09-29T09:35:47+00:00" } ], "packages-dev": [ From f3a9739c890739ffc1f9a29f5a810a4c9fc8a100 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 29 Sep 2026 13:56:13 +0300 Subject: [PATCH 11/13] release: AI agent support - Added AI agent support: let AI assistants browse and import starter sites for you. From 89e41c5231a9f725bcff3b0fda79eac511bcc5ce Mon Sep 17 00:00:00 2001 From: selul Date: Wed, 30 Sep 2026 12:08:04 +0300 Subject: [PATCH 12/13] feat: add Manage with AI card to the onboarding features list Show a label and description for easy-mcp-ai (Easy MCP AI) on the Select features step. Demo data now marks it as a mandatory plugin on all imports, and without an entry the card had no description. Co-Authored-By: Claude Opus 5.5 --- onboarding/src/Components/FeaturesList.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/onboarding/src/Components/FeaturesList.js b/onboarding/src/Components/FeaturesList.js index 679f49f1..bb59ac4c 100644 --- a/onboarding/src/Components/FeaturesList.js +++ b/onboarding/src/Components/FeaturesList.js @@ -44,6 +44,12 @@ const featuredPluginCollection = [ * Appears only if they are a part of the required plugins for template site. */ const pluginCollection = [ + { + id: 'manageWithAi', + pluginSlug: 'easy-mcp-ai', + label: __('Manage with AI', 'templates-patterns-collection'), + description: __('Connect Claude or ChatGPT to manage your site with AI.', 'templates-patterns-collection') + }, { id: 'visualizer', pluginSlug: 'visualizer', From ac9539df0afe29c58d5c5f329d310e83d4e897c6 Mon Sep 17 00:00:00 2001 From: selul Date: Wed, 30 Sep 2026 18:53:13 +0300 Subject: [PATCH 13/13] feat: skip the Easy MCP AI setup redirect after starter site import Easy MCP AI sets a 60-second transient on activation that redirects the next admin page load to its setup screen. Delete it with the other plugin activation redirects, so the import flow is not interrupted. Setup stays incomplete, so users still see the setup when they open Manage with AI. Co-Authored-By: Claude Opus 5.5 --- includes/Importers/Plugin_Importer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/Importers/Plugin_Importer.php b/includes/Importers/Plugin_Importer.php index d724871c..7a565422 100755 --- a/includes/Importers/Plugin_Importer.php +++ b/includes/Importers/Plugin_Importer.php @@ -174,6 +174,7 @@ public function run_plugins_install( $plugins_array ) { private function remove_possible_redirects() { delete_transient( '_wc_activation_redirect' ); delete_transient( 'wpforms_activation_redirect' ); + delete_transient( 'easy_mcp_ai_setup_redirect' ); update_option( 'themeisle_blocks_settings_redirect', false ); update_option( 'masteriyo_first_time_activation_flag', true ); }