PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 All 37 releases
← All changes | includes/widgets/widget-site-views.php +199 -33 0.9.8 → 1.1.13 View file →
@@ -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 );