Completed
Push — add/plugin-search-hints ( eeaec1...3fb6fa )
by
unknown
06:37
created

Jetpack_Plugin_Search   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 436
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Importance

Changes 0
Metric Value
dl 0
loc 436
rs 5.5199
c 0
b 0
f 0
wmc 56
lcom 1
cbo 5

21 Methods

Rating   Name   Duplication   Size   Complexity  
A init() 0 10 2
A __construct() 0 3 1
A start() 0 8 4
A plugin_details() 0 5 2
A register_endpoints() 0 15 1
A can_request() 0 3 1
A is_hint_id() 0 5 2
A dismiss() 0 5 2
A get_dismissed_hints() 0 6 3
A add_to_dismissed_hints() 0 3 1
A is_not_dismissed() 0 3 1
A load_plugins_search_script() 0 24 1
A get_jetpack_plugin_data() 0 21 3
A get_extra_features() 0 13 1
B inject_jetpack_module_suggestion() 0 57 6
A sanitize_search_term() 0 11 1
A track_search_term() 0 3 1
A by_sorting_option() 0 3 1
A get_upgrade_url() 0 7 2
B get_configure_url() 0 20 6
C insert_module_related_links() 0 52 14

How to fix   Complexity   

Complex Class

Complex classes like Jetpack_Plugin_Search often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Jetpack_Plugin_Search, and based on these observations, apply Extract Interface, too.

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