| @@ -1,34 +1,50 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — Site Views Widget. | |
| 3 | + * OpenStation — Site Views Widget. | |
| 4 | 4 | * |
| 5 | 5 | * Sparkline graph of page views over the last 7 days with |
| 6 | 6 | * week-over-week delta. Tries Jetpack Stats first, falls back |
| 7 | - * to a _post_views_YYYY-MM-DD meta-key aggregator. | |
| 7 | + * to a _post_views_YYYY-MM-DD meta-key aggregator. Both sources are | |
| 8 | + * read server side, one route each, and both answer their rows as | |
| 9 | + * `days`: `{ date, views }`, oldest first. | |
| 8 | 10 | * |
| 9 | 11 | * Refresh: every 10 minutes. |
| 10 | - * Requires: Desktop Mode 0.18.0+ (desktop_mode_register_widget). | |
| 12 | + * Requires: OpenStation 0.18.0+ (openstation_register_widget). | |
| 11 | 13 | * |
| 12 | - * @package WPDesktopMode | |
| 14 | + * @package OpenStation | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | defined( 'ABSPATH' ) || exit; |
| 16 | 18 | |
| 17 | 19 | /** |
| 18 | - * Register the REST endpoint that aggregates per-post view counts | |
| 19 | - * from the _post_views_YYYY-MM-DD meta key (plain-WP fallback). | |
| 20 | + * Register the widget's two REST endpoints, one per source. | |
| 20 | 21 | * |
| 21 | - * Route: GET /desktop-mode/v1/site-views-meta | |
| 22 | - * Permission: edit_posts. | |
| 22 | + * Route: GET /desktop-mode/v1/site-views-jetpack (Jetpack Stats) | |
| 23 | + * Route: GET /desktop-mode/v1/site-views-meta (plain-WP fallback, | |
| 24 | + * per-post `_post_views_YYYY-MM-DD` meta) | |
| 25 | + * Permission: edit_posts on both. The Jetpack route then applies | |
| 26 | + * Jetpack's own stats gate per caller, see | |
| 27 | + * {@see openstation_site_views_jetpack_callback()}. | |
| 23 | 28 | */ |
| 24 | -function desktop_mode_register_site_views_rest_route() { | |
| 29 | +function openstation_register_site_views_rest_route() { | |
| 25 | 30 | register_rest_route( |
| 26 | 31 | 'desktop-mode/v1', |
| 32 | + '/site-views-jetpack', | |
| 33 | + array( | |
| 34 | + 'methods' => WP_REST_Server::READABLE, | |
| 35 | + 'callback' => 'openstation_site_views_jetpack_callback', | |
| 36 | + 'permission_callback' => static function () { | |
| 37 | + return current_user_can( 'edit_posts' ); | |
| 38 | + }, | |
| 39 | + ) | |
| 40 | + ); | |
| 41 | + register_rest_route( | |
| 42 | + 'desktop-mode/v1', | |
| 27 | 43 | '/site-views-meta', |
| 28 | 44 | array( |
| 29 | 45 | 'methods' => WP_REST_Server::READABLE, |
| 30 | - 'callback' => 'desktop_mode_site_views_meta_callback', | |
| 46 | + 'callback' => 'openstation_site_views_meta_callback', | |
| 31 | 47 | 'permission_callback' => static function () { |
| 32 | 48 | return current_user_can( 'edit_posts' ); |
| 33 | 49 | }, |
| 34 | 50 | ) |
| @@ -33,11 +49,161 @@ | ||
| 33 | 49 | }, |
| 34 | 50 | ) |
| 35 | 51 | ); |
| 36 | 52 | } |
| 37 | -add_action( 'rest_api_init', 'desktop_mode_register_site_views_rest_route' ); | |
| 53 | +add_action( 'rest_api_init', 'openstation_register_site_views_rest_route' ); | |
| 38 | 54 | |
| 39 | 55 | /** |
| 56 | + * Last 14 days of site visits from Jetpack Stats. | |
| 57 | + * | |
| 58 | + * Jetpack registers no `jetpack/v4/stats/visits` route: its own | |
| 59 | + * dashboard reads `jetpack/v4/stats-app/sites/<blog id>/stats/visits`, | |
| 60 | + * which proxies `WPCOM_Stats::get_visits()`. Calling that method here | |
| 61 | + * keeps the blog id on the server, and `WPCOM_Stats` already caches | |
| 62 | + * the WordPress.com answer for five minutes per site. | |
| 63 | + * | |
| 64 | + * Answers `available: false` instead of an error when Jetpack cannot | |
| 65 | + * serve this caller, so the client falls through to the meta source | |
| 66 | + * without a failed request on every site that has no Jetpack: | |
| 67 | + * | |
| 68 | + * - The caller does not pass Jetpack's stats gate, see | |
| 69 | + * {@see openstation_site_views_user_can_read_jetpack()}. That answer | |
| 70 | + * also carries `restricted: true`, so the widget can say the numbers | |
| 71 | + * exist and are withheld instead of asking for a plugin that is | |
| 72 | + * already active. | |
| 73 | + * - Jetpack is absent, the Stats module is off, or WordPress.com | |
| 74 | + * answered with an error (for example a site that is not connected). | |
| 75 | + * | |
| 76 | + * A successful answer is trusted even when every day is zero: Jetpack | |
| 77 | + * is collecting, and a quiet week is a real answer. | |
| 78 | + * | |
| 79 | + * @return array{available:bool,restricted:bool,days:array} | |
| 80 | + */ | |
| 81 | +function openstation_site_views_jetpack_callback() { | |
| 82 | + $restricted = openstation_site_views_jetpack_stats_active() && ! openstation_site_views_user_can_read_jetpack(); | |
| 83 | + $days = $restricted ? null : openstation_site_views_jetpack_days(); | |
| 84 | + | |
| 85 | + return array( | |
| 86 | + 'available' => null !== $days, | |
| 87 | + 'restricted' => $restricted, | |
| 88 | + 'days' => null === $days ? array() : $days, | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** | |
| 93 | + * Whether the current user passes Jetpack's own stats gate. | |
| 94 | + * | |
| 95 | + * Mirrors the `permission_callback` of Jetpack's stats-app REST | |
| 96 | + * controller: `manage_options` or `view_stats` (the roles picked in | |
| 97 | + * Jetpack's Stats settings). Every surface that serves a number read | |
| 98 | + * from Jetpack Stats asks this, the Living Tree's traffic total | |
| 99 | + * included, so OpenStation never hands out traffic Jetpack itself | |
| 100 | + * withholds from the caller. | |
| 101 | + * | |
| 102 | + * @return bool | |
| 103 | + */ | |
| 104 | +function openstation_site_views_user_can_read_jetpack() { | |
| 105 | + return current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown -- Jetpack's stats capability, mapped by its Stats package. | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * Whether Jetpack Stats is there to ask: its reader is loaded and the | |
| 110 | + * Stats module is on. | |
| 111 | + * | |
| 112 | + * `WPCOM_Stats` is autoloaded whenever Jetpack is active, module on or | |
| 113 | + * off, so its presence alone says nothing about whether the site is | |
| 114 | + * counting. With the module off the recent days read as zero, which | |
| 115 | + * would hide a post-views plugin that is counting. A site where the | |
| 116 | + * module state cannot be read is treated as off for the same reason. | |
| 117 | + * | |
| 118 | + * @return bool | |
| 119 | + */ | |
| 120 | +function openstation_site_views_jetpack_stats_active() { | |
| 121 | + return class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) | |
| 122 | + && class_exists( '\Automattic\Jetpack\Modules' ) | |
| 123 | + && ( new \Automattic\Jetpack\Modules() )->is_active( 'stats' ); | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** | |
| 127 | + * Read and normalise 14 daily rows from `WPCOM_Stats::get_visits()`. | |
| 128 | + * | |
| 129 | + * WordPress.com answers `{ fields: [ 'period', 'views', ... ], data: | |
| 130 | + * [ [ 'YYYY-MM-DD', 12, ... ], ... ] }`. Rows are positional per | |
| 131 | + * `fields`, so both columns are located by name. An answer that names | |
| 132 | + * neither is not guessed at by position: another column read as views | |
| 133 | + * is a wrong number, and no answer lets the meta source speak instead. | |
| 134 | + * | |
| 135 | + * Not gated on the caller: every caller that serves these rows, or a | |
| 136 | + * total of them, asks {@see openstation_site_views_user_can_read_jetpack()}. | |
| 137 | + * | |
| 138 | + * @return array<int,array{date:string,views:int}>|null Rows oldest | |
| 139 | + * first, or null when Jetpack Stats cannot answer. | |
| 140 | + */ | |
| 141 | +function openstation_site_views_jetpack_days() { | |
| 142 | + if ( ! openstation_site_views_jetpack_stats_active() ) { | |
| 143 | + return null; | |
| 144 | + } | |
| 145 | + $wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats(); | |
| 146 | + if ( ! method_exists( $wpcom_stats, 'get_visits' ) ) { | |
| 147 | + return null; | |
| 148 | + } | |
| 149 | + | |
| 150 | + try { | |
| 151 | + $stats = $wpcom_stats->get_visits( | |
| 152 | + array( | |
| 153 | + 'unit' => 'day', | |
| 154 | + 'quantity' => 14, | |
| 155 | + ) | |
| 156 | + ); | |
| 157 | + } catch ( \Throwable $e ) { | |
| 158 | + return null; | |
| 159 | + } | |
| 160 | + if ( is_wp_error( $stats ) ) { | |
| 161 | + return null; | |
| 162 | + } | |
| 163 | + | |
| 164 | + // Jetpack versions differ on object-vs-assoc-array decoding. | |
| 165 | + $stats = json_decode( wp_json_encode( $stats ), true ); | |
| 166 | + if ( ! is_array( $stats ) || empty( $stats['data'] ) || ! is_array( $stats['data'] ) ) { | |
| 167 | + return null; | |
| 168 | + } | |
| 169 | + | |
| 170 | + $fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array(); | |
| 171 | + $date_index = array_search( 'period', $fields, true ); | |
| 172 | + $views_index = array_search( 'views', $fields, true ); | |
| 173 | + if ( false === $date_index || false === $views_index ) { | |
| 174 | + return null; | |
| 175 | + } | |
| 176 | + | |
| 177 | + $days = array(); | |
| 178 | + foreach ( $stats['data'] as $row ) { | |
| 179 | + if ( ! is_array( $row ) || ! isset( $row[ $date_index ] ) ) { | |
| 180 | + continue; | |
| 181 | + } | |
| 182 | + $date = (string) $row[ $date_index ]; | |
| 183 | + if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { | |
| 184 | + continue; | |
| 185 | + } | |
| 186 | + $views = isset( $row[ $views_index ] ) && is_numeric( $row[ $views_index ] ) ? (int) $row[ $views_index ] : 0; | |
| 187 | + $days[] = array( | |
| 188 | + 'date' => $date, | |
| 189 | + 'views' => max( 0, $views ), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + if ( empty( $days ) ) { | |
| 193 | + return null; | |
| 194 | + } | |
| 195 | + | |
| 196 | + usort( | |
| 197 | + $days, | |
| 198 | + static function ( $a, $b ) { | |
| 199 | + return strcmp( $a['date'], $b['date'] ); | |
| 200 | + } | |
| 201 | + ); | |
| 202 | + return $days; | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** | |
| 40 | 206 | * Aggregate _post_views_YYYY-MM-DD meta across all posts for 14 days. |
| 41 | 207 | * |
| 42 | 208 | * The result is cached in a 5-minute transient: the aggregation runs |
| 43 | 209 | * 14 SUM() queries over postmeta, the widget only refreshes every |
| @@ -48,9 +214,9 @@ | ||
| 48 | 214 | * |
| 49 | 215 | * @param WP_REST_Request $request REST request. |
| 50 | 216 | * @return array |
| 51 | 217 | */ |
| 52 | -function desktop_mode_site_views_meta_callback( $request ) { | |
| 218 | +function openstation_site_views_meta_callback( $request ) { | |
| 53 | 219 | global $wpdb; |
| 54 | 220 | |
| 55 | 221 | $cached = get_transient( 'desktop_mode_site_views_meta' ); |
| 56 | 222 | if ( is_array( $cached ) ) { |
| @@ -70,9 +236,9 @@ | ||
| 70 | 236 | WHERE meta_key = %s", |
| 71 | 237 | $meta_key |
| 72 | 238 | ) |
| 73 | 239 | ); |
| 74 | - $rows[] = array( | |
| 240 | + $rows[] = array( | |
| 75 | 241 | 'date' => $date, |
| 76 | 242 | 'views' => $total, |
| 77 | 243 | ); |
| 78 | 244 | } |
| @@ -92,60 +258,60 @@ | ||
| 92 | 258 | |
| 93 | 259 | /** |
| 94 | 260 | * Register the JS + CSS assets. |
| 95 | 261 | */ |
| 96 | -function desktop_mode_register_site_views_widget_assets() { | |
| 97 | - $suffix = desktop_mode_asset_suffix(); | |
| 98 | - $version = defined( 'DESKTOP_MODE_VERSION' ) ? DESKTOP_MODE_VERSION : '0'; | |
| 262 | +function openstation_register_site_views_widget_assets() { | |
| 263 | + $suffix = openstation_asset_suffix(); | |
| 264 | + $version = defined( 'OPENSTATION_VERSION' ) ? OPENSTATION_VERSION : '0'; | |
| 99 | 265 | |
| 100 | - $js_path = DESKTOP_MODE_DIR . 'assets/js/widget-site-views' . $suffix . '.js'; | |
| 101 | - $css_path = DESKTOP_MODE_DIR . 'assets/js/widget-site-views' . $suffix . '.css'; | |
| 266 | + $js_path = OPENSTATION_DIR . 'assets/js/widget-site-views' . $suffix . '.js'; | |
| 267 | + $css_path = OPENSTATION_DIR . 'assets/js/widget-site-views' . $suffix . '.css'; | |
| 102 | 268 | |
| 103 | 269 | wp_register_style( |
| 104 | - 'desktop-mode-site-views-widget', | |
| 105 | - DESKTOP_MODE_URL . 'assets/js/widget-site-views' . $suffix . '.css', | |
| 270 | + 'os-site-views-widget', | |
| 271 | + OPENSTATION_URL . 'assets/js/widget-site-views' . $suffix . '.css', | |
| 106 | 272 | array(), |
| 107 | 273 | file_exists( $css_path ) ? (string) filemtime( $css_path ) : $version |
| 108 | 274 | ); |
| 109 | 275 | |
| 110 | 276 | wp_register_script( |
| 111 | - 'desktop-mode-site-views-widget', | |
| 112 | - DESKTOP_MODE_URL . 'assets/js/widget-site-views' . $suffix . '.js', | |
| 277 | + 'os-site-views-widget', | |
| 278 | + OPENSTATION_URL . 'assets/js/widget-site-views' . $suffix . '.js', | |
| 113 | 279 | array( 'wp-api-fetch' ), |
| 114 | 280 | file_exists( $js_path ) ? (string) filemtime( $js_path ) : $version, |
| 115 | 281 | true |
| 116 | 282 | ); |
| 117 | 283 | } |
| 118 | -add_action( 'init', 'desktop_mode_register_site_views_widget_assets', 5 ); | |
| 284 | +add_action( 'init', 'openstation_register_site_views_widget_assets', 5 ); | |
| 119 | 285 | |
| 120 | 286 | /** |
| 121 | 287 | * Eagerly enqueue the CSS on shell pages. |
| 122 | 288 | */ |
| 123 | -function desktop_mode_enqueue_site_views_widget_styles() { | |
| 124 | - if ( function_exists( 'desktop_mode_is_enabled' ) && ! desktop_mode_is_enabled() ) { | |
| 289 | +function openstation_enqueue_site_views_widget_styles() { | |
| 290 | + if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled() ) { | |
| 125 | 291 | return; |
| 126 | 292 | } |
| 127 | - if ( function_exists( 'desktop_mode_is_chromeless_request' ) && desktop_mode_is_chromeless_request() ) { | |
| 293 | + if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) { | |
| 128 | 294 | return; |
| 129 | 295 | } |
| 130 | - wp_enqueue_style( 'desktop-mode-site-views-widget' ); | |
| 296 | + wp_enqueue_style( 'os-site-views-widget' ); | |
| 131 | 297 | } |
| 132 | -add_action( 'admin_enqueue_scripts', 'desktop_mode_enqueue_site_views_widget_styles', 20 ); | |
| 298 | +add_action( 'admin_enqueue_scripts', 'openstation_enqueue_site_views_widget_styles', 20 ); | |
| 133 | 299 | |
| 134 | 300 | /** |
| 135 | 301 | * Register the widget definition. |
| 136 | 302 | */ |
| 137 | -function desktop_mode_register_site_views_widget() { | |
| 138 | - if ( ! function_exists( 'desktop_mode_register_widget' ) ) { | |
| 303 | +function openstation_register_site_views_widget() { | |
| 304 | + if ( ! function_exists( 'openstation_register_widget' ) ) { | |
| 139 | 305 | return; |
| 140 | 306 | } |
| 141 | - desktop_mode_register_widget( | |
| 307 | + openstation_register_widget( | |
| 142 | 308 | 'desktop-mode/site-views', |
| 143 | 309 | array( |
| 144 | 310 | 'label' => __( 'Site Views', 'desktop-mode' ), |
| 145 | 311 | 'description' => __( 'Sparkline of page views over the last 7 days with week-over-week delta.', 'desktop-mode' ), |
| 146 | 312 | 'icon' => 'dashicons-chart-area', |
| 147 | - 'script' => 'desktop-mode-site-views-widget', | |
| 313 | + 'script' => 'os-site-views-widget', | |
| 148 | 314 | 'movable' => true, |
| 149 | 315 | 'resizable' => true, |
| 150 | 316 | 'min_width' => 240, |
| 151 | 317 | 'min_height' => 160, |
| @@ -153,5 +319,5 @@ | ||
| 153 | 319 | 'default_height' => 200, |
| 154 | 320 | ) |
| 155 | 321 | ); |
| 156 | 322 | } |
| 157 | -add_action( 'init', 'desktop_mode_register_site_views_widget', 6 ); | |
| 323 | +add_action( 'init', 'openstation_register_site_views_widget', 6 ); | |