Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.90% covered (warning)
55.90%
161 / 288
31.25% covered (danger)
31.25%
5 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Inline_Search
55.90% covered (warning)
55.90%
161 / 288
31.25% covered (danger)
31.25%
5 / 16
599.70
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
71.43% covered (warning)
71.43%
30 / 42
0.00% covered (danger)
0.00%
0 / 1
18.57
 search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convert_wp_query_to_api_args
45.36% covered (danger)
45.36%
44 / 97
0.00% covered (danger)
0.00%
0 / 1
70.85
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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        // Do the actual search query!
233        $this->search_result = $this->search( $api_query_args );
234
235        if ( is_wp_error( $this->search_result ) || ! is_array( $this->search_result ) || empty( $this->search_result['results'] ) || ! is_array( $this->search_result['results'] ) ) {
236            $this->found_posts = 0;
237
238            return;
239        }
240
241        // If we have aggregations, fix the ordering to match the input order (ES doesn't guarantee the return order).
242        if ( isset( $this->search_result['aggregations'] ) && ! empty( $this->search_result['aggregations'] ) ) {
243            $this->search_result['aggregations'] = $this->fix_aggregation_ordering( $this->search_result['aggregations'], $this->aggregations );
244        }
245
246        // Total number of results for paging purposes. Capped at $max_offset + $posts_per_page, as deep paging gets quite expensive.
247        $this->found_posts = min( $this->search_result['total'], $max_offset + $posts_per_page );
248    }
249
250    /**
251     * Run a search on the WordPress.com v1.3 public API.
252     *
253     * @param array $es_args Args conforming to the WP.com v1.3 search endpoint.
254     *
255     * @return array|\WP_Error The response from the public API converted to Classic Search format, or a WP_Error.
256     */
257    public function search( array $es_args ) {
258        return $this->instant_api( $es_args );
259    }
260
261    /**
262     * Converts WP_Query style args to v1.3 search API args.
263     *
264     * @param array $args Array of WP_Query style arguments.
265     *
266     * @return array Array of Search API v1.3 style request arguments.
267     */
268    public function convert_wp_query_to_api_args( array $args ) {
269        $from = 0;
270        if ( ! empty( $args['offset'] ) ) {
271            $from = absint( $args['offset'] );
272        } elseif ( ! empty( $args['paged'] ) ) {
273            $from = max( 0, ( absint( $args['paged'] ) - 1 ) * absint( $args['posts_per_page'] ) );
274        }
275
276        switch ( $args['orderby'] ?? 'relevance' ) {
277            case 'date':
278                $sort = ( strtolower( $args['order'] ?? '' ) === 'asc' ) ? 'date_asc' : 'date_desc';
279                break;
280            case 'relevance':
281            default:
282                $sort = 'score_recency';
283                break;
284        }
285        $aggregations = array();
286        foreach ( $args['aggregations'] ?? array() as $label => $aggregation ) {
287            if ( empty( $aggregation['type'] ) ) {
288                continue;
289            }
290            $size = min( (int) ( $aggregation['count'] ?? 10 ), $this->max_aggregations_count );
291            switch ( $aggregation['type'] ) {
292                case 'taxonomy':
293                    if ( $aggregation['taxonomy'] === 'post_tag' ) {
294                        $field = 'tag.slug_slash_name';
295                    } elseif ( $aggregation['taxonomy'] === 'category' ) {
296                        $field = 'category.slug_slash_name';
297                    } else {
298                        $field = "taxonomy.{$aggregation['taxonomy']}.slug_slash_name";
299                    }
300                    $aggregations[ $label ] = array(
301                        'terms' => array(
302                            'field' => $field,
303                            'size'  => $size,
304                        ),
305                    );
306                    break;
307                case 'post_type':
308                    $aggregations[ $label ] = array(
309                        'terms' => array(
310                            'field' => 'post_type',
311                            'size'  => $size,
312                        ),
313                    );
314                    break;
315                case 'author':
316                    $aggregations[ $label ] = array(
317                        'terms' => array(
318                            'field' => 'author_login_slash_name',
319                            'size'  => $size,
320                        ),
321                    );
322                    break;
323                case 'date_histogram':
324                    // remove post_ prefix from field name, e.g. replace post_date_gmt with date_gmt
325                    $aggregations[ $label ] = array(
326                        'date_histogram' => array(
327                            'field'             => str_replace( 'post_', '', $aggregation['field'] ?? '' ),
328                            'calendar_interval' => $aggregation['interval'],
329                            'min_doc_count'     => (int) ( $args['min_doc_count'] ?? 1 ),
330                        ),
331                    );
332                    break;
333                case 'product_attribute':
334                    if ( ! empty( $aggregation['attribute'] ) ) {
335                        $field                  = "taxonomy.{$aggregation['attribute']}.slug_slash_name";
336                        $aggregations[ $label ] = array(
337                            'terms' => array(
338                                'field' => $field,
339                                'size'  => $size,
340                            ),
341                        );
342                    }
343                    break;
344            }
345        }
346
347        $highlight_fields = array(
348            'title',
349            'content',
350            'comments',
351        );
352
353        $fields = array(
354            'blog_id',
355            'post_id',
356            'title',
357            'content',
358            'comments',
359        );
360
361        return array(
362            'blog_id'          => $this->jetpack_blog_id,
363            'size'             => (int) absint( $args['posts_per_page'] ),
364            'from'             => (int) min( $from, Helper::get_max_offset() ),
365            'fields'           => $fields,
366            'highlight_fields' => $highlight_fields,
367            'query'            => $args['query'] ?? '',
368            'sort'             => $sort,
369            'aggregations'     => empty( $aggregations ) ? null : $aggregations,
370            'langs'            => $this->get_langs(),
371            'filter'           => array(
372                'bool' => array(
373                    'must' => $this->build_es_filters( $args ),
374                ),
375            ),
376            'highlight'        => array(
377                'fields' => $highlight_fields,
378            ),
379        );
380    }
381
382    /**
383     * Trigger the jetpack_search_es_query_args filter for compatibility with Classic Search.
384     *
385     * The arguments can only be simulated, so this is not a 1:1 replacement.
386     * We support only some modifications, since not all of them are supported by Instant API.
387     * The goal is to support all common ones.
388     *
389     * @param array     $api_query_args Array of API query arguments.
390     * @param \WP_Query $query The original WP_Query object.
391     *
392     * @return array
393     */
394    private function trigger_es_query_args_filter( array $api_query_args, \WP_Query $query ): array {
395        $es_query_args = array(
396            'blog_id'      => $api_query_args['blog_id'] ?? 1,
397            'size'         => $api_query_args['size'] ?? 10,
398            'from'         => $api_query_args['from'] ?? 0,
399            'sort'         => array(
400                array( '_score' => array( 'order' => 'desc' ) ),
401            ),
402            'filter'       => $api_query_args['filter'] ?? array(),
403            'query'        => array(
404                'function_score' => array(
405                    'query'      => array(
406                        'bool' => array(
407                            'must' => array(
408                                array(
409                                    'multi_match' => array(
410                                        'fields'   => array( 'title.en' ),
411                                        'query'    => $api_query_args['query'] ?? '',
412                                        'operator' => 'and',
413                                    ),
414                                ),
415                            ),
416                        ),
417                    ),
418                    'functions'  => array( array( 'gauss' => array( 'date_gmt' => array( 'origin' => '2025-05-13' ) ) ) ),
419                    'max_boost'  => 2.0,
420                    'score_mode' => 'multiply',
421                    'boost_mode' => 'multiply',
422                ),
423            ),
424            'aggregations' => $api_query_args['aggregations'] ?? array(),
425            'fields'       => $api_query_args['fields'] ?? array(),
426        );
427
428        $es_query_args = apply_filters( 'jetpack_search_es_query_args', $es_query_args, $query );
429
430        if ( ! empty( $es_query_args['aggregations'] ) && is_array( $es_query_args['aggregations'] ) ) {
431            $api_query_args['aggregations'] = $es_query_args['aggregations'];
432        }
433        $api_query_args['filter'] = $es_query_args['filter'] ?? $api_query_args['filter'];
434        $api_query_args['size']   = $es_query_args['size'] ?? $api_query_args['size'];
435        $api_query_args['from']   = $es_query_args['from'] ?? $api_query_args['from'];
436        if ( isset( $es_query_args['query']['bool']['must_not'] ) ) {
437            $api_query_args['filter'] = array(
438                'bool' => array(
439                    'must_not' => $es_query_args['query']['bool']['must_not'],
440                    'filter'   => array(
441                        $api_query_args['filter'],
442                    ),
443                ),
444            );
445        }
446        if ( isset( $es_query_args['query']['bool']['filter'] ) && is_array( $es_query_args['query']['bool']['filter'] ) ) {
447            $new_filter = array(
448                'bool' => array(
449                    'filter' => $es_query_args['query']['bool']['filter'],
450                ),
451            );
452            if ( ! empty( $api_query_args['filter'] ) ) {
453                $new_filter['bool']['filter'][] = $api_query_args['filter'];
454            }
455            $api_query_args['filter'] = $new_filter;
456        }
457
458        return $api_query_args;
459    }
460
461    /**
462     * Trigger jetpack_instant_search_options for compatibility with Instant Search.
463     *
464     * @param array $api_query_args Array of API query arguments.
465     *
466     * @return array
467     */
468    private function trigger_instant_search_query_args_filter( array $api_query_args ): array {
469        // this will trigger jetpack_instant_search_options filter
470        $options = Helper::generate_initial_javascript_state();
471
472        if ( isset( $options['adminQueryFilter'] ) ) {
473            $api_query_args['filter'] = array(
474                'bool' => array(
475                    'filter' => $api_query_args['filter'],
476                    'must'   => $options['adminQueryFilter'],
477                ),
478            );
479        }
480
481        return $api_query_args;
482    }
483
484    /**
485     * Return array of languages to search on after executing the dedicated filter.
486     *
487     * @return array
488     */
489    private function get_langs(): array {
490        /**
491         * Filter the languages used by Jetpack Search's Query Parser.
492         *
493         * @module search
494         *
495         * @since  7.9.0
496         *
497         * @param array $languages The array of languages. Default is value of get_locale().
498         */
499        return (array) apply_filters( 'jetpack_search_query_languages', array( get_locale() ) );
500    }
501
502    /**
503     * Converts WP_Query style search args to ES filters.
504     *
505     * @param array $args WP_Query style search arguments.
506     *
507     * @return array ES filters.
508     */
509    private function build_es_filters( array $args ): array {
510        $filters = array();
511
512        if ( ! empty( $args['author'] ) ) {
513            // ES stores usernames, not IDs, so transform.
514            foreach ( (array) $args['author'] as $author ) {
515                $user = get_user_by( 'id', $author );
516
517                if ( $user && ! empty( $user->user_login ) ) {
518                    $args['author_name'][] = $user->user_login;
519                }
520            }
521        }
522        if ( ! empty( $args['author_name'] ) ) {
523            $filters[] = array( 'terms' => array( 'author_login' => (array) $args['author_name'] ) );
524        }
525        if ( ! empty( $args['post_type'] ) ) {
526            $filters[] = array( 'terms' => array( 'post_type' => (array) $args['post_type'] ) );
527        }
528
529        if ( ! empty( $args['date_range'] ) && isset( $args['date_range']['field'] ) ) {
530            $field = $args['date_range']['field'];
531            unset( $args['date_range']['field'] );
532            $filters[] = array( 'range' => array( $field => $args['date_range'] ) );
533        }
534
535        if ( ! empty( $args['terms'] ) && is_array( $args['terms'] ) ) {
536            foreach ( $args['terms'] as $tax => $terms ) {
537                $terms = (array) $terms;
538
539                if ( count( $terms ) && mb_strlen( $tax ) ) {
540                    switch ( $tax ) {
541                        case 'post_tag':
542                            $tax_fld = 'tag.slug';
543                            break;
544                        case 'category':
545                            $tax_fld = 'category.slug';
546                            break;
547                        default:
548                            $tax_fld = 'taxonomy.' . $tax . '.slug';
549                            break;
550                    }
551
552                    foreach ( $terms as $term ) {
553                        $filters[] = array( 'term' => array( $tax_fld => $term ) );
554                    }
555                }
556            }
557        }
558
559        return $filters;
560    }
561
562    /**
563     * Executes v1.3 search API request.
564     *
565     * @param array $es_args Array of Search API v1.3 style request arguments.
566     *
567     * @return array|\WP_Error API response body array or error.
568     */
569    protected function instant_api( array $es_args ) {
570        $instant_search                  = new Instant_Search();
571        $instant_search->jetpack_blog_id = $this->jetpack_blog_id;
572
573        return $instant_search->instant_api( $es_args );
574    }
575
576    /**
577     * Get the most recent API response.
578     *
579     * @param bool $raw Ignored.
580     *
581     * @return array|\WP_Error|null Search API response.
582     */
583    public function get_search_result(
584        $raw = false // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
585    ) {
586        return $this->search_result;
587    }
588
589    /**
590     * Process search results to extract post IDs and highlighted content.
591     */
592    private function process_search_results() {
593        $post_ids = array();
594
595        foreach ( $this->search_result['results'] as $result ) {
596            $post_id    = (int) ( $result['fields']['post_id'] ?? 0 );
597            $post_ids[] = $post_id;
598        }
599
600        $this->search_result_ids = $post_ids;
601        $this->highlighter       = new Inline_Search_Highlighter( $post_ids );
602
603        // Hand the entire results array over; Inline_Search_Highlighter
604        // will pull out `fields.post_id` and `highlight` for each one.
605        $this->highlighter->process_results( $this->search_result['results'] );
606
607        $this->highlighter->setup();
608    }
609
610    /**
611     * Create a WP_Query to fetch the posts for search results.
612     *
613     * @param \WP_Query $original_query The original WP_Query.
614     *
615     * @return \WP_Query The new query with posts matching the search results.
616     */
617    private function create_posts_query( \WP_Query $original_query ): \WP_Query {
618        $args = array(
619            'post__in'            => $this->search_result_ids,
620            'orderby'             => 'post__in',
621            'perm'                => 'readable',
622            'post_type'           => 'any',
623            'ignore_sticky_posts' => true,
624            'suppress_filters'    => true,
625            'posts_per_page'      => $original_query->get( 'posts_per_page' ),
626        );
627
628        return new \WP_Query( $args );
629    }
630}