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
desktop-mode / includes / widgets / widget-site-views.php

widget-site-views.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at includes/widgets/widget-site-views.php

324 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Site Views Widget.
4 *
5 * Sparkline graph of page views over the last 7 days with
6 * week-over-week delta. Tries Jetpack Stats first, falls back
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.
10 *
11 * Refresh: every 10 minutes.
12 * Requires: OpenStation 0.18.0+ (openstation_register_widget).
13 *
14 * @package OpenStation
15 */
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Register the widget's two REST endpoints, one per source.
21 *
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()}.
28 */
29 function openstation_register_site_views_rest_route() {
30 register_rest_route(
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',
43 '/site-views-meta',
44 array(
45 'methods' => WP_REST_Server::READABLE,
46 'callback' => 'openstation_site_views_meta_callback',
47 'permission_callback' => static function () {
48 return current_user_can( 'edit_posts' );
49 },
50 )
51 );
52 }
53 add_action( 'rest_api_init', 'openstation_register_site_views_rest_route' );
54
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 /**
206 * Aggregate _post_views_YYYY-MM-DD meta across all posts for 14 days.
207 *
208 * The result is cached in a 5-minute transient: the aggregation runs
209 * 14 SUM() queries over postmeta, the widget only refreshes every
210 * 10 minutes, and the data is site-wide (not per-user) — so every
211 * concurrent viewer can share one computation. View counts accrue
212 * continuously, so a short TTL is the invalidation strategy; no
213 * event-based purge is needed.
214 *
215 * @param WP_REST_Request $request REST request.
216 * @return array
217 */
218 function openstation_site_views_meta_callback( $request ) {
219 global $wpdb;
220
221 $cached = get_transient( 'desktop_mode_site_views_meta' );
222 if ( is_array( $cached ) ) {
223 return $cached;
224 }
225
226 $rows = array();
227 $today = current_time( 'Y-m-d' );
228
229 for ( $i = 0; $i < 14; $i++ ) {
230 $date = gmdate( 'Y-m-d', strtotime( $today . ' -' . $i . ' days' ) );
231 $meta_key = '_post_views_' . $date;
232 $total = (int) $wpdb->get_var(
233 $wpdb->prepare(
234 "SELECT COALESCE( SUM( CAST( meta_value AS UNSIGNED ) ), 0 )
235 FROM {$wpdb->postmeta}
236 WHERE meta_key = %s",
237 $meta_key
238 )
239 );
240 $rows[] = array(
241 'date' => $date,
242 'views' => $total,
243 );
244 }
245
246 $has_data = array_sum( array_column( $rows, 'views' ) ) > 0;
247
248 $result = array(
249 'source' => 'post-meta',
250 'has_data' => $has_data,
251 'days' => array_reverse( $rows ),
252 );
253
254 set_transient( 'desktop_mode_site_views_meta', $result, 5 * MINUTE_IN_SECONDS );
255
256 return $result;
257 }
258
259 /**
260 * Register the JS + CSS assets.
261 */
262 function openstation_register_site_views_widget_assets() {
263 $suffix = openstation_asset_suffix();
264 $version = defined( 'OPENSTATION_VERSION' ) ? OPENSTATION_VERSION : '0';
265
266 $js_path = OPENSTATION_DIR . 'assets/js/widget-site-views' . $suffix . '.js';
267 $css_path = OPENSTATION_DIR . 'assets/js/widget-site-views' . $suffix . '.css';
268
269 wp_register_style(
270 'os-site-views-widget',
271 OPENSTATION_URL . 'assets/js/widget-site-views' . $suffix . '.css',
272 array(),
273 file_exists( $css_path ) ? (string) filemtime( $css_path ) : $version
274 );
275
276 wp_register_script(
277 'os-site-views-widget',
278 OPENSTATION_URL . 'assets/js/widget-site-views' . $suffix . '.js',
279 array( 'wp-api-fetch' ),
280 file_exists( $js_path ) ? (string) filemtime( $js_path ) : $version,
281 true
282 );
283 }
284 add_action( 'init', 'openstation_register_site_views_widget_assets', 5 );
285
286 /**
287 * Eagerly enqueue the CSS on shell pages.
288 */
289 function openstation_enqueue_site_views_widget_styles() {
290 if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled() ) {
291 return;
292 }
293 if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) {
294 return;
295 }
296 wp_enqueue_style( 'os-site-views-widget' );
297 }
298 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_site_views_widget_styles', 20 );
299
300 /**
301 * Register the widget definition.
302 */
303 function openstation_register_site_views_widget() {
304 if ( ! function_exists( 'openstation_register_widget' ) ) {
305 return;
306 }
307 openstation_register_widget(
308 'desktop-mode/site-views',
309 array(
310 'label' => __( 'Site Views', 'desktop-mode' ),
311 'description' => __( 'Sparkline of page views over the last 7 days with week-over-week delta.', 'desktop-mode' ),
312 'icon' => 'dashicons-chart-area',
313 'script' => 'os-site-views-widget',
314 'movable' => true,
315 'resizable' => true,
316 'min_width' => 240,
317 'min_height' => 160,
318 'default_width' => 300,
319 'default_height' => 200,
320 )
321 );
322 }
323 add_action( 'init', 'openstation_register_site_views_widget', 6 );
324