_ai_connect_metadata', 'add_ai_connect_meta' ); * * function add_ai_connect_meta( $data ) { * return [ * 'name' => , // optional, defaults to the product's friendly name * 'notice_cases' => [ 'optimize new uploads', 'purge cached images', 'offload originals' ], // 2-3 short use cases * 'prompts' => [ 'Show me my delivery settings ...', ... ], // ready-to-copy prompts * 'ability_prefix' => 'optimole', // optional, every registered "optimole/..." ability is switched on in Easy MCP on Enable, except the ones registered with meta 'ai_connect' => false * 'abilities' => [ 'optimole/get-delivery-settings', ... ], // optional, explicit ability names to switch on as well * 'internal_slug' => 'mlo', // optional, the slug the product passes to themeisle_internal_page when it is not its install slug * ] * } * * @package ThemeIsleSDK * @subpackage Modules * @copyright Copyright (c) 2026, Themeisle * @license http://opensource.org/licenses/gpl-3.0.php GNU Public License * @since 3.3.62 */ namespace ThemeisleSDK\Modules; use ThemeisleSDK\Common\Abstract_Module; use ThemeisleSDK\Loader; use ThemeisleSDK\Product; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * AI Connect module for ThemeIsle SDK. */ class Ai_Connect extends Abstract_Module { const CONNECTOR_SLUG = 'easy-mcp-ai'; const CONNECTOR_FILE = 'easy-mcp-ai/easy-mcp-ai.php'; const CONNECTOR_ROUTE = 'easy-mcp-ai/v1/mcp'; const INTEGRATIONS_URL = 'https://easymcpai.com/integrations'; const ABILITIES_OPTION = 'easy_mcp_ai_enabled_abilities'; /** * Easy MCP arms this one-shot transient on activation and its admin page * redirects to the setup wizard once it is found (Setup_State::REDIRECT_TRANSIENT). */ const CONNECTOR_REDIRECT_TRANSIENT = 'easy_mcp_ai_setup_redirect'; const AJAX_ENABLE = 'themeisle_sdk_ai_connect_enable'; const AJAX_DISMISS = 'themeisle_sdk_ai_connect_dismiss'; const NONCE = 'themeisle_sdk_ai_connect'; const DISMISSED_KEY = 'themeisle_sdk_ai_connect_dismissed'; /** * After a dismissal nothing is shown for this long; then the next product * the user has not dismissed gets its turn. */ const REMIND_AFTER = 30 * DAY_IN_SECONDS; /** * A user is asked this many times at most, however many products opted in. */ const MAX_DISMISSALS = 3; /** * The notice waits this long after the product was installed. */ const MINIMUM_INSTALL_AGE = DAY_IN_SECONDS; /** * And this long after the module first ran on the site, so an update does * not greet a long-time user with it. */ const MINIMUM_SINCE = 2 * DAY_IN_SECONDS; /** * When the module first ran on this site. */ const SINCE_OPTION = 'themeisle_sdk_ai_connect_since'; /** * Every product that opted in, keyed by product key. Shared by all the * instances so one screen renders ONE notice and ONE modal, however many * Themeisle products the site runs. * * @var array */ private static $registered = array(); /** * Whether the shared hooks were added already. * * @var bool */ private static $hooked = false; /** * Slug of the product whose internal page is being displayed, if any. * * @var string */ private static $internal_product = ''; /** * Whether this request is the Customizer controls screen. * * @var bool */ private static $in_customizer = false; /** * Whether this request is the Site Editor (block themes edit their site * there, never in the Customizer). * * @var bool */ private static $in_site_editor = false; /** * Whether the assets were enqueued on this request. * * @var bool */ private static $enqueued = false; /** * The product picked for this screen. * * @var array|null */ private static $current = null; /** * This product's metadata, received from the filter. * * @var array */ private $data = array(); /** * Should we load this module. * * @param Product $product Product object. * * @return bool */ public function can_load( $product ) { if ( $this->is_from_partner( $product ) ) { return false; } if ( ! is_admin() ) { return false; } // With the connector already running there is nothing left to offer: no // notice, no plugin-row link, no modal. if ( $this->is_connector_active() ) { return false; } $this->data = self::sanitize_metadata( apply_filters( $product->get_key() . '_ai_connect_metadata', array() ), $product ); return ! empty( $this->data ); } /** * Keep only what the UI can use. A product that sends no prompt and no use * case has nothing to say, and is treated as not opted in. * * @param mixed $data Raw filter result. * @param Product $product Product object. * * @return array */ public static function sanitize_metadata( $data, $product ) { if ( ! is_array( $data ) ) { return array(); } $strings = function ( $list, $max ) { $list = is_array( $list ) ? $list : array(); $list = array_filter( array_map( function ( $item ) { return is_string( $item ) ? trim( wp_strip_all_tags( $item ) ) : ''; }, $list ) ); return array_slice( array_values( $list ), 0, $max ); }; $cases = $strings( isset( $data['notice_cases'] ) ? $data['notice_cases'] : array(), 3 ); $prompts = $strings( isset( $data['prompts'] ) ? $data['prompts'] : array(), 5 ); if ( empty( $cases ) || empty( $prompts ) ) { return array(); } $abilities = array(); foreach ( $strings( isset( $data['abilities'] ) ? $data['abilities'] : array(), 100 ) as $ability ) { if ( preg_match( '#^[a-z0-9\-]+/[a-z0-9\-/]+$#', $ability ) ) { $abilities[] = $ability; } } $prefixes = array(); $raw = isset( $data['ability_prefix'] ) ? $data['ability_prefix'] : array(); foreach ( $strings( is_string( $raw ) ? array( $raw ) : $raw, 5 ) as $prefix ) { if ( preg_match( '#^[a-z0-9\-]+$#', $prefix ) ) { $prefixes[] = $prefix; } } $name = isset( $data['name'] ) && is_string( $data['name'] ) && '' !== trim( $data['name'] ) ? trim( wp_strip_all_tags( $data['name'] ) ) : $product->get_friendly_name(); $internal_slug = isset( $data['internal_slug'] ) && is_string( $data['internal_slug'] ) ? sanitize_key( $data['internal_slug'] ) : ''; return array( 'name' => $name, 'notice_cases' => $cases, 'prompts' => $prompts, 'abilities' => $abilities, 'prefixes' => $prefixes, 'internal_slug' => '' !== $internal_slug ? $internal_slug : $product->get_slug(), ); } /** * Registers the hooks. * * @param Product $product Product to load. * * @return Ai_Connect */ public function load( $product ) { $this->product = $product; self::$registered[ $product->get_key() ] = array( 'product' => $product, 'data' => $this->data, ); if ( $product->is_plugin() ) { add_filter( 'plugin_row_meta', array( $this, 'add_row_meta' ), 10, 2 ); } if ( self::$hooked ) { return $this; } self::$hooked = true; if ( ! get_option( self::SINCE_OPTION ) ) { add_option( self::SINCE_OPTION, time(), '', false ); } add_action( 'themeisle_internal_page', array( __CLASS__, 'mark_internal_page' ), 10, 2 ); add_action( 'admin_notices', array( $this, 'render_notice' ) ); add_action( 'in_admin_header', array( $this, 'ensure_notice_hook' ), PHP_INT_MAX ); // Late: after the products had their chance to fire themeisle_internal_page. add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ), 999 ); // admin_footer runs BEFORE admin_print_footer_scripts, so the modal is in // the DOM when the script initialises. add_action( 'admin_footer', array( $this, 'render_modal' ) ); // Themes: the Customizer has no admin notices, so the same invitation // goes into its notification area (see ai-connect.js). add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_customizer' ), 999 ); add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_modal' ), 1 ); add_action( 'wp_ajax_' . self::AJAX_ENABLE, array( $this, 'ajax_enable' ) ); add_action( 'wp_ajax_' . self::AJAX_DISMISS, array( $this, 'ajax_dismiss' ) ); return $this; } /** * Remember which product's internal page this is. * * @param string $product_slug Product slug. * @param string $page_slug Page slug. * * @return void */ public static function mark_internal_page( $product_slug, $page_slug = '' ) { if ( ! is_string( $product_slug ) || '' !== self::$internal_product ) { return; } // Some products pass their plugin basename ("dir/file.php") instead of // the slug; the directory is the slug either way. if ( false !== strpos( $product_slug, '/' ) ) { $product_slug = dirname( $product_slug ); } self::$internal_product = $product_slug; } /** * Some products clear every admin_notices callback on their own screens * (remove_all_actions on load-{$hook}), which takes ours with it. Put it * back just before the notices are printed. * * @return void */ public function ensure_notice_hook() { if ( false === has_action( 'admin_notices', array( $this, 'render_notice' ) ) ) { add_action( 'admin_notices', array( $this, 'render_notice' ) ); } // Before anything prints: the review/opt-in queue picks its notice at // admin_notices and reads this timestamp, so it yields to us today. if ( $this->should_show_notice() ) { self::mark_seen(); } } /** * Did any product on this site opt in? The Promotions module asks, so the * site gets one invitation to the connector and not two. * * @return bool */ public static function has_products() { return ! empty( self::$registered ); } /** * Was the SDK's own Easy MCP promotion dismissed on this site? It is the * same invitation, so the answer counts here too. * * @return bool */ public static function is_promotion_dismissed() { $stored = json_decode( (string) get_option( 'themeisle_sdk_promotions', '{}' ), true ); if ( ! is_array( $stored ) ) { return false; } return ! empty( $stored['easy-mcp-plugins-install'] ) || ! empty( $stored['easy-mcp-profile'] ); } /** * Only users who may install AND activate plugins can do what the modal * offers, so nobody else sees any of it. * * @return bool */ public static function user_can_connect() { return current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' ); } /** * Whether this is the plugins list screen. * * @return bool */ private static function is_plugins_screen() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && 'plugins' === $screen->id; } /** * Whether this is the WordPress Dashboard (index.php). * * @return bool */ private static function is_dashboard_screen() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && 'dashboard' === $screen->id; } /** * Is this one of the theme surfaces: the themes list, the Customizer or * the Site Editor? * * @return bool */ private static function is_theme_surface() { if ( self::$in_customizer || self::is_site_editor() ) { return true; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && 'themes' === $screen->id; } /** * Whether this is the Site Editor (site-editor.php). Its admin notices are * printed behind the full-screen editor, so the invitation goes through the * editor's own notices store instead (see ai-connect.js). * * @return bool */ private static function is_site_editor() { if ( self::$in_site_editor ) { return true; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && 'site-editor' === $screen->id ) { self::$in_site_editor = true; } return self::$in_site_editor; } /** * The product this screen talks about: the owner of the internal page, on * the plugins list the opted-in plugin that was installed first, on the * themes list and in the Customizer the opted-in theme. * * @return array|null { product, data } */ private static function current() { // Not cached: products fire themeisle_internal_page from their own // admin_enqueue_scripts callbacks, so an early answer would be a wrong one. self::$current = null; if ( '' !== self::$internal_product ) { $editor = self::$in_customizer || self::is_site_editor(); foreach ( self::$registered as $entry ) { if ( $entry['product']->get_slug() !== self::$internal_product && $entry['data']['internal_slug'] !== self::$internal_product ) { continue; } // A plugin that counts the editor as its own page (Otter fires // themeisle_internal_page from the block editor) must not take // the Customizer or the Site Editor away from the theme. if ( $editor && ! $entry['product']->is_theme() ) { break; } self::$current = $entry; return self::$current; } if ( ! $editor ) { return self::$current; } } $theme_surface = self::is_theme_surface(); if ( ! $theme_surface && ! self::is_plugins_screen() ) { return null; } $oldest = PHP_INT_MAX; foreach ( self::$registered as $entry ) { if ( $entry['product']->is_theme() !== $theme_surface || self::is_product_dismissed( $entry['product'] ) ) { continue; } $installed = (int) $entry['product']->get_install_time(); if ( $installed < $oldest ) { $oldest = $installed; self::$current = $entry; } } return self::$current; } /** * Is the connector plugin active? * * @return bool */ public function is_connector_active() { return $this->is_plugin_active( self::CONNECTOR_SLUG ); } /** * Is the notice resting for this user? True for REMIND_AFTER after a * dismissal, and for good after MAX_DISMISSALS. Per user, not per site: * another administrator has not seen it yet. * * @return bool */ public static function is_dismissed() { $dismissals = self::dismissals(); if ( empty( $dismissals ) ) { return false; } return count( $dismissals ) >= self::MAX_DISMISSALS || ( time() - max( $dismissals ) ) < self::REMIND_AFTER; } /** * What this user dismissed: product key => time. Per user, not per site. * * @return array */ public static function dismissals() { $stored = get_user_meta( get_current_user_id(), self::DISMISSED_KEY, true ); if ( empty( $stored ) ) { return array(); } if ( ! is_array( $stored ) ) { // A bare time, from before dismissals were kept per product. return array( '_' => (int) $stored ); } return array_map( 'intval', $stored ); } /** * Has this user dismissed this product's notice? * * @param Product $product Product. * * @return bool */ private static function is_product_dismissed( $product ) { return array_key_exists( $product->get_key(), self::dismissals() ); } /** * Whether the notice shows on this request. * * @return bool */ public function should_show_notice() { if ( ! self::user_can_connect() || self::is_dismissed() || self::is_promotion_dismissed() ) { return false; } $current = self::current(); // A product's own page only ever talks about that product. if ( null === $current || self::is_product_dismissed( $current['product'] ) ) { return false; } if ( ( time() - (int) get_option( self::SINCE_OPTION, time() ) ) < self::MINIMUM_SINCE ) { return false; } // The sale is the one SDK notice that goes first; every other one waits for us. if ( apply_filters( 'themeisle_sdk_is_black_friday_sale', false ) ) { return false; } return ( time() - (int) $current['product']->get_install_time() ) >= self::MINIMUM_INSTALL_AGE; } /** * Tell the other SDK notices we were here, so they keep their distance: * the review/opt-in queue waits its usual days after us, and the promotions * their usual weeks after a dismissal. Ours goes first: it is marked before * the notices print, so on the same page view the queue already yields. * * @param bool $dismissed Whether the user dismissed us, or just saw us. * * @return void */ public static function mark_seen( $dismissed = false ) { $notifications = get_option( 'themeisle_sdk_notifications', array() ); $notifications = is_array( $notifications ) ? $notifications : array(); $last_active = isset( $notifications['last_notification_active'] ) ? (int) $notifications['last_notification_active'] : 0; // Once a day is enough: this runs on every page view that shows the notice. if ( $dismissed || ( time() - $last_active ) > DAY_IN_SECONDS ) { $notifications['last_notification_active'] = time(); update_option( 'themeisle_sdk_notifications', $notifications ); } if ( $dismissed ) { $promotions = json_decode( (string) get_option( 'themeisle_sdk_promotions', '{}' ), true ); $promotions = is_array( $promotions ) ? $promotions : array(); $promotions['ai-connect'] = time(); update_option( 'themeisle_sdk_promotions', wp_json_encode( $promotions ) ); } } /** * Plugin-row link, next to "View details". * * @param string[] $links Row meta links. * @param string $file Plugin base file. * * @return string[] */ public function add_row_meta( $links, $file ) { if ( plugin_basename( $this->product->get_basefile() ) !== $file || ! self::user_can_connect() ) { return $links; } $links[] = '' . self::sparkle() . esc_html( Loader::$labels['ai_connect']['row_link'] ) . ''; return $links; } /** * The notice. * * @return void */ public function render_notice() { // The Site Editor prints admin notices behind its full-screen app; the // invitation reaches it through the editor's notices store instead. if ( self::is_site_editor() || ! $this->should_show_notice() ) { return; } // Some products only fire themeisle_internal_page while their scripts // print, after admin_enqueue_scripts: the page was unknown when assets // were decided. Both are footer-safe, so enqueue them now. $this->enqueue(); $current = self::current(); $labels = Loader::$labels['ai_connect']; $do = self::join_cases( $current['data']['notice_cases'] ); ?>

should_show_notice() ); } /** * Customizer controls: same assets and modal, when the notice is due. * * @return void */ public function enqueue_customizer() { self::$in_customizer = true; $this->enqueue(); } /** * Assets, only where the link or the notice can be. * * @return void */ public function enqueue() { // The Customizer fires admin_enqueue_scripts too (WP_Customize_Widgets // does, from inside customize_controls_enqueue_scripts), so this can run // before enqueue_customizer(): recognise the screen either way. if ( did_action( 'customize_controls_init' ) ) { self::$in_customizer = true; } if ( self::$enqueued || ! $this->is_relevant_screen() ) { return; } self::$enqueued = true; $handle = 'themeisle-sdk-ai-connect'; $base = $this->get_sdk_uri() . 'assets/js/build/ai_connect/'; $asset = dirname( dirname( __DIR__ ) ) . '/assets/js/build/ai_connect/ai-connect.asset.php'; $meta = is_readable( $asset ) ? require $asset : array( 'dependencies' => array(), 'version' => Loader::get_version(), ); wp_enqueue_style( $handle, $base . 'ai-connect.css', array(), $meta['version'] ); wp_enqueue_script( $handle, $base . 'ai-connect.js', $meta['dependencies'], $meta['version'], true ); $endpoint = rest_url( self::CONNECTOR_ROUTE ); $products = array(); foreach ( self::$registered as $key => $entry ) { $products[ $key ] = array( 'name' => $entry['data']['name'], 'prompts' => $entry['data']['prompts'], ); } $current = self::current(); $labels = Loader::$labels['ai_connect']; // The button says what will happen: install and activate, or just activate. $labels['enable'] = $this->enable_label(); $labels['enabling'] = $this->is_plugin_installed( self::CONNECTOR_SLUG ) ? $labels['activating'] : $labels['enabling']; $pitch = array(); $surface = ''; // The Customizer and the Site Editor have no usable admin notices: the // script delivers the invitation through their own notification APIs. if ( self::$in_customizer ) { $surface = 'customizer'; } elseif ( self::is_site_editor() ) { $surface = 'site_editor'; } if ( '' !== $surface && null !== $current && $this->should_show_notice() ) { self::mark_seen(); $pitch = array( 'title' => sprintf( $labels['notice_title'], $current['data']['name'] ), 'text' => sprintf( $labels['notice_text'], self::join_cases( $current['data']['notice_cases'] ) ), 'icon' => self::sparkle(), ); } wp_localize_script( $handle, 'themeisleSDKAiConnect', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE ), 'actions' => array( 'enable' => self::AJAX_ENABLE, 'dismiss' => self::AJAX_DISMISS, ), 'endpoint' => $endpoint, 'links' => self::connect_links( $endpoint ), 'products' => $products, 'current' => $current ? $current['product']->get_key() : ( empty( $products ) ? '' : key( $products ) ), 'labels' => $labels, 'pitch' => $pitch, 'surface' => $surface, ) ); } /** * One-click deep links. They mirror Easy MCP's own * Admin_Page::build_connect_url() so they can be offered before it runs. * ChatGPT cannot be pre-filled: the user pastes the URL. Cursor's link wants * its `config` as Base64 JSON; the script appends it (window.btoa), because * PHP's Base64 function is refused by the WordPress.org plugin check in * every product that bundles the SDK, whatever the reason for the call. * * @param string $endpoint The site's MCP URL. * * @return array */ public static function connect_links( $endpoint ) { // get_bloginfo() returns the name escaped for display; a URL needs the real one. $title = trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ); $name = '' !== $title ? $title . ' WordPress' : 'WordPress'; return array( 'claude' => 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=' . rawurlencode( $name ) . '&connectorUrl=' . rawurlencode( $endpoint ), 'chatgpt' => 'https://chatgpt.com/plugins#settings/Connectors?create-connector=true&redirectAfter=%2Fplugins', 'cursor' => 'cursor://anysphere.cursor-deeplink/mcp/install?name=' . rawurlencode( $name ), ); } /** * The modal. Printed once, whatever the number of opted-in products. * * @return void */ public function render_modal() { if ( ! $this->is_relevant_screen() ) { return; } $labels = Loader::$labels['ai_connect']; ?> Loader::$labels['ai_connect']['error_permission'] ), 403 ); } } /** * Dismiss the notice for this user. * * @return void */ public function ajax_dismiss() { $this->guard_ajax(); $key = isset( $_POST['product'] ) ? sanitize_key( wp_unslash( $_POST['product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard_ajax(). if ( ! isset( self::$registered[ $key ] ) ) { $key = '_'; } $dismissals = self::dismissals(); $dismissals[ $key ] = time(); update_user_meta( get_current_user_id(), self::DISMISSED_KEY, $dismissals ); self::mark_seen( true ); wp_send_json_success(); } /** * Install (when missing) and activate the connector, then switch the * product's abilities on in it. * * @return void */ public function ajax_enable() { $this->guard_ajax(); $key = isset( $_POST['product'] ) ? sanitize_key( wp_unslash( $_POST['product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard_ajax(). $entry = isset( self::$registered[ $key ] ) ? self::$registered[ $key ] : null; $result = $this->install_and_activate(); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 ); } if ( null !== $entry ) { self::enable_abilities( array_merge( $entry['data']['abilities'], self::abilities_with_prefix( $entry['data']['prefixes'] ) ) ); } $endpoint = rest_url( self::CONNECTOR_ROUTE ); wp_send_json_success( array( 'endpoint' => $endpoint, 'links' => self::connect_links( $endpoint ), ) ); } /** * Install from WordPress.org when missing, then activate. Both steps are * skipped when already done, so calling it twice is harmless. * * @return true|\WP_Error */ public function install_and_activate() { if ( $this->is_connector_active() ) { return true; } require_once ABSPATH . 'wp-admin/includes/plugin.php'; if ( ! $this->is_plugin_installed( self::CONNECTOR_SLUG ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; if ( ! function_exists( 'plugins_api' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; } require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; $api = plugins_api( 'plugin_information', array( 'slug' => self::CONNECTOR_SLUG, 'fields' => array( 'sections' => false ), ) ); if ( is_wp_error( $api ) ) { return $api; } if ( ! is_object( $api ) || empty( $api->download_link ) ) { return new \WP_Error( 'themeisle_ai_connect_install_failed', Loader::$labels['ai_connect']['error_install'] ); } $upgrader = new \Plugin_Upgrader( new \WP_Ajax_Upgrader_Skin() ); $installed = $upgrader->install( $api->download_link ); if ( is_wp_error( $installed ) ) { return $installed; } if ( true !== $installed ) { $errors = $upgrader->skin->get_errors(); return is_wp_error( $errors ) && $errors->has_errors() ? $errors : new \WP_Error( 'themeisle_ai_connect_install_failed', Loader::$labels['ai_connect']['error_install'] ); } wp_clean_plugins_cache(); } $activated = activate_plugin( self::CONNECTOR_FILE ); if ( is_wp_error( $activated ) ) { return $activated; } self::disarm_connector_setup_redirect(); return true; } /** * The person is in the modal, about to copy the MCP URL and connect an * agent: Easy MCP's setup-wizard redirect, armed by its activation hook for * the next admin page load, would take them away from that. Disarm it; the * wizard is still offered on Easy MCP's own page. * * @return void */ public static function disarm_connector_setup_redirect() { delete_transient( self::CONNECTOR_REDIRECT_TRANSIENT ); } /** * Switch the product's abilities on in the connector, keeping whatever the * site owner enabled before. Only names that are really registered are * added, and nothing is ever removed. * * @param string[] $abilities Ability names declared by the product. * * @return string[] The names that were added. */ public static function enable_abilities( $abilities ) { if ( empty( $abilities ) || ! current_user_can( 'manage_options' ) ) { return array(); } // A name can come from both the explicit list and the prefix. $abilities = array_values( array_unique( array_filter( $abilities, 'is_string' ) ) ); if ( function_exists( 'wp_get_abilities' ) ) { $abilities = array_values( array_intersect( $abilities, array_keys( (array) wp_get_abilities() ) ) ); } $enabled = get_option( self::ABILITIES_OPTION, array() ); $enabled = is_array( $enabled ) ? $enabled : array(); $added = array_values( array_diff( $abilities, $enabled ) ); if ( ! empty( $added ) ) { update_option( self::ABILITIES_OPTION, array_values( array_merge( $enabled, $added ) ) ); } return $added; } /** * The registered abilities under the given name prefixes, minus the ones * the product registered with meta 'ai_connect' => false. * * @param string[] $prefixes Ability name prefixes, without the slash. * * @return string[] */ public static function abilities_with_prefix( $prefixes ) { if ( empty( $prefixes ) || ! function_exists( 'wp_get_abilities' ) ) { return array(); } $names = array(); foreach ( (array) wp_get_abilities() as $name => $ability ) { if ( ! in_array( strtok( (string) $name, '/' ), $prefixes, true ) ) { continue; } if ( is_object( $ability ) && method_exists( $ability, 'get_meta_item' ) && false === $ability->get_meta_item( 'ai_connect', true ) ) { continue; } $names[] = (string) $name; } return $names; } /** * The Enable button's label: the plugin is installed and only needs * activating, or it is fetched from WordPress.org first. * * @return string */ private function enable_label() { $labels = Loader::$labels['ai_connect']; return $this->is_plugin_installed( self::CONNECTOR_SLUG ) ? $labels['enable_installed'] : $labels['enable']; } /** * The sparkle icon. Dashicons has no AI icon. * * @return string */ public static function sparkle() { return ''; } /** * Forget everything. Tests only. * * @return void */ public static function reset() { self::$registered = array(); self::$hooked = false; self::$internal_product = ''; self::$in_customizer = false; self::$in_site_editor = false; delete_option( self::SINCE_OPTION ); self::$current = null; self::$enqueued = false; } }