load_dependencies(); $this->init_components(); $this->register_hooks(); } /** * Load required files */ private function load_dependencies() { require_once plugin_dir_path( __FILE__ ) . 'class-settings-pages.php'; require_once plugin_dir_path( __FILE__ ) . 'class-setup-wizard.php'; require_once plugin_dir_path( __FILE__ ) . 'class-post-meta-box.php'; require_once plugin_dir_path( __FILE__ ) . 'class-deactivation-feedback.php'; } /** * Initialize components */ private function init_components() { $this->settings_pages = new AI_Search_Settings_Pages(); $this->setup_wizard = new AI_Search_Setup_Wizard(); $this->post_meta_box = new AI_Search_Post_Meta_Box(); new AI_Search_Deactivation_Feedback(); } /** * Register admin hooks */ private function register_hooks() { add_action( 'admin_menu', [ $this, 'register_settings_menu' ] ); add_action( 'admin_notices', [ $this->setup_wizard, 'show_setup_notice' ] ); add_action( 'admin_notices', [ $this->setup_wizard, 'show_setup_success_notice' ] ); add_action( 'admin_notices', [ $this, 'show_domain_mismatch_notice' ] ); add_action( 'admin_notices', [ $this, 'show_woocommerce_welcome_notice' ] ); add_action( 'admin_notices', [ $this, 'show_quota_warning_banner' ] ); add_action( 'admin_notices', [ $this, 'show_pro_upgrade_banner' ] ); add_action( 'admin_post_ai_search_snooze_quota_banner', [ $this, 'snooze_quota_banner' ] ); add_action( 'admin_post_ai_search_dismiss_quota_banner_forever', [ $this, 'dismiss_quota_banner_forever' ] ); add_action( 'admin_post_ai_search_dismiss_pro_banner_forever', [ $this, 'dismiss_pro_banner_forever' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] ); add_action( 'admin_enqueue_scripts', [ $this->setup_wizard, 'enqueue_scripts' ] ); add_action( 'admin_head', [ $this, 'add_menu_icon_styles' ] ); add_filter( 'parent_file', [ $this, 'fix_admin_parent_file' ] ); add_filter( 'submenu_file', [ $this, 'fix_admin_submenu_file' ] ); // Admin post actions add_action( 'admin_post_ai_search_generate_embeddings', [ $this->settings_pages, 'generate_selected_embeddings' ] ); add_action( 'admin_post_ai_search_generate_term_embeddings', [ $this->settings_pages, 'generate_term_embeddings_bulk' ] ); add_action( 'admin_post_ai_search_clear_cache', [ $this->settings_pages, 'clear_embeddings_cache' ] ); add_action( 'admin_post_ai_search_clear_cache_filtered', [ $this->settings_pages, 'clear_embeddings_cache_filtered' ] ); add_action( 'admin_post_ai_search_validate_token', [ $this->settings_pages, 'validate_service_token' ] ); add_action( 'admin_post_ai_search_recover_token', [ $this->settings_pages, 'recover_service_token' ] ); add_action( 'admin_post_ai_search_save_custom_fields', [ $this->settings_pages, 'save_custom_fields_settings' ] ); add_action( 'admin_post_ai_search_save_woocommerce_fields', [ $this->settings_pages, 'save_woocommerce_fields_settings' ] ); add_action( 'admin_post_ai_search_refresh_quota', [ $this->settings_pages, 'refresh_quota_data' ] ); add_action( 'admin_post_ai_search_claim_subscription', [ $this->settings_pages, 'handle_claim_subscription' ] ); add_action( 'admin_post_ai_search_release_slot', [ $this->settings_pages, 'handle_release_slot' ] ); add_action( 'admin_post_ai_search_dismiss_reindex_notice', [ $this->settings_pages, 'handle_dismiss_reindex_notice' ] ); add_action( 'admin_post_ai_search_complete_setup', [ $this->setup_wizard, 'complete_setup' ] ); add_action( 'admin_post_ai_search_reconnect_service', [ $this, 'reconnect_service' ] ); // AJAX actions add_action( 'wp_ajax_ai_search_validate_token', [ $this->settings_pages, 'validate_service_token' ] ); add_action( 'wp_ajax_ai_search_get_taxonomies', [ $this, 'ajax_get_taxonomies' ] ); add_action( 'wp_ajax_ai_search_get_terms', [ $this, 'ajax_get_terms' ] ); add_action( 'wp_ajax_ai_search_dismiss_woo_welcome', [ $this, 'ajax_dismiss_woo_welcome' ] ); add_action( 'admin_post_ai_search_dismiss_blocks_notice', [ $this, 'dismiss_blocks_notice' ] ); add_action( 'admin_post_ai_search_snooze_pro_banner', [ $this, 'snooze_pro_banner' ] ); add_action( 'wp_ajax_ai_search_wizard_generate_batch', [ $this->setup_wizard, 'ajax_generate_batch' ] ); add_action( 'wp_ajax_ai_search_wizard_prepare_sync', [ $this->setup_wizard, 'ajax_prepare_sync' ] ); add_action( 'wp_ajax_ai_search_dismiss_wizard', [ $this->setup_wizard, 'ajax_dismiss_wizard' ] ); add_action( 'wp_ajax_ai_search_dismiss_setup_success', [ $this->setup_wizard, 'ajax_dismiss_setup_success' ] ); add_action( 'wp_ajax_ai_search_test_query', [ $this->settings_pages, 'ajax_test_query' ] ); } /** * AJAX handler to get taxonomies for a post type. */ public function ajax_get_taxonomies() { check_ajax_referer( 'ai_search_ajax' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized' ); } $post_type = isset( $_POST['post_type'] ) ? sanitize_text_field( $_POST['post_type'] ) : 'post'; $taxonomies = get_object_taxonomies( $post_type, 'objects' ); $result = []; foreach ( $taxonomies as $taxonomy ) { if ( $taxonomy->public && $taxonomy->show_ui ) { $result[ $taxonomy->name ] = [ 'label' => $taxonomy->label, 'name' => $taxonomy->name, ]; } } wp_send_json_success( $result ); } /** * AJAX handler to get terms for a taxonomy. */ public function ajax_get_terms() { check_ajax_referer( 'ai_search_ajax' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized' ); } $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : ''; if ( empty( $taxonomy ) || ! taxonomy_exists( $taxonomy ) ) { wp_send_json_error( 'Invalid taxonomy' ); } $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 100, ] ); if ( is_wp_error( $terms ) ) { wp_send_json_error( $terms->get_error_message() ); } $result = []; foreach ( $terms as $term ) { $result[] = [ 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'count' => $term->count, ]; } wp_send_json_success( $result ); } /** * Enqueue admin CSS and JS */ public function enqueue_admin_assets( $hook ) { // Only load on our settings page (now it's a top-level menu) if ( 'toplevel_page_ai-search' !== $hook ) { return; } // Enqueue WordPress pointer for compatibility with other plugins wp_enqueue_script( 'wp-pointer' ); wp_enqueue_style( 'wp-pointer' ); wp_enqueue_style( 'ai-search-admin-settings', plugins_url( 'css/admin-settings.css', __FILE__ ), [], AI_SEARCH_VERSION ); } /** * Register settings menu in admin */ public function register_settings_menu() { // Main menu page add_menu_page( 'AI Search', // Page title 'AI Search', // Menu title 'manage_options', // Capability 'ai-search', // Menu slug [ $this->settings_pages, 'display_settings_page' ], // Callback AI_SEARCH_URL . 'assets/icon.svg', // Icon 58 // Position (before Settings) ); // Submenu pages add_submenu_page( 'ai-search', // Parent slug 'General Settings', // Page title 'General Settings', // Menu title 'manage_options', // Capability 'ai-search', // Menu slug (same as parent for first item) [ $this->settings_pages, 'display_settings_page' ] // Callback ); // The Setup Wizard submenu only makes sense until the user has // walked through it. After that it's noise: the same settings live // in the tabs, and a permanent "Setup Wizard" item next to the // General/Search/Indexing surface reads like the plugin still // isn't configured. The wizard URL keeps working for anyone who // navigates to it manually. if ( ! get_option( 'ai_search_setup_completed', false ) ) { add_submenu_page( 'ai-search', 'Setup Wizard', 'Setup Wizard', 'manage_options', 'ai-search&setup=wizard', [ $this->settings_pages, 'display_settings_page' ] ); } // Upgrade submenu — Free plan only, AI Service provider only. // * Paid customers already got what they were pitched, a permanent // "Upgrade" item after checkout reads as unresolved status. // * OpenAI (BYO key) users pay OpenAI directly and have no plan // concept in our service — showing them "Upgrade — 7-day trial" // would be misleading, so hide it. if ( 'ai_service' === get_option( 'ai_search_provider', 'ai_service' ) && $this->is_on_free_plan() ) { add_submenu_page( 'ai-search', __( 'Upgrade', 'ai-search' ), __( 'Upgrade: 7-day trial', 'ai-search' ), 'manage_options', 'ai-search-upgrade', [ $this, 'display_upgrade_page' ] ); } add_submenu_page( 'ai-search', 'Support Forum', 'Support Forum', 'manage_options', 'https://wordpress.org/support/plugin/ai-search/', '' ); add_submenu_page( 'ai-search', 'Contact the Author', 'Contact the Author', 'manage_options', 'https://samuelsilva.pt/lets-talk', '' ); } /** * Whether this site is currently on the Free plan. * * Reads from the quota transient the Quota Manager keeps (6h cache) — * NEVER makes an HTTP call from here, because admin_menu fires on * every wp-admin page load and blocking the sidebar on a 20s request * to Railway would tank the whole dashboard. If the transient is * empty, default to Free: worst case the paid customer sees an * unnecessary "Upgrade" item until the next quota refresh — mildly * annoying, not broken. * * @return bool */ private function is_on_free_plan() { $quota = get_transient( 'ai_search_quota_data' ); if ( ! is_array( $quota ) ) { return true; } $plan = isset( $quota['plan'] ) ? $quota['plan'] : null; $sub = isset( $quota['subscription'] ) ? $quota['subscription'] : null; $key = is_array( $plan ) && isset( $plan['key'] ) ? (string) $plan['key'] : 'free'; return ! ( 'free' !== $key && is_array( $sub ) ); } /** * Render the Upgrade page — a full-height iframe of the pricing page * in embed mode so the customer never leaves wp-admin. */ public function display_upgrade_page() { // `?minimal=1` triggers our own embed-mode CSS on wp-search.ai (hides // header/footer). Do NOT use `?embed=1` here: WordPress reserves that // query var for its oEmbed handler and would serve the minimal oEmbed // preview (title + excerpt + site name) instead of the actual page. $pricing_url = 'https://wp-search.ai/pricing/?minimal=1&utm_source=wp-admin&utm_medium=upgrade-menu'; ?>
settings_pages; } /** * Get setup wizard instance */ public function get_setup_wizard() { return $this->setup_wizard; } /** * Add custom CSS for menu icon sizing */ public function add_menu_icon_styles() { ?>

:

:

check_origin_match() ) { return; } $registered_origin = $service->get_registered_origin(); $current_origin = get_site_url(); $reconnect_url = wp_nonce_url( admin_url( 'admin-post.php?action=ai_search_reconnect_service' ), 'ai_search_reconnect_service' ); ?>

' . esc_html( $registered_origin ) . '', '' . esc_html( $current_origin ) . '' ); ?>

register_origin(); if ( $new_token ) { wp_redirect( admin_url( 'admin.php?page=ai-search&ai_search_reconnected=success' ) ); } else { wp_redirect( admin_url( 'admin.php?page=ai-search&ai_search_reconnected=error' ) ); } exit; } /** * Show a one-time welcome notice when WooCommerce is active. */ public function show_woocommerce_welcome_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! class_exists( 'WooCommerce' ) ) { return; } if ( ! get_option( 'ai_search_setup_completed', false ) ) { return; } if ( get_option( 'ai_search_woo_welcome_dismissed', false ) ) { return; } $settings_url = admin_url( 'admin.php?page=ai-search&tab=woocommerce' ); $nonce = wp_create_nonce( 'ai_search_dismiss_woo_welcome' ); ?>

:

id, 'ai-search' ) ) { return; } // Do not stack on top of the Subscription hero after a fresh claim, // or the reindex reminder card - both live on the General tab and // deserve to be the star for that first load. if ( get_transient( 'ai_search_claim_success' ) ) { return; } if ( ! get_option( 'ai_search_setup_completed', false ) ) { return; } // v1.35.0: if the quota banner is going to render this request, don't // stack another purple upsell above/below it - the quota story is // more urgent and specific, let it stand alone. if ( $this->resolve_quota_banner_variant() ) { return; } // v1.35.0: permanent opt-out. The 60-day snooze is fine when the // user is undecided; anyone who clicks "Don't show again" has // decided, and we owe them silence. if ( get_option( 'ai_search_pro_banner_dismissed_forever', false ) ) { return; } $snoozed_until = (int) get_option( 'ai_search_pro_banner_snoozed_until', 0 ); if ( $snoozed_until > time() ) { return; } // Skip paid customers. $quota = get_transient( 'ai_search_quota_data' ); if ( is_array( $quota ) && isset( $quota['plan']['key'] ) && 'free' !== $quota['plan']['key'] ) { return; } $pricing_url = 'https://wp-search.ai/pricing/?utm_source=wp-admin&utm_medium=pro-banner'; $dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=ai_search_snooze_pro_banner' ), 'ai_search_snooze_pro_banner' ); $forever_url = wp_nonce_url( admin_url( 'admin-post.php?action=ai_search_dismiss_pro_banner_forever' ), 'ai_search_dismiss_pro_banner_forever' ); ?>
= 80% used), 'quota_exhausted' * (>= 100% used), or null (do not show). Also honours the preview * query var so the design can be reviewed on any account state. * * The 80/100 thresholds compare the WORSE of the daily and monthly * fractions - a store that has burned 100% of today's throttle wants * the same red banner as one that used up its monthly cap. * * @since 1.35.0 * * @return string|null 'quota_warning' | 'quota_exhausted' | null */ private function resolve_quota_banner_variant() { // Preview mode: any admin can force a variant with ?ai_search_preview=... // on a plugin page to review the design. This bypasses plan/quota // checks so paid customers or fresh installs can see it too. $preview = isset( $_GET['ai_search_preview'] ) ? sanitize_key( (string) $_GET['ai_search_preview'] ) : ''; if ( in_array( $preview, [ 'quota_warning', 'quota_exhausted' ], true ) ) { return $preview; } $quota = get_transient( 'ai_search_quota_data' ); if ( ! is_array( $quota ) ) { return null; } // Only nag Free plans. Paid customers already have room. $plan_key = isset( $quota['plan']['key'] ) ? (string) $quota['plan']['key'] : 'free'; if ( 'free' !== $plan_key ) { return null; } $used_daily = (int) ( $quota['daily_usage'] ?? 0 ); $limit_daily = (int) ( $quota['daily_limit'] ?? 0 ); $used_total = (int) ( $quota['total_embeddings_used'] ?? 0 ); $limit_total = (int) ( $quota['total_embeddings_limit'] ?? 0 ); $daily_pct = $limit_daily > 0 ? ( $used_daily / $limit_daily ) : 0; $total_pct = $limit_total > 0 ? ( $used_total / $limit_total ) : 0; $worst = max( $daily_pct, $total_pct ); if ( $worst >= 1.0 ) { return 'quota_exhausted'; } if ( $worst >= 0.8 ) { return 'quota_warning'; } return null; } /** * Render the quota-warning banner when Free-plan usage approaches or * hits the cap. Two variants (see resolve_quota_banner_variant()), * both dismissable via a short (3-day) snooze because the underlying * situation is dynamic - a purchase reset or a fresh month can flip * the state on its own. * * @since 1.35.0 */ public function show_quota_warning_banner() { if ( ! current_user_can( 'manage_options' ) ) { return; } $screen = get_current_screen(); if ( ! $screen || false === strpos( (string) $screen->id, 'ai-search' ) ) { return; } // Do not stack on the claim-success hero or a fresh install without // setup. Same reasoning as show_pro_upgrade_banner(). if ( get_transient( 'ai_search_claim_success' ) ) { return; } if ( ! get_option( 'ai_search_setup_completed', false ) ) { return; } // Snooze / permanent-dismiss respect - both ignored when previewing // so the design can be reviewed even after the user opted out. $preview = isset( $_GET['ai_search_preview'] ) ? sanitize_key( (string) $_GET['ai_search_preview'] ) : ''; if ( ! $preview && get_option( 'ai_search_quota_banner_dismissed_forever', false ) ) { return; } $snoozed_until = (int) get_option( 'ai_search_quota_banner_snoozed_until', 0 ); if ( ! $preview && $snoozed_until > time() ) { return; } $variant = $this->resolve_quota_banner_variant(); if ( ! $variant ) { return; } $pricing_url = 'https://wp-search.ai/pricing/?utm_source=wp-admin&utm_medium=quota-banner&utm_content=' . rawurlencode( $variant ); $dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=ai_search_snooze_quota_banner' ), 'ai_search_snooze_quota_banner' ); $forever_url = wp_nonce_url( admin_url( 'admin-post.php?action=ai_search_dismiss_quota_banner_forever' ), 'ai_search_dismiss_quota_banner_forever' ); if ( 'quota_exhausted' === $variant ) { $title = __( 'Monthly cap reached - new content is no longer being indexed', 'ai-search' ); $lede = __( 'You have used every embedding on the Free plan. Existing search keeps working, but new posts and updates will not be searchable until you upgrade or the monthly counter resets.', 'ai-search' ); $cta = __( 'Upgrade to keep indexing', 'ai-search' ); } else { $title = __( 'You are running low on Free-plan embeddings', 'ai-search' ); $lede = __( 'At the current pace you will hit the monthly cap soon. Standard raises it to 25,000/month, runs on the more precise text-embedding-3-large model, and starts with a 7-day free trial.', 'ai-search' ); $cta = __( 'See Standard', 'ai-search' ); } ?>