Completed
Push — add/sync-rest-comments ( 1da85c )
by
unknown
09:46
created

Jetpack_Display_Posts_Widget::wp_update_option()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 3
rs 10
cc 1
eloc 2
nc 1
nop 2
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
		if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
601
			/**
602
			 * If Jetpack is not active or in development mode, we don't want to update widget data.
603
			 */
604
			if ( ! Jetpack::is_active() && ! Jetpack::is_development_mode() ) {
605
				return false;
606
			}
607
608
			/**
609
			 * If Extra Sidebar Widgets module is not active, we don't need to update widget data.
610
			 */
611
			if ( ! Jetpack::is_module_active( 'widgets' ) ) {
612
				return false;
613
			}
614
		}
615
		
616
		/**
617
		 * If none of the above checks failed, then we definitely want to update widget data.
618
		 */
619
		return true;
620
	}
621
622
	/**
623
	 * Main cron code. Updates all instances of the widget.
624
	 *
625
	 * @return bool
626
	 */
627
	public function cron_task() {
628
629
		/**
630
		 * If the cron should not be running, disable it.
631
		 */
632
		if ( false === $this->should_cron_be_running() ) {
633
			return true;
634
		}
635
636
		$instances_to_update = $this->get_instances_sites();
637
638
		/**
639
		 * If no instances are found to be updated - stop.
640
		 */
641
		if ( empty( $instances_to_update ) || ! is_array( $instances_to_update ) ) {
642
			return true;
643
		}
644
645
		foreach ( $instances_to_update as $site_url ) {
646
			$this->update_instance( $site_url );
647
		}
648
649
		return true;
650
	}
651
652
	/**
653
	 * Get a list of unique sites from all instances of the widget.
654
	 *
655
	 * @return array|bool
656
	 */
657
	public function get_instances_sites() {
658
659
		$widget_settings = $this->wp_get_option( 'widget_jetpack_display_posts_widget' );
660
661
		/**
662
		 * If the widget still hasn't been added anywhere, the config will not be present.
663
		 *
664
		 * In such case we don't want to continue execution.
665
		 */
666
		if ( false === $widget_settings || ! is_array( $widget_settings ) ) {
667
			return false;
668
		}
669
670
		$urls = array();
671
672
		foreach ( $widget_settings as $widget_instance_data ) {
673
			if ( isset( $widget_instance_data['url'] ) && ! empty( $widget_instance_data['url'] ) ) {
674
				$urls[] = $widget_instance_data['url'];
675
			}
676
		}
677
678
		/**
679
		 * Make sure only unique URLs are returned.
680
		 */
681
		$urls = array_unique( $urls );
682
683
		return $urls;
684
685
	}
686
687
	/**
688
	 * Update a widget instance.
689
	 *
690
	 * @param string $site The site to fetch the latest data for.
691
	 */
692
	public function update_instance( $site ) {
693
694
		/**
695
		 * Fetch current information for a site.
696
		 */
697
		$site_hash = $this->get_site_hash( $site );
698
699
		$option_key = $this->widget_options_key_prefix . $site_hash;
700
701
		$instance_data = $this->wp_get_option( $option_key );
702
703
		/**
704
		 * Fetch blog data and save it in $instance_data.
705
		 */
706
		$new_data = $this->fetch_blog_data( $site, $instance_data );
707
708
		/**
709
		 * If the option doesn't exist yet - create a new option
710
		 */
711
		if ( false === $instance_data ) {
712
			$this->wp_add_option( $option_key, $new_data );
713
		}
714
		else {
715
			$this->wp_update_option( $option_key, $new_data );
716
		}
717
	}
718
719
	/**
720
	 * Set up the widget display on the front end.
721
	 *
722
	 * @param array $args
723
	 * @param array $instance
724
	 */
725
	public function widget( $args, $instance ) {
726
727
		/** This filter is documented in core/src/wp-includes/default-widgets.php */
728
		$title = apply_filters( 'widget_title', $instance['title'] );
729
730
		wp_enqueue_style( 'jetpack_display_posts_widget', plugins_url( 'wordpress-post-widget/style.css', __FILE__ ) );
731
732
		echo $args['before_widget'];
733
734
		$data = $this->get_blog_data( $instance['url'] );
735
736
		// check for errors
737
		if ( is_wp_error( $data ) || empty( $data['site_info']['data'] ) ) {
738
			echo '<p>' . __( 'Cannot load blog information at this time.', 'jetpack' ) . '</p>';
739
			echo $args['after_widget'];
740
741
			return;
742
		}
743
744
		$site_info = $data['site_info']['data'];
745
746
		if ( ! empty( $title ) ) {
747
			echo $args['before_title'] . esc_html( $title . ': ' . $site_info->name ) . $args['after_title'];
748
		}
749
		else {
750
			echo $args['before_title'] . esc_html( $site_info->name ) . $args['after_title'];
751
		}
752
753
		echo '<div class="jetpack-display-remote-posts">';
754
755
		if ( is_wp_error( $data['posts']['data'] ) || empty( $data['posts']['data'] ) ) {
756
			echo '<p>' . __( 'Cannot load blog posts at this time.', 'jetpack' ) . '</p>';
757
			echo '</div><!-- .jetpack-display-remote-posts -->';
758
			echo $args['after_widget'];
759
760
			return;
761
		}
762
763
		$posts_list = $data['posts']['data'];
764
765
		/**
766
		 * Show only as much posts as we need. If we have less than configured amount,
767
		 * we must show only that much posts.
768
		 */
769
		$number_of_posts = min( $instance['number_of_posts'], count( $posts_list ) );
770
771
		for ( $i = 0; $i < $number_of_posts; $i ++ ) {
772
			$single_post = $posts_list[ $i ];
773
			$post_title  = ( $single_post['title'] ) ? $single_post['title'] : '( No Title )';
774
775
			$target = '';
776
			if ( isset( $instance['open_in_new_window'] ) && $instance['open_in_new_window'] == true ) {
777
				$target = ' target="_blank"';
778
			}
779
			echo '<h4><a href="' . esc_url( $single_post['url'] ) . '"' . $target . '>' . esc_html( $post_title ) . '</a></h4>' . "\n";
780
			if ( ( $instance['featured_image'] == true ) && ( ! empty ( $single_post['featured_image'] ) ) ) {
781
				$featured_image = $single_post['featured_image'];
782
				/**
783
				 * Allows setting up custom Photon parameters to manipulate the image output in the Display Posts widget.
784
				 *
785
				 * @see    https://developer.wordpress.com/docs/photon/
786
				 *
787
				 * @module widgets
788
				 *
789
				 * @since  3.6.0
790
				 *
791
				 * @param array $args Array of Photon Parameters.
792
				 */
793
				$image_params = apply_filters( 'jetpack_display_posts_widget_image_params', array() );
794
				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>';
795
			}
796
797
			if ( $instance['show_excerpts'] == true ) {
798
				echo $single_post['excerpt'];
799
			}
800
		}
801
802
		echo '</div><!-- .jetpack-display-remote-posts -->';
803
		echo $args['after_widget'];
804
	}
805
806
	/**
807
	 * Scan and extract first error from blog data array.
808
	 *
809
	 * @param array|WP_Error $blog_data Blog data to scan for errors.
810
	 *
811
	 * @return string First error message found
812
	 */
813
	public function extract_errors_from_blog_data( $blog_data ) {
814
815
		$errors = array(
816
			'message' => '',
817
			'debug'   => '',
818
			'where'   => '',
819
		);
820
821
822
		/**
823
		 * When the cache result is an error. Usually when the cache is empty.
824
		 * This is not an error case for now.
825
		 */
826
		if ( is_wp_error( $blog_data ) ) {
827
			return $errors;
828
		}
829
830
		/**
831
		 * Loop through `site_info` and `posts` keys of $blog_data.
832
		 */
833
		foreach ( array( 'site_info', 'posts' ) as $info_key ) {
834
835
			/**
836
			 * Contains information on which stage the error ocurred.
837
			 */
838
			$errors['where'] = $info_key;
839
840
			/**
841
			 * If an error is set, we want to check it for usable messages.
842
			 */
843
			if ( isset( $blog_data[ $info_key ]['error'] ) && ! empty( $blog_data[ $info_key ]['error'] ) ) {
844
845
				/**
846
				 * Extract error message from the error, if possible.
847
				 */
848
				if ( is_wp_error( $blog_data[ $info_key ]['error'] ) ) {
849
					/**
850
					 * In the case of WP_Error we want to have the error message
851
					 * and the debug information available.
852
					 */
853
					$error_messages    = $blog_data[ $info_key ]['error']->get_error_messages();
854
					$errors['message'] = reset( $error_messages );
855
856
					$extra_data = $blog_data[ $info_key ]['error']->get_error_data();
857
					if ( is_array( $extra_data ) ) {
858
						$errors['debug'] = implode( '; ', $extra_data );
859
					}
860
					else {
861
						$errors['debug'] = $extra_data;
862
					}
863
864
					break;
865
				}
866
				elseif ( is_array( $blog_data[ $info_key ]['error'] ) ) {
867
					/**
868
					 * In this case we don't have debug information, because
869
					 * we have no way to know the format. The widget works with
870
					 * WP_Error objects only.
871
					 */
872
					$errors['message'] = reset( $blog_data[ $info_key ]['error'] );
873
					break;
874
				}
875
876
				/**
877
				 * We do nothing if no usable error is found.
878
				 */
879
			}
880
		}
881
882
		return $errors;
883
	}
884
885
	/**
886
	 * Display the widget administration form.
887
	 *
888
	 * @param array $instance Widget instance configuration.
889
	 *
890
	 * @return string|void
891
	 */
892
	public function form( $instance ) {
893
894
		/**
895
		 * Initialize widget configuration variables.
896
		 */
897
		$title              = ( isset( $instance['title'] ) ) ? $instance['title'] : __( 'Recent Posts', 'jetpack' );
898
		$url                = ( isset( $instance['url'] ) ) ? $instance['url'] : '';
899
		$number_of_posts    = ( isset( $instance['number_of_posts'] ) ) ? $instance['number_of_posts'] : 5;
900
		$open_in_new_window = ( isset( $instance['open_in_new_window'] ) ) ? $instance['open_in_new_window'] : false;
901
		$featured_image     = ( isset( $instance['featured_image'] ) ) ? $instance['featured_image'] : false;
902
		$show_excerpts      = ( isset( $instance['show_excerpts'] ) ) ? $instance['show_excerpts'] : false;
903
904
905
		/**
906
		 * Check if the widget instance has errors available.
907
		 *
908
		 * Only do so if a URL is set.
909
		 */
910
		$update_errors = array();
911
912
		if ( ! empty( $url ) ) {
913
			$data          = $this->get_blog_data( $url );
914
			$update_errors = $this->extract_errors_from_blog_data( $data );
915
		}
916
917
		?>
918
		<p>
919
			<label for="<?php echo $this->get_field_id( 'title' ); ?>"><?php _e( 'Title:', 'jetpack' ); ?></label>
920
			<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 ); ?>" />
921
		</p>
922
923
		<p>
924
			<label for="<?php echo $this->get_field_id( 'url' ); ?>"><?php _e( 'Blog URL:', 'jetpack' ); ?></label>
925
			<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 ); ?>" />
926
			<i>
927
				<?php _e( "Enter a WordPress.com or Jetpack WordPress site URL.", 'jetpack' ); ?>
928
			</i>
929
			<?php
930
			if ( empty( $url ) ) {
931
				?>
932
				<br />
933
				<i class="error-message"><?php echo __( 'You must specify a valid blog URL!', 'jetpack' ); ?></i>
934
				<?php
935
			}
936
			?>
937
		</p>
938
		<p>
939
			<label for="<?php echo $this->get_field_id( 'number_of_posts' ); ?>"><?php _e( 'Number of Posts to Display:', 'jetpack' ); ?></label>
940
			<select name="<?php echo $this->get_field_name( 'number_of_posts' ); ?>">
941
				<?php
942
				for ( $i = 1; $i <= 10; $i ++ ) {
943
					echo '<option value="' . $i . '" ' . selected( $number_of_posts, $i ) . '>' . $i . '</option>';
944
				}
945
				?>
946
			</select>
947
		</p>
948
		<p>
949
			<label for="<?php echo $this->get_field_id( 'open_in_new_window' ); ?>"><?php _e( 'Open links in new window/tab:', 'jetpack' ); ?></label>
950
			<input type="checkbox" name="<?php echo $this->get_field_name( 'open_in_new_window' ); ?>" <?php checked( $open_in_new_window, 1 ); ?> />
951
		</p>
952
		<p>
953
			<label for="<?php echo $this->get_field_id( 'featured_image' ); ?>"><?php _e( 'Show Featured Image:', 'jetpack' ); ?></label>
954
			<input type="checkbox" name="<?php echo $this->get_field_name( 'featured_image' ); ?>" <?php checked( $featured_image, 1 ); ?> />
955
		</p>
956
		<p>
957
			<label for="<?php echo $this->get_field_id( 'show_excerpts' ); ?>"><?php _e( 'Show Excerpts:', 'jetpack' ); ?></label>
958
			<input type="checkbox" name="<?php echo $this->get_field_name( 'show_excerpts' ); ?>" <?php checked( $show_excerpts, 1 ); ?> />
959
		</p>
960
961
		<?php
962
963
		/**
964
		 * Show error messages.
965
		 */
966
		if ( ! empty( $update_errors['message'] ) ) {
967
968
			/**
969
			 * Prepare the error messages.
970
			 */
971
972
			$where_message = '';
973
			switch ( $update_errors['where'] ) {
974
				case 'posts':
975
					$where_message .= __( 'An error occurred while downloading blog posts list', 'jetpack' );
976
					break;
977
978
				/**
979
				 * If something else, beside `posts` and `site_info` broke,
980
				 * don't handle it and default to blog `information`,
981
				 * as it is generic enough.
982
				 */
983
				case 'site_info':
984
				default:
985
					$where_message .= __( 'An error occurred while downloading blog information', 'jetpack' );
986
					break;
987
			}
988
989
			?>
990
			<p class="error-message">
991
				<?php echo esc_html( $where_message ); ?>:
992
				<br />
993
				<i>
994
					<?php echo esc_html( $update_errors['message'] ); ?>
995
					<?php
996
					/**
997
					 * If there is any debug - show it here.
998
					 */
999
					if ( ! empty( $update_errors['debug'] ) ) {
1000
						?>
1001
						<br />
1002
						<br />
1003
						<?php esc_html_e( 'Detailed information', 'jetpack' ); ?>:
1004
						<br />
1005
						<?php echo esc_html( $update_errors['debug'] ); ?>
1006
						<?php
1007
					}
1008
					?>
1009
				</i>
1010
			</p>
1011
1012
			<?php
1013
		}
1014
	}
1015
1016
	public function update( $new_instance, $old_instance ) {
1017
1018
		$instance          = array();
1019
		$instance['title'] = ( ! empty( $new_instance['title'] ) ) ? strip_tags( $new_instance['title'] ) : '';
1020
		$instance['url']   = ( ! empty( $new_instance['url'] ) ) ? strip_tags( $new_instance['url'] ) : '';
1021
		$instance['url']   = preg_replace( "!^https?://!is", "", $instance['url'] );
1022
		$instance['url']   = untrailingslashit( $instance['url'] );
1023
1024
1025
		/**
1026
		 * Check if the URL should be with or without the www prefix before saving.
1027
		 */
1028
		if ( ! empty( $instance['url'] ) ) {
1029
			$blog_data = $this->fetch_blog_data( $instance['url'], array(), true );
1030
1031
			if ( is_wp_error( $blog_data['site_info']['error'] ) && 'www.' === substr( $instance['url'], 0, 4 ) ) {
1032
				$blog_data = $this->fetch_blog_data( substr( $instance['url'], 4 ), array(), true );
1033
1034
				if ( ! is_wp_error( $blog_data['site_info']['error'] ) ) {
1035
					$instance['url'] = substr( $instance['url'], 4 );
1036
				}
1037
			}
1038
		}
1039
1040
		$instance['number_of_posts']    = ( ! empty( $new_instance['number_of_posts'] ) ) ? intval( $new_instance['number_of_posts'] ) : '';
1041
		$instance['open_in_new_window'] = ( ! empty( $new_instance['open_in_new_window'] ) ) ? true : '';
1042
		$instance['featured_image']     = ( ! empty( $new_instance['featured_image'] ) ) ? true : '';
1043
		$instance['show_excerpts']      = ( ! empty( $new_instance['show_excerpts'] ) ) ? true : '';
1044
1045
		/**
1046
		 * Forcefully activate the update cron when saving widget instance.
1047
		 *
1048
		 * So we can be sure that it will be running later.
1049
		 */
1050
		$this->activate_cron();
1051
1052
1053
		/**
1054
		 * If there is no cache entry for the specified URL, run a forced update.
1055
		 *
1056
		 * @see get_blog_data Returns WP_Error if the cache is empty, which is what is needed here.
1057
		 */
1058
		$cached_data = $this->get_blog_data( $instance['url'] );
1059
1060
		if ( is_wp_error( $cached_data ) ) {
1061
			$this->update_instance( $instance['url'] );
1062
		}
1063
1064
		return $instance;
1065
	}
1066
1067
	/**
1068
	 * This is just to make method mocks in the unit tests easier.
1069
	 *
1070
	 * @param string $param Option key to get
1071
	 *
1072
	 * @return mixed
1073
	 *
1074
	 * @codeCoverageIgnore
1075
	 */
1076
	public function wp_get_option( $param ) {
1077
		return get_option( $param );
1078
	}
1079
1080
	/**
1081
	 * This is just to make method mocks in the unit tests easier.
1082
	 *
1083
	 * @param string $option_name  Option name to be added
1084
	 * @param mixed  $option_value Option value
1085
	 *
1086
	 * @return mixed
1087
	 *
1088
	 * @codeCoverageIgnore
1089
	 */
1090
	public function wp_add_option( $option_name, $option_value ) {
1091
		return add_option( $option_name, $option_value );
1092
	}
1093
1094
	/**
1095
	 * This is just to make method mocks in the unit tests easier.
1096
	 *
1097
	 * @param string $option_name  Option name to be updated
1098
	 * @param mixed  $option_value Option value
1099
	 *
1100
	 * @return mixed
1101
	 *
1102
	 * @codeCoverageIgnore
1103
	 */
1104
	public function wp_update_option( $option_name, $option_value ) {
1105
		return update_option( $option_name, $option_value );
1106
	}
1107
1108
1109
	/**
1110
	 * This is just to make method mocks in the unit tests easier.
1111
	 *
1112
	 * @param string $url  The URL to fetch
1113
	 * @param array  $args Optional. Request arguments.
1114
	 *
1115
	 * @return array|WP_Error
1116
	 *
1117
	 * @codeCoverageIgnore
1118
	 */
1119
	public function wp_wp_remote_get( $url, $args = array() ) {
1120
		return wp_remote_get( $url, $args );
1121
	}
1122
}
1123