slug = 'facets'; $this->group = 'core-search'; $this->requires_install_reindex = false; $this->default_settings = [ 'match_type' => 'all', ]; $types = [ 'taxonomy' => __NAMESPACE__ . '\Types\Taxonomy\FacetType', ]; if ( version_compare( get_bloginfo( 'version' ), '5.8', '>=' ) ) { $types['meta'] = __NAMESPACE__ . '\Types\Meta\FacetType'; $types['meta-range'] = __NAMESPACE__ . '\Types\MetaRange\FacetType'; $types['post-type'] = __NAMESPACE__ . '\Types\PostType\FacetType'; $types['date'] = __NAMESPACE__ . '\Types\Date\FacetType'; } /** * Filter the Facet types available. * * ``` * add_filter( * 'ep_facet_types', * function ( $types ) { * $types['post_type'] = '\MyPlugin\PostType'; * return $types; * } * ); * ``` * * @since 4.3.0 * @hook ep_facet_types * @param {array} $types Array of types available. Keys are slugs, values are class names. * @return {array} New array of types available */ $types = apply_filters( 'ep_facet_types', $types ); foreach ( $types as $type => $class ) { if ( is_a( $class, __NAMESPACE__ . '\FacetType', true ) ) { $this->types[ $type ] = new $class(); } } parent::__construct(); } /** * Sets i18n strings. * * @return void * @since 5.2.0 */ public function set_i18n_strings(): void { $this->title = esc_html__( 'Filters', 'elasticpress' ); $this->summary = '
' . ( wp_is_block_theme() ? sprintf( /* translators: Site Editor URL */ __( 'Adds filter blocks that administrators can add to the website’s templates and template parts, so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ), esc_url( admin_url( 'site-editor.php' ) ) ) : sprintf( /* translators: Widgets Edit Screen URL */ __( 'Adds filter widgets that administrators can add to the website’s sidebars (widgetized areas), so that visitors can filter applicable content and search results by one or more taxonomy terms, metafields, and date ranges.', 'elasticpress' ), esc_url( admin_url( 'widgets.php' ) ) ) ) . '
'; $this->docs_url = __( 'https://www.elasticpress.io/resources/articles/configuring-elasticpress-via-the-plugin-dashboard/#filters', 'elasticpress' ); } /** * Setup hooks and filters for feature * * @since 2.5 */ public function setup() { foreach ( $this->types as $type => $class ) { $this->types[ $type ]->setup(); } add_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_legacy_widget' ] ); add_action( 'ep_valid_response', [ $this, 'get_aggs' ], 10, 4 ); add_action( 'wp_enqueue_scripts', [ $this, 'front_scripts' ] ); add_action( 'enqueue_block_editor_assets', [ $this, 'front_scripts' ] ); add_action( 'ep_feature_box_settings_facets', [ $this, 'settings' ], 10, 1 ); add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 ); add_action( 'pre_get_posts', [ $this, 'facet_query' ] ); add_filter( 'ep_post_filters', [ $this, 'apply_facets_filters' ], 10, 3 ); add_action( 'rest_api_init', [ $this, 'setup_endpoints' ] ); } /** * Unsetup Facets related hooks * * @since 5.1.0 */ public function tear_down() { remove_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $this, 'hide_legacy_widget' ] ); remove_action( 'ep_valid_response', [ $this, 'get_aggs' ] ); remove_action( 'wp_enqueue_scripts', [ $this, 'front_scripts' ] ); remove_action( 'enqueue_block_editor_assets', [ $this, 'front_scripts' ] ); remove_action( 'ep_feature_box_settings_facets', [ $this, 'settings' ] ); remove_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ] ); remove_action( 'pre_get_posts', [ $this, 'facet_query' ] ); remove_filter( 'ep_post_filters', [ $this, 'apply_facets_filters' ] ); remove_action( 'rest_api_init', [ $this, 'setup_endpoints' ] ); } /** * If we are doing `or` matches, we need to remove filters from aggs. * * By default, the same filters applied to the main query are applied to aggregations. * If doing `or` matches, those should be removed so we get a broader set of results. * * @param array $args ES arguments * @param array $query_args Query arguments * @param WP_Query $query WP Query instance * @since 2.5 * @return array */ public function set_agg_filters( $args, $query_args, $query ) { // Not a facetable query if ( empty( $query_args['ep_facet'] ) ) { return $args; } if ( 'any' === $this->get_match_type() ) { add_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 ); } /** * This flag is used to differentiate filters being applied to the query and to its aggregations. */ $query_args['ep_facet_adding_agg_filters'] = true; /** * Filter WP query arguments that will be used to build the aggregations filter. * * The returned `$query_args` will be used to build the aggregations filter passing * it through `Indexable\Post\Post::format_args()`. * * @hook ep_facet_agg_filters * @since 4.3.0 * @param {array} $query_args Query arguments * @param {array} $args ES arguments * @param {array} $query WP Query instance * @return {array} New facets aggregations */ $query_args = apply_filters( 'ep_facet_agg_filters', $query_args, $args, $query ); remove_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 ); $facet_formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_args, $query ); add_filter( 'ep_post_formatted_args', [ $this, 'set_agg_filters' ], 10, 3 ); remove_filter( 'ep_post_filters', [ $this, 'remove_facets_filter' ], 11 ); $args['aggs']['terms']['filter'] = $facet_formatted_args['post_filter']; return $args; } /** * Output scripts for widget admin * * @param string $hook WP hook * @since 2.5 */ public function admin_scripts( $hook ) { _doing_it_wrong( __METHOD__, esc_html__( 'Facets no longer require admin styles.', 'elasticpress' ), '4.7.0' ); } /** * Output front end facets styles * * @since 2.5 */ public function front_scripts() { wp_register_script( 'elasticpress-facets', EP_URL . 'dist/js/facets-script.js', Utils\get_asset_info( 'facets-script', 'dependencies' ), Utils\get_asset_info( 'facets-script', 'version' ), true ); wp_set_script_translations( 'elasticpress-facets', 'elasticpress' ); wp_register_style( 'elasticpress-facets', EP_URL . 'dist/css/facets-styles.css', Utils\get_asset_info( 'facets-styles', 'dependencies' ), Utils\get_asset_info( 'facets-styles', 'version' ) ); } /** * Figure out if we can/should facet the query * * @param WP_Query $query WP Query * @since 2.5 * @return bool */ public function is_facetable( $query ) { /** * Bypass the standard checks and set a query to be facetable. * * @deprecated 5.3.0 Use the 'ep_is_facetable' argument in WP_Query instead. * * @hook ep_is_facetable * @param {bool} $bypass Defaults to false. * @param {WP_Query} $query The current WP_Query. * @return {bool} true to bypass, false to ignore */ if ( apply_filters_deprecated( 'ep_is_facetable', [ false, $query ], 'ElasticPress 5.3.0', 'WP_Query->ep_is_facetable argument' ) ) { return true; } $ep_is_facetable = $query->get( 'ep_is_facetable', null ); if ( null !== $ep_is_facetable ) { return (bool) $ep_is_facetable; } if ( is_admin() || is_feed() ) { return false; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return false; } if ( ! $query->is_main_query() ) { return false; } $ep_integrate = $query->get( 'ep_integrate', null ); if ( false === $ep_integrate ) { return false; } $woocommerce = Features::factory()->get_registered_feature( 'woocommerce' ); if ( ! $woocommerce->is_active() && ( function_exists( 'is_product_category' ) && is_product_category() ) ) { return false; } if ( ! $this->is_facetable_page( $query ) ) { return false; } return true; } /** * We enable ElasticPress facet on all archive/search queries as well as non-static home pages. There is no way to know * when a facet widget is used before the main query is executed so we enable EP * everywhere where a facet widget could be used. * * @param WP_Query $query WP Query * @since 2.5 */ public function facet_query( $query ) { if ( ! $this->is_facetable( $query ) ) { return; } // If any filter was selected, there is no reason to prepend the list with sticky posts. $selected_filters = $this->get_selected(); if ( ! empty( array_filter( $selected_filters ) ) ) { $query->set( 'ignore_sticky_posts', true ); } /** * Filter facet aggregations. * * This is used by facet types to add their own aggregations to the * general facet. * * @hook ep_facet_wp_query_aggs_facet * @since 4.3.0 * @param {array} $facets Facets aggregations * @return {array} New facets aggregations */ $facets = apply_filters( 'ep_facet_wp_query_aggs_facet', [] ); if ( empty( $facets ) ) { return; } $query->set( 'ep_integrate', true ); $query->set( 'ep_facet', true ); $aggs = array( 'name' => 'terms', 'use-filter' => true, 'aggs' => $facets, ); $query->set( 'aggs', $aggs ); } /** * Get aggregations from Elasticsearch response and store on query object * * @param array $response ES response * @param array $query Prepared Elasticsearch query * @param array $query_args Current WP Query arguments * @param mixed $query_object Could be WP_Query, WP_User_Query, etc. * @since 2.5 */ public function get_aggs( $response, $query, $query_args, $query_object ) { if ( empty( $query_object ) || ! $query_object instanceof \WP_Query || ! $this->is_facetable( $query_object ) ) { return; } if ( ! empty( $response['aggregations'] ) ) { $processed_aggs = []; if ( isset( $response['aggregations']['terms'] ) && is_array( $response['aggregations']['terms'] ) ) { foreach ( $response['aggregations']['terms'] as $key => $agg ) { if ( 'doc_count' === $key ) { continue; } if ( ! is_array( $agg ) || ( empty( $agg['buckets'] ) && empty( $agg['value'] ) ) ) { continue; } $processed_aggs[ $key ] = []; if ( ! empty( $agg['value'] ) ) { $processed_aggs[ $key ] = $agg['value']; continue; } foreach ( $agg['buckets'] as $bucket ) { $processed_aggs[ $key ][ $bucket['key'] ] = $bucket['doc_count']; } } } $this->set_query_aggregations( $query_object, $processed_aggs ); } } /** * Set aggregation data for a query. * * @since 5.3.0 * @param \WP_Query $query The WP_Query object to store data for * @param array $aggregations The aggregation data to store */ public function set_query_aggregations( $query, $aggregations ): void { // Store aggregations on the query object $query->ep_aggregations = $aggregations; if ( $query->is_main_query() && $this->should_sync_to_global( $query ) ) { _doing_it_wrong( __METHOD__, esc_html__( 'The global variable $GLOBALS[\'ep_facet_aggs\'] is deprecated. Access aggregation data directly from the query object using $query->ep_aggregations or the Facets feature methods get_query_aggregations() and get_facet_aggregation().', 'elasticpress' ), 'ElasticPress 5.3.0' ); $GLOBALS['ep_facet_aggs'] = $aggregations; } } /** * Get aggregation data for a specific query * * @since 5.3.0 * @param \WP_Query $query The WP_Query object * @return array|false Aggregation data or false if not found */ public function get_query_aggregations( $query ) { if ( $query instanceof \WP_Query && isset( $query->ep_aggregations ) && false !== $query->ep_aggregations ) { return $query->ep_aggregations; } // Fallback to global variable return $GLOBALS['ep_facet_aggs'] ?? false; } /** * Get aggregation data for a specific facet within a query * * @since 5.3.0 * @param \WP_Query $query The WP_Query object * @param string $facet_name The name of the facet to retrieve * @return array|false Facet aggregation data or false if not found */ public function get_facet_aggregation( $query, $facet_name ) { $aggregations = $this->get_query_aggregations( $query ); if ( false === $aggregations || ! isset( $aggregations[ $facet_name ] ) ) { return false; } return $aggregations[ $facet_name ]; } /** * Determine if aggregations should be synced to global variable * * @since 5.3.0 * @param \WP_Query $query The query to evaluate * @return bool True if should sync to global, false otherwise */ protected function should_sync_to_global( $query ) { /** * Filter whether to sync query aggregations to global variable * * @since 5.3.0 * @hook ep_facet_sync_aggregations_to_global * @param {bool} $sync Whether to sync (default: false) * @param {WP_Query} $query The query object * @return {bool} Whether to sync aggregations to global variable */ return apply_filters( 'ep_facet_sync_aggregations_to_global', false, $query ); } /** * Get currently selected facets from query args * * @since 2.5 * @return array */ public function get_selected() { $allowed_args = $this->get_allowed_query_args(); $filters = []; $filter_names = []; foreach ( $this->types as $type_obj ) { $filter_names[ $type_obj->get_filter_name() ] = $type_obj; } foreach ( $_GET as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification $key = sanitize_key( $key ); foreach ( $filter_names as $filter_name => $type_obj ) { if ( 0 === strpos( $key, $filter_name ) ) { if ( empty( $value ) ) { continue; } $facet = str_replace( $filter_name, '', $key ); $filters = $type_obj->format_selected( $facet, $value, $filters ); } } if ( in_array( $key, $allowed_args, true ) ) { $filters[ $key ] = $value; } } /** * Filter selected filters. * * @hook ep_facet_selected_filters * @since 5.1.4 * @param {array} $filters Current filters * @return {array} New filters */ return apply_filters( 'ep_facet_selected_filters', $filters ); } /** * Build query url * * @since 2.5 * @param array $filters Facet filters * @return string */ public function build_query_url( $filters ) { $query_params = array(); foreach ( $this->types as $type_obj ) { if ( empty( $filters[ $type_obj->get_filter_type() ] ) ) { continue; } $query_params = $type_obj->add_query_params( $query_params, $filters ); } $feature = Features::factory()->get_registered_feature( 'facets' ); $allowed_args = $feature->get_allowed_query_args(); if ( ! empty( $filters ) ) { foreach ( $filters as $filter => $value ) { if ( in_array( $filter, $allowed_args, true ) ) { $query_params[ $filter ] = $value; } } } $query_string = build_query( $query_params ); /** * Filter facet query string * * @hook ep_facet_query_string * @param {string} $query_string Current query string * @param {array} $query_params Query parameters * @return {string} New query string */ $query_string = apply_filters( 'ep_facet_query_string', $query_string, $query_params ); $url = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $pagination = strpos( $url, '/page' ); if ( false !== $pagination ) { $url = substr( $url, 0, $pagination ); } return strtok( trailingslashit( $url ), '?' ) . ( ( ! empty( $query_string ) ) ? '?' . $query_string : '' ); } /** * Register facet widget(s) * * @since 2.5, deprecated in 4.3.0 */ public function register_widgets() { _deprecated_function( __METHOD__, '4.3.0', "\ElasticPress\Features::factory()->get_registered_feature( 'facets' )->types[ \$type ]->register_widgets()" ); } /** * Hide the legacy widget. * * Hides the legacy widget in favor of the Block when the block editor * is in use and the legacy widget has not been used. * * @since 4.3 * @param array $widgets An array of excluded widget-type IDs. * @return array array of excluded widget-type IDs to hide. */ public function hide_legacy_widget( $widgets ) { $widgets[] = 'ep-facet'; $widgets[] = 'ep-facet-date'; $widgets[] = 'ep-facet-meta'; $widgets[] = 'ep-facet-meta-range'; return $widgets; } /** * Returns allowed query args for facets * * @return mixed|void * @since 3.6.0 */ public function get_allowed_query_args() { $args = array( 's', 'post_type', 'orderby' ); // Retrieve all registered query variables for public taxonomies $taxonomies = get_taxonomies( [ 'public' => true ], 'objects' ); foreach ( $taxonomies as $taxonomy ) { if ( $taxonomy->query_var ) { $args[] = $taxonomy->query_var; } } /** * To keep backward compatibility, WordPress uses `'cat'` for default categories. * It also allows access using the `?taxonomy=