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

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

1,322 lines 41.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Helper class providing various static utility functions for use in Search.
4 *
5 * @package automattic/jetpack-search
6 */
7
8 namespace Automattic\Jetpack\Search;
9
10 use Automattic\Jetpack\Status;
11 use GP_Locales;
12 use Jetpack; // TODO: Remove this once migrated.
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit( 0 );
16 }
17
18 /**
19 * Various helper functions for reuse throughout the Jetpack Search code.
20 */
21 class Helper {
22
23 /**
24 * The search widget's base ID.
25 *
26 * @since 5.8.0
27 * @var string
28 */
29 const FILTER_WIDGET_BASE = 'jetpack-search-filters';
30
31 /**
32 * Create a URL for the current search that doesn't include the "paged" parameter.
33 *
34 * @since 5.8.0
35 *
36 * @return string The search URL.
37 */
38 public static function get_search_url() {
39 // WordPress search doesn't use nonces.
40 $query_args = stripslashes_deep( $_GET ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended
41
42 // Handle the case where a permastruct is being used, such as /search/{$query}.
43 if ( ! isset( $query_args['s'] ) ) {
44 $query_args['s'] = get_search_query();
45 }
46
47 if ( isset( $query_args['paged'] ) ) {
48 unset( $query_args['paged'] );
49 }
50
51 $query = http_build_query( $query_args );
52
53 return home_url( "?{$query}" );
54 }
55
56 /**
57 * Wraps add_query_arg() with the URL defaulting to the current search URL.
58 *
59 * @see add_query_arg()
60 *
61 * @since 5.8.0
62 *
63 * @param string|array $key Either a query variable key, or an associative array of query variables.
64 * @param string $value Optional. A query variable value.
65 * @param bool|string $url Optional. A URL to act upon. Defaults to the current search URL.
66 *
67 * @return string New URL query string (unescaped).
68 */
69 public static function add_query_arg( $key, $value = false, $url = false ) {
70 $url = empty( $url ) ? self::get_search_url() : $url;
71 if ( is_array( $key ) ) {
72 return add_query_arg( $key, $url );
73 }
74
75 return add_query_arg( $key, $value, $url );
76 }
77
78 /**
79 * Wraps remove_query_arg() with the URL defaulting to the current search URL.
80 *
81 * @see remove_query_arg()
82 *
83 * @since 5.8.0
84 *
85 * @param string|array $key Query key or keys to remove.
86 * @param bool|string $url Optional. A URL to act upon. Defaults to the current search URL.
87 *
88 * @return string New URL query string (unescaped).
89 */
90 public static function remove_query_arg( $key, $url = false ) {
91 $url = empty( $url ) ? self::get_search_url() : $url;
92
93 return remove_query_arg( $key, $url );
94 }
95
96 /**
97 * Returns the name of the search widget's option.
98 *
99 * @since 5.8.0
100 *
101 * @return string The search widget option name.
102 */
103 public static function get_widget_option_name() {
104 return sprintf( 'widget_%s', self::FILTER_WIDGET_BASE );
105 }
106
107 /**
108 * Returns the search widget instances from the widget's option.
109 *
110 * @since 5.8.0
111 *
112 * @return array The widget options.
113 */
114 public static function get_widgets_from_option() {
115 $widget_options = get_option( self::get_widget_option_name(), array() );
116
117 // We don't need this.
118 if ( ! empty( $widget_options ) && isset( $widget_options['_multiwidget'] ) ) {
119 unset( $widget_options['_multiwidget'] );
120 }
121
122 return $widget_options;
123 }
124
125 /**
126 * Returns the widget ID (widget base plus the numeric ID).
127 *
128 * @param int $number The widget's numeric ID.
129 *
130 * @return string The widget's numeric ID prefixed with the search widget base.
131 */
132 public static function build_widget_id( $number ) {
133 return sprintf( '%s-%d', self::FILTER_WIDGET_BASE, $number );
134 }
135
136 /**
137 * Wrapper for is_active_widget() with the other parameters automatically supplied.
138 *
139 * @see is_active_widget()
140 *
141 * @since 5.8.0
142 *
143 * @param int $widget_id Widget ID.
144 *
145 * @return bool Whether the widget is active or not.
146 */
147 public static function is_active_widget( $widget_id ) {
148 return (bool) is_active_widget( false, $widget_id, self::FILTER_WIDGET_BASE, true );
149 }
150
151 /**
152 * Returns an array of the filters from all active search widgets.
153 *
154 * @since 5.8.0
155 *
156 * @param array|null $allowed_widget_ids array of allowed widget IDs.
157 *
158 * @return array Active filters.
159 */
160 public static function get_filters_from_widgets( $allowed_widget_ids = null ) {
161 $filters = array();
162
163 $widget_options = self::get_widgets_from_option();
164 if ( empty( $widget_options ) ) {
165 return $filters;
166 }
167
168 foreach ( (array) $widget_options as $number => $settings ) {
169 $widget_id = self::build_widget_id( $number );
170 if ( ! self::is_active_widget( $widget_id ) || empty( $settings['filters'] ) ) {
171 continue;
172 }
173 if ( isset( $allowed_widget_ids ) && ! in_array( $widget_id, $allowed_widget_ids, true ) ) {
174 continue;
175 }
176
177 foreach ( (array) $settings['filters'] as $widget_filter ) {
178 $widget_filter['widget_id'] = $widget_id;
179
180 if ( empty( $widget_filter['name'] ) ) {
181 $widget_filter['name'] = self::generate_widget_filter_name( $widget_filter );
182 }
183
184 $type = $widget_filter['type'] ?? '';
185
186 // If this is a product_attribute filter with no specific attribute, expand it to all global attributes.
187 if ( 'product_attribute' === $type && empty( $widget_filter['attribute'] ) ) {
188 $filters = self::expand_product_attribute_filters( $widget_filter, $filters );
189 } else {
190 $key = sprintf( '%s_%d', $type, count( $filters ) );
191 $filters[ $key ] = $widget_filter;
192 }
193 }
194 }
195
196 return $filters;
197 }
198
199 /**
200 * Expands a product_attribute filter into individual filters for each attribute.
201 *
202 * @since 5.8.0
203 *
204 * @param array $widget_filter The filter configuration.
205 * @param array $filters The existing filters array.
206 * @return array The filters array with expanded product attribute filters.
207 */
208 private static function expand_product_attribute_filters( $widget_filter, $filters ) {
209 if ( ! function_exists( 'wc_get_attribute_taxonomies' ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) {
210 return $filters;
211 }
212
213 $product_attributes = wc_get_attribute_taxonomies();
214 $included_attributes = isset( $widget_filter['included_attributes'] ) ? (array) $widget_filter['included_attributes'] : array();
215
216 // If no attributes are explicitly included, show all attributes (backward compatibility).
217 // Also optimize by treating "all selected" the same as "none selected" to avoid O(n²) in_array() checks.
218 $show_all = empty( $included_attributes ) || count( $included_attributes ) === count( $product_attributes );
219
220 foreach ( $product_attributes as $attribute ) {
221 $attribute_name = wc_attribute_taxonomy_name( $attribute->attribute_name );
222
223 if ( ! $show_all && ! in_array( $attribute_name, $included_attributes, true ) ) {
224 continue;
225 }
226
227 $key = sprintf( 'product_attribute_%d', count( $filters ) );
228 $expanded_filter = $widget_filter;
229 $expanded_filter['attribute'] = $attribute_name;
230 $expanded_filter['name'] = $attribute->attribute_label;
231 unset( $expanded_filter['included_attributes'] );
232 $filters[ $key ] = $expanded_filter;
233 }
234
235 return $filters;
236 }
237
238 /**
239 * Get the localized default label for a date filter.
240 *
241 * @since 5.8.0
242 *
243 * @param string $type Date type, either year or month.
244 * @param bool $is_updated Whether the filter was updated or not (adds "Updated" to the end).
245 *
246 * @return string The filter label.
247 */
248 public static function get_date_filter_type_name( $type, $is_updated = false ) {
249 switch ( $type ) {
250 case 'year':
251 $string = ( $is_updated )
252 ? esc_html_x( 'Year Updated', 'label for filtering posts', 'jetpack-search-pkg' )
253 : esc_html_x( 'Year', 'label for filtering posts', 'jetpack-search-pkg' );
254 break;
255 case 'month':
256 default:
257 $string = ( $is_updated )
258 ? esc_html_x( 'Month Updated', 'label for filtering posts', 'jetpack-search-pkg' )
259 : esc_html_x( 'Month', 'label for filtering posts', 'jetpack-search-pkg' );
260 break;
261 }
262
263 return $string;
264 }
265
266 /**
267 * Creates a default name for a filter. Used when the filter label is blank.
268 *
269 * @since 5.8.0
270 *
271 * @param array $widget_filter The filter to generate the title for.
272 *
273 * @return string The suggested filter name.
274 */
275 public static function generate_widget_filter_name( $widget_filter ) {
276 $name = '';
277
278 if ( ! isset( $widget_filter['type'] ) ) {
279 return $name;
280 }
281
282 switch ( $widget_filter['type'] ) {
283 case 'post_type':
284 $name = _x( 'Post Types', 'label for filtering posts', 'jetpack-search-pkg' );
285 break;
286
287 case 'author':
288 $name = _x( 'Authors', 'label for filtering posts', 'jetpack-search-pkg' );
289 break;
290
291 case 'blog_id':
292 $name = _x( 'Blogs', 'label for filtering posts', 'jetpack-search-pkg' );
293 break;
294
295 case 'date_histogram':
296 $modified_fields = array(
297 'post_modified',
298 'post_modified_gmt',
299 );
300 switch ( $widget_filter['interval'] ) {
301 case 'year':
302 $name = self::get_date_filter_type_name(
303 'year',
304 in_array( $widget_filter['field'], $modified_fields, true )
305 );
306 break;
307 case 'month':
308 default:
309 $name = self::get_date_filter_type_name(
310 'month',
311 in_array( $widget_filter['field'], $modified_fields, true )
312 );
313 break;
314 }
315 break;
316
317 case 'taxonomy':
318 $tax = get_taxonomy( $widget_filter['taxonomy'] );
319 if ( ! $tax ) {
320 break;
321 }
322
323 if ( isset( $tax->label ) ) {
324 $name = $tax->label;
325 } elseif ( isset( $tax->labels ) && isset( $tax->labels->name ) ) {
326 $name = $tax->labels->name;
327 }
328 break;
329
330 case 'product_attribute':
331 $name = _x( 'Product Attributes', 'label for filtering posts', 'jetpack-search-pkg' );
332 break;
333
334 }
335
336 return $name;
337 }
338
339 /**
340 * Whether we should rerun a search in the customizer preview or not.
341 *
342 * @since 5.8.0
343 *
344 * @return bool
345 */
346 public static function should_rerun_search_in_customizer_preview() {
347 // Only update when in a customizer preview and data is being posted.
348 // Check for $_POST removes an extra update when the customizer loads.
349 //
350 // Note: We use $GLOBALS['wp_customize'] here instead of is_customize_preview() to support unit tests.
351 return isset( $GLOBALS['wp_customize'] ) && $GLOBALS['wp_customize']->is_preview() && ! empty( $_POST ); // phpcs:ignore
352 }
353
354 /**
355 * Since PHP's built-in array_diff() works by comparing the values that are in array 1 to the other arrays,
356 * if there are less values in array 1, it's possible to get an empty diff where one might be expected.
357 *
358 * @since 5.8.0
359 *
360 * @param array $array_1 The first array.
361 * @param array $array_2 The second array.
362 *
363 * @return array
364 */
365 public static function array_diff( $array_1, $array_2 ) {
366 // If the array counts are the same, then the order doesn't matter. If the count of
367 // $array_1 is higher than $array_2, that's also fine. If the count of $array_2 is higher,
368 // we need to swap the array order though.
369 if ( count( $array_1 ) !== count( $array_2 ) && count( $array_2 ) > count( $array_1 ) ) {
370 $temp = $array_1;
371 $array_1 = $array_2;
372 $array_2 = $temp;
373 }
374
375 // Disregard keys.
376 return array_values( array_diff( $array_1, $array_2 ) );
377 }
378
379 /**
380 * Given the widget instance, will return true when selected post types differ from searchable post types.
381 *
382 * @since 5.8.0
383 *
384 * @param array $post_types An array of post types.
385 *
386 * @return bool
387 */
388 public static function post_types_differ_searchable( $post_types ) {
389 if ( empty( $post_types ) ) {
390 return false;
391 }
392
393 $searchable_post_types = get_post_types( array( 'exclude_from_search' => false ) );
394 $diff_of_searchable = self::array_diff( $searchable_post_types, (array) $post_types );
395
396 return ! empty( $diff_of_searchable );
397 }
398
399 /**
400 * Given the array of post types, will return true when these differ from the current search query.
401 *
402 * @since 5.8.0
403 *
404 * @param array $post_types An array of post types.
405 *
406 * @return bool
407 */
408 public static function post_types_differ_query( $post_types ) {
409 if ( empty( $post_types ) ) {
410 return false;
411 }
412
413 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- WordPress search doesn't use nonces.
414 // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Sanitization happens at the end.
415 if ( empty( $_GET['post_type'] ) ) {
416 $post_types_from_query = array();
417 } elseif ( is_array( $_GET['post_type'] ) ) {
418 $post_types_from_query = $_GET['post_type'];
419 } else {
420 $post_types_from_query = explode( ',', $_GET['post_type'] );
421 }
422 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
423
424 $post_types_from_query = array_map( 'sanitize_key', $post_types_from_query );
425
426 $diff_query = self::array_diff( (array) $post_types, $post_types_from_query );
427
428 return ! empty( $diff_query );
429 }
430
431 /**
432 * Determine what Tracks value should be used when updating a widget.
433 *
434 * @since 5.8.0
435 *
436 * @param mixed $old_value The old option value.
437 * @param mixed $new_value The new option value.
438 *
439 * @return array|false False if the widget wasn't updated, otherwise an array of the Tracks action and widget properties.
440 */
441 public static function get_widget_tracks_value( $old_value, $new_value ) {
442 $old_value = (array) $old_value;
443 if ( isset( $old_value['_multiwidget'] ) ) {
444 unset( $old_value['_multiwidget'] );
445 }
446
447 $new_value = (array) $new_value;
448 if ( isset( $new_value['_multiwidget'] ) ) {
449 unset( $new_value['_multiwidget'] );
450 }
451
452 $old_keys = array_keys( $old_value );
453 $new_keys = array_keys( $new_value );
454
455 if ( count( $new_keys ) > count( $old_keys ) ) { // This is the case for a widget being added.
456 $diff = self::array_diff( $new_keys, $old_keys );
457 $action = 'widget_added';
458 $widget = empty( $diff ) || ! isset( $new_value[ $diff[0] ] )
459 ? false
460 : $new_value[ $diff[0] ];
461 } elseif ( count( $old_keys ) > count( $new_keys ) ) { // This is the case for a widget being deleted.
462 $diff = self::array_diff( $old_keys, $new_keys );
463 $action = 'widget_deleted';
464 $widget = empty( $diff ) || ! isset( $old_value[ $diff[0] ] )
465 ? false
466 : $old_value[ $diff[0] ];
467 } else {
468 $action = 'widget_updated';
469 $widget = false;
470
471 // This is a bit crazy. Since there can be multiple widgets stored in a single option,
472 // we need to diff the old and new values to figure out which widget was updated.
473 foreach ( $new_value as $key => $new_instance ) {
474 if ( ! isset( $old_value[ $key ] ) ) {
475 continue;
476 }
477 $old_instance = $old_value[ $key ];
478
479 // First, let's test the keys of each instance.
480 $diff = self::array_diff( array_keys( $new_instance ), array_keys( $old_instance ) );
481 if ( ! empty( $diff ) ) {
482 $widget = $new_instance;
483 break;
484 }
485
486 // Next, lets's loop over each value and compare it.
487 foreach ( $new_instance as $k => $v ) {
488 if ( is_scalar( $v ) && (string) $v !== (string) $old_instance[ $k ] ) {
489 $widget = $new_instance;
490 break;
491 }
492
493 if ( 'filters' === $k ) {
494 if ( ! is_countable( $new_instance['filters'] ) || ! is_countable( $old_instance['filters'] ) ) {
495 continue;
496 }
497
498 if ( count( $new_instance['filters'] ) !== count( $old_instance['filters'] ) ) {
499 $widget = $new_instance;
500 break;
501 }
502
503 foreach ( $v as $filter_key => $new_filter_value ) {
504 $diff = self::array_diff( $new_filter_value, $old_instance['filters'][ $filter_key ] );
505 if ( ! empty( $diff ) ) {
506 $widget = $new_instance;
507 break;
508 }
509 }
510 }
511 }
512 }
513 }
514
515 if ( empty( $widget ) ) {
516 return false;
517 }
518
519 return array(
520 'action' => $action,
521 'widget' => self::get_widget_properties_for_tracks( $widget ),
522 );
523 }
524
525 /**
526 * Creates the widget properties for sending to Tracks.
527 *
528 * @since 5.8.0
529 *
530 * @param array $widget The widget instance.
531 *
532 * @return array The widget properties.
533 */
534 public static function get_widget_properties_for_tracks( $widget ) {
535 $sanitized = array();
536
537 foreach ( (array) $widget as $key => $value ) {
538 if ( '_multiwidget' === $key ) {
539 continue;
540 }
541
542 if ( is_scalar( $value ) ) {
543 $key = str_replace( '-', '_', sanitize_key( $key ) );
544 $key = "widget_{$key}";
545 $sanitized[ $key ] = $value;
546 }
547 }
548
549 $filters_properties = ! empty( $widget['filters'] )
550 ? self::get_filter_properties_for_tracks( $widget['filters'] )
551 : array();
552
553 return array_merge( $sanitized, $filters_properties );
554 }
555
556 /**
557 * Creates the filter properties for sending to Tracks.
558 *
559 * @since 5.8.0
560 *
561 * @param array $filters An array of filters.
562 *
563 * @return array The filter properties.
564 */
565 public static function get_filter_properties_for_tracks( $filters ) {
566 if ( empty( $filters ) ) {
567 return $filters;
568 }
569
570 $filters_properties = array(
571 'widget_filter_count' => count( $filters ),
572 );
573
574 foreach ( $filters as $filter ) {
575 if ( empty( $filter['type'] ) ) {
576 continue;
577 }
578
579 $key = sprintf( 'widget_filter_type_%s', $filter['type'] );
580 if ( isset( $filters_properties[ $key ] ) ) {
581 ++$filters_properties[ $key ];
582 } else {
583 $filters_properties[ $key ] = 1;
584 }
585 }
586
587 return $filters_properties;
588 }
589
590 /**
591 * Gets the active post types given a set of filters.
592 *
593 * @since 5.8.0
594 *
595 * @param array $filters The active filters for the current query.
596 *
597 * @return array The active post types.
598 */
599 public static function get_active_post_types( $filters ) {
600 $active_post_types = array();
601
602 foreach ( $filters as $item ) {
603 if ( ( 'post_type' === $item['type'] ) && isset( $item['query_vars']['post_type'] ) ) {
604 $active_post_types[] = $item['query_vars']['post_type'];
605 }
606 }
607
608 return $active_post_types;
609 }
610
611 /**
612 * Sets active to false on all post type buckets.
613 *
614 * @since 5.8.0
615 *
616 * @param array $filters The available filters for the current query.
617 *
618 * @return array The filters for the current query with modified active field.
619 */
620 public static function remove_active_from_post_type_buckets( $filters ) {
621 $modified = $filters;
622 foreach ( $filters as $key => $filter ) {
623 if ( 'post_type' === $filter['type'] && ! empty( $filter['buckets'] ) ) {
624 foreach ( $filter['buckets'] as $k => $bucket ) {
625 $bucket['active'] = false;
626 $modified[ $key ]['buckets'][ $k ] = $bucket;
627 }
628 }
629 }
630
631 return $modified;
632 }
633
634 /**
635 * Given a url and an array of post types, will ensure that the post types are properly applied to the URL as args.
636 *
637 * @since 5.8.0
638 *
639 * @param string $url The URL to add post types to.
640 * @param array $post_types An array of post types that should be added to the URL.
641 *
642 * @return string The URL with added post types.
643 */
644 public static function add_post_types_to_url( $url, $post_types ) {
645 $url = self::remove_query_arg( 'post_type', $url );
646 if ( empty( $post_types ) ) {
647 return $url;
648 }
649
650 $url = self::add_query_arg(
651 'post_type',
652 implode( ',', $post_types ),
653 $url
654 );
655
656 return $url;
657 }
658
659 /**
660 * Since we provide support for the widget restricting post types by adding the selected post types as
661 * active filters, if removing a post type filter would result in there no longer be post_type args in the URL,
662 * we need to be sure to add them back.
663 *
664 * @since 5.8.0
665 *
666 * @param array $filters An array of possible filters for the current query.
667 * @param array $post_types The post types to ensure are on the link.
668 *
669 * @return array The updated array of filters with post typed added to the remove URLs.
670 */
671 public static function ensure_post_types_on_remove_url( $filters, $post_types ) {
672 $modified = $filters;
673
674 foreach ( (array) $filters as $filter_key => $filter ) {
675 if ( 'post_type' !== $filter['type'] || empty( $filter['buckets'] ) ) {
676 $modified[ $filter_key ] = $filter;
677 continue;
678 }
679
680 foreach ( (array) $filter['buckets'] as $bucket_key => $bucket ) {
681 if ( empty( $bucket['remove_url'] ) ) {
682 continue;
683 }
684
685 $parsed = wp_parse_url( $bucket['remove_url'] );
686 if ( ! $parsed ) {
687 continue;
688 }
689
690 $query = array();
691 if ( ! empty( $parsed['query'] ) ) {
692 wp_parse_str( $parsed['query'], $query );
693 }
694
695 if ( empty( $query['post_type'] ) ) {
696 $modified[ $filter_key ]['buckets'][ $bucket_key ]['remove_url'] = self::add_post_types_to_url(
697 $bucket['remove_url'],
698 $post_types
699 );
700 }
701 }
702 }
703
704 return $modified;
705 }
706
707 /**
708 * Wraps a WordPress filter called "jetpack_search_disable_widget_filters" that allows
709 * developers to disable filters supplied by the search widget. Useful if filters are
710 * being defined at the code level.
711 *
712 * @since 5.8.0
713 *
714 * @return bool
715 */
716 public static function are_filters_by_widget_disabled() {
717 /**
718 * Allows developers to disable filters being set by widget, in favor of manually
719 * setting filters via `Classic_Search::set_filters()`.
720 *
721 * @module search
722 *
723 * @since 5.7.0
724 *
725 * @param bool false
726 */
727 return apply_filters( 'jetpack_search_disable_widget_filters', false );
728 }
729
730 /**
731 * Returns the maximum posts per page for a search query.
732 *
733 * @since 5.8.0
734 *
735 * @return int
736 */
737 public static function get_max_posts_per_page() {
738 return Options::site_has_vip_index() ? 1000 : 100;
739 }
740
741 /**
742 * Resolves the effective results-per-page value for the Search 3.0 blocks.
743 *
744 * `$override` is the author-set `resultsPerPage` block attribute; `0` (or
745 * any non-positive value) means "use the site's `posts_per_page` Reading
746 * setting." A non-positive Reading setting (e.g. `-1`, "show all") falls
747 * back to 10 rather than clamping down to a single result. Either way the
748 * result is capped at `get_max_posts_per_page()`.
749 *
750 * @param int $override Author override, or 0 to use the site default.
751 * @return int Clamped, always-positive results-per-page value.
752 */
753 public static function resolve_results_per_page( $override = 0 ) {
754 $value = $override > 0 ? (int) $override : (int) get_option( 'posts_per_page' );
755 if ( $value <= 0 ) {
756 $value = 10;
757 }
758 return min( $value, self::get_max_posts_per_page() );
759 }
760
761 /**
762 * Returns the maximum offset for a search query.
763 *
764 * @since 5.8.0
765 *
766 * @return int
767 */
768 public static function get_max_offset() {
769 return Options::site_has_vip_index() ? 9000 : 1000;
770 }
771
772 /**
773 * Returns the maximum offset for a search query.
774 *
775 * @since 8.4.0
776 * @param string $locale A potentially valid locale string.
777 *
778 * @return bool
779 */
780 public static function is_valid_locale( $locale ) {
781 if ( ! class_exists( 'GP_Locales' ) ) {
782 // Assume locale to be valid if we can't check with GlotPress.
783 return true;
784 }
785 return false !== GP_Locales::by_field( 'wp_locale', $locale );
786 }
787
788 /**
789 * Get the version number to use when loading the file. Allows us to bypass cache when developing.
790 *
791 * @since 8.6.0
792 * @param string $file Path of the file we are looking for.
793 * @return string $script_version Version number.
794 */
795 public static function get_asset_version( $file ) {
796 return Package::is_development_version() && file_exists( Package::get_installed_path() . $file )
797 ? filemtime( Package::get_installed_path() . $file )
798 : Package::VERSION;
799 }
800
801 /**
802 * Generates a customizer settings ID for a given post type.
803 *
804 * @since 8.8.0
805 * @param object $post_type Post type object returned from get_post_types.
806 * @return string $customizer_id Customizer setting ID.
807 */
808 public static function generate_post_type_customizer_id( $post_type ) {
809 return Options::OPTION_PREFIX . 'disable_post_type_' . $post_type->name;
810 }
811
812 /**
813 * Generates an array of post types associated with their customizer IDs.
814 *
815 * @since 8.8.0
816 * @return array $ids Post type => post type customizer ID object.
817 */
818 public static function generate_post_type_customizer_ids() {
819 return array_map(
820 array( 'self', 'generate_post_type_customizer_id' ),
821 get_post_types( array( 'exclude_from_search' => false ), 'objects' )
822 );
823 }
824
825 /**
826 * Sanitizes a checkbox value for writing to the database.
827 *
828 * @since 8.9.0
829 *
830 * @param mixed $value from the customizer form.
831 * @return string either '0' or '1'.
832 */
833 public static function sanitize_checkbox_value( $value ) {
834 return true === $value ? '1' : '0';
835 }
836
837 /**
838 * Sanitizes a checkbox value for rendering the Customizer.
839 *
840 * @since 8.9.0
841 *
842 * @param mixed $value from the database.
843 * @return boolean
844 */
845 public static function sanitize_checkbox_value_for_js( $value ) {
846 return '1' === $value;
847 }
848
849 /**
850 * Passes all options to the JS app.
851 */
852 public static function generate_initial_javascript_state() {
853 $widget_options = self::get_widgets_from_option();
854 if ( is_array( $widget_options ) ) {
855 $widget_options = end( $widget_options );
856 }
857
858 $overlay_widget_ids = is_active_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ) ?
859 wp_get_sidebars_widgets()[ Instant_Search::INSTANT_SEARCH_SIDEBAR ] : array();
860 $filters = self::get_filters_from_widgets();
861 $widgets = array();
862 $widgets_outside_overlay = array();
863 foreach ( $filters as $key => &$filter ) {
864 $filter['filter_id'] = $key;
865
866 if ( in_array( $filter['widget_id'], $overlay_widget_ids, true ) ) {
867 if ( ! isset( $widgets[ $filter['widget_id'] ] ) ) {
868 $widgets[ $filter['widget_id'] ]['filters'] = array();
869 $widgets[ $filter['widget_id'] ]['widget_id'] = $filter['widget_id'];
870 }
871 $widgets[ $filter['widget_id'] ]['filters'][] = $filter;
872 } else {
873 if ( ! isset( $widgets_outside_overlay[ $filter['widget_id'] ] ) ) {
874 $widgets_outside_overlay[ $filter['widget_id'] ]['filters'] = array();
875 $widgets_outside_overlay[ $filter['widget_id'] ]['widget_id'] = $filter['widget_id'];
876 }
877 $widgets_outside_overlay[ $filter['widget_id'] ]['filters'][] = $filter;
878 }
879 }
880 unset( $filter );
881
882 $has_non_search_widgets = false;
883 foreach ( $overlay_widget_ids as $overlay_widget_id ) {
884 if ( strpos( $overlay_widget_id, self::FILTER_WIDGET_BASE ) === false ) {
885 $has_non_search_widgets = true;
886 break;
887 }
888 }
889
890 $post_type_objs = get_post_types( array( 'exclude_from_search' => false ), 'objects' );
891 $post_type_labels = array();
892 foreach ( $post_type_objs as $key => $obj ) {
893 $post_type_labels[ $key ] = array(
894 'singular_name' => $obj->labels->singular_name,
895 'name' => $obj->labels->name,
896 );
897 }
898
899 $prefix = Options::OPTION_PREFIX;
900 $posts_per_page = (int) get_option( 'posts_per_page' );
901 if ( ( $posts_per_page > 20 ) || ( $posts_per_page <= 0 ) ) {
902 $posts_per_page = 20;
903 }
904
905 $excluded_post_types = get_option( $prefix . 'excluded_post_types' ) ? explode( ',', get_option( $prefix . 'excluded_post_types', '' ) ) : array();
906 $post_types = array_values(
907 get_post_types(
908 array(
909 'exclude_from_search' => false,
910 'public' => true,
911 )
912 )
913 );
914 $unexcluded_post_types = array_diff( $post_types, $excluded_post_types );
915 // NOTE: If all post types are being excluded, ignore the option value.
916 if ( array() === $unexcluded_post_types ) {
917 $excluded_post_types = array();
918 }
919
920 $is_wpcom = static::is_wpcom();
921 $is_private_site = ( new Status() )->is_private_site();
922 $is_jetpack_photon_enabled = method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'photon' );
923
924 $options = array(
925 'overlayOptions' => array(
926 'colorTheme' => get_option( $prefix . 'color_theme', 'light' ),
927 'enableInfScroll' => get_option( $prefix . 'inf_scroll', '1' ) === '1',
928 'enableFilteringOpensOverlay' => get_option( $prefix . 'filtering_opens_overlay', '1' ) === '1',
929 'enablePostDate' => get_option( $prefix . 'show_post_date', '1' ) === '1',
930 'enableProductPrice' => get_option( $prefix . 'show_product_price', '1' ) === '1',
931 'enableSort' => get_option( $prefix . 'enable_sort', '1' ) === '1',
932 'highlightColor' => get_option( $prefix . 'highlight_color', '#FFC' ),
933 'overlayTrigger' => get_option( $prefix . 'overlay_trigger', Options::DEFAULT_OVERLAY_TRIGGER ),
934 'resultFormat' => get_option( $prefix . 'result_format', Options::RESULT_FORMAT_MINIMAL ),
935 'showPoweredBy' => ( new Plan() )->is_free_plan() || ( get_option( $prefix . 'show_powered_by', '1' ) === '1' ),
936
937 // These options require kicking off a new search.
938 'defaultSort' => get_option( $prefix . 'default_sort', 'relevance' ),
939 'excludedPostTypes' => $excluded_post_types,
940
941 // Fallback image options
942 'fallbackImageUrl' => get_option( $prefix . 'fallback_image_url', '' ),
943 'enableFallbackImage' => get_option( $prefix . 'enable_fallback_image', '0' ) === '1',
944 ),
945
946 // core config.
947 'homeUrl' => home_url(),
948 'locale' => str_replace( '_', '-', self::is_valid_locale( get_locale() ) ? get_locale() : 'en_US' ),
949 'postsPerPage' => $posts_per_page,
950 'siteId' => self::get_wpcom_site_id(),
951 'searchSuggestionsEnabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ),
952 'postTypes' => $post_type_labels,
953 'webpackPublicPath' => plugins_url( '/build/instant-search/', __DIR__ ),
954 'isPhotonEnabled' => ( $is_wpcom || $is_jetpack_photon_enabled ) && ! $is_private_site,
955 'isFreePlan' => ( new Plan() )->is_free_plan(),
956
957 // config values related to private site support.
958 'apiRoot' => esc_url_raw( rest_url() ),
959 'apiNonce' => wp_create_nonce( 'wp_rest' ),
960 'isPrivateSite' => $is_private_site,
961 'isWpcom' => $is_wpcom,
962
963 // widget info.
964 'hasOverlayWidgets' => is_countable( $overlay_widget_ids ) && count( $overlay_widget_ids ) > 0,
965 'widgets' => array_values( $widgets ),
966 'widgetsOutsideOverlay' => array_values( $widgets_outside_overlay ),
967 'hasNonSearchWidgets' => $has_non_search_widgets,
968 /**
969 * Whether to prevent tracking cookie reset. More information `pbmxuV-39H-p2`.
970 *
971 * @module search
972 *
973 * @since 0.41.0
974 *
975 * @param bool Prevent cookie reset for automattic sites as default value.
976 */
977 'preventTrackingCookiesReset' => apply_filters( 'jetpack_instant_search_prevent_tracking_cookies_reset', function_exists( 'is_automattic' ) && is_automattic() ),
978
979 /**
980 * Whether to disable Tracks and TrainTracks analytics.
981 *
982 * This can be enabled via URL parameter (?disable_tracking=1) for testing,
983 * or via the filter for permanent configuration. Useful for debugging issues
984 * where tracking may interfere with search functionality, such as Safari's
985 * advanced tracking protection.
986 *
987 * @module search
988 *
989 * @since 0.56.0
990 *
991 * @param bool $disable_tracking Whether to disable tracking. Default false.
992 */
993 'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ),
994 'aiAnswersEnabled' => AI_Answers::is_enabled(),
995 'aiMasterEnabled' => AI_Answers::is_master_enabled(),
996 );
997
998 /**
999 * Customize Instant Search Options.
1000 *
1001 * @module search
1002 *
1003 * @since 7.7.0
1004 *
1005 * @param array $options Array of parameters used in Instant Search queries.
1006 */
1007 return apply_filters( 'jetpack_instant_search_options', $options );
1008 }
1009
1010 /**
1011 * Default highlight fields when `highlightFields` is unset in
1012 * `jetpack_instant_search_options`. Matches instant search's JS default.
1013 *
1014 * @since 7.4.0
1015 */
1016 const DEFAULT_INSTANT_SEARCH_HIGHLIGHT_FIELDS = array( 'title', 'content', 'comments' );
1017
1018 /**
1019 * Fields added to API `fields` when searching additional blogs.
1020 *
1021 * @since 7.4.0
1022 */
1023 const MULTISITE_SEARCH_FIELD_NAMES = array( 'author', 'blog_name', 'blog_icon_url', 'blog_id' );
1024
1025 /**
1026 * Read Instant Search query-customization options from a filtered options array.
1027 *
1028 * @since 7.4.0
1029 *
1030 * @param array $options Raw `jetpack_instant_search_options` value.
1031 * @return array Query options with keys:
1032 * `highlightPhraseOnly`, `highlightFilterStopwords`, `highlightFields`,
1033 * `additionalBlogIds`, `adminQueryFilter`, and `customResults`.
1034 */
1035 public static function parse_instant_search_query_options( array $options ): array {
1036 $highlight_phrase_only = ! empty( $options['highlightPhraseOnly'] );
1037
1038 // The v1.3 API rejects requests combining `highlight_filter_stopwords` with
1039 // `highlight_phrase_only` (phrase-only wins server-side) — drop the stopword
1040 // list so requests stay valid when a filter sets both.
1041 $stopwords = array();
1042 if ( ! $highlight_phrase_only && ! empty( $options['highlightFilterStopwords'] ) && is_array( $options['highlightFilterStopwords'] ) ) {
1043 $stopwords = array_values(
1044 array_filter(
1045 $options['highlightFilterStopwords'],
1046 static function ( $word ) {
1047 return is_string( $word ) && '' !== $word;
1048 }
1049 )
1050 );
1051 }
1052
1053 $highlight_fields = null;
1054 if ( ! empty( $options['highlightFields'] ) && is_array( $options['highlightFields'] ) ) {
1055 $highlight_fields = array_values(
1056 array_filter(
1057 $options['highlightFields'],
1058 static function ( $field ) {
1059 return is_string( $field ) && '' !== $field;
1060 }
1061 )
1062 );
1063 if ( array() === $highlight_fields ) {
1064 $highlight_fields = null;
1065 }
1066 }
1067
1068 $additional_blog_ids = array();
1069 if ( ! empty( $options['additionalBlogIds'] ) && is_array( $options['additionalBlogIds'] ) ) {
1070 $additional_blog_ids = array_values( $options['additionalBlogIds'] );
1071 }
1072
1073 $admin_query_filter = null;
1074 if ( ! empty( $options['adminQueryFilter'] ) && is_array( $options['adminQueryFilter'] ) ) {
1075 $admin_query_filter = $options['adminQueryFilter'];
1076 }
1077
1078 $custom_results = array();
1079 if ( ! empty( $options['customResults'] ) && is_array( $options['customResults'] ) ) {
1080 foreach ( $options['customResults'] as $rule ) {
1081 if ( ! is_array( $rule ) ) {
1082 continue;
1083 }
1084 $pattern = isset( $rule['pattern'] ) && is_string( $rule['pattern'] ) ? $rule['pattern'] : '';
1085 $ids = isset( $rule['ids'] ) && is_array( $rule['ids'] ) ? array_values( $rule['ids'] ) : array();
1086 if ( '' === $pattern || array() === $ids ) {
1087 continue;
1088 }
1089 $custom_results[] = array(
1090 'pattern' => $pattern,
1091 'ids' => $ids,
1092 );
1093 }
1094 }
1095
1096 return array(
1097 'highlightPhraseOnly' => $highlight_phrase_only,
1098 'highlightFilterStopwords' => $stopwords,
1099 'highlightFields' => $highlight_fields,
1100 'additionalBlogIds' => $additional_blog_ids,
1101 'adminQueryFilter' => $admin_query_filter,
1102 'customResults' => $custom_results,
1103 );
1104 }
1105
1106 /**
1107 * Read Instant Search query-customization options via `jetpack_instant_search_options`.
1108 *
1109 * Passing an empty array into the filter matches `Filter_Static::read_raw_entries()`
1110 * — callbacks only add keys.
1111 *
1112 * @since 7.4.0
1113 *
1114 * @return array Query options with keys:
1115 * `highlightPhraseOnly`, `highlightFilterStopwords`, `highlightFields`,
1116 * `additionalBlogIds`, `adminQueryFilter`, and `customResults`.
1117 */
1118 public static function get_instant_search_query_options(): array {
1119 $options = apply_filters( 'jetpack_instant_search_options', array() );
1120 if ( ! is_array( $options ) ) {
1121 $options = array();
1122 }
1123
1124 return self::parse_instant_search_query_options( $options );
1125 }
1126
1127 /**
1128 * Merge Instant Search query-customization options into v1.3 API args.
1129 *
1130 * Shared by Inline Search (Theme) and any server-side v1.3 callers.
1131 *
1132 * @since 7.4.0
1133 *
1134 * @param array $api_query_args API query arguments.
1135 * @param array|null $options Filtered `jetpack_instant_search_options` value. Defaults to `generate_initial_javascript_state()`.
1136 * @return array
1137 */
1138 public static function apply_instant_search_query_options_to_api_args( array $api_query_args, ?array $options = null ): array {
1139 if ( null === $options ) {
1140 $options = self::generate_initial_javascript_state();
1141 }
1142 if ( ! is_array( $options ) ) {
1143 return $api_query_args;
1144 }
1145
1146 $query_options = self::parse_instant_search_query_options( $options );
1147
1148 if ( null !== $query_options['adminQueryFilter'] ) {
1149 $api_query_args['filter'] = array(
1150 'bool' => array(
1151 'filter' => $api_query_args['filter'] ?? array(),
1152 'must' => $query_options['adminQueryFilter'],
1153 ),
1154 );
1155 }
1156
1157 if ( $query_options['highlightPhraseOnly'] ) {
1158 $api_query_args['highlight_phrase_only'] = true;
1159 }
1160
1161 if ( ! empty( $query_options['highlightFilterStopwords'] ) ) {
1162 $api_query_args['highlight_filter_stopwords'] = $query_options['highlightFilterStopwords'];
1163 }
1164
1165 if ( null !== $query_options['highlightFields'] ) {
1166 $api_query_args['highlight_fields'] = $query_options['highlightFields'];
1167 $api_query_args['highlight'] = array(
1168 'fields' => $query_options['highlightFields'],
1169 );
1170 }
1171
1172 if ( ! empty( $query_options['additionalBlogIds'] ) ) {
1173 $api_query_args['additional_blog_ids'] = $query_options['additionalBlogIds'];
1174 $fields = isset( $api_query_args['fields'] ) && is_array( $api_query_args['fields'] )
1175 ? $api_query_args['fields']
1176 : array();
1177 $api_query_args['fields'] = array_values(
1178 array_unique( array_merge( $fields, self::MULTISITE_SEARCH_FIELD_NAMES ) )
1179 );
1180 }
1181
1182 $matched_custom_results = self::resolve_instant_search_custom_results(
1183 (string) ( $api_query_args['query'] ?? '' ),
1184 $query_options['customResults']
1185 );
1186 if ( null !== $matched_custom_results ) {
1187 $api_query_args['custom_results'] = $matched_custom_results;
1188 }
1189
1190 return $api_query_args;
1191 }
1192
1193 /**
1194 * Resolve `customResults` post IDs for a search query.
1195 *
1196 * Mirrors instant search's exact / `regex:` pattern matching.
1197 *
1198 * @since 7.4.0
1199 *
1200 * @param string $query Current search query.
1201 * @param array $custom_results Normalized custom-results rules.
1202 * @return array<int, int|string>|null
1203 */
1204 public static function resolve_instant_search_custom_results( string $query, array $custom_results ): ?array {
1205 if ( array() === $custom_results ) {
1206 return null;
1207 }
1208
1209 foreach ( $custom_results as $rule ) {
1210 $pattern = $rule['pattern'];
1211 $ids = $rule['ids'];
1212 if ( 0 === strpos( $pattern, 'regex:' ) ) {
1213 // Escape the delimiter so patterns containing `/` (e.g. `regex:docs/.*`) stay valid PCRE.
1214 $regex = '/^' . str_replace( '/', '\/', substr( $pattern, strlen( 'regex:' ) ) ) . '$/';
1215 if ( @preg_match( $regex, $query ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid user regex should be skipped, not fatal.
1216 return $ids;
1217 }
1218 } elseif ( $query === $pattern ) {
1219 return $ids;
1220 }
1221 }
1222
1223 return null;
1224 }
1225
1226 /**
1227 * Returns true if the site is a WordPress.com simple site, i.e. the code runs on WPCOM.
1228 */
1229 public static function is_wpcom() {
1230 return defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' );
1231 }
1232
1233 /**
1234 * Prints the Instant Search sidebar.
1235 */
1236 public static function print_instant_search_sidebar() {
1237 ?>
1238 <div class="jetpack-instant-search__widget-area" style="display: none">
1239 <?php if ( is_active_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ) ) { ?>
1240 <?php dynamic_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ); ?>
1241 <?php } ?>
1242 </div>
1243 <?php
1244 }
1245
1246 /**
1247 * Gets all of the active plugins via site options.
1248 * Forked from Jetpack::get_active_plugins from the Jetpack plugin.
1249 *
1250 * @return string[]
1251 */
1252 public static function get_active_plugins() {
1253 // active_plugins plugins as values.
1254 $active_plugins = (array) get_option( 'active_plugins', array() );
1255
1256 // active_sitewide_plugins stores plugins as keys.
1257 if ( is_multisite() ) {
1258 $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) );
1259 if ( $network_plugins ) {
1260 $active_plugins = array_merge( $active_plugins, $network_plugins );
1261 }
1262 }
1263
1264 sort( $active_plugins );
1265 return array_unique( $active_plugins );
1266 }
1267
1268 /**
1269 * Get the current site's WordPress.com ID.
1270 *
1271 * @return int Blog ID.
1272 */
1273 public static function get_wpcom_site_id() {
1274 // Returns local blog ID for a multi-site network.
1275 if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) {
1276 return \get_current_blog_id();
1277 }
1278
1279 // Returns cache site ID.
1280 return \Jetpack_Options::get_option( 'id' );
1281 }
1282
1283 /**
1284 * Returns true if the free_plan is set to not empty in URL, which is used for testing purpose.
1285 */
1286 public static function is_forced_free_plan() {
1287 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1288 return isset( $_GET['free_plan'] ) && $_GET['free_plan'];
1289 }
1290
1291 /**
1292 * Returns true if the new_pricing_202210 is set to not empty in URL for testing purpose.
1293 */
1294 public static function is_forced_new_pricing_202208() {
1295 $referrer = wp_get_referer();
1296 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1297 return ( isset( $_GET['new_pricing_202208'] ) && $_GET['new_pricing_202208'] ) || $referrer && strpos( $referrer, 'new_pricing_202208=1' ) !== false;
1298 }
1299
1300 /**
1301 * Returns true if tracking should be disabled via URL parameter, which is used for testing purposes.
1302 *
1303 * @since 0.56.0
1304 *
1305 * @return bool
1306 */
1307 public static function is_tracking_disabled() {
1308 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1309 return isset( $_GET['disable_tracking'] ) && $_GET['disable_tracking'];
1310 }
1311
1312 /**
1313 * Enqueue the WordPress.com Tracks library that drains `window._tkq` and
1314 * sends the queued events. Shared by Instant Search and the Search blocks so
1315 * the handle, src, and cache-busting version live in one place. Callers own
1316 * the decision of whether to load it (e.g. the `is_tracking_disabled()` gate).
1317 */
1318 public static function enqueue_tracks_script() {
1319 wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
1320 }
1321 }
1322