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 '';
}
}