Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 268
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
jetpack_is_psh_active
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Jetpack_Plugin_Search
0.00% covered (danger)
0.00%
0 / 258
0.00% covered (danger)
0.00%
0 / 20
5256
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 start
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 plugin_details
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 register_endpoints
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 can_request
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_hint_id
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 dismiss
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_dismissed_hints
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 add_to_dismissed_hints
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_display_hint
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 load_plugins_search_script
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 get_jetpack_plugin_data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 get_extra_features
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 inject_jetpack_module_suggestion
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
156
 filter_cards
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 sanitize_search_term
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 by_sorting_option
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_configure_url
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 insert_module_related_links
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
306
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Plugin Search Hints, aka Feature Suggestions.
4 *
5 * @since 7.1.0
6 *
7 * @package automattic/jetpack
8 */
9
10// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files.
11
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
14use Automattic\Jetpack\Redirect;
15use Automattic\Jetpack\Tracking;
16
17// Disable direct access and execution.
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22if (
23    is_admin() &&
24    Jetpack::is_connection_ready() &&
25    /** This filter is documented in _inc/lib/admin-pages/class.jetpack-react-page.php */
26    apply_filters( 'jetpack_show_promotions', true ) &&
27    // Disable feature hints when plugins cannot be installed.
28    ! Constants::is_true( 'DISALLOW_FILE_MODS' ) &&
29    jetpack_is_psh_active()
30) {
31    Jetpack_Plugin_Search::init();
32}
33
34// Register endpoints when WP REST API is initialized.
35add_action( 'rest_api_init', array( 'Jetpack_Plugin_Search', 'register_endpoints' ) );
36
37/**
38 * Class that includes cards in the plugin search results when users enter terms that match some Jetpack feature.
39 * Card can be dismissed and includes a title, description, button to enable the feature and a link for more information.
40 *
41 * @since 7.1.0
42 */
43class Jetpack_Plugin_Search {
44
45    /**
46     * PSH slug name.
47     *
48     * @var string
49     */
50    public static $slug = 'jetpack-plugin-search';
51
52    /**
53     * Singleton constructor.
54     *
55     * @return Jetpack_Plugin_Search
56     */
57    public static function init() {
58        static $instance = null;
59
60        if ( ! $instance ) {
61            $instance = new Jetpack_Plugin_Search();
62        }
63
64        return $instance;
65    }
66
67    /**
68     * Jetpack_Plugin_Search constructor.
69     */
70    public function __construct() {
71        add_action( 'current_screen', array( $this, 'start' ) );
72    }
73
74    /**
75     * Add actions and filters only if this is the plugin installation screen and it's the first page.
76     *
77     * @param object $screen WP SCreen object.
78     *
79     * @since 7.1.0
80     */
81    public function start( $screen ) {
82        if ( 'plugin-install' === $screen->base && ( ! isset( $_GET['paged'] ) || 1 === intval( $_GET['paged'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
83            add_action( 'admin_enqueue_scripts', array( $this, 'load_plugins_search_script' ) );
84            add_filter( 'plugins_api_result', array( $this, 'inject_jetpack_module_suggestion' ), 10, 3 );
85            add_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
86            add_filter( 'plugin_install_action_links', array( $this, 'insert_module_related_links' ), 10, 2 );
87        }
88    }
89
90    /**
91     * Modify URL used to fetch to plugin information so it pulls Jetpack plugin page.
92     *
93     * @param string $url URL to load in dialog pulling the plugin page from wporg.
94     *
95     * @since 7.1.0
96     *
97     * @return string The URL with 'jetpack' instead of 'jetpack-plugin-search'.
98     */
99    public function plugin_details( $url ) {
100        return false !== stripos( $url, 'tab=plugin-information&amp;plugin=' . self::$slug )
101            ? 'plugin-install.php?tab=plugin-information&amp;plugin=jetpack&amp;TB_iframe=true&amp;width=600&amp;height=550'
102            : $url;
103    }
104
105    /**
106     * Register REST API endpoints.
107     *
108     * @since 7.1.0
109     */
110    public static function register_endpoints() {
111        register_rest_route(
112            'jetpack/v4',
113            '/hints',
114            array(
115                'methods'             => WP_REST_Server::EDITABLE,
116                'callback'            => __CLASS__ . '::dismiss',
117                'permission_callback' => __CLASS__ . '::can_request',
118                'args'                => array(
119                    'hint' => array(
120                        'default'           => '',
121                        'type'              => 'string',
122                        'required'          => true,
123                        'validate_callback' => __CLASS__ . '::is_hint_id',
124                    ),
125                ),
126            )
127        );
128    }
129
130    /**
131     * A WordPress REST API permission callback method that accepts a request object and
132     * decides if the current user has enough privileges to act.
133     *
134     * @since 7.1.0
135     *
136     * @return bool does a current user have enough privileges.
137     */
138    public static function can_request() {
139        return current_user_can( 'jetpack_admin_page' );
140    }
141
142    /**
143     * Validates that the ID of the hint to dismiss is a string.
144     *
145     * @since 7.1.0
146     *
147     * @param string|bool     $value Value to check.
148     * @param WP_REST_Request $request The request sent to the WP REST API.
149     * @param string          $param Name of the parameter passed to endpoint holding $value.
150     *
151     * @return bool|WP_Error
152     */
153    public static function is_hint_id( $value, $request, $param ) {
154        return in_array( $value, Jetpack::get_available_modules(), true )
155            ? true
156            /* translators: %s is the name of a parameter passed to an endpoint. */
157            : new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an alphanumeric string.', 'jetpack' ), $param ) );
158    }
159
160    /**
161     * A WordPress REST API callback method that accepts a request object and decides what to do with it.
162     *
163     * @param WP_REST_Request $request {
164     *     Array of parameters received by request.
165     *
166     *     @type string $hint Slug of card to dismiss.
167     * }
168     *
169     * @since 7.1.0
170     *
171     * @return bool|array|WP_Error a resulting value or object, or an error.
172     */
173    public static function dismiss( WP_REST_Request $request ) {
174        return self::add_to_dismissed_hints( $request['hint'] )
175            ? rest_ensure_response( array( 'code' => 'success' ) )
176            : new WP_Error( 'not_dismissed', esc_html__( 'The card could not be dismissed', 'jetpack' ), array( 'status' => 400 ) );
177    }
178
179    /**
180     * Returns a list of previously dismissed hints.
181     *
182     * @since 7.1.0
183     *
184     * @return array List of dismissed hints.
185     */
186    protected static function get_dismissed_hints() {
187        $dismissed_hints = Jetpack_Options::get_option( 'dismissed_hints' );
188        return isset( $dismissed_hints ) && is_array( $dismissed_hints )
189            ? $dismissed_hints
190            : array();
191    }
192
193    /**
194     * Save the hint in the list of dismissed hints.
195     *
196     * @since 7.1.0
197     *
198     * @param string $hint The hint id, which is a Jetpack module slug.
199     *
200     * @return bool Whether the card was added to the list and hence dismissed.
201     */
202    protected static function add_to_dismissed_hints( $hint ) {
203        return Jetpack_Options::update_option( 'dismissed_hints', array_merge( self::get_dismissed_hints(), array( $hint ) ) );
204    }
205
206    /**
207     * Checks that the module slug passed should be displayed.
208     *
209     * A feature hint will be displayed if it has not been dismissed before or if 2 or fewer other hints have been dismissed.
210     *
211     * @since 7.2.1
212     *
213     * @param string $hint The hint id, which is a Jetpack module slug.
214     *
215     * @return bool True if $hint should be displayed.
216     */
217    protected function should_display_hint( $hint ) {
218        $dismissed_hints = static::get_dismissed_hints();
219        // If more than 2 hints have been dismissed, then show no more.
220        if ( 2 < count( $dismissed_hints ) ) {
221            return false;
222        }
223
224        $plan = Jetpack_Plan::get();
225        if ( isset( $plan['class'] ) && ( 'free' === $plan['class'] || 'personal' === $plan['class'] ) && 'vaultpress' === $hint ) {
226            return false;
227        }
228
229        return ! in_array( $hint, $dismissed_hints, true );
230    }
231
232    /**
233     * Load the search scripts and CSS for PSH.
234     */
235    public function load_plugins_search_script() {
236        wp_enqueue_script( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.js', JETPACK__PLUGIN_FILE ), array( 'jquery' ), JETPACK__VERSION, true );
237        wp_localize_script(
238            self::$slug,
239            'jetpackPluginSearch',
240            array(
241                'nonce'          => wp_create_nonce( 'wp_rest' ),
242                'base_rest_url'  => rest_url( '/jetpack/v4' ),
243                'manageSettings' => esc_html__( 'Configure', 'jetpack' ),
244                'activateModule' => esc_html__( 'Activate Module', 'jetpack' ),
245                'getStarted'     => esc_html__( 'Get started', 'jetpack' ),
246                'activated'      => esc_html__( 'Activated', 'jetpack' ),
247                'activating'     => esc_html__( 'Activating', 'jetpack' ),
248                'logo'           => 'https://ps.w.org/jetpack/assets/icon.svg?rev=1791404',
249                'legend'         => esc_html__(
250                    'This suggestion was made by Jetpack, the security and performance plugin already installed on your site.',
251                    'jetpack'
252                ),
253                'supportText'    => esc_html__(
254                    'Learn more about these suggestions.',
255                    'jetpack'
256                ),
257                'supportLink'    => Redirect::get_url( 'plugin-hint-learn-support' ),
258                'hideText'       => esc_html__( 'Hide this suggestion', 'jetpack' ),
259            )
260        );
261
262        wp_enqueue_style( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.css', JETPACK__PLUGIN_FILE ), array(), JETPACK__VERSION );
263    }
264
265    /**
266     * Get the plugin repo's data for Jetpack to populate the fields with.
267     *
268     * @return array|mixed|object|WP_Error
269     */
270    public static function get_jetpack_plugin_data() {
271        $data = get_transient( 'jetpack_plugin_data' );
272
273        if ( false === $data || is_wp_error( $data ) ) {
274            include_once ABSPATH . 'wp-admin/includes/plugin-install.php';
275            $data = plugins_api(
276                'plugin_information',
277                array(
278                    'slug'   => 'jetpack',
279                    'is_ssl' => is_ssl(),
280                    'fields' => array(
281                        'banners'         => true,
282                        'reviews'         => true,
283                        'active_installs' => true,
284                        'versions'        => false,
285                        'sections'        => false,
286                    ),
287                )
288            );
289            set_transient( 'jetpack_plugin_data', $data, DAY_IN_SECONDS );
290        }
291
292        return $data;
293    }
294
295    /**
296     * Create a list with additional features for those we don't have a module, like Akismet.
297     *
298     * @since 7.1.0
299     *
300     * @return array List of features.
301     */
302    public function get_extra_features() {
303        return array(
304            'akismet'       => array(
305                'name'                => 'Akismet',
306                'search_terms'        => 'akismet, anti-spam, antispam, comments, spam, spam protection, form spam, captcha, no captcha, nocaptcha, recaptcha, phising, google',
307                'short_description'   => esc_html__( 'Keep your visitors and search engines happy by stopping comment and contact form spam with Akismet.', 'jetpack' ),
308                'requires_connection' => true,
309                'module'              => 'akismet',
310                'sort'                => '16',
311                'learn_more_button'   => Redirect::get_url( 'plugin-hint-upgrade-akismet' ),
312                'configure_url'       => admin_url( 'admin.php?page=akismet-key-config' ),
313            ),
314            'sharing-block' => array(
315                'name'                => esc_html__( 'Sharing buttons block', 'jetpack' ),
316                'search_terms'        => 'share, sharing, sharing block, sharing button, social buttons, buttons, share facebook, share twitter, social share, icons, email, facebook, twitter, x, linkedin, pinterest, social media',
317                'short_description'   => esc_html__( 'Add sharing buttons blocks anywhere on your website to help your visitors share your content.', 'jetpack' ),
318                'requires_connection' => false,
319                'module'              => 'sharing-block',
320                'sort'                => '13',
321                'learn_more_button'   => Redirect::get_url( 'jetpack-support-sharing-block' ),
322                'configure_url'       => admin_url( 'site-editor.php?path=%2Fwp_template' ),
323            ),
324        );
325    }
326
327    /**
328     * Intercept the plugins API response and add in an appropriate card for Jetpack
329     *
330     * @param object $result Plugin search results.
331     * @param string $action unused.
332     * @param object $args Search args.
333     */
334    public function inject_jetpack_module_suggestion( $result, $action, $args ) {
335        /*
336         * Bail if something else hooks into the Plugins' API response
337         * and does not return results.
338         */
339        if ( empty( $result->plugins ) || is_wp_error( $result ) ) {
340            return $result;
341        }
342
343        // Looks like a search query; it's matching time.
344        if ( ! empty( $args->search ) ) {
345            $searchable_modules = array(
346                'contact-form',
347                'monitor',
348                'photon',
349                'photon-cdn',
350                'protect',
351                'publicize',
352                'related-posts',
353                'akismet',
354                'vaultpress',
355                'videopress',
356                'search',
357            );
358
359            /*
360             * Let's handle the Sharing feature differently.
361             * If we're using a block-based theme, we should suggest the sharing block.
362             * If using a classic theme, we should suggest the old sharing module.
363             */
364            if ( wp_is_block_theme() ) {
365                $searchable_modules[] = 'sharing-block';
366            } else {
367                $searchable_modules[] = 'sharedaddy';
368            }
369
370            /*
371             * Only surface the SEO Tools hint once the new Jetpack SEO admin page is
372             * available. The page is gated behind the `rsm_jetpack_seo` feature flag,
373             * so without this gate the hint's CTAs would point to a page that isn't
374             * registered yet.
375             */
376            if ( apply_filters( 'rsm_jetpack_seo', false ) ) {
377                $searchable_modules[] = 'seo-tools';
378            }
379
380            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
381            $tracking             = new Tracking();
382            $jetpack_modules_list = array_intersect_key(
383                array_merge( $this->get_extra_features(), Jetpack_Admin::init()->get_modules() ),
384                array_flip( $searchable_modules )
385            );
386            uasort( $jetpack_modules_list, array( $this, 'by_sorting_option' ) );
387
388            // Record event when user searches for a term over 3 chars (less than 3 is not very useful).
389            if ( strlen( $args->search ) >= 3 ) {
390                $tracking->record_user_event( 'wpa_plugin_search_term', array( 'search_term' => $args->search ) );
391            }
392
393            // Lowercase, trim, remove punctuation/special chars, decode url, remove 'jetpack'.
394            $normalized_term = $this->sanitize_search_term( $args->search );
395
396            $matching_module = null;
397
398            // Try to match a passed search term with module's search terms.
399            foreach ( $jetpack_modules_list as $module_slug => $module_opts ) {
400                /*
401                * Does the site's current plan support the feature?
402                * We don't use Jetpack_Plan::supports() here because
403                * that check always returns Akismet as supported,
404                * since Akismet has a free version.
405                */
406                $current_plan         = Jetpack_Plan::get();
407                $is_supported_by_plan = in_array( $module_slug, $current_plan['supports'], true );
408
409                if (
410                    false !== stripos( $module_opts['search_terms'] . ', ' . $module_opts['name'], $normalized_term )
411                    && $is_supported_by_plan
412                ) {
413                    $matching_module = $module_slug;
414                    break;
415                }
416            }
417
418            if ( isset( $matching_module ) && $this->should_display_hint( $matching_module ) ) {
419                // Record event when a matching feature is found.
420                $tracking->record_user_event( 'wpa_plugin_search_match_found', array( 'feature' => $matching_module ) );
421
422                $inject    = (array) self::get_jetpack_plugin_data();
423                $image_url = plugins_url( 'modules/plugin-search/psh', JETPACK__PLUGIN_FILE );
424                $overrides = array(
425                    'plugin-search'       => true, // Helps to determine if that an injected card.
426                    'name'                => sprintf(       // Supplement name/description so that they clearly indicate this was added.
427                        /* translators: Jetpack module name */
428                        esc_html_x( 'Jetpack: %s', 'Jetpack: Module Name', 'jetpack' ),
429                        $jetpack_modules_list[ $matching_module ]['name']
430                    ),
431                    'short_description'   => $jetpack_modules_list[ $matching_module ]['short_description'],
432                    'author'              => esc_attr__( 'Jetpack (installed)', 'jetpack' ),
433                    'requires_connection' => (bool) $jetpack_modules_list[ $matching_module ]['requires_connection'],
434                    'slug'                => self::$slug,
435                    'version'             => JETPACK__VERSION,
436                    'icons'               => array(
437                        '1x'  => "$image_url-128.png",
438                        '2x'  => "$image_url-256.png",
439                        'svg' => "$image_url.svg",
440                    ),
441                );
442
443                // Splice in the base module data.
444                $inject = array_merge( $inject, $jetpack_modules_list[ $matching_module ], $overrides );
445
446                // Add it to the top of the list.
447                $result->plugins = array_filter( $result->plugins, array( $this, 'filter_cards' ) );
448                array_unshift( $result->plugins, $inject );
449            }
450        }
451        return $result;
452    }
453
454    /**
455     * Remove cards for Jetpack plugins since we don't want duplicates.
456     *
457     * @since 7.1.0
458     * @since 7.2.0 Only remove Jetpack.
459     * @since 7.4.0 Simplify for WordPress 5.1+.
460     *
461     * @param array|object $plugin WordPress search result card.
462     *
463     * @return bool
464     */
465    public function filter_cards( $plugin ) {
466        /*
467         * $plugin is normally an array.
468         * However, since the response data can be filtered,
469         * we cannot fully trust its format.
470         * Let's handle both arrays and objects, and bail if it's neither.
471         */
472        if ( is_array( $plugin ) && ! empty( $plugin['slug'] ) ) {
473            $slug = $plugin['slug'];
474        } elseif ( is_object( $plugin ) && ! empty( $plugin->slug ) ) {
475            $slug = $plugin->slug;
476        } else {
477            return false;
478        }
479
480        return ! in_array( $slug, array( 'jetpack' ), true );
481    }
482
483    /**
484     * Take a raw search query and return something a bit more standardized and
485     * easy to work with.
486     *
487     * @param  string $term The raw search term.
488     * @return string A simplified/sanitized version.
489     */
490    private function sanitize_search_term( $term ) {
491        $term = strtolower( urldecode( $term ) );
492
493        // remove non-alpha/space chars.
494        $term = preg_replace( '/[^a-z ]/', '', $term );
495
496        // remove strings that don't help matches.
497        $term = trim( str_replace( array( 'jetpack', 'jp', 'free', 'wordpress' ), '', $term ) );
498
499        return $term;
500    }
501
502    /**
503     * Callback function to sort the array of modules by the sort option.
504     *
505     * @param array $m1 Array 1 to sort.
506     * @param array $m2 Array 2 to sort.
507     */
508    private function by_sorting_option( $m1, $m2 ) {
509        return $m1['sort'] <=> $m2['sort'];
510    }
511
512    /**
513     * Modify the URL to the feature settings, for example Publicize.
514     * Sharing is included here because while we still have a page in WP Admin,
515     * we prefer to send users to Calypso.
516     *
517     * @param string $feature Feature.
518     * @param string $configure_url URL to configure feature.
519     *
520     * @return string
521     * @since 7.1.0
522     */
523    private function get_configure_url( $feature, $configure_url ) {
524        switch ( $feature ) {
525            case 'sharing':
526            case 'publicize':
527                $configure_url = Redirect::get_url( 'calypso-marketing-connections' );
528                break;
529            case 'seo-tools':
530                // Jetpack SEO has its own wp-admin page (the new SEO home base);
531                // send users there rather than the legacy Traffic page.
532                $configure_url = admin_url( 'admin.php?page=jetpack-seo' );
533                break;
534            case 'google-analytics':
535                $configure_url = Redirect::get_url(
536                    'calypso-marketing-traffic',
537                    array(
538                        'anchor' => 'analytics',
539                    )
540                );
541                break;
542            case 'wordads':
543                $configure_url = Redirect::get_url( 'wpcom-ads-settings' );
544                break;
545        }
546        return $configure_url;
547    }
548
549    /**
550     * Put some more appropriate links on our custom result cards.
551     *
552     * @param array $links Related links.
553     * @param array $plugin Plugin result information.
554     */
555    public function insert_module_related_links( $links, $plugin ) {
556        if ( self::$slug !== $plugin['slug'] ) {
557            return $links;
558        }
559
560        // By the time this filter is applied, self_admin_url was already applied and we don't need it anymore.
561        remove_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
562
563        $links = array();
564
565        if ( 'sharing-block' === $plugin['module'] ) {
566            $links['jp_get_started'] = '<a
567                id="plugin-select-settings"
568                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
569                href="' . esc_url( admin_url( 'site-editor.php?path=%2Fwp_template' ) ) . '"
570                data-module="' . esc_attr( $plugin['module'] ) . '"
571                data-track="get_started"
572                >' . esc_html__( 'Add block', 'jetpack' ) . '</a>';
573        } elseif ( 'akismet' === $plugin['module'] || 'vaultpress' === $plugin['module'] ) {
574            $links['jp_get_started'] = '<a
575                id="plugin-select-settings"
576                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
577                href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
578                target="_blank"
579                data-module="' . esc_attr( $plugin['module'] ) . '"
580                data-track="get_started"
581                >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
582            // Jetpack installed, active, feature not enabled; prompt to enable.
583        } elseif (
584            current_user_can( 'jetpack_activate_modules' ) &&
585            ! Jetpack::is_module_active( $plugin['module'] ) &&
586            Jetpack_Plan::supports( $plugin['module'] )
587        ) {
588            $links[] = '<button
589                    id="plugin-select-activate"
590                    class="jetpack-plugin-search__primary button"
591                    data-module="' . esc_attr( $plugin['module'] ) . '"
592                    data-configure-url="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
593                    > ' . esc_html__( 'Enable', 'jetpack' ) . '</button>';
594
595            // Jetpack installed, active, feature enabled; link to settings.
596        } elseif (
597            ! empty( $plugin['configure_url'] ) &&
598            current_user_can( 'jetpack_configure_modules' ) &&
599            Jetpack::is_module_active( $plugin['module'] ) &&
600            /** This filter is documented in class.jetpack-admin.php */
601            apply_filters( 'jetpack_module_configurable_' . $plugin['module'], false )
602        ) {
603            $links[] = '<a
604                id="plugin-select-settings"
605                class="jetpack-plugin-search__primary button jetpack-plugin-search__configure"
606                href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
607                data-module="' . esc_attr( $plugin['module'] ) . '"
608                data-track="configure"
609                >' . esc_html__( 'Configure', 'jetpack' ) . '</a>';
610            // Module is active, doesn't have options to configure.
611        } elseif ( 'seo-tools' === $plugin['module'] && Jetpack::is_module_active( $plugin['module'] ) ) {
612            // Jetpack SEO has its own wp-admin page; send users there (same tab)
613            // rather than a jetpack.com doc redirect, which isn't registered for
614            // this module. Reuse get_configure_url() so the destination stays in
615            // one place.
616            $links['jp_get_started'] = '<a
617                id="plugin-select-settings"
618                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
619                href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
620                data-module="' . esc_attr( $plugin['module'] ) . '"
621                data-track="get_started"
622                >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
623        } elseif ( Jetpack::is_module_active( $plugin['module'] ) ) {
624            $links['jp_get_started'] = '<a
625                id="plugin-select-settings"
626                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
627                href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
628                target="_blank"
629                data-module="' . esc_attr( $plugin['module'] ) . '"
630                data-track="get_started"
631                >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
632        }
633
634        // Add link pointing to a relevant doc page in jetpack.com only if the Get started button isn't displayed.
635        if ( ! empty( $plugin['learn_more_button'] ) && ! isset( $links['jp_get_started'] ) ) {
636            $links[] = '<a
637                class="jetpack-plugin-search__learn-more"
638                href="' . esc_url( $plugin['learn_more_button'] ) . '"
639                target="_blank"
640                data-module="' . esc_attr( $plugin['module'] ) . '"
641                data-track="learn_more"
642                >' . esc_html__( 'Learn more', 'jetpack' ) . '</a>';
643        }
644
645        // Dismiss link.
646        $links[] = '<a
647            class="jetpack-plugin-search__dismiss"
648            data-module="' . esc_attr( $plugin['module'] ) . '"
649            >' . esc_html__( 'Hide this suggestion', 'jetpack' ) . '</a>';
650
651        return $links;
652    }
653}
654
655/**
656 * Master control that checks if Plugin search hints is active.
657 *
658 * @since 7.1.1
659 *
660 * @return bool True if PSH is active.
661 */
662function jetpack_is_psh_active() {
663    /**
664     * Disables the Plugin Search Hints feature found when searching the plugins page.
665     *
666     * @since 8.7.0
667     *
668     * @param bool Set false to disable the feature.
669     */
670    return apply_filters( 'jetpack_psh_active', true );
671}