Completed
Push — add/pathinfo-sitemap ( eb9d33...02fba8 )
by Jeremy
14:23 queued 04:10
created

Jetpack_Display_Posts_Widget   D

Complexity

Total Complexity 112

Size/Duplication

Total Lines 1021
Duplicated Lines 8.03 %

Coupling/Cohesion

Components 1
Dependencies 1

Importance

Changes 0
Metric Value
dl 82
loc 1021
rs 4.4368
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
C widget() 0 81 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
function jetpack_display_posts_widget_cron_intervals( $current_schedules ) {
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
732
		/** This filter is documented in core/src/wp-includes/default-widgets.php */
733
		$title = apply_filters( 'widget_title', $instance['title'] );
734
735
		// Enqueue front end assets.
736
		$this->enqueue_scripts();
737
738
		echo $args['before_widget'];
739
740
		$data = $this->get_blog_data( $instance['url'] );
741
742
		// check for errors
743
		if ( is_wp_error( $data ) || empty( $data['site_info']['data'] ) ) {
744
			echo '<p>' . __( 'Cannot load blog information at this time.', 'jetpack' ) . '</p>';
745
			echo $args['after_widget'];
746
747
			return;
748
		}
749
750
		$site_info = $data['site_info']['data'];
751
752
		if ( ! empty( $title ) ) {
753
			echo $args['before_title'] . esc_html( $title . ': ' . $site_info->name ) . $args['after_title'];
754
		}
755
		else {
756
			echo $args['before_title'] . esc_html( $site_info->name ) . $args['after_title'];
757
		}
758
759
		echo '<div class="jetpack-display-remote-posts">';
760
761
		if ( is_wp_error( $data['posts']['data'] ) || empty( $data['posts']['data'] ) ) {
762
			echo '<p>' . __( 'Cannot load blog posts at this time.', 'jetpack' ) . '</p>';
763
			echo '</div><!-- .jetpack-display-remote-posts -->';
764
			echo $args['after_widget'];
765
766
			return;
767
		}
768
769
		$posts_list = $data['posts']['data'];
770
771
		/**
772
		 * Show only as much posts as we need. If we have less than configured amount,
773
		 * we must show only that much posts.
774
		 */
775
		$number_of_posts = min( $instance['number_of_posts'], count( $posts_list ) );
776
777
		for ( $i = 0; $i < $number_of_posts; $i ++ ) {
778
			$single_post = $posts_list[ $i ];
779
			$post_title  = ( $single_post['title'] ) ? $single_post['title'] : '( No Title )';
780
781
			$target = '';
782
			if ( isset( $instance['open_in_new_window'] ) && $instance['open_in_new_window'] == true ) {
783
				$target = ' target="_blank"';
784
			}
785
			echo '<h4><a href="' . esc_url( $single_post['url'] ) . '"' . $target . '>' . esc_html( $post_title ) . '</a></h4>' . "\n";
786
			if ( ( $instance['featured_image'] == true ) && ( ! empty ( $single_post['featured_image'] ) ) ) {
787
				$featured_image = $single_post['featured_image'];
788
				/**
789
				 * Allows setting up custom Photon parameters to manipulate the image output in the Display Posts widget.
790
				 *
791
				 * @see    https://developer.wordpress.com/docs/photon/
792
				 *
793
				 * @module widgets
794
				 *
795
				 * @since  3.6.0
796
				 *
797
				 * @param array $args Array of Photon Parameters.
798
				 */
799
				$image_params = apply_filters( 'jetpack_display_posts_widget_image_params', array() );
800
				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>';
801
			}
802
803
			if ( $instance['show_excerpts'] == true ) {
804
				echo $single_post['excerpt'];
805
			}
806
		}
807
808
		echo '</div><!-- .jetpack-display-remote-posts -->';
809
		echo $args['after_widget'];
810
	}
811
812
	/**
813
	 * Scan and extract first error from blog data array.
814
	 *
815
	 * @param array|WP_Error $blog_data Blog data to scan for errors.
816
	 *
817
	 * @return string First error message found
818
	 */
819
	public function extract_errors_from_blog_data( $blog_data ) {
820
821
		$errors = array(
822
			'message' => '',
823
			'debug'   => '',
824
			'where'   => '',
825
		);
826
827
828
		/**
829
		 * When the cache result is an error. Usually when the cache is empty.
830
		 * This is not an error case for now.
831
		 */
832
		if ( is_wp_error( $blog_data ) ) {
833
			return $errors;
834
		}
835
836
		/**
837
		 * Loop through `site_info` and `posts` keys of $blog_data.
838
		 */
839
		foreach ( array( 'site_info', 'posts' ) as $info_key ) {
840
841
			/**
842
			 * Contains information on which stage the error ocurred.
843
			 */
844
			$errors['where'] = $info_key;
845
846
			/**
847
			 * If an error is set, we want to check it for usable messages.
848
			 */
849
			if ( isset( $blog_data[ $info_key ]['error'] ) && ! empty( $blog_data[ $info_key ]['error'] ) ) {
850
851
				/**
852
				 * Extract error message from the error, if possible.
853
				 */
854
				if ( is_wp_error( $blog_data[ $info_key ]['error'] ) ) {
855
					/**
856
					 * In the case of WP_Error we want to have the error message
857
					 * and the debug information available.
858
					 */
859
					$error_messages    = $blog_data[ $info_key ]['error']->get_error_messages();
860
					$errors['message'] = reset( $error_messages );
861
862
					$extra_data = $blog_data[ $info_key ]['error']->get_error_data();
863
					if ( is_array( $extra_data ) ) {
864
						$errors['debug'] = implode( '; ', $extra_data );
865
					}
866
					else {
867
						$errors['debug'] = $extra_data;
868
					}
869
870
					break;
871
				}
872
				elseif ( is_array( $blog_data[ $info_key ]['error'] ) ) {
873
					/**
874
					 * In this case we don't have debug information, because
875
					 * we have no way to know the format. The widget works with
876
					 * WP_Error objects only.
877
					 */
878
					$errors['message'] = reset( $blog_data[ $info_key ]['error'] );
879
					break;
880
				}
881
882
				/**
883
				 * We do nothing if no usable error is found.
884
				 */
885
			}
886
		}
887
888
		return $errors;
889
	}
890
891
	/**
892
	 * Enqueue CSS and JavaScript.
893
	 *
894
	 * @since 4.0.0
895
	 */
896
	public function enqueue_scripts() {
897
		wp_enqueue_style( 'jetpack_display_posts_widget', plugins_url( 'wordpress-post-widget/style.css', __FILE__ ) );
898
	}
899
900
	/**
901
	 * Display the widget administration form.
902
	 *
903
	 * @param array $instance Widget instance configuration.
904
	 *
905
	 * @return string|void
906
	 */
907
	public function form( $instance ) {
908
909
		/**
910
		 * Initialize widget configuration variables.
911
		 */
912
		$title              = ( isset( $instance['title'] ) ) ? $instance['title'] : __( 'Recent Posts', 'jetpack' );
913
		$url                = ( isset( $instance['url'] ) ) ? $instance['url'] : '';
914
		$number_of_posts    = ( isset( $instance['number_of_posts'] ) ) ? $instance['number_of_posts'] : 5;
915
		$open_in_new_window = ( isset( $instance['open_in_new_window'] ) ) ? $instance['open_in_new_window'] : false;
916
		$featured_image     = ( isset( $instance['featured_image'] ) ) ? $instance['featured_image'] : false;
917
		$show_excerpts      = ( isset( $instance['show_excerpts'] ) ) ? $instance['show_excerpts'] : false;
918
919
920
		/**
921
		 * Check if the widget instance has errors available.
922
		 *
923
		 * Only do so if a URL is set.
924
		 */
925
		$update_errors = array();
926
927
		if ( ! empty( $url ) ) {
928
			$data          = $this->get_blog_data( $url );
929
			$update_errors = $this->extract_errors_from_blog_data( $data );
930
		}
931
932
		?>
933
		<p>
934
			<label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title:', 'jetpack' ); ?></label>
935
			<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 ); ?>" />
936
		</p>
937
938
		<p>
939
			<label for="<?php echo $this->get_field_id( 'url' ); ?>"><?php _e( 'Blog URL:', 'jetpack' ); ?></label>
940
			<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 ); ?>" />
941
			<i>
942
				<?php _e( "Enter a WordPress.com or Jetpack WordPress site URL.", 'jetpack' ); ?>
943
			</i>
944
			<?php
945
			/**
946
			 * Show an error if the URL field was left empty.
947
			 *
948
			 * The error is shown only when the widget was already saved.
949
			 */
950
			if ( empty( $url ) && ! preg_match( '/__i__|%i%/', $this->id ) ) {
951
				?>
952
				<br />
953
				<i class="error-message"><?php echo __( 'You must specify a valid blog URL!', 'jetpack' ); ?></i>
954
				<?php
955
			}
956
			?>
957
		</p>
958
		<p>
959
			<label for="<?php echo $this->get_field_id( 'number_of_posts' ); ?>"><?php _e( 'Number of Posts to Display:', 'jetpack' ); ?></label>
960
			<select name="<?php echo $this->get_field_name( 'number_of_posts' ); ?>">
961
				<?php
962
				for ( $i = 1; $i <= 10; $i ++ ) {
963
					echo '<option value="' . $i . '" ' . selected( $number_of_posts, $i ) . '>' . $i . '</option>';
964
				}
965
				?>
966
			</select>
967
		</p>
968
		<p>
969
			<label for="<?php echo $this->get_field_id( 'open_in_new_window' ); ?>"><?php _e( 'Open links in new window/tab:', 'jetpack' ); ?></label>
970
			<input type="checkbox" name="<?php echo $this->get_field_name( 'open_in_new_window' ); ?>" <?php checked( $open_in_new_window, 1 ); ?> />
971
		</p>
972
		<p>
973
			<label for="<?php echo $this->get_field_id( 'featured_image' ); ?>"><?php _e( 'Show Featured Image:', 'jetpack' ); ?></label>
974
			<input type="checkbox" name="<?php echo $this->get_field_name( 'featured_image' ); ?>" <?php checked( $featured_image, 1 ); ?> />
975
		</p>
976
		<p>
977
			<label for="<?php echo $this->get_field_id( 'show_excerpts' ); ?>"><?php _e( 'Show Excerpts:', 'jetpack' ); ?></label>
978
			<input type="checkbox" name="<?php echo $this->get_field_name( 'show_excerpts' ); ?>" <?php checked( $show_excerpts, 1 ); ?> />
979
		</p>
980
981
		<?php
982
983
		/**
984
		 * Show error messages.
985
		 */
986
		if ( ! empty( $update_errors['message'] ) ) {
987
988
			/**
989
			 * Prepare the error messages.
990
			 */
991
992
			$where_message = '';
993
			switch ( $update_errors['where'] ) {
994
				case 'posts':
995
					$where_message .= __( 'An error occurred while downloading blog posts list', 'jetpack' );
996
					break;
997
998
				/**
999
				 * If something else, beside `posts` and `site_info` broke,
1000
				 * don't handle it and default to blog `information`,
1001
				 * as it is generic enough.
1002
				 */
1003
				case 'site_info':
1004
				default:
1005
					$where_message .= __( 'An error occurred while downloading blog information', 'jetpack' );
1006
					break;
1007
			}
1008
1009
			?>
1010
			<p class="error-message">
1011
				<?php echo esc_html( $where_message ); ?>:
1012
				<br />
1013
				<i>
1014
					<?php echo esc_html( $update_errors['message'] ); ?>
1015
					<?php
1016
					/**
1017
					 * If there is any debug - show it here.
1018
					 */
1019
					if ( ! empty( $update_errors['debug'] ) ) {
1020
						?>
1021
						<br />
1022
						<br />
1023
						<?php esc_html_e( 'Detailed information', 'jetpack' ); ?>:
1024
						<br />
1025
						<?php echo esc_html( $update_errors['debug'] ); ?>
1026
						<?php
1027
					}
1028
					?>
1029
				</i>
1030
			</p>
1031
1032
			<?php
1033
		}
1034
	}
1035
1036
	public function update( $new_instance, $old_instance ) {
1037
1038
		$instance          = array();
1039
		$instance['title'] = ( ! empty( $new_instance['title'] ) ) ? strip_tags( $new_instance['title'] ) : '';
1040
		$instance['url']   = ( ! empty( $new_instance['url'] ) ) ? strip_tags( $new_instance['url'] ) : '';
1041
		$instance['url']   = preg_replace( "!^https?://!is", "", $instance['url'] );
1042
		$instance['url']   = untrailingslashit( $instance['url'] );
1043
1044
1045
		/**
1046
		 * Check if the URL should be with or without the www prefix before saving.
1047
		 */
1048
		if ( ! empty( $instance['url'] ) ) {
1049
			$blog_data = $this->fetch_blog_data( $instance['url'], array(), true );
1050
1051
			if ( is_wp_error( $blog_data['site_info']['error'] ) && 'www.' === substr( $instance['url'], 0, 4 ) ) {
1052
				$blog_data = $this->fetch_blog_data( substr( $instance['url'], 4 ), array(), true );
1053
1054
				if ( ! is_wp_error( $blog_data['site_info']['error'] ) ) {
1055
					$instance['url'] = substr( $instance['url'], 4 );
1056
				}
1057
			}
1058
		}
1059
1060
		$instance['number_of_posts']    = ( ! empty( $new_instance['number_of_posts'] ) ) ? intval( $new_instance['number_of_posts'] ) : '';
1061
		$instance['open_in_new_window'] = ( ! empty( $new_instance['open_in_new_window'] ) ) ? true : '';
1062
		$instance['featured_image']     = ( ! empty( $new_instance['featured_image'] ) ) ? true : '';
1063
		$instance['show_excerpts']      = ( ! empty( $new_instance['show_excerpts'] ) ) ? true : '';
1064
1065
		/**
1066
		 * Forcefully activate the update cron when saving widget instance.
1067
		 *
1068
		 * So we can be sure that it will be running later.
1069
		 */
1070
		$this->activate_cron();
1071
1072
1073
		/**
1074
		 * If there is no cache entry for the specified URL, run a forced update.
1075
		 *
1076
		 * @see get_blog_data Returns WP_Error if the cache is empty, which is what is needed here.
1077
		 */
1078
		$cached_data = $this->get_blog_data( $instance['url'] );
1079
1080
		if ( is_wp_error( $cached_data ) ) {
1081
			$this->update_instance( $instance['url'] );
1082
		}
1083
1084
		return $instance;
1085
	}
1086
1087
	/**
1088
	 * This is just to make method mocks in the unit tests easier.
1089
	 *
1090
	 * @param string $param Option key to get
1091
	 *
1092
	 * @return mixed
1093
	 *
1094
	 * @codeCoverageIgnore
1095
	 */
1096
	public function wp_get_option( $param ) {
1097
		return get_option( $param );
1098
	}
1099
1100
	/**
1101
	 * This is just to make method mocks in the unit tests easier.
1102
	 *
1103
	 * @param string $option_name  Option name to be added
1104
	 * @param mixed  $option_value Option value
1105
	 *
1106
	 * @return mixed
1107
	 *
1108
	 * @codeCoverageIgnore
1109
	 */
1110
	public function wp_add_option( $option_name, $option_value ) {
1111
		return add_option( $option_name, $option_value );
1112
	}
1113
1114
	/**
1115
	 * This is just to make method mocks in the unit tests easier.
1116
	 *
1117
	 * @param string $option_name  Option name to be updated
1118
	 * @param mixed  $option_value Option value
1119
	 *
1120
	 * @return mixed
1121
	 *
1122
	 * @codeCoverageIgnore
1123
	 */
1124
	public function wp_update_option( $option_name, $option_value ) {
1125
		return update_option( $option_name, $option_value );
1126
	}
1127
1128
1129
	/**
1130
	 * This is just to make method mocks in the unit tests easier.
1131
	 *
1132
	 * @param string $url  The URL to fetch
1133
	 * @param array  $args Optional. Request arguments.
1134
	 *
1135
	 * @return array|WP_Error
1136
	 *
1137
	 * @codeCoverageIgnore
1138
	 */
1139
	public function wp_wp_remote_get( $url, $args = array() ) {
1140
		return wp_remote_get( $url, $args );
1141
	}
1142
}
1143