Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.07% covered (warning)
83.07%
319 / 384
53.66% covered (warning)
53.66%
22 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
Helper
83.51% covered (warning)
83.51%
319 / 382
53.66% covered (warning)
53.66%
22 / 41
317.53
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
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
11
 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
 resolve_results_per_page
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 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
84.44% covered (warning)
84.44%
76 / 90
0.00% covered (danger)
0.00%
0 / 1
23.82
 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
 enqueue_tracks_script
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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 = $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        );
996
997        /**
998         * Customize Instant Search Options.
999         *
1000         * @module search
1001         *
1002         * @since 7.7.0
1003         *
1004         * @param array $options Array of parameters used in Instant Search queries.
1005         */
1006        return apply_filters( 'jetpack_instant_search_options', $options );
1007    }
1008
1009    /**
1010     * Returns true if the site is a WordPress.com simple site, i.e. the code runs on WPCOM.
1011     */
1012    public static function is_wpcom() {
1013        return defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' );
1014    }
1015
1016    /**
1017     * Prints the Instant Search sidebar.
1018     */
1019    public static function print_instant_search_sidebar() {
1020        ?>
1021        <div class="jetpack-instant-search__widget-area" style="display: none">
1022            <?php if ( is_active_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ) ) { ?>
1023                <?php dynamic_sidebar( Instant_Search::INSTANT_SEARCH_SIDEBAR ); ?>
1024            <?php } ?>
1025        </div>
1026        <?php
1027    }
1028
1029    /**
1030     * Gets all of the active plugins via site options.
1031     * Forked from Jetpack::get_active_plugins from the Jetpack plugin.
1032     *
1033     * @return string[]
1034     */
1035    public static function get_active_plugins() {
1036        // active_plugins plugins as values.
1037        $active_plugins = (array) get_option( 'active_plugins', array() );
1038
1039        // active_sitewide_plugins stores plugins as keys.
1040        if ( is_multisite() ) {
1041            $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) );
1042            if ( $network_plugins ) {
1043                $active_plugins = array_merge( $active_plugins, $network_plugins );
1044            }
1045        }
1046
1047        sort( $active_plugins );
1048        return array_unique( $active_plugins );
1049    }
1050
1051    /**
1052     * Get the current site's WordPress.com ID.
1053     *
1054     * @return int Blog ID.
1055     */
1056    public static function get_wpcom_site_id() {
1057        // Returns local blog ID for a multi-site network.
1058        if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) {
1059            return \get_current_blog_id();
1060        }
1061
1062        // Returns cache site ID.
1063        return \Jetpack_Options::get_option( 'id' );
1064    }
1065
1066    /**
1067     * Returns true if the free_plan is set to not empty in URL, which is used for testing purpose.
1068     */
1069    public static function is_forced_free_plan() {
1070        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1071        return isset( $_GET['free_plan'] ) && $_GET['free_plan'];
1072    }
1073
1074    /**
1075     * Returns true if the new_pricing_202210 is set to not empty in URL for testing purpose.
1076     */
1077    public static function is_forced_new_pricing_202208() {
1078        $referrer = wp_get_referer();
1079        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1080        return ( isset( $_GET['new_pricing_202208'] ) && $_GET['new_pricing_202208'] ) || $referrer && strpos( $referrer, 'new_pricing_202208=1' ) !== false;
1081    }
1082
1083    /**
1084     * Returns true if tracking should be disabled via URL parameter, which is used for testing purposes.
1085     *
1086     * @since 0.56.0
1087     *
1088     * @return bool
1089     */
1090    public static function is_tracking_disabled() {
1091        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1092        return isset( $_GET['disable_tracking'] ) && $_GET['disable_tracking'];
1093    }
1094
1095    /**
1096     * Enqueue the WordPress.com Tracks library that drains `window._tkq` and
1097     * sends the queued events. Shared by Instant Search and the Search blocks so
1098     * the handle, src, and cache-busting version live in one place. Callers own
1099     * the decision of whether to load it (e.g. the `is_tracking_disabled()` gate).
1100     */
1101    public static function enqueue_tracks_script() {
1102        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
1103    }
1104}