Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.48% covered (danger)
4.48%
39 / 870
11.11% covered (danger)
11.11%
5 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
Classic_Search
4.48% covered (danger)
4.48%
39 / 870
11.11% covered (danger)
11.11%
5 / 45
64745.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 instance
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 initialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 __clone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __wakeup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init_hooks
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 has_vip_index
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 store_query_failure
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 print_query_failure
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 store_last_query_info
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 print_query_success
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 get_last_query_info
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 get_last_query_failure_info
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 are_filters_by_widget_disabled
n/a
0 / 0
n/a
0 / 0
1
 set_filters_from_widgets
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 maybe_add_post_type_as_var
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 search
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
182
 filter__posts_pre_query
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 do_search
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
210
 update_search_results_aggregations
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 get_es_wp_query_terms_for_query
35.71% covered (danger)
35.71%
5 / 14
0.00% covered (danger)
0.00%
0 / 1
43.15
 get_es_wp_query_post_type_for_query
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
7.04
 action__widgets_init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_search_result
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
42
 filter__add_date_filter_to_query
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 convert_wp_es_to_es_args
0.00% covered (danger)
0.00%
0 / 257
0.00% covered (danger)
0.00%
0 / 1
1406
 add_aggregations_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 add_taxonomy_aggregation_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 add_post_type_aggregation_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 add_author_aggregation_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 add_date_histogram_aggregation_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 add_product_attribute_aggregation_to_es_query_builder
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 build_product_attribute_agg
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 and_es_filters
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 set_filters
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_search_aggregations_results
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_filters
0.00% covered (danger)
0.00%
0 / 188
0.00% covered (danger)
0.00%
0 / 1
4692
 get_active_filter_buckets
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 get_taxonomy_query_var
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 fix_aggregation_ordering
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 track_widget_updates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 move_search_widgets_to_inactive
11.76% covered (danger)
11.76%
2 / 17
0.00% covered (danger)
0.00%
0 / 1
78.70
 should_handle_query
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 _get_caret_boosted_fields
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 _apply_boosts_multiplier
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Classic Search: Our original search experience with filtering capability.
4 *
5 * @package    @automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Connection\Client;
11use WP_Error;
12use WP_Query;
13use WP_Tax_Query;
14
15/**
16 * Class responsible for enabling the Classic Search experience on the site.
17 */
18class Classic_Search {
19    /**
20     * The singleton instance of this class.
21     *
22     * @since 5.0.0
23     * @var Classic_Search
24     */
25    private static $instance;
26
27    /**
28     * The number of found posts.
29     *
30     * @since 5.0.0
31     * @var int
32     */
33    protected $found_posts = 0;
34
35    /**
36     * The search result, as returned by the WordPress.com REST API.
37     *
38     * @since 5.0.0
39     * @var array
40     */
41    protected $search_result;
42
43    /**
44     * This site's blog ID on WordPress.com.
45     *
46     * @since 5.0.0
47     * @var int
48     */
49    protected $jetpack_blog_id;
50
51    /**
52     * The Elasticsearch aggregations (filters).
53     *
54     * @since 5.0.0
55     * @var array
56     */
57    protected $aggregations = array();
58
59    /**
60     * The maximum number of aggregations allowed.
61     *
62     * @since 5.0.0
63     * @var int
64     */
65    protected $max_aggregations_count = 100;
66
67    /**
68     * Statistics about the last Elasticsearch query.
69     *
70     * @since 5.6.0
71     * @var array
72     */
73    protected $last_query_info = array();
74
75    /**
76     * Statistics about the last Elasticsearch query failure.
77     *
78     * @since 5.6.0
79     * @var array
80     */
81    protected $last_query_failure_info = array();
82
83    /**
84     * Languages with custom analyzers. Other languages are supported, but are analyzed with the default analyzer.
85     *
86     * @since 5.0.0
87     * @var array
88     */
89    public static $analyzed_langs = array( 'ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'eu', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'hy', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' );
90
91    /**
92     * The constructor is not used for this singleton class.
93     */
94    protected function __construct() {
95    }
96
97    /**
98     * Returns a class singleton. Initializes with first-time setup if given a blog ID parameter.
99     *
100     * @param string $blog_id Blog id.
101     * @return static The class singleton.
102     */
103    public static function instance( $blog_id = null ) {
104        if ( ! isset( self::$instance ) ) {
105            if ( null === $blog_id ) {
106                $blog_id = Helper::get_wpcom_site_id();
107            }
108            self::$instance = new static();
109            self::$instance->setup( $blog_id );
110        }
111        return self::$instance;
112    }
113
114    /**
115     * Alias of the instance function.
116     */
117    public static function initialize() {
118        return call_user_func_array( array( static::class, 'instance' ), func_get_args() );
119    }
120
121    /**
122     * Performs setup tasks for the singleton. To be used exclusively after singleton instantitaion.
123     *
124     * @param string|int $blog_id Blog id.
125     */
126    public function setup( $blog_id ) {
127        if ( ! $blog_id ) {
128            return;
129        }
130
131        $this->jetpack_blog_id = $blog_id;
132        $this->init_hooks();
133    }
134
135    /**
136     * Prevent __clone()'ing of this class.
137     *
138     * @since 5.0.0
139     */
140    public function __clone() {
141        wp_die( "Please don't __clone Classic_Search" );
142    }
143
144    /**
145     * Prevent __wakeup()'ing of this class.
146     *
147     * @since 5.0.0
148     */
149    public function __wakeup() {
150        wp_die( "Please don't __wakeup Classic_Search" );
151    }
152
153    /**
154     * Setup the various hooks needed for the plugin to take over search duties.
155     *
156     * @since 5.0.0
157     */
158    public function init_hooks() {
159        if ( ! is_admin() ) {
160            add_filter( 'posts_pre_query', array( $this, 'filter__posts_pre_query' ), 10, 2 );
161
162            add_filter( 'jetpack_search_es_wp_query_args', array( $this, 'filter__add_date_filter_to_query' ), 10, 2 );
163
164            add_action( 'did_jetpack_search_query', array( $this, 'store_last_query_info' ) );
165            add_action( 'failed_jetpack_search_query', array( $this, 'store_query_failure' ) );
166
167            add_action( 'init', array( $this, 'set_filters_from_widgets' ) );
168
169            add_action( 'pre_get_posts', array( $this, 'maybe_add_post_type_as_var' ) );
170        } else {
171            add_action( 'update_option', array( $this, 'track_widget_updates' ), 10, 3 );
172        }
173
174        add_action( 'jetpack_deactivate_module_search', array( $this, 'move_search_widgets_to_inactive' ) );
175    }
176
177    /**
178     * Does this site have a VIP index
179     * Get the version number to use when loading the file. Allows us to bypass cache when developing.
180     *
181     * @since 6.0
182     * @return string $script_version Version number.
183     */
184    public function has_vip_index() {
185        return defined( 'JETPACK_SEARCH_VIP_INDEX' ) && JETPACK_SEARCH_VIP_INDEX;
186    }
187
188    /**
189     * When an Elasticsearch query fails, this stores it and enqueues some debug information in the footer.
190     *
191     * @since 5.6.0
192     *
193     * @param array $meta Information about the failure.
194     */
195    public function store_query_failure( $meta ) {
196        $this->last_query_failure_info = $meta;
197        add_action( 'wp_footer', array( $this, 'print_query_failure' ) );
198    }
199
200    /**
201     * Outputs information about the last Elasticsearch failure.
202     *
203     * @since 5.6.0
204     */
205    public function print_query_failure() {
206        if ( $this->last_query_failure_info ) {
207            printf(
208                '<!-- Jetpack Search failed with code %s: %s - %s -->',
209                esc_html( $this->last_query_failure_info['response_code'] ),
210                esc_html( $this->last_query_failure_info['json']['error'] ),
211                esc_html( $this->last_query_failure_info['json']['message'] )
212            );
213        }
214    }
215
216    /**
217     * Stores information about the last Elasticsearch query and enqueues some debug information in the footer.
218     *
219     * @since 5.6.0
220     *
221     * @param array $meta Information about the query.
222     */
223    public function store_last_query_info( $meta ) {
224        $this->last_query_info = $meta;
225        add_action( 'wp_footer', array( $this, 'print_query_success' ) );
226    }
227
228    /**
229     * Outputs information about the last Elasticsearch search.
230     *
231     * @since 5.6.0
232     */
233    public function print_query_success() {
234        if ( $this->last_query_info ) {
235            printf(
236                '<!-- Jetpack Search took %s ms, ES time %s ms -->',
237                (int) $this->last_query_info['elapsed_time'],
238                esc_html( $this->last_query_info['es_time'] )
239            );
240
241            if ( isset( $_GET['searchdebug'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
242                printf(
243                    '<!-- Query response data: %s -->',
244                    esc_html( print_r( $this->last_query_info, 1 ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
245                );
246            }
247        }
248    }
249
250    /**
251     * Returns the last query information, or false if no information was stored.
252     *
253     * @since 5.8.0
254     *
255     * @return bool|array
256     */
257    public function get_last_query_info() {
258        return empty( $this->last_query_info ) ? false : $this->last_query_info;
259    }
260
261    /**
262     * Returns the last query failure information, or false if no failure information was stored.
263     *
264     * @since 5.8.0
265     *
266     * @return bool|array
267     */
268    public function get_last_query_failure_info() {
269        return empty( $this->last_query_failure_info ) ? false : $this->last_query_failure_info;
270    }
271
272    /**
273     * Wraps a WordPress filter called "jetpack_search_disable_widget_filters" that allows
274     * developers to disable filters supplied by the search widget. Useful if filters are
275     * being defined at the code level.
276     *
277     * @since      5.7.0
278     * @deprecated 5.8.0 Use Helper::are_filters_by_widget_disabled() directly.
279     *
280     * @return bool
281     */
282    public function are_filters_by_widget_disabled() {
283        return Helper::are_filters_by_widget_disabled();
284    }
285
286    /**
287     * Retrieves a list of known Jetpack search filters widget IDs, gets the filters for each widget,
288     * and applies those filters to this Classic_Search object.
289     *
290     * @since 5.7.0
291     */
292    public function set_filters_from_widgets() {
293        if ( Helper::are_filters_by_widget_disabled() ) {
294            return;
295        }
296
297        $filters = Helper::get_filters_from_widgets();
298
299        if ( ! empty( $filters ) ) {
300            $this->set_filters( $filters );
301        }
302    }
303
304    /**
305     * Restricts search results to certain post types via a GET argument.
306     *
307     * @since 5.8.0
308     *
309     * @param WP_Query $query A WP_Query instance.
310     */
311    public function maybe_add_post_type_as_var( WP_Query $query ) {
312        // phpcs:disable WordPress.Security.NonceVerification.Recommended
313        if ( $this->should_handle_query( $query ) && ! empty( $_GET['post_type'] ) ) {
314            if ( is_array( $_GET['post_type'] ) ) {
315                $post_types = array_map( 'sanitize_key', $_GET['post_type'] );
316            } else {
317                $post_types = array_map(
318                    'sanitize_key',
319                    explode(
320                        ',',
321                        sanitize_text_field( wp_unslash( $_GET['post_type'] ) )
322                    )
323                );
324            }
325            // phpcs:enable WordPress.Security.NonceVerification.Recommended
326            $query->set( 'post_type', $post_types );
327        }
328    }
329
330    /**
331     * Run a search on the WordPress.com public API.
332     *
333     * @since 5.0.0
334     *
335     * @param array $es_args Args conforming to the WP.com /sites/<blog_id>/search endpoint.
336     *
337     * @return object|WP_Error The response from the public API, or a WP_Error.
338     */
339    public function search( array $es_args ) {
340        $endpoint    = sprintf( '/sites/%s/search', $this->jetpack_blog_id );
341        $service_url = 'https://public-api.wordpress.com/rest/v1' . $endpoint;
342
343        $do_authenticated_request = false;
344
345        if ( class_exists( 'Automattic\\Jetpack\\Connection\\Client' ) &&
346            isset( $es_args['authenticated_request'] ) &&
347            true === $es_args['authenticated_request'] ) {
348            $do_authenticated_request = true;
349        }
350
351        unset( $es_args['authenticated_request'] );
352
353        $request_args = array(
354            'headers'    => array(
355                'Content-Type' => 'application/json',
356            ),
357            'timeout'    => 10,
358            'user-agent' => 'jetpack_search',
359        );
360
361        $request_body = wp_json_encode( $es_args, JSON_UNESCAPED_SLASHES );
362
363        $start_time = microtime( true );
364
365        if ( $do_authenticated_request ) {
366            $request_args['method'] = 'POST';
367
368            $request = Client::wpcom_json_api_request_as_blog( $endpoint, Client::WPCOM_JSON_API_VERSION, $request_args, $request_body );
369        } else {
370            $request_args = array_merge(
371                $request_args,
372                array(
373                    'body' => $request_body,
374                )
375            );
376
377            $request = wp_remote_post( $service_url, $request_args );
378        }
379
380        $end_time = microtime( true );
381
382        if ( is_wp_error( $request ) ) {
383            return $request;
384        }
385        $response_code = wp_remote_retrieve_response_code( $request );
386
387        if ( ! $response_code || $response_code < 200 || $response_code >= 300 ) {
388            return new WP_Error( 'invalid_search_api_response', 'Invalid response from API - ' . $response_code );
389        }
390
391        $response = json_decode( wp_remote_retrieve_body( $request ), true );
392        if ( isset( $response['swap_classic_to_inline_search'] ) && $response['swap_classic_to_inline_search'] === true ) {
393            update_option( Module_Control::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, true );
394        }
395
396        $took = is_array( $response ) && ! empty( $response['took'] )
397            ? $response['took']
398            : null;
399
400        $query = array(
401            'args'          => $es_args,
402            'response'      => $response,
403            'response_code' => $response_code,
404            'elapsed_time'  => ( $end_time - $start_time ) * 1000, // Convert from float seconds to ms.
405            'es_time'       => $took,
406            'url'           => $service_url,
407        );
408
409        /**
410         * Fires after a search request has been performed.
411         *
412         * Includes the following info in the $query parameter:
413         *
414         * array args Array of Elasticsearch arguments for the search
415         * array response Raw API response, JSON decoded
416         * int response_code HTTP response code of the request
417         * float elapsed_time Roundtrip time of the search request, in milliseconds
418         * float es_time Amount of time Elasticsearch spent running the request, in milliseconds
419         * string url API url that was queried
420         *
421         * @module search
422         *
423         * @since  5.0.0
424         * @since  5.8.0 This action now fires on all queries instead of just successful queries.
425         *
426         * @param array $query Array of information about the query performed
427         */
428        do_action( 'did_jetpack_search_query', $query );
429
430        return $response;
431    }
432
433    /**
434     * Bypass the normal Search query and offload it to Jetpack servers.
435     *
436     * This is the main hook of the plugin and is responsible for returning the posts that match the search query.
437     *
438     * @since 5.0.0
439     *
440     * @param array    $posts Current array of posts (still pre-query).
441     * @param WP_Query $query The WP_Query being filtered.
442     *
443     * @return array Array of matching posts.
444     */
445    public function filter__posts_pre_query( $posts, $query ) {
446        if ( ! $this->should_handle_query( $query ) ) {
447            // Intentionally not adding the 'jetpack_search_abort' action since this should fire for every request except for search.
448            return $posts;
449        }
450
451        $this->do_search( $query );
452
453        if ( ! is_array( $this->search_result ) ) {
454            do_action( 'jetpack_search_abort', 'no_search_results_array', $this->search_result );
455            return $posts;
456        }
457
458        // If no results, nothing to do.
459        if ( ! is_countable( $this->search_result['results']['hits'] ) ) {
460            return array();
461        }
462        if ( ! count( $this->search_result['results']['hits'] ) ) {
463            return array();
464        }
465
466        $post_ids = array();
467
468        foreach ( $this->search_result['results']['hits'] as $result ) {
469            $post_ids[] = (int) $result['fields']['post_id'];
470        }
471
472        // Query all posts now.
473        $args = array(
474            'post__in'            => $post_ids,
475            'orderby'             => 'post__in',
476            'perm'                => 'readable',
477            'post_type'           => 'any',
478            'ignore_sticky_posts' => true,
479            'suppress_filters'    => true,
480            'posts_per_page'      => $query->get( 'posts_per_page' ),
481        );
482
483        $posts_query = new WP_Query( $args );
484
485        // WP Core doesn't call the set_found_posts and its filters when filtering posts_pre_query like we do, so need to do these manually.
486        $query->found_posts   = $this->found_posts;
487        $query->max_num_pages = ceil( $this->found_posts / $query->get( 'posts_per_page' ) );
488
489        return $posts_query->posts;
490    }
491
492    /**
493     * Build up the search, then run it against the Jetpack servers.
494     *
495     * @since 5.0.0
496     *
497     * @param WP_Query $query The original WP_Query to use for the parameters of our search.
498     */
499    public function do_search( WP_Query $query ) {
500        if ( ! $this->should_handle_query( $query ) ) {
501            // If we make it here, either 'filter__posts_pre_query' somehow allowed it or a different entry to do_search.
502            do_action( 'jetpack_search_abort', 'search_attempted_non_search_query', $query );
503            return;
504        }
505
506        $page = ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1;
507
508        // Get maximum allowed offset and posts per page values for the API.
509        $max_offset         = Helper::get_max_offset();
510        $max_posts_per_page = Helper::get_max_posts_per_page();
511
512        $posts_per_page = $query->get( 'posts_per_page' );
513        if ( $posts_per_page > $max_posts_per_page ) {
514            $posts_per_page = $max_posts_per_page;
515        }
516
517        // Start building the WP-style search query args.
518        // They'll be translated to ES format args later.
519        $es_wp_query_args = array(
520            'query'          => $query->get( 's' ),
521            'posts_per_page' => $posts_per_page,
522            'paged'          => $page,
523            'orderby'        => $query->get( 'orderby' ),
524            'order'          => $query->get( 'order' ),
525        );
526
527        if ( ! empty( $this->aggregations ) ) {
528            $es_wp_query_args['aggregations'] = $this->aggregations;
529        }
530
531        // Did we query for authors?
532        if ( $query->get( 'author_name' ) ) {
533            $es_wp_query_args['author_name'] = $query->get( 'author_name' );
534        }
535
536        $es_wp_query_args['post_type'] = $this->get_es_wp_query_post_type_for_query( $query );
537        $es_wp_query_args['terms']     = $this->get_es_wp_query_terms_for_query( $query );
538
539        /**
540         * Modify the search query parameters, such as controlling the post_type.
541         *
542         * These arguments are in the format of WP_Query arguments
543         *
544         * @module search
545         *
546         * @since  5.0.0
547         *
548         * @param array    $es_wp_query_args The current query args, in WP_Query format.
549         * @param WP_Query $query            The original WP_Query object.
550         */
551        $es_wp_query_args = apply_filters( 'jetpack_search_es_wp_query_args', $es_wp_query_args, $query );
552
553        // If page * posts_per_page is greater than our max offset, send a 404. This is necessary because the offset is
554        // capped at Helper::get_max_offset(), so a high page would always return the last page of results otherwise.
555        if ( ( $es_wp_query_args['paged'] * $es_wp_query_args['posts_per_page'] ) > $max_offset ) {
556            $query->set_404();
557
558            return;
559        }
560
561        // If there were no post types returned, then 404 to avoid querying against non-public post types, which could
562        // happen if we don't add the post type restriction to the ES query.
563        if ( empty( $es_wp_query_args['post_type'] ) ) {
564            $query->set_404();
565
566            return;
567        }
568
569        // Convert the WP-style args into ES args.
570        $es_query_args = $this->convert_wp_es_to_es_args( $es_wp_query_args );
571
572        // Only trust ES to give us IDs, not the content since it is a mirror.
573        $es_query_args['fields'] = array(
574            'post_id',
575        );
576
577        /**
578         * Modify the underlying ES query that is passed to the search endpoint. The returned args must represent a valid ES query
579         *
580         * This filter is harder to use if you're unfamiliar with ES, but allows complete control over the query
581         *
582         * @module search
583         *
584         * @since  5.0.0
585         *
586         * @param array    $es_query_args The raw Elasticsearch query args.
587         * @param WP_Query $query         The original WP_Query object.
588         */
589        $es_query_args = apply_filters( 'jetpack_search_es_query_args', $es_query_args, $query );
590
591        // Do the actual search query!
592        $this->search_result = $this->search( $es_query_args );
593
594        if ( is_wp_error( $this->search_result ) || ! is_array( $this->search_result ) || empty( $this->search_result['results'] ) || empty( $this->search_result['results']['hits'] ) ) {
595            $this->found_posts = 0;
596
597            return;
598        }
599
600        // If we have aggregations, fix the ordering to match the input order (ES doesn't guarantee the return order).
601        if ( isset( $this->search_result['results']['aggregations'] ) && ! empty( $this->search_result['results']['aggregations'] ) ) {
602            $this->search_result['results']['aggregations'] = $this->fix_aggregation_ordering( $this->search_result['results']['aggregations'], $this->aggregations );
603        }
604
605        // Total number of results for paging purposes. Capped at $max_offset + $posts_per_page, as deep paging gets quite expensive.
606        $this->found_posts = min( $this->search_result['results']['total'], $max_offset + $posts_per_page );
607    }
608
609    /**
610     * If the query has already been run before filters have been updated, then we need to re-run the query
611     * to get the latest aggregations.
612     *
613     * This is especially useful for supporting widget management in the customizer.
614     *
615     * @since 5.8.0
616     *
617     * @return bool Whether the query was successful or not.
618     */
619    public function update_search_results_aggregations() {
620        if ( empty( $this->last_query_info ) || empty( $this->last_query_info['args'] ) ) {
621            return false;
622        }
623
624        $es_args = $this->last_query_info['args'];
625        $builder = new WPES\Query_Builder();
626        $this->add_aggregations_to_es_query_builder( $this->aggregations, $builder );
627        $es_args['aggregations'] = $builder->build_aggregation();
628
629        $this->search_result = $this->search( $es_args );
630
631        return ! is_wp_error( $this->search_result );
632    }
633
634    /**
635     * Given a WP_Query, convert its WP_Tax_Query (if present) into the WP-style Elasticsearch term arguments for the search.
636     *
637     * @since 5.0.0
638     *
639     * @param WP_Query $query The original WP_Query object for which to parse the taxonomy query.
640     *
641     * @return array The new WP-style Elasticsearch arguments (that will be converted into 'real' Elasticsearch arguments).
642     */
643    public function get_es_wp_query_terms_for_query( WP_Query $query ) {
644        $args = array();
645
646        $the_tax_query = $query->tax_query;
647
648        if ( ! $the_tax_query ) {
649            return $args;
650        }
651
652        if ( ! $the_tax_query instanceof WP_Tax_Query || empty( $the_tax_query->queried_terms ) || ! is_array( $the_tax_query->queried_terms ) ) {
653            return $args;
654        }
655
656        foreach ( $the_tax_query->queries as $tax_query ) {
657            // Right now we only support slugs...see note above.
658            if ( ! is_array( $tax_query ) || ! isset( $tax_query['field'] ) || 'slug' !== $tax_query['field'] ) {
659                continue;
660            }
661
662            $taxonomy = $tax_query['taxonomy'];
663
664            if ( ! isset( $args[ $taxonomy ] ) || ! is_array( $args[ $taxonomy ] ) ) {
665                $args[ $taxonomy ] = array();
666            }
667
668            $args[ $taxonomy ] = array_merge( $args[ $taxonomy ], $tax_query['terms'] );
669        }
670
671        return $args;
672    }
673
674    /**
675     * Parse out the post type from a WP_Query.
676     *
677     * Only allows post types that are not marked as 'exclude_from_search'.
678     *
679     * @since 5.0.0
680     *
681     * @param WP_Query $query Original WP_Query object.
682     *
683     * @return array Array of searchable post types corresponding to the original query.
684     */
685    public function get_es_wp_query_post_type_for_query( WP_Query $query ) {
686        $post_types = $query->get( 'post_type' );
687
688        // If we're searching 'any', we want to only pass searchable post types to Elasticsearch.
689        if ( 'any' === $post_types ) {
690            $post_types = array_values(
691                get_post_types(
692                    array(
693                        'exclude_from_search' => false,
694                    )
695                )
696            );
697        }
698
699        if ( ! is_array( $post_types ) ) {
700            $post_types = array( $post_types );
701        }
702
703        $post_types = array_unique( $post_types );
704
705        $sanitized_post_types = array();
706
707        // Make sure the post types are queryable.
708        foreach ( $post_types as $post_type ) {
709            if ( ! $post_type ) {
710                continue;
711            }
712
713            $post_type_object = get_post_type_object( $post_type );
714            if ( ! $post_type_object || $post_type_object->exclude_from_search ) {
715                continue;
716            }
717
718            $sanitized_post_types[] = $post_type;
719        }
720
721        return $sanitized_post_types;
722    }
723
724    /**
725     * Initialize widgets for the Search module (on wp.com only).
726     *
727     * @module search
728     */
729    public function action__widgets_init() {
730        // NOTE: This module only exists on WPCOM.
731        // TODO: Migrate this function to WPCOM!
732        require_once __DIR__ . '/class.jetpack-search-widget-filters.php';
733
734        register_widget( 'Jetpack_Search_Widget_Filters' );
735    }
736
737    /**
738     * Get the Elasticsearch result.
739     *
740     * @since 5.0.0
741     *
742     * @param bool $raw If true, does not check for WP_Error or return the 'results' array - the JSON decoded HTTP response.
743     *
744     * @return array|bool The search results, or false if there was a failure.
745     */
746    public function get_search_result( $raw = false ) {
747        if ( $raw ) {
748            return $this->search_result;
749        }
750
751        return ( ! empty( $this->search_result ) && ! is_wp_error( $this->search_result ) && is_array( $this->search_result ) && ! empty( $this->search_result['results'] ) ) ? $this->search_result['results'] : false;
752    }
753
754    /**
755     * Add the date portion of a WP_Query onto the query args.
756     *
757     * @since 5.0.0
758     *
759     * @param array    $es_wp_query_args The Elasticsearch query arguments in WordPress form.
760     * @param WP_Query $query            The original WP_Query.
761     *
762     * @return array The es wp query args, with date filters added (as needed).
763     */
764    public function filter__add_date_filter_to_query( array $es_wp_query_args, WP_Query $query ) {
765        if ( $query->get( 'year' ) ) {
766            if ( $query->get( 'monthnum' ) ) {
767                // Padding.
768                $date_monthnum = sprintf( '%02d', $query->get( 'monthnum' ) );
769
770                if ( $query->get( 'day' ) ) {
771                    // Padding.
772                    $date_day = sprintf( '%02d', $query->get( 'day' ) );
773
774                    $date_start = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $date_day . ' 00:00:00';
775                    $date_end   = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $date_day . ' 23:59:59';
776                } else {
777                    $days_in_month = gmdate( 't', mktime( 0, 0, 0, $query->get( 'monthnum' ), 14, $query->get( 'year' ) ) ); // 14 = middle of the month so no chance of DST issues
778
779                    $date_start = $query->get( 'year' ) . '-' . $date_monthnum . '-01 00:00:00';
780                    $date_end   = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $days_in_month . ' 23:59:59';
781                }
782            } else {
783                $date_start = $query->get( 'year' ) . '-01-01 00:00:00';
784                $date_end   = $query->get( 'year' ) . '-12-31 23:59:59';
785            }
786
787            $es_wp_query_args['date_range'] = array(
788                'field' => 'date',
789                'gte'   => $date_start,
790                'lte'   => $date_end,
791            );
792        }
793
794        return $es_wp_query_args;
795    }
796
797    /**
798     * Converts WP_Query style args to Elasticsearch args.
799     *
800     * @since 5.0.0
801     *
802     * @param array $args Array of WP_Query style arguments.
803     *
804     * @return array Array of ES style query arguments.
805     */
806    public function convert_wp_es_to_es_args( array $args ) {
807        $defaults = array(
808            'blog_id'        => get_current_blog_id(),
809            'query'          => null,    // Search phrase.
810            'query_fields'   => array(), // list of fields to search.
811            'excess_boost'   => array(), // map of field to excess boost values (multiply).
812            'post_type'      => null,    // string or an array.
813            'terms'          => array(), // ex: array( 'taxonomy-1' => array( 'slug' ), 'taxonomy-2' => array( 'slug-a', 'slug-b' ) ). phpcs:ignore Squiz.PHP.CommentedOutCode.Found.
814            'author'         => null,    // id or an array of ids.
815            'author_name'    => array(), // string or an array.
816            'date_range'     => null,    // array( 'field' => 'date', 'gt' => 'YYYY-MM-dd', 'lte' => 'YYYY-MM-dd' ); date formats: 'YYYY-MM-dd' or 'YYYY-MM-dd HH:MM:SS'. phpcs:ignore Squiz.PHP.CommentedOutCode.Found.
817            'orderby'        => null,    // Defaults to 'relevance' if query is set, otherwise 'date'. Pass an array for multiple orders.
818            'order'          => 'DESC',
819            'posts_per_page' => 10,
820            'offset'         => null,
821            'paged'          => null,
822            /**
823             * Aggregations. Examples:
824             * array(
825             *     'Tag'       => array( 'type' => 'taxonomy', 'taxonomy' => 'post_tag', 'count' => 10 ) ),
826             *     'Post Type' => array( 'type' => 'post_type', 'count' => 10 ) ),
827             * );
828             */
829            'aggregations'   => null,
830        );
831
832        $args = wp_parse_args( $args, $defaults );
833
834        $parser = new WPES\Query_Parser(
835            $args['query'],
836            /**
837             * Filter the languages used by Jetpack Search's Query Parser.
838             *
839             * @module search
840             *
841             * @since  7.9.0
842             *
843             * @param array $languages The array of languages. Default is value of get_locale().
844             */
845            apply_filters( 'jetpack_search_query_languages', array( get_locale() ) )
846        );
847
848        if ( empty( $args['query_fields'] ) ) {
849            if ( $this->has_vip_index() ) {
850                // VIP indices do not have per language fields.
851                $match_fields = $this->_get_caret_boosted_fields(
852                    array(
853                        'title'         => 0.1,
854                        'content'       => 0.1,
855                        'excerpt'       => 0.1,
856                        'tag.name'      => 0.1,
857                        'category.name' => 0.1,
858                        'author_login'  => 0.1,
859                        'author'        => 0.1,
860                    )
861                );
862
863                $boost_fields = $this->_get_caret_boosted_fields(
864                    $this->_apply_boosts_multiplier(
865                        array(
866                            'title'         => 2,
867                            'tag.name'      => 1,
868                            'category.name' => 1,
869                            'author_login'  => 1,
870                            'author'        => 1,
871                        ),
872                        $args['excess_boost']
873                    )
874                );
875
876                $boost_phrase_fields = $this->_get_caret_boosted_fields(
877                    array(
878                        'title'         => 1,
879                        'content'       => 1,
880                        'excerpt'       => 1,
881                        'tag.name'      => 1,
882                        'category.name' => 1,
883                        'author'        => 1,
884                    )
885                );
886            } else {
887                $match_fields = $parser->merge_ml_fields(
888                    array(
889                        'title'         => 0.1,
890                        'content'       => 0.1,
891                        'excerpt'       => 0.1,
892                        'tag.name'      => 0.1,
893                        'category.name' => 0.1,
894                    ),
895                    $this->_get_caret_boosted_fields(
896                        array(
897                            'author_login' => 0.1,
898                            'author'       => 0.1,
899                        )
900                    )
901                );
902
903                $boost_fields = $parser->merge_ml_fields(
904                    $this->_apply_boosts_multiplier(
905                        array(
906                            'title'         => 2,
907                            'tag.name'      => 1,
908                            'category.name' => 1,
909                        ),
910                        $args['excess_boost']
911                    ),
912                    $this->_get_caret_boosted_fields(
913                        $this->_apply_boosts_multiplier(
914                            array(
915                                'author_login' => 1,
916                                'author'       => 1,
917                            ),
918                            $args['excess_boost']
919                        )
920                    )
921                );
922
923                $boost_phrase_fields = $parser->merge_ml_fields(
924                    array(
925                        'title'         => 1,
926                        'content'       => 1,
927                        'excerpt'       => 1,
928                        'tag.name'      => 1,
929                        'category.name' => 1,
930                    ),
931                    $this->_get_caret_boosted_fields(
932                        array(
933                            'author' => 1,
934                        )
935                    )
936                );
937            }
938        } else {
939            // If code is overriding the fields, then use that. Important for backwards compatibility.
940            $match_fields        = $args['query_fields'];
941            $boost_phrase_fields = $match_fields;
942            $boost_fields        = null;
943        }
944
945        $parser->phrase_filter(
946            array(
947                'must_query_fields'  => $match_fields,
948                'boost_query_fields' => null,
949            )
950        );
951        $parser->remaining_query(
952            array(
953                'must_query_fields'  => $match_fields,
954                'boost_query_fields' => $boost_fields,
955            )
956        );
957
958        // Boost on phrase matches.
959        $parser->remaining_query(
960            array(
961                'boost_query_fields' => $boost_phrase_fields,
962                'boost_query_type'   => 'phrase',
963            )
964        );
965
966        /**
967         * Modify the recency decay parameters for the search query.
968         *
969         * The recency decay lowers the search scores based on the age of a post relative to an origin date. Basic adjustments:
970         *  - origin: A date. Posts with this date will have the highest score and no decay applied. Default is today.
971         *  - offset: Number of days/months/years (eg 30d). All posts within this time range of the origin (before and after) will have no decay applied. Default is no offset.
972         *  - scale: The number of days/months/years from the origin+offset at which the decay will equal the decay param. Default 360d
973         *  - decay: The amount of decay applied at offset+scale. Default 0.9.
974         *
975         * The curve applied is a Gaussian. More details available at {@see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-decay}
976         *
977         * @module search
978         *
979         * @since  5.8.0
980         *
981         * @param array $decay_params The decay parameters.
982         * @param array $args         The WP query parameters.
983         */
984        $decay_params = apply_filters(
985            'jetpack_search_recency_score_decay',
986            array(
987                'origin' => gmdate( 'Y-m-d' ),
988                'scale'  => '360d',
989                'decay'  => 0.9,
990            ),
991            $args
992        );
993
994        if ( ! empty( $decay_params ) ) {
995            // Newer content gets weighted slightly higher.
996            $parser->add_decay(
997                'gauss',
998                array(
999                    'date_gmt' => $decay_params,
1000                )
1001            );
1002        }
1003
1004        $es_query_args = array(
1005            'blog_id' => absint( $args['blog_id'] ),
1006            'size'    => absint( $args['posts_per_page'] ),
1007        );
1008
1009        // ES "from" arg (offset).
1010        if ( $args['offset'] ) {
1011            $es_query_args['from'] = absint( $args['offset'] );
1012        } elseif ( $args['paged'] ) {
1013            $es_query_args['from'] = max( 0, ( absint( $args['paged'] ) - 1 ) * $es_query_args['size'] );
1014        }
1015
1016        $es_query_args['from'] = min( $es_query_args['from'], Helper::get_max_offset() );
1017
1018        if ( ! is_array( $args['author_name'] ) ) {
1019            $args['author_name'] = array( $args['author_name'] );
1020        }
1021
1022        // ES stores usernames, not IDs, so transform.
1023        if ( ! empty( $args['author'] ) ) {
1024            if ( ! is_array( $args['author'] ) ) {
1025                $args['author'] = array( $args['author'] );
1026            }
1027
1028            foreach ( $args['author'] as $author ) {
1029                $user = get_user_by( 'id', $author );
1030
1031                if ( $user && ! empty( $user->user_login ) ) {
1032                    $args['author_name'][] = $user->user_login;
1033                }
1034            }
1035        }
1036
1037        /*
1038         * Build the filters from the query elements.
1039         * Filters rock because they are cached from one query to the next
1040         * but they are cached as individual filters, rather than all combined together.
1041         * May get performance boost by also caching the top level boolean filter too.
1042         */
1043
1044        if ( $args['post_type'] ) {
1045            if ( ! is_array( $args['post_type'] ) ) {
1046                $args['post_type'] = array( $args['post_type'] );
1047            }
1048
1049            $parser->add_filter(
1050                array(
1051                    'terms' => array(
1052                        'post_type' => $args['post_type'],
1053                    ),
1054                )
1055            );
1056        }
1057
1058        if ( $args['author_name'] ) {
1059            $parser->add_filter(
1060                array(
1061                    'terms' => array(
1062                        'author_login' => $args['author_name'],
1063                    ),
1064                )
1065            );
1066        }
1067
1068        if ( ! empty( $args['date_range'] ) && isset( $args['date_range']['field'] ) ) {
1069            $field = $args['date_range']['field'];
1070
1071            unset( $args['date_range']['field'] );
1072
1073            $parser->add_filter(
1074                array(
1075                    'range' => array(
1076                        $field => $args['date_range'],
1077                    ),
1078                )
1079            );
1080        }
1081
1082        if ( is_array( $args['terms'] ) ) {
1083            foreach ( $args['terms'] as $tax => $terms ) {
1084                $terms = (array) $terms;
1085
1086                if ( count( $terms ) && mb_strlen( $tax ) ) {
1087                    switch ( $tax ) {
1088                        case 'post_tag':
1089                            $tax_fld = 'tag.slug';
1090
1091                            break;
1092
1093                        case 'category':
1094                            $tax_fld = 'category.slug';
1095
1096                            break;
1097
1098                        default:
1099                            $tax_fld = 'taxonomy.' . $tax . '.slug';
1100
1101                            break;
1102                    }
1103
1104                    foreach ( $terms as $term ) {
1105                        $parser->add_filter(
1106                            array(
1107                                'term' => array(
1108                                    $tax_fld => $term,
1109                                ),
1110                            )
1111                        );
1112                    }
1113                }
1114            }
1115        }
1116
1117        if ( ! $args['orderby'] ) {
1118            if ( $args['query'] ) {
1119                $args['orderby'] = array( 'relevance' );
1120            } else {
1121                $args['orderby'] = array( 'date' );
1122            }
1123        }
1124
1125        // Validate the "order" field.
1126        switch ( strtolower( $args['order'] ) ) {
1127            case 'asc':
1128                $args['order'] = 'asc';
1129                break;
1130
1131            case 'desc':
1132            default:
1133                $args['order'] = 'desc';
1134                break;
1135        }
1136
1137        $es_query_args['sort'] = array();
1138
1139        foreach ( (array) $args['orderby'] as $orderby ) {
1140            // Translate orderby from WP field to ES field.
1141            switch ( $orderby ) {
1142                case 'relevance':
1143                    // never order by score ascending.
1144                    $es_query_args['sort'][] = array(
1145                        '_score' => array(
1146                            'order' => 'desc',
1147                        ),
1148                    );
1149
1150                    break;
1151
1152                case 'date':
1153                    $es_query_args['sort'][] = array(
1154                        'date' => array(
1155                            'order' => $args['order'],
1156                        ),
1157                    );
1158
1159                    break;
1160
1161                case 'ID':
1162                    $es_query_args['sort'][] = array(
1163                        'id' => array(
1164                            'order' => $args['order'],
1165                        ),
1166                    );
1167
1168                    break;
1169
1170                case 'author':
1171                    $es_query_args['sort'][] = array(
1172                        'author.raw' => array(
1173                            'order' => $args['order'],
1174                        ),
1175                    );
1176
1177                    break;
1178            } // End switch.
1179        } // End foreach.
1180
1181        if ( empty( $es_query_args['sort'] ) ) {
1182            unset( $es_query_args['sort'] );
1183        }
1184
1185        // Aggregations.
1186        if ( ! empty( $args['aggregations'] ) ) {
1187            $this->add_aggregations_to_es_query_builder( $args['aggregations'], $parser );
1188        }
1189
1190        $es_query_args['filter']       = $parser->build_filter();
1191        $es_query_args['query']        = $parser->build_query();
1192        $es_query_args['aggregations'] = $parser->build_aggregation();
1193
1194        return $es_query_args;
1195    }
1196
1197    /**
1198     * Given an array of aggregations, parse and add them onto the query builder object for use in Elasticsearch.
1199     *
1200     * @since 5.0.0
1201     *
1202     * @param array                                         $aggregations Array of aggregations (filters) to add to the query builder.
1203     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder      The builder instance that is creating the Elasticsearch query.
1204     */
1205    public function add_aggregations_to_es_query_builder( array $aggregations, $builder ) {
1206        foreach ( $aggregations as $label => $aggregation ) {
1207            if ( ! isset( $aggregation['type'] ) ) {
1208                continue;
1209            }
1210            switch ( $aggregation['type'] ) {
1211                case 'taxonomy':
1212                    $this->add_taxonomy_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1213
1214                    break;
1215
1216                case 'post_type':
1217                    $this->add_post_type_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1218
1219                    break;
1220
1221                case 'author':
1222                    $this->add_author_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1223
1224                    break;
1225
1226                case 'date_histogram':
1227                    $this->add_date_histogram_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1228
1229                    break;
1230
1231                case 'product_attribute':
1232                    $this->add_product_attribute_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1233
1234                    break;
1235            }
1236        }
1237    }
1238
1239    /**
1240     * Given an individual taxonomy aggregation, add it to the query builder object for use in Elasticsearch.
1241     *
1242     * @since 5.0.0
1243     *
1244     * @param array                                         $aggregation The aggregation to add to the query builder.
1245     * @param string                                        $label       The 'label' (unique id) for this aggregation.
1246     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1247     */
1248    public function add_taxonomy_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) {
1249        $field = null;
1250
1251        switch ( $aggregation['taxonomy'] ) {
1252            case 'post_tag':
1253                $field = 'tag';
1254                break;
1255
1256            case 'category':
1257                $field = 'category';
1258                break;
1259
1260            default:
1261                $field = 'taxonomy.' . $aggregation['taxonomy'];
1262                break;
1263        }
1264
1265        $builder->add_aggs(
1266            $label,
1267            array(
1268                'terms' => array(
1269                    'field' => $field . '.slug',
1270                    'size'  => min( (int) $aggregation['count'], $this->max_aggregations_count ),
1271                ),
1272            )
1273        );
1274    }
1275
1276    /**
1277     * Given an individual post_type aggregation, add it to the query builder object for use in Elasticsearch.
1278     *
1279     * @since 5.0.0
1280     *
1281     * @param array                                         $aggregation The aggregation to add to the query builder.
1282     * @param string                                        $label       The 'label' (unique id) for this aggregation.
1283     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1284     */
1285    public function add_post_type_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) {
1286        $builder->add_aggs(
1287            $label,
1288            array(
1289                'terms' => array(
1290                    'field' => 'post_type',
1291                    'size'  => min( (int) $aggregation['count'], $this->max_aggregations_count ),
1292                ),
1293            )
1294        );
1295    }
1296
1297    /**
1298     * Given an individual author aggregation, add it to the query builder object for use in Elasticsearch.
1299     *
1300     * @since 0.20.0
1301     *
1302     * @param array                                         $aggregation The aggregation to add to the query builder.
1303     * @param string                                        $label       The 'label' (unique id) for this aggregation.
1304     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1305     */
1306    public function add_author_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) {
1307        $builder->add_aggs(
1308            $label,
1309            array(
1310                'terms' => array(
1311                    'field' => 'author_login_slash_name',
1312                    'size'  => min( (int) $aggregation['count'], $this->max_aggregations_count ),
1313                ),
1314            )
1315        );
1316    }
1317
1318    /**
1319     * Given an individual date_histogram aggregation, add it to the query builder object for use in Elasticsearch.
1320     *
1321     * @since 5.0.0
1322     *
1323     * @param array                                         $aggregation The aggregation to add to the query builder.
1324     * @param string                                        $label       The 'label' (unique id) for this aggregation.
1325     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1326     */
1327    public function add_date_histogram_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) {
1328        $args = array(
1329            'interval' => $aggregation['interval'],
1330            'field'    => ( ! empty( $aggregation['field'] ) && 'post_date_gmt' === $aggregation['field'] ) ? 'date_gmt' : 'date',
1331        );
1332
1333        if ( isset( $aggregation['min_doc_count'] ) ) {
1334            $args['min_doc_count'] = (int) $aggregation['min_doc_count'];
1335        } else {
1336            $args['min_doc_count'] = 1;
1337        }
1338
1339        $builder->add_aggs(
1340            $label,
1341            array(
1342                'date_histogram' => $args,
1343            )
1344        );
1345    }
1346
1347    /**
1348     * Given an individual product_attribute aggregation, add it to the query builder object for use in Elasticsearch.
1349     *
1350     * @since 0.44.0
1351     *
1352     * @param array                                         $aggregation The aggregation to add to the query builder.
1353     * @param string                                        $label       The 'label' (unique id) for this aggregation.
1354     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1355     */
1356    public function add_product_attribute_aggregation_to_es_query_builder( array $aggregation, $label, $builder ) {
1357        // Handle a specific attribute (from expanded widget filters or direct API usage).
1358        if ( ! empty( $aggregation['attribute'] ) ) {
1359            $this->build_product_attribute_agg( $aggregation['attribute'], $aggregation['count'], $label, $builder );
1360            return;
1361        }
1362
1363        if ( ! function_exists( 'wc_get_attribute_taxonomies' ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) {
1364            return;
1365        }
1366
1367        $product_attributes = wc_get_attribute_taxonomies();
1368
1369        if ( empty( $product_attributes ) ) {
1370            return;
1371        }
1372
1373        foreach ( $product_attributes as $attribute ) {
1374            $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name );
1375            $agg_label      = $label . '_' . $attribute_name;
1376
1377            $this->build_product_attribute_agg( $attribute_name, $aggregation['count'], $agg_label, $builder );
1378
1379            // Store this aggregation in the aggregations array so get_filters() can process it.
1380            $this->aggregations[ $agg_label ] = array(
1381                'type'      => 'product_attribute',
1382                'attribute' => $attribute_name,
1383                'count'     => $aggregation['count'],
1384                'name'      => $aggregation['name'] ?? '',
1385            );
1386        }
1387    }
1388
1389    /**
1390     * Builds and adds a product attribute aggregation to the query builder.
1391     *
1392     * @since 0.44.0
1393     *
1394     * @param string                                        $attribute_name The attribute taxonomy name.
1395     * @param int                                           $count          The maximum number of buckets to return.
1396     * @param string                                        $label          The aggregation label.
1397     * @param \Automattic\Jetpack\Search\WPES\Query_Builder $builder        The query builder instance.
1398     */
1399    private function build_product_attribute_agg( $attribute_name, $count, $label, $builder ) {
1400        $field = 'taxonomy.' . $attribute_name . '.slug';
1401
1402        $builder->add_aggs(
1403            $label,
1404            array(
1405                'terms' => array(
1406                    'field' => $field,
1407                    'size'  => min( (int) $count, $this->max_aggregations_count ),
1408                ),
1409            )
1410        );
1411    }
1412
1413    /**
1414     * And an existing filter object with a list of additional filters.
1415     *
1416     * Attempts to optimize the filters somewhat.
1417     *
1418     * @since 5.0.0
1419     *
1420     * @param array $curr_filter The existing filters to build upon.
1421     * @param array $filters     The new filters to add.
1422     *
1423     * @return array The resulting merged filters.
1424     */
1425    public static function and_es_filters( array $curr_filter, array $filters ) {
1426        if ( isset( $curr_filter['match_all'] ) ) {
1427            if ( 1 === count( $filters ) ) {
1428                return $filters[0];
1429            }
1430
1431            return array(
1432                'and' => $filters,
1433            );
1434        }
1435
1436        return array(
1437            'and' => array_merge( array( $curr_filter ), $filters ),
1438        );
1439    }
1440
1441    /**
1442     * Set the available filters for the search.
1443     *
1444     * These get rendered via the Jetpack_Search_Widget() widget.
1445     *
1446     * Behind the scenes, these are implemented using Elasticsearch Aggregations.
1447     *
1448     * If you do not require counts of how many documents match each filter, please consider using regular WP Query
1449     * arguments instead, such as via the jetpack_search_es_wp_query_args filter
1450     *
1451     * @see    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
1452     *
1453     * @since  5.0.0
1454     *
1455     * @param array $aggregations Array of filters (aggregations) to apply to the search.
1456     */
1457    public function set_filters( array $aggregations ) {
1458        foreach ( $aggregations as $key => $agg ) {
1459            if ( empty( $agg['name'] ) ) {
1460                $aggregations[ $key ]['name'] = $key;
1461            }
1462        }
1463        $this->aggregations = $aggregations;
1464    }
1465
1466    /**
1467     * Get the raw Aggregation results from the Elasticsearch response.
1468     *
1469     * @since  5.0.0
1470     *
1471     * @see    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
1472     *
1473     * @return array Array of Aggregations performed on the search.
1474     */
1475    public function get_search_aggregations_results() {
1476        $aggregations = array();
1477
1478        $search_result = $this->get_search_result();
1479
1480        if ( ! empty( $search_result ) && ! empty( $search_result['aggregations'] ) ) {
1481            $aggregations = $search_result['aggregations'];
1482        }
1483
1484        return $aggregations;
1485    }
1486
1487    /**
1488     * Get the results of the Filters performed, including the number of matching documents.
1489     *
1490     * Returns an array of Filters (keyed by $label, as passed to Classic_Search::set_filters()), containing the Filter and all resulting
1491     * matching buckets, the url for applying/removing each bucket, etc.
1492     *
1493     * NOTE - if this is called before the search is performed, an empty array will be returned. Use the $aggregations class
1494     * member if you need to access the raw filters set in Classic_Search::set_filters().
1495     *
1496     * @since 5.0.0
1497     *
1498     * @param WP_Query $query The optional original WP_Query to use for determining which filters are active. Defaults to the main query.
1499     *
1500     * @return array Array of filters applied and info about them.
1501     */
1502    public function get_filters( ?WP_Query $query = null ) {
1503        if ( ! $query instanceof WP_Query ) {
1504            global $wp_query;
1505
1506            $query = $wp_query;
1507        }
1508
1509        $aggregation_data = $this->aggregations;
1510
1511        if ( empty( $aggregation_data ) ) {
1512            return $aggregation_data;
1513        }
1514
1515        $aggregation_results = $this->get_search_aggregations_results();
1516
1517        if ( ! $aggregation_results ) {
1518            return $aggregation_data;
1519        }
1520
1521        // NOTE - Looping over the _results_, not the original configured aggregations, so we get the 'real' data from ES.
1522        foreach ( $aggregation_results as $label => $aggregation ) {
1523            if ( empty( $aggregation ) ) {
1524                continue;
1525            }
1526
1527            if ( ! isset( $this->aggregations[ $label ] ) ) {
1528                continue;
1529            }
1530
1531            $type = $this->aggregations[ $label ]['type'];
1532
1533            $aggregation_data[ $label ]['buckets'] = array();
1534
1535            $existing_term_slugs = array();
1536
1537            $tax_query_var = null;
1538
1539            // Figure out which terms are active in the query, for this taxonomy.
1540            if ( 'taxonomy' === $this->aggregations[ $label ]['type'] ) {
1541                $tax_query_var = $this->get_taxonomy_query_var( $this->aggregations[ $label ]['taxonomy'] );
1542
1543                if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) {
1544                    foreach ( $query->tax_query->queries as $tax_query ) {
1545                        if (
1546                            is_array( $tax_query ) &&
1547                            isset( $tax_query['taxonomy'] ) &&
1548                            $this->aggregations[ $label ]['taxonomy'] === $tax_query['taxonomy'] &&
1549                            'slug' === $tax_query['field'] &&
1550                            is_array( $tax_query['terms'] )
1551                        ) {
1552                            $existing_term_slugs = array_merge( $existing_term_slugs, $tax_query['terms'] );
1553                        }
1554                    }
1555                }
1556            }
1557
1558            // Now take the resulting found aggregation items and generate the additional info about them, such as activation/deactivation url, name, count, etc.
1559            $buckets = array();
1560
1561            if ( ! empty( $aggregation['buckets'] ) ) {
1562                $buckets = (array) $aggregation['buckets'];
1563            }
1564
1565            if ( 'date_histogram' === $type ) {
1566                // re-order newest to oldest.
1567                $buckets = array_reverse( $buckets );
1568            }
1569
1570            // Some aggregation types like date_histogram don't support the max results parameter.
1571            if ( is_int( $this->aggregations[ $label ]['count'] ) && count( $buckets ) > $this->aggregations[ $label ]['count'] ) {
1572                $buckets = array_slice( $buckets, 0, $this->aggregations[ $label ]['count'] );
1573            }
1574
1575            foreach ( $buckets as $item ) {
1576                $query_vars = array();
1577                $active     = false;
1578                $remove_url = null;
1579                $name       = '';
1580
1581                // What type was the original aggregation?
1582                switch ( $type ) {
1583                    case 'taxonomy':
1584                        $taxonomy = $this->aggregations[ $label ]['taxonomy'];
1585
1586                        $term = get_term_by( 'slug', $item['key'], $taxonomy );
1587
1588                        if ( ! $term || ! $tax_query_var ) {
1589                            continue 2; // switch() is considered a looping structure.
1590                        }
1591
1592                        $query_vars = array(
1593                            $tax_query_var => implode( '+', array_merge( $existing_term_slugs, array( $term->slug ) ) ),
1594                        );
1595
1596                        $name = $term->name;
1597
1598                        // Let's determine if this term is active or not.
1599
1600                        if ( in_array( $item['key'], $existing_term_slugs, true ) ) {
1601                            $active = true;
1602
1603                            $slug_count = count( $existing_term_slugs );
1604
1605                            if ( $slug_count > 1 ) {
1606                                $remove_url = Helper::add_query_arg(
1607                                    $tax_query_var,
1608                                    rawurlencode( implode( '+', array_diff( $existing_term_slugs, array( $item['key'] ) ) ) )
1609                                );
1610                            } else {
1611                                $remove_url = Helper::remove_query_arg( $tax_query_var );
1612                            }
1613                        }
1614
1615                        break;
1616
1617                    case 'product_attribute':
1618                        $attribute_taxonomy = $this->aggregations[ $label ]['attribute'];
1619
1620                        $attribute_term = get_term_by( 'slug', $item['key'], $attribute_taxonomy );
1621
1622                        if ( ! $attribute_term ) {
1623                            continue 2; // switch() is considered a looping structure.
1624                        }
1625
1626                        $tax_query_var = $this->get_taxonomy_query_var( $attribute_taxonomy );
1627
1628                        if ( ! $tax_query_var ) {
1629                            continue 2;
1630                        }
1631
1632                        // Figure out which terms are already selected for this attribute.
1633                        $existing_attribute_slugs = array();
1634                        if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) {
1635                            foreach ( $query->tax_query->queries as $tax_query ) {
1636                                if ( is_array( $tax_query ) && $attribute_taxonomy === $tax_query['taxonomy'] &&
1637                                    'slug' === $tax_query['field'] &&
1638                                    is_array( $tax_query['terms'] ) ) {
1639                                    $existing_attribute_slugs = array_merge( $existing_attribute_slugs, $tax_query['terms'] );
1640                                }
1641                            }
1642                        }
1643
1644                        $name = $attribute_term->name;
1645
1646                        // Let's determine if this attribute is active or not.
1647                        $is_active = in_array( $item['key'], $existing_attribute_slugs, true );
1648
1649                        if ( $is_active ) {
1650                            $active = true;
1651
1652                            // For active items, maintain the current state (don't redundantly add the slug again).
1653                            $query_vars = array(
1654                                $tax_query_var => implode( '+', $existing_attribute_slugs ),
1655                            );
1656
1657                            $slug_count = count( $existing_attribute_slugs );
1658
1659                            if ( $slug_count > 1 ) {
1660                                $remove_url = Helper::add_query_arg(
1661                                    $tax_query_var,
1662                                    rawurlencode( implode( '+', array_diff( $existing_attribute_slugs, array( $item['key'] ) ) ) )
1663                                );
1664                            } else {
1665                                $remove_url = Helper::remove_query_arg( $tax_query_var );
1666                            }
1667                        } else {
1668                            // For inactive items, add this slug to the existing ones.
1669                            $query_vars = array(
1670                                $tax_query_var => implode( '+', array_merge( $existing_attribute_slugs, array( $attribute_term->slug ) ) ),
1671                            );
1672                        }
1673
1674                        break;
1675
1676                    case 'post_type':
1677                        $post_type = get_post_type_object( $item['key'] );
1678
1679                        if ( ! $post_type || $post_type->exclude_from_search ) {
1680                            continue 2;  // switch() is considered a looping structure.
1681                        }
1682
1683                        $query_vars = array(
1684                            'post_type' => $item['key'],
1685                        );
1686
1687                        $name = $post_type->labels->singular_name;
1688
1689                        // Is this post type active on this search?
1690                        $post_types = $query->get( 'post_type' );
1691
1692                        if ( ! is_array( $post_types ) ) {
1693                            $post_types = array( $post_types );
1694                        }
1695
1696                        if ( in_array( $item['key'], $post_types, true ) ) {
1697                            $active = true;
1698
1699                            $post_type_count = count( $post_types );
1700
1701                            // 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.
1702                            if ( $post_type_count > 1 ) {
1703                                $remove_url = Helper::add_query_arg(
1704                                    'post_type',
1705                                    rawurlencode( implode( ',', array_diff( $post_types, array( $item['key'] ) ) ) )
1706                                );
1707                            } else {
1708                                $remove_url = Helper::remove_query_arg( 'post_type' );
1709                            }
1710                        }
1711
1712                        break;
1713
1714                    // The `author` filter is NOT supported in Classic Search. This is used to keep the compatibility for filters outside the overlay with Instant Search.
1715                    case 'author':
1716                        $split_names = preg_split( '/\/(.?)/', $item['key'] );
1717
1718                        $name = '';
1719
1720                        if ( false !== $split_names ) {
1721                            $name = $split_names[0];
1722                        }
1723
1724                        if ( empty( $name ) ) {
1725                            continue 2;  // switch() is considered a looping structure.
1726                        }
1727
1728                        $query_vars = array(
1729                            'author' => $name,
1730                        );
1731
1732                        $active = true;
1733
1734                        $remove_url = Helper::remove_query_arg( 'author' );
1735
1736                        break;
1737
1738                    case 'date_histogram':
1739                        $timestamp = $item['key'] / 1000;
1740
1741                        $current_year  = $query->get( 'year' );
1742                        $current_month = $query->get( 'monthnum' );
1743                        $current_day   = $query->get( 'day' );
1744
1745                        switch ( $this->aggregations[ $label ]['interval'] ) {
1746                            case 'year':
1747                                $year = (int) gmdate( 'Y', $timestamp );
1748
1749                                $query_vars = array(
1750                                    'year'     => $year,
1751                                    'monthnum' => false,
1752                                    'day'      => false,
1753                                );
1754
1755                                $name = $year;
1756
1757                                // Is this year currently selected?
1758                                if ( ! empty( $current_year ) && (int) $current_year === $year ) {
1759                                    $active = true;
1760
1761                                    $remove_url = Helper::remove_query_arg( array( 'year', 'monthnum', 'day' ) );
1762                                }
1763
1764                                break;
1765
1766                            case 'month':
1767                                $year  = (int) gmdate( 'Y', $timestamp );
1768                                $month = (int) gmdate( 'n', $timestamp );
1769
1770                                $query_vars = array(
1771                                    'year'     => $year,
1772                                    'monthnum' => $month,
1773                                    'day'      => false,
1774                                );
1775
1776                                $name = gmdate( 'F Y', $timestamp );
1777
1778                                // Is this month currently selected?
1779                                if ( ! empty( $current_year ) && (int) $current_year === $year &&
1780                                    ! empty( $current_month ) && (int) $current_month === $month ) {
1781                                    $active = true;
1782
1783                                    $remove_url = Helper::remove_query_arg( array( 'year', 'monthnum' ) );
1784                                }
1785
1786                                break;
1787
1788                            case 'day':
1789                                $year  = (int) gmdate( 'Y', $timestamp );
1790                                $month = (int) gmdate( 'n', $timestamp );
1791                                $day   = (int) gmdate( 'j', $timestamp );
1792
1793                                $query_vars = array(
1794                                    'year'     => $year,
1795                                    'monthnum' => $month,
1796                                    'day'      => $day,
1797                                );
1798
1799                                $name = gmdate( 'F jS, Y', $timestamp );
1800
1801                                // Is this day currently selected?
1802                                if ( ! empty( $current_year ) && (int) $current_year === $year &&
1803                                    ! empty( $current_month ) && (int) $current_month === $month &&
1804                                    ! empty( $current_day ) && (int) $current_day === $day ) {
1805                                    $active = true;
1806
1807                                    $remove_url = Helper::remove_query_arg( array( 'day' ) );
1808                                }
1809
1810                                break;
1811
1812                            default:
1813                                continue 3; // switch() is considered a looping structure.
1814                        } // End switch.
1815
1816                        break;
1817
1818                    default:
1819                        // continue 2; // switch() is considered a looping structure.
1820                } // End switch.
1821
1822                // Need to urlencode param values since add_query_arg doesn't.
1823                $url_params = urlencode_deep( $query_vars );
1824
1825                $aggregation_data[ $label ]['buckets'][] = array(
1826                    'url'        => Helper::add_query_arg( $url_params ),
1827                    'query_vars' => $query_vars,
1828                    'name'       => $name,
1829                    'count'      => $item['doc_count'],
1830                    'active'     => $active,
1831                    'remove_url' => $remove_url,
1832                    'type'       => $type,
1833                    'type_label' => $aggregation_data[ $label ]['name'],
1834                    'widget_id'  => ! empty( $aggregation_data[ $label ]['widget_id'] ) ? $aggregation_data[ $label ]['widget_id'] : 0,
1835                );
1836            } // End foreach.
1837        } // End foreach.
1838
1839        /**
1840         * Modify the aggregation filters returned by get_filters().
1841         *
1842         * Useful if you are setting custom filters outside of the supported filters (taxonomy, post_type etc.) and
1843         * want to hook them up so they're returned when you call `get_filters()`.
1844         *
1845         * @module search
1846         *
1847         * @since  6.9.0
1848         *
1849         * @param array    $aggregation_data The array of filters keyed on label.
1850         * @param WP_Query $query            The WP_Query object.
1851         */
1852        return apply_filters( 'jetpack_search_get_filters', $aggregation_data, $query );
1853    }
1854
1855    /**
1856     * Get the filters that are currently applied to this search.
1857     *
1858     * @since 5.0.0
1859     *
1860     * @return array Array of filters that were applied.
1861     */
1862    public function get_active_filter_buckets() {
1863        $active_buckets = array();
1864
1865        $filters = $this->get_filters();
1866
1867        if ( ! is_array( $filters ) ) {
1868            return $active_buckets;
1869        }
1870
1871        foreach ( $filters as $filter ) {
1872            if ( isset( $filter['buckets'] ) && is_array( $filter['buckets'] ) ) {
1873                foreach ( $filter['buckets'] as $item ) {
1874                    if ( isset( $item['active'] ) && $item['active'] ) {
1875                        $active_buckets[] = $item;
1876                    }
1877                }
1878            }
1879        }
1880
1881        return $active_buckets;
1882    }
1883
1884    /**
1885     * Calculate the right query var to use for a given taxonomy.
1886     *
1887     * Allows custom code to modify the GET var that is used to represent a given taxonomy, via the jetpack_search_taxonomy_query_var filter.
1888     *
1889     * @since 5.0.0
1890     *
1891     * @param string $taxonomy_name The name of the taxonomy for which to get the query var.
1892     *
1893     * @return bool|string The query var to use for this taxonomy, or false if none found.
1894     */
1895    public function get_taxonomy_query_var( $taxonomy_name ) {
1896        $taxonomy = get_taxonomy( $taxonomy_name );
1897
1898        if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1899            return false;
1900        }
1901
1902        /**
1903         * Modify the query var to use for a given taxonomy
1904         *
1905         * @module search
1906         *
1907         * @since  5.0.0
1908         *
1909         * @param string $query_var     The current query_var for the taxonomy
1910         * @param string $taxonomy_name The taxonomy name
1911         */
1912        return apply_filters( 'jetpack_search_taxonomy_query_var', $taxonomy->query_var, $taxonomy_name );
1913    }
1914
1915    /**
1916     * Takes an array of aggregation results, and ensures the array key ordering matches the key order in $desired
1917     * which is the input order.
1918     *
1919     * Necessary because ES does not always return aggregations in the same order that you pass them in,
1920     * and it should be possible to control the display order easily.
1921     *
1922     * @since 5.0.0
1923     *
1924     * @param array $aggregations Aggregation results to be reordered.
1925     * @param array $desired      Array with keys representing the desired ordering.
1926     *
1927     * @return array A new array with reordered keys, matching those in $desired.
1928     */
1929    public function fix_aggregation_ordering( array $aggregations, array $desired ) {
1930        if ( empty( $aggregations ) || empty( $desired ) ) {
1931            return $aggregations;
1932        }
1933
1934        $reordered = array();
1935
1936        foreach ( array_keys( $desired ) as $agg_name ) {
1937            if ( isset( $aggregations[ $agg_name ] ) ) {
1938                $reordered[ $agg_name ] = $aggregations[ $agg_name ];
1939            }
1940        }
1941
1942        return $reordered;
1943    }
1944
1945    /**
1946     * Sends events to Tracks when a search filters widget is updated.
1947     *
1948     * @since 5.8.0
1949     *
1950     * @param string $option    The option name. Only "widget_jetpack-search-filters" is cared about.
1951     * @param array  $old_value The old option value.
1952     * @param array  $new_value The new option value.
1953     */
1954    public function track_widget_updates( $option, $old_value, $new_value ) {
1955        if ( 'widget_jetpack-search-filters' !== $option ) {
1956            return;
1957        }
1958
1959        $event = Helper::get_widget_tracks_value( $old_value, $new_value );
1960        if ( ! $event ) {
1961            return;
1962        }
1963
1964        $tracking = new \Automattic\Jetpack\Tracking();
1965        $tracking->tracks_record_event(
1966            wp_get_current_user(),
1967            sprintf( 'jetpack_search_widget_%s', $event['action'] ),
1968            $event['widget']
1969        );
1970    }
1971
1972    /**
1973     * Moves any active search widgets to the inactive category.
1974     *
1975     * @since 5.9.0
1976     */
1977    public function move_search_widgets_to_inactive() {
1978        if ( ! is_active_widget( false, false, Helper::FILTER_WIDGET_BASE, true ) ) {
1979            return;
1980        }
1981
1982        $sidebars_widgets = wp_get_sidebars_widgets();
1983
1984        if ( ! is_array( $sidebars_widgets ) ) {
1985            return;
1986        }
1987
1988        $changed = false;
1989
1990        foreach ( $sidebars_widgets as $sidebar => $widgets ) {
1991            if ( 'wp_inactive_widgets' === $sidebar || str_starts_with( $sidebar, 'orphaned_widgets' ) ) {
1992                continue;
1993            }
1994
1995            if ( is_array( $widgets ) ) {
1996                foreach ( $widgets as $key => $widget ) {
1997                    if ( _get_widget_id_base( $widget ) === Helper::FILTER_WIDGET_BASE ) {
1998                        $changed = true;
1999
2000                        array_unshift( $sidebars_widgets['wp_inactive_widgets'], $widget );
2001                        unset( $sidebars_widgets[ $sidebar ][ $key ] );
2002                    }
2003                }
2004            }
2005        }
2006
2007        if ( $changed ) {
2008            wp_set_sidebars_widgets( $sidebars_widgets );
2009        }
2010    }
2011
2012    /**
2013     * Determine whether a given WP_Query should be handled by ElasticSearch.
2014     *
2015     * @param WP_Query $query The WP_Query object.
2016     *
2017     * @return bool
2018     */
2019    public function should_handle_query( $query ) {
2020        /**
2021         * Determine whether a given WP_Query should be handled by ElasticSearch.
2022         *
2023         * @module search
2024         *
2025         * @since  5.6.0
2026         *
2027         * @param bool     $should_handle Should be handled by Jetpack Search.
2028         * @param WP_Query $query         The WP_Query object.
2029         */
2030        return apply_filters( 'jetpack_search_should_handle_query', $query->is_main_query() && $query->is_search(), $query );
2031    }
2032
2033    /**
2034     * Transforms an array with fields name as keys and boosts as value into
2035     * shorthand "caret" format.
2036     *
2037     * @param array $fields_boost [ "title" => "2", "content" => "1" ].
2038     *
2039     * @return array [ "title^2", "content^1" ]
2040     */
2041    private function _get_caret_boosted_fields( array $fields_boost ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
2042        $caret_boosted_fields = array();
2043        foreach ( $fields_boost as $field => $boost ) {
2044            $caret_boosted_fields[] = "$field^$boost";
2045        }
2046        return $caret_boosted_fields;
2047    }
2048
2049    /**
2050     * Apply a multiplier to boost values.
2051     *
2052     * @param array $fields_boost [ "title" => 2, "content" => 1 ].
2053     * @param array $fields_boost_multiplier [ "title" => 0.1234 ].
2054     *
2055     * @return array [ "title" => "0.247", "content" => "1.000" ]
2056     */
2057    private function _apply_boosts_multiplier( array $fields_boost, array $fields_boost_multiplier ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
2058        foreach ( $fields_boost as $field_name => $field_boost ) {
2059            if ( isset( $fields_boost_multiplier[ $field_name ] ) ) {
2060                $fields_boost[ $field_name ] *= $fields_boost_multiplier[ $field_name ];
2061            }
2062
2063            // Set a floor and format the number as string.
2064            $fields_boost[ $field_name ] = number_format(
2065                max( 0.001, $fields_boost[ $field_name ] ),
2066                3,
2067                '.',
2068                ''
2069            );
2070        }
2071
2072        return $fields_boost;
2073    }
2074}