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 +335 -114 0.9.71.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,17 +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.
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.
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.
21 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 + *
22 44 * Payload shape:
23 45 *
24 46 * {
25 47 * profile: { id, name, avatarUrl, link, roleLabels?, registered? },
@@ -42,15 +64,20 @@
42 64 * types) so the renderer can pick a Post-vs-Page icon
43 65 * without a second REST lookup.
44 66 *
45 67 * "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
68 + * creation, i.e. the user opened an existing post and saved it
47 69 * again. The initial save (which WordPress also writes as a revision)
48 70 * is excluded so the per-day "updates" count doesn't double up with
49 - * 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.
50 78 *
51 - * @package WPDesktopMode
52 - * @since 0.8.2
79 + * @package OpenStation
53 80 */
54 81
55 82 defined( 'ABSPATH' ) || exit;
56 83
@@ -55,20 +82,21 @@
55 82 defined( 'ABSPATH' ) || exit;
56 83
57 84 /**
58 85 * Register the route.
59 - *
60 - * @since 0.8.2
61 86 */
62 -function desktop_mode_my_wordpress_register_user_footprint_route() {
87 +function openstation_my_wordpress_register_user_footprint_route() {
63 88 register_rest_route(
64 89 'desktop-mode/v1',
65 90 '/user-footprint/(?P<id>\d+)',
66 91 array(
67 92 'methods' => WP_REST_Server::READABLE,
68 - 'callback' => 'desktop_mode_my_wordpress_user_footprint_callback',
93 + 'callback' => 'openstation_my_wordpress_user_footprint_callback',
69 94 'permission_callback' => static function () {
70 - 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();
71 99 },
72 100 'args' => array(
73 101 'id' => array(
74 102 'required' => true,
@@ -78,19 +106,131 @@
78 106 ),
79 107 )
80 108 );
81 109 }
82 -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' );
83 111
84 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 +/**
85 227 * Aggregator callback. See the file docblock for the payload shape.
86 228 *
87 - * @since 0.8.2
88 - *
89 229 * @param WP_REST_Request $request REST request.
90 230 * @return array|WP_Error
91 231 */
92 -function desktop_mode_my_wordpress_user_footprint_callback( $request ) {
232 +function openstation_my_wordpress_user_footprint_callback( $request ) {
93 233 global $wpdb;
94 234
95 235 $user_id = (int) $request->get_param( 'id' );
96 236 $user = get_userdata( $user_id );
@@ -95,16 +235,16 @@
95 235 $user_id = (int) $request->get_param( 'id' );
96 236 $user = get_userdata( $user_id );
97 237 if ( ! $user ) {
98 238 return new WP_Error(
99 - 'desktop_mode_user_not_found',
239 + 'openstation_user_not_found',
100 240 __( 'User not found.', 'desktop-mode' ),
101 241 array( 'status' => 404 )
102 242 );
103 243 }
104 244
105 - $can_see_private = current_user_can( 'list_users' )
106 - || ( 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 );
107 247
108 248 // ---- Profile (minimal — the dossier already returned the full one) ----
109 249 $profile = array(
110 250 'id' => (int) $user->ID,
@@ -128,24 +268,48 @@
128 268 }
129 269 }
130 270
131 271 // ---- Range: rolling 365-day window ending today (UTC bookends) -------
132 - $days = 365;
133 - $now = current_time( 'timestamp', true ); // UTC
272 + $days = 365;
273 + $now = time(); // UTC
134 274 $from_ts = strtotime( '-' . ( $days - 1 ) . ' days', $now );
135 275 $to_ts = $now;
136 - $range = array(
276 + $range = array(
137 277 'from' => gmdate( 'Y-m-d', $from_ts ),
138 278 'to' => gmdate( 'Y-m-d', $to_ts ),
139 279 'days' => $days,
140 280 );
141 281
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(
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(
148 312 $wpdb->prepare(
149 313 "SELECT DATE(post_date_gmt) AS d, COUNT(*) AS n
150 314 FROM {$wpdb->posts}
151 315 WHERE post_author = %d
@@ -163,16 +327,17 @@
163 327 foreach ( (array) $post_rows as $row ) {
164 328 $post_by_day[ (string) $row['d'] ] = (int) $row['n'];
165 329 }
166 330
167 - $comment_rows = $wpdb->get_results(
331 + $comment_rows = $wpdb->get_results(
168 332 $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
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
175 340 ORDER BY d ASC",
176 341 $user_id,
177 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
178 343 ),
@@ -177,46 +342,57 @@
177 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
178 343 ),
179 344 ARRAY_A
180 345 );
181 - $comment_by_day = array();
182 - foreach ( (array) $comment_rows as $row ) {
183 - $comment_by_day[ (string) $row['d'] ] = (int) $row['n'];
184 - }
346 + $comment_by_day = openstation_my_wordpress_footprint_visible_counts( $comment_rows, true, $verdicts )['by_day'];
185 347
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(
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(
194 366 $wpdb->prepare(
195 - "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
196 370 FROM {$wpdb->posts} r
197 371 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
198 372 WHERE r.post_author = %d
199 373 AND r.post_type = 'revision'
200 374 AND r.post_status = 'inherit'
201 - 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 + )
202 382 AND r.post_date_gmt >= %s
203 - GROUP BY d
383 + GROUP BY d, post_id
204 384 ORDER BY d ASC",
205 - $user_id,
206 - 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 ) ) )
207 386 ),
208 387 ARRAY_A
209 388 );
210 - $update_by_day = array();
211 - foreach ( (array) $update_rows as $row ) {
212 - $update_by_day[ (string) $row['d'] ] = (int) $row['n'];
213 - }
389 + $update_by_day = openstation_my_wordpress_footprint_visible_counts( $update_rows, false, $verdicts )['by_day'];
214 390
215 391 $daily = array();
216 - for ( $i = 0; $i < $days; $i += 1 ) {
217 - $ts = strtotime( '+' . $i . ' days', $from_ts );
218 - $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 );
219 395 $daily[] = array(
220 396 'date' => $date,
221 397 'posts' => isset( $post_by_day[ $date ] ) ? $post_by_day[ $date ] : 0,
222 398 'comments' => isset( $comment_by_day[ $date ] ) ? $comment_by_day[ $date ] : 0,
@@ -237,9 +413,9 @@
237 413 $user_id
238 414 ),
239 415 ARRAY_A
240 416 );
241 - $weekday = array( 0, 0, 0, 0, 0, 0, 0 );
417 + $weekday = array( 0, 0, 0, 0, 0, 0, 0 );
242 418 foreach ( (array) $weekday_rows as $row ) {
243 419 $dow = (int) $row['dow'];
244 420 if ( $dow >= 1 && $dow <= 7 ) {
245 421 $weekday[ $dow - 1 ] = (int) $row['n'];
@@ -261,9 +437,9 @@
261 437 $user_id
262 438 ),
263 439 ARRAY_A
264 440 );
265 - $hour = array_fill( 0, 24, 0 );
441 + $hour = array_fill( 0, 24, 0 );
266 442 foreach ( (array) $hour_rows as $row ) {
267 443 $h = (int) $row['h'];
268 444 if ( $h >= 0 && $h <= 23 ) {
269 445 $hour[ $h ] = (int) $row['n'];
@@ -294,9 +470,9 @@
294 470 if ( $is_active( $entry ) ) {
295 471 if ( ! $prev_day_active ) {
296 472 $run_start = $entry['date'];
297 473 }
298 - $longest_run += 1;
474 + ++$longest_run;
299 475 if ( $longest_run > $longest ) {
300 476 $longest = $longest_run;
301 477 $longest_from = $run_start;
302 478 $longest_to = $entry['date'];
@@ -307,11 +483,11 @@
307 483 $prev_day_active = false;
308 484 }
309 485 }
310 486 // Current streak — walk backward from today.
311 - for ( $i = count( $daily ) - 1; $i >= 0; $i -= 1 ) {
487 + for ( $i = count( $daily ) - 1; $i >= 0; --$i ) {
312 488 if ( $is_active( $daily[ $i ] ) ) {
313 - $current += 1;
489 + ++$current;
314 490 } else {
315 491 break;
316 492 }
317 493 }
@@ -327,9 +503,9 @@
327 503 // ---- Timeline: 30 most recent posts + comments, interleaved by date -
328 504 // One query per kind, then merge + sort + slice in PHP. Smaller and
329 505 // simpler than a SQL `UNION ALL`, and each branch already has the
330 506 // right index.
331 - $timeline_posts = $wpdb->get_results(
507 + $timeline_posts = $wpdb->get_results(
332 508 $wpdb->prepare(
333 509 "SELECT ID, post_title, post_status, post_date_gmt, post_type
334 510 FROM {$wpdb->posts}
335 511 WHERE post_author = %d
@@ -368,9 +544,15 @@
368 544 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
369 545 WHERE r.post_author = %d
370 546 AND r.post_type = 'revision'
371 547 AND r.post_status = 'inherit'
372 - 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 + )
373 555 AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
374 556 GROUP BY r.post_parent
375 557 ORDER BY last_save DESC
376 558 LIMIT 30",
@@ -377,17 +559,33 @@
377 559 $user_id
378 560 ),
379 561 ARRAY_A
380 562 );
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.
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 + }
387 585 foreach ( (array) $timeline_posts as $p ) {
388 586 $pid = (int) $p['ID'];
389 - if ( 'publish' !== (string) $p['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
587 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
390 588 continue;
391 589 }
392 590 $timeline[] = array(
393 591 'kind' => 'post',
@@ -400,13 +598,9 @@
400 598 );
401 599 }
402 600 foreach ( (array) $timeline_comments as $c ) {
403 601 $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 ) ) {
602 + if ( ! openstation_my_wordpress_footprint_can_see_post( $pid > 0 ? get_post( $pid ) : null, true ) ) {
409 603 continue;
410 604 }
411 605 $timeline[] = array(
412 606 'kind' => 'comment',
@@ -418,9 +612,9 @@
418 612 );
419 613 }
420 614 foreach ( (array) $timeline_updates as $u ) {
421 615 $pid = (int) $u['parent_id'];
422 - if ( 'publish' !== (string) $u['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
616 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
423 617 continue;
424 618 }
425 619 $timeline[] = array(
426 620 'kind' => 'post-update',
@@ -440,48 +634,77 @@
440 634 );
441 635 $timeline = array_slice( $timeline, 0, 30 );
442 636
443 637 // ---- Totals + most-prolific month -----------------------------------
444 - $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(
445 645 $wpdb->prepare(
446 - "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}
447 650 WHERE post_author = %d
448 - AND post_type = 'post'
449 - AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
450 - $user_id
451 - )
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
452 657 );
453 - $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(
454 669 $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' )",
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",
459 676 $user_id
460 - )
677 + ),
678 + ARRAY_A
461 679 );
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 - );
680 + $totals_comments = openstation_my_wordpress_footprint_visible_counts( $comment_totals, true, $verdicts )['total'];
469 681 // Lifetime updates = revisions this user saved after the initial
470 682 // creation of the parent post. Matches the per-day `updates`
471 683 // definition so the hero stat and heatmap rollups agree.
472 - $totals_updates = (int) $wpdb->get_var(
684 + $update_totals = $wpdb->get_results(
473 685 $wpdb->prepare(
474 - "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
475 689 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
476 690 WHERE r.post_author = %d
477 691 AND r.post_type = 'revision'
478 692 AND r.post_status = 'inherit'
479 - AND r.post_date_gmt > p.post_date_gmt",
480 - $user_id
481 - )
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
482 704 );
483 - $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(
484 707 $wpdb->prepare(
485 708 "SELECT DATE_FORMAT(post_date_gmt, '%%Y-%%m') AS ym, COUNT(*) AS n
486 709 FROM {$wpdb->posts}
487 710 WHERE post_author = %d
@@ -493,9 +716,9 @@
493 716 $user_id
494 717 ),
495 718 ARRAY_A
496 719 );
497 - $totals = array(
720 + $totals = array(
498 721 'posts' => $totals_posts,
499 722 'pages' => $totals_pages,
500 723 'comments' => $totals_comments,
501 724 'updates' => $totals_updates,
@@ -523,11 +746,9 @@
523 746 * the My WordPress folder window. Plugins can extend the timeline
524 747 * with their own activity rows, or replace the streak math with
525 748 * something domain-specific.
526 749 *
527 - * @since 0.8.2
528 - *
529 750 * @param array $payload Footprint payload.
530 751 * @param int $user_id Subject user id.
531 752 */
532 - return apply_filters( 'desktop_mode_my_wordpress_user_footprint', $payload, $user_id );
753 + return apply_filters( 'openstation_my_wordpress_user_footprint', $payload, $user_id );
533 754 }