Completed
Push — add/carousel-lightbox-single-i... ( 204ac6...43c884 )
by
unknown
09:26
created

Jetpack_Display_Posts_Widget   D

Complexity

Total Complexity 112

Size/Duplication

Total Lines 1023
Duplicated Lines 8.02 %

Coupling/Cohesion

Components 1
Dependencies 1

Importance

Changes 0
Metric Value
dl 82
loc 1023
rs 4.436
c 0
b 0
f 0
wmc 112
lcom 1
cbo 1

27 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 2
A get_site_hash() 0 3 1
A fetch_service_endpoint() 0 14 2
B parse_service_response() 0 75 6
A fetch_site_info() 0 6 1
A parse_site_info_response() 22 22 3
A fetch_posts_for_site() 0 23 1
B parse_posts_response() 26 26 4
C format_posts_for_storage() 0 30 7
B fetch_blog_data() 34 95 5
A get_blog_data() 0 19 2
A activate_cron() 0 5 2
A deactivate_cron() 0 3 1
A deactivate_cron_static() 0 4 1
C should_cron_be_running() 0 31 8
B cron_task() 0 24 5
B get_instances_sites() 0 29 6
B update_instance() 0 26 2
D widget() 0 83 13
C extract_errors_from_blog_data() 0 71 8
A enqueue_scripts() 0 3 1
F form() 0 128 15
F update() 0 50 12
A wp_get_option() 0 3 1
A wp_add_option() 0 3 1
A wp_update_option() 0 3 1
A wp_wp_remote_get() 0 3 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Jetpack_Display_Posts_Widget 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_Display_Posts_Widget, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Plugin Name: Display Recent WordPress Posts Widget
4
 * Description: Displays recent posts from a WordPress.com or Jetpack-enabled self-hosted WordPress site.
5
 * Version: 1.0
6
 * Author: Brad Angelcyk, Kathryn Presner, Justin Shreve, Carolyn Sonnek
7
 * Author URI: http://automattic.com
8
 * License: GPL2
9
 */
10
11
/**
12
 * Disable direct access/execution to/of the widget code.
13
 */
14
if ( ! defined( 'ABSPATH' ) ) {
15
	exit;
16
}
17
18
add_action( 'widgets_init', 'jetpack_display_posts_widget' );
19
function jetpack_display_posts_widget() {
20
	register_widget( 'Jetpack_Display_Posts_Widget' );
21
}
22
23
24
/**
25
 * Cron tasks
26
 */
27
28
add_filter( 'cron_schedules', 'jetpack_display_posts_widget_cron_intervals' );
29
30
/**
31
 * Adds 10 minute running interval to the cron schedules.
32
 *
33
 * @param array $current_schedules Currently defined schedules list.
34
 *
35
 * @return array
36
 */
37 View Code Duplication
function jetpack_display_posts_widget_cron_intervals( $current_schedules ) {
0 ignored issues
show
Duplication introduced by
This function seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
38
39
	/**
40
	 * Only add the 10 minute interval if it wasn't already set.
41
	 */
42
	if ( ! isset( $current_schedules['minutes_10'] ) ) {
43
		$current_schedules['minutes_10'] = array(
44
			'interval' => 10 * MINUTE_IN_SECONDS,
45
			'display'  => 'Every 10 minutes'
46
		);
47
	}
48
49
	return $current_schedules;
50
}
51
52
/**
53
 * Execute the cron task
54
 */
55
add_action( 'jetpack_display_posts_widget_cron_update', 'jetpack_display_posts_update_cron_action' );
56
function jetpack_display_posts_update_cron_action() {
57
	$widget = new Jetpack_Display_Posts_Widget();
58
	$widget->cron_task();
59
}
60
61
/**
62
 * Handle activation procedures for the cron.
63
 *
64
 * `updating_jetpack_version` - Handle cron activation when Jetpack gets updated. It's here
65
 *                              to cover the first cron activation after the update.
66
 *
67
 * `jetpack_activate_module_widgets` - Activate the cron when the Extra Sidebar widgets are activated.
68
 *
69
 * `activated_plugin` - Activate the cron when Jetpack gets activated.
70
 *
71
 */
72
add_action( 'updating_jetpack_version', 'jetpack_display_posts_widget_conditionally_activate_cron' );
73
add_action( 'jetpack_activate_module_widgets', 'Jetpack_Display_Posts_Widget::activate_cron' );
74
add_action( 'activated_plugin', 'jetpack_conditionally_activate_cron_on_plugin_activation' );
75
76
/**
77
 * Executed when Jetpack gets activated. Tries to activate the cron if it is needed.
78
 *
79
 * @param string $plugin_file_name The plugin file that was activated.
80
 */
81
function jetpack_conditionally_activate_cron_on_plugin_activation( $plugin_file_name ) {
82
	if ( plugin_basename( JETPACK__PLUGIN_FILE ) === $plugin_file_name ) {
83
		jetpack_display_posts_widget_conditionally_activate_cron();
84
	}
85
}
86
87
/**
88
 * Activates the cron only when needed.
89
 * @see Jetpack_Display_Posts_Widget::should_cron_be_running
90
 */
91
function jetpack_display_posts_widget_conditionally_activate_cron() {
92
	$widget = new Jetpack_Display_Posts_Widget();
93
	if ( $widget->should_cron_be_running() ) {
94
		$widget->activate_cron();
95
	}
96
97
	unset( $widget );
98
}
99
100
/**
101
 * End of cron activation handling.
102
 */
103
104
105
/**
106
 * Handle deactivation procedures where they are needed.
107
 *
108
 * If Extra Sidebar Widgets module is deactivated, the cron is not needed.
109
 *
110
 * If Jetpack is deactivated, the cron is not needed.
111
 */
112
add_action( 'jetpack_deactivate_module_widgets', 'Jetpack_Display_Posts_Widget::deactivate_cron_static' );
113
register_deactivation_hook( plugin_basename( JETPACK__PLUGIN_FILE ), 'Jetpack_Display_Posts_Widget::deactivate_cron_static' );
114
115
/**
116
 * End of Cron tasks
117
 */
118
/*
119
 * Display a list of recent posts from a WordPress.com or Jetpack-enabled blog.
120
 */
121
122
class Jetpack_Display_Posts_Widget extends WP_Widget {
123
124
	/**
125
	 * @var string Remote service API URL prefix.
126
	 */
127
	public $service_url = 'https://public-api.wordpress.com/rest/v1.1/';
128
129
	/**
130
	 * @var string Widget options key prefix.
131
	 */
132
	public $widget_options_key_prefix = 'display_posts_site_data_';
133
134
	/**
135
	 * @var string The name of the cron that will update widget data.
136
	 */
137
	public static $cron_name = 'jetpack_display_posts_widget_cron_update';
138
139
140
	public function __construct() {
141
		parent::__construct(
142
		// internal id
143
			'jetpack_display_posts_widget',
144
			/** This filter is documented in modules/widgets/facebook-likebox.php */
145
			apply_filters( 'jetpack_widget_name', __( 'Display WordPress Posts', 'jetpack' ) ),
146
			array(
147
				'description' => __( 'Displays a list of recent posts from another WordPress.com or Jetpack-enabled blog.', 'jetpack' ),
148
				'customize_selective_refresh' => true,
149
			)
150
		);
151
152
		if ( is_customize_preview() ) {
153
			add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
154
		}
155
	}
156
157
	/**
158
	 * Expiring transients have a name length maximum of 45 characters,
159
	 * so this function returns an abbreviated MD5 hash to use instead of
160
	 * the full URI.
161
	 *
162
	 * @param string $site Site to get the hash for.
163
	 *
164
	 * @return string
165
	 */
166
	public function get_site_hash( $site ) {
167
		return substr( md5( $site ), 0, 21 );
168
	}
169
170
	/**
171
	 * Fetch a remote service endpoint and parse it.
172
	 *
173
	 * Timeout is set to 15 seconds right now, because sometimes the WordPress API
174
	 * takes more than 5 seconds to fully respond.
175
	 *
176
	 * Caching is used here so we can avoid re-downloading the same endpoint
177
	 * in a single request.
178
	 *
179
	 * @param string $endpoint Parametrized endpoint to call.
180
	 *
181
	 * @param int    $timeout  How much time to wait for the API to respond before failing.
182
	 *
183
	 * @return array|WP_Error
184
	 */
185
	public function fetch_service_endpoint( $endpoint, $timeout = 15 ) {
186
187
		/**
188
		 * Holds endpoint request cache.
189
		 */
190
		static $cache = array();
191
192
		if ( ! isset( $cache[ $endpoint ] ) ) {
193
			$raw_data           = $this->wp_wp_remote_get( $this->service_url . ltrim( $endpoint, '/' ), array( 'timeout' => $timeout ) );
194
			$cache[ $endpoint ] = $this->parse_service_response( $raw_data );
195
		}
196
197
		return $cache[ $endpoint ];
198
	}
199
200
	/**
201
	 * Parse data from service response.
202
	 * Do basic error handling for general service and data errors
203
	 *
204
	 * @param array $service_response Response from the service.
205
	 *
206
	 * @return array|WP_Error
207
	 */
208
	public function parse_service_response( $service_response ) {
209
		/**
210
		 * If there is an error, we add the error message to the parsed response
211
		 */
212
		if ( is_wp_error( $service_response ) ) {
213
			return new WP_Error(
214
				'general_error',
215
				__( 'An error occurred fetching the remote data.', 'jetpack' ),
216
				$service_response->get_error_messages()
0 ignored issues
show
Bug introduced by
The method get_error_messages cannot be called on $service_response (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
217
			);
218
		}
219
220
		/**
221
		 * Validate HTTP response code.
222
		 */
223
		if ( 200 !== wp_remote_retrieve_response_code( $service_response ) ) {
224
			return new WP_Error(
225
				'http_error',
226
				__( 'An error occurred fetching the remote data.', 'jetpack' ),
227
				wp_remote_retrieve_response_message( $service_response )
228
			);
229
		}
230
231
232
		/**
233
		 * Extract service response body from the request.
234
		 */
235
236
		$service_response_body = wp_remote_retrieve_body( $service_response );
237
238
239
		/**
240
		 * No body has been set in the response. This should be pretty bad.
241
		 */
242
		if ( ! $service_response_body ) {
243
			return new WP_Error(
244
				'no_body',
245
				__( 'Invalid remote response.', 'jetpack' ),
246
				'No body in response.'
247
			);
248
		}
249
250
		/**
251
		 * Parse the JSON response from the API. Convert to associative array.
252
		 */
253
		$parsed_data = json_decode( $service_response_body );
254
255
		/**
256
		 * If there is a problem with parsing the posts return an empty array.
257
		 */
258
		if ( is_null( $parsed_data ) ) {
259
			return new WP_Error(
260
				'no_body',
261
				__( 'Invalid remote response.', 'jetpack' ),
262
				'Invalid JSON from remote.'
263
			);
264
		}
265
266
		/**
267
		 * Check for errors in the parsed body.
268
		 */
269
		if ( isset( $parsed_data->error ) ) {
270
			return new WP_Error(
271
				'remote_error',
272
				__( 'We cannot display information for this blog.', 'jetpack' ),
273
				$parsed_data->error
274
			);
275
		}
276
277
278
		/**
279
		 * No errors found, return parsed data.
280
		 */
281
		return $parsed_data;
282
	}
283
284
	/**
285
	 * Fetch site information from the WordPress public API
286
	 *
287
	 * @param string $site URL of the site to fetch the information for.
288
	 *
289
	 * @return array|WP_Error
290
	 */
291
	public function fetch_site_info( $site ) {
292
293
		$response = $this->fetch_service_endpoint( sprintf( '/sites/%s', urlencode( $site ) ) );
294
295
		return $response;
296
	}
297
298
	/**
299
	 * Parse external API response from the site info call and handle errors if they occur.
300
	 *
301
	 * @param array|WP_Error $service_response The raw response to be parsed.
302
	 *
303
	 * @return array|WP_Error
304
	 */
305 View Code Duplication
	public function parse_site_info_response( $service_response ) {
306
307
		/**
308
		 * If the service returned an error, we pass it on.
309
		 */
310
		if ( is_wp_error( $service_response ) ) {
311
			return $service_response;
312
		}
313
314
		/**
315
		 * Check if the service returned proper site information.
316
		 */
317
		if ( ! isset( $service_response->ID ) ) {
318
			return new WP_Error(
319
				'no_site_info',
320
				__( 'Invalid site information returned from remote.', 'jetpack' ),
321
				'No site ID present in the response.'
322
			);
323
		}
324
325
		return $service_response;
326
	}
327
328
	/**
329
	 * Fetch list of posts from the WordPress public API.
330
	 *
331
	 * @param int $site_id The site to fetch the posts for.
332
	 *
333
	 * @return array|WP_Error
334
	 */
335
	public function fetch_posts_for_site( $site_id ) {
336
337
		$response = $this->fetch_service_endpoint(
338
			sprintf(
339
				'/sites/%1$d/posts/%2$s',
340
				$site_id,
341
				/**
342
				 * Filters the parameters used to fetch for posts in the Display Posts Widget.
343
				 *
344
				 * @see    https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/
345
				 *
346
				 * @module widgets
347
				 *
348
				 * @since  3.6.0
349
				 *
350
				 * @param string $args Extra parameters to filter posts returned from the WordPress.com REST API.
351
				 */
352
				apply_filters( 'jetpack_display_posts_widget_posts_params', '' )
353
			)
354
		);
355
356
		return $response;
357
	}
358
359
	/**
360
	 * Parse external API response from the posts list request and handle errors if any occur.
361
	 *
362
	 * @param object|WP_Error $service_response The raw response to be parsed.
363
	 *
364
	 * @return array|WP_Error
365
	 */
366 View Code Duplication
	public function parse_posts_response( $service_response ) {
367
368
		/**
369
		 * If the service returned an error, we pass it on.
370
		 */
371
		if ( is_wp_error( $service_response ) ) {
372
			return $service_response;
373
		}
374
375
		/**
376
		 * Check if the service returned proper posts array.
377
		 */
378
		if ( ! isset( $service_response->posts ) || ! is_array( $service_response->posts ) ) {
379
			return new WP_Error(
380
				'no_posts',
381
				__( 'No posts data returned by remote.', 'jetpack' ),
382
				'No posts information set in the returned data.'
383
			);
384
		}
385
386
		/**
387
		 * Format the posts to preserve storage space.
388
		 */
389
390
		return $this->format_posts_for_storage( $service_response );
391
	}
392
393
	/**
394
	 * Format the posts for better storage. Drop all the data that is not used.
395
	 *
396
	 * @param object $parsed_data Array of posts returned by the APIs.
397
	 *
398
	 * @return array Formatted posts or an empty array if no posts were found.
399
	 */
400
	public function format_posts_for_storage( $parsed_data ) {
401
402
		$formatted_posts = array();
403
404
		/**
405
		 * Only go through the posts list if we have valid posts array.
406
		 */
407
		if ( isset( $parsed_data->posts ) && is_array( $parsed_data->posts ) ) {
408
409
			/**
410
			 * Loop through all the posts and format them appropriately.
411
			 */
412
			foreach ( $parsed_data->posts as $single_post ) {
413
414
				$prepared_post = array(
415
					'title'          => $single_post->title ? $single_post->title : '',
416
					'excerpt'        => $single_post->excerpt ? $single_post->excerpt : '',
417
					'featured_image' => $single_post->featured_image ? $single_post->featured_image : '',
418
					'url'            => $single_post->URL,
419
				);
420
421
				/**
422
				 * Append the formatted post to the results.
423
				 */
424
				$formatted_posts[] = $prepared_post;
425
			}
426
		}
427
428
		return $formatted_posts;
429
	}
430
431
	/**
432
	 * Fetch site information and posts list for a site.
433
	 *
434
	 * @param string $site           Site to fetch the data for.
435
	 * @param array  $original_data  Optional original data to updated.
436
	 *
437
	 * @param bool   $site_data_only Fetch only site information, skip posts list.
438
	 *
439
	 * @return array Updated or new data.
440
	 */
441
	public function fetch_blog_data( $site, $original_data = array(), $site_data_only = false ) {
442
443
		/**
444
		 * If no optional data is supplied, initialize a new structure
445
		 */
446
		if ( ! empty( $original_data ) ) {
447
			$widget_data = $original_data;
448
		}
449
		else {
450
			$widget_data = array(
451
				'site_info' => array(
452
					'last_check'  => null,
453
					'last_update' => null,
454
					'error'       => null,
455
					'data'        => array(),
456
				),
457
				'posts'     => array(
458
					'last_check'  => null,
459
					'last_update' => null,
460
					'error'       => null,
461
					'data'        => array(),
462
				)
463
			);
464
		}
465
466
		/**
467
		 * Update check time and fetch site information.
468
		 */
469
		$widget_data['site_info']['last_check'] = time();
470
471
		$site_info_raw_data    = $this->fetch_site_info( $site );
472
		$site_info_parsed_data = $this->parse_site_info_response( $site_info_raw_data );
473
474
475
		/**
476
		 * If there is an error with the fetched site info, save the error and update the checked time.
477
		 */
478 View Code Duplication
		if ( is_wp_error( $site_info_parsed_data ) ) {
479
			$widget_data['site_info']['error'] = $site_info_parsed_data;
480
481
			return $widget_data;
482
		}
483
		/**
484
		 * If data is fetched successfully, update the data and set the proper time.
485
		 *
486
		 * Data is only updated if we have valid results. This is done this way so we can show
487
		 * something if external service is down.
488
		 *
489
		 */
490
		else {
491
			$widget_data['site_info']['last_update'] = time();
492
			$widget_data['site_info']['data']        = $site_info_parsed_data;
493
			$widget_data['site_info']['error']       = null;
494
		}
495
496
497
		/**
498
		 * If only site data is needed, return it here, don't fetch posts data.
499
		 */
500
		if ( true === $site_data_only ) {
501
			return $widget_data;
502
		}
503
504
		/**
505
		 * Update check time and fetch posts list.
506
		 */
507
		$widget_data['posts']['last_check'] = time();
508
509
		$site_posts_raw_data    = $this->fetch_posts_for_site( $site_info_parsed_data->ID );
510
		$site_posts_parsed_data = $this->parse_posts_response( $site_posts_raw_data );
511
512
513
		/**
514
		 * If there is an error with the fetched posts, save the error and update the checked time.
515
		 */
516 View Code Duplication
		if ( is_wp_error( $site_posts_parsed_data ) ) {
517
			$widget_data['posts']['error'] = $site_posts_parsed_data;
518
519
			return $widget_data;
520
		}
521
		/**
522
		 * If data is fetched successfully, update the data and set the proper time.
523
		 *
524
		 * Data is only updated if we have valid results. This is done this way so we can show
525
		 * something if external service is down.
526
		 *
527
		 */
528
		else {
529
			$widget_data['posts']['last_update'] = time();
530
			$widget_data['posts']['data']        = $site_posts_parsed_data;
531
			$widget_data['posts']['error']       = null;
532
		}
533
534
		return $widget_data;
535
	}
536
537
	/**
538
	 * Gets blog data from the cache.
539
	 *
540
	 * @param string $site
541
	 *
542
	 * @return array|WP_Error
543
	 */
544
	public function get_blog_data( $site ) {
545
		// load from cache, if nothing return an error
546
		$site_hash = $this->get_site_hash( $site );
547
548
		$cached_data = $this->wp_get_option( $this->widget_options_key_prefix . $site_hash );
549
550
		/**
551
		 * If the cache is empty, return an empty_cache error.
552
		 */
553
		if ( false === $cached_data ) {
554
			return new WP_Error(
555
				'empty_cache',
556
				__( 'Information about this blog is currently being retrieved.', 'jetpack' )
557
			);
558
		}
559
560
		return $cached_data;
561
562
	}
563
564
	/**
565
	 * Activates widget update cron task.
566
	 */
567
	public static function activate_cron() {
568
		if ( ! wp_next_scheduled( self::$cron_name ) ) {
569
			wp_schedule_event( time(), 'minutes_10', self::$cron_name );
570
		}
571
	}
572
573
	/**
574
	 * Deactivates widget update cron task.
575
	 *
576
	 * This is a wrapper over the static method as it provides some syntactic sugar.
577
	 */
578
	public function deactivate_cron() {
579
		self::deactivate_cron_static();
580
	}
581
582
	/**
583
	 * Deactivates widget update cron task.
584
	 */
585
	public static function deactivate_cron_static() {
586
		$next_scheduled_time = wp_next_scheduled( self::$cron_name );
587
		wp_unschedule_event( $next_scheduled_time, self::$cron_name );
588
	}
589
590
	/**
591
	 * Checks if the update cron should be running and returns appropriate result.
592
	 *
593
	 * @return bool If the cron should be running or not.
594
	 */
595
	public function should_cron_be_running() {
596
		/**
597
		 * The cron doesn't need to run empty loops.
598
		 */
599
		$widget_instances = $this->get_instances_sites();
600
601
		if ( empty( $widget_instances ) || ! is_array( $widget_instances ) ) {
602
			return false;
603
		}
604
605
		if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
606
			/**
607
			 * If Jetpack is not active or in development mode, we don't want to update widget data.
608
			 */
609
			if ( ! Jetpack::is_active() && ! Jetpack::is_development_mode() ) {
610
				return false;
611
			}
612
613
			/**
614
			 * If Extra Sidebar Widgets module is not active, we don't need to update widget data.
615
			 */
616
			if ( ! Jetpack::is_module_active( 'widgets' ) ) {
617
				return false;
618
			}
619
		}
620
		
621
		/**
622
		 * If none of the above checks failed, then we definitely want to update widget data.
623
		 */
624
		return true;
625
	}
626
627
	/**
628
	 * Main cron code. Updates all instances of the widget.
629
	 *
630
	 * @return bool
631
	 */
632
	public function cron_task() {
633
634
		/**
635
		 * If the cron should not be running, disable it.
636
		 */
637
		if ( false === $this->should_cron_be_running() ) {
638
			return true;
639
		}
640
641
		$instances_to_update = $this->get_instances_sites();
642
643
		/**
644
		 * If no instances are found to be updated - stop.
645
		 */
646
		if ( empty( $instances_to_update ) || ! is_array( $instances_to_update ) ) {
647
			return true;
648
		}
649
650
		foreach ( $instances_to_update as $site_url ) {
651
			$this->update_instance( $site_url );
652
		}
653
654
		return true;
655
	}
656
657
	/**
658
	 * Get a list of unique sites from all instances of the widget.
659
	 *
660
	 * @return array|bool
661
	 */
662
	public function get_instances_sites() {
663
664
		$widget_settings = $this->wp_get_option( 'widget_jetpack_display_posts_widget' );
665
666
		/**
667
		 * If the widget still hasn't been added anywhere, the config will not be present.
668
		 *
669
		 * In such case we don't want to continue execution.
670
		 */
671
		if ( false === $widget_settings || ! is_array( $widget_settings ) ) {
672
			return false;
673
		}
674
675
		$urls = array();
676
677
		foreach ( $widget_settings as $widget_instance_data ) {
678
			if ( isset( $widget_instance_data['url'] ) && ! empty( $widget_instance_data['url'] ) ) {
679
				$urls[] = $widget_instance_data['url'];
680
			}
681
		}
682
683
		/**
684
		 * Make sure only unique URLs are returned.
685
		 */
686
		$urls = array_unique( $urls );
687
688
		return $urls;
689
690
	}
691
692
	/**
693
	 * Update a widget instance.
694
	 *
695
	 * @param string $site The site to fetch the latest data for.
696
	 */
697
	public function update_instance( $site ) {
698
699
		/**
700
		 * Fetch current information for a site.
701
		 */
702
		$site_hash = $this->get_site_hash( $site );
703
704
		$option_key = $this->widget_options_key_prefix . $site_hash;
705
706
		$instance_data = $this->wp_get_option( $option_key );
707
708
		/**
709
		 * Fetch blog data and save it in $instance_data.
710
		 */
711
		$new_data = $this->fetch_blog_data( $site, $instance_data );
712
713
		/**
714
		 * If the option doesn't exist yet - create a new option
715
		 */
716
		if ( false === $instance_data ) {
717
			$this->wp_add_option( $option_key, $new_data );
718
		}
719
		else {
720
			$this->wp_update_option( $option_key, $new_data );
721
		}
722
	}
723
724
	/**
725
	 * Set up the widget display on the front end.
726
	 *
727
	 * @param array $args
728
	 * @param array $instance
729
	 */
730
	public function widget( $args, $instance ) {
731
		/** This action is documented in modules/widgets/gravatar-profile.php */
732
		do_action( 'jetpack_stats_extra', 'widget_view', 'display_posts' );
733
734
		/** This filter is documented in core/src/wp-includes/default-widgets.php */
735
		$title = apply_filters( 'widget_title', $instance['title'] );
736
737
		// Enqueue front end assets.
738
		$this->enqueue_scripts();
739
740
		echo $args['before_widget'];
741
742
		$data = $this->get_blog_data( $instance['url'] );
743
744
		// check for errors
745
		if ( is_wp_error( $data ) || empty( $data['site_info']['data'] ) ) {
746
			echo '<p>' . __( 'Cannot load blog information at this time.', 'jetpack' ) . '</p>';
747
			echo $args['after_widget'];
748
749
			return;
750
		}
751
752
		$site_info = $data['site_info']['data'];
753
754
		if ( ! empty( $title ) ) {
755
			echo $args['before_title'] . esc_html( $title . ': ' . $site_info->name ) . $args['after_title'];
756
		}
757
		else {
758
			echo $args['before_title'] . esc_html( $site_info->name ) . $args['after_title'];
759
		}
760
761
		echo '<div class="jetpack-display-remote-posts">';
762
763
		if ( is_wp_error( $data['posts']['data'] ) || empty( $data['posts']['data'] ) ) {
764
			echo '<p>' . __( 'Cannot load blog posts at this time.', 'jetpack' ) . '</p>';
765
			echo '</div><!-- .jetpack-display-remote-posts -->';
766
			echo $args['after_widget'];
767
768
			return;
769
		}
770
771
		$posts_list = $data['posts']['data'];
772
773
		/**
774
		 * Show only as much posts as we need. If we have less than configured amount,
775
		 * we must show only that much posts.
776
		 */
777
		$number_of_posts = min( $instance['number_of_posts'], count( $posts_list ) );
778
779
		for ( $i = 0; $i < $number_of_posts; $i ++ ) {
780
			$single_post = $posts_list[ $i ];
781
			$post_title  = ( $single_post['title'] ) ? $single_post['title'] : '( No Title )';
782
783
			$target = '';
784
			if ( isset( $instance['open_in_new_window'] ) && $instance['open_in_new_window'] == true ) {
785
				$target = ' target="_blank"';
786
			}
787
			echo '<h4><a href="' . esc_url( $single_post['url'] ) . '"' . $target . '>' . esc_html( $post_title ) . '</a></h4>' . "\n";
788
			if ( ( $instance['featured_image'] == true ) && ( ! empty ( $single_post['featured_image'] ) ) ) {
789
				$featured_image = $single_post['featured_image'];
790
				/**
791
				 * Allows setting up custom Photon parameters to manipulate the image output in the Display Posts widget.
792
				 *
793
				 * @see    https://developer.wordpress.com/docs/photon/
794
				 *
795
				 * @module widgets
796
				 *
797
				 * @since  3.6.0
798
				 *
799
				 * @param array $args Array of Photon Parameters.
800
				 */
801
				$image_params = apply_filters( 'jetpack_display_posts_widget_image_params', array() );
802
				echo '<a title="' . esc_attr( $post_title ) . '" href="' . esc_url( $single_post['url'] ) . '"' . $target . '><img src="' . jetpack_photon_url( $featured_image, $image_params ) . '" alt="' . esc_attr( $post_title ) . '"/></a>';
803
			}
804
805
			if ( $instance['show_excerpts'] == true ) {
806
				echo $single_post['excerpt'];
807
			}
808
		}
809
810
		echo '</div><!-- .jetpack-display-remote-posts -->';
811
		echo $args['after_widget'];
812
	}
813
814
	/**
815
	 * Scan and extract first error from blog data array.
816
	 *
817
	 * @param array|WP_Error $blog_data Blog data to scan for errors.
818
	 *
819
	 * @return string First error message found
820
	 */
821
	public function extract_errors_from_blog_data( $blog_data ) {
822
823
		$errors = array(
824
			'message' => '',
825
			'debug'   => '',
826
			'where'   => '',
827
		);
828
829
830
		/**
831
		 * When the cache result is an error. Usually when the cache is empty.
832
		 * This is not an error case for now.
833
		 */
834
		if ( is_wp_error( $blog_data ) ) {
835
			return $errors;
836
		}
837
838
		/**
839
		 * Loop through `site_info` and `posts` keys of $blog_data.
840
		 */
841
		foreach ( array( 'site_info', 'posts' ) as $info_key ) {
842
843
			/**
844
			 * Contains information on which stage the error ocurred.
845
			 */
846
			$errors['where'] = $info_key;
847
848
			/**
849
			 * If an error is set, we want to check it for usable messages.
850
			 */
851
			if ( isset( $blog_data[ $info_key ]['error'] ) && ! empty( $blog_data[ $info_key ]['error'] ) ) {
852
853
				/**
854
				 * Extract error message from the error, if possible.
855
				 */
856
				if ( is_wp_error( $blog_data[ $info_key ]['error'] ) ) {
857
					/**
858
					 * In the case of WP_Error we want to have the error message
859
					 * and the debug information available.
860
					 */
861
					$error_messages    = $blog_data[ $info_key ]['error']->get_error_messages();
862
					$errors['message'] = reset( $error_messages );
863
864
					$extra_data = $blog_data[ $info_key ]['error']->get_error_data();
865
					if ( is_array( $extra_data ) ) {
866
						$errors['debug'] = implode( '; ', $extra_data );
867
					}
868
					else {
869
						$errors['debug'] = $extra_data;
870
					}
871
872
					break;
873
				}
874
				elseif ( is_array( $blog_data[ $info_key ]['error'] ) ) {
875
					/**
876
					 * In this case we don't have debug information, because
877
					 * we have no way to know the format. The widget works with
878
					 * WP_Error objects only.
879
					 */
880
					$errors['message'] = reset( $blog_data[ $info_key ]['error'] );
881
					break;
882
				}
883
884
				/**
885
				 * We do nothing if no usable error is found.
886
				 */
887
			}
888
		}
889
890
		return $errors;
891
	}
892
893
	/**
894
	 * Enqueue CSS and JavaScript.
895
	 *
896
	 * @since 4.0.0
897
	 */
898
	public function enqueue_scripts() {
899
		wp_enqueue_style( 'jetpack_display_posts_widget', plugins_url( 'wordpress-post-widget/style.css', __FILE__ ) );
900
	}
901
902
	/**
903
	 * Display the widget administration form.
904
	 *
905
	 * @param array $instance Widget instance configuration.
906
	 *
907
	 * @return string|void
908
	 */
909
	public function form( $instance ) {
910
911
		/**
912
		 * Initialize widget configuration variables.
913
		 */
914
		$title              = ( isset( $instance['title'] ) ) ? $instance['title'] : __( 'Recent Posts', 'jetpack' );
915
		$url                = ( isset( $instance['url'] ) ) ? $instance['url'] : '';
916
		$number_of_posts    = ( isset( $instance['number_of_posts'] ) ) ? $instance['number_of_posts'] : 5;
917
		$open_in_new_window = ( isset( $instance['open_in_new_window'] ) ) ? $instance['open_in_new_window'] : false;
918
		$featured_image     = ( isset( $instance['featured_image'] ) ) ? $instance['featured_image'] : false;
919
		$show_excerpts      = ( isset( $instance['show_excerpts'] ) ) ? $instance['show_excerpts'] : false;
920
921
922
		/**
923
		 * Check if the widget instance has errors available.
924
		 *
925
		 * Only do so if a URL is set.
926
		 */
927
		$update_errors = array();
928
929
		if ( ! empty( $url ) ) {
930
			$data          = $this->get_blog_data( $url );
931
			$update_errors = $this->extract_errors_from_blog_data( $data );
932
		}
933
934
		?>
935
		<p>
936
			<label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title:', 'jetpack' ); ?></label>
937
			<input class="widefat" id="<?php echo $this->get_field_id( 'title' ); ?>" name="<?php echo $this->get_field_name( 'title' ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>" />
938
		</p>
939
940
		<p>
941
			<label for="<?php echo $this->get_field_id( 'url' ); ?>"><?php _e( 'Blog URL:', 'jetpack' ); ?></label>
942
			<input class="widefat" id="<?php echo $this->get_field_id( 'url' ); ?>" name="<?php echo $this->get_field_name( 'url' ); ?>" type="text" value="<?php echo esc_attr( $url ); ?>" />
943
			<i>
944
				<?php _e( "Enter a WordPress.com or Jetpack WordPress site URL.", 'jetpack' ); ?>
945
			</i>
946
			<?php
947
			/**
948
			 * Show an error if the URL field was left empty.
949
			 *
950
			 * The error is shown only when the widget was already saved.
951
			 */
952
			if ( empty( $url ) && ! preg_match( '/__i__|%i%/', $this->id ) ) {
953
				?>
954
				<br />
955
				<i class="error-message"><?php echo __( 'You must specify a valid blog URL!', 'jetpack' ); ?></i>
956
				<?php
957
			}
958
			?>
959
		</p>
960
		<p>
961
			<label for="<?php echo $this->get_field_id( 'number_of_posts' ); ?>"><?php _e( 'Number of Posts to Display:', 'jetpack' ); ?></label>
962
			<select name="<?php echo $this->get_field_name( 'number_of_posts' ); ?>">
963
				<?php
964
				for ( $i = 1; $i <= 10; $i ++ ) {
965
					echo '<option value="' . $i . '" ' . selected( $number_of_posts, $i ) . '>' . $i . '</option>';
966
				}
967
				?>
968
			</select>
969
		</p>
970
		<p>
971
			<label for="<?php echo $this->get_field_id( 'open_in_new_window' ); ?>"><?php _e( 'Open links in new window/tab:', 'jetpack' ); ?></label>
972
			<input type="checkbox" name="<?php echo $this->get_field_name( 'open_in_new_window' ); ?>" <?php checked( $open_in_new_window, 1 ); ?> />
973
		</p>
974
		<p>
975
			<label for="<?php echo $this->get_field_id( 'featured_image' ); ?>"><?php _e( 'Show Featured Image:', 'jetpack' ); ?></label>
976
			<input type="checkbox" name="<?php echo $this->get_field_name( 'featured_image' ); ?>" <?php checked( $featured_image, 1 ); ?> />
977
		</p>
978
		<p>
979
			<label for="<?php echo $this->get_field_id( 'show_excerpts' ); ?>"><?php _e( 'Show Excerpts:', 'jetpack' ); ?></label>
980
			<input type="checkbox" name="<?php echo $this->get_field_name( 'show_excerpts' ); ?>" <?php checked( $show_excerpts, 1 ); ?> />
981
		</p>
982
983
		<?php
984
985
		/**
986
		 * Show error messages.
987
		 */
988
		if ( ! empty( $update_errors['message'] ) ) {
989
990
			/**
991
			 * Prepare the error messages.
992
			 */
993
994
			$where_message = '';
995
			switch ( $update_errors['where'] ) {
996
				case 'posts':
997
					$where_message .= __( 'An error occurred while downloading blog posts list', 'jetpack' );
998
					break;
999
1000
				/**
1001
				 * If something else, beside `posts` and `site_info` broke,
1002
				 * don't handle it and default to blog `information`,
1003
				 * as it is generic enough.
1004
				 */
1005
				case 'site_info':
1006
				default:
1007
					$where_message .= __( 'An error occurred while downloading blog information', 'jetpack' );
1008
					break;
1009
			}
1010
1011
			?>
1012
			<p class="error-message">
1013
				<?php echo esc_html( $where_message ); ?>:
1014
				<br />
1015
				<i>
1016
					<?php echo esc_html( $update_errors['message'] ); ?>
1017
					<?php
1018
					/**
1019
					 * If there is any debug - show it here.
1020
					 */
1021
					if ( ! empty( $update_errors['debug'] ) ) {
1022
						?>
1023
						<br />
1024
						<br />
1025
						<?php esc_html_e( 'Detailed information', 'jetpack' ); ?>:
1026
						<br />
1027
						<?php echo esc_html( $update_errors['debug'] ); ?>
1028
						<?php
1029
					}
1030
					?>
1031
				</i>
1032
			</p>
1033
1034
			<?php
1035
		}
1036
	}
1037
1038
	public function update( $new_instance, $old_instance ) {
1039
1040
		$instance          = array();
1041
		$instance['title'] = ( ! empty( $new_instance['title'] ) ) ? strip_tags( $new_instance['title'] ) : '';
1042
		$instance['url']   = ( ! empty( $new_instance['url'] ) ) ? strip_tags( $new_instance['url'] ) : '';
1043
		$instance['url']   = preg_replace( "!^https?://!is", "", $instance['url'] );
1044
		$instance['url']   = untrailingslashit( $instance['url'] );
1045
1046
1047
		/**
1048
		 * Check if the URL should be with or without the www prefix before saving.
1049
		 */
1050
		if ( ! empty( $instance['url'] ) ) {
1051
			$blog_data = $this->fetch_blog_data( $instance['url'], array(), true );
1052
1053
			if ( is_wp_error( $blog_data['site_info']['error'] ) && 'www.' === substr( $instance['url'], 0, 4 ) ) {
1054
				$blog_data = $this->fetch_blog_data( substr( $instance['url'], 4 ), array(), true );
1055
1056
				if ( ! is_wp_error( $blog_data['site_info']['error'] ) ) {
1057
					$instance['url'] = substr( $instance['url'], 4 );
1058
				}
1059
			}
1060
		}
1061
1062
		$instance['number_of_posts']    = ( ! empty( $new_instance['number_of_posts'] ) ) ? intval( $new_instance['number_of_posts'] ) : '';
1063
		$instance['open_in_new_window'] = ( ! empty( $new_instance['open_in_new_window'] ) ) ? true : '';
1064
		$instance['featured_image']     = ( ! empty( $new_instance['featured_image'] ) ) ? true : '';
1065
		$instance['show_excerpts']      = ( ! empty( $new_instance['show_excerpts'] ) ) ? true : '';
1066
1067
		/**
1068
		 * Forcefully activate the update cron when saving widget instance.
1069
		 *
1070
		 * So we can be sure that it will be running later.
1071
		 */
1072
		$this->activate_cron();
1073
1074
1075
		/**
1076
		 * If there is no cache entry for the specified URL, run a forced update.
1077
		 *
1078
		 * @see get_blog_data Returns WP_Error if the cache is empty, which is what is needed here.
1079
		 */
1080
		$cached_data = $this->get_blog_data( $instance['url'] );
1081
1082
		if ( is_wp_error( $cached_data ) ) {
1083
			$this->update_instance( $instance['url'] );
1084
		}
1085
1086
		return $instance;
1087
	}
1088
1089
	/**
1090
	 * This is just to make method mocks in the unit tests easier.
1091
	 *
1092
	 * @param string $param Option key to get
1093
	 *
1094
	 * @return mixed
1095
	 *
1096
	 * @codeCoverageIgnore
1097
	 */
1098
	public function wp_get_option( $param ) {
1099
		return get_option( $param );
1100
	}
1101
1102
	/**
1103
	 * This is just to make method mocks in the unit tests easier.
1104
	 *
1105
	 * @param string $option_name  Option name to be added
1106
	 * @param mixed  $option_value Option value
1107
	 *
1108
	 * @return mixed
1109
	 *
1110
	 * @codeCoverageIgnore
1111
	 */
1112
	public function wp_add_option( $option_name, $option_value ) {
1113
		return add_option( $option_name, $option_value );
1114
	}
1115
1116
	/**
1117
	 * This is just to make method mocks in the unit tests easier.
1118
	 *
1119
	 * @param string $option_name  Option name to be updated
1120
	 * @param mixed  $option_value Option value
1121
	 *
1122
	 * @return mixed
1123
	 *
1124
	 * @codeCoverageIgnore
1125
	 */
1126
	public function wp_update_option( $option_name, $option_value ) {
1127
		return update_option( $option_name, $option_value );
1128
	}
1129
1130
1131
	/**
1132
	 * This is just to make method mocks in the unit tests easier.
1133
	 *
1134
	 * @param string $url  The URL to fetch
1135
	 * @param array  $args Optional. Request arguments.
1136
	 *
1137
	 * @return array|WP_Error
1138
	 *
1139
	 * @codeCoverageIgnore
1140
	 */
1141
	public function wp_wp_remote_get( $url, $args = array() ) {
1142
		return wp_remote_get( $url, $args );
1143
	}
1144
}
1145