PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3 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 All 508 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-a.7, at jetpack_vendor/automattic/jetpack-search/src/inline-search/class-inline-search.php

623 lines 18.7 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 /**
11 * Inline Search class
12 */
13 class 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 }
623