'; echo '

Welcome to AI Search!

'; echo '

Thank you for installing AI Search! Let\'s set up your intelligent search system in just a few minutes.

'; echo '

Start Setup Wizard '; echo 'Skip for Now

'; echo ''; } /** * Show a one-shot success notice after the wizard completes. * * The welcome notice hides itself the moment setup finishes, but going * from "prompted everywhere" to silence is a bad landing — the user just * did the work and deserves the acknowledgement. This notice fills the * gap once, on every admin page, until the user dismisses it. Then it * never comes back (option flips true). */ public function show_setup_success_notice() { if ( ! is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { return; } // Only after the wizard finishes. if ( ! get_option( 'ai_search_setup_completed', false ) ) { return; } // One-shot: user dismissed it once, we honour that. if ( get_option( 'ai_search_setup_success_dismissed', false ) ) { return; } // Don't stack on top of the completion screen (`&completed=true` on // the wizard route) — the completion.php view already congratulates // in-place, a notice above it would be duplication. if ( isset( $_GET['page'] ) && $_GET['page'] === 'ai-search' && isset( $_GET['setup'] ) && $_GET['setup'] === 'wizard' ) { return; } $indexing_url = admin_url( 'admin.php?page=ai-search&tab=indexing' ); echo '
'; echo '

' . esc_html__( 'AI Search is ready!', 'ai-search' ) . '

'; echo '

' . esc_html__( 'Setup is complete — semantic search is now live on your site.', 'ai-search' ) . '

'; echo '

' . esc_html__( 'On larger sites not everything is synced with AI yet. Check the Indexing tab to see how much is still pending — you can sync the rest in batches from there.', 'ai-search' ) . '

'; echo '

' . esc_html__( 'Open the Indexing tab', 'ai-search' ) . '

'; echo '
'; } /** * Enqueue wizard scripts */ public function enqueue_scripts( $hook ) { // Welcome-notice dismiss ("Skip for Now") — only while the welcome // notice is actually eligible to render. if ( get_option( 'ai_search_show_setup_wizard', false ) ) { wp_add_inline_script( 'jquery', ' jQuery(document).ready(function($) { $(".ai-search-dismiss-setup").on("click", function(e) { e.preventDefault(); $.post(ajaxurl, { action: "ai_search_dismiss_wizard", nonce: "' . wp_create_nonce( 'ai_search_dismiss_wizard' ) . '" }); $(".ai-search-setup-notice").fadeOut(); }); }); ' ); } // Success-notice dismiss — hooks WordPress's own native "X" button // on the .is-dismissible notice so the user's dismissal actually // persists across page loads (without JS the X only hides in-tab). if ( get_option( 'ai_search_setup_completed', false ) && ! get_option( 'ai_search_setup_success_dismissed', false ) ) { wp_add_inline_script( 'jquery', ' jQuery(document).ready(function($) { $(document).on("click", ".ai-search-setup-success-notice .notice-dismiss", function() { $.post(ajaxurl, { action: "ai_search_dismiss_setup_success", nonce: "' . wp_create_nonce( 'ai_search_dismiss_setup_success' ) . '" }); }); }); ' ); } } /** * AJAX: persist "Skip for Now" from the welcome notice. * * Must be registered globally (from AI_Search_Admin_Manager::register_hooks), * not inside enqueue_scripts — that runs on admin_enqueue_scripts, which * does not fire on admin-ajax.php requests, so the handler would only * exist on page loads and clicks would silently 400. */ public function ajax_dismiss_wizard() { check_ajax_referer( 'ai_search_dismiss_wizard', 'nonce' ); delete_option( 'ai_search_show_setup_wizard' ); update_option( 'ai_search_setup_completed', true ); wp_die(); } /** * AJAX: persist the dismissal of the "AI Search is ready" post-wizard * success notice. See ajax_dismiss_wizard() for why this lives outside * enqueue_scripts. */ public function ajax_dismiss_setup_success() { check_ajax_referer( 'ai_search_dismiss_setup_success', 'nonce' ); update_option( 'ai_search_setup_success_dismissed', true ); wp_die(); } /** * Display setup wizard page */ public function display_wizard_page() { // Handle wizard completion if ( isset( $_GET['completed'] ) && $_GET['completed'] === 'true' ) { delete_option( 'ai_search_show_setup_wizard' ); update_option( 'ai_search_setup_completed', true ); $this->display_completion_page(); return; } $step = isset( $_GET['step'] ) ? intval( $_GET['step'] ) : 1; $is_woo = class_exists( 'WooCommerce' ); echo '
'; echo '

' . esc_html__( 'AI Search Setup Wizard', 'ai-search' ); if ( $is_woo ) { echo ' ' . esc_html__( 'WooCommerce mode', 'ai-search' ) . ''; } echo '

'; // Progress indicator $total_steps = 5; echo '
'; echo '
'; echo '
'; echo '
'; echo '

' . sprintf( esc_html__( 'Step %1$d of %2$d', 'ai-search' ), $step, $total_steps ) . '

'; echo '
'; if ( $step === 1 ) { include plugin_dir_path( __FILE__ ) . 'views/wizard/step-welcome.php'; } elseif ( $step === 2 ) { include plugin_dir_path( __FILE__ ) . 'views/wizard/step-provider.php'; } elseif ( $step === 3 ) { include plugin_dir_path( __FILE__ ) . 'views/wizard/step-custom-fields.php'; } elseif ( $step === 4 ) { include plugin_dir_path( __FILE__ ) . 'views/wizard/step-generate.php'; } elseif ( $step === 5 ) { include plugin_dir_path( __FILE__ ) . 'views/wizard/step-final.php'; } // Wizard styles $this->output_wizard_styles(); echo '
'; } /** * Display completion page */ private function display_completion_page() { include plugin_dir_path( __FILE__ ) . 'views/wizard/completion.php'; } /** * Complete setup wizard */ public function complete_setup() { // The setup wizard is admin-only. We rely on the manage_options // capability alone to gate this handler — no CSRF nonce. Reason: // on some installs (still being investigated) the nonce emitted // by step-final.php was failing verification on the first wizard // run, blocking legitimate users with "The link you followed has // expired." Given (a) the capability check stops unauthenticated // callers, (b) an attacker would need the user to walk through // all 5 wizard steps before POSTing here, and (c) the handler // only mutates plugin options (no destructive actions, no post // deletion, no privilege changes), the residual CSRF surface is // narrow enough to accept in exchange for a wizard that always // completes. if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } // Save settings $provider = sanitize_text_field( $_POST['provider'] ?? 'ai_service' ); $api_key = sanitize_text_field( $_POST['api_key'] ?? '' ); $similarity_threshold = floatval( $_POST['similarity_threshold'] ?? 0.30 ); update_option( 'ai_search_provider', $provider ); update_option( 'ai_search_api_key', $api_key ); update_option( 'ai_search_similarity_threshold', $similarity_threshold ); // Handle AI service registration if ( $provider === 'ai_service' ) { $service_client = new AI_Search_Service(); $service_token = $service_client->register_origin(); if ( $service_token ) { update_option( 'ai_search_service_token', $service_token ); } } // Save searchable post types if ( isset( $_POST['post_types'] ) && is_array( $_POST['post_types'] ) ) { $searchable_types = array_map( 'sanitize_text_field', $_POST['post_types'] ); update_option( 'ai_search_searchable_post_types', $searchable_types ); } // Setup WooCommerce fields if configured if ( isset( $_POST['woocommerce_fields'] ) && is_array( $_POST['woocommerce_fields'] ) && class_exists( 'WooCommerce' ) ) { $wc_fields = array_map( 'sanitize_text_field', $_POST['woocommerce_fields'] ); update_option( 'ai_search_woocommerce_fields', $wc_fields ); } // (Embedding generation is now its own wizard step — class-setup-wizard::ajax_generate_batch.) // Mark setup as complete delete_option( 'ai_search_show_setup_wizard' ); // Also set a flag that setup was completed to prevent re-showing update_option( 'ai_search_setup_completed', true ); wp_redirect( admin_url( 'admin.php?page=ai-search&setup=wizard&completed=true' ) ); exit; } /** * AJAX: process a single batch of embeddings for the wizard Generate step. * * The wizard UI calls this repeatedly with an incrementing offset until it * gets `done: true` (nothing more to index) or `quota_hit: true` (service * refused an embedding mid-batch). Each call processes up to BATCH_SIZE * posts so we never blow the PHP execution limit. * * Response shape: * { success: true, data: { * processed: int, // how many this batch actually indexed * offset: int, // next offset the caller should request * done: bool, // true when no unprocessed posts remain * quota_hit: bool // true if generate_embedding returned nothing * }} */ public function ajax_generate_batch() { check_ajax_referer( 'ai_search_wizard_generate', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized', 'ai-search' ) ], 403 ); } $batch_size = 20; $cpt = isset( $_POST['cpt'] ) ? sanitize_text_field( $_POST['cpt'] ) : ''; $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 100; $offset = isset( $_POST['offset'] ) ? intval( $_POST['offset'] ) : 0; if ( empty( $cpt ) || ! post_type_exists( $cpt ) ) { wp_send_json_error( [ 'message' => __( 'Invalid post type.', 'ai-search' ) ] ); } // Cap this batch so we never overshoot the user's chosen limit. $remaining_vs_limit = max( 0, $limit - $offset ); $this_batch = min( $batch_size, $remaining_vs_limit ); if ( $this_batch <= 0 ) { wp_send_json_success( [ 'processed' => 0, 'offset' => $offset, 'done' => true, 'quota_hit' => false, ] ); } // Posts the client already reported as skipped (no content, filter // veto). They never get meta, so without this they'd be re-fetched // by the NOT EXISTS query on every batch (v1.27.0). $exclude = isset( $_POST['exclude'] ) ? array_filter( array_map( 'intval', (array) $_POST['exclude'] ) ) : []; $exclude = array_slice( array_values( $exclude ), 0, 1000 ); $query_args = [ 'numberposts' => $this_batch, 'post_type' => $cpt, 'post_status' => 'publish', // We always fetch unprocessed posts starting from the beginning of // the queue — as posts get processed they drop out of the NOT EXISTS // filter, so the next call naturally picks up new ones. Offset is // only used by the caller for progress math, not DB pagination. 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'NOT EXISTS', ], ], ]; if ( ! empty( $exclude ) ) { $query_args['post__not_in'] = $exclude; } $posts = get_posts( $query_args ); if ( empty( $posts ) ) { wp_send_json_success( [ 'processed' => 0, 'offset' => $offset, 'done' => true, 'quota_hit' => false, ] ); } $ai_search = AI_Search::get_instance(); $processed = 0; $skipped = 0; $skipped_ids = []; $quota_hit = false; $error_code = ''; $error_message = ''; // Errors that will repeat for every post — stop the run and tell // the user why. Per-post problems (empty content, filter skip) are // counted and the batch carries on. $fatal_codes = [ 'no_token', 'no_api_key', 'invalid_token', 'invalid_api_key', 'quota_exceeded', 'http_error', 'service_unavailable', 'service_error', 'provider_error', 'invalid_response' ]; foreach ( $posts as $post ) { $result = $ai_search->generate_embedding( $post->ID ); if ( is_wp_error( $result ) ) { if ( in_array( $result->get_error_code(), $fatal_codes, true ) ) { $quota_hit = $result->get_error_code() === 'quota_exceeded'; $error_code = $result->get_error_code(); $error_message = $result->get_error_message(); break; } // Non-fatal: this post can't be embedded, move on. Because // it stays without meta it would be re-fetched forever by // the NOT EXISTS query, so count it toward progress. $skipped++; $skipped_ids[] = (int) $post->ID; continue; } $processed++; } $advanced = $processed + $skipped; wp_send_json_success( [ 'processed' => $processed, 'skipped' => $skipped, 'skipped_ids' => $skipped_ids, 'offset' => $offset + $advanced, 'done' => $error_code === '' && ( $offset + $advanced >= $limit ), 'quota_hit' => $quota_hit, 'error_code' => $error_code, 'error_message' => $error_message, ] ); } /** * Output wizard styles */ private function output_wizard_styles() { echo ''; } }