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