Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.11% covered (warning)
81.11%
73 / 90
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Top_Posts_Helper
81.11% covered (warning)
81.11%
73 / 90
0.00% covered (danger)
0.00%
0 / 1
29.21
0.00% covered (danger)
0.00%
0 / 1
 get_top_posts
81.11% covered (warning)
81.11%
73 / 90
0.00% covered (danger)
0.00%
0 / 1
29.21
1<?php
2/**
3 * Top Posts & Pages block helper.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Stats\WPCOM_Stats;
9
10/**
11 * Class Jetpack_Top_Posts_Helper
12 */
13class Jetpack_Top_Posts_Helper {
14    /**
15     * Returns user's top posts.
16     *
17     * @param int|string $period      Period of days to draw stats from, or 'all-time'.
18     * @param int        $items_count Optional. Number of items to display.
19     * @param string     $types       Optional. Content types to include.
20     * @return array
21     */
22    public static function get_top_posts( $period, $items_count = null, $types = null ) {
23        $all_time_days = floor( ( time() - strtotime( get_option( 'site_created_date' ) ) ) / ( 60 * 60 * 24 * 365 ) );
24
25        // While we only display ten posts, users can filter out content types.
26        // As such, we should obtain a few spare posts from the Stats endpoint.
27        $posts_to_obtain_count = 30;
28
29        // We should not override cache when displaying the block on the frontend.
30        // But we should allow instant preview of changes when editing the block.
31        $is_rendering_block = ! empty( $types );
32        $override_cache     = ! $is_rendering_block;
33
34        $query_args = array(
35            'max'       => $posts_to_obtain_count,
36            'summarize' => true,
37            'num'       => $period !== 'all-time' ? $period : $all_time_days,
38            'period'    => 'day',
39        );
40
41        // Atomic or self-hosted sites via WPCOM public v1.1 endpoint.
42        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
43            $data = ( new WPCOM_Stats() )->get_top_posts( $query_args, $override_cache );
44        } else {
45            // Directly access posts on WPCOM, as Simple sites run on the same environment.
46            require_lib( 'jetpack-stats' );
47            if ( class_exists( '\Jetpack\Stats\Top_Posts' ) ) {
48                // @phan-suppress-next-line PhanUndeclaredClassMethod
49                $data = ( new \Jetpack\Stats\Top_Posts() )->get_top_posts( get_current_blog_id(), $query_args );
50            } else {
51                $data = array( 'summary' => array( 'postviews' => array() ) );
52            }
53        }
54
55        if ( is_wp_error( $data ) ) {
56            $data = array( 'summary' => array( 'postviews' => array() ) );
57        }
58
59        $acceptable_types = $is_rendering_block ? explode( ',', $types ) : array();
60
61        // Remove posts that have subsequently been deleted. The endpoint can return more
62        // entries than the `max` we asked for, so stop there, then keep only further ones
63        // the block can render, so its type filter is never starved.
64        $published  = array();
65        $renderable = 0;
66
67        foreach ( (array) ( $data['summary']['postviews'] ?? array() ) as $item ) {
68            $capped = $is_rendering_block && count( $published ) >= $posts_to_obtain_count;
69
70            if ( $capped && $renderable >= $items_count ) {
71                break;
72            }
73            if ( get_post_status( $item['id'] ) !== 'publish' ) {
74                continue;
75            }
76
77            $can_render = $is_rendering_block
78                && ! empty( $item['public'] )
79                && in_array( $item['type'] ?? '', $acceptable_types, true );
80
81            if ( $capped && ! $can_render ) {
82                continue;
83            }
84
85            $published[] = $item;
86
87            if ( $can_render ) {
88                ++$renderable;
89            }
90        }
91
92        $data['summary']['postviews'] = $published;
93        $posts_retrieved              = count( $published );
94
95        // Fallback to random posts if user does not have enough top content.
96        if ( $posts_retrieved < $posts_to_obtain_count ) {
97            $args = array(
98                'numberposts' => $posts_to_obtain_count - $posts_retrieved,
99                'exclude'     => array_column( $data['summary']['postviews'], 'id' ),
100                'orderby'     => 'rand',
101                'post_status' => 'publish',
102            );
103
104            $random_posts = get_posts( $args );
105
106            foreach ( $random_posts as $post ) {
107                $random_posts_data = array(
108                    'id'     => $post->ID,
109                    'href'   => get_permalink( $post->ID ),
110                    'date'   => $post->post_date,
111                    'title'  => $post->post_title,
112                    'type'   => 'post',
113                    'public' => true,
114                );
115
116                $data['summary']['postviews'][] = $random_posts_data;
117            }
118
119            $data['summary']['postviews'] = array_slice( $data['summary']['postviews'], 0, 10 );
120        }
121
122        $top_posts = array();
123
124        foreach ( $data['summary']['postviews'] as $post ) {
125            // Non-public entries are discarded, so skip their thumbnail lookups entirely.
126            if ( empty( $post['public'] ) ) {
127                continue;
128            }
129
130            $post_id   = $post['id'];
131            $thumbnail = get_the_post_thumbnail_url( $post_id );
132
133            if ( ! $thumbnail ) {
134                $post_images = get_attached_media( 'image', $post_id );
135                $post_image  = reset( $post_images );
136                if ( $post_image ) {
137                    $thumbnail = wp_get_attachment_url( $post_image->ID );
138                }
139            }
140
141            $top_post = array(
142                'id'        => $post_id,
143                'author'    => get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) ),
144                'context'   => get_the_category( $post_id ) ? get_the_category( $post_id ) : get_the_tags( $post_id ),
145                // Use the local permalink to avoid stale values from the Stats API.
146                'href'      => get_permalink( $post_id ),
147                'date'      => get_the_date( '', $post_id ),
148                'title'     => $post['title'],
149                'type'      => $post['type'] ?? '',
150                'public'    => $post['public'],
151                'views'     => $post['views'] ?? 0,
152                'thumbnail' => $thumbnail,
153            );
154
155            /**
156             * Allows modifying the title of each individual post returned by the Top Posts helper.
157             *
158             * Applies to both the Top Posts block's front-end output and the REST
159             * response used by the block editor preview.
160             *
161             * @module stats
162             *
163             * @since 15.8
164             *
165             * @param string $post_title Post title.
166             * @param array  $top_post   Information about the post.
167             */
168            $top_post['title'] = apply_filters( 'jetpack_top_posts_item_title', $top_post['title'], $top_post );
169
170            $top_posts[] = $top_post;
171        }
172
173        // This applies for rendering the block front-end, but not for editing it.
174        if ( $is_rendering_block ) {
175            $top_posts = array_filter(
176                $top_posts,
177                function ( $item ) use ( $acceptable_types ) {
178                    return in_array( $item['type'], $acceptable_types, true );
179                }
180            );
181
182            $top_posts = array_slice( $top_posts, 0, $items_count );
183        }
184
185        return $top_posts;
186    }
187}