Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.38% covered (warning)
84.38%
108 / 128
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Post_List_Column
84.38% covered (warning)
84.38%
108 / 128
50.00% covered (danger)
50.00%
5 / 10
62.31
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 stats_load_admin_css
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 add_stats_post_table_cell
82.61% covered (warning)
82.61%
38 / 46
0.00% covered (danger)
0.00%
0 / 1
13.89
 add_stats_post_table
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
12.37
 get_post_page_views_for_current_list
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
11.10
 get_stats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_validated_locale
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_formatter
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 get_fallback_format_to_compact_version
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * A class that adds a stats column to wp-admin Post List.
4 *
5 * @package automattic/jetpack-stats-admin
6 */
7
8namespace Automattic\Jetpack\Stats_Admin;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Redirect;
12use Automattic\Jetpack\Stats\Options as Stats_Options;
13use Automattic\Jetpack\Stats\WPCOM_Stats;
14use Automattic\Jetpack\Status\Host;
15use NumberFormatter;
16
17/**
18 * Add a Stats column in the post and page lists.
19 */
20class Admin_Post_List_Column {
21
22    /**
23     * Create the object.
24     *
25     * @return self
26     */
27    public static function register() {
28        return new self();
29    }
30
31    /**
32     * A list of NumberFormatters.
33     *
34     * @var \NumberFormatter[]
35     */
36    private $formatter;
37
38    /**
39     * The current locale.
40     *
41     * @var string
42     */
43    private $locale;
44
45    /**
46     * The constructor.
47     */
48    public function __construct() {
49        // Add an icon to see stats in WordPress.com for a particular post.
50        add_action( 'admin_enqueue_scripts', array( $this, 'stats_load_admin_css' ) );
51
52        add_filter( 'manage_posts_columns', array( $this, 'add_stats_post_table' ) );
53        add_filter( 'manage_pages_columns', array( $this, 'add_stats_post_table' ) );
54
55        add_action( 'manage_posts_custom_column', array( $this, 'add_stats_post_table_cell' ), 10, 2 );
56        add_action( 'manage_pages_custom_column', array( $this, 'add_stats_post_table_cell' ), 10, 2 );
57    }
58
59    /**
60     * Load CSS needed for Stats column width in WP-Admin area.
61     *
62     * @since 4.7.0
63     * @since 0.33.0 Added the `$hook_suffix` parameter.
64     *
65     * @param string $hook_suffix The current admin page.
66     */
67    public function stats_load_admin_css( $hook_suffix = '' ) {
68        if ( 'edit.php' !== $hook_suffix ) {
69            return;
70        }
71
72        wp_add_inline_style(
73            'common',
74            '.wp-list-table.fixed .column-stats { width: 9em; white-space: nowrap; }'
75        );
76    }
77
78    /**
79     * Set content for cell with link to an entry's stats in Odyssey Stats.
80     *
81     * @param string $column  The name of the column to display.
82     * @param int    $post_id The current post ID.
83     *
84     * @since 4.7.0
85     */
86    public function add_stats_post_table_cell( $column, $post_id ) {
87        if ( 'stats' === $column ) {
88            if ( 'publish' !== get_post_status( $post_id ) ) {
89                printf(
90                    '<span aria-hidden="true">—</span><span class="screen-reader-text">%s</span>',
91                    esc_html__( 'No stats', 'jetpack-stats-admin' )
92                );
93            } else {
94                // Link to the wp-admin stats page.
95                $query_args = array(
96                    'from'         => 'postList',
97                    'jp_post_type' => get_post_type( $post_id ),
98                );
99
100                $list_criteria_params = array(
101                    's'             => sanitize_text_field( get_search_query() ),
102                    'paged'         => absint( get_query_var( 'paged' ) ),
103                    'post_status'   => sanitize_text_field( get_query_var( 'post_status' ) ),
104                    'orderby'       => sanitize_text_field( get_query_var( 'orderby' ) ),
105                    'order'         => sanitize_text_field( get_query_var( 'order' ) ),
106                    'author'        => absint( get_query_var( 'author' ) ),
107                    'cat'           => absint( get_query_var( 'cat' ) ), // 'cat' is the query var for category ID
108                    'm'             => absint( get_query_var( 'm' ) ),   // 'm' is the query var for YYYYMM
109                    'category_name' => sanitize_text_field( get_query_var( 'category_name' ) ),
110                );
111
112                foreach ( $list_criteria_params as $key => $value ) {
113                    if ( isset( $_GET[ $key ] ) && $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checking if the key existts and not reading the value from the request.
114                        $query_args[ 'jp_' . $key ] = $value;
115                    }
116                }
117
118                $stats_post_url = add_query_arg( $query_args, admin_url( 'admin.php?page=stats#!/stats/post/' . $post_id . '/' . \Jetpack_Options::get_option( 'id', 0 ) ) );
119                // Unless the user is on a Default style WOA site, in which case link to Calypso.
120                if ( ( new Host() )->is_woa_site() && Stats_Options::get_option( 'enable_odyssey_stats' ) && 'wp-admin' !== get_option( 'wpcom_admin_interface' ) ) {
121                    $stats_post_url = Redirect::get_url(
122                        'calypso-stats-post',
123                        array(
124                            'path' => $post_id,
125                        )
126                    );
127                }
128
129                /**
130                 * Filters where the post list table's views column links to.
131                 *
132                 * Lets a newer analytics dashboard claim the entry point without this
133                 * package knowing about it.
134                 *
135                 * @since 0.34.0
136                 *
137                 * @param string $stats_post_url Stats URL for the post.
138                 * @param int    $post_id        The post the row belongs to.
139                 */
140                $stats_post_url = apply_filters( 'jetpack_stats_post_list_column_url', $stats_post_url, $post_id );
141
142                static $post_views = null;
143
144                /**
145                 * Jetpack_stats_get_post_page_views_for_current_list makes a request with all post ids in the current $wp_query.
146                 * This way, we'll make a single API request instead of making one for each post.
147                 *
148                 * For this reason, we'll cache the result with the static $post_views variable.
149                 */
150                if ( null === $post_views ) {
151                    $post_views = $this->get_post_page_views_for_current_list();
152                }
153
154                $views = $post_views[ $post_id ] ?? null;
155
156                $current_locale = get_locale();
157
158                if ( null !== $views ) {
159                    $formatted_views = class_exists( '\NumberFormatter' )
160                        ? $this->get_formatter( $current_locale )->format( $views )
161                        : $this->get_fallback_format_to_compact_version( $views );
162                } else {
163                    $formatted_views = '';
164                }
165
166                ?>
167                <a href="<?php echo esc_url( $stats_post_url ); ?>"
168                    title="<?php echo esc_html__( 'Views for the last thirty days. Click for detailed stats', 'jetpack-stats-admin' ); ?>">
169                    <span
170                        class="dashicons dashicons-visibility"></span>&nbsp;<span><?php echo null !== $views ? esc_html( $formatted_views ) : ''; ?></span>
171                </a>
172                <?php
173            }
174        }
175    }
176
177    /**
178     * Set header for column that allows to view an entry's stats.
179     *
180     * @param array $columns An array of column names.
181     *
182     * @return mixed
183     */
184    public function add_stats_post_table( $columns ) {
185        // Skip stats column for non-public post types when screen info is available.
186        if ( function_exists( 'get_current_screen' ) ) {
187            $screen = get_current_screen();
188            if ( $screen && $screen->post_type ) {
189                $post_type_object = get_post_type_object( $screen->post_type );
190                if ( $post_type_object && ! $post_type_object->public ) {
191                    return $columns;
192                }
193            }
194        }
195
196        /**
197         * The manage_options capability is a fallback for Simple.
198         * This should be updated with a proper fix. Implemented based on this PR: https://github.com/Automattic/jetpack/pull/41549.
199         */
200        $has_access = current_user_can( 'view_stats' ) || current_user_can( 'manage_options' );
201
202        /*
203         * Stats can be accessed in wp-admin or in Calypso,
204         * depending on what version of the stats screen is enabled on your site.
205         *
206         * In both cases, the user must be allowed to access stats.
207         *
208         * If the Odyssey Stats experience isn't enabled, the user will need to go to Calypso,
209         * so they need to be connected to WordPress.com to be able to access that page.
210         */
211        if (
212            ! $has_access
213            || (
214                ! Stats_Options::get_option( 'enable_odyssey_stats' )
215                && ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected()
216            )
217        ) {
218            return $columns;
219        }
220
221        // Array-Fu to add before comments.
222        $pos = array_search( 'comments', array_keys( $columns ), true );
223
224        // Fallback to the last position if the post type does not support comments.
225        if ( ! is_int( $pos ) ) {
226            $pos = count( $columns );
227        }
228
229        // If comments position is 0, then prepend the element at the beginning of the array.
230        if ( 0 === $pos ) {
231            return array_merge(
232                array( 'stats' => esc_html__( 'Views: 30 days', 'jetpack-stats-admin' ) ),
233                $columns
234            );
235        }
236
237        $chunks             = array_chunk( $columns, $pos, true );
238        $chunks[0]['stats'] = esc_html__( 'Views: 30 days', 'jetpack-stats-admin' );
239
240        return call_user_func_array( 'array_merge', $chunks );
241    }
242
243    /**
244     * Get a list of post views for each post id from the global $wp_query.
245     *
246     * @return array
247     */
248    public function get_post_page_views_for_current_list(): array {
249        global $wp_query;
250
251        if ( $wp_query->posts ) {
252            $post_ids = wp_list_pluck( $wp_query->posts, 'ID' );
253        } elseif ( wp_doing_ajax() && ! empty( $_POST['action'] ) && 'inline-save' === $_POST['action'] && ! empty( $_POST['post_ID'] ) && check_ajax_referer( 'inlineeditnonce', '_inline_edit' ) ) {
254            $post_ids = array( absint( wp_unslash( $_POST['post_ID'] ) ) );
255        } else {
256            return array();
257        }
258
259        $wpcom_stats = $this->get_stats();
260        $post_views  = $wpcom_stats->get_total_post_views(
261            array(
262                'num'      => 30,
263                'post_ids' => implode( ',', $post_ids ),
264            )
265        );
266
267        if ( is_wp_error( $post_views ) || empty( $post_views ) ) {
268            return array();
269        }
270
271        $views = array();
272
273        foreach ( $post_views['posts'] as $post ) {
274            $views[ $post['ID'] ] = $post['views'];
275        }
276
277        return $views;
278    }
279
280    /**
281     * Get the stats object.
282     *
283     * @return WPCOM_Stats
284     */
285    protected function get_stats() {
286        return new WPCOM_Stats();
287    }
288
289    /**
290     * Get and validate the locale.
291     *
292     * @param string $locale The locale to validate.
293     *
294     * @return string The validated locale.
295     */
296    public function get_validated_locale( string $locale ): string {
297        if ( isset( $this->locale ) ) {
298            return $this->locale;
299        }
300
301        /*
302         * Check if the locale is valid and available.
303         * If not, fallback to en_US.
304         */
305        if ( ! in_array( $locale, \IntlCalendar::getAvailableLocales(), true ) ) {
306            $locale = 'en_US';
307        }
308
309        $this->locale = $locale;
310        return $locale;
311    }
312
313    /**
314     * Get the NumberFormatter instance.
315     *
316     * @param string $locale The current locale.
317     *
318     * @return NumberFormatter
319     */
320    protected function get_formatter( string $locale ): \NumberFormatter {
321        if ( isset( $this->formatter[ $locale ] ) ) {
322            return $this->formatter[ $locale ];
323        }
324
325        $locale = $this->get_validated_locale( $locale );
326
327        /**
328         * PHP's NumberFormatter is just a wrapper over the ICU C library. The library does support decimal compact short formatter, but PHP doesn't have a stub for it (=< PHP 8.4).
329         *
330         * @see https://unicode-org.github.io/icu-docs/apidoc/dev/icu4c/unum_8h.html UNUM_DECIMAL_COMPACT_SHORT constant.
331         */
332        $compact_decimal_short = 14;
333
334        /**
335         * NumberFormatter::DECIMAL_COMPACT_SHORT only exists in PHP 8.5 and later. At this time, NumberFormatter::DECIMAL_COMPACT_SHORT only exists in PHP `main` branch.
336         *
337         * Use the constant if it's defined since it's safer.
338         */
339        if ( defined( '\NumberFormatter::DECIMAL_COMPACT_SHORT' ) ) {
340            // @phan-suppress-next-line PhanUndeclaredConstantOfClass
341            $compact_decimal_short = NumberFormatter::DECIMAL_COMPACT_SHORT;
342        }
343
344        try {
345            $formatter = new \NumberFormatter( $locale, $compact_decimal_short );
346            $formatter->setAttribute( \NumberFormatter::MAX_FRACTION_DIGITS, 1 );
347        } catch ( \Exception $e ) {
348            // Fallback to decimal if for some reason it fails to work.
349            $formatter = new \NumberFormatter( $locale, \NumberFormatter::DECIMAL );
350        }
351
352        $this->formatter[ $locale ] = $formatter;
353
354        return $formatter;
355    }
356
357    /**
358     * Fallback Format a number to a compact version if the Intl extension is not available.
359     *
360     * @param int $views The given number.
361     *
362     * @return string
363     */
364    public function get_fallback_format_to_compact_version( $views ) {
365        if ( $views >= 10000000 ) {
366            return round( $views / 1000000 ) . 'M';
367        } elseif ( $views >= 1000000 ) {
368            $views = round( $views / 1000000, 1 );
369            return preg_replace( '/\.0$/', '', (string) $views ) . 'M';
370        } elseif ( $views >= 10000 ) {
371            return round( $views / 1000 ) . 'K';
372        } elseif ( $views >= 1000 ) {
373            $views = round( $views / 1000, 1 );
374            return preg_replace( '/\.0$/', '', (string) $views ) . 'K';
375        }
376
377        return (string) $views;
378    }
379}