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