Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.74% covered (success)
91.74%
100 / 109
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Module_Control
93.46% covered (success)
93.46%
100 / 107
78.57% covered (warning)
78.57%
11 / 14
55.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 is_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_instant_search_enabled
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 is_swap_classic_to_inline_search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activate
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 deactivate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 update_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 disable_instant_search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enable_instant_search
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 update_instant_search_status
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 update_swap_classic_to_inline_search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_experience
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 update_experience
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
15
 get_active_modules
n/a
0 / 0
n/a
0 / 0
1
 search_filter_available_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jetpack Search: Module_Control class
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Modules;
12use Automattic\Jetpack\Status;
13use WP_Error;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * To get and set Search module settings
21 */
22class Module_Control {
23    /**
24     * Plan object
25     *
26     * @var Plan
27     */
28    protected $plan;
29
30    /**
31     * Connection_Manager object
32     *
33     * @var \Automattic\Jetpack\Connection\Manager
34     */
35    protected $connection_manager;
36
37    /**
38     * We use the same options as Jetpack the plugin to flag whether Search is active.
39     */
40    const JETPACK_ACTIVE_MODULES_OPTION_KEY               = 'active_modules';
41    const JETPACK_SEARCH_MODULE_SLUG                      = 'search';
42    const SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY         = 'instant_search_enabled';
43    const SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY = 'swap_classic_to_inline_search';
44    const SEARCH_MODULE_EXPERIENCE_OPTION_KEY             = 'jetpack_search_experience';
45
46    /**
47     * Valid experience values.
48     *
49     * `EXPERIENCE_OVERLAY_BLOCKS` is the experimental blocks-powered overlay
50     * (server-rendered Search blocks template inside a modal). It is gated
51     * by the `jetpack_search_overlay_block_template_enabled` filter — the
52     * dashboard option only appears when the filter is on, and the runtime
53     * swap from the legacy preact app to the new overlay only kicks in when
54     * the user has explicitly chosen this experience.
55     */
56    const EXPERIENCE_OVERLAY        = 'overlay';
57    const EXPERIENCE_OVERLAY_BLOCKS = 'overlay_blocks';
58    const EXPERIENCE_EMBEDDED       = 'embedded';
59    const EXPERIENCE_INLINE         = 'inline';
60    const EXPERIENCE_OFF            = 'off';
61
62    /**
63     * Contructor
64     *
65     * @param Plan|null                                   $plan - Plan object.
66     * @param \Automattic\Jetpack\Connection\Manager|null $connection_manager - Connection_Manager object.
67     */
68    public function __construct( $plan = null, $connection_manager = null ) {
69        $this->plan               = $plan === null ? new Plan() : $plan;
70        $this->connection_manager = $connection_manager === null ? new Connection_Manager( Package::SLUG ) : $connection_manager;
71        if ( ! did_action( 'jetpack_search_module_control_initialized' ) ) {
72            add_filter( 'jetpack_get_available_standalone_modules', array( $this, 'search_filter_available_modules' ), 10, 1 );
73            if ( Helper::is_wpcom() ) {
74                add_filter( 'jetpack_active_modules', array( $this, 'search_filter_available_modules' ), 10, 2 );
75            }
76            /**
77             * Fires when the Automattic\Jetpack\Search\Module_Control is initialized for the first time.
78             */
79            do_action( 'jetpack_search_module_control_initialized' );
80        }
81    }
82
83    /**
84     * Returns a boolean for whether of the module is enabled.
85     *
86     * @return bool
87     */
88    public function is_active() {
89        return ( new Modules() )->is_active( self::JETPACK_SEARCH_MODULE_SLUG );
90    }
91
92    /**
93     * Returns a boolean for whether instant search is enabled.
94     *
95     * Reads the canonical `jetpack_search_experience` option first — only the
96     * `'overlay'` experience enables Instant Search, and an explicit `'inline'`
97     * or `'embedded'` value means the user opted out regardless of the legacy
98     * boolean. Falls back to the legacy `instant_search_enabled` option only
99     * when the experience option has never been written (pre-existing sites
100     * that haven't saved via the new UI).
101     *
102     * Module-inactive sites always return false: when Search is off the
103     * experience option may still hold the user's prior preference (preserved
104     * for re-enable), and we don't want a stale `'overlay'` to read as
105     * "Instant Search is on" while the module isn't loaded.
106     *
107     * Keeps callers that still read the legacy boolean (debug-bar, AI Chat
108     * editor state, etc.) correct without each having to migrate to
109     * `get_experience()` individually.
110     *
111     * @return bool
112     */
113    public function is_instant_search_enabled() {
114        if ( ! $this->plan->supports_instant_search() || ! $this->is_active() ) {
115            return false;
116        }
117
118        $saved = get_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false );
119        if ( self::EXPERIENCE_OVERLAY === $saved ) {
120            return true;
121        }
122        if (
123            self::EXPERIENCE_INLINE === $saved
124            || self::EXPERIENCE_EMBEDDED === $saved
125            || self::EXPERIENCE_OVERLAY_BLOCKS === $saved
126        ) {
127            return false;
128        }
129
130        // Legacy fallback: experience never written, trust the legacy boolean.
131        return (bool) get_option( self::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY );
132    }
133
134    /**
135     * Returns a boolean for whether new inline search is enabled.
136     *
137     * @return bool
138     */
139    public function is_swap_classic_to_inline_search() {
140        return (bool) get_option( self::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, false );
141    }
142
143    /**
144     * Activiate Search module
145     */
146    public function activate() {
147        $is_wpcom = defined( 'IS_WPCOM' ) && IS_WPCOM;
148        if ( ( new Status() )->is_offline_mode() ) {
149            return new WP_Error( 'site_offline', __( 'Jetpack Search cannot be used in offline mode.', 'jetpack-search-pkg' ) );
150        }
151        if ( ! $is_wpcom && ! $this->connection_manager->is_connected() ) {
152            return new WP_Error( 'connection_required', __( 'Connect your site to use Jetpack Search.', 'jetpack-search-pkg' ) );
153        }
154        if ( ! $this->plan->supports_search() ) {
155            return new WP_Error( 'not_supported', __( 'Your plan does not support Jetpack Search.', 'jetpack-search-pkg' ) );
156        }
157
158        $success = ( new Modules() )->activate( self::JETPACK_SEARCH_MODULE_SLUG, false, false );
159        if ( false === $success ) {
160            return new WP_Error( 'not_updated', __( 'Setting not updated.', 'jetpack-search-pkg' ) );
161        }
162        return $success;
163    }
164
165    /**
166     * Deactiviate Search module
167     */
168    public function deactivate() {
169        $success = ( new Modules() )->deactivate( self::JETPACK_SEARCH_MODULE_SLUG );
170
171        $this->disable_instant_search();
172
173        return $success;
174    }
175
176    /**
177     * Update module status
178     *
179     * @param boolean $active - true to activate, false to deactivate.
180     */
181    public function update_status( $active ) {
182        return $active ? $this->activate() : $this->deactivate();
183    }
184
185    /**
186     * Disable Instant Search Experience
187     */
188    public function disable_instant_search() {
189        return update_option( self::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, false );
190    }
191
192    /**
193     * Enable Instant Search Experience
194     *
195     * Also writes `'overlay'` to the canonical experience option so callers that
196     * arrive here through the legacy entry points (`update_instant_search_status`,
197     * the legacy auto-setup REST endpoint, etc.) leave the option in agreement
198     * with the boolean. Without this, a stale `'embedded'` / `'inline'` in the
199     * experience option keeps `is_instant_search_enabled()` returning false even
200     * though the legacy boolean was just set true.
201     */
202    public function enable_instant_search() {
203        if ( ! $this->is_active() ) {
204            return new WP_Error( 'search_module_inactive', __( 'Search module needs to be activated before enabling instant search.', 'jetpack-search-pkg' ) );
205        }
206        if ( ! $this->plan->supports_instant_search() ) {
207            return new WP_Error( 'not_supported', __( 'Your plan does not support Instant Search.', 'jetpack-search-pkg' ) );
208        }
209        update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_OVERLAY );
210        return update_option( self::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, true );
211    }
212
213    /**
214     * Update instant search status
215     *
216     * When disabling via this legacy path we also clear the canonical experience
217     * option — the user is asking for "not Overlay", and leaving a stale
218     * `'overlay'` in the option would keep `is_instant_search_enabled()`
219     * returning true. We don't clear from `disable_instant_search` itself
220     * because `deactivate()` calls that to flip the legacy boolean, and
221     * deactivation is meant to preserve the user's experience choice for the
222     * next re-activation.
223     *
224     * @param boolean $enabled - true to enable, false to disable.
225     */
226    public function update_instant_search_status( $enabled ) {
227        if ( ! $enabled ) {
228            update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, '' );
229        }
230        return $enabled ? $this->enable_instant_search() : $this->disable_instant_search();
231    }
232
233    /**
234     * Update setting indicating whether inline search should use newer 1.3 API.
235     *
236     * @param bool $swap_classic_to_inline_search - true to use Inline Search, false to use Classic Search.
237     */
238    public function update_swap_classic_to_inline_search( bool $swap_classic_to_inline_search ) {
239        return update_option( self::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, $swap_classic_to_inline_search );
240    }
241
242    /**
243     * Get the active search experience.
244     *
245     * `'off'` is read from the global Jetpack module-active state (not stored in
246     * this package's option). `'inline'`, `'embedded'`, `'overlay'`, and
247     * `'overlay_blocks'` are each written as their literal value to
248     * `jetpack_search_experience` whenever the experience changes, and read
249     * straight back here.
250     *
251     * @return string One of 'embedded', 'overlay', 'overlay_blocks', 'inline', 'off'.
252     */
253    public function get_experience() {
254        if ( ! $this->is_active() ) {
255            return self::EXPERIENCE_OFF;
256        }
257
258        $saved = get_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false );
259        if ( self::EXPERIENCE_EMBEDDED === $saved ) {
260            return self::EXPERIENCE_EMBEDDED;
261        }
262        if ( self::EXPERIENCE_OVERLAY === $saved ) {
263            return self::EXPERIENCE_OVERLAY;
264        }
265        if ( self::EXPERIENCE_OVERLAY_BLOCKS === $saved ) {
266            return self::EXPERIENCE_OVERLAY_BLOCKS;
267        }
268
269        // Legacy fallback for sites that have never saved via the new UI: a true
270        // `instant_search_enabled` boolean reads as overlay; otherwise inline.
271        if ( $this->is_instant_search_enabled() ) {
272            return self::EXPERIENCE_OVERLAY;
273        }
274
275        return self::EXPERIENCE_INLINE;
276    }
277
278    /**
279     * Update the search experience.
280     *
281     * Storage is narrower than the wire format: `'off'` only deactivates the global
282     * module (no write to the experience option) and disables
283     * `instant_search_enabled` so the legacy boolean doesn't drift true after
284     * a non-overlay re-enable. `'inline'` deletes the experience option (the
285     * absence of an opt-in *is* inline). Only `'embedded'` and `'overlay'`
286     * write affirmative values.
287     *
288     * Legacy `module_active` / `instant_search_enabled` are kept in lockstep so
289     * unmigrated readers (Initializer, Options, sidebar registration) continue to
290     * see the right state until they're migrated to consult get_experience().
291     *
292     * @param string $experience One of 'embedded', 'overlay', 'inline', 'off'.
293     * @return bool|WP_Error WP_Error on failure; true on success for the affirmative
294     *                      branches; the bool from Modules::deactivate() for `'off'`
295     *                      (false signals the module was already inactive — a benign
296     *                      no-op the REST controller treats as success).
297     */
298    public function update_experience( string $experience ) {
299        $valid_values = array(
300            self::EXPERIENCE_OVERLAY,
301            self::EXPERIENCE_OVERLAY_BLOCKS,
302            self::EXPERIENCE_EMBEDDED,
303            self::EXPERIENCE_INLINE,
304            self::EXPERIENCE_OFF,
305        );
306        if ( ! in_array( $experience, $valid_values, true ) ) {
307            return new WP_Error(
308                'invalid_experience',
309                esc_html__( 'Invalid experience value.', 'jetpack-search-pkg' ),
310                array( 'status' => 400 )
311            );
312        }
313
314        // Defence-in-depth for the experimental blocks-powered overlay: the
315        // dashboard already hides the option when the
316        // `jetpack_search_overlay_block_template_enabled` filter is off, but
317        // a scripted REST POST could otherwise pre-stage `overlay_blocks` on
318        // a site where the operator has pinned the filter to false. Reject
319        // the value at the boundary so the runtime gate isn't the only
320        // safety net.
321        if (
322            self::EXPERIENCE_OVERLAY_BLOCKS === $experience
323            && ! (bool) apply_filters( 'jetpack_search_overlay_block_template_enabled', true )
324        ) {
325            return new WP_Error(
326                'experience_not_available',
327                esc_html__( 'The blocks-powered Overlay search experience is not enabled on this site.', 'jetpack-search-pkg' ),
328                array( 'status' => 400 )
329            );
330        }
331
332        switch ( $experience ) {
333            case self::EXPERIENCE_OFF:
334                $this->disable_instant_search();
335                return ( new Modules() )->deactivate( self::JETPACK_SEARCH_MODULE_SLUG );
336
337            case self::EXPERIENCE_INLINE:
338                $result = $this->activate();
339                if ( is_wp_error( $result ) ) {
340                    return $result;
341                }
342                $this->disable_instant_search();
343                // Inline is the absence of an opt-in — delete the option rather than
344                // writing 'inline'. Pre-existing sites that have never saved are
345                // already in this state, so this also normalises after a switch.
346                update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, '' );
347                return true;
348
349            case self::EXPERIENCE_EMBEDDED:
350                $result = $this->activate();
351                if ( is_wp_error( $result ) ) {
352                    return $result;
353                }
354                $this->disable_instant_search();
355                update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_EMBEDDED );
356                return true;
357
358            case self::EXPERIENCE_OVERLAY:
359                $result = $this->activate();
360                if ( is_wp_error( $result ) ) {
361                    return $result;
362                }
363                $result = $this->enable_instant_search();
364                if ( is_wp_error( $result ) ) {
365                    return $result;
366                }
367                update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_OVERLAY );
368                return true;
369
370            case self::EXPERIENCE_OVERLAY_BLOCKS:
371                // The blocks-powered overlay does not boot the legacy preact
372                // `SearchApp`, so we keep `instant_search_enabled` off; the
373                // runtime hookup lives in `Search_Blocks::is_block_template_overlay_enabled()`
374                // which combines the user's experience choice with the
375                // `jetpack_search_overlay_block_template_enabled` server filter.
376                $result = $this->activate();
377                if ( is_wp_error( $result ) ) {
378                    return $result;
379                }
380                $this->disable_instant_search();
381                update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_OVERLAY_BLOCKS );
382                return true;
383
384            default:
385                // Unreachable — the `in_array` guard at the top of this
386                // method already restricts $experience to the cases above.
387                // Returning a typed value (rather than falling through to an
388                // implicit `null`) satisfies the static analyzers that flag
389                // switch statements without a `default` branch, and keeps the
390                // method's `bool|WP_Error` contract honest if a future caller
391                // loosens the validation.
392                return true;
393        }
394    }
395
396    /**
397     * Get a list of activated modules as an array of module slugs.
398     *
399     * @deprecated 0.12.3
400     * @return Array $active_modules
401     */
402    public function get_active_modules() {
403        _deprecated_function(
404            __METHOD__,
405            'jetpack-search-0.12.3',
406            'Automattic\\Jetpack\\Modules\\get_active'
407        );
408
409        return ( new Modules() )->get_active();
410    }
411
412    /**
413     * Adds search to the list of available modules
414     *
415     * @param array $modules The available modules.
416     * @return array
417     */
418    public function search_filter_available_modules( $modules ) {
419        return array_merge( array( self::JETPACK_SEARCH_MODULE_SLUG ), $modules );
420    }
421}