Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.28% covered (warning)
55.28%
157 / 284
37.50% covered (danger)
37.50%
6 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Inline_Search
55.32% covered (warning)
55.32%
156 / 282
37.50% covered (danger)
37.50%
6 / 16
620.69
0.00% covered (danger)
0.00%
0 / 1
 should_replace_classic_search
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 instance
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 get_instance_maybe_fallback_to_classic
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 filter__posts_pre_query
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 do_search
75.00% covered (warning)
75.00%
36 / 48
0.00% covered (danger)
0.00%
0 / 1
18.52
 search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convert_wp_query_to_api_args
43.01% covered (danger)
43.01%
40 / 93
0.00% covered (danger)
0.00%
0 / 1
77.97
 trigger_es_query_args_filter
98.25% covered (success)
98.25%
56 / 57
0.00% covered (danger)
0.00%
0 / 1
7
 trigger_instant_search_query_args_filter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_langs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_es_filters
27.59% covered (danger)
27.59%
8 / 29
0.00% covered (danger)
0.00%
0 / 1
141.03
 instant_api
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_search_result
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process_search_results
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 create_posts_query
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Inline Search: search without popup using v1.3 Instant Search API
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Inline Search class
16 */
17class Inline_Search extends Classic_Search {
18    /**
19     * The singleton instance of this class.
20     *
21     * @var Inline_Search
22     */
23    private static $instance;
24
25    /**
26     * The Search Highlighter instance.
27     *
28     * @var Inline_Search_Highlighter|null
29     * @since 0.50.0
30     */
31    private $highlighter;
32
33    /**
34     * The search correction instance.
35     *
36     * @var Inline_Search_Correction|null
37     * @since 0.50.0
38     */
39    private $correction;
40
41    /**
42     * Stores the list of post IDs that are actual search results.
43     *
44     * @var array
45     */
46    private $search_result_ids = array();
47
48    /**
49     * Returns whether this class should be used instead of Classic_Search.
50     */
51    public static function should_replace_classic_search(): bool {
52        $option_value = get_option( Module_Control::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, false );
53        return (bool) apply_filters( 'jetpack_search_replace_classic', $option_value );
54    }
55
56    /**
57     * Returns a class singleton. Initializes with first-time setup.
58     *
59     * @param string|int $blog_id Blog id.
60     *
61     * @return Inline_Search The class singleton.
62     */
63    public static function instance( $blog_id = null ) {
64        if ( ! isset( self::$instance ) ) {
65            if ( null === $blog_id ) {
66                $blog_id = Helper::get_wpcom_site_id();
67            }
68            self::$instance = new static();
69            self::$instance->setup( $blog_id );
70
71            // Initialize search correction handling
72            self::$instance->correction = new Inline_Search_Correction();
73
74            // Add hooks for displaying corrected query notice
75            add_action( 'pre_get_posts', array( self::$instance->correction, 'setup_corrected_query_hooks' ) );
76        }
77
78        return self::$instance;
79    }
80
81    /**
82     * Returns a class singleton - either this class, or Classic_Search if we haven't enabled the new feature yet.
83     *
84     * @param string|int $blog_id Blog ID.
85     *
86     * @return Classic_Search|Inline_Search
87     */
88    public static function get_instance_maybe_fallback_to_classic( $blog_id = null ) {
89        if ( self::should_replace_classic_search() ) {
90            return self::instance( $blog_id );
91        } else {
92            return Classic_Search::instance( $blog_id );
93        }
94    }
95
96    /**
97     * Set up the highlighter.
98     *
99     * @param string $blog_id The blog ID to set up for.
100     */
101    public function setup( $blog_id ) {
102        parent::setup( $blog_id );
103        // The highlighter will be initialized with data during search processing
104        $this->highlighter = null;
105    }
106
107    /**
108     * Bypass WP search and offload it to 1.3 search API instead.
109     *
110     * This is the main hook of the plugin and is responsible for returning the posts that match the search query.
111     *
112     * @param array     $posts Current array of posts (still pre-query).
113     * @param \WP_Query $query The WP_Query being filtered.
114     *
115     * @return array Array of matching posts.
116     */
117    public function filter__posts_pre_query( $posts, $query ) {
118        if ( ! $this->should_handle_query( $query ) ) {
119            return $posts;
120        }
121
122        $this->do_search( $query );
123
124        if ( ! is_array( $this->search_result ) ) {
125            do_action( 'jetpack_search_abort', 'no_search_results_array', $this->search_result );
126
127            return $posts;
128        }
129
130        // If no results, nothing to do.
131        if ( ! is_countable( $this->search_result['results'] ) ) {
132            return array();
133        }
134        if ( ! count( $this->search_result['results'] ) ) {
135            return array();
136        }
137
138        // Process the search results to extract post IDs and highlighted content.
139        $this->process_search_results();
140
141        // Create a WP_Query to fetch the actual posts.
142        $posts_query = $this->create_posts_query( $query );
143
144        // 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.
145        $query->found_posts   = $this->found_posts;
146        $query->max_num_pages = ceil( $this->found_posts / $query->get( 'posts_per_page' ) );
147
148        return $posts_query->posts;
149    }
150
151    /**
152     * Execute 1.3 search API request.
153     *
154     * @param \WP_Query $query The original WP_Query to use for the parameters of our search.
155     */
156    public function do_search( \WP_Query $query ) {
157        if ( ! $this->should_handle_query( $query ) ) {
158            do_action( 'jetpack_search_abort', 'search_attempted_non_search_query', $query );
159
160            return;
161        }
162
163        $page = ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1;
164
165        // Get maximum allowed offset and posts per page values for the API.
166        $max_offset         = Helper::get_max_offset();
167        $max_posts_per_page = Helper::get_max_posts_per_page();
168
169        $posts_per_page = $query->get( 'posts_per_page' );
170        if ( $posts_per_page > $max_posts_per_page ) {
171            $posts_per_page = $max_posts_per_page;
172        }
173
174        // Start building the WP-style search query args.
175        // They'll be translated to API format args later.
176        $wp_query_args = array(
177            'query'          => $query->get( 's' ),
178            'posts_per_page' => $posts_per_page,
179            'paged'          => $page,
180            'orderby'        => $query->get( 'orderby' ),
181            'order'          => $query->get( 'order' ),
182        );
183
184        if ( ! empty( $this->aggregations ) ) {
185            $wp_query_args['aggregations'] = $this->aggregations;
186        }
187
188        // Did we query for authors?
189        if ( $query->get( 'author_name' ) ) {
190            $wp_query_args['author_name'] = $query->get( 'author_name' );
191        }
192
193        $wp_query_args['post_type'] = $this->get_es_wp_query_post_type_for_query( $query );
194        $wp_query_args['terms']     = $this->get_es_wp_query_terms_for_query( $query );
195
196        /**
197         * Modify the search query parameters, such as controlling the post_type.
198         *
199         * These arguments are in the format of WP_Query arguments
200         *
201         * @module search
202         *
203         * @since  5.0.0
204         *
205         * @param array $wp_query_args The current query args, in WP_Query format.
206         * @param \WP_Query $query The original WP_Query object.
207         */
208        $wp_query_args = apply_filters( 'jetpack_search_es_wp_query_args', $wp_query_args, $query );
209
210        // If page * posts_per_page is greater than our max offset, send a 404. This is necessary because the offset is
211        // capped at Helper::get_max_offset(), so a high page would always return the last page of results otherwise.
212        if ( ( $wp_query_args['paged'] * $wp_query_args['posts_per_page'] ) > $max_offset ) {
213            $query->set_404();
214
215            return;
216        }
217
218        // If there were no post types returned, then 404 to avoid querying against non-public post types, which could
219        // happen if we don't add the post type restriction to the ES query.
220        if ( empty( $wp_query_args['post_type'] ) ) {
221            $query->set_404();
222
223            return;
224        }
225
226        // Convert the WP-style args into ES args.
227        $api_query_args = $this->convert_wp_query_to_api_args( $wp_query_args );
228        $api_query_args = $this->trigger_es_query_args_filter( $api_query_args, $query );
229        $api_query_args = $this->trigger_instant_search_query_args_filter( $api_query_args );
230
231        // Only trust ES to give us IDs, not the content since it is a mirror.
232        $api_query_args['fields'] = array(
233            'post_id',
234        );
235
236        if ( ! empty( $api_query_args['additional_blog_ids'] ) ) {
237            $api_query_args['fields'] = array_values(
238                array_unique(
239                    array_merge( $api_query_args['fields'], Helper::MULTISITE_SEARCH_FIELD_NAMES )
240                )
241            );
242        }
243
244        // Do the actual search query!
245        $this->search_result = $this->search( $api_query_args );
246
247        if ( is_wp_error( $this->search_result ) || ! is_array( $this->search_result ) || empty( $this->search_result['results'] ) || ! is_array( $this->search_result['results'] ) ) {
248            $this->found_posts = 0;
249
250            return;
251        }
252
253        // If we have aggregations, fix the ordering to match the input order (ES doesn't guarantee the return order).
254        if ( isset( $this->search_result['aggregations'] ) && ! empty( $this->search_result['aggregations'] ) ) {
255            $this->search_result['aggregations'] = $this->fix_aggregation_ordering( $this->search_result['aggregations'], $this->aggregations );
256        }
257
258        // Total number of results for paging purposes. Capped at $max_offset + $posts_per_page, as deep paging gets quite expensive.
259        $this->found_posts = min( $this->search_result['total'], $max_offset + $posts_per_page );
260    }
261
262    /**
263     * Run a search on the WordPress.com v1.3 public API.
264     *
265     * @param array $es_args Args conforming to the WP.com v1.3 search endpoint.
266     *
267     * @return array|\WP_Error The response from the public API converted to Classic Search format, or a WP_Error.
268     */
269    public function search( array $es_args ) {
270        return $this->instant_api( $es_args );
271    }
272
273    /**
274     * Converts WP_Query style args to v1.3 search API args.
275     *
276     * @param array $args Array of WP_Query style arguments.
277     *
278     * @return array Array of Search API v1.3 style request arguments.
279     */
280    public function convert_wp_query_to_api_args( array $args ) {
281        $from = 0;
282        if ( ! empty( $args['offset'] ) ) {
283            $from = absint( $args['offset'] );
284        } elseif ( ! empty( $args['paged'] ) ) {
285            $from = max( 0, ( absint( $args['paged'] ) - 1 ) * absint( $args['posts_per_page'] ) );
286        }
287
288        switch ( $args['orderby'] ?? 'relevance' ) {
289            case 'date':
290                $sort = ( strtolower( $args['order'] ?? '' ) === 'asc' ) ? 'date_asc' : 'date_desc';
291                break;
292            case 'relevance':
293            default:
294                $sort = 'score_recency';
295                break;
296        }
297        $aggregations = array();
298        foreach ( $args['aggregations'] ?? array() as $label => $aggregation ) {
299            if ( empty( $aggregation['type'] ) ) {
300                continue;
301            }
302            $size = min( (int) ( $aggregation['count'] ?? 10 ), $this->max_aggregations_count );
303            switch ( $aggregation['type'] ) {
304                case 'taxonomy':
305                    if ( $aggregation['taxonomy'] === 'post_tag' ) {
306                        $field = 'tag.slug_slash_name';
307                    } elseif ( $aggregation['taxonomy'] === 'category' ) {
308                        $field = 'category.slug_slash_name';
309                    } else {
310                        $field = "taxonomy.{$aggregation['taxonomy']}.slug_slash_name";
311                    }
312                    $aggregations[ $label ] = array(
313                        'terms' => array(
314                            'field' => $field,
315                            'size'  => $size,
316                        ),
317                    );
318                    break;
319                case 'post_type':
320                    $aggregations[ $label ] = array(
321                        'terms' => array(
322                            'field' => 'post_type',
323                            'size'  => $size,
324                        ),
325                    );
326                    break;
327                case 'author':
328                    $aggregations[ $label ] = array(
329                        'terms' => array(
330                            'field' => 'author_login_slash_name',
331                            'size'  => $size,
332                        ),
333                    );
334                    break;
335                case 'date_histogram':
336                    // remove post_ prefix from field name, e.g. replace post_date_gmt with date_gmt
337                    $aggregations[ $label ] = array(
338                        'date_histogram' => array(
339                            'field'             => str_replace( 'post_', '', $aggregation['field'] ?? '' ),
340                            'calendar_interval' => $aggregation['interval'],
341                            'min_doc_count'     => (int) ( $args['min_doc_count'] ?? 1 ),
342                        ),
343                    );
344                    break;
345                case 'product_attribute':
346                    if ( ! empty( $aggregation['attribute'] ) ) {
347                        $field                  = "taxonomy.{$aggregation['attribute']}.slug_slash_name";
348                        $aggregations[ $label ] = array(
349                            'terms' => array(
350                                'field' => $field,
351                                'size'  => $size,
352                            ),
353                        );
354                    }
355                    break;
356            }
357        }
358
359        $highlight_fields = Helper::DEFAULT_INSTANT_SEARCH_HIGHLIGHT_FIELDS;
360
361        $fields = array(
362            'blog_id',
363            'post_id',
364            'title',
365            'content',
366            'comments',
367        );
368
369        return array(
370            'blog_id'          => $this->jetpack_blog_id,
371            'size'             => (int) absint( $args['posts_per_page'] ),
372            'from'             => (int) min( $from, Helper::get_max_offset() ),
373            'fields'           => $fields,
374            'highlight_fields' => $highlight_fields,
375            'query'            => $args['query'] ?? '',
376            'sort'             => $sort,
377            'aggregations'     => empty( $aggregations ) ? null : $aggregations,
378            'langs'            => $this->get_langs(),
379            'filter'           => array(
380                'bool' => array(
381                    'must' => $this->build_es_filters( $args ),
382                ),
383            ),
384            'highlight'        => array(
385                'fields' => $highlight_fields,
386            ),
387        );
388    }
389
390    /**
391     * Trigger the jetpack_search_es_query_args filter for compatibility with Classic Search.
392     *
393     * The arguments can only be simulated, so this is not a 1:1 replacement.
394     * We support only some modifications, since not all of them are supported by Instant API.
395     * The goal is to support all common ones.
396     *
397     * @param array     $api_query_args Array of API query arguments.
398     * @param \WP_Query $query The original WP_Query object.
399     *
400     * @return array
401     */
402    private function trigger_es_query_args_filter( array $api_query_args, \WP_Query $query ): array {
403        $es_query_args = array(
404            'blog_id'      => $api_query_args['blog_id'] ?? 1,
405            'size'         => $api_query_args['size'] ?? 10,
406            'from'         => $api_query_args['from'] ?? 0,
407            'sort'         => array(
408                array( '_score' => array( 'order' => 'desc' ) ),
409            ),
410            'filter'       => $api_query_args['filter'] ?? array(),
411            'query'        => array(
412                'function_score' => array(
413                    'query'      => array(
414                        'bool' => array(
415                            'must' => array(
416                                array(
417                                    'multi_match' => array(
418                                        'fields'   => array( 'title.en' ),
419                                        'query'    => $api_query_args['query'] ?? '',
420                                        'operator' => 'and',
421                                    ),
422                                ),
423                            ),
424                        ),
425                    ),
426                    'functions'  => array( array( 'gauss' => array( 'date_gmt' => array( 'origin' => '2025-05-13' ) ) ) ),
427                    'max_boost'  => 2.0,
428                    'score_mode' => 'multiply',
429                    'boost_mode' => 'multiply',
430                ),
431            ),
432            'aggregations' => $api_query_args['aggregations'] ?? array(),
433            'fields'       => $api_query_args['fields'] ?? array(),
434        );
435
436        $es_query_args = apply_filters( 'jetpack_search_es_query_args', $es_query_args, $query );
437
438        if ( ! empty( $es_query_args['aggregations'] ) && is_array( $es_query_args['aggregations'] ) ) {
439            $api_query_args['aggregations'] = $es_query_args['aggregations'];
440        }
441        $api_query_args['filter'] = $es_query_args['filter'] ?? $api_query_args['filter'];
442        $api_query_args['size']   = $es_query_args['size'] ?? $api_query_args['size'];
443        $api_query_args['from']   = $es_query_args['from'] ?? $api_query_args['from'];
444        if ( isset( $es_query_args['query']['bool']['must_not'] ) ) {
445            $api_query_args['filter'] = array(
446                'bool' => array(
447                    'must_not' => $es_query_args['query']['bool']['must_not'],
448                    'filter'   => array(
449                        $api_query_args['filter'],
450                    ),
451                ),
452            );
453        }
454        if ( isset( $es_query_args['query']['bool']['filter'] ) && is_array( $es_query_args['query']['bool']['filter'] ) ) {
455            $new_filter = array(
456                'bool' => array(
457                    'filter' => $es_query_args['query']['bool']['filter'],
458                ),
459            );
460            if ( ! empty( $api_query_args['filter'] ) ) {
461                $new_filter['bool']['filter'][] = $api_query_args['filter'];
462            }
463            $api_query_args['filter'] = $new_filter;
464        }
465
466        return $api_query_args;
467    }
468
469    /**
470     * Trigger jetpack_instant_search_options for compatibility with Instant Search.
471     *
472     * @param array $api_query_args Array of API query arguments.
473     *
474     * @return array
475     */
476    private function trigger_instant_search_query_args_filter( array $api_query_args ): array {
477        return Helper::apply_instant_search_query_options_to_api_args( $api_query_args );
478    }
479
480    /**
481     * Return array of languages to search on after executing the dedicated filter.
482     *
483     * @return array
484     */
485    private function get_langs(): array {
486        /**
487         * Filter the languages used by Jetpack Search's Query Parser.
488         *
489         * @module search
490         *
491         * @since  7.9.0
492         *
493         * @param array $languages The array of languages. Default is value of get_locale().
494         */
495        return (array) apply_filters( 'jetpack_search_query_languages', array( get_locale() ) );
496    }
497
498    /**
499     * Converts WP_Query style search args to ES filters.
500     *
501     * @param array $args WP_Query style search arguments.
502     *
503     * @return array ES filters.
504     */
505    private function build_es_filters( array $args ): array {
506        $filters = array();
507
508        if ( ! empty( $args['author'] ) ) {
509            // ES stores usernames, not IDs, so transform.
510            foreach ( (array) $args['author'] as $author ) {
511                $user = get_user_by( 'id', $author );
512
513                if ( $user && ! empty( $user->user_login ) ) {
514                    $args['author_name'][] = $user->user_login;
515                }
516            }
517        }
518        if ( ! empty( $args['author_name'] ) ) {
519            $filters[] = array( 'terms' => array( 'author_login' => (array) $args['author_name'] ) );
520        }
521        if ( ! empty( $args['post_type'] ) ) {
522            $filters[] = array( 'terms' => array( 'post_type' => (array) $args['post_type'] ) );
523        }
524
525        if ( ! empty( $args['date_range'] ) && isset( $args['date_range']['field'] ) ) {
526            $field = $args['date_range']['field'];
527            unset( $args['date_range']['field'] );
528            $filters[] = array( 'range' => array( $field => $args['date_range'] ) );
529        }
530
531        if ( ! empty( $args['terms'] ) && is_array( $args['terms'] ) ) {
532            foreach ( $args['terms'] as $tax => $terms ) {
533                $terms = (array) $terms;
534
535                if ( count( $terms ) && mb_strlen( $tax ) ) {
536                    switch ( $tax ) {
537                        case 'post_tag':
538                            $tax_fld = 'tag.slug';
539                            break;
540                        case 'category':
541                            $tax_fld = 'category.slug';
542                            break;
543                        default:
544                            $tax_fld = 'taxonomy.' . $tax . '.slug';
545                            break;
546                    }
547
548                    foreach ( $terms as $term ) {
549                        $filters[] = array( 'term' => array( $tax_fld => $term ) );
550                    }
551                }
552            }
553        }
554
555        return $filters;
556    }
557
558    /**
559     * Executes v1.3 search API request.
560     *
561     * @param array $es_args Array of Search API v1.3 style request arguments.
562     *
563     * @return array|\WP_Error API response body array or error.
564     */
565    protected function instant_api( array $es_args ) {
566        $instant_search                  = new Instant_Search();
567        $instant_search->jetpack_blog_id = $this->jetpack_blog_id;
568
569        return $instant_search->instant_api( $es_args );
570    }
571
572    /**
573     * Get the most recent API response.
574     *
575     * @param bool $raw Ignored.
576     *
577     * @return array|\WP_Error|null Search API response.
578     */
579    public function get_search_result(
580        $raw = false // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
581    ) {
582        return $this->search_result;
583    }
584
585    /**
586     * Process search results to extract post IDs and highlighted content.
587     */
588    private function process_search_results() {
589        $post_ids = array();
590
591        foreach ( $this->search_result['results'] as $result ) {
592            $post_id    = (int) ( $result['fields']['post_id'] ?? 0 );
593            $post_ids[] = $post_id;
594        }
595
596        $this->search_result_ids = $post_ids;
597        $this->highlighter       = new Inline_Search_Highlighter( $post_ids );
598
599        // Hand the entire results array over; Inline_Search_Highlighter
600        // will pull out `fields.post_id` and `highlight` for each one.
601        $this->highlighter->process_results( $this->search_result['results'] );
602
603        $this->highlighter->setup();
604    }
605
606    /**
607     * Create a WP_Query to fetch the posts for search results.
608     *
609     * @param \WP_Query $original_query The original WP_Query.
610     *
611     * @return \WP_Query The new query with posts matching the search results.
612     */
613    private function create_posts_query( \WP_Query $original_query ): \WP_Query {
614        $args = array(
615            'post__in'            => $this->search_result_ids,
616            'orderby'             => 'post__in',
617            'perm'                => 'readable',
618            'post_type'           => 'any',
619            'ignore_sticky_posts' => true,
620            'suppress_filters'    => true,
621            'posts_per_page'      => $original_query->get( 'posts_per_page' ),
622        );
623
624        return new \WP_Query( $args );
625    }
626}