PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-search / src / inline-search / class-inline-search.php

class-inline-search.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-search/src/inline-search/class-inline-search.php

627 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Inline Search: search without popup using v1.3 Instant Search API
4 *
5 * @package automattic/jetpack-search
6 */
7
8 namespace Automattic\Jetpack\Search;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit( 0 );
12 }
13
14 /**
15 * Inline Search class
16 */
17 class 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 }
627