to add to the query builder. * @param string $label The 'label' (unique id) for this aggregation. * @param Automattic\Jetpack\Search\WPES\Query_Builder $builder The builder instance that is creating the Elasticsearch query. */ public function add_post_type_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) { $builder->add_aggs( $label, array( 'terms' => array( 'field' => 'post_type', 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), ), ) ); } /** * Given an individual author aggregation, add it to the query builder object for use in Elasticsearch. * * @since 0.20.0 * * @param array $aggregation The aggregation to add to the query builder. * @param string $label The 'label' (unique id) for this aggregation. * @param Automattic\Jetpack\Search\WPES\Query_Builder $builder The builder instance that is creating the Elasticsearch query. */ public function add_author_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) { $builder->add_aggs( $label, array( 'terms' => array( 'field' => 'author_login_slash_name', 'size' => min( (int) $aggregation['count'], $this->max_aggregations_count ), ), ) ); } /** * Given an individual date_histogram aggregation, add it to the query builder object for use in Elasticsearch. * * @since 5.0.0 * * @param array $aggregation The aggregation to add to the query builder. * @param string $label The 'label' (unique id) for this aggregation. * @param Automattic\Jetpack\Search\WPES\Query_Builder $builder The builder instance that is creating the Elasticsearch query. */ public function add_date_histogram_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) { $args = array( 'interval' => $aggregation['interval'], 'field' => ( ! empty( $aggregation['field'] ) && 'post_date_gmt' === $aggregation['field'] ) ? 'date_gmt' : 'date', ); if ( isset( $aggregation['min_doc_count'] ) ) { $args['min_doc_count'] = (int) $aggregation['min_doc_count']; } else { $args['min_doc_count'] = 1; } $builder->add_aggs( $label, array( 'date_histogram' => $args, ) ); } /** * And an existing filter object with a list of additional filters. * * Attempts to optimize the filters somewhat. * * @since 5.0.0 * * @param array $curr_filter The existing filters to build upon. * @param array $filters The new filters to add. * * @return array The resulting merged filters. */ public static function and_es_filters( array $curr_filter, array $filters ) { if ( ! is_array( $curr_filter ) || isset( $curr_filter['match_all'] ) ) { if ( 1 === count( $filters ) ) { return $filters[0]; } return array( 'and' => $filters, ); } return array( 'and' => array_merge( array( $curr_filter ), $filters ), ); } /** * Set the available filters for the search. * * These get rendered via the Jetpack_Search_Widget() widget. * * Behind the scenes, these are implemented using Elasticsearch Aggregations. * * If you do not require counts of how many documents match each filter, please consider using regular WP Query * arguments instead, such as via the jetpack_search_es_wp_query_args filter * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html * * @since 5.0.0 * * @param array $aggregations Array of filters (aggregations) to apply to the search. */ public function set_filters( array $aggregations ) { foreach ( (array) $aggregations as $key => $agg ) { if ( empty( $agg['name'] ) ) { $aggregations[ $key ]['name'] = $key; } } $this->aggregations = $aggregations; } /** * Get the raw Aggregation results from the Elasticsearch response. * * @since 5.0.0 * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html * * @return array Array of Aggregations performed on the search. */ public function get_search_aggregations_results() { $aggregations = array(); $search_result = $this->get_search_result(); if ( ! empty( $search_result ) && ! empty( $search_result['aggregations'] ) ) { $aggregations = $search_result['aggregations']; } return $aggregations; } /** * Get the results of the Filters performed, including the number of matching documents. * * Returns an array of Filters (keyed by $label, as passed to Classic_Search::set_filters()), containing the Filter and all resulting * matching buckets, the url for applying/removing each bucket, etc. * * NOTE - if this is called before the search is performed, an empty array will be returned. Use the $aggregations class * member if you need to access the raw filters set in Classic_Search::set_filters(). * * @since 5.0.0 * * @param WP_Query $query The optional original WP_Query to use for determining which filters are active. Defaults to the main query. * * @return array Array of filters applied and info about them. */ public function get_filters( WP_Query $query = null ) { if ( ! $query instanceof WP_Query ) { global $wp_query; $query = $wp_query; } $aggregation_data = $this->aggregations; if ( empty( $aggregation_data ) ) { return $aggregation_data; } $aggregation_results = $this->get_search_aggregations_results(); if ( ! $aggregation_results ) { return $aggregation_data; } // NOTE - Looping over the _results_, not the original configured aggregations, so we get the 'real' data from ES. foreach ( $aggregation_results as $label => $aggregation ) { if ( empty( $aggregation ) ) { continue; } $type = $this->aggregations[ $label ]['type']; $aggregation_data[ $label ]['buckets'] = array(); $existing_term_slugs = array(); $tax_query_var = null; // Figure out which terms are active in the query, for this taxonomy. if ( 'taxonomy' === $this->aggregations[ $label ]['type'] ) { $tax_query_var = $this->get_taxonomy_query_var( $this->aggregations[ $label ]['taxonomy'] ); if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) { foreach ( $query->tax_query->queries as $tax_query ) { if ( is_array( $tax_query ) && $this->aggregations[ $label ]['taxonomy'] === $tax_query['taxonomy'] && 'slug' === $tax_query['field'] && is_array( $tax_query['terms'] ) ) { $existing_term_slugs = array_merge( $existing_term_slugs, $tax_query['terms'] ); } } } } // Now take the resulting found aggregation items and generate the additional info about them, such as activation/deactivation url, name, count, etc. $buckets = array(); if ( ! empty( $aggregation['buckets'] ) ) { $buckets = (array) $aggregation['buckets']; } if ( 'date_histogram' === $type ) { // re-order newest to oldest. $buckets = array_reverse( $buckets ); } // Some aggregation types like date_histogram don't support the max results parameter. if ( is_int( $this->aggregations[ $label ]['count'] ) && count( $buckets ) > $this->aggregations[ $label ]['count'] ) { $buckets = array_slice( $buckets, 0, $this->aggregations[ $label ]['count'] ); } foreach ( $buckets as $item ) { $query_vars = array(); $active = false; $remove_url = null; $name = ''; // What type was the original aggregation? switch ( $type ) { case 'taxonomy': $taxonomy = $this->aggregations[ $label ]['taxonomy']; $term = get_term_by( 'slug', $item['key'], $taxonomy ); if ( ! $term || ! $tax_query_var ) { continue 2; // switch() is considered a looping structure. } $query_vars = array( $tax_query_var => implode( '+', array_merge( $existing_term_slugs, array( $term->slug ) ) ), ); $name = $term->name; // Let's determine if this term is active or not. if ( in_array( $item['key'], $existing_term_slugs, true ) ) { $active = true; $slug_count = count( $existing_term_slugs ); if ( $slug_count > 1 ) { $remove_url = Helper::add_query_arg( $tax_query_var, rawurlencode( implode( '+', array_diff( $existing_term_slugs, array( $item['key'] ) ) ) ) ); } else { $remove_url = Helper::remove_query_arg( $tax_query_var ); } } break; case 'post_type': $post_type = get_post_type_object( $item['key'] ); if ( ! $post_type || $post_type->exclude_from_search ) { continue 2; // switch() is considered a looping structure. } $query_vars = array( 'post_type' => $item['key'], ); $name = $post_type->labels->singular_name; // Is this post type active on this search? $post_types = $query->get( 'post_type' ); if ( ! is_array( $post_types ) ) { $post_types = array( $post_types ); } if ( in_array( $item['key'], $post_types, true ) ) { $active = true; $post_type_count = count( $post_types ); // For the right 'remove filter' url, we need to remove the post type from the array, or remove the param entirely if it's the only one. if ( $post_type_count > 1 ) { $remove_url = Helper::add_query_arg( 'post_type', rawurlencode( implode( ',', array_diff( $post_types, array( $item['key'] ) ) ) ) ); } else { $remove_url = Helper::remove_query_arg( 'post_type' ); } } break; // The `author` filter is NOT supported in Classic Search. This is used to keep the compatibility for filters outside the overlay with Instant Search. case 'author': $split_names = preg_split( '/\/(.?)/', $item['key'] ); $name = ''; if ( false !== $split_names ) { $name = $split_names[0]; } if ( empty( $name ) ) { continue 2; // switch() is considered a looping structure. } $query_vars = array( 'author' => $name, ); $active = true; $remove_url = Helper::remove_query_arg( 'author' ); break; case 'date_histogram': $timestamp = $item['key'] / 1000; $current_year = $query->get( 'year' ); $current_month = $query->get( 'monthnum' ); $current_day = $query->get( 'day' ); switch ( $this->aggregations[ $label ]['interval'] ) { case 'year': $year = (int) gmdate( 'Y', $timestamp ); $query_vars = array( 'year' => $year, 'monthnum' => false, 'day' => false, ); $name = $year; // Is this year currently selected? if ( ! empty( $current_year ) && (int) $current_year === $year ) { $active = true; $remove_url = Helper::remove_query_arg( array( 'year', 'monthnum', 'day' ) ); } break; case 'month': $year = (int) gmdate( 'Y', $timestamp ); $month = (int) gmdate( 'n', $timestamp ); $query_vars = array( 'year' => $year, 'monthnum' => $month, 'day' => false, ); $name = gmdate( 'F Y', $timestamp ); // Is this month currently selected? if ( ! empty( $current_year ) && (int) $current_year === $year && ! empty( $current_month ) && (int) $current_month === $month ) { $active = true; $remove_url = Helper::remove_query_arg( array( 'year', 'monthnum' ) ); } break; case 'day': $year = (int) gmdate( 'Y', $timestamp ); $month = (int) gmdate( 'n', $timestamp ); $day = (int) gmdate( 'j', $timestamp ); $query_vars = array( 'year' => $year, 'monthnum' => $month, 'day' => $day, ); $name = gmdate( 'F jS, Y', $timestamp ); // Is this day currently selected? if ( ! empty( $current_year ) && (int) $current_year === $year && ! empty( $current_month ) && (int) $current_month === $month && ! empty( $current_day ) && (int) $current_day === $day ) { $active = true; $remove_url = Helper::remove_query_arg( array( 'day' ) ); } break; default: continue 3; // switch() is considered a looping structure. } // End switch. break; default: // continue 2; // switch() is considered a looping structure. } // End switch. // Need to urlencode param values since add_query_arg doesn't. $url_params = urlencode_deep( $query_vars ); $aggregation_data[ $label ]['buckets'][] = array( 'url' => Helper::add_query_arg( $url_params ), 'query_vars' => $query_vars, 'name' => $name, 'count' => $item['doc_count'], 'active' => $active, 'remove_url' => $remove_url, 'type' => $type, 'type_label' => $aggregation_data[ $label ]['name'], 'widget_id' => ! empty( $aggregation_data[ $label ]['widget_id'] ) ? $aggregation_data[ $label ]['widget_id'] : 0, ); } // End foreach. } // End foreach. /** * Modify the aggregation filters returned by get_filters(). * * Useful if you are setting custom filters outside of the supported filters (taxonomy, post_type etc.) and * want to hook them up so they're returned when you call `get_filters()`. * * @module search * * @since 6.9.0 * * @param array $aggregation_data The array of filters keyed on label. * @param WP_Query $query The WP_Query object. */ return apply_filters( 'jetpack_search_get_filters', $aggregation_data, $query ); } /** * Get the filters that are currently applied to this search. * * @since 5.0.0 * * @return array Array of filters that were applied. */ public function get_active_filter_buckets() { $active_buckets = array(); $filters = $this->get_filters(); if ( ! is_array( $filters ) ) { return $active_buckets; } foreach ( $filters as $filter ) { if ( isset( $filter['buckets'] ) && is_array( $filter['buckets'] ) ) { foreach ( $filter['buckets'] as $item ) { if ( isset( $item['active'] ) && $item['active'] ) { $active_buckets[] = $item; } } } } return $active_buckets; } /** * Calculate the right query var to use for a given taxonomy. * * Allows custom code to modify the GET var that is used to represent a given taxonomy, via the jetpack_search_taxonomy_query_var filter. * * @since 5.0.0 * * @param string $taxonomy_name The name of the taxonomy for which to get the query var. * * @return bool|string The query var to use for this taxonomy, or false if none found. */ public function get_taxonomy_query_var( $taxonomy_name ) { $taxonomy = get_taxonomy( $taxonomy_name ); if ( ! $taxonomy || is_wp_error( $taxonomy ) ) { return false; } /** * Modify the query var to use for a given taxonomy * * @module search * * @since 5.0.0 * * @param string $query_var The current query_var for the taxonomy * @param string $taxonomy_name The taxonomy name */ return apply_filters( 'jetpack_search_taxonomy_query_var', $taxonomy->query_var, $taxonomy_name ); } /** * Takes an array of aggregation results, and ensures the array key ordering matches the key order in $desired * which is the input order. * * Necessary because ES does not always return aggregations in the same order that you pass them in, * and it should be possible to control the display order easily. * * @since 5.0.0 * * @param array $aggregations Aggregation results to be reordered. * @param array $desired Array with keys representing the desired ordering. * * @return array A new array with reordered keys, matching those in $desired. */ public function fix_aggregation_ordering( array $aggregations, array $desired ) { if ( empty( $aggregations ) || empty( $desired ) ) { return $aggregations; } $reordered = array(); foreach ( array_keys( $desired ) as $agg_name ) { if ( isset( $aggregations[ $agg_name ] ) ) { $reordered[ $agg_name ] = $aggregations[ $agg_name ]; } } return $reordered; } /** * Sends events to Tracks when a search filters widget is updated. * * @since 5.8.0 * * @param string $option The option name. Only "widget_jetpack-search-filters" is cared about. * @param array $old_value The old option value. * @param array $new_value The new option value. */ public function track_widget_updates( $option, $old_value, $new_value ) { if ( 'widget_jetpack-search-filters' !== $option ) { return; } $event = Helper::get_widget_tracks_value( $old_value, $new_value ); if ( ! $event ) { return; } $tracking = new \Automattic\Jetpack\Tracking(); $tracking->tracks_record_event( wp_get_current_user(), sprintf( 'jetpack_search_widget_%s', $event['action'] ), $event['widget'] ); } /** * Moves any active search widgets to the inactive category. * * @since 5.9.0 */ public function move_search_widgets_to_inactive() { if ( ! is_active_widget( false, false, Helper::FILTER_WIDGET_BASE, true ) ) { return; } $sidebars_widgets = wp_get_sidebars_widgets(); if ( ! is_array( $sidebars_widgets ) ) { return; } $changed = false; foreach ( $sidebars_widgets as $sidebar => $widgets ) { if ( 'wp_inactive_widgets' === $sidebar || str_starts_with( $sidebar, 'orphaned_widgets' ) ) { continue; } if ( is_array( $widgets ) ) { foreach ( $widgets as $key => $widget ) { if ( _get_widget_id_base( $widget ) === Helper::FILTER_WIDGET_BASE ) { $changed = true; array_unshift( $sidebars_widgets['wp_inactive_widgets'], $widget ); unset( $sidebars_widgets[ $sidebar ][ $key ] ); } } } } if ( $changed ) { wp_set_sidebars_widgets( $sidebars_widgets ); } } /** * Determine whether a given WP_Query should be handled by ElasticSearch. * * @param WP_Query $query The WP_Query object. * * @return bool */ public function should_handle_query( $query ) { /** * Determine whether a given WP_Query should be handled by ElasticSearch. * * @module search * * @since 5.6.0 * * @param bool $should_handle Should be handled by Jetpack Search. * @param WP_Query $query The WP_Query object. */ return apply_filters( 'jetpack_search_should_handle_query', $query->is_main_query() && $query->is_search(), $query ); } /** * Transforms an array with fields name as keys and boosts as value into * shorthand "caret" format. * * @param array $fields_boost [ "title" => "2", "content" => "1" ]. * * @return array [ "title^2", "content^1" ] */ private function _get_caret_boosted_fields( array $fields_boost ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore $caret_boosted_fields = array(); foreach ( $fields_boost as $field => $boost ) { $caret_boosted_fields[] = "$field^$boost"; } return $caret_boosted_fields; } /** * Apply a multiplier to boost values. * * @param array $fields_boost [ "title" => 2, "content" => 1 ]. * @param array $fields_boost_multiplier [ "title" => 0.1234 ]. * * @return array [ "title" => "0.247", "content" => "1.000" ] */ private function _apply_boosts_multiplier( array $fields_boost, array $fields_boost_multiplier ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore foreach ( $fields_boost as $field_name => $field_boost ) { if ( isset( $fields_boost_multiplier[ $field_name ] ) ) { $fields_boost[ $field_name ] *= $fields_boost_multiplier[ $field_name ]; } // Set a floor and format the number as string. $fields_boost[ $field_name ] = number_format( max( 0.001, $fields_boost[ $field_name ] ), 3, '.', '' ); } return $fields_boost; } }