PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | modules/plugin-search.php +683 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,683 @@
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 +
12 +use Automattic\Jetpack\Constants;
13 +use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
14 +use Automattic\Jetpack\Redirect;
15 +use Automattic\Jetpack\Tracking;
16 +
17 +// Disable direct access and execution.
18 +if ( ! defined( 'ABSPATH' ) ) {
19 + exit( 0 );
20 +}
21 +
22 +if (
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.
35 +add_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 + */
43 +class 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' => $this->get_sharing_block_editor_url(),
323 + ),
324 + );
325 + }
326 +
327 + /**
328 + * Site Editor URL for the template the Sharing Buttons block goes in.
329 + *
330 + * @return string
331 + */
332 + private function get_sharing_block_editor_url() {
333 + return admin_url(
334 + 'site-editor.php?p=' . rawurlencode( '/wp_template/' . get_stylesheet() . '//single' ) . '&canvas=edit'
335 + );
336 + }
337 +
338 + /**
339 + * Intercept the plugins API response and add in an appropriate card for Jetpack
340 + *
341 + * @param object $result Plugin search results.
342 + * @param string $action unused.
343 + * @param object $args Search args.
344 + */
345 + public function inject_jetpack_module_suggestion( $result, $action, $args ) {
346 + /*
347 + * Bail if something else hooks into the Plugins' API response
348 + * and does not return results.
349 + */
350 + if ( empty( $result->plugins ) || is_wp_error( $result ) ) {
351 + return $result;
352 + }
353 +
354 + // Looks like a search query; it's matching time.
355 + if ( ! empty( $args->search ) ) {
356 + $searchable_modules = array(
357 + 'contact-form',
358 + 'monitor',
359 + 'photon',
360 + 'photon-cdn',
361 + 'protect',
362 + 'publicize',
363 + 'related-posts',
364 + 'akismet',
365 + 'vaultpress',
366 + 'videopress',
367 + 'search',
368 + );
369 +
370 + /*
371 + * Let's handle the Sharing feature differently.
372 + * If we're using a block-based theme, we should suggest the sharing block.
373 + * If using a classic theme, we should suggest the old sharing module.
374 + */
375 + if ( wp_is_block_theme() ) {
376 + $searchable_modules[] = 'sharing-block';
377 + } else {
378 + $searchable_modules[] = 'sharedaddy';
379 + }
380 +
381 + /*
382 + * Only surface the SEO Tools hint when the new Jetpack SEO admin page is
383 + * available and registered, so its CTA never points to a missing page.
384 + */
385 + if (
386 + \Automattic\Jetpack\SEO\Initializer::is_available()
387 + && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
388 + ) {
389 + $searchable_modules[] = 'seo-tools';
390 + }
391 +
392 + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
393 + $tracking = new Tracking();
394 + $jetpack_modules_list = array_intersect_key(
395 + array_merge( $this->get_extra_features(), Jetpack_Admin::init()->get_modules() ),
396 + array_flip( $searchable_modules )
397 + );
398 + uasort( $jetpack_modules_list, array( $this, 'by_sorting_option' ) );
399 +
400 + // Record event when user searches for a term over 3 chars (less than 3 is not very useful).
401 + if ( strlen( $args->search ) >= 3 ) {
402 + $tracking->record_user_event( 'wpa_plugin_search_term', array( 'search_term' => $args->search ) );
403 + }
404 +
405 + // Lowercase, trim, remove punctuation/special chars, decode url, remove 'jetpack'.
406 + $normalized_term = $this->sanitize_search_term( $args->search );
407 +
408 + $matching_module = null;
409 +
410 + // Try to match a passed search term with module's search terms.
411 + foreach ( $jetpack_modules_list as $module_slug => $module_opts ) {
412 + /*
413 + * Does the site's current plan support the feature?
414 + * We don't use Jetpack_Plan::supports() here because
415 + * that check always returns Akismet as supported,
416 + * since Akismet has a free version.
417 + */
418 + $current_plan = Jetpack_Plan::get();
419 + $is_supported_by_plan = in_array( $module_slug, $current_plan['supports'], true );
420 +
421 + if (
422 + false !== stripos( $module_opts['search_terms'] . ', ' . $module_opts['name'], $normalized_term )
423 + && $is_supported_by_plan
424 + ) {
425 + $matching_module = $module_slug;
426 + break;
427 + }
428 + }
429 +
430 + if ( isset( $matching_module ) && $this->should_display_hint( $matching_module ) ) {
431 + // Record event when a matching feature is found.
432 + $tracking->record_user_event( 'wpa_plugin_search_match_found', array( 'feature' => $matching_module ) );
433 +
434 + $inject = (array) self::get_jetpack_plugin_data();
435 + $image_url = plugins_url( 'modules/plugin-search/psh', JETPACK__PLUGIN_FILE );
436 + $overrides = array(
437 + 'plugin-search' => true, // Helps to determine if that an injected card.
438 + 'name' => sprintf( // Supplement name/description so that they clearly indicate this was added.
439 + /* translators: Jetpack module name */
440 + esc_html_x( 'Jetpack: %s', 'Jetpack: Module Name', 'jetpack' ),
441 + $jetpack_modules_list[ $matching_module ]['name']
442 + ),
443 + 'short_description' => $jetpack_modules_list[ $matching_module ]['short_description'],
444 + 'author' => esc_attr__( 'Jetpack (installed)', 'jetpack' ),
445 + 'requires_connection' => (bool) $jetpack_modules_list[ $matching_module ]['requires_connection'],
446 + 'slug' => self::$slug,
447 + 'version' => JETPACK__VERSION,
448 + 'icons' => array(
449 + '1x' => "$image_url-128.png",
450 + '2x' => "$image_url-256.png",
451 + 'svg' => "$image_url.svg",
452 + ),
453 + );
454 +
455 + // Splice in the base module data.
456 + $inject = array_merge( $inject, $jetpack_modules_list[ $matching_module ], $overrides );
457 +
458 + // Add it to the top of the list.
459 + $result->plugins = array_filter( $result->plugins, array( $this, 'filter_cards' ) );
460 + array_unshift( $result->plugins, $inject );
461 + }
462 + }
463 + return $result;
464 + }
465 +
466 + /**
467 + * Remove cards for Jetpack plugins since we don't want duplicates.
468 + *
469 + * @since 7.1.0
470 + * @since 7.2.0 Only remove Jetpack.
471 + * @since 7.4.0 Simplify for WordPress 5.1+.
472 + *
473 + * @param array|object $plugin WordPress search result card.
474 + *
475 + * @return bool
476 + */
477 + public function filter_cards( $plugin ) {
478 + /*
479 + * $plugin is normally an array.
480 + * However, since the response data can be filtered,
481 + * we cannot fully trust its format.
482 + * Let's handle both arrays and objects, and bail if it's neither.
483 + */
484 + if ( is_array( $plugin ) && ! empty( $plugin['slug'] ) ) {
485 + $slug = $plugin['slug'];
486 + } elseif ( is_object( $plugin ) && ! empty( $plugin->slug ) ) {
487 + $slug = $plugin->slug;
488 + } else {
489 + return false;
490 + }
491 +
492 + return ! in_array( $slug, array( 'jetpack' ), true );
493 + }
494 +
495 + /**
496 + * Take a raw search query and return something a bit more standardized and
497 + * easy to work with.
498 + *
499 + * @param string $term The raw search term.
500 + * @return string A simplified/sanitized version.
501 + */
502 + private function sanitize_search_term( $term ) {
503 + $term = strtolower( urldecode( $term ) );
504 +
505 + // remove non-alpha/space chars.
506 + $term = preg_replace( '/[^a-z ]/', '', $term );
507 +
508 + // remove strings that don't help matches.
509 + $term = trim( str_replace( array( 'jetpack', 'jp', 'free', 'wordpress' ), '', $term ) );
510 +
511 + return $term;
512 + }
513 +
514 + /**
515 + * Callback function to sort the array of modules by the sort option.
516 + *
517 + * @param array $m1 Array 1 to sort.
518 + * @param array $m2 Array 2 to sort.
519 + */
520 + private function by_sorting_option( $m1, $m2 ) {
521 + return $m1['sort'] <=> $m2['sort'];
522 + }
523 +
524 + /**
525 + * Modify the URL to the feature settings, for example Publicize.
526 + * Sharing is included here because while we still have a page in WP Admin,
527 + * we prefer to send users to Calypso.
528 + *
529 + * @param string $feature Feature.
530 + * @param string $configure_url URL to configure feature.
531 + *
532 + * @return string
533 + * @since 7.1.0
534 + */
535 + private function get_configure_url( $feature, $configure_url ) {
536 + switch ( $feature ) {
537 + case 'sharing':
538 + case 'publicize':
539 + $configure_url = Redirect::get_url( 'calypso-marketing-connections' );
540 + break;
541 + case 'seo-tools':
542 + // Jetpack SEO has its own wp-admin page (the new SEO home base);
543 + // send users there rather than the legacy Traffic page.
544 + $configure_url = admin_url( 'admin.php?page=jetpack-seo' );
545 + break;
546 + case 'google-analytics':
547 + $configure_url = Redirect::get_url(
548 + 'calypso-marketing-traffic',
549 + array(
550 + 'anchor' => 'analytics',
551 + )
552 + );
553 + break;
554 + case 'wordads':
555 + $configure_url = Redirect::get_url( 'wpcom-ads-settings' );
556 + break;
557 + }
558 + return $configure_url;
559 + }
560 +
561 + /**
562 + * Put some more appropriate links on our custom result cards.
563 + *
564 + * @param array $links Related links.
565 + * @param array $plugin Plugin result information.
566 + */
567 + public function insert_module_related_links( $links, $plugin ) {
568 + if ( self::$slug !== $plugin['slug'] ) {
569 + return $links;
570 + }
571 +
572 + // By the time this filter is applied, self_admin_url was already applied and we don't need it anymore.
573 + remove_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
574 +
575 + $links = array();
576 +
577 + if ( 'sharing-block' === $plugin['module'] ) {
578 + $links['jp_get_started'] = '<a
579 + id="plugin-select-settings"
580 + class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
581 + href="' . esc_url( $this->get_sharing_block_editor_url() ) . '"
582 + data-module="' . esc_attr( $plugin['module'] ) . '"
583 + data-track="get_started"
584 + >' . esc_html__( 'Add block', 'jetpack' ) . '</a>';
585 + } elseif ( 'akismet' === $plugin['module'] || 'vaultpress' === $plugin['module'] ) {
586 + $links['jp_get_started'] = '<a
587 + id="plugin-select-settings"
588 + class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
589 + href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
590 + target="_blank"
591 + data-module="' . esc_attr( $plugin['module'] ) . '"
592 + data-track="get_started"
593 + >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
594 + // Jetpack installed, active, feature not enabled; prompt to enable.
595 + } elseif (
596 + current_user_can( 'jetpack_activate_modules' ) &&
597 + ! Jetpack::is_module_active( $plugin['module'] ) &&
598 + Jetpack_Plan::supports( $plugin['module'] )
599 + ) {
600 + $links[] = '<button
601 + id="plugin-select-activate"
602 + class="jetpack-plugin-search__primary button"
603 + data-module="' . esc_attr( $plugin['module'] ) . '"
604 + data-configure-url="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
605 + > ' . esc_html__( 'Enable', 'jetpack' ) . '</button>';
606 +
607 + // Jetpack installed, active, feature enabled; link to settings.
608 + } elseif (
609 + ! empty( $plugin['configure_url'] ) &&
610 + current_user_can( 'jetpack_configure_modules' ) &&
611 + Jetpack::is_module_active( $plugin['module'] ) &&
612 + /** This filter is documented in class.jetpack-admin.php */
613 + apply_filters( 'jetpack_module_configurable_' . $plugin['module'], false )
614 + ) {
615 + $links[] = '<a
616 + id="plugin-select-settings"
617 + class="jetpack-plugin-search__primary button jetpack-plugin-search__configure"
618 + href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
619 + data-module="' . esc_attr( $plugin['module'] ) . '"
620 + data-track="configure"
621 + >' . esc_html__( 'Configure', 'jetpack' ) . '</a>';
622 + // Module is active, doesn't have options to configure.
623 + } elseif ( 'seo-tools' === $plugin['module'] && Jetpack::is_module_active( $plugin['module'] ) ) {
624 + // Jetpack SEO has its own wp-admin page; send users there (same tab)
625 + // rather than a jetpack.com doc redirect, which isn't registered for
626 + // this module. Reuse get_configure_url() so the destination stays in
627 + // one place.
628 + $links['jp_get_started'] = '<a
629 + id="plugin-select-settings"
630 + class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
631 + href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
632 + data-module="' . esc_attr( $plugin['module'] ) . '"
633 + data-track="get_started"
634 + >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
635 + } elseif ( Jetpack::is_module_active( $plugin['module'] ) ) {
636 + $links['jp_get_started'] = '<a
637 + id="plugin-select-settings"
638 + class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
639 + href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
640 + target="_blank"
641 + data-module="' . esc_attr( $plugin['module'] ) . '"
642 + data-track="get_started"
643 + >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
644 + }
645 +
646 + // Add link pointing to a relevant doc page in jetpack.com only if the Get started button isn't displayed.
647 + if ( ! empty( $plugin['learn_more_button'] ) && ! isset( $links['jp_get_started'] ) ) {
648 + $links[] = '<a
649 + class="jetpack-plugin-search__learn-more"
650 + href="' . esc_url( $plugin['learn_more_button'] ) . '"
651 + target="_blank"
652 + data-module="' . esc_attr( $plugin['module'] ) . '"
653 + data-track="learn_more"
654 + >' . esc_html__( 'Learn more', 'jetpack' ) . '</a>';
655 + }
656 +
657 + // Dismiss link.
658 + $links[] = '<a
659 + class="jetpack-plugin-search__dismiss"
660 + data-module="' . esc_attr( $plugin['module'] ) . '"
661 + >' . esc_html__( 'Hide this suggestion', 'jetpack' ) . '</a>';
662 +
663 + return $links;
664 + }
665 +}
666 +
667 +/**
668 + * Master control that checks if Plugin search hints is active.
669 + *
670 + * @since 7.1.1
671 + *
672 + * @return bool True if PSH is active.
673 + */
674 +function jetpack_is_psh_active() {
675 + /**
676 + * Disables the Plugin Search Hints feature found when searching the plugins page.
677 + *
678 + * @since 8.7.0
679 + *
680 + * @param bool Set false to disable the feature.
681 + */
682 + return apply_filters( 'jetpack_psh_active', true );
683 +}