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