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

AI Search Settings

'; $this->display_tab_navigation( $active_tab ); $this->display_tab_content( $active_tab ); echo '
'; } /** * Display tab navigation */ private function display_tab_navigation( $active_tab ) { echo ''; } /** * Display tab content */ private function display_tab_content( $active_tab ) { switch ( $active_tab ) { case 'general': include plugin_dir_path( __FILE__ ) . 'views/settings-general.php'; break; case 'search-config': include plugin_dir_path( __FILE__ ) . 'views/settings-search-config.php'; break; case 'quota': include plugin_dir_path( __FILE__ ) . 'views/settings-quota.php'; break; case 'embeddings': include plugin_dir_path( __FILE__ ) . 'views/settings-embeddings.php'; break; case 'custom-fields': include plugin_dir_path( __FILE__ ) . 'views/settings-custom-fields.php'; break; case 'woocommerce': if ( class_exists( 'WooCommerce' ) ) { include plugin_dir_path( __FILE__ ) . 'views/settings-woocommerce.php'; } break; case 'cache': include plugin_dir_path( __FILE__ ) . 'views/settings-cache.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 ); $processed_titles = []; // 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 ); $ai_search = AI_Search::get_instance(); foreach ( $posts as $post ) { $ai_search->generate_embedding( $post->ID ); $processed_titles[] = $post->post_title; } set_transient( 'ai_search_processed_titles', $processed_titles, MINUTE_IN_SECONDS ); wp_redirect( admin_url( 'admin.php?page=ai-search&embeddings_generated=true&tab=embeddings' ) ); 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() { return [ 'product_description', 'product_short_description', 'product_categories', 'product_tags' ]; } /** * 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; } /** * 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' ); } }