get_var( "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = '_ai_search_embedding' LIMIT 1" ); } if ( $has_embeddings ) { return; } static $has_content = null; if ( $has_content === null ) { $has_content = (bool) $wpdb->get_var( "SELECT 1 FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type NOT IN ('attachment', 'revision', 'nav_menu_item', 'oembed_cache', 'user_request', 'wp_block') LIMIT 1" ); } if ( ! $has_content ) { return; } $wizard_url = admin_url( 'admin.php?page=ai-search&setup=wizard' ); $icon_url = AI_SEARCH_URL . 'assets/icon.svg'; ?>
'; 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-related scripts. * * Since 1.40.0 the persistent "Sync content" notice has no dismiss * button, so nothing to bind for it. Only the post-setup success * notice still needs a click-to-persist handler on its native "X". */ public function enqueue_scripts( $hook ) { 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 the setup screen. * * Since 1.39.0 the wizard is a single "Sync content" page — no more * five-step form. The provider handshake (silent register), the * custom-fields picker and the batched generator have all been * folded into one screen driven by ajax_prepare_sync + * ajax_generate_batch. Advanced users can still open the Content * and Search tabs to prune fields or tune the threshold afterwards. * * Legacy `?step=N` query params are ignored; the URL still resolves * to this same page so bookmarks and welcome-notice links from older * versions keep working. */ public function display_wizard_page() { // Handle wizard completion — same URL contract as before. 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; } $is_woo = class_exists( 'WooCommerce' ); $scan = $this->scan_content(); echo '
'; echo '

' . esc_html__( 'Sync your content with AI', 'ai-search' ); if ( $is_woo ) { echo ' ' . esc_html__( 'WooCommerce mode', 'ai-search' ) . ''; } echo '

'; include plugin_dir_path( __FILE__ ) . 'views/wizard/step-sync.php'; $this->output_wizard_styles(); echo '
'; } /** * Display completion page */ private function display_completion_page() { include plugin_dir_path( __FILE__ ) . 'views/wizard/completion.php'; } // v1.44.1 security: the legacy `complete_setup()` handler was removed. // It skipped CSRF verification by design (comment cited intermittent // nonce-expiry on step-final.php) and persisted `api_key` straight // from $_POST — a cross-site forged POST could silently swap the // site's OpenAI key for an attacker-controlled one. step-final.php was // deleted in v1.40.0 and no live UI calls this endpoint any more; the // single-screen Sync-content wizard writes settings via its own AJAX // path (ajax_prepare_sync + ajax_generate_batch), both nonce-checked. /** * AJAX: process a single batch of embeddings. * * Wired to two action names — `ai_search_wizard_generate_batch` for * historical reasons (the wizard is still the primary caller) and * `ai_search_generate_batch` for the Indexing tab and any future * surface. Both hit this exact method; there is no divergent code. * * Callers keep firing requests with an incrementing offset until * `done: true` comes back. Batch size is bounded so a single HTTP * request cannot blow past a PHP timeout, which fixes the "form POST * with 500+ posts" timeouts that used to plague the Indexing tab * (v1.42.0). * * Params (POST): * nonce string — action `ai_search_wizard_generate` * cpt string — post type slug, required * limit int — max posts to process across the whole run * (default 100). Ignored when 0/negative and * regenerate=1 — the run then processes * everything in the CPT (bounded by paging). * offset int — client progress cursor. In default mode * it's just for math; in regenerate mode it * drives the actual DB query pagination. * regenerate bool — drop the NOT EXISTS filter and re-embed * every matching post (v1.42.0, previously * only reachable via the Indexing form). * taxonomy string — optional taxonomy slug to filter by * term_id int — optional term to filter by (needs taxonomy) * exclude[] int[] — client-reported post ids to skip. Posts * without meta would otherwise be re-fetched * forever by the NOT EXISTS query (v1.27.0). * * Response shape: * { success: true, data: { * processed: int, // successful embeddings this batch * skipped: int, // non-fatal skips this batch * skipped_ids: int[], // ids to feed back as exclude[] * ok_titles: string[], // human-readable success list * failed: [{id,title,code,message}, ...], * offset: int, // next offset the caller should send * done: bool, // true when no work remains * quota_hit: bool, // convenience alias of error_code * error_code: string, // fatal code, empty when clean * error_message: string, // human message for the fatal code * }} */ 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; $regenerate = ! empty( $_POST['regenerate'] ); $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : ''; $term_id = isset( $_POST['term_id'] ) ? intval( $_POST['term_id'] ) : 0; if ( empty( $cpt ) || ! post_type_exists( $cpt ) ) { wp_send_json_error( [ 'message' => __( 'Invalid post type.', 'ai-search' ) ] ); } // When the client passes limit <= 0 in regenerate mode, treat it // as "everything in this CPT" and let pagination drive the loop. // Otherwise cap this batch so we never overshoot the chosen limit. if ( $limit > 0 ) { $remaining_vs_limit = max( 0, $limit - $offset ); $this_batch = min( $batch_size, $remaining_vs_limit ); } else { $this_batch = $batch_size; } if ( $this_batch <= 0 ) { wp_send_json_success( [ 'processed' => 0, 'skipped' => 0, 'skipped_ids' => [], 'ok_titles' => [], 'failed' => [], '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', ]; if ( $regenerate ) { // Every post is fair game and none drop out mid-run, so // pagination is real — client's $offset drives the DB cursor. $query_args['offset'] = $offset; } else { // Unprocessed-first mode: NOT EXISTS filter naturally shrinks // as posts get embedded, so we always fetch from position 0 // and $offset is only used by the caller for progress math. $query_args['meta_query'] = [ [ 'key' => '_ai_search_embedding', 'compare' => 'NOT EXISTS', ], ]; } if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) && $term_id > 0 ) { $query_args['tax_query'] = [ [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_id, ], ]; } if ( ! empty( $exclude ) ) { $query_args['post__not_in'] = $exclude; } // First batch only: also compute the real total matching the // filters (regenerate, taxonomy, exclude) so the progress bar // can show "X / actual" instead of "X / user-chosen-limit" // when the CPT has fewer items than the user asked for // (v1.42.0). posts_per_page=1 + WP_Query.found_posts is a // single COUNT under the hood. $total_pending = null; if ( $offset === 0 ) { $count_args = $query_args; $count_args['posts_per_page'] = 1; $count_args['fields'] = 'ids'; $count_args['no_found_rows'] = false; unset( $count_args['numberposts'], $count_args['offset'] ); $counter = new WP_Query( $count_args ); $total_pending = (int) $counter->found_posts; wp_reset_postdata(); } $posts = get_posts( $query_args ); if ( empty( $posts ) ) { $empty_response = [ 'processed' => 0, 'skipped' => 0, 'skipped_ids' => [], 'ok_titles' => [], 'failed' => [], 'offset' => $offset, 'done' => true, 'quota_hit' => false, ]; if ( null !== $total_pending ) { $empty_response['total_pending'] = $total_pending; } // Terminal state — clean up any resume transient so a // future page load doesn't try to pick up an empty run. self::persist_sync_run_state( $cpt, [ 'done' => true ] ); wp_send_json_success( $empty_response ); } $ai_search = AI_Search::get_instance(); $processed = 0; $skipped = 0; $skipped_ids = []; $ok_titles = []; $failed = []; $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' ]; // v1.42.0: single HTTP round-trip to the service for the whole batch // (previously one call per post). generate_embeddings_batch() keeps // the same per-post outcome shape — the loop below just projects it // into the response fields the JS driver expects. $post_by_id = []; $ids = []; foreach ( $posts as $post ) { $post_by_id[ (int) $post->ID ] = $post; $ids[] = (int) $post->ID; } $batch_results = $ai_search->generate_embeddings_batch( $ids ); foreach ( $ids as $pid ) { $post = $post_by_id[ $pid ]; $result = isset( $batch_results[ $pid ] ) ? $batch_results[ $pid ] : new WP_Error( 'invalid_response', __( 'No result returned for this post.', 'ai-search' ) ); if ( is_wp_error( $result ) ) { $code = $result->get_error_code(); if ( in_array( $code, $fatal_codes, true ) ) { $quota_hit = $code === 'quota_exceeded'; $error_code = $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; $failed[] = [ 'id' => (int) $post->ID, 'title' => (string) $post->post_title, 'code' => (string) $code, 'message' => (string) $result->get_error_message(), ]; continue; } $processed++; $ok_titles[] = (string) $post->post_title; } $advanced = $processed + $skipped; $done = $error_code === '' && ( ( $limit > 0 && $offset + $advanced >= $limit ) || count( $posts ) < $this_batch ); $response = [ 'processed' => $processed, 'skipped' => $skipped, 'skipped_ids' => $skipped_ids, 'ok_titles' => $ok_titles, 'failed' => $failed, 'offset' => $offset + $advanced, 'done' => $done, 'quota_hit' => $quota_hit, 'error_code' => $error_code, 'error_message' => $error_message, ]; // Piggy-back the real total on the first batch reply so the // client can rescale its progress bar denominator (v1.42.0). if ( null !== $total_pending ) { $response['total_pending'] = $total_pending; } // v1.42.0: persist run state so a browser refresh mid-sync // resumes where it left off. Delete on terminal states (done // or fatal error); otherwise refresh the 30-min TTL. This is // per-site (not per-user); only one admin should be syncing // at a time, and if two race, the last write wins. self::persist_sync_run_state( $cpt, [ 'cpt' => $cpt, 'taxonomy' => $taxonomy, 'term_id' => $term_id, 'limit' => $limit, 'regenerate' => $regenerate, 'offset' => $offset + $advanced, 'total_pending' => $total_pending, // null after first batch, that's fine 'stats' => [ 'processed' => $processed, // batch-local; accumulator lives client-side 'skipped' => $skipped, ], 'skipped_ids' => $skipped_ids, 'done' => $done, 'error_code' => $error_code, ] ); wp_send_json_success( $response ); } /** * Store or clear the current sync-run state so a page reload can * pick up the loop mid-run. State lives in a 30-minute transient * and holds only what the client needs to resume — no full stats * (the client accumulates its own copy). * * The client PATCHES this transient every batch by sending * `resume_processed`, `resume_ok_titles[]`, `resume_failed[]` on * every request (see settings-embeddings.php) so the server keeps * the accumulated summary even if the tab is closed. * * @since 1.42.0 * * @param string $cpt CPT being synced. * @param array $patch Fields to persist. */ public static function persist_sync_run_state( $cpt, array $patch ) { // Terminal states clean up. Everything else refreshes TTL. if ( ! empty( $patch['done'] ) || ! empty( $patch['error_code'] ) ) { delete_transient( 'ai_search_sync_run' ); return; } // Fold in the accumulator fields the client sent on this request // so the transient holds the full summary if the tab closes. $resume_processed = isset( $_POST['resume_processed'] ) ? max( 0, (int) $_POST['resume_processed'] ) : 0; $resume_ok = isset( $_POST['resume_ok_titles'] ) ? array_slice( array_map( 'strval', (array) $_POST['resume_ok_titles'] ), 0, 200 ) : []; $resume_failed = isset( $_POST['resume_failed'] ) ? array_slice( (array) $_POST['resume_failed'], 0, 200 ) : []; $patch['run_processed'] = $resume_processed; $patch['run_ok_titles'] = $resume_ok; $patch['run_failed'] = $resume_failed; $patch['updated_at'] = time(); set_transient( 'ai_search_sync_run', $patch, 30 * MINUTE_IN_SECONDS ); } /** * Snapshot everything the one-click sync page needs to render its * preview panel and drive the batch loop. * * Returned shape: * [ * 'post_types' => [ slug => [ 'label' => str, 'count' => int, 'pending' => int, 'fields' => [ meta_key, ... ] ], ... ], * 'has_woo' => bool, * 'total' => int, // total published items across all post types * 'pending' => int, // total posts without _ai_search_embedding * 'estimated' => int, // rough embedding count if the whole thing runs * ] * * Custom fields are the DISTINCT public meta_keys (not starting with * `_`) that actually exist on published rows of each type. We do NOT * touch the saved `ai_search_custom_fields_{type}` option here — that * belongs to auto_populate_defaults() and only fires when the user * hits the button. */ public function scan_content() { global $wpdb; $has_woo = class_exists( 'WooCommerce' ); $type_names = get_post_types( [ 'public' => true, 'exclude_from_search' => false ], 'names' ); unset( $type_names['attachment'] ); $type_names = array_values( $type_names ); $result = [ 'post_types' => [], 'has_woo' => $has_woo, 'total' => 0, 'pending' => 0, 'estimated' => 0, ]; foreach ( $type_names as $slug ) { $obj = get_post_type_object( $slug ); if ( ! $obj ) continue; $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'", $slug ) ); if ( $count === 0 ) continue; $pending = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = 'publish' AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} m WHERE m.post_id = p.ID AND m.meta_key = '_ai_search_embedding' )", $slug ) ); // Auto-detected custom fields: distinct meta_keys not starting // with `_`. Capped to prevent runaway UI listings on sites with // thousands of meta rows. $fields = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT m.meta_key FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE p.post_type = %s AND p.post_status = 'publish' AND m.meta_key NOT LIKE %s ORDER BY m.meta_key LIMIT 50", $slug, $wpdb->esc_like( '_' ) . '%' ) ); $result['post_types'][ $slug ] = [ 'label' => $obj->label, 'count' => $count, 'pending' => $pending, 'fields' => array_values( array_map( 'strval', $fields ?: [] ) ), ]; $result['total'] += $count; $result['pending'] += $pending; $result['estimated'] += $pending; } return $result; } /** * Runs on the "Sync content" button click, before the first batch * fires. Silently applies the defaults a first-time user would have * clicked through the old wizard for: * * - Auto-populate `ai_search_custom_fields_{type}` for any post type * that doesn't yet have a saved config. * - Auto-enable term embeddings when Woo is active and the option * hasn't been set. * - Auto-register on the AI Search Service when there's no token yet. * * We only ever WRITE options that are currently unset — existing user * preferences are never overwritten, so an existing user hitting the * sync page a second time sees their pruning honoured. */ public function ajax_prepare_sync() { check_ajax_referer( 'ai_search_wizard_generate', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized', 'ai-search' ) ], 403 ); } $scan = $this->scan_content(); // 1. Auto-register with the AI Search Service if we have no token // and the user hasn't switched to OpenAI. Silent failure is // fine — the caller will get a proper error from the first // embedding call. Registering blindly is safer than blocking // the click. $provider = get_option( 'ai_search_provider', 'ai_service' ); $token = get_option( 'ai_search_service_token', '' ); if ( $provider === 'ai_service' && empty( $token ) ) { $service = new AI_Search_Service(); $new_token = $service->register_origin(); if ( $new_token ) { update_option( 'ai_search_service_token', $new_token ); } } // 2. Auto-populate custom fields for post types without config. // Use `get_option(...,'__unset__')` sentinel because an existing // but deliberately-empty array is a valid user choice ("index // nothing extra"), not a signal to re-populate. $auto_populated = []; foreach ( $scan['post_types'] as $slug => $meta ) { $option_key = "ai_search_custom_fields_{$slug}"; $current = get_option( $option_key, '__unset__' ); if ( $current === '__unset__' && ! empty( $meta['fields'] ) ) { update_option( $option_key, $meta['fields'] ); $auto_populated[ $slug ] = $meta['fields']; } } // 3. Auto-enable term embeddings on Woo sites where the toggle // was never explicitly touched. Existing preference wins. if ( $scan['has_woo'] && get_option( 'ai_search_term_embeddings_enabled', '__unset__' ) === '__unset__' ) { update_option( 'ai_search_term_embeddings_enabled', true ); } // 4. Ensure the searchable post types option includes everything // the scan sees. Old flows saved just [ 'post', 'page' ]; this // change makes any newly-indexed CPT actually reachable by the // search filter. $searchable = array_keys( $scan['post_types'] ); $existing = get_option( 'ai_search_searchable_post_types', [] ); if ( is_array( $existing ) ) { $searchable = array_values( array_unique( array_merge( $existing, $searchable ) ) ); } update_option( 'ai_search_searchable_post_types', $searchable ); wp_send_json_success( [ 'post_types' => array_keys( $scan['post_types'] ), 'plan' => $scan['post_types'], // slug => { label, pending, ... } 'total_pending' => $scan['pending'], 'auto_populated' => $auto_populated, 'woo_terms' => $scan['has_woo'], ] ); } /** * Output wizard styles. * * Since 1.40.0 the sync page is presented as a full brand-styled * card (purple->pink gradient, on-brand type, circular progress * ring) rather than the native wp-admin form idiom used before. * All colours are hard-coded because the plugin has no user-facing * theming API and we want the sync experience to feel like the * marketing site, not the WP admin shell. */ private function output_wizard_styles() { echo ''; } }