Completed
Push — v2/videopress ( 946a33...00803f )
by George
35:29
created

parse_posts_response()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 26
Code Lines 9

Duplication

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