Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.75% covered (danger)
48.75%
136 / 279
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Display_Posts_Widget__Base
49.10% covered (danger)
49.10%
136 / 277
53.33% covered (warning)
53.33%
8 / 15
1061.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 enqueue_scripts
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 get_blog_data
n/a
0 / 0
n/a
0 / 0
0
 update_instance
n/a
0 / 0
n/a
0 / 0
0
 widget
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
272
 form
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
272
 update
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
156
 get_site_hash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch_service_endpoint
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parse_service_response
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
6
 fetch_site_info
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parse_site_info_response
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 fetch_posts_for_site
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 parse_posts_response
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 format_posts_for_storage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 fetch_blog_data
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
5
 extract_errors_from_blog_data
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 wp_wp_remote_get
n/a
0 / 0
n/a
0 / 0
1
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
4
5if ( ! 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 */
28abstract 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'] . ': ' . $site_info->name . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
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="' . 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 .= $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              = ( isset( $instance['title'] ) ) ? $instance['title'] : __( 'Recent Posts', 'jetpack' );
220        $url                = ( isset( $instance['url'] ) ) ? $instance['url'] : '';
221        $number_of_posts    = ( isset( $instance['number_of_posts'] ) ) ? $instance['number_of_posts'] : 5;
222        $open_in_new_window = ( isset( $instance['open_in_new_window'] ) ) ? $instance['open_in_new_window'] : false;
223        $featured_image     = ( isset( $instance['featured_image'] ) ) ? $instance['featured_image'] : false;
224        $show_excerpts      = ( isset( $instance['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 ) ) {
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}