Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.40% covered (warning)
82.40%
309 / 375
51.28% covered (warning)
51.28%
20 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Helper
82.84% covered (warning)
82.84%
309 / 373
51.28% covered (warning)
51.28%
20 / 39
326.94
0.00% covered (danger)
0.00%
0 / 1
 get_search_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 add_query_arg
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 remove_query_arg
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_widget_option_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_widgets_from_option
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 build_widget_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_active_widget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_filters_from_widgets
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
12.14
 expand_product_attribute_filters
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 get_date_filter_type_name
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 generate_widget_filter_name
75.56% covered (warning)
75.56%
34 / 45
0.00% covered (danger)
0.00%
0 / 1
18.29
 should_rerun_search_in_customizer_preview
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 array_diff
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 post_types_differ_searchable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 post_types_differ_query
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 get_widget_tracks_value
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
22.46
 get_widget_properties_for_tracks
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 get_filter_properties_for_tracks
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 get_active_post_types
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 remove_active_from_post_type_buckets
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 add_post_types_to_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 ensure_post_types_on_remove_url
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 are_filters_by_widget_disabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_max_posts_per_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_max_offset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_valid_locale
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_asset_version
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 generate_post_type_customizer_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generate_post_type_customizer_ids
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 sanitize_checkbox_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 sanitize_checkbox_value_for_js
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generate_initial_javascript_state
83.72% covered (warning)
83.72%
72 / 86
0.00% covered (danger)
0.00%
0 / 1
24.09
 is_wpcom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 print_instant_search_sidebar
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 get_active_plugins
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 get_wpcom_site_id
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 is_forced_free_plan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_forced_new_pricing_202208
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
20
 is_tracking_disabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Helper class providing various static utility functions for use in Search.
4 *
5 * @package    automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Status;
11use GP_Locales;
12use Jetpack; // TODO: Remove this once migrated.
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Various helper functions for reuse throughout the Jetpack Search code.
20 */
21class 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 = ( isset( $widget_filter['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     * Returns the maximum offset for a search query.
743     *
744     * @since 5.8.0
745     *
746     * @return int
747     */
748    public static function get_max_offset() {
749        return Options::site_has_vip_index() ? 9000 : 1000;
750    }
751
752    /**
753     * Returns the maximum offset for a search query.
754     *
755     * @since 8.4.0
756     * @param string $locale    A potentially valid locale string.
757     *
758     * @return bool
759     */
760    public static function is_valid_locale( $locale ) {
761        if ( ! class_exists( 'GP_Locales' ) ) {
762            // Assume locale to be valid if we can't check with GlotPress.
763            return true;
764        }
765        return false !== GP_Locales::by_field( 'wp_locale', $locale );
766    }
767
768    /**
769     * Get the version number to use when loading the file. Allows us to bypass cache when developing.
770     *
771     * @since 8.6.0
772     * @param string $file Path of the file we are looking for.
773     * @return string $script_version Version number.
774     */
775    public static function get_asset_version( $file ) {
776        return Package::is_development_version() && file_exists( Package::get_installed_path() . $file )
777            ? filemtime( Package::get_installed_path() . $file )
778            : Package::VERSION;
779    }
780
781    /**
782     * Generates a customizer settings ID for a given post type.
783     *
784     * @since 8.8.0
785     * @param object $post_type Post type object returned from get_post_types.
786     * @return string $customizer_id Customizer setting ID.
787     */
788    public static function generate_post_type_customizer_id( $post_type ) {
789        return Options::OPTION_PREFIX . 'disable_post_type_' . $post_type->name;
790    }
791
792    /**
793     * Generates an array of post types associated with their customizer IDs.
794     *
795     * @since 8.8.0
796     * @return array $ids Post type => post type customizer ID object.
797     */
798    public static function generate_post_type_customizer_ids() {
799        return array_map(
800            array( 'self', 'generate_post_type_customizer_id' ),
801            get_post_types( array( 'exclude_from_search' => false ), 'objects' )
802        );
803    }
804
805    /**
806     * Sanitizes a checkbox value for writing to the database.
807     *
808     * @since 8.9.0
809     *
810     * @param mixed $value from the customizer form.
811     * @return string either '0' or '1'.
812     */
813    public static function sanitize_checkbox_value( $value ) {
814        return true === $value ? '1' : '0';
815    }
816
817    /**
818     * Sanitizes a checkbox value for rendering the Customizer.
819     *
820     * @since 8.9.0
821     *
822     * @param mixed $value from the database.
823     * @return boolean
824     */
825    public static function sanitize_checkbox_value_for_js( $value ) {
826        return '1' === $value;
827    }
828
829    /**
830     * Passes all options to the JS app.
831     */
832    public static function generate_initial_javascript_state() {
833        $widget_options = self::get_widgets_from_option();
834        if ( is_array( $widget_options ) ) {
835            $widget_options = end( $widget_options );
836        }
837
838        $overlay_widget_ids      = is_active_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ) ?
839            wp_get_sidebars_widgets()[ Instant_Search::INSTANT_SEARCH_SIDEBAR ] : array();
840        $filters                 = self::get_filters_from_widgets();
841        $widgets                 = array();
842        $widgets_outside_overlay = array();
843        foreach ( $filters as $key => &$filter ) {
844            $filter['filter_id'] = $key;
845
846            if ( in_array( $filter['widget_id'], $overlay_widget_ids, true ) ) {
847                if ( ! isset( $widgets[ $filter['widget_id'] ] ) ) {
848                    $widgets[ $filter['widget_id'] ]['filters']   = array();
849                    $widgets[ $filter['widget_id'] ]['widget_id'] = $filter['widget_id'];
850                }
851                $widgets[ $filter['widget_id'] ]['filters'][] = $filter;
852            } else {
853                if ( ! isset( $widgets_outside_overlay[ $filter['widget_id'] ] ) ) {
854                    $widgets_outside_overlay[ $filter['widget_id'] ]['filters']   = array();
855                    $widgets_outside_overlay[ $filter['widget_id'] ]['widget_id'] = $filter['widget_id'];
856                }
857                $widgets_outside_overlay[ $filter['widget_id'] ]['filters'][] = $filter;
858            }
859        }
860        unset( $filter );
861
862        $has_non_search_widgets = false;
863        foreach ( $overlay_widget_ids as $overlay_widget_id ) {
864            if ( strpos( $overlay_widget_id, self::FILTER_WIDGET_BASE ) === false ) {
865                $has_non_search_widgets = true;
866                break;
867            }
868        }
869
870        $post_type_objs   = get_post_types( array( 'exclude_from_search' => false ), 'objects' );
871        $post_type_labels = array();
872        foreach ( $post_type_objs as $key => $obj ) {
873            $post_type_labels[ $key ] = array(
874                'singular_name' => $obj->labels->singular_name,
875                'name'          => $obj->labels->name,
876            );
877        }
878
879        $prefix         = Options::OPTION_PREFIX;
880        $posts_per_page = (int) get_option( 'posts_per_page' );
881        if ( ( $posts_per_page > 20 ) || ( $posts_per_page <= 0 ) ) {
882            $posts_per_page = 20;
883        }
884
885        $excluded_post_types   = get_option( $prefix . 'excluded_post_types' ) ? explode( ',', get_option( $prefix . 'excluded_post_types', '' ) ) : array();
886        $post_types            = array_values(
887            get_post_types(
888                array(
889                    'exclude_from_search' => false,
890                    'public'              => true,
891                )
892            )
893        );
894        $unexcluded_post_types = array_diff( $post_types, $excluded_post_types );
895        // NOTE: If all post types are being excluded, ignore the option value.
896        if ( array() === $unexcluded_post_types ) {
897            $excluded_post_types = array();
898        }
899
900        $is_wpcom                  = static::is_wpcom();
901        $is_private_site           = ( new Status() )->is_private_site();
902        $is_jetpack_photon_enabled = method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'photon' );
903
904        $options = array(
905            'overlayOptions'              => array(
906                'colorTheme'                  => get_option( $prefix . 'color_theme', 'light' ),
907                'enableInfScroll'             => get_option( $prefix . 'inf_scroll', '1' ) === '1',
908                'enableFilteringOpensOverlay' => get_option( $prefix . 'filtering_opens_overlay', '1' ) === '1',
909                'enablePostDate'              => get_option( $prefix . 'show_post_date', '1' ) === '1',
910                'enableProductPrice'          => get_option( $prefix . 'show_product_price', '1' ) === '1',
911                'enableSort'                  => get_option( $prefix . 'enable_sort', '1' ) === '1',
912                'highlightColor'              => get_option( $prefix . 'highlight_color', '#FFC' ),
913                'overlayTrigger'              => get_option( $prefix . 'overlay_trigger', Options::DEFAULT_OVERLAY_TRIGGER ),
914                'resultFormat'                => get_option( $prefix . 'result_format', Options::RESULT_FORMAT_MINIMAL ),
915                'showPoweredBy'               => ( new Plan() )->is_free_plan() || ( get_option( $prefix . 'show_powered_by', '1' ) === '1' ),
916
917                // These options require kicking off a new search.
918                'defaultSort'                 => get_option( $prefix . 'default_sort', 'relevance' ),
919                'excludedPostTypes'           => $excluded_post_types,
920            ),
921
922            // core config.
923            'homeUrl'                     => home_url(),
924            'locale'                      => str_replace( '_', '-', self::is_valid_locale( get_locale() ) ? get_locale() : 'en_US' ),
925            'postsPerPage'                => $posts_per_page,
926            'siteId'                      => self::get_wpcom_site_id(),
927            'postTypes'                   => $post_type_labels,
928            'webpackPublicPath'           => plugins_url( '/build/instant-search/', __DIR__ ),
929            'isPhotonEnabled'             => ( $is_wpcom || $is_jetpack_photon_enabled ) && ! $is_private_site,
930            'isFreePlan'                  => ( new Plan() )->is_free_plan(),
931
932            // config values related to private site support.
933            'apiRoot'                     => esc_url_raw( rest_url() ),
934            'apiNonce'                    => wp_create_nonce( 'wp_rest' ),
935            'isPrivateSite'               => $is_private_site,
936            'isWpcom'                     => $is_wpcom,
937
938            // widget info.
939            'hasOverlayWidgets'           => is_countable( $overlay_widget_ids ) && count( $overlay_widget_ids ) > 0,
940            'widgets'                     => array_values( $widgets ),
941            'widgetsOutsideOverlay'       => array_values( $widgets_outside_overlay ),
942            'hasNonSearchWidgets'         => $has_non_search_widgets,
943            /**
944             * Whether to prevent tracking cookie reset. More information `pbmxuV-39H-p2`.
945             *
946             * @module search
947             *
948             * @since 0.41.0
949             *
950             * @param bool Prevent cookie reset for automattic sites as default value.
951             */
952            'preventTrackingCookiesReset' => apply_filters( 'jetpack_instant_search_prevent_tracking_cookies_reset', function_exists( 'is_automattic' ) && is_automattic() ),
953
954            /**
955             * Whether to disable Tracks and TrainTracks analytics.
956             *
957             * This can be enabled via URL parameter (?disable_tracking=1) for testing,
958             * or via the filter for permanent configuration. Useful for debugging issues
959             * where tracking may interfere with search functionality, such as Safari's
960             * advanced tracking protection.
961             *
962             * @module search
963             *
964             * @since 0.56.0
965             *
966             * @param bool $disable_tracking Whether to disable tracking. Default false.
967             */
968            'disableTracking'             => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ),
969        );
970
971        /**
972         * Customize Instant Search Options.
973         *
974         * @module search
975         *
976         * @since 7.7.0
977         *
978         * @param array $options Array of parameters used in Instant Search queries.
979         */
980        return apply_filters( 'jetpack_instant_search_options', $options );
981    }
982
983    /**
984     * Returns true if the site is a WordPress.com simple site, i.e. the code runs on WPCOM.
985     */
986    public static function is_wpcom() {
987        return defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' );
988    }
989
990    /**
991     * Prints the Instant Search sidebar.
992     */
993    public static function print_instant_search_sidebar() {
994        ?>
995        <div class="jetpack-instant-search__widget-area" style="display: none">
996            <?php if ( is_active_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ) ) { ?>
997                <?php dynamic_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ); ?>
998            <?php } ?>
999        </div>
1000        <?php
1001    }
1002
1003    /**
1004     * Gets all of the active plugins via site options.
1005     * Forked from Jetpack::get_active_plugins from the Jetpack plugin.
1006     *
1007     * @return string[]
1008     */
1009    public static function get_active_plugins() {
1010        // active_plugins plugins as values.
1011        $active_plugins = (array) get_option( 'active_plugins', array() );
1012
1013        // active_sitewide_plugins stores plugins as keys.
1014        if ( is_multisite() ) {
1015            $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) );
1016            if ( $network_plugins ) {
1017                $active_plugins = array_merge( $active_plugins, $network_plugins );
1018            }
1019        }
1020
1021        sort( $active_plugins );
1022        return array_unique( $active_plugins );
1023    }
1024
1025    /**
1026     * Get the current site's WordPress.com ID.
1027     *
1028     * @return int Blog ID.
1029     */
1030    public static function get_wpcom_site_id() {
1031        // Returns local blog ID for a multi-site network.
1032        if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) {
1033            return \get_current_blog_id();
1034        }
1035
1036        // Returns cache site ID.
1037        return \Jetpack_Options::get_option( 'id' );
1038    }
1039
1040    /**
1041     * Returns true if the free_plan is set to not empty in URL, which is used for testing purpose.
1042     */
1043    public static function is_forced_free_plan() {
1044        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1045        return isset( $_GET['free_plan'] ) && $_GET['free_plan'];
1046    }
1047
1048    /**
1049     * Returns true if the new_pricing_202210 is set to not empty in URL for testing purpose.
1050     */
1051    public static function is_forced_new_pricing_202208() {
1052        $referrer = wp_get_referer();
1053        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1054        return ( isset( $_GET['new_pricing_202208'] ) && $_GET['new_pricing_202208'] ) || $referrer && strpos( $referrer, 'new_pricing_202208=1' ) !== false;
1055    }
1056
1057    /**
1058     * Returns true if tracking should be disabled via URL parameter, which is used for testing purposes.
1059     *
1060     * @since 0.56.0
1061     *
1062     * @return bool
1063     */
1064    public static function is_tracking_disabled() {
1065        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1066        return isset( $_GET['disable_tracking'] ) && $_GET['disable_tracking'];
1067    }
1068}