PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/my-wordpress/user-footprint.php +342 -99 0.9.21.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — My WordPress: per-user activity footprint endpoint.
3 + * OpenStation — My WordPress: per-user activity footprint endpoint.
4 4 *
5 5 * `GET /desktop-mode/v1/user-footprint/<id>` returns a deep activity
6 6 * footprint for one user: a year of day-by-day publishing counts
7 7 * (GitHub-style calendar heatmap), weekday and hour-of-day
@@ -9,13 +9,39 @@
9 9 * a recent-events timeline (posts published + comments left, last
10 10 * 30). The right-click "View activity footprint" action in the My
11 11 * WordPress users folder paints from this single payload.
12 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.
13 + * Permission: the My WordPress module's gate,
14 + * `openstation_my_wordpress_user_can_use()` (`edit_posts` unless a site
15 + * filters it), so a site that narrows WP Explorer narrows this data
16 + * with it. Past that gate, `list_users` (or the subject viewing their
17 + * own footprint) only decides the profile fields (`roleLabels`,
18 + * `registered`), the same split `user-stats.php` uses. Sensitive
19 + * fields (email, IP) are NOT returned from this endpoint:
20 + * `user-stats.php` carries those for the preview pane, and the
21 + * footprint focuses on activity patterns.
17 22 *
23 + * **Activity is gated per post, and a count is gated exactly like
24 + * the rows it summarises.** A timeline row is emitted only when
25 + * `openstation_my_wordpress_footprint_can_see_post()` lets the viewer
26 + * see its post: a public status of a viewable type for everyone,
27 + * `read_post` for any other status, `edit_post` for a type with no
28 + * readable front end, and the comment dossier's parent gate for
29 + * comment rows (`edit_post` while the parent is still sealed by a
30 + * password the viewer has not entered, `moderate_comments` once it is
31 + * deleted). The counts that can reach those same posts
32 + * (`totals.posts`, `totals.pages`, `totals.comments`,
33 + * `totals.updates`, and each day's `comments` and `updates`, which
34 + * the streak reads) ask that gate of every post they count, so a
35 + * plugin filtering `read_post` for a single post moves the counts
36 + * with the rows. A Contributor's heatmap and hero stats cannot
37 + * report, as numbers, the drafts, private edits or internal records
38 + * the timeline withholds, and an Editor's totals include the drafts
39 + * their timeline lists. The remaining aggregates (`daily[].posts`,
40 + * `weekday`, `hour`, `mostProlificMonth`) count published posts and
41 + * pages only. The payload is viewer-dependent: never cache it under
42 + * a subject-only key.
43 + *
18 44 * Payload shape:
19 45 *
20 46 * {
21 47 * profile: { id, name, avatarUrl, link, roleLabels?, registered? },
@@ -38,15 +64,20 @@
38 64 * types) so the renderer can pick a Post-vs-Page icon
39 65 * without a second REST lookup.
40 66 *
41 67 * "Updates" are revisions saved by the user AFTER a post's original
42 - * creation — i.e. the user opened an existing post and saved it
68 + * creation, i.e. the user opened an existing post and saved it
43 69 * again. The initial save (which WordPress also writes as a revision)
44 70 * is excluded so the per-day "updates" count doesn't double up with
45 - * the per-day "posts" count.
71 + * the per-day "posts" count. So every revision after a post's first
72 + * one is an update, whenever it was saved: while the post was a
73 + * draft, before a scheduled post went live, or after. The first
74 + * revision counts too when it is newer than the post's date, as when
75 + * a post that never had a revision is edited later; a draft or
76 + * pending post has no date yet (`post_date_gmt` stays zero), so its
77 + * first revision never does.
46 78 *
47 - * @package WPDesktopMode
48 - * @since 0.20.0
79 + * @package OpenStation
49 80 */
50 81
51 82 defined( 'ABSPATH' ) || exit;
52 83
@@ -51,20 +82,21 @@
51 82 defined( 'ABSPATH' ) || exit;
52 83
53 84 /**
54 85 * Register the route.
55 - *
56 - * @since 0.20.0
57 86 */
58 -function desktop_mode_my_wordpress_register_user_footprint_route() {
87 +function openstation_my_wordpress_register_user_footprint_route() {
59 88 register_rest_route(
60 89 'desktop-mode/v1',
61 90 '/user-footprint/(?P<id>\d+)',
62 91 array(
63 92 'methods' => WP_REST_Server::READABLE,
64 - 'callback' => 'desktop_mode_my_wordpress_user_footprint_callback',
93 + 'callback' => 'openstation_my_wordpress_user_footprint_callback',
65 94 'permission_callback' => static function () {
66 - return is_user_logged_in();
95 + // The module's gate, so a site that narrows WP Explorer
96 + // narrows this data with it. Every per-post check lives in
97 + // the callback.
98 + return openstation_my_wordpress_user_can_use();
67 99 },
68 100 'args' => array(
69 101 'id' => array(
70 102 'required' => true,
@@ -74,19 +106,131 @@
74 106 ),
75 107 )
76 108 );
77 109 }
78 -add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_user_footprint_route' );
110 +add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_footprint_route' );
79 111
80 112 /**
113 + * Whether the current user may see footprint activity on a post.
114 + *
115 + * One gate for the timeline rows and for the counts that summarise
116 + * them (see openstation_my_wordpress_footprint_visible_counts()), so
117 + * the two cannot disagree about what a viewer is allowed to know.
118 + *
119 + * - A comment's parent goes through
120 + * openstation_my_wordpress_can_read_comment_post(), the comment
121 + * dossier's gate: an orphaned comment is moderators-only, and a
122 + * parent of a non-viewable type needs `edit_post`, as does a parent
123 + * still sealed by a password. A viewer who has already entered that
124 + * password is not looking at a sealed post (`post_password_required()`
125 + * reads the cookie), and reads on `read_post` like anyone else.
126 + * - Any other post of a type with no readable front end needs
127 + * `edit_post`. Core resolves `read_post` on a published post of such
128 + * a type to plain `read`, which every logged-in user holds, so it
129 + * would read like public content.
130 + * - A viewable post in a public status is public already.
131 + * - Anything else is `read_post`, which resolves per status: the
132 + * post's author always, `read_private_posts` for a private post, and
133 + * `edit_others_posts` for drafts, pending and scheduled posts.
134 + *
135 + * Core answers a post of an unregistered type or status with
136 + * `edit_others_posts`, after a `_doing_it_wrong()` notice. Rows a
137 + * deactivated plugin left behind get the same answer here, without
138 + * the notice.
139 + *
140 + * @param WP_Post|null $post The post, or null when it no longer exists.
141 + * @param bool $for_comment Whether the activity is a comment on the post.
142 + * @return bool
143 + */
144 +function openstation_my_wordpress_footprint_can_see_post( $post, $for_comment = false ) {
145 + if ( ! $post ) {
146 + return $for_comment && current_user_can( 'moderate_comments' );
147 + }
148 + $status = get_post_status_object( $post->post_status );
149 + if ( ! $status || ! get_post_type_object( $post->post_type ) ) {
150 + return current_user_can( 'edit_others_posts' );
151 + }
152 + if ( $for_comment ) {
153 + return openstation_my_wordpress_can_read_comment_post( $post );
154 + }
155 + if ( ! is_post_type_viewable( $post->post_type ) ) {
156 + return current_user_can( 'edit_post', $post->ID );
157 + }
158 + return $status->public || current_user_can( 'read_post', $post->ID );
159 +}
160 +
161 +/**
162 + * Sum activity counts, keeping only the rows on posts the viewer may see.
163 + *
164 + * Each count query returns one row per post it needs decided, so the
165 + * gate above runs on every post a count includes, exactly as the
166 + * timeline runs it per row: a plugin that filters `read_post` or
167 + * `edit_post` for a single post moves the counts with the rows. Two
168 + * shapes keep that affordable:
169 + *
170 + * - Activity on posts anyone may see (a public status of a viewable
171 + * type, which the gate allows without a capability check) arrives
172 + * collapsed under `post_id` 0, so a prolific author's published
173 + * archive is one row rather than one per post.
174 + * - Every other post is loaded in one query, and decided once per
175 + * request however many days or counts it appears in.
176 + *
177 + * Comments have no bulk row: their gate asks `read_post` and the
178 + * parent's password even on a published post. For comments, `post_id`
179 + * 0 is a comment whose post no longer exists.
180 + *
181 + * @param array[]|null $rows Rows carrying `post_id` and `n`, plus `d` (Y-m-d) for per-day counts.
182 + * @param bool $for_comment Whether the rows count comments on the posts.
183 + * @param array $verdicts Gate answers already reached in this request, keyed by kind and post id.
184 + * @return array{ total: int, by_day: array<string, int> }
185 + */
186 +function openstation_my_wordpress_footprint_visible_counts( $rows, $for_comment, array &$verdicts ) {
187 + $rows = (array) $rows;
188 + $prefix = $for_comment ? 'comment:' : 'post:';
189 + $unseen = array();
190 + foreach ( $rows as $row ) {
191 + $id = (int) $row['post_id'];
192 + if ( $id > 0 && ! isset( $verdicts[ $prefix . $id ] ) ) {
193 + $unseen[ $id ] = $id;
194 + }
195 + }
196 + if ( $unseen ) {
197 + _prime_post_caches( array_values( $unseen ), false, false );
198 + }
199 +
200 + $total = 0;
201 + $by_day = array();
202 + foreach ( $rows as $row ) {
203 + $id = (int) $row['post_id'];
204 + if ( $id > 0 || $for_comment ) {
205 + $key = $prefix . $id;
206 + if ( ! isset( $verdicts[ $key ] ) ) {
207 + $verdicts[ $key ] = openstation_my_wordpress_footprint_can_see_post( $id > 0 ? get_post( $id ) : null, $for_comment );
208 + }
209 + if ( ! $verdicts[ $key ] ) {
210 + continue;
211 + }
212 + }
213 + $n = (int) $row['n'];
214 + $total += $n;
215 + if ( isset( $row['d'] ) ) {
216 + $day = (string) $row['d'];
217 + $by_day[ $day ] = ( $by_day[ $day ] ?? 0 ) + $n;
218 + }
219 + }
220 + return array(
221 + 'total' => $total,
222 + 'by_day' => $by_day,
223 + );
224 +}
225 +
226 +/**
81 227 * Aggregator callback. See the file docblock for the payload shape.
82 228 *
83 - * @since 0.20.0
84 - *
85 229 * @param WP_REST_Request $request REST request.
86 230 * @return array|WP_Error
87 231 */
88 -function desktop_mode_my_wordpress_user_footprint_callback( $request ) {
232 +function openstation_my_wordpress_user_footprint_callback( $request ) {
89 233 global $wpdb;
90 234
91 235 $user_id = (int) $request->get_param( 'id' );
92 236 $user = get_userdata( $user_id );
@@ -91,16 +235,16 @@
91 235 $user_id = (int) $request->get_param( 'id' );
92 236 $user = get_userdata( $user_id );
93 237 if ( ! $user ) {
94 238 return new WP_Error(
95 - 'desktop_mode_user_not_found',
239 + 'openstation_user_not_found',
96 240 __( 'User not found.', 'desktop-mode' ),
97 241 array( 'status' => 404 )
98 242 );
99 243 }
100 244
101 - $can_see_private = current_user_can( 'list_users' )
102 - || ( get_current_user_id() === $user_id );
245 + $viewer_id = get_current_user_id();
246 + $can_see_private = current_user_can( 'list_users' ) || ( $viewer_id === $user_id );
103 247
104 248 // ---- Profile (minimal — the dossier already returned the full one) ----
105 249 $profile = array(
106 250 'id' => (int) $user->ID,
@@ -124,24 +268,48 @@
124 268 }
125 269 }
126 270
127 271 // ---- Range: rolling 365-day window ending today (UTC bookends) -------
128 - $days = 365;
129 - $now = current_time( 'timestamp', true ); // UTC
272 + $days = 365;
273 + $now = time(); // UTC
130 274 $from_ts = strtotime( '-' . ( $days - 1 ) . ' days', $now );
131 275 $to_ts = $now;
132 - $range = array(
276 + $range = array(
133 277 'from' => gmdate( 'Y-m-d', $from_ts ),
134 278 'to' => gmdate( 'Y-m-d', $to_ts ),
135 279 'days' => $days,
136 280 );
137 281
138 - // ---- Daily counts (posts published per day + comments LEFT per day) --
139 - // Two queries (one for posts, one for comments), each grouped by
140 - // `DATE(post_date_gmt)` / `DATE(comment_date_gmt)`. Then we
141 - // densify to a full day-by-day array so the heatmap renders
142 - // every cell, even empty ones.
143 - $post_rows = $wpdb->get_results(
282 + // ---- Posts anyone may see ------------------------------------------
283 + // A public status of a viewable type. The gate allows those without a
284 + // capability check, so the update and content counts total them in
285 + // SQL under `post_id` 0 and name every other post for the gate; see
286 + // openstation_my_wordpress_footprint_visible_counts(). `$verdicts`
287 + // keeps each post's answer for the rest of the request.
288 + $open_stati = array_values( get_post_stati( array( 'public' => true ) ) );
289 + $open_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) );
290 + if ( ! $open_types ) {
291 + // Keeps the IN list valid. No row has an empty type, so every post
292 + // then goes through the gate.
293 + $open_types = array( '' );
294 + }
295 + $open_stati_in = implode( ', ', array_fill( 0, count( $open_stati ), '%s' ) );
296 + $open_types_in = implode( ', ', array_fill( 0, count( $open_types ), '%s' ) );
297 + $open_args = array_merge( $open_stati, $open_types );
298 + $verdicts = array();
299 +
300 + // ---- Daily counts (posts published, comments LEFT, updates saved) ----
301 + // One query per kind, each grouped by `DATE(post_date_gmt)` /
302 + // `DATE(comment_date_gmt)`. Then we densify to a full day-by-day
303 + // array so the heatmap renders every cell, even empty ones.
304 + //
305 + // Posts are published posts and pages, which anyone may see. A
306 + // comment or an update can land on a post the viewer may not read,
307 + // so those two queries name each post the timeline's gate has to
308 + // decide, and the rows it refuses are dropped: a heatmap cell
309 + // must not report "this user commented on, or edited, something
310 + // private on Tuesday" when the timeline withholds the row saying so.
311 + $post_rows = $wpdb->get_results(
144 312 $wpdb->prepare(
145 313 "SELECT DATE(post_date_gmt) AS d, COUNT(*) AS n
146 314 FROM {$wpdb->posts}
147 315 WHERE post_author = %d
@@ -159,16 +327,17 @@
159 327 foreach ( (array) $post_rows as $row ) {
160 328 $post_by_day[ (string) $row['d'] ] = (int) $row['n'];
161 329 }
162 330
163 - $comment_rows = $wpdb->get_results(
331 + $comment_rows = $wpdb->get_results(
164 332 $wpdb->prepare(
165 - "SELECT DATE(comment_date_gmt) AS d, COUNT(*) AS n
166 - FROM {$wpdb->comments}
167 - WHERE user_id = %d
168 - AND comment_approved = '1'
169 - AND comment_date_gmt >= %s
170 - GROUP BY d
333 + "SELECT DATE(c.comment_date_gmt) AS d, p.ID AS post_id, COUNT(*) AS n
334 + FROM {$wpdb->comments} c
335 + LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
336 + WHERE c.user_id = %d
337 + AND c.comment_approved = '1'
338 + AND c.comment_date_gmt >= %s
339 + GROUP BY d, p.ID
171 340 ORDER BY d ASC",
172 341 $user_id,
173 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
174 343 ),
@@ -173,46 +342,57 @@
173 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
174 343 ),
175 344 ARRAY_A
176 345 );
177 - $comment_by_day = array();
178 - foreach ( (array) $comment_rows as $row ) {
179 - $comment_by_day[ (string) $row['d'] ] = (int) $row['n'];
180 - }
346 + $comment_by_day = openstation_my_wordpress_footprint_visible_counts( $comment_rows, true, $verdicts )['by_day'];
181 347
182 - // Updates = revisions saved by this user, joined back to the
183 - // parent post so we can skip the initial-save revision (where the
184 - // revision's `post_date_gmt` equals the parent's `post_date_gmt`).
185 - // `r.post_author` (not the parent's) tracks who hit Save, so
186 - // updates an editor makes to someone else's post show up on the
187 - // editor's footprint — same shape GitHub's contribution graph
188 - // uses for commits across repos you don't own.
189 - $update_rows = $wpdb->get_results(
348 + // Updates = revisions saved by this user, joined back to the parent
349 + // post so we can skip the initial-save revision. `r.post_author`
350 + // (not the parent's) tracks who hit Save, so updates an editor makes
351 + // to someone else's post show up on the editor's footprint, the same
352 + // shape GitHub's contribution graph uses for commits across repos you
353 + // don't own.
354 + //
355 + // "Not the initial save" cannot be a date test alone, because a post's
356 + // date is when it goes live. A draft or pending post has none yet
357 + // (`post_date_gmt` stays zero) and a scheduled post's is in the
358 + // future, so every save made before publication compares as older
359 + // than the post and would never count, not even once it is published.
360 + // Every revision after the post's first therefore counts, and the
361 + // first counts only when it is newer than a real post date, as when a
362 + // post that never had a revision is edited later. The lifetime count
363 + // and the timeline query below carry the same clause; keep the three
364 + // in step.
365 + $update_rows = $wpdb->get_results(
190 366 $wpdb->prepare(
191 - "SELECT DATE(r.post_date_gmt) AS d, COUNT(*) AS n
367 + "SELECT DATE(r.post_date_gmt) AS d,
368 + CASE WHEN p.post_status IN ( {$open_stati_in} ) AND p.post_type IN ( {$open_types_in} ) THEN 0 ELSE p.ID END AS post_id,
369 + COUNT(*) AS n
192 370 FROM {$wpdb->posts} r
193 371 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
194 372 WHERE r.post_author = %d
195 373 AND r.post_type = 'revision'
196 374 AND r.post_status = 'inherit'
197 - AND r.post_date_gmt > p.post_date_gmt
375 + AND (
376 + ( p.post_date_gmt <> '0000-00-00 00:00:00' AND r.post_date_gmt > p.post_date_gmt )
377 + OR EXISTS (
378 + SELECT 1 FROM {$wpdb->posts} r0
379 + WHERE r0.post_parent = p.ID AND r0.post_type = 'revision' AND r0.ID < r.ID
380 + )
381 + )
198 382 AND r.post_date_gmt >= %s
199 - GROUP BY d
383 + GROUP BY d, post_id
200 384 ORDER BY d ASC",
201 - $user_id,
202 - gmdate( 'Y-m-d 00:00:00', $from_ts )
385 + array_merge( $open_args, array( $user_id, gmdate( 'Y-m-d 00:00:00', $from_ts ) ) )
203 386 ),
204 387 ARRAY_A
205 388 );
206 - $update_by_day = array();
207 - foreach ( (array) $update_rows as $row ) {
208 - $update_by_day[ (string) $row['d'] ] = (int) $row['n'];
209 - }
389 + $update_by_day = openstation_my_wordpress_footprint_visible_counts( $update_rows, false, $verdicts )['by_day'];
210 390
211 391 $daily = array();
212 - for ( $i = 0; $i < $days; $i += 1 ) {
213 - $ts = strtotime( '+' . $i . ' days', $from_ts );
214 - $date = gmdate( 'Y-m-d', $ts );
392 + for ( $i = 0; $i < $days; ++$i ) {
393 + $ts = strtotime( '+' . $i . ' days', $from_ts );
394 + $date = gmdate( 'Y-m-d', $ts );
215 395 $daily[] = array(
216 396 'date' => $date,
217 397 'posts' => isset( $post_by_day[ $date ] ) ? $post_by_day[ $date ] : 0,
218 398 'comments' => isset( $comment_by_day[ $date ] ) ? $comment_by_day[ $date ] : 0,
@@ -233,9 +413,9 @@
233 413 $user_id
234 414 ),
235 415 ARRAY_A
236 416 );
237 - $weekday = array( 0, 0, 0, 0, 0, 0, 0 );
417 + $weekday = array( 0, 0, 0, 0, 0, 0, 0 );
238 418 foreach ( (array) $weekday_rows as $row ) {
239 419 $dow = (int) $row['dow'];
240 420 if ( $dow >= 1 && $dow <= 7 ) {
241 421 $weekday[ $dow - 1 ] = (int) $row['n'];
@@ -257,9 +437,9 @@
257 437 $user_id
258 438 ),
259 439 ARRAY_A
260 440 );
261 - $hour = array_fill( 0, 24, 0 );
441 + $hour = array_fill( 0, 24, 0 );
262 442 foreach ( (array) $hour_rows as $row ) {
263 443 $h = (int) $row['h'];
264 444 if ( $h >= 0 && $h <= 23 ) {
265 445 $hour[ $h ] = (int) $row['n'];
@@ -290,9 +470,9 @@
290 470 if ( $is_active( $entry ) ) {
291 471 if ( ! $prev_day_active ) {
292 472 $run_start = $entry['date'];
293 473 }
294 - $longest_run += 1;
474 + ++$longest_run;
295 475 if ( $longest_run > $longest ) {
296 476 $longest = $longest_run;
297 477 $longest_from = $run_start;
298 478 $longest_to = $entry['date'];
@@ -303,11 +483,11 @@
303 483 $prev_day_active = false;
304 484 }
305 485 }
306 486 // Current streak — walk backward from today.
307 - for ( $i = count( $daily ) - 1; $i >= 0; $i -= 1 ) {
487 + for ( $i = count( $daily ) - 1; $i >= 0; --$i ) {
308 488 if ( $is_active( $daily[ $i ] ) ) {
309 - $current += 1;
489 + ++$current;
310 490 } else {
311 491 break;
312 492 }
313 493 }
@@ -323,9 +503,9 @@
323 503 // ---- Timeline: 30 most recent posts + comments, interleaved by date -
324 504 // One query per kind, then merge + sort + slice in PHP. Smaller and
325 505 // simpler than a SQL `UNION ALL`, and each branch already has the
326 506 // right index.
327 - $timeline_posts = $wpdb->get_results(
507 + $timeline_posts = $wpdb->get_results(
328 508 $wpdb->prepare(
329 509 "SELECT ID, post_title, post_status, post_date_gmt, post_type
330 510 FROM {$wpdb->posts}
331 511 WHERE post_author = %d
@@ -339,9 +519,9 @@
339 519 );
340 520 $timeline_comments = $wpdb->get_results(
341 521 $wpdb->prepare(
342 522 "SELECT c.comment_ID, c.comment_post_ID, c.comment_date_gmt, c.comment_approved,
343 - p.post_title
523 + p.post_title, p.post_status
344 524 FROM {$wpdb->comments} c
345 525 LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
346 526 WHERE c.user_id = %d
347 527 AND c.comment_approved = '1'
@@ -364,9 +544,15 @@
364 544 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
365 545 WHERE r.post_author = %d
366 546 AND r.post_type = 'revision'
367 547 AND r.post_status = 'inherit'
368 - AND r.post_date_gmt > p.post_date_gmt
548 + AND (
549 + ( p.post_date_gmt <> '0000-00-00 00:00:00' AND r.post_date_gmt > p.post_date_gmt )
550 + OR EXISTS (
551 + SELECT 1 FROM {$wpdb->posts} r0
552 + WHERE r0.post_parent = p.ID AND r0.post_type = 'revision' AND r0.ID < r.ID
553 + )
554 + )
369 555 AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
370 556 GROUP BY r.post_parent
371 557 ORDER BY last_save DESC
372 558 LIMIT 30",
@@ -373,11 +559,35 @@
373 559 $user_id
374 560 ),
375 561 ARRAY_A
376 562 );
377 - $timeline = array();
563 + $timeline = array();
564 + // Per-row gate: openstation_my_wordpress_footprint_can_see_post(), the
565 + // same one the counts above ask. Rows for non-published posts (draft,
566 + // pending, private, future, ...) carry titles the viewer may not be
567 + // allowed to see, and so do published rows of a type with no readable
568 + // front end and a comment's password-protected or deleted parent.
569 + // Authors and editors keep their full timeline, while ordinary
570 + // logged-in users only see published, viewable work.
571 + $timeline_ids = array_filter(
572 + array_map(
573 + 'intval',
574 + array_merge(
575 + wp_list_pluck( (array) $timeline_posts, 'ID' ),
576 + wp_list_pluck( (array) $timeline_comments, 'comment_post_ID' ),
577 + wp_list_pluck( (array) $timeline_updates, 'parent_id' )
578 + )
579 + )
580 + );
581 + if ( $timeline_ids ) {
582 + // Bulk-warm the post cache: the gate and get_permalink() read from it.
583 + _prime_post_caches( array_unique( $timeline_ids ), false, false );
584 + }
378 585 foreach ( (array) $timeline_posts as $p ) {
379 586 $pid = (int) $p['ID'];
587 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
588 + continue;
589 + }
380 590 $timeline[] = array(
381 591 'kind' => 'post',
382 592 'date' => mysql2date( 'c', $p['post_date_gmt'], false ),
383 593 'title' => (string) $p['post_title'],
@@ -388,8 +598,11 @@
388 598 );
389 599 }
390 600 foreach ( (array) $timeline_comments as $c ) {
391 601 $pid = (int) $c['comment_post_ID'];
602 + if ( ! openstation_my_wordpress_footprint_can_see_post( $pid > 0 ? get_post( $pid ) : null, true ) ) {
603 + continue;
604 + }
392 605 $timeline[] = array(
393 606 'kind' => 'comment',
394 607 'date' => mysql2date( 'c', $c['comment_date_gmt'], false ),
395 608 'title' => (string) ( $c['post_title'] ?? '' ),
@@ -399,8 +612,11 @@
399 612 );
400 613 }
401 614 foreach ( (array) $timeline_updates as $u ) {
402 615 $pid = (int) $u['parent_id'];
616 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
617 + continue;
618 + }
403 619 $timeline[] = array(
404 620 'kind' => 'post-update',
405 621 'date' => mysql2date( 'c', $u['last_save'], false ),
406 622 'title' => (string) $u['post_title'],
@@ -418,48 +634,77 @@
418 634 );
419 635 $timeline = array_slice( $timeline, 0, 30 );
420 636
421 637 // ---- Totals + most-prolific month -----------------------------------
422 - $totals_posts = (int) $wpdb->get_var(
638 + // Lifetime counts, each decided per post by the timeline's gate. Posts
639 + // and pages cover every non-internal status the viewer may read, so a
640 + // Subscriber gets published work only and cannot read how many
641 + // drafts, pending, private and scheduled posts another user is sitting
642 + // on (or watch that number move), while an Editor, whose timeline
643 + // lists those drafts, gets them counted too.
644 + $content_rows = $wpdb->get_results(
423 645 $wpdb->prepare(
424 - "SELECT COUNT(*) FROM {$wpdb->posts}
646 + "SELECT post_type,
647 + CASE WHEN post_status IN ( {$open_stati_in} ) AND post_type IN ( {$open_types_in} ) THEN 0 ELSE ID END AS post_id,
648 + COUNT(*) AS n
649 + FROM {$wpdb->posts}
425 650 WHERE post_author = %d
426 - AND post_type = 'post'
427 - AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
428 - $user_id
429 - )
651 + AND post_type IN ( 'post', 'page' )
652 + AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
653 + GROUP BY post_type, post_id",
654 + array_merge( $open_args, array( $user_id ) )
655 + ),
656 + ARRAY_A
430 657 );
431 - $totals_pages = (int) $wpdb->get_var(
658 + $totals_posts = openstation_my_wordpress_footprint_visible_counts(
659 + wp_list_filter( (array) $content_rows, array( 'post_type' => 'post' ) ),
660 + false,
661 + $verdicts
662 + )['total'];
663 + $totals_pages = openstation_my_wordpress_footprint_visible_counts(
664 + wp_list_filter( (array) $content_rows, array( 'post_type' => 'page' ) ),
665 + false,
666 + $verdicts
667 + )['total'];
668 + $comment_totals = $wpdb->get_results(
432 669 $wpdb->prepare(
433 - "SELECT COUNT(*) FROM {$wpdb->posts}
434 - WHERE post_author = %d
435 - AND post_type = 'page'
436 - AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
670 + "SELECT p.ID AS post_id, COUNT(*) AS n
671 + FROM {$wpdb->comments} c
672 + LEFT JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
673 + WHERE c.user_id = %d
674 + AND c.comment_approved = '1'
675 + GROUP BY p.ID",
437 676 $user_id
438 - )
677 + ),
678 + ARRAY_A
439 679 );
440 - $totals_comments = (int) $wpdb->get_var(
441 - $wpdb->prepare(
442 - "SELECT COUNT(*) FROM {$wpdb->comments}
443 - WHERE user_id = %d AND comment_approved = '1'",
444 - $user_id
445 - )
446 - );
680 + $totals_comments = openstation_my_wordpress_footprint_visible_counts( $comment_totals, true, $verdicts )['total'];
447 681 // Lifetime updates = revisions this user saved after the initial
448 682 // creation of the parent post. Matches the per-day `updates`
449 683 // definition so the hero stat and heatmap rollups agree.
450 - $totals_updates = (int) $wpdb->get_var(
684 + $update_totals = $wpdb->get_results(
451 685 $wpdb->prepare(
452 - "SELECT COUNT(*) FROM {$wpdb->posts} r
686 + "SELECT CASE WHEN p.post_status IN ( {$open_stati_in} ) AND p.post_type IN ( {$open_types_in} ) THEN 0 ELSE p.ID END AS post_id,
687 + COUNT(*) AS n
688 + FROM {$wpdb->posts} r
453 689 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
454 690 WHERE r.post_author = %d
455 691 AND r.post_type = 'revision'
456 692 AND r.post_status = 'inherit'
457 - AND r.post_date_gmt > p.post_date_gmt",
458 - $user_id
459 - )
693 + AND (
694 + ( p.post_date_gmt <> '0000-00-00 00:00:00' AND r.post_date_gmt > p.post_date_gmt )
695 + OR EXISTS (
696 + SELECT 1 FROM {$wpdb->posts} r0
697 + WHERE r0.post_parent = p.ID AND r0.post_type = 'revision' AND r0.ID < r.ID
698 + )
699 + )
700 + GROUP BY post_id",
701 + array_merge( $open_args, array( $user_id ) )
702 + ),
703 + ARRAY_A
460 704 );
461 - $month_row = $wpdb->get_row(
705 + $totals_updates = openstation_my_wordpress_footprint_visible_counts( $update_totals, false, $verdicts )['total'];
706 + $month_row = $wpdb->get_row(
462 707 $wpdb->prepare(
463 708 "SELECT DATE_FORMAT(post_date_gmt, '%%Y-%%m') AS ym, COUNT(*) AS n
464 709 FROM {$wpdb->posts}
465 710 WHERE post_author = %d
@@ -471,9 +716,9 @@
471 716 $user_id
472 717 ),
473 718 ARRAY_A
474 719 );
475 - $totals = array(
720 + $totals = array(
476 721 'posts' => $totals_posts,
477 722 'pages' => $totals_pages,
478 723 'comments' => $totals_comments,
479 724 'updates' => $totals_updates,
@@ -501,11 +746,9 @@
501 746 * the My WordPress folder window. Plugins can extend the timeline
502 747 * with their own activity rows, or replace the streak math with
503 748 * something domain-specific.
504 749 *
505 - * @since 0.20.0
506 - *
507 750 * @param array $payload Footprint payload.
508 751 * @param int $user_id Subject user id.
509 752 */
510 - return apply_filters( 'desktop_mode_my_wordpress_user_footprint', $payload, $user_id );
753 + return apply_filters( 'openstation_my_wordpress_user_footprint', $payload, $user_id );
511 754 }