admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('saef_allergen_filter_nonce'), 'action' => 'saef_filter_allergens', 'action_autocomplete' => 'saef_autocomplete_allergens', 'loading_message' => esc_html__('Looking for safe products...', 'allergen-exclusion-filter'), // (ES) Nuevo mensaje para autocompletado vacío / (EN) New empty autocomplete message. 'no_results_message' => esc_html__('No matching allergens found.', 'allergen-exclusion-filter'), )); } /** * (ES) Elimina todos los transients de autocompletado de la tabla options. * Se ejecuta cuando se crea/edita/borra un alérgeno para mantener * la caché sincronizada con la base de datos. * (EN) Removes all autocomplete transients from the options table. * Runs when an allergen is created/edited/deleted to keep the * cache synchronized with the database. */ public function saef_clear_autocomplete_cache() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WP API exists to delete transients by pattern; cleanup operation. $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_saef_ac_%'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Same as above. $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_saef_ac_%'"); } public function saef_autocomplete_allergens() { check_ajax_referer('saef_allergen_filter_nonce', 'nonce'); // (ES) Defensa extra: si WooCommerce está desactivado, devolvemos vacío sin error. // (EN) Extra defense: return empty without error if WooCommerce is disabled. if (! class_exists('WooCommerce')) { wp_send_json_success(array()); } // (ES) Rate limiting simple: máximo 1 petición por segundo por IP. // Evita que un atacante spamee el endpoint para extraer el listado completo. // (EN) Simple rate limiting: max 1 request per second per IP. // Prevents attackers from spamming the endpoint to extract the full list. $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '0.0.0.0'; $rate_key = 'saef_rate_' . md5($ip); $last_request = (int) get_transient($rate_key); if ($last_request > 0 && (time() - $last_request) < 1) { wp_send_json_error(array( 'message' => esc_html__('Too many requests. Please wait a moment.', 'allergen-exclusion-filter'), )); } set_transient($rate_key, time(), 10); $busqueda = isset($_POST['term']) ? sanitize_text_field(wp_unslash($_POST['term'])) : ''; if (strlen($busqueda) < 2) { wp_send_json_success(array()); } // (ES) Clave de caché única por búsqueda / (EN) Unique cache key per search. $cache_key = 'saef_ac_' . md5($busqueda); $cached = get_transient($cache_key); // (ES) Si existe en caché, devolvemos directamente sin consultar la base de datos. // (EN) If cached, return directly without querying the database. if (false !== $cached) { wp_send_json_success($cached); } $terminos = get_terms(array( 'taxonomy' => 'saef_product_allergens', 'name__like' => $busqueda, 'hide_empty' => false, 'number' => 10 )); $sugerencias = array(); if (! is_wp_error($terminos) && ! empty($terminos)) { foreach ($terminos as $termino) { $sugerencias[] = array( 'id' => $termino->term_id, 'name' => $termino->name, 'slug' => $termino->slug ); } } // (ES) Guardamos en caché durante 1 hora / (EN) Cache for 1 hour. set_transient($cache_key, $sugerencias, HOUR_IN_SECONDS); wp_send_json_success($sugerencias); } public function saef_handle_filter_ajax() { // (ES) Defensa extra: si WooCommerce está desactivado, respondemos con error limpio. // Esto evita el "Connection error" que veías al desactivar WooCommerce. // (EN) Extra defense: respond cleanly if WooCommerce is disabled. // This avoids the "Connection error" you saw when disabling WooCommerce. if (! class_exists('WooCommerce')) { wp_send_json_error(array( 'message' => esc_html__('WooCommerce is not active. The search engine cannot work without it.', 'allergen-exclusion-filter'), )); } // (ES) Verificación del Nonce / (EN) Nonce verification. $nonce_valor = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (! wp_verify_nonce($nonce_valor, 'saef_allergen_filter_nonce')) { wp_send_json_error(array('message' => 'Session expired.')); } // (ES) Sanitización de la entrada del usuario / (EN) User input sanitization. $alergenos_input = isset($_POST['alergenos_input']) ? sanitize_text_field(wp_unslash($_POST['alergenos_input'])) : ''; if (empty(trim($alergenos_input))) { // (ES) wp_send_json_success ya termina la ejecución; wp_die() era redundante. // (EN) wp_send_json_success already ends execution; wp_die() was redundant. wp_send_json_success(array('html' => '', 'conteo' => 0)); } // (ES) Sanitizamos los items entrantes / (EN) Sanitize incoming items. $items_recibidos = array_filter(array_map('trim', explode(',', $alergenos_input))); $slugs_candidatos = array_map('sanitize_title', $items_recibidos); // (ES) Consulta única por slug (Punto 9 de la auditoría). // Como los slugs son versiones lowercase del nombre, esto cubre tanto // "Gluten" como "gluten" en una sola query. // (EN) Single slug query (Point 9 of the audit). // Since slugs are lowercase versions of names, this covers both // "Gluten" and "gluten" in a single query. $terms = get_terms(array( 'taxonomy' => 'saef_product_allergens', 'slug' => $slugs_candidatos, 'hide_empty' => false, )); $slugs_validos = array(); $terminos_invalidos = array(); if (! is_wp_error($terms) && ! empty($terms)) { $encontrados = array(); foreach ($terms as $term) { $slugs_validos[] = $term->slug; $encontrados[$term->slug] = true; } // (ES) Detectamos los items que no se encontraron / (EN) Detect unmatched items. foreach ($items_recibidos as $i => $item) { if (! isset($encontrados[$slugs_candidatos[$i]])) { $terminos_invalidos[] = $item; } } } else { // (ES) Ninguno encontrado: todos son inválidos / (EN) None found: all invalid. $terminos_invalidos = $items_recibidos; } ob_start(); // A. Notice of terms not found if (! empty($terminos_invalidos)) { echo '
'; echo ' ⚠️'; echo '

' . esc_html__('Unrecognized ingredient', 'allergen-exclusion-filter') . '

'; echo '

' . sprintf( /* translators: %s: Search term entered by the user */ esc_html__('These ingredients "%s" have not been recognized.', 'allergen-exclusion-filter'), esc_html(implode(', ', $terminos_invalidos)) ) . '

'; echo '
'; } // B. Search for actual products // (ES) Usamos wc_get_products() en lugar de WP_Query (Punto 11 de la auditoría): // - Es compatible con HPOS y futuras migraciones de almacenamiento de WC. // - Respeta nativamente la visibilidad de catálogo (ocultos, excluir de búsqueda, etc.). // - Devuelve directamente objetos WC_Product, evitando instanciarlos de nuevo en el loop. // (EN) We use wc_get_products() instead of WP_Query (Point 11 of the audit): // - HPOS-compatible and future-proof against WC storage migrations. // - Natively respects catalog visibility (hidden, exclude-from-search, etc.). // - Returns WC_Product objects directly, avoiding re-instantiation in the loop. $conteo = 0; if (! empty($slugs_validos)) { // (ES) Leemos el valor configurable si la clase Settings existe; si no, usamos 12. // (EN) Read configurable value if Settings class exists; otherwise use 12. if (class_exists('SAEF_Settings')) { $posts_per_page = (int) SAEF_Settings::saef_get_option('posts_per_page', 12); } else { $posts_per_page = 12; } // (ES) Argumentos para la API nativa de WooCommerce. // - 'limit' sustituye a 'posts_per_page'. // - 'paginate' => true devuelve un objeto con total, page y products. // - No necesitamos tax_query de product_visibility: wc_get_products lo gestiona. // (EN) Arguments for the native WooCommerce API. // - 'limit' replaces 'posts_per_page'. // - 'paginate' => true returns an object with total, page and products. // - No need for product_visibility tax_query: wc_get_products handles it. $args = array( 'status' => 'publish', 'limit' => $posts_per_page, 'paginate' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Required for allergen exclusion filtering; user-triggered, limited and paginated. 'tax_query' => array( array( 'taxonomy' => 'saef_product_allergens', 'field' => 'slug', 'terms' => $slugs_validos, 'operator' => 'NOT IN', ), ), ); // (ES) Ejecutamos la consulta nativa de WooCommerce. // (EN) Execute the native WooCommerce query. $resultados_wc = wc_get_products($args); // (ES) Con 'paginate' => true, wc_get_products devuelve un objeto con: // - total (int): número total de productos que coinciden. // - total_pages (int): número total de páginas. // - page (int): página actual. // - products (array): array de objetos WC_Product. // (EN) With 'paginate' => true, wc_get_products returns an object with: // - total (int): total number of matching products. // - total_pages (int): total number of pages. // - page (int): current page. // - products (array): array of WC_Product objects. $conteo = (int) $resultados_wc->total; $this->saef_render_results_html($resultados_wc, $slugs_validos); } $html = ob_get_clean(); // (ES) wp_send_json_success ya termina la ejecución; wp_die() era redundante. // (EN) wp_send_json_success already ends execution; wp_die() was redundant. wp_send_json_success(array('html' => $html, 'conteo' => $conteo)); } /** * (ES) Renderiza el HTML de los resultados de productos usando la API nativa de WooCommerce. * Mantiene los strings en inglés para compatibilidad con internacionalización (i18n) * y para no romper las traducciones existentes publicadas en WordPress.org. * (EN) Renders the HTML of the product results using the native WooCommerce API. * Keeps strings in English for i18n compatibility and to avoid breaking * existing translations already published on WordPress.org. * * @param object $resultados_wc Objeto devuelto por wc_get_products() con 'paginate' => true. * Object returned by wc_get_products() with 'paginate' => true. * @param array $excluir_slugs Array de slugs de alérgenos a excluir. * Array of allergen slugs to exclude. */ private function saef_render_results_html($resultados_wc, $excluir_slugs) { // 1. (ES) Verificamos si hay resultados en la consulta de WooCommerce. // (EN) Check whether the WooCommerce query returned results. if ($resultados_wc->total > 0) { // 2. (ES) Renderizado del mensaje de éxito con variables seguras. // (EN) Render the success message with safe variables. // NOTA: No modificar el texto del string — rompería traducciones existentes. // NOTE: Do not modify the string text — it would break existing translations. echo '

'; echo esc_html( sprintf( /* translators: %d: Number of safe products found */ __('Good news! We have %d safe products for you:', 'allergen-exclusion-filter'), (int) $resultados_wc->total ) ); echo '

'; // 3. (ES) Renderizamos el bucle de productos delegando a una función específica. // (EN) Render the products loop delegating to a dedicated function. $this->saef_render_products_loop($resultados_wc->products); // 4. (ES) Construcción segura de la URL para el botón "Ver todo". // Aplicamos sanitize_title por defensa en profundidad, aunque los slugs // ya provengan de get_terms() y estén saneados. // (EN) Safe construction of the URL for the "View all" button. // We apply sanitize_title as defense in depth, even though the slugs // already come from get_terms() and are sanitized. $slugs_str = implode(',', array_map('sanitize_title', $excluir_slugs)); $shop_page_id = wc_get_page_id('shop'); $url_base = ($shop_page_id > 0) ? get_permalink($shop_page_id) : home_url(); $url_resultados = add_query_arg('saef_exclude', urlencode($slugs_str), $url_base); // 5. (ES) Renderizado del botón con su correspondiente texto traducible. // NOTA: No modificar el texto del string — rompería traducciones existentes. // (EN) Render the button with its corresponding translatable text. // NOTE: Do not modify the string text — it would break existing translations. echo '

'; echo ''; echo esc_html( sprintf( /* translators: %d: Number of safe products found */ __('VIEW THE COMPLETE LIST (%d)', 'allergen-exclusion-filter'), (int) $resultados_wc->total ) ); echo ''; echo '

'; } else { // 6. (ES) Mensaje de respaldo cuando no hay coincidencias. // NOTA: No modificar el texto del string — rompería traducciones existentes. // (EN) Fallback message when there are no matches. // NOTE: Do not modify the string text — it would break existing translations. echo '

' . esc_html__('There are no matching products.', 'allergen-exclusion-filter') . '

'; } } /** * (ES) Recorre el array de objetos WC_Product y renderiza su HTML. * Asegura la protección contra inyección de scripts (XSS). * (EN) Iterates the array of WC_Product objects and renders their HTML. * Ensures protection against script injection (XSS). * * @param array $productos Array de objetos WC_Product. * Array of WC_Product objects. */ private function saef_render_products_loop($productos) { echo ''; } /** * NUEVO: Función para mostrar el título dinámico específicamente en la página de resultados (Tienda) */ /** * Muestra el título dinámico en la página de resultados de la tienda. * Optimizado para Loco Translate y seguridad. */ public function saef_display_results_page_title() { // 1. Verificación de entrada // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter, no data modification. if (!isset($_GET['saef_exclude'])) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter, no data modification. $slugs_str = sanitize_text_field(wp_unslash($_GET['saef_exclude'])); if (empty($slugs_str)) return; $excluir_slugs = explode(',', urldecode($slugs_str)); $valid_allergen_names = array(); // 2. Obtener nombres de los alérgenos foreach ($excluir_slugs as $slug) { $term = get_term_by('slug', $slug, 'saef_product_allergens'); if ($term) { $valid_allergen_names[] = $term->name; } } // 3. Construcción gramatical de la lista $list_text = ''; if (!empty($valid_allergen_names)) { if (count($valid_allergen_names) === 1) { $list_text = $valid_allergen_names[0]; } else { $last_item = array_pop($valid_allergen_names); // Traducimos el nexo "and" de forma independiente pero literal $connector = __('and', 'allergen-exclusion-filter'); $list_text = implode(', ', $valid_allergen_names) . ' ' . $connector . ' ' . $last_item; } } // 4. Obtain global and site data global $wp_query; $count = (int) $wp_query->found_posts; $site_name = get_bloginfo('name'); // 5. Renderizado del título (Sintaxis simplificada para Loco Translate) echo '

'; printf( /* translators: 1: Number of products, 2: Allergen name, 3: Site name */ esc_html(_n( 'This is the %1$s product on %3$s that does not contain %2$s among its ingredients', 'These are the %1$s products on %3$s that do not contain %2$s among their ingredients', $count, 'allergen-exclusion-filter' )), '' . esc_html((string) $count) . '', '' . esc_html($list_text) . '', '' . esc_html($site_name) . '' ); echo '

'; } public function saef_filter_main_query($query) { // (ES) No tocamos consultas del admin ni secundarias. // (EN) Do not touch admin or secondary queries. if (is_admin() || ! $query->is_main_query()) { return; } // (ES) Solo aplicamos en la tienda y en archivos de taxonomía de producto. // Antes era demasiado amplio y podía interferir con otros archivos. // (EN) Only apply on the shop page and product taxonomy archives. // Previously too broad and could interfere with other archives. if (! function_exists('is_shop') || ! (is_shop() || is_product_taxonomy())) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter, no data modification. $raw_exclude = isset($_GET['saef_exclude']) ? sanitize_text_field(wp_unslash($_GET['saef_exclude'])) : ''; if (empty($raw_exclude)) { return; } // (ES) Sanitizamos cada slug recibido por URL y eliminamos vacíos. // (EN) Sanitize each slug from the URL and remove empty entries. $excluir_slugs = array_filter(array_map('sanitize_title', explode(',', urldecode($raw_exclude)))); if (empty($excluir_slugs)) { return; } $tax_query = (array) $query->get('tax_query'); $tax_query[] = array( 'taxonomy' => 'saef_product_allergens', 'field' => 'slug', 'terms' => $excluir_slugs, 'operator' => 'NOT IN', ); $query->set('tax_query', $tax_query); $query->set('post_type', 'product'); } }