Completed
Push — update/grunion-personal-data ( beacb7...22114a )
by
unknown
14:02
created

Jetpack_Display_Posts_Widget::form()   F

Complexity

Conditions 15
Paths 3584

Size

Total Lines 128
Code Lines 65

Duplication

Lines 6
Ratio 4.69 %

Importance

Changes 0
Metric Value
cc 15
eloc 65
nc 3584
nop 1
dl 6
loc 128
rs 2
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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

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

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

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

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