PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | modules/widgets/wordpress-post-widget/class.jetpack-display-posts-widget-base.php +850 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,850 @@
1 +<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 +
3 +use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
4 +
5 +if ( ! defined( 'ABSPATH' ) ) {
6 + exit( 0 );
7 +}
8 +
9 +/**
10 + * For back-compat, the final widget class must be named
11 + * Jetpack_Display_Posts_Widget.
12 + *
13 + * For convenience, it's nice to have a widget class constructor with no
14 + * arguments. Otherwise, we have to register the widget with an instance
15 + * instead of a class name. This makes unregistering annoying.
16 + *
17 + * Both WordPress.com and Jetpack implement the final widget class by
18 + * extending this __Base class and adding data fetching and storage.
19 + *
20 + * This would be a bit cleaner with dependency injection, but we already
21 + * use mocking to test, so it's not a big win.
22 + *
23 + * That this widget is currently implemented as these two classes
24 + * is an implementation detail and should not be depended on :)
25 + *
26 + * phpcs:disable PEAR.NamingConventions.ValidClassName.Invalid
27 + */
28 +abstract class Jetpack_Display_Posts_Widget__Base extends WP_Widget {
29 + // phpcs:enable PEAR.NamingConventions.ValidClassName.Invalid
30 +
31 + /**
32 + * Remote service API URL prefix.
33 + *
34 + * @var string
35 + */
36 + public $service_url = 'https://public-api.wordpress.com/rest/v1.1/';
37 +
38 + /**
39 + * Jetpack_Display_Posts_Widget__Base constructor.
40 + */
41 + public function __construct() {
42 + parent::__construct(
43 + // Internal id.
44 + 'jetpack_display_posts_widget',
45 + /** This filter is documented in modules/widgets/facebook-likebox.php */
46 + apply_filters( 'jetpack_widget_name', __( 'Display WordPress Posts', 'jetpack' ) ),
47 + array(
48 + 'description' => __( 'Displays a list of recent posts from another WordPress.com or Jetpack-enabled blog.', 'jetpack' ),
49 + 'customize_selective_refresh' => true,
50 + )
51 + );
52 +
53 + if ( is_customize_preview() ) {
54 + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
55 + }
56 + }
57 +
58 + /**
59 + * Enqueue CSS and JavaScript.
60 + *
61 + * @since 4.0.0
62 + */
63 + public function enqueue_scripts() {
64 + wp_enqueue_style(
65 + 'jetpack_display_posts_widget',
66 + plugins_url( 'style.css', __FILE__ ),
67 + array(),
68 + JETPACK__VERSION
69 + );
70 + }
71 +
72 + // DATA STORE: Must implement.
73 +
74 + /**
75 + * Gets blog data from the cache.
76 + *
77 + * @param string $site Site.
78 + *
79 + * @return array|WP_Error
80 + */
81 + abstract public function get_blog_data( $site );
82 +
83 + /**
84 + * Update a widget instance.
85 + *
86 + * @param string $site The site to fetch the latest data for.
87 + *
88 + * @return array - the new data
89 + */
90 + abstract public function update_instance( $site );
91 +
92 + // WIDGET API.
93 +
94 + /**
95 + * Set up the widget display on the front end.
96 + *
97 + * @param array $args Widget args.
98 + * @param array $instance Widget instance.
99 + */
100 + public function widget( $args, $instance ) {
101 + /** This action is documented in modules/widgets/gravatar-profile.php */
102 + do_action( 'jetpack_stats_extra', 'widget_view', 'display_posts' );
103 +
104 + // Enqueue front end assets.
105 + $this->enqueue_scripts();
106 +
107 + $content = $args['before_widget'];
108 +
109 + if ( empty( $instance['url'] ) ) {
110 + if ( current_user_can( 'manage_options' ) ) {
111 + $content .= '<p>';
112 + /* Translators: the "Blog URL" field mentioned is the input field labeled as such in the widget form. */
113 + $content .= esc_html__( 'The Blog URL is not properly set up in the widget.', 'jetpack' );
114 + $content .= '</p>';
115 + }
116 + $content .= $args['after_widget'];
117 +
118 + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
119 + return;
120 + }
121 +
122 + $data = $this->get_blog_data( $instance['url'] );
123 + // Check for errors.
124 + if ( is_wp_error( $data ) || empty( $data['site_info']['data'] ) ) {
125 + $content .= '<p>' . __( 'Cannot load blog information at this time.', 'jetpack' ) . '</p>';
126 + $content .= $args['after_widget'];
127 +
128 + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
129 + return;
130 + }
131 +
132 + $site_info = $data['site_info']['data'];
133 +
134 + if ( ! empty( $instance['title'] ) ) {
135 + /** This filter is documented in core/src/wp-includes/default-widgets.php */
136 + $instance['title'] = apply_filters( 'widget_title', $instance['title'] );
137 + $content .= $args['before_title'] . $instance['title'] . ': ' . esc_html( $site_info->name ) . $args['after_title'];
138 + } else {
139 + $content .= $args['before_title'] . esc_html( $site_info->name ) . $args['after_title'];
140 + }
141 +
142 + $content .= '<div class="jetpack-display-remote-posts">';
143 +
144 + if ( is_wp_error( $data['posts']['data'] ) || empty( $data['posts']['data'] ) ) {
145 + $content .= '<p>' . __( 'Cannot load blog posts at this time.', 'jetpack' ) . '</p>';
146 + $content .= '</div><!-- .jetpack-display-remote-posts -->';
147 + $content .= $args['after_widget'];
148 +
149 + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
150 + return;
151 + }
152 +
153 + $posts_list = $data['posts']['data'];
154 +
155 + /**
156 + * Show only as much posts as we need. If we have less than configured amount,
157 + * we must show only that much posts.
158 + */
159 + $number_of_posts = min( $instance['number_of_posts'], is_countable( $posts_list ) ? count( $posts_list ) : 0 );
160 +
161 + for ( $i = 0; $i < $number_of_posts; $i++ ) {
162 + $single_post = $posts_list[ $i ];
163 + $post_title = ( $single_post['title'] ) ? $single_post['title'] : '( No Title )';
164 +
165 + $target = '';
166 + if ( isset( $instance['open_in_new_window'] ) && true === $instance['open_in_new_window'] ) {
167 + $target = ' target="_blank" rel="noopener"';
168 + }
169 + $content .= '<h4><a href="' . esc_url( $single_post['url'] ) . '"' . $target . '>' . esc_html( $post_title ) . '</a></h4>' . "\n";
170 + if ( ( true === $instance['featured_image'] ) && ( ! empty( $single_post['featured_image'] ) ) ) {
171 + $featured_image = $single_post['featured_image'];
172 + /**
173 + * Allows setting up custom Photon parameters to manipulate the image output in the Display Posts widget.
174 + *
175 + * @see https://developer.wordpress.com/docs/photon/
176 + *
177 + * @module widgets
178 + *
179 + * @since 3.6.0
180 + *
181 + * @param array $args Array of Photon Parameters.
182 + */
183 + $image_params = apply_filters( 'jetpack_display_posts_widget_image_params', array() );
184 + $content .= '<a title="' . esc_attr( $post_title ) . '" href="' . esc_url( $single_post['url'] ) . '"' . $target . '><img src="' . esc_url( Image_CDN_Core::cdn_url( $featured_image, $image_params ) ) . '" alt="' . esc_attr( $post_title ) . '"/></a>';
185 + }
186 +
187 + if ( true === $instance['show_excerpts'] ) {
188 + $content .= wp_kses_post( $single_post['excerpt'] );
189 + }
190 + }
191 +
192 + $content .= '</div><!-- .jetpack-display-remote-posts -->';
193 + $content .= $args['after_widget'];
194 +
195 + /**
196 + * Filter the WordPress Posts widget content.
197 + *
198 + * @module widgets
199 + *
200 + * @since 4.7.0
201 + *
202 + * @param string $content Widget content.
203 + */
204 + echo apply_filters( 'jetpack_display_posts_widget_content', $content ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
205 + }
206 +
207 + /**
208 + * Display the widget administration form.
209 + *
210 + * @param array $instance Widget instance configuration.
211 + *
212 + * @return string|void
213 + */
214 + public function form( $instance ) {
215 +
216 + /**
217 + * Initialize widget configuration variables.
218 + */
219 + $title = $instance['title'] ?? __( 'Recent Posts', 'jetpack' );
220 + $url = $instance['url'] ?? '';
221 + $number_of_posts = $instance['number_of_posts'] ?? 5;
222 + $open_in_new_window = $instance['open_in_new_window'] ?? false;
223 + $featured_image = $instance['featured_image'] ?? false;
224 + $show_excerpts = $instance['show_excerpts'] ?? false;
225 +
226 + /**
227 + * Check if the widget instance has errors available.
228 + *
229 + * Only do so if a URL is set.
230 + */
231 + $update_errors = array();
232 +
233 + if ( ! empty( $url ) ) {
234 + $data = $this->get_blog_data( $url );
235 + $update_errors = $this->extract_errors_from_blog_data( $data );
236 + }
237 +
238 + ?>
239 + <p>
240 + <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'jetpack' ); ?></label>
241 + <input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>" />
242 + </p>
243 +
244 + <p>
245 + <label for="<?php echo esc_attr( $this->get_field_id( 'url' ) ); ?>"><?php esc_html_e( 'Blog URL:', 'jetpack' ); ?></label>
246 + <input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'url' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'url' ) ); ?>" type="text" value="<?php echo esc_attr( $url ); ?>" />
247 + <i>
248 + <?php esc_html_e( 'Enter a WordPress.com or Jetpack WordPress site URL.', 'jetpack' ); ?>
249 + </i>
250 + <?php
251 + /**
252 + * Show an error if the URL field was left empty.
253 + *
254 + * The error is shown only when the widget was already saved.
255 + */
256 + if ( empty( $url ) && ! preg_match( '/__i__|%i%/', $this->id ) ) {
257 + ?>
258 + <br />
259 + <i class="error-message"><?php esc_html_e( 'You must specify a valid blog URL!', 'jetpack' ); ?></i>
260 + <?php
261 + }
262 + ?>
263 + </p>
264 + <p>
265 + <label for="<?php echo esc_attr( $this->get_field_id( 'number_of_posts' ) ); ?>"><?php esc_html_e( 'Number of Posts to Display:', 'jetpack' ); ?></label>
266 + <select name="<?php echo esc_attr( $this->get_field_name( 'number_of_posts' ) ); ?>">
267 + <?php
268 + for ( $i = 1; $i <= 10; $i++ ) {
269 + echo '<option value="' . esc_attr( $i ) . '" ' . selected( $number_of_posts, $i ) . '>' . esc_html( $i ) . '</option>';
270 + }
271 + ?>
272 + </select>
273 + </p>
274 + <p>
275 + <label for="<?php echo esc_attr( $this->get_field_id( 'open_in_new_window' ) ); ?>"><?php esc_html_e( 'Open links in new window/tab:', 'jetpack' ); ?></label>
276 + <input type="checkbox" name="<?php echo esc_attr( $this->get_field_name( 'open_in_new_window' ) ); ?>" <?php checked( $open_in_new_window, 1 ); ?> />
277 + </p>
278 + <p>
279 + <label for="<?php echo esc_attr( $this->get_field_id( 'featured_image' ) ); ?>"><?php esc_html_e( 'Show Featured Image:', 'jetpack' ); ?></label>
280 + <input type="checkbox" name="<?php echo esc_attr( $this->get_field_name( 'featured_image' ) ); ?>" <?php checked( $featured_image, 1 ); ?> />
281 + </p>
282 + <p>
283 + <label for="<?php echo esc_attr( $this->get_field_id( 'show_excerpts' ) ); ?>"><?php esc_html_e( 'Show Excerpts:', 'jetpack' ); ?></label>
284 + <input type="checkbox" name="<?php echo esc_attr( $this->get_field_name( 'show_excerpts' ) ); ?>" <?php checked( $show_excerpts, 1 ); ?> />
285 + </p>
286 +
287 + <?php
288 +
289 + /**
290 + * Show error messages.
291 + */
292 + if ( ! empty( $update_errors['message'] ) ) {
293 +
294 + /**
295 + * Prepare the error messages.
296 + */
297 +
298 + $where_message = '';
299 + switch ( $update_errors['where'] ) {
300 + case 'posts':
301 + $where_message .= __( 'An error occurred while downloading blog posts list', 'jetpack' );
302 + break;
303 +
304 + /**
305 + * If something else, beside `posts` and `site_info` broke,
306 + * don't handle it and default to blog `information`,
307 + * as it is generic enough.
308 + */
309 + case 'site_info':
310 + default:
311 + $where_message .= __( 'An error occurred while downloading blog information', 'jetpack' );
312 + break;
313 + }
314 +
315 + ?>
316 + <p class="error-message">
317 + <?php echo esc_html( $where_message ); ?>:
318 + <br />
319 + <i>
320 + <?php echo esc_html( $update_errors['message'] ); ?>
321 + <?php
322 + /**
323 + * If there is any debug - show it here.
324 + */
325 + if ( ! empty( $update_errors['debug'] ) ) {
326 + ?>
327 + <br />
328 + <br />
329 + <?php esc_html_e( 'Detailed information', 'jetpack' ); ?>:
330 + <br />
331 + <?php echo esc_html( $update_errors['debug'] ); ?>
332 + <?php
333 + }
334 + ?>
335 + </i>
336 + </p>
337 +
338 + <?php
339 + }
340 + }
341 +
342 + /**
343 + * Widget update function.
344 + *
345 + * @param array $new_instance New instance widget settings.
346 + * @param array $old_instance Old instance widget settings.
347 + */
348 + public function update( $new_instance, $old_instance ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
349 +
350 + $instance = array();
351 + $instance['title'] = ( ! empty( $new_instance['title'] ) ) ? wp_strip_all_tags( $new_instance['title'] ) : '';
352 + $instance['url'] = ( ! empty( $new_instance['url'] ) ) ? wp_strip_all_tags( trim( $new_instance['url'] ) ) : '';
353 + $instance['url'] = preg_replace( '!^https?://!is', '', $instance['url'] );
354 + $instance['url'] = untrailingslashit( $instance['url'] );
355 +
356 + /**
357 + * Check if the URL should be with or without the www prefix before saving.
358 + */
359 + if ( ! empty( $instance['url'] ) ) {
360 + $blog_data = $this->fetch_blog_data( $instance['url'], array(), true );
361 +
362 + if ( is_wp_error( $blog_data['site_info']['error'] ) && str_starts_with( $instance['url'], 'www.' ) ) {
363 + $blog_data = $this->fetch_blog_data( substr( $instance['url'], 4 ), array(), true );
364 +
365 + if ( ! is_wp_error( $blog_data['site_info']['error'] ) ) {
366 + $instance['url'] = substr( $instance['url'], 4 );
367 + }
368 + }
369 + }
370 +
371 + $instance['number_of_posts'] = ( ! empty( $new_instance['number_of_posts'] ) ) ? (int) $new_instance['number_of_posts'] : '';
372 + $instance['open_in_new_window'] = ( ! empty( $new_instance['open_in_new_window'] ) ) ? true : '';
373 + $instance['featured_image'] = ( ! empty( $new_instance['featured_image'] ) ) ? true : '';
374 + $instance['show_excerpts'] = ( ! empty( $new_instance['show_excerpts'] ) ) ? true : '';
375 +
376 + /**
377 + * If there is no cache entry for the specified URL, run a forced update.
378 + *
379 + * @see get_blog_data Returns WP_Error if the cache is empty, which is what is needed here.
380 + */
381 + $cached_data = $this->get_blog_data( $instance['url'] );
382 +
383 + if ( is_wp_error( $cached_data ) ) {
384 + $this->update_instance( $instance['url'] );
385 + }
386 +
387 + return $instance;
388 + }
389 +
390 + // DATA PROCESSING.
391 +
392 + /**
393 + * Expiring transients have a name length maximum of 45 characters,
394 + * so this function returns an abbreviated MD5 hash to use instead of
395 + * the full URI.
396 + *
397 + * @param string $site Site to get the hash for.
398 + *
399 + * @return string
400 + */
401 + public function get_site_hash( $site ) {
402 + return substr( md5( $site ), 0, 21 );
403 + }
404 +
405 + /**
406 + * Fetch a remote service endpoint and parse it.
407 + *
408 + * Timeout is set to 15 seconds right now, because sometimes the WordPress API
409 + * takes more than 5 seconds to fully respond.
410 + *
411 + * Caching is used here so we can avoid re-downloading the same endpoint
412 + * in a single request.
413 + *
414 + * @param string $endpoint Parametrized endpoint to call.
415 + *
416 + * @param int $timeout How much time to wait for the API to respond before failing.
417 + *
418 + * @return array|WP_Error
419 + */
420 + public function fetch_service_endpoint( $endpoint, $timeout = 15 ) {
421 +
422 + /**
423 + * Holds endpoint request cache.
424 + */
425 + static $cache = array();
426 +
427 + if ( ! isset( $cache[ $endpoint ] ) ) {
428 + $raw_data = $this->wp_wp_remote_get( $this->service_url . ltrim( $endpoint, '/' ), array( 'timeout' => $timeout ) );
429 + $cache[ $endpoint ] = $this->parse_service_response( $raw_data );
430 + }
431 +
432 + return $cache[ $endpoint ];
433 + }
434 +
435 + /**
436 + * Parse data from service response.
437 + * Do basic error handling for general service and data errors
438 + *
439 + * @param array $service_response Response from the service.
440 + *
441 + * @return array|WP_Error
442 + */
443 + public function parse_service_response( $service_response ) {
444 + /**
445 + * If there is an error, we add the error message to the parsed response
446 + */
447 + if ( is_wp_error( $service_response ) ) {
448 + return new WP_Error(
449 + 'general_error',
450 + __( 'An error occurred fetching the remote data.', 'jetpack' ),
451 + $service_response->get_error_messages()
452 + );
453 + }
454 +
455 + /**
456 + * Validate HTTP response code.
457 + */
458 + if ( 200 !== wp_remote_retrieve_response_code( $service_response ) ) {
459 + return new WP_Error(
460 + 'http_error',
461 + __( 'An error occurred fetching the remote data.', 'jetpack' ),
462 + wp_remote_retrieve_response_message( $service_response )
463 + );
464 + }
465 +
466 + /**
467 + * Extract service response body from the request.
468 + */
469 +
470 + $service_response_body = wp_remote_retrieve_body( $service_response );
471 +
472 + /**
473 + * No body has been set in the response. This should be pretty bad.
474 + */
475 + if ( ! $service_response_body ) {
476 + return new WP_Error(
477 + 'no_body',
478 + __( 'Invalid remote response.', 'jetpack' ),
479 + 'No body in response.'
480 + );
481 + }
482 +
483 + /**
484 + * Parse the JSON response from the API. Convert to associative array.
485 + */
486 + $parsed_data = json_decode( $service_response_body );
487 +
488 + /**
489 + * If there is a problem with parsing the posts return an empty array.
490 + */
491 + if ( $parsed_data === null ) {
492 + return new WP_Error(
493 + 'no_body',
494 + __( 'Invalid remote response.', 'jetpack' ),
495 + 'Invalid JSON from remote.'
496 + );
497 + }
498 +
499 + /**
500 + * Check for errors in the parsed body.
501 + */
502 + if ( isset( $parsed_data->error ) ) {
503 + return new WP_Error(
504 + 'remote_error',
505 + __( 'It looks like the WordPress site URL is incorrectly configured. Please check it in your widget settings.', 'jetpack' ),
506 + $parsed_data->error
507 + );
508 + }
509 +
510 + /**
511 + * No errors found, return parsed data.
512 + */
513 + return $parsed_data;
514 + }
515 +
516 + /**
517 + * Fetch site information from the WordPress public API
518 + *
519 + * @param string $site URL of the site to fetch the information for.
520 + *
521 + * @return array|WP_Error
522 + */
523 + public function fetch_site_info( $site ) {
524 +
525 + $response = $this->fetch_service_endpoint( sprintf( '/sites/%s', rawurlencode( $site ) ) );
526 +
527 + return $response;
528 + }
529 +
530 + /**
531 + * Parse external API response from the site info call and handle errors if they occur.
532 + *
533 + * @param array|WP_Error $service_response The raw response to be parsed.
534 + *
535 + * @return array|WP_Error
536 + */
537 + public function parse_site_info_response( $service_response ) {
538 +
539 + /**
540 + * If the service returned an error, we pass it on.
541 + */
542 + if ( is_wp_error( $service_response ) ) {
543 + return $service_response;
544 + }
545 +
546 + /**
547 + * Check if the service returned proper site information.
548 + */
549 + if ( ! isset( $service_response->ID ) ) {
550 + return new WP_Error(
551 + 'no_site_info',
552 + __( 'Invalid site information returned from remote.', 'jetpack' ),
553 + 'No site ID present in the response.'
554 + );
555 + }
556 +
557 + return $service_response;
558 + }
559 +
560 + /**
561 + * Fetch list of posts from the WordPress public API.
562 + *
563 + * @param int $site_id The site to fetch the posts for.
564 + *
565 + * @return array|WP_Error
566 + */
567 + public function fetch_posts_for_site( $site_id ) {
568 +
569 + $response = $this->fetch_service_endpoint(
570 + sprintf(
571 + '/sites/%1$d/posts/%2$s',
572 + $site_id,
573 + /**
574 + * Filters the parameters used to fetch for posts in the Display Posts Widget.
575 + *
576 + * @see https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/
577 + *
578 + * @module widgets
579 + *
580 + * @since 3.6.0
581 + *
582 + * @param string $args Extra parameters to filter posts returned from the WordPress.com REST API.
583 + */
584 + apply_filters( 'jetpack_display_posts_widget_posts_params', '?fields=id,title,excerpt,URL,featured_image' )
585 + )
586 + );
587 +
588 + return $response;
589 + }
590 +
591 + /**
592 + * Parse external API response from the posts list request and handle errors if any occur.
593 + *
594 + * @param object|WP_Error $service_response The raw response to be parsed.
595 + *
596 + * @return array|WP_Error
597 + */
598 + public function parse_posts_response( $service_response ) {
599 +
600 + /**
601 + * If the service returned an error, we pass it on.
602 + */
603 + if ( is_wp_error( $service_response ) ) {
604 + return $service_response;
605 + }
606 +
607 + /**
608 + * Check if the service returned proper posts array.
609 + */
610 + if ( ! isset( $service_response->posts ) || ! is_array( $service_response->posts ) ) {
611 + return new WP_Error(
612 + 'no_posts',
613 + __( 'No posts data returned by remote.', 'jetpack' ),
614 + 'No posts information set in the returned data.'
615 + );
616 + }
617 +
618 + /**
619 + * Format the posts to preserve storage space.
620 + */
621 +
622 + return $this->format_posts_for_storage( $service_response );
623 + }
624 +
625 + /**
626 + * Format the posts for better storage. Drop all the data that is not used.
627 + *
628 + * @param object $parsed_data Array of posts returned by the APIs.
629 + *
630 + * @return array Formatted posts or an empty array if no posts were found.
631 + */
632 + public function format_posts_for_storage( $parsed_data ) {
633 +
634 + $formatted_posts = array();
635 +
636 + /**
637 + * Only go through the posts list if we have valid posts array.
638 + */
639 + if ( isset( $parsed_data->posts ) && is_array( $parsed_data->posts ) ) {
640 +
641 + /**
642 + * Loop through all the posts and format them appropriately.
643 + */
644 + foreach ( $parsed_data->posts as $single_post ) {
645 +
646 + $prepared_post = array(
647 + 'title' => $single_post->title ? $single_post->title : '',
648 + 'excerpt' => $single_post->excerpt ? $single_post->excerpt : '',
649 + 'featured_image' => $single_post->featured_image ? $single_post->featured_image : '',
650 + 'url' => $single_post->URL, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
651 + );
652 +
653 + /**
654 + * Append the formatted post to the results.
655 + */
656 + $formatted_posts[] = $prepared_post;
657 + }
658 + }
659 +
660 + return $formatted_posts;
661 + }
662 +
663 + /**
664 + * Fetch site information and posts list for a site.
665 + *
666 + * @param string $site Site to fetch the data for.
667 + * @param array $original_data Optional original data to updated.
668 + *
669 + * @param bool $site_data_only Fetch only site information, skip posts list.
670 + *
671 + * @return array Updated or new data.
672 + */
673 + public function fetch_blog_data( $site, $original_data = array(), $site_data_only = false ) {
674 +
675 + /**
676 + * If no optional data is supplied, initialize a new structure
677 + */
678 + if ( ! empty( $original_data ) && is_array( $original_data ) ) {
679 + $widget_data = $original_data;
680 + } else {
681 + $widget_data = array(
682 + 'site_info' => array(
683 + 'last_check' => null,
684 + 'last_update' => null,
685 + 'error' => null,
686 + 'data' => array(),
687 + ),
688 + 'posts' => array(
689 + 'last_check' => null,
690 + 'last_update' => null,
691 + 'error' => null,
692 + 'data' => array(),
693 + ),
694 + );
695 + }
696 +
697 + /**
698 + * Update check time and fetch site information.
699 + */
700 + $widget_data['site_info']['last_check'] = time();
701 +
702 + $site_info_raw_data = $this->fetch_site_info( $site );
703 + $site_info_parsed_data = $this->parse_site_info_response( $site_info_raw_data );
704 +
705 + /**
706 + * If there is an error with the fetched site info, save the error and update the checked time.
707 + */
708 + if ( is_wp_error( $site_info_parsed_data ) ) {
709 + $widget_data['site_info']['error'] = $site_info_parsed_data;
710 +
711 + return $widget_data;
712 + } else {
713 + /**
714 + * If data is fetched successfully, update the data and set the proper time.
715 + *
716 + * Data is only updated if we have valid results. This is done this way so we can show
717 + * something if external service is down.
718 + */
719 + $widget_data['site_info']['last_update'] = time();
720 + $widget_data['site_info']['data'] = $site_info_parsed_data;
721 + $widget_data['site_info']['error'] = null;
722 + }
723 +
724 + /**
725 + * If only site data is needed, return it here, don't fetch posts data.
726 + */
727 + if ( true === $site_data_only ) {
728 + return $widget_data;
729 + }
730 +
731 + /**
732 + * Update check time and fetch posts list.
733 + */
734 + $widget_data['posts']['last_check'] = time();
735 +
736 + $site_posts_raw_data = $this->fetch_posts_for_site( $site_info_parsed_data->ID );
737 + $site_posts_parsed_data = $this->parse_posts_response( $site_posts_raw_data );
738 +
739 + /**
740 + * If there is an error with the fetched posts, save the error and update the checked time.
741 + */
742 + if ( is_wp_error( $site_posts_parsed_data ) ) {
743 + $widget_data['posts']['error'] = $site_posts_parsed_data;
744 +
745 + return $widget_data;
746 + } else {
747 + /**
748 + * If data is fetched successfully, update the data and set the proper time.
749 + *
750 + * Data is only updated if we have valid results. This is done this way so we can show
751 + * something if external service is down.
752 + */
753 + $widget_data['posts']['last_update'] = time();
754 + $widget_data['posts']['data'] = $site_posts_parsed_data;
755 + $widget_data['posts']['error'] = null;
756 + }
757 +
758 + return $widget_data;
759 + }
760 +
761 + /**
762 + * Scan and extract first error from blog data array.
763 + *
764 + * @param array|WP_Error $blog_data Blog data to scan for errors.
765 + *
766 + * @return string First error message found
767 + */
768 + public function extract_errors_from_blog_data( $blog_data ) {
769 +
770 + $errors = array(
771 + 'message' => '',
772 + 'debug' => '',
773 + 'where' => '',
774 + );
775 +
776 + /**
777 + * When the cache result is an error. Usually when the cache is empty.
778 + * This is not an error case for now.
779 + */
780 + if ( is_wp_error( $blog_data ) ) {
781 + return $errors;
782 + }
783 +
784 + /**
785 + * Loop through `site_info` and `posts` keys of $blog_data.
786 + */
787 + foreach ( array( 'site_info', 'posts' ) as $info_key ) {
788 +
789 + /**
790 + * Contains information on which stage the error ocurred.
791 + */
792 + $errors['where'] = $info_key;
793 +
794 + /**
795 + * If an error is set, we want to check it for usable messages.
796 + */
797 + if ( isset( $blog_data[ $info_key ]['error'] ) && ! empty( $blog_data[ $info_key ]['error'] ) ) {
798 +
799 + /**
800 + * Extract error message from the error, if possible.
801 + */
802 + if ( is_wp_error( $blog_data[ $info_key ]['error'] ) ) {
803 + /**
804 + * In the case of WP_Error we want to have the error message
805 + * and the debug information available.
806 + */
807 + $error_messages = $blog_data[ $info_key ]['error']->get_error_messages();
808 + $errors['message'] = reset( $error_messages );
809 +
810 + $extra_data = $blog_data[ $info_key ]['error']->get_error_data();
811 + if ( is_array( $extra_data ) ) {
812 + $errors['debug'] = implode( '; ', $extra_data );
813 + } else {
814 + $errors['debug'] = $extra_data;
815 + }
816 +
817 + break;
818 + } elseif ( is_array( $blog_data[ $info_key ]['error'] ) ) {
819 + /**
820 + * In this case we don't have debug information, because
821 + * we have no way to know the format. The widget works with
822 + * WP_Error objects only.
823 + */
824 + $errors['message'] = reset( $blog_data[ $info_key ]['error'] );
825 + break;
826 + }
827 +
828 + /**
829 + * We do nothing if no usable error is found.
830 + */
831 + }
832 + }
833 +
834 + return $errors;
835 + }
836 +
837 + /**
838 + * This is just to make method mocks in the unit tests easier.
839 + *
840 + * @param string $url The URL to fetch.
841 + * @param array $args Optional. Request arguments.
842 + *
843 + * @return array|WP_Error
844 + *
845 + * @codeCoverageIgnore
846 + */
847 + public function wp_wp_remote_get( $url, $args = array() ) {
848 + return wp_remote_get( $url, $args );
849 + }
850 +}