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