array( orderby, order ). */ const SORTS = array( 'date-desc' => array( 'date', 'DESC' ), 'date-asc' => array( 'date', 'ASC' ), 'modified-desc' => array( 'modified', 'DESC' ), 'title-asc' => array( 'title', 'ASC' ), 'title-desc' => array( 'title', 'DESC' ), 'comment_count-desc' => array( 'comment_count', 'DESC' ), ); /** * Evita que nuestro propio filtro se aplique al calcular conteos. * * @var bool */ private static $computing_counts = false; /** * Variables originales de la consulta principal, antes de filtrarla. * * @var array|null */ private static $main_vars = null; /** * Parámetros originales de la petición de la consulta principal (para conteos). * * @var array|null */ private static $main_request = null; /** * Caché del escaneo de bloques de la página actual. * * @var array|null */ private static $scan = null; public static function init() { add_action( 'init', array( __CLASS__, 'register_blocks' ) ); add_filter( 'block_categories_all', array( __CLASS__, 'block_category' ) ); add_filter( 'query_loop_block_query_vars', array( __CLASS__, 'filter_query_block_vars' ), 10, 2 ); add_filter( 'render_block_data', array( __CLASS__, 'enable_client_navigation' ), 5 ); add_filter( 'render_block_core/query', array( __CLASS__, 'add_router_region' ), 20, 2 ); add_filter( 'render_block_context', array( __CLASS__, 'enable_client_navigation_context' ) ); add_filter( 'posts_orderby', array( __CLASS__, 'title_orderby' ), 10, 2 ); } /** * Registra los bloques. Las traducciones (PHP y scripts del editor) las carga * WordPress desde wp-content/languages/plugins (translate.wordpress.org). */ public static function register_blocks() { foreach ( self::BLOCKS as $block ) { register_block_type( FILTERWELL_DIR . '/build/' . $block ); } } /** * Categoría "FilterWell" en el insertador, primera de la lista, para que * los bloques del plugin se encuentren juntos. * * @param array $categories Categorías de bloques. */ public static function block_category( $categories ) { array_unshift( $categories, array( 'slug' => 'filterwell', 'title' => 'FilterWell', 'icon' => null, ) ); return $categories; } /** * Prefijo de parámetros para un bucle. */ public static function prefix( $query_id, $inherit ) { return $inherit ? 'qlf' : 'qlf' . absint( $query_id ); } /** * Texto de un atributo o, si está vacío, el texto por defecto traducido. * * Los textos por defecto no se guardan en block.json (ahí no se pueden * traducir): el atributo queda vacío y se usa el de la traducción del sitio. * * @param array $attributes Atributos del bloque. * @param string $name Atributo. * @param string $fallback Texto por defecto, ya traducido. */ public static function text( array $attributes, $name, $fallback ) { $value = $attributes[ $name ] ?? ''; return is_string( $value ) && '' !== trim( $value ) ? $value : $fallback; } /** * Convierte un color guardado (`var:preset|color|slug` o valor directo) a CSS. */ public static function color_value( $value ) { if ( ! is_string( $value ) || '' === $value ) { return ''; } if ( preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/', $value, $match ) ) { return 'var(--wp--preset--color--' . $match[1] . ')'; } return preg_match( '/^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([0-9.,%\s\/]+\))$/i', $value ) ? $value : ''; } /** * Variables CSS de los colores de las casillas (fondo, borde, marca). */ public static function option_color_style( array $attributes ) { return self::color_vars_style( $attributes, array( 'optionElement' => '--qlf-option-bg', 'optionElementBorder' => '--qlf-option-border', 'optionElementSelected' => '--qlf-option-selected', 'optionElementSelectedText' => '--qlf-option-selected-text', ) ); } /** * Convierte atributos de color en variables CSS. * * @param array $attributes Atributos del bloque. * @param array $map atributo => variable CSS. */ public static function color_vars_style( array $attributes, array $map ) { $style = ''; foreach ( $map as $attribute => $variable ) { $color = self::color_value( $attributes[ $attribute ] ?? '' ); if ( $color ) { $style .= $variable . ':' . $color . ';'; } } return $style; } /** * Clases y estilos de borde, color y tipografía del bloque para aplicarlos a un * input (equivale a lo que hace el bloque Buscar del núcleo con su campo). * * @param array $attributes Atributos del bloque. * @return array{class: string, style: string} */ public static function input_style_attributes( array $attributes ) { $styles = array_intersect_key( $attributes['style'] ?? array(), array_flip( array( 'border', 'color', 'typography' ) ) ); $engine = wp_style_engine_get_styles( $styles ); $classes = isset( $engine['classnames'] ) ? explode( ' ', $engine['classnames'] ) : array(); $presets = array( 'borderColor' => array( 'has-border-color', 'has-%s-border-color' ), 'textColor' => array( 'has-text-color', 'has-%s-color' ), 'backgroundColor' => array( 'has-background', 'has-%s-background-color' ), 'fontSize' => array( '', 'has-%s-font-size' ), 'fontFamily' => array( '', 'has-%s-font-family' ), ); foreach ( $presets as $attribute => $pattern ) { if ( ! empty( $attributes[ $attribute ] ) ) { $classes[] = $pattern[0]; $classes[] = sprintf( $pattern[1], sanitize_html_class( $attributes[ $attribute ] ) ); } } if ( ! empty( $styles['color']['text'] ) ) { $classes[] = 'has-text-color'; } if ( ! empty( $styles['color']['background'] ) ) { $classes[] = 'has-background'; } return array( 'class' => implode( ' ', array_unique( array_filter( $classes ) ) ), 'style' => $engine['css'] ?? '', ); } /** * Imprime el atributo data-qlf-url (enlace al archivo de un término), si hay URL. * * @param string $url URL sin escapar. */ public static function url_attr( $url ) { if ( $url ) { echo ' data-qlf-url="' . esc_url( $url ) . '"'; } } /** * Título con el mismo markup que el bloque Título del núcleo, para que use * la tipografía de encabezados definida en los estilos globales. * * @param int $level Nivel (2-6). * @param string $html Contenido HTML ya escapado. * @param array $extra Atributos extra. */ public static function heading( $level, $html, array $extra = array() ) { $level = min( 6, max( 2, (int) $level ) ); $attrs = ''; foreach ( $extra as $name => $value ) { $attrs .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) ); } return sprintf( '%3$s', $level, $attrs, $html ); } /** * ID estable para una región del router. * * Debe ser igual en la página actual y en la que se navega, así que no se usa * `wp_unique_id()` (su contador global cambia si otros bloques lo usan, por * ejemplo el botón "Limpiar filtros", que solo aparece con filtros activos). * * @param string $base Base del ID. */ public static function region_id( $base ) { static $counters = array(); $counters[ $base ] = ( $counters[ $base ] ?? 0 ) + 1; return $base . '-' . $counters[ $base ]; } /** * Nombre del parámetro de URL de una taxonomía. */ public static function tax_param( $prefix, $taxonomy, $logic ) { if ( self::AUTHOR === $taxonomy ) { return $prefix . '_au'; } return $prefix . ( 'and' === $logic ? '_ta_' : '_t_' ) . $taxonomy; } /** * Término del archivo actual (categoría, etiqueta, taxonomía o autor), si lo es. * * Un autor se representa como un término de la "taxonomía" AUTHOR: slug = * nicename, term_id = ID del usuario. * * @return WP_Term|object|null */ public static function archive_term() { $queried = get_queried_object(); if ( is_author() && $queried instanceof WP_User ) { return self::author_as_term( $queried ); } if ( ! is_category() && ! is_tag() && ! is_tax() ) { return null; } return $queried instanceof WP_Term ? $queried : null; } /** * Un usuario con la forma de un término (para el filtro por autor). * * @param WP_User $user Usuario. */ public static function author_as_term( WP_User $user ) { return (object) array( 'term_id' => $user->ID, 'slug' => $user->user_nicename, 'name' => $user->display_name, 'parent' => 0, 'taxonomy' => self::AUTHOR, ); } /** * URL de archivo de un término (o de un autor). * * @param WP_Term|object $term Término. * @return string URL o cadena vacía. */ public static function term_url( $term ) { if ( self::AUTHOR === $term->taxonomy ) { return get_author_posts_url( $term->term_id, $term->slug ); } $url = get_term_link( $term ); return is_wp_error( $url ) ? '' : $url; } /** * ¿El bucle de este prefijo sigue el término del archivo? */ public static function follows_archive( $prefix ) { if ( 'qlf' === $prefix ) { return true; // Hereda la consulta de la plantilla: ya está filtrado por el archivo. } return in_array( (int) substr( $prefix, 3 ), self::scan()['follow'], true ); } /** * Término del archivo si una taxonomía de un bucle debe partir filtrada por él. * * @param string $taxonomy Taxonomía del filtro. * @param string $prefix Prefijo del bucle. * @return WP_Term|null */ public static function archive_term_for( $taxonomy, $prefix ) { $term = self::archive_term(); return $term && $term->taxonomy === $taxonomy && self::follows_archive( $prefix ) ? $term : null; } /** * Período del archivo por fecha actual (/2025/, /2025/09/ o /2025/09/14/). * * @return array{from: string, to: string}|null Fechas AAAA-MM-DD, inclusive. */ public static function archive_date_range() { if ( ! is_date() ) { return null; } $year = (int) get_query_var( 'year' ); $month = (int) get_query_var( 'monthnum' ); $day = (int) get_query_var( 'day' ); if ( ! $year ) { return null; } if ( $day && $month ) { $from = sprintf( '%04d-%02d-%02d', $year, $month, $day ); return array( 'from' => $from, 'to' => $from, ); } if ( $month ) { return array( 'from' => sprintf( '%04d-%02d-01', $year, $month ), 'to' => sprintf( '%04d-%02d-%02d', $year, $month, (int) gmdate( 't', gmmktime( 0, 0, 0, $month, 1, $year ) ) ), ); } return array( 'from' => sprintf( '%04d-01-01', $year ), 'to' => sprintf( '%04d-12-31', $year ), ); } /** * URL de "Todas": el archivo del tipo de contenido (la página de entradas, para posts). */ public static function all_items_url( $post_type ) { $url = get_post_type_archive_link( $post_type ); return $url ? $url : home_url( '/' ); } /** * Clave de SORTS para un orderby / order de WP_Query. */ public static function sort_key( $orderby, $order ) { $orderby = is_string( $orderby ) && '' !== $orderby ? preg_replace( '/^post_/', '', $orderby ) : 'date'; $order = is_string( $order ) && 'ASC' === strtoupper( $order ) ? 'asc' : 'desc'; return $orderby . '-' . $order; } /** * Orden propio de un bucle (sin el parámetro de orden de la URL). * * @param int $query_id ID del bucle. * @param bool $inherit Si hereda la consulta de la plantilla. */ public static function default_sort( $query_id, $inherit ) { if ( $inherit ) { $vars = self::main_query_vars(); return self::sort_key( $vars['orderby'] ?? '', $vars['order'] ?? '' ); } $query = self::scan()['queries'][ (int) $query_id ]['query'] ?? array(); return self::sort_key( $query['orderBy'] ?? 'date', $query['order'] ?? 'desc' ); } /** * Lee de la URL los filtros activos para un prefijo. * * @param string $prefix Prefijo de parámetros. * @param bool $with_archive Incluir el término del archivo actual como filtro. * @return array{s: string, tax: array, from: string, to: string, date_archive: bool, order: string} */ public static function get_request( $prefix, $with_archive = true ) { $request = array( 's' => '', 'tax' => array(), 'from' => '', 'to' => '', 'date_archive' => false, // El rango viene del archivo por fecha, no de la URL. 'order' => '', ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Filtros públicos de solo lectura. foreach ( $_GET as $key => $value ) { $key = (string) $key; if ( $prefix . '_s' === $key && is_string( $value ) ) { $request['s'] = sanitize_text_field( wp_unslash( $value ) ); continue; } // Fechas: solo AAAA-MM-DD válidas. foreach ( array( 'from', 'to' ) as $edge ) { if ( $prefix . '_' . $edge === $key && is_string( $value ) && preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $date ) && checkdate( (int) $date[2], (int) $date[3], (int) $date[1] ) ) { $request[ $edge ] = $value; } } if ( $prefix . '_o' === $key && is_string( $value ) && isset( self::SORTS[ $value ] ) ) { $request['order'] = $value; continue; } if ( $prefix . '_au' === $key ) { $values = is_array( $value ) ? array_filter( $value, 'is_string' ) : explode( ',', (string) $value ); $authors = array_values( array_filter( array_map( 'sanitize_title', wp_unslash( $values ) ) ) ); if ( $authors ) { $request['tax'][ self::AUTHOR ] = array( 'terms' => $authors, 'logic' => 'or', 'param' => $key, ); } continue; } foreach ( array( 'or' => '_t_', 'and' => '_ta_' ) as $logic => $infix ) { $start = $prefix . $infix; if ( 0 !== strpos( $key, $start ) ) { continue; } $taxonomy = substr( $key, strlen( $start ) ); if ( ! taxonomy_exists( $taxonomy ) || ! is_taxonomy_viewable( $taxonomy ) ) { continue; } $values = is_array( $value ) ? array_filter( $value, 'is_string' ) : explode( ',', (string) $value ); $terms = array_values( array_filter( array_map( 'sanitize_title', wp_unslash( $values ) ) ) ); if ( $terms ) { $request['tax'][ $taxonomy ] = array( 'terms' => $terms, 'logic' => $logic, 'param' => $key, ); } } } // phpcs:enable $archive = $with_archive ? self::archive_term() : null; if ( $archive && ! isset( $request['tax'][ $archive->taxonomy ] ) && self::follows_archive( $prefix ) ) { $request['tax'][ $archive->taxonomy ] = array( 'terms' => array( $archive->slug ), 'logic' => 'or', 'param' => self::tax_param( $prefix, $archive->taxonomy, 'or' ), 'archive' => true, ); } // En un archivo por fecha (p. ej. /2025/09/), su período es el rango de fechas. $range = $with_archive && ! $request['from'] && ! $request['to'] ? self::archive_date_range() : null; if ( $range && self::follows_archive( $prefix ) ) { $request['from'] = $range['from']; $request['to'] = $range['to']; $request['date_archive'] = true; } return $request; } /** * Aplica los filtros de la URL a un array de argumentos de WP_Query. * * @param array $args Argumentos de la consulta. * @param string $prefix Prefijo de parámetros. * @param string|null $except_tax Taxonomía a omitir (para conteos). * @param bool $with_archive Incluir el término del archivo actual. */ public static function apply_to_args( array $args, $prefix, $except_tax = null, $with_archive = true ) { $request = self::get_request( $prefix, $with_archive ); if ( '' !== $request['s'] ) { $args['s'] = $request['s']; } if ( $request['order'] ) { list( $args['orderby'], $args['order'] ) = self::SORTS[ $request['order'] ]; // Ordenar por título sin los signos iniciales (ver title_orderby()). $args['filterwell_title'] = 'title' === $args['orderby']; } if ( $request['from'] || $request['to'] ) { $range = array( 'inclusive' => true ); if ( $request['from'] ) { $range['after'] = $request['from']; } if ( $request['to'] ) { $range['before'] = $request['to']; } $args['date_query'] = empty( $args['date_query'] ) ? array( $range ) : array( 'relation' => 'AND', $args['date_query'], $range ); } $clauses = array(); foreach ( $request['tax'] as $taxonomy => $filter ) { if ( $taxonomy === $except_tax ) { continue; } if ( self::AUTHOR === $taxonomy ) { $ids = array(); foreach ( $filter['terms'] as $nicename ) { $user = get_user_by( 'slug', $nicename ); if ( $user ) { $ids[] = $user->ID; } } $args['author__in'] = $ids ? $ids : array( 0 ); // Autor inexistente: sin resultados. continue; } $clauses[] = array( 'taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $filter['terms'], 'operator' => 'and' === $filter['logic'] ? 'AND' : 'IN', 'include_children' => true, ); } if ( $clauses ) { $tax_query = array( 'relation' => 'AND' ); if ( ! empty( $args['tax_query'] ) ) { $tax_query[] = $args['tax_query']; } $args['tax_query'] = array_merge( $tax_query, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } return $args; } /** * Orden por título que ignora los signos iniciales: "¿Cómo…?" va con la C * y no antes de la A. Los acentos y mayúsculas ya los ignora el cotejamiento. * * Se quitan con TRIM anidados (y no REGEXP_REPLACE) para funcionar en * cualquier versión de MySQL y MariaDB que soporta WordPress. * * @param string $orderby Cláusula ORDER BY. * @param WP_Query $query Consulta. */ public static function title_orderby( $orderby, $query ) { if ( ! $query->get( 'filterwell_title' ) ) { return $orderby; } global $wpdb; $title = "{$wpdb->posts}.post_title"; foreach ( array( ' ', '"', "'", '“', '”', '‘', '’', '«', '»', '(', '[', '¿', '¡' ) as $char ) { $title = "TRIM(LEADING '" . esc_sql( $char ) . "' FROM $title)"; } $order = 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC'; return "$title $order, {$wpdb->posts}.ID $order"; } /** * Bucles con consulta propia. */ public static function filter_query_block_vars( $query, $block ) { if ( self::$computing_counts || ! isset( $block->context['queryId'] ) ) { return $query; } $query = self::apply_to_args( $query, self::prefix( $block->context['queryId'], false ) ); // El orden propio del bucle por título usa el mismo criterio que el selector. if ( 'title' === ( $query['orderby'] ?? '' ) && in_array( (int) $block->context['queryId'], self::scan()['targets'], true ) ) { $query['filterwell_title'] = true; } return $query; } /** * Variables de la consulta principal tal como llegó (sin los filtros). */ public static function main_query_vars() { if ( null !== self::$main_vars ) { return self::$main_vars; } return isset( $GLOBALS['wp_query'] ) ? (array) $GLOBALS['wp_query']->query_vars : array(); } /** * Parámetros de la petición de la consulta principal (sin los filtros). */ public static function main_query_request() { if ( null !== self::$main_request ) { return self::$main_request; } return isset( $GLOBALS['wp_query'] ) ? (array) $GLOBALS['wp_query']->query : array(); } /** * ¿Hay algún filtro en la URL para este prefijo? * * @param array $request Resultado de get_request(). */ public static function has_filters( array $request ) { return '' !== $request['s'] || $request['tax'] || $request['from'] || $request['to'] || $request['order']; } /** * Bucles que heredan la consulta de la plantilla: justo antes de dibujar un * bucle así que tenga filtros conectados, la consulta principal se vuelve a * hacer con los filtros de la URL. * * Se hace aquí y no en `pre_get_posts` porque ahí aún no se conoce la * plantilla: modificar siempre la consulta principal haría que los * parámetros afectaran a cualquier página, aunque no tenga filtros. * * @param array $parsed_block Bloque core/query. */ private static function maybe_filter_main_query( array $parsed_block ) { if ( null !== self::$main_vars || empty( $parsed_block['attrs']['query']['inherit'] ) ) { return; } // La consulta principal ya está filtrada por el archivo: solo se aplican los parámetros de la URL. if ( ! self::has_filters( self::get_request( 'qlf', false ) ) ) { return; } global $wp_query; self::$main_vars = (array) $wp_query->query_vars; self::$main_request = (array) $wp_query->query; $wp_query = new WP_Query( self::apply_to_args( self::$main_vars, 'qlf', null, false ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Los bloques del bucle heredado leen la consulta global. } /** * Marca como "paginación mejorada" los bucles que tienen filtros apuntándolos. * * Esto permite que el núcleo detecte bloques incompatibles con la navegación * sin recarga dentro del bucle (y, en ese caso, la desactive). */ public static function enable_client_navigation( $parsed_block ) { if ( 'core/query' !== $parsed_block['blockName'] || ! isset( $parsed_block['attrs']['queryId'] ) ) { return $parsed_block; } $scan = self::scan(); if ( in_array( (int) $parsed_block['attrs']['queryId'], $scan['targets'], true ) ) { $parsed_block['attrs']['enhancedPagination'] = true; self::maybe_filter_main_query( $parsed_block ); } return $parsed_block; } /** * Pasa `enhancedPagination` a los bloques internos del bucle (paginación), * para que sus enlaces también naveguen sin recargar. * * @param array $context Contexto del bloque. */ public static function enable_client_navigation_context( $context ) { if ( isset( $context['queryId'] ) && empty( $context['enhancedPagination'] ) && in_array( (int) $context['queryId'], self::scan()['targets'], true ) ) { $context['enhancedPagination'] = true; } return $context; } /** * Agrega al bucle las directivas de la región navegable. * * En bloques anidados el núcleo ya leyó los atributos antes de que * `render_block_data` los cambie, así que las directivas se agregan aquí * igual que lo hace `render_block_core_query()`. * * @param string $content Contenido renderizado. * @param array $block Bloque parseado. */ public static function add_router_region( $content, $block ) { if ( ! isset( $block['attrs']['queryId'] ) || false !== strpos( $content, 'data-wp-router-region' ) ) { return $content; } $query_id = (int) $block['attrs']['queryId']; if ( ! in_array( $query_id, self::scan()['targets'], true ) ) { return $content; } $processor = new WP_HTML_Tag_Processor( $content ); if ( ! $processor->next_tag() ) { return $content; } $processor->set_attribute( 'data-wp-interactive', 'core/query' ); $processor->set_attribute( 'data-wp-router-region', 'query-' . $query_id ); $processor->set_attribute( 'data-wp-context', '{}' ); $processor->set_attribute( 'data-wp-key', (string) $query_id ); wp_enqueue_script_module( '@wordpress/block-library/query/view' ); return $processor->get_updated_html(); } /** * Recorre los bloques de la plantilla y del contenido actual para encontrar * bucles de consulta y bloques de filtros. * * @return array{queries: array, targets: int[]} */ public static function scan() { if ( null !== self::$scan ) { return self::$scan; } self::$scan = array( 'queries' => array(), 'targets' => array(), 'follow' => array(), ); $sources = array(); if ( ! empty( $GLOBALS['_wp_current_template_content'] ) ) { $sources[] = $GLOBALS['_wp_current_template_content']; } if ( is_singular() ) { $post = get_queried_object(); if ( $post instanceof WP_Post ) { $sources[] = $post->post_content; } } foreach ( $sources as $source ) { self::walk( parse_blocks( $source ), 0 ); } self::$scan['targets'] = array_values( array_unique( self::$scan['targets'] ) ); return self::$scan; } private static function walk( array $blocks, $depth ) { if ( $depth > 10 ) { return; } foreach ( $blocks as $block ) { $name = $block['blockName']; $attrs = $block['attrs']; if ( 'core/query' === $name && isset( $attrs['queryId'] ) ) { self::$scan['queries'][ (int) $attrs['queryId'] ] = $attrs; } elseif ( 'filterwell/filters' === $name && isset( $attrs['queryId'] ) ) { self::$scan['targets'][] = (int) $attrs['queryId']; if ( $attrs['followArchive'] ?? true ) { self::$scan['follow'][] = (int) $attrs['queryId']; } } elseif ( 'core/block' === $name && ! empty( $attrs['ref'] ) ) { $reusable = get_post( $attrs['ref'] ); if ( $reusable && 'wp_block' === $reusable->post_type ) { self::walk( parse_blocks( $reusable->post_content ), $depth + 1 ); } } elseif ( 'core/pattern' === $name && ! empty( $attrs['slug'] ) ) { // Patrones del tema insertados en plantillas (). $pattern = WP_Block_Patterns_Registry::get_instance()->get_registered( $attrs['slug'] ); if ( ! empty( $pattern['content'] ) ) { self::walk( parse_blocks( $pattern['content'] ), $depth + 1 ); } } elseif ( 'core/template-part' === $name && ! empty( $attrs['slug'] ) ) { $theme = $attrs['theme'] ?? get_stylesheet(); $part = get_block_template( $theme . '//' . $attrs['slug'], 'wp_template_part' ); if ( $part && ! empty( $part->content ) ) { self::walk( parse_blocks( $part->content ), $depth + 1 ); } } if ( ! empty( $block['innerBlocks'] ) ) { self::walk( $block['innerBlocks'], $depth + 1 ); } } } /** * Cuenta cuántos resultados tendría cada término de una taxonomía (o cada autor) * considerando los demás filtros activos (como hace WooCommerce). * * @param string $taxonomy Taxonomía. * @param array $context Contexto del bloque de filtros (queryId, inherit, postType). * @return array term_id => cantidad. */ public static function term_counts( $taxonomy, array $context ) { $query_id = (int) ( $context['filterwell/queryId'] ?? 0 ); $inherit = ! empty( $context['filterwell/inherit'] ); $prefix = self::prefix( $query_id, $inherit ); if ( $inherit ) { $base = self::main_query_request(); unset( $base['paged'] ); // En el archivo de esta misma taxonomía, los demás términos se cuentan // sin la restricción del término del archivo. $archive = self::archive_term(); if ( $archive && $archive->taxonomy === $taxonomy ) { $query_var = self::AUTHOR === $taxonomy ? '' : get_taxonomy( $taxonomy )->query_var; unset( $base['cat'], $base['category_name'], $base['tag'], $base['tag_id'], $base['taxonomy'], $base['term'], $base['author'], $base['author_name'] ); if ( $query_var ) { unset( $base[ $query_var ] ); } $base['post_type'] = $base['post_type'] ?? ( $context['filterwell/postType'] ?? 'post' ); } } else { $scan = self::scan(); if ( isset( $scan['queries'][ $query_id ] ) ) { $query_block = new WP_Block( array( 'blockName' => 'core/post-template', 'attrs' => array(), 'innerBlocks' => array(), ), array( 'query' => $scan['queries'][ $query_id ]['query'] ?? array(), 'queryId' => $query_id, ) ); self::$computing_counts = true; $base = build_query_vars_from_query_block( $query_block, 1 ); self::$computing_counts = false; } else { $base = array( 'post_type' => $context['filterwell/postType'] ?? 'post' ); } } $args = array_merge( self::apply_to_args( $base, $prefix, $taxonomy ), array( 'fields' => 'ids', 'posts_per_page' => -1, 'offset' => 0, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); unset( $args['paged'] ); // Caché: la clave cambia sola cuando se modifican entradas o términos. $key = 'filterwell_counts_' . md5( wp_json_encode( array( $taxonomy, $args ) ) . wp_cache_get_last_changed( 'posts' ) . wp_cache_get_last_changed( 'terms' ) ); $counts = wp_cache_get( $key, 'filterwell' ); if ( false === $counts ) { $counts = get_transient( $key ); } if ( false === $counts ) { $counts = self::count_objects( $taxonomy, ( new WP_Query( $args ) )->posts ); wp_cache_set( $key, $counts, 'filterwell', HOUR_IN_SECONDS ); // Con texto de búsqueda las combinaciones son infinitas: no se guardan en la base de datos. if ( empty( $args['s'] ) ) { set_transient( $key, $counts, HOUR_IN_SECONDS ); } } return $counts; } /** * Cuenta, entre los contenidos dados, cuántos tiene cada término (o autor). * * @param string $taxonomy Taxonomía (o AUTHOR). * @param int[] $ids IDs de los contenidos. * @return array term_id (o ID del autor) => cantidad. */ private static function count_objects( $taxonomy, array $ids ) { if ( ! $ids ) { return array(); } global $wpdb; // Autores: cantidad de contenidos por autor. if ( self::AUTHOR === $taxonomy ) { $counts = array(); foreach ( array_chunk( array_map( 'intval', $ids ), 1000 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resultado guardado en caché por term_counts(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_author, COUNT(*) AS total FROM {$wpdb->posts} WHERE ID IN ({$placeholders}) GROUP BY post_author", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Marcadores %d generados arriba. $chunk ) ); foreach ( $rows as $row ) { $counts[ (int) $row->post_author ] = ( $counts[ (int) $row->post_author ] ?? 0 ) + (int) $row->total; } } return $counts; } $objects_by_term = array(); foreach ( array_chunk( array_map( 'intval', $ids ), 1000 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resultado guardado en caché por term_counts(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT tt.term_id, tr.object_id FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = %s AND tr.object_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Marcadores %d generados arriba. array_merge( array( $taxonomy ), $chunk ) ) ); foreach ( $rows as $row ) { $objects_by_term[ (int) $row->term_id ][ (int) $row->object_id ] = true; // En taxonomías jerárquicas, el padre también cuenta los posts de sus hijos. if ( is_taxonomy_hierarchical( $taxonomy ) ) { foreach ( get_ancestors( (int) $row->term_id, $taxonomy, 'taxonomy' ) as $ancestor ) { $objects_by_term[ $ancestor ][ (int) $row->object_id ] = true; } } } } return array_map( 'count', $objects_by_term ); } }