PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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 0.9.7, at includes/my-wordpress/user-footprint.php

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