Completed
Push — enhance/9539 ( 3ba0c7 )
by
unknown
07:24
created

Jetpack_Plugin_Search::get_extra_features()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 14
rs 9.7998
c 0
b 0
f 0
1
<?php
2
/**
3
 * Disable direct access and execution.
4
 */
5
if ( ! defined( 'ABSPATH' ) ) {
6
	exit;
7
}
8
9
if (
10
	is_admin() &&
11
	Jetpack::is_active() &&
12
	/** This filter is documented in _inc/lib/admin-pages/class.jetpack-react-page.php */
13
	apply_filters( 'jetpack_show_promotions', true )
14
) {
15
	Jetpack_Plugin_Search::init();
16
}
17
18
// Register endpoints when WP REST API is initialized.
19
add_action( 'rest_api_init', array( 'Jetpack_Plugin_Search', 'register_endpoints' ) );
20
21
/**
22
 * Class that includes cards in the plugin search results when users enter terms that match some Jetpack feature.
23
 * Card can be dismissed and includes a title, description, button to enable the feature and a link for more information.
24
 *
25
 * @since 7.1.0
26
 */
27
class Jetpack_Plugin_Search {
28
29
	static $slug = 'jetpack-plugin-search';
30
31
	public static function init() {
32
		static $instance = null;
33
34
		if ( ! $instance ) {
35
			jetpack_require_lib( 'tracks/client' );
36
			$instance = new Jetpack_Plugin_Search();
37
		}
38
39
		return $instance;
40
	}
41
42
	public function __construct() {
43
		add_action( 'current_screen', array( $this, 'start' ) );
44
	}
45
46
	/**
47
	 * Add actions and filters only if this is the plugin installation screen and it's the first page.
48
	 *
49
	 * @param object $screen
50
	 *
51
	 * @since 7.1.0
52
	 */
53
	public function start( $screen ) {
54
		if ( 'plugin-install' === $screen->base && ( ! isset( $_GET['paged'] ) || 1 == $_GET['paged'] ) ) {
55
			add_action( 'admin_enqueue_scripts', array( $this, 'load_plugins_search_script' ) );
56
			add_filter( 'plugins_api_result', array( $this, 'inject_jetpack_module_suggestion' ), 10, 3 );
57
			add_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
58
			add_filter( 'plugin_install_action_links', array( $this, 'insert_module_related_links' ), 10, 2 );
59
		}
60
	}
61
62
	/**
63
	 * Modify URL used to fetch to plugin information so it pulls Jetpack plugin page.
64
	 *
65
	 * @param string $url URL to load in dialog pulling the plugin page from wporg.
66
	 *
67
	 * @since 7.1.0
68
	 *
69
	 * @return string The URL with 'jetpack' instead of 'jetpack-plugin-search'.
70
	 */
71
	public function plugin_details( $url ) {
72
		return false !== stripos( $url, 'tab=plugin-information&amp;plugin=' . self::$slug )
73
			? 'plugin-install.php?tab=plugin-information&amp;plugin=jetpack&amp;TB_iframe=true&amp;width=600&amp;height=550'
74
			: $url;
75
	}
76
77
	/**
78
	 * Register REST API endpoints.
79
	 *
80
	 * @since 7.1.0
81
	 */
82
	public static function register_endpoints() {
83
		register_rest_route( 'jetpack/v4', '/hints', array(
84
			'methods' => WP_REST_Server::EDITABLE,
85
			'callback' => __CLASS__ . '::dismiss',
86
			'permission_callback' => __CLASS__ . '::can_request',
87
			'args' => array(
88
				'hint' => array(
89
					'default'           => '',
90
					'type'              => 'string',
91
					'required'          => true,
92
					'validate_callback' => __CLASS__ . '::is_hint_id',
93
				),
94
			)
95
		) );
96
	}
97
98
	/**
99
	 * A WordPress REST API permission callback method that accepts a request object and
100
	 * decides if the current user has enough privileges to act.
101
	 *
102
	 * @since 7.1.0
103
	 *
104
	 * @return bool does a current user have enough privileges.
105
	 */
106
	public static function can_request() {
107
		return current_user_can( 'jetpack_admin_page' );
108
	}
109
110
	/**
111
	 * Validates that the ID of the hint to dismiss is a string.
112
	 *
113
	 * @since 7.1.0
114
	 *
115
	 * @param string|bool $value Value to check.
116
	 * @param WP_REST_Request $request The request sent to the WP REST API.
117
	 * @param string $param Name of the parameter passed to endpoint holding $value.
118
	 *
119
	 * @return bool|WP_Error
120
	 */
121
	public static function is_hint_id( $value, $request, $param ) {
122
		return in_array( $value, Jetpack::get_available_modules(), true )
123
			? true
124
			: new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an alphanumeric string.', 'jetpack' ), $param ) );
125
	}
126
127
	/**
128
	 * A WordPress REST API callback method that accepts a request object and decides what to do with it.
129
	 *
130
	 * @param WP_REST_Request $request {
131
	 *     Array of parameters received by request.
132
	 *
133
	 *     @type string $hint Slug of card to dismiss.
134
	 * }
135
	 *
136
	 * @since 7.1.0
137
	 *
138
	 * @return bool|array|WP_Error a resulting value or object, or an error.
139
	 */
140
	public static function dismiss( WP_REST_Request $request ) {
141
		return self::add_to_dismissed_hints( $request['hint'] )
142
			? rest_ensure_response( array( 'code' => 'success' ) )
143
			: new WP_Error( 'not_dismissed', esc_html__( 'The card could not be dismissed', 'jetpack' ), array( 'status' => 400 ) );
144
	}
145
146
	/**
147
	 * Returns a list of previously dismissed hints.
148
	 *
149
	 * @since 7.1.0
150
	 *
151
	 * @return array List of dismissed hints.
152
	 */
153
	protected static function get_dismissed_hints() {
154
		$dismissed_hints = Jetpack_Options::get_option( 'dismissed_hints' );
155
		return isset( $dismissed_hints ) && is_array( $dismissed_hints )
156
			? $dismissed_hints
157
			: array();
158
	}
159
160
	/**
161
	 * Save the hint in the list of dismissed hints.
162
	 *
163
	 * @since 7.1.0
164
	 *
165
	 * @param string $hint The hint id, which is a Jetpack module slug.
166
	 *
167
	 * @return bool Whether the card was added to the list and hence dismissed.
168
	 */
169
	protected static function add_to_dismissed_hints( $hint ) {
170
		return Jetpack_Options::update_option( 'dismissed_hints', array_merge( self::get_dismissed_hints(), array( $hint ) ) );
171
	}
172
173
	/**
174
	 * Checks that the module slug passed, the hint, is not in the list of previously dismissed hints.
175
	 *
176
	 * @since 7.1.0
177
	 *
178
	 * @param string $hint The hint id, which is a Jetpack module slug.
179
	 *
180
	 * @return bool True if $hint wasn't already dismissed.
181
	 */
182
	protected function is_not_dismissed( $hint ) {
183
		return ! in_array( $hint, $this->get_dismissed_hints(), true );
184
	}
185
186
	public function load_plugins_search_script() {
187
		wp_enqueue_script( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.js', JETPACK__PLUGIN_FILE ), array( 'jquery' ), JETPACK__VERSION, true );
188
		wp_localize_script(
189
			self::$slug,
190
			'jetpackPluginSearch',
191
			array(
192
				'nonce'          => wp_create_nonce( 'wp_rest' ),
193
				'base_rest_url'  => rest_url( '/jetpack/v4' ),
194
				'manageSettings' => esc_html__( 'Configure', 'jetpack' ),
195
				'activateModule' => esc_html__( 'Activate Module', 'jetpack' ),
196
				'getStarted'     => esc_html__( 'Get started', 'jetpack' ),
197
				'activated'      => esc_html__( 'Activated', 'jetpack' ),
198
				'activating'     => esc_html__( 'Activating', 'jetpack' ),
199
				'logo'           => 'https://ps.w.org/jetpack/assets/icon.svg?rev=1791404',
200
				'legend'         => esc_html__(
201
					'Jetpack is trusted by millions to help secure and speed up their WordPress site. Make the most of it today.',
202
					'jetpack'
203
				),
204
				'hideText'       => esc_html__( 'Hide this suggestion', 'jetpack' ),
205
			)
206
		);
207
208
		wp_enqueue_style( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.css', JETPACK__PLUGIN_FILE ) );
209
	}
210
211
	/**
212
	 * Get the plugin repo's data for Jetpack to populate the fields with.
213
	 *
214
	 * @return array|mixed|object|WP_Error
215
	 */
216
	public static function get_jetpack_plugin_data() {
217
		$data = get_transient( 'jetpack_plugin_data' );
218
219
		if ( false === $data || is_wp_error( $data ) ) {
220
			include_once( ABSPATH . 'wp-admin/includes/plugin-install.php' );
221
			$data = plugins_api( 'plugin_information', array(
222
				'slug' => 'jetpack',
223
				'is_ssl' => is_ssl(),
224
				'fields' => array(
225
					'banners' => true,
226
					'reviews' => true,
227
					'active_installs' => true,
228
					'versions' => false,
229
					'sections' => false,
230
				),
231
			) );
232
			set_transient( 'jetpack_plugin_data', $data, DAY_IN_SECONDS );
233
		}
234
235
		return $data;
236
	}
237
238
	/**
239
	 * Create a list with additional features for those we don't have a module, like Akismet.
240
	 *
241
	 * @since 7.1.0
242
	 *
243
	 * @return array List of features.
244
	 */
245
	public function get_extra_features() {
246
		return array(
247
			'akismet' => array(
248
				'name' => 'Akismet',
249
				'search_terms' => 'akismet, anti-spam, antispam, comments, spam, spam protection, form spam, captcha, no captcha, nocaptcha, recaptcha, phising, google',
250
				'short_description' => esc_html__( 'Keep your visitors and search engines happy by stopping comment and contact form spam with Akismet.', 'jetpack' ),
251
				'requires_connection' => true,
252
				'module' => 'akismet',
253
				'sort' => '16',
254
				'learn_more_button' => 'https://jetpack.com/features/security/spam-filtering/',
255
				'configure_url' => admin_url( 'admin.php?page=akismet-key-config' ),
256
			),
257
		);
258
	}
259
260
	/**
261
	 * Intercept the plugins API response and add in an appropriate card for Jetpack
262
	 */
263
	public function inject_jetpack_module_suggestion( $result, $action, $args ) {
264
265
		// Looks like a search query; it's matching time
266
		if ( ! empty( $args->search ) ) {
267
			require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
268
			$jetpack_modules_list = array_intersect_key(
269
				array_merge( $this->get_extra_features(), Jetpack_Admin::init()->get_modules() ),
270
				array_flip( array(
271
					'contact-form',
272
					'lazy-images',
273
					'monitor',
274
					'photon',
275
					'photon-cdn',
276
					'protect',
277
					'publicize',
278
					'related-posts',
279
					'sharedaddy',
280
					'akismet',
281
					'vaultpress',
282
					'videopress',
283
					'search',
284
				) )
285
			);
286
			usort( $jetpack_modules_list, array( $this, 'by_sorting_option' ) );
287
288
			// Record event when user searches for a term
289
			JetpackTracking::record_user_event( 'wpa_plugin_search_term', array( 'search_term' => $args->search ) );
290
291
			// Lowercase, trim, remove punctuation/special chars, decode url, remove 'jetpack'
292
			$normalized_term = $this->sanitize_search_term( $args->search );
293
294
			$matching_module = null;
295
296
			// Try to match a passed search term with module's search terms
297
			foreach ( $jetpack_modules_list as $module_slug => $module_opts ) {
298
				if ( false !== stripos( $module_opts['search_terms'] . ', ' . $module_opts['name'], $normalized_term ) ) {
299
					$matching_module = $module_slug;
300
					break;
301
				}
302
			}
303
304
			if ( isset( $matching_module ) && $this->is_not_dismissed( $jetpack_modules_list[ $matching_module ]['module'] ) ) {
305
				// Record event when a matching feature is found
306
				JetpackTracking::record_user_event( 'wpa_plugin_search_match_found', array( 'feature' => $matching_module ) );
307
308
				$inject = (array) self::get_jetpack_plugin_data();
309
				$image_url = plugins_url( 'modules/plugin-search/psh', JETPACK__PLUGIN_FILE );
310
				$overrides = array(
311
					'plugin-search' => true, // Helps to determine if that an injected card.
312
					'name' => sprintf(       // Supplement name/description so that they clearly indicate this was added.
313
						esc_html_x( 'Jetpack: %s', 'Jetpack: Module Name', 'jetpack' ),
314
						$jetpack_modules_list[ $matching_module ]['name']
315
					),
316
					'short_description' => $jetpack_modules_list[ $matching_module ]['short_description'],
317
					'requires_connection' => (bool) $jetpack_modules_list[ $matching_module ]['requires_connection'],
318
					'slug'    => self::$slug,
319
					'version' => JETPACK__VERSION,
320
					'icons' => array(
321
						'1x'  => "$image_url-128.png",
322
						'2x'  => "$image_url-256.png",
323
						'svg' => "$image_url.svg",
324
					),
325
				);
326
327
				// Splice in the base module data
328
				$inject = array_merge( $inject, $jetpack_modules_list[ $matching_module ], $overrides );
329
330
				// Add it to the top of the list
331
				$result->plugins = array( $inject ) + array_filter( $result->plugins, array( $this, 'filter_cards' ) );
332
			}
333
		}
334
		return $result;
335
	}
336
337
	/**
338
	 * Remove cards for Akismet, Jetpack and VaultPress plugins since we don't want duplicates.
339
	 *
340
	 * @param array $plugin
341
	 *
342
	 * @return bool
343
	 */
344
	function filter_cards( $plugin = array() ) {
345
		return ! in_array( $plugin['slug'], array( 'akismet', 'jetpack', 'vaultpress' ), true );
346
	}
347
348
	/**
349
	 * Take a raw search query and return something a bit more standardized and
350
	 * easy to work with.
351
	 *
352
	 * @param  String $term The raw search term
353
	 * @return String A simplified/sanitized version.
354
	 */
355
	private function sanitize_search_term( $term ) {
356
		$term = strtolower( urldecode( $term ) );
357
358
		// remove non-alpha/space chars.
359
		$term = preg_replace( '/[^a-z ]/', '', $term );
360
361
		// remove strings that don't help matches.
362
		$term = trim( str_replace( array( 'jetpack', 'jp', 'free', 'wordpress' ), '', $term ) );
363
364
		return $term;
365
	}
366
367
	/**
368
	 * Callback function to sort the array of modules by the sort option.
369
	 */
370
	private function by_sorting_option( $m1, $m2 ) {
371
		return $m1['sort'] - $m2['sort'];
372
	}
373
374
	/**
375
	 * Builds a URL to purchase and upgrade inserting the site fragment and the affiliate code if it exists.
376
	 *
377
	 * @param string $feature Module slug (or forged one for extra features).
378
	 *
379
	 * @since 7.1.0
380
	 *
381
	 * @return string URL to upgrade.
382
	 */
383
	private function get_upgrade_url( $feature ) {
384
		$site_raw_url = Jetpack::build_raw_urls( get_home_url() );
385
		$affiliateCode = Jetpack_Affiliate::init()->get_affiliate_code();
386
		$user = wp_get_current_user()->ID;
387
		return "https://jetpack.com/redirect/?source=plugin-hint-upgrade-$feature&site=$site_raw_url&u=$user" .
388
		       ( $affiliateCode ? "&aff=$affiliateCode" : '' );
389
	}
390
391
	/**
392
	 * Modify the URL to the feature settings, for example Publicize.
393
	 * Sharing is included here because while we still have a page in WP Admin,
394
	 * we prefer to send users to Calypso.
395
	 *
396
	 * @param string $feature
397
	 * @param string $configure_url
398
	 *
399
	 * @return string
400
	 * @since 7.1.0
401
	 *
402
	 */
403
	private function get_configure_url( $feature, $configure_url ) {
404
		$siteFragment = Jetpack::build_raw_urls( get_home_url() );
405
		switch ( $feature ) {
406
			case 'sharing':
407
			case 'publicize':
408
				$configure_url = "https://wordpress.com/sharing/$siteFragment";
409
				break;
410
			case 'seo-tools':
411
				$configure_url = "https://wordpress.com/settings/traffic/$siteFragment#seo";
412
				break;
413
			case 'google-analytics':
414
				$configure_url = "https://wordpress.com/settings/traffic/$siteFragment#analytics";
415
				break;
416
			case 'wordads':
417
				$configure_url = "https://wordpress.com/ads/settings/$siteFragment";
418
				break;
419
		}
420
		return $configure_url;
421
	}
422
423
	/**
424
	 * Put some more appropriate links on our custom result cards.
425
	 */
426
	public function insert_module_related_links( $links, $plugin ) {
427
		if ( self::$slug !== $plugin['slug'] ) {
428
			return $links;
429
		}
430
431
		// By the time this filter is applied, self_admin_url was already applied and we don't need it anymore.
432
		remove_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
433
434
		$links = array();
435
436
		if ( 'akismet' === $plugin['module'] || 'vaultpress' === $plugin['module'] ) {
437
			$links['jp_get_started'] = '<a
438
				id="plugin-select-settings"
439
				class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
440
				href="https://jetpack.com/redirect/?source=plugin-hint-learn-' . $plugin['module'] . '"
441
				data-module="' . esc_attr( $plugin['module'] ) . '"
442
				data-track="get_started"
443
				>' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
444
			// Jetpack installed, active, feature not enabled; prompt to enable.
445
		} elseif (
446
			current_user_can( 'jetpack_activate_modules' ) &&
447
			! Jetpack::is_module_active( $plugin['module'] )
448
		) {
449
			$links[] = Jetpack::active_plan_supports( $plugin['module'] )
450
				? '<button
451
					id="plugin-select-activate"
452
					class="jetpack-plugin-search__primary button"
453
					data-module="' . esc_attr( $plugin['module'] ) . '"
454
					data-configure-url="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
455
					> ' . esc_html__( 'Enable', 'jetpack' ) . '</button>'
456
				: '<a
457
					class="jetpack-plugin-search__primary button"
458
					href="' . esc_url( $this->get_upgrade_url( $plugin['module'] ) ) . '"
459
					data-module="' . esc_attr( $plugin['module'] ) . '"
460
					data-track="purchase"
461
					> ' . esc_html__( 'Purchase', 'jetpack' ) . '</button>';
462
463
			// Jetpack installed, active, feature enabled; link to settings.
464
		} elseif (
465
			! empty( $plugin['configure_url'] ) &&
466
			current_user_can( 'jetpack_configure_modules' ) &&
467
			Jetpack::is_module_active( $plugin['module'] ) &&
468
			/** This filter is documented in class.jetpack-admin.php */
469
			apply_filters( 'jetpack_module_configurable_' . $plugin['module'], false )
470
		) {
471
			$links[] = '<a
472
				id="plugin-select-settings"
473
				class="jetpack-plugin-search__primary button jetpack-plugin-search__configure"
474
				href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
475
				data-module="' . esc_attr( $plugin['module'] ) . '"
476
				data-track="configure"
477
				>' . esc_html__( 'Configure', 'jetpack' ) . '</a>';
478
			// Module is active, doesn't have options to configure
479
		} elseif ( Jetpack::is_module_active( $plugin['module'] ) ) {
480
			$links['jp_get_started'] = '<a
481
				id="plugin-select-settings"
482
				class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
483
				href="https://jetpack.com/redirect/?source=plugin-hint-learn-' . $plugin['module'] . '"
484
				data-module="' . esc_attr( $plugin['module'] ) . '"
485
				data-track="get_started"
486
				>' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
487
		}
488
489
		// Add link pointing to a relevant doc page in jetpack.com only if the Get started button isn't displayed.
490
		if ( ! empty( $plugin['learn_more_button'] ) && ! isset( $links['jp_get_started'] ) ) {
491
			$links[] = '<a
492
				class="jetpack-plugin-search__learn-more"
493
				href="' . esc_url( $plugin['learn_more_button'] ) . '"
494
				target="_blank"
495
				data-module="' . esc_attr( $plugin['module'] ) . '"
496
				data-track="learn_more"
497
				>' . esc_html__( 'Learn more', 'jetpack' ) . '</a>';
498
		}
499
500
		// Dismiss link
501
		$links[] = '<a
502
			class="jetpack-plugin-search__dismiss"
503
			data-module="' . esc_attr( $plugin['module'] ) . '"
504
			>' . esc_html__( 'Hide this suggestion', 'jetpack' ) . '</a>';
505
506
		return $links;
507
	}
508
509
510
}
511