PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
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 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / user-footprint.php

user-footprint.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/my-wordpress/user-footprint.php

527 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: per-user activity footprint endpoint.
4 *
5 * `GET /desktop-mode/v1/user-footprint/<id>` returns a deep activity
6 * footprint for one user: a year of day-by-day publishing counts
7 * (GitHub-style calendar heatmap), weekday and hour-of-day
8 * distribution (publishing rhythm), longest publishing streak, and
9 * a recent-events timeline (posts published + comments left, last
10 * 30). The right-click "View activity footprint" action in the My
11 * WordPress users folder paints from this single payload.
12 *
13 * Permission: any logged-in user (the dossier route already has
14 * the same gate). Sensitive fields (email, IP) are NOT returned
15 * from this endpoint — `user-stats.php` carries those for the
16 * preview pane, and the footprint focuses on activity patterns.
17 * Timeline rows whose underlying post is not published are only
18 * emitted when the viewer passes `current_user_can( 'read_post' )`
19 * for that post, so draft/pending/private/future titles never leak
20 * to ordinary logged-in users.
21 *
22 * Payload shape:
23 *
24 * {
25 * profile: { id, name, avatarUrl, link, roleLabels?, registered? },
26 * range: { from, to, days }, // YYYY-MM-DD bookends + day count
27 * daily: [ { date, posts, comments, updates } ], // length = range.days; missing days = 0
28 * weekday: [ 0..6 ], // post counts, Sunday-indexed
29 * hour: [ 0..23 ], // post counts, server-local hour
30 * streak: { longest, current, longestRange:{ from, to } },
31 * timeline:[ // 30 most recent activity rows
32 * { kind:'post'|'comment'|'post-update', date, title, link, status, postId?, type? }
33 * ],
34 * totals: { posts, pages, comments, updates, mostProlificMonth?:{ ym, n } }
35 * }
36 *
37 * Timeline row fields:
38 * - `kind` — discriminator: `'post'` (publish), `'comment'`, or
39 * `'post-update'` (revision rollup).
40 * - `type` — only set when `kind` is `'post'` or `'post-update'`.
41 * Carries the post's CPT slug (`'post'`, `'page'`, custom
42 * types) so the renderer can pick a Post-vs-Page icon
43 * without a second REST lookup.
44 *
45 * "Updates" are revisions saved by the user AFTER a post's original
46 * creation — i.e. the user opened an existing post and saved it
47 * again. The initial save (which WordPress also writes as a revision)
48 * is excluded so the per-day "updates" count doesn't double up with
49 * the per-day "posts" count.
50 *
51 * @package OpenStation
52 */
53
54 defined( 'ABSPATH' ) || exit;
55
56 /**
57 * Register the route.
58 */
59 function openstation_my_wordpress_register_user_footprint_route() {
60 register_rest_route(
61 'desktop-mode/v1',
62 '/user-footprint/(?P<id>\d+)',
63 array(
64 'methods' => WP_REST_Server::READABLE,
65 'callback' => 'openstation_my_wordpress_user_footprint_callback',
66 'permission_callback' => static function () {
67 return is_user_logged_in();
68 },
69 'args' => array(
70 'id' => array(
71 'required' => true,
72 'type' => 'integer',
73 'sanitize_callback' => 'absint',
74 ),
75 ),
76 )
77 );
78 }
79 add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_footprint_route' );
80
81 /**
82 * Aggregator callback. See the file docblock for the payload shape.
83 *
84 * @param WP_REST_Request $request REST request.
85 * @return array|WP_Error
86 */
87 function openstation_my_wordpress_user_footprint_callback( $request ) {
88 global $wpdb;
89
90 $user_id = (int) $request->get_param( 'id' );
91 $user = get_userdata( $user_id );
92 if ( ! $user ) {
93 return new WP_Error(
94 'openstation_user_not_found',
95 __( 'User not found.', 'desktop-mode' ),
96 array( 'status' => 404 )
97 );
98 }
99
100 $can_see_private = current_user_can( 'list_users' )
101 || ( get_current_user_id() === $user_id );
102
103 // ---- Profile (minimal — the dossier already returned the full one) ----
104 $profile = array(
105 'id' => (int) $user->ID,
106 'name' => (string) $user->display_name,
107 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 128 ) ),
108 'link' => get_author_posts_url( $user->ID ),
109 );
110 if ( $can_see_private ) {
111 $role_labels = array();
112 if ( function_exists( 'wp_roles' ) ) {
113 $wp_roles = wp_roles();
114 foreach ( (array) $user->roles as $slug ) {
115 $role_labels[] = isset( $wp_roles->role_names[ $slug ] )
116 ? translate_user_role( $wp_roles->role_names[ $slug ] )
117 : $slug;
118 }
119 }
120 $profile['roleLabels'] = $role_labels;
121 if ( '' !== $user->user_registered ) {
122 $profile['registered'] = mysql2date( 'c', $user->user_registered, false );
123 }
124 }
125
126 // ---- Range: rolling 365-day window ending today (UTC bookends) -------
127 $days = 365;
128 $now = time(); // UTC
129 $from_ts = strtotime( '-' . ( $days - 1 ) . ' days', $now );
130 $to_ts = $now;
131 $range = array(
132 'from' => gmdate( 'Y-m-d', $from_ts ),
133 'to' => gmdate( 'Y-m-d', $to_ts ),
134 'days' => $days,
135 );
136
137 // ---- Daily counts (posts published per day + comments LEFT per day) --
138 // Two queries (one for posts, one for comments), each grouped by
139 // `DATE(post_date_gmt)` / `DATE(comment_date_gmt)`. Then we
140 // densify to a full day-by-day array so the heatmap renders
141 // every cell, even empty ones.
142 $post_rows = $wpdb->get_results(
143 $wpdb->prepare(
144 "SELECT DATE(post_date_gmt) AS d, COUNT(*) AS n
145 FROM {$wpdb->posts}
146 WHERE post_author = %d
147 AND post_status = 'publish'
148 AND post_type IN ( 'post', 'page' )
149 AND post_date_gmt >= %s
150 GROUP BY d
151 ORDER BY d ASC",
152 $user_id,
153 gmdate( 'Y-m-d 00:00:00', $from_ts )
154 ),
155 ARRAY_A
156 );
157 $post_by_day = array();
158 foreach ( (array) $post_rows as $row ) {
159 $post_by_day[ (string) $row['d'] ] = (int) $row['n'];
160 }
161
162 $comment_rows = $wpdb->get_results(
163 $wpdb->prepare(
164 "SELECT DATE(comment_date_gmt) AS d, COUNT(*) AS n
165 FROM {$wpdb->comments}
166 WHERE user_id = %d
167 AND comment_approved = '1'
168 AND comment_date_gmt >= %s
169 GROUP BY d
170 ORDER BY d ASC",
171 $user_id,
172 gmdate( 'Y-m-d 00:00:00', $from_ts )
173 ),
174 ARRAY_A
175 );
176 $comment_by_day = array();
177 foreach ( (array) $comment_rows as $row ) {
178 $comment_by_day[ (string) $row['d'] ] = (int) $row['n'];
179 }
180
181 // Updates = revisions saved by this user, joined back to the
182 // parent post so we can skip the initial-save revision (where the
183 // revision's `post_date_gmt` equals the parent's `post_date_gmt`).
184 // `r.post_author` (not the parent's) tracks who hit Save, so
185 // updates an editor makes to someone else's post show up on the
186 // editor's footprint — same shape GitHub's contribution graph
187 // uses for commits across repos you don't own.
188 $update_rows = $wpdb->get_results(
189 $wpdb->prepare(
190 "SELECT DATE(r.post_date_gmt) AS d, COUNT(*) AS n
191 FROM {$wpdb->posts} r
192 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
193 WHERE r.post_author = %d
194 AND r.post_type = 'revision'
195 AND r.post_status = 'inherit'
196 AND r.post_date_gmt > p.post_date_gmt
197 AND r.post_date_gmt >= %s
198 GROUP BY d
199 ORDER BY d ASC",
200 $user_id,
201 gmdate( 'Y-m-d 00:00:00', $from_ts )
202 ),
203 ARRAY_A
204 );
205 $update_by_day = array();
206 foreach ( (array) $update_rows as $row ) {
207 $update_by_day[ (string) $row['d'] ] = (int) $row['n'];
208 }
209
210 $daily = array();
211 for ( $i = 0; $i < $days; ++$i ) {
212 $ts = strtotime( '+' . $i . ' days', $from_ts );
213 $date = gmdate( 'Y-m-d', $ts );
214 $daily[] = array(
215 'date' => $date,
216 'posts' => isset( $post_by_day[ $date ] ) ? $post_by_day[ $date ] : 0,
217 'comments' => isset( $comment_by_day[ $date ] ) ? $comment_by_day[ $date ] : 0,
218 'updates' => isset( $update_by_day[ $date ] ) ? $update_by_day[ $date ] : 0,
219 );
220 }
221
222 // ---- Weekday distribution (Sunday-indexed) ---------------------------
223 // `DAYOFWEEK` returns 1=Sunday through 7=Saturday in MySQL.
224 $weekday_rows = $wpdb->get_results(
225 $wpdb->prepare(
226 "SELECT DAYOFWEEK(post_date_gmt) AS dow, COUNT(*) AS n
227 FROM {$wpdb->posts}
228 WHERE post_author = %d
229 AND post_status = 'publish'
230 AND post_type IN ( 'post', 'page' )
231 GROUP BY dow",
232 $user_id
233 ),
234 ARRAY_A
235 );
236 $weekday = array( 0, 0, 0, 0, 0, 0, 0 );
237 foreach ( (array) $weekday_rows as $row ) {
238 $dow = (int) $row['dow'];
239 if ( $dow >= 1 && $dow <= 7 ) {
240 $weekday[ $dow - 1 ] = (int) $row['n'];
241 }
242 }
243
244 // ---- Hour-of-day distribution (0..23, site timezone) -----------------
245 // `post_date` is already in site timezone — that's the timestamp
246 // the author saw when they hit Publish. Using GMT here would shift
247 // the bars by the offset and feel wrong to anyone in a non-UTC tz.
248 $hour_rows = $wpdb->get_results(
249 $wpdb->prepare(
250 "SELECT HOUR(post_date) AS h, COUNT(*) AS n
251 FROM {$wpdb->posts}
252 WHERE post_author = %d
253 AND post_status = 'publish'
254 AND post_type IN ( 'post', 'page' )
255 GROUP BY h",
256 $user_id
257 ),
258 ARRAY_A
259 );
260 $hour = array_fill( 0, 24, 0 );
261 foreach ( (array) $hour_rows as $row ) {
262 $h = (int) $row['h'];
263 if ( $h >= 0 && $h <= 23 ) {
264 $hour[ $h ] = (int) $row['n'];
265 }
266 }
267
268 // ---- Streak (longest consecutive run of days with ≥1 post over the
269 // 365-day window; current run ending today). ------------------------
270 $longest = 0;
271 $current = 0;
272 $longest_run = 0;
273 $longest_from = '';
274 $longest_to = '';
275 $run_start = '';
276 $today_str = $range['to'];
277 $prev_day_active = false;
278
279 // "Active" = published a post, left a comment, or saved a revision.
280 // Pre-0.8.7 this only counted publish days, so an editor doing
281 // daily updates without new posts had a "0 day" streak — wrong
282 // flavour of GitHub-style for a CMS where most work is editing.
283 $is_active = static function ( $entry ) {
284 return $entry['posts'] > 0
285 || ( isset( $entry['updates'] ) && $entry['updates'] > 0 )
286 || ( isset( $entry['comments'] ) && $entry['comments'] > 0 );
287 };
288 foreach ( $daily as $entry ) {
289 if ( $is_active( $entry ) ) {
290 if ( ! $prev_day_active ) {
291 $run_start = $entry['date'];
292 }
293 ++$longest_run;
294 if ( $longest_run > $longest ) {
295 $longest = $longest_run;
296 $longest_from = $run_start;
297 $longest_to = $entry['date'];
298 }
299 $prev_day_active = true;
300 } else {
301 $longest_run = 0;
302 $prev_day_active = false;
303 }
304 }
305 // Current streak — walk backward from today.
306 for ( $i = count( $daily ) - 1; $i >= 0; --$i ) {
307 if ( $is_active( $daily[ $i ] ) ) {
308 ++$current;
309 } else {
310 break;
311 }
312 }
313 $streak = array(
314 'longest' => $longest,
315 'current' => $current,
316 'longestRange' => array(
317 'from' => $longest_from,
318 'to' => $longest_to,
319 ),
320 );
321
322 // ---- Timeline: 30 most recent posts + comments, interleaved by date -
323 // One query per kind, then merge + sort + slice in PHP. Smaller and
324 // simpler than a SQL `UNION ALL`, and each branch already has the
325 // right index.
326 $timeline_posts = $wpdb->get_results(
327 $wpdb->prepare(
328 "SELECT ID, post_title, post_status, post_date_gmt, post_type
329 FROM {$wpdb->posts}
330 WHERE post_author = %d
331 AND post_type IN ( 'post', 'page' )
332 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
333 ORDER BY post_date_gmt DESC
334 LIMIT 30",
335 $user_id
336 ),
337 ARRAY_A
338 );
339 $timeline_comments = $wpdb->get_results(
340 $wpdb->prepare(
341 "SELECT c.comment_ID, c.comment_post_ID, c.comment_date_gmt, c.comment_approved,
342 p.post_title, p.post_status
343 FROM {$wpdb->comments} c
344 LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
345 WHERE c.user_id = %d
346 AND c.comment_approved = '1'
347 ORDER BY c.comment_date_gmt DESC
348 LIMIT 30",
349 $user_id
350 ),
351 ARRAY_A
352 );
353 // Recent updates — newest revision per parent post saved by this
354 // user. We collapse per-parent (`GROUP BY r.post_parent`) so a
355 // burst of saves on one post reads as one row in the activity
356 // list (otherwise an editor polishing a single article would push
357 // every other event off the screen). The MAX(r.post_date_gmt)
358 // surfaces the most recent save as the row's timestamp.
359 $timeline_updates = $wpdb->get_results(
360 $wpdb->prepare(
361 "SELECT r.post_parent AS parent_id, MAX(r.post_date_gmt) AS last_save, p.post_title, p.post_status, p.post_type
362 FROM {$wpdb->posts} r
363 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
364 WHERE r.post_author = %d
365 AND r.post_type = 'revision'
366 AND r.post_status = 'inherit'
367 AND r.post_date_gmt > p.post_date_gmt
368 AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
369 GROUP BY r.post_parent
370 ORDER BY last_save DESC
371 LIMIT 30",
372 $user_id
373 ),
374 ARRAY_A
375 );
376 $timeline = array();
377 // Per-row gate: rows for non-published posts (draft, pending,
378 // private, future, …) carry titles the viewer may not be allowed
379 // to see. `read_post` resolves to the right meta cap per status,
380 // so authors/editors keep their full timeline while ordinary
381 // logged-in users only see published work.
382 foreach ( (array) $timeline_posts as $p ) {
383 $pid = (int) $p['ID'];
384 if ( 'publish' !== (string) $p['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
385 continue;
386 }
387 $timeline[] = array(
388 'kind' => 'post',
389 'date' => mysql2date( 'c', $p['post_date_gmt'], false ),
390 'title' => (string) $p['post_title'],
391 'status' => (string) $p['post_status'],
392 'postId' => $pid,
393 'link' => (string) get_permalink( $pid ),
394 'type' => (string) $p['post_type'],
395 );
396 }
397 foreach ( (array) $timeline_comments as $c ) {
398 $pid = (int) $c['comment_post_ID'];
399 // LEFT-joined parent: a NULL status means the post is gone —
400 // nothing to leak, keep the row (title is already ''). A
401 // non-published parent leaks its title via the join, so it
402 // gets the same `read_post` gate as the post rows above.
403 if ( isset( $c['post_status'] ) && 'publish' !== (string) $c['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
404 continue;
405 }
406 $timeline[] = array(
407 'kind' => 'comment',
408 'date' => mysql2date( 'c', $c['comment_date_gmt'], false ),
409 'title' => (string) ( $c['post_title'] ?? '' ),
410 'status' => 'approved',
411 'postId' => $pid,
412 'link' => $pid ? (string) get_permalink( $pid ) : '',
413 );
414 }
415 foreach ( (array) $timeline_updates as $u ) {
416 $pid = (int) $u['parent_id'];
417 if ( 'publish' !== (string) $u['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
418 continue;
419 }
420 $timeline[] = array(
421 'kind' => 'post-update',
422 'date' => mysql2date( 'c', $u['last_save'], false ),
423 'title' => (string) $u['post_title'],
424 'status' => (string) $u['post_status'],
425 'postId' => $pid,
426 'link' => $pid ? (string) get_permalink( $pid ) : '',
427 'type' => (string) $u['post_type'],
428 );
429 }
430 usort(
431 $timeline,
432 static function ( $a, $b ) {
433 return strcmp( (string) $b['date'], (string) $a['date'] );
434 }
435 );
436 $timeline = array_slice( $timeline, 0, 30 );
437
438 // ---- Totals + most-prolific month -----------------------------------
439 $totals_posts = (int) $wpdb->get_var(
440 $wpdb->prepare(
441 "SELECT COUNT(*) FROM {$wpdb->posts}
442 WHERE post_author = %d
443 AND post_type = 'post'
444 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
445 $user_id
446 )
447 );
448 $totals_pages = (int) $wpdb->get_var(
449 $wpdb->prepare(
450 "SELECT COUNT(*) FROM {$wpdb->posts}
451 WHERE post_author = %d
452 AND post_type = 'page'
453 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
454 $user_id
455 )
456 );
457 $totals_comments = (int) $wpdb->get_var(
458 $wpdb->prepare(
459 "SELECT COUNT(*) FROM {$wpdb->comments}
460 WHERE user_id = %d AND comment_approved = '1'",
461 $user_id
462 )
463 );
464 // Lifetime updates = revisions this user saved after the initial
465 // creation of the parent post. Matches the per-day `updates`
466 // definition so the hero stat and heatmap rollups agree.
467 $totals_updates = (int) $wpdb->get_var(
468 $wpdb->prepare(
469 "SELECT COUNT(*) FROM {$wpdb->posts} r
470 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
471 WHERE r.post_author = %d
472 AND r.post_type = 'revision'
473 AND r.post_status = 'inherit'
474 AND r.post_date_gmt > p.post_date_gmt",
475 $user_id
476 )
477 );
478 $month_row = $wpdb->get_row(
479 $wpdb->prepare(
480 "SELECT DATE_FORMAT(post_date_gmt, '%%Y-%%m') AS ym, COUNT(*) AS n
481 FROM {$wpdb->posts}
482 WHERE post_author = %d
483 AND post_status = 'publish'
484 AND post_type IN ( 'post', 'page' )
485 GROUP BY ym
486 ORDER BY n DESC
487 LIMIT 1",
488 $user_id
489 ),
490 ARRAY_A
491 );
492 $totals = array(
493 'posts' => $totals_posts,
494 'pages' => $totals_pages,
495 'comments' => $totals_comments,
496 'updates' => $totals_updates,
497 );
498 if ( $month_row && isset( $month_row['ym'] ) ) {
499 $totals['mostProlificMonth'] = array(
500 'ym' => (string) $month_row['ym'],
501 'n' => (int) $month_row['n'],
502 );
503 }
504
505 $payload = array(
506 'profile' => $profile,
507 'range' => $range,
508 'daily' => $daily,
509 'weekday' => $weekday,
510 'hour' => $hour,
511 'streak' => $streak,
512 'timeline' => $timeline,
513 'totals' => $totals,
514 );
515
516 /**
517 * Filter the per-user footprint payload before it's returned to
518 * the My WordPress folder window. Plugins can extend the timeline
519 * with their own activity rows, or replace the streak math with
520 * something domain-specific.
521 *
522 * @param array $payload Footprint payload.
523 * @param int $user_id Subject user id.
524 */
525 return apply_filters( 'openstation_my_wordpress_user_footprint', $payload, $user_id );
526 }
527