service_client = new AI_Search_Service(); // Load quota manager require_once plugin_dir_path( __FILE__ ) . 'class-quota-manager.php'; $this->quota_manager = new AI_Search_Quota_Manager(); } /** * Display main settings page with tabs */ public function display_settings_page() { // Check if setup wizard should be shown if ( isset( $_GET['setup'] ) && $_GET['setup'] === 'wizard' ) { $wizard = new AI_Search_Setup_Wizard(); $wizard->display_wizard_page(); return; } $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general'; // No page-title H1: it added nothing on top of the tabs and, since // 1.34.0, competed for attention with the celebration hero right // after a claim. echo '
'; $this->display_tab_navigation( $active_tab ); $this->display_tab_content( $active_tab ); echo '
'; } /** * Display tab navigation */ /** * Map legacy tab slugs (pre-1.29.0) to the consolidated tabs so old * links, bookmarks and admin-post redirects keep working. */ private function normalize_tab( $tab ) { $aliases = [ 'search-config' => 'search', 'quota' => 'general', 'embeddings' => 'indexing', 'term-embeddings' => 'indexing', 'custom-fields' => 'content', 'cache' => 'tools', ]; return isset( $aliases[ $tab ] ) ? $aliases[ $tab ] : $tab; } private function display_tab_navigation( $active_tab ) { $active_tab = $this->normalize_tab( $active_tab ); $tabs = [ 'general' => __( 'General', 'ai-search' ), 'search' => __( 'Search', 'ai-search' ), 'indexing' => __( 'Indexing', 'ai-search' ), 'content' => __( 'Content', 'ai-search' ), 'insights' => __( 'Insights', 'ai-search' ), 'tools' => __( 'Tools', 'ai-search' ), ]; // WooCommerce gets its own tab on stores (v1.32.0), sitting right // after Content where its fields used to live. if ( class_exists( 'WooCommerce' ) ) { $tabs = array_slice( $tabs, 0, 4, true ) + [ 'woocommerce' => __( 'WooCommerce', 'ai-search' ) ] + array_slice( $tabs, 4, null, true ); } echo ''; } private function display_tab_content( $active_tab ) { $active_tab = $this->normalize_tab( $active_tab ); $views = plugin_dir_path( __FILE__ ) . 'views/'; switch ( $active_tab ) { case 'search': include $views . 'settings-search-config.php'; break; case 'indexing': include $views . 'settings-embeddings.php'; include $views . 'settings-term-embeddings.php'; break; case 'content': include $views . 'settings-custom-fields.php'; break; case 'woocommerce': include $views . 'settings-woocommerce.php'; break; case 'insights': include $views . 'settings-insights.php'; break; case 'tools': include $views . 'settings-cache.php'; break; case 'general': default: include $views . 'settings-general.php'; include $views . 'settings-quota.php'; break; } } /** * Generate embedding for selected custom post type. */ public function generate_selected_embeddings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_generate_embeddings' ); if ( ! isset( $_POST['cpt'] ) ) { wp_die( __( 'No post type selected', 'ai-search' ) ); } // Increase execution time for large batches if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); // 5 minutes } $cpt = sanitize_text_field( $_POST['cpt'] ); $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 50; $limit = min( max( $limit, 1 ), 1000 ); // Build query args $query_args = [ 'numberposts' => $limit, 'post_type' => $cpt, 'post_status' => 'publish', 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'NOT EXISTS' ] ] ]; // Add taxonomy filter if specified $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : ''; $term_id = isset( $_POST['term'] ) ? intval( $_POST['term'] ) : 0; if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) && $term_id > 0 ) { $query_args['tax_query'] = [ [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_id, ] ]; } $posts = get_posts( $query_args ); // Per-run report so the UI can say exactly what happened instead of // "generated!" regardless of outcome (v1.27.0). $report = [ 'post_type' => $cpt, 'requested' => count( $posts ), 'ok' => [], 'failed' => [], 'stopped' => false, ]; $ai_search = AI_Search::get_instance(); foreach ( $posts as $post ) { $result = $ai_search->generate_embedding( $post->ID ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $report['failed'][] = [ 'id' => $post->ID, 'title' => $post->post_title, 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), 'status' => is_array( $data ) && isset( $data['status'] ) ? $data['status'] : null, ]; // Provider-level failures will repeat for every post — stop // early instead of burning the whole batch on the same error. if ( in_array( $result->get_error_code(), [ 'no_token', 'no_api_key', 'invalid_token', 'invalid_api_key', 'quota_exceeded', 'http_error', 'service_unavailable' ], true ) ) { $report['stopped'] = true; break; } continue; } $report['ok'][] = $post->post_title; } set_transient( 'ai_search_generation_report', $report, 5 * MINUTE_IN_SECONDS ); wp_redirect( admin_url( 'admin.php?page=ai-search&embeddings_generated=true&tab=embeddings' ) ); exit; } /** * Bulk-generate embeddings for taxonomy terms that don't have one yet. * * Mirrors `generate_selected_embeddings` for the term side (v1.26.0). */ public function generate_term_embeddings_bulk() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_generate_term_embeddings' ); if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); } $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : ''; $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 100; $limit = min( max( $limit, 1 ), 1000 ); // Build the taxonomy list — explicit taxonomy or all enabled. $disabled_taxonomies = (array) get_option( 'ai_search_disabled_taxonomies', [] ); if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) { if ( in_array( $taxonomy, $disabled_taxonomies, true ) ) { wp_die( __( 'That taxonomy is currently disabled in settings.', 'ai-search' ) ); } $taxonomies = [ $taxonomy ]; } else { $public = get_taxonomies( [ 'public' => true ] ); $taxonomies = array_values( array_diff( $public, $disabled_taxonomies, [ 'post_format' ] ) ); } if ( empty( $taxonomies ) ) { wp_die( __( 'No eligible taxonomies to process.', 'ai-search' ) ); } $terms = get_terms( [ 'taxonomy' => $taxonomies, 'hide_empty' => false, 'number' => $limit, 'meta_query' => [ [ 'key' => '_ai_search_term_embedding', 'compare' => 'NOT EXISTS', ], ], ] ); $processed = []; if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { $ai_search = AI_Search::get_instance(); foreach ( $terms as $term ) { if ( $ai_search->generate_term_embedding( $term->term_id, $term->taxonomy ) ) { $processed[] = $term->name . ' (' . $term->taxonomy . ')'; } } } set_transient( 'ai_search_processed_term_names', $processed, MINUTE_IN_SECONDS ); wp_redirect( admin_url( 'admin.php?page=ai-search&tab=term-embeddings&term_embeddings_generated=true' ) ); exit; } /** * Clear all embedding cache (transients and post meta). */ public function clear_embeddings_cache() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_clear_cache' ); $cache_type = isset( $_POST['cache_type'] ) ? sanitize_text_field( $_POST['cache_type'] ) : 'transients'; $cleared_count = 0; if ( $cache_type === 'transients' || $cache_type === 'all' ) { $cleared_count += $this->clear_embedding_transients(); } if ( $cache_type === 'post_meta' || $cache_type === 'all' ) { $cleared_count += $this->clear_post_meta_embeddings(); } $message = $cache_type === 'all' ? 'all_cache_cleared' : $cache_type . '_cleared'; wp_redirect( admin_url( 'admin.php?page=ai-search&cache_cleared=' . $message . '&count=' . $cleared_count . '&tab=cache' ) ); exit; } /** * Validate the service token via admin action. */ public function validate_service_token() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_validate_token' ); $service_token = get_option( 'ai_search_service_token', '' ); if ( empty( $service_token ) ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=no_token&tab=general' ) ); exit; } $validation_result = $this->service_client->validate_token(); if ( $validation_result === false ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=error&tab=general' ) ); } elseif ( isset( $validation_result['valid'] ) && $validation_result['valid'] === true ) { set_transient( 'ai_search_validation_data', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=success&tab=general' ) ); } else { set_transient( 'ai_search_validation_error', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=invalid&tab=general' ) ); } exit; } /** * Handle the "Reconnect with new token" admin action. * * Triggered from the General settings tab when a connection check * surfaced an "Invalid token" error. Calls the service's recover-token * endpoint, then immediately re-validates so the user sees the same * green "Connection successful" notice they'd get from a manual check. */ public function recover_service_token() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_recover_token' ); $recovered = $this->service_client->recover_token(); if ( ! $recovered ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=failed&tab=general' ) ); exit; } // Re-validate with the freshly issued token so the success notice // shows live quota numbers from the service, not just a generic // "token rotated" message. $validation_result = $this->service_client->validate_token(); if ( is_array( $validation_result ) && ! empty( $validation_result['valid'] ) ) { set_transient( 'ai_search_validation_data', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=success&token_validation=success&tab=general' ) ); } else { // Token rotation worked but the follow-up validate didn't — // probably a transient network issue. Still report success on the // recovery itself; the user can click "Check Connection" again. wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=success&tab=general' ) ); } exit; } /** * Save custom fields settings. */ public function save_custom_fields_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_save_custom_fields' ); $custom_fields = isset( $_POST['custom_fields'] ) ? $_POST['custom_fields'] : []; $post_types = get_post_types( [ 'public' => true ] ); foreach ( $post_types as $post_type ) { if ( isset( $custom_fields[ $post_type ] ) && is_array( $custom_fields[ $post_type ] ) ) { $selected_fields = array_map( 'sanitize_text_field', $custom_fields[ $post_type ] ); update_option( 'ai_search_custom_fields_' . $post_type, $selected_fields ); } else { delete_option( 'ai_search_custom_fields_' . $post_type ); } } wp_redirect( admin_url( 'admin.php?page=ai-search&custom_fields_saved=true&tab=custom-fields' ) ); exit; } /** * Save WooCommerce fields settings. */ public function save_woocommerce_fields_settings() { if ( ! current_user_can( 'manage_options' ) || ! class_exists( 'WooCommerce' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_save_woocommerce_fields' ); $woocommerce_fields = isset( $_POST['woocommerce_fields'] ) ? $_POST['woocommerce_fields'] : []; if ( ! empty( $woocommerce_fields ) && is_array( $woocommerce_fields ) ) { $selected_fields = array_map( 'sanitize_text_field', $woocommerce_fields ); update_option( 'ai_search_woocommerce_fields', $selected_fields ); } else { delete_option( 'ai_search_woocommerce_fields' ); } wp_redirect( admin_url( 'admin.php?page=ai-search&woocommerce_fields_saved=true&tab=woocommerce' ) ); exit; } /** * Clear embedding transients from WordPress cache. */ private function clear_embedding_transients() { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_timeout_ai_search_embedding_%' ) ); $result += $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_service_embedding_%', '_transient_timeout_ai_service_embedding_%' ) ); return $result; } /** * Clear post meta embeddings. */ private function clear_post_meta_embeddings() { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); return $result; } /** * Clear post meta embeddings filtered by post type and optionally taxonomy/term. */ public function clear_embeddings_cache_filtered() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_clear_cache_filtered' ); if ( ! isset( $_POST['cpt'] ) ) { wp_die( __( 'No post type selected', 'ai-search' ) ); } $cpt = sanitize_text_field( $_POST['cpt'] ); $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : ''; $term_id = isset( $_POST['term'] ) ? intval( $_POST['term'] ) : 0; // Build query args to get post IDs $query_args = [ 'numberposts' => -1, 'post_type' => $cpt, 'post_status' => 'any', 'fields' => 'ids', 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'EXISTS' ] ] ]; // Add taxonomy filter if specified if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) && $term_id > 0 ) { $query_args['tax_query'] = [ [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_id, ] ]; } $post_ids = get_posts( $query_args ); $cleared_count = 0; if ( ! empty( $post_ids ) ) { foreach ( $post_ids as $post_id ) { if ( delete_post_meta( $post_id, '_ai_search_embedding' ) ) { $cleared_count++; } } } wp_redirect( admin_url( 'admin.php?page=ai-search&cache_cleared=post_meta_filtered_cleared&count=' . $cleared_count . '&tab=cache' ) ); exit; } /** * Get cache statistics. */ public function get_cache_statistics() { global $wpdb; $transients = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_ai_service_embedding_%' ) ); $post_meta = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); return [ 'transients' => intval( $transients ), 'post_meta' => intval( $post_meta ) ]; } /** * Get all custom fields used by a specific post type. */ public function get_custom_fields_for_post_type( $post_type ) { global $wpdb; $fields = []; // Get regular custom fields (non-underscore prefixed) $query = $wpdb->prepare( " SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE p.post_type = %s AND pm.meta_key NOT LIKE '\_%' AND pm.meta_key != 'ai_search_embedding' AND pm.meta_value != '' ORDER BY pm.meta_key ", $post_type ); $regular_fields = $wpdb->get_col( $query ); if ( $regular_fields ) { $fields = array_merge( $fields, $regular_fields ); } // If ACF is active, get ACF field groups for this post type if ( function_exists( 'acf_get_field_groups' ) ) { $field_groups = acf_get_field_groups( [ 'post_type' => $post_type ] ); foreach ( $field_groups as $field_group ) { $acf_fields = acf_get_fields( $field_group['key'] ); if ( $acf_fields ) { foreach ( $acf_fields as $field ) { // Only include fields that can be converted to text $searchable_types = [ 'text', 'textarea', 'number', 'email', 'url', 'password', 'wysiwyg', 'select', 'checkbox', 'radio', 'button_group', 'true_false', 'post_object', 'relationship', 'taxonomy', 'user', 'date_picker', 'date_time_picker', 'time_picker', 'color_picker' ]; if ( in_array( $field['type'], $searchable_types ) ) { $fields[] = $field['name']; } } } } } // Remove duplicates and sort $fields = array_unique( $fields ); sort( $fields ); return $fields; } /** * Get available WooCommerce fields. */ public function get_woocommerce_fields() { return [ 'content' => [ 'product_description' => 'Product Description', 'product_short_description' => 'Short Description', 'product_sku' => 'Product SKU', ], 'taxonomies' => [ 'product_categories' => 'Product Categories', 'product_tags' => 'Product Tags', ] ]; } /** * Get product attributes. */ public function get_product_attributes() { if ( ! class_exists( 'WooCommerce' ) ) { return []; } return wc_get_attribute_taxonomies(); } /** * Get default WooCommerce fields to include. */ public function get_default_woocommerce_fields() { // Single source of truth shared with the search engine, so the UI // can never advertise defaults the indexer ignores (v1.32.0). return AI_Search::default_woocommerce_fields(); } /** * Refresh quota data from AI Search Service. * * This method will be called when the user clicks the "Refresh Quota" button. * Forces a fresh API call and updates the cache. */ public function refresh_quota_data() { // Security checks if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_refresh_quota', 'ai_search_quota_nonce' ); // Force refresh from API $quota_data = $this->quota_manager->get_quota_data( true ); if ( $quota_data !== null ) { // Successfully fetched and cached wp_redirect( admin_url( 'admin.php?page=ai-search&tab=quota"a_refreshed=success' ) ); } else { // Failed to fetch quota data - store error in transient for display $error_message = $this->get_last_quota_error(); if ( $error_message ) { set_transient( 'ai_search_quota_error', $error_message, 60 ); } wp_redirect( admin_url( 'admin.php?page=ai-search&tab=quota"a_refreshed=error' ) ); } exit; } /** * Handle the "Activate" button on the General tab. Called via admin-post. * * @since 1.34.0 */ public function handle_claim_subscription() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_claim_subscription' ); $claim_key = isset( $_POST['ai_search_claim_key'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_search_claim_key'] ) ) : ''; $service = new AI_Search_Service(); $result = $service->claim_subscription( $claim_key ); if ( is_wp_error( $result ) ) { // Persist the error briefly so the redirected page can render it. set_transient( 'ai_search_claim_error', [ 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), ], 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general&claim=error' ) ); exit; } // Fresh quota to reflect the new plan immediately in the Quota tab. delete_transient( 'ai_search_quota_data' ); // Two persisted signals about the upgrade: // // - `claim_success` is a short-lived transient so the "welcome" // banner renders exactly once on the redirected page. // - `upgrade_pending_reindex` is a real option that survives // reloads, because the reindex has to actually happen. It stays // true until the customer either dismisses it or (ideally) // finishes a bulk regeneration on the paid model. set_transient( 'ai_search_claim_success', [ 'plan_key' => isset( $result['plan']['key'] ) ? (string) $result['plan']['key'] : '', 'plan_label' => isset( $result['plan']['label'] ) ? (string) $result['plan']['label'] : '', 'model' => isset( $result['plan']['model'] ) ? (string) $result['plan']['model'] : '', ], 60 ); update_option( 'ai_search_upgrade_pending_reindex', true ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general&claim=success' ) ); exit; } /** * Handle the "Dismiss" on the "regenerate embeddings" panel. Sets the * option to false so the panel goes away on the next reload. * * @since 1.34.0 */ public function handle_dismiss_reindex_notice() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_dismiss_reindex_notice' ); update_option( 'ai_search_upgrade_pending_reindex', false ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general' ) ); exit; } /** * Handle the "Move to another site" button. Internally still called * release_slot because that is the service-side terminology (and the * transient / admin-post action names would be painful to rename * without breakage); the customer-facing label is the friendlier one. * * @since 1.34.0 */ public function handle_release_slot() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_release_slot' ); $service = new AI_Search_Service(); $result = $service->release_slot(); delete_transient( 'ai_search_quota_data' ); if ( is_wp_error( $result ) ) { set_transient( 'ai_search_claim_error', [ 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), ], 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general&release=error' ) ); exit; } // Now that this site is back on Free, the "regenerate embeddings" // reminder from the last claim is no longer relevant - the model // switched back to text-embedding-3-small, so the existing large- // model vectors are the wrong shape. That is a whole separate // problem for another day; we do not want to nag about it either. delete_option( 'ai_search_upgrade_pending_reindex' ); set_transient( 'ai_search_release_success', true, 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general&release=success' ) ); exit; } /** * Get the last quota API error from error log or return generic message. * * @return string Error message */ private function get_last_quota_error() { // Try to get error from transient (we'll set this in the service class) $error = get_transient( 'ai_search_quota_api_error' ); if ( $error ) { delete_transient( 'ai_search_quota_api_error' ); return $error; } return __( 'Unable to connect to AI Search Service. Please check your connection and try again.', 'ai-search' ); } }