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 +309 -81 1.1.71.1.10 View file →
@@ -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,12 +64,18 @@
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 79 * @package OpenStation
52 80 */
53 81
@@ -63,9 +91,12 @@
63 91 array(
64 92 'methods' => WP_REST_Server::READABLE,
65 93 'callback' => 'openstation_my_wordpress_user_footprint_callback',
66 94 'permission_callback' => static function () {
67 - 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();
68 99 },
69 100 'args' => array(
70 101 'id' => array(
71 102 'required' => true,
@@ -78,8 +109,122 @@
78 109 }
79 110 add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_footprint_route' );
80 111
81 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 +/**
82 227 * Aggregator callback. See the file docblock for the payload shape.
83 228 *
84 229 * @param WP_REST_Request $request REST request.
85 230 * @return array|WP_Error
@@ -96,10 +241,10 @@
96 241 array( 'status' => 404 )
97 242 );
98 243 }
99 244
100 - $can_see_private = current_user_can( 'list_users' )
101 - || ( 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 );
102 247
103 248 // ---- Profile (minimal — the dossier already returned the full one) ----
104 249 $profile = array(
105 250 'id' => (int) $user->ID,
@@ -133,13 +278,37 @@
133 278 'to' => gmdate( 'Y-m-d', $to_ts ),
134 279 'days' => $days,
135 280 );
136 281
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.
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.
142 311 $post_rows = $wpdb->get_results(
143 312 $wpdb->prepare(
144 313 "SELECT DATE(post_date_gmt) AS d, COUNT(*) AS n
145 314 FROM {$wpdb->posts}
@@ -160,14 +329,15 @@
160 329 }
161 330
162 331 $comment_rows = $wpdb->get_results(
163 332 $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
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
170 340 ORDER BY d ASC",
171 341 $user_id,
172 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
173 343 ),
@@ -172,41 +342,52 @@
172 342 gmdate( 'Y-m-d 00:00:00', $from_ts )
173 343 ),
174 344 ARRAY_A
175 345 );
176 - $comment_by_day = array();
177 - foreach ( (array) $comment_rows as $row ) {
178 - $comment_by_day[ (string) $row['d'] ] = (int) $row['n'];
179 - }
346 + $comment_by_day = openstation_my_wordpress_footprint_visible_counts( $comment_rows, true, $verdicts )['by_day'];
180 347
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.
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.
188 365 $update_rows = $wpdb->get_results(
189 366 $wpdb->prepare(
190 - "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
191 370 FROM {$wpdb->posts} r
192 371 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
193 372 WHERE r.post_author = %d
194 373 AND r.post_type = 'revision'
195 374 AND r.post_status = 'inherit'
196 - 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 + )
197 382 AND r.post_date_gmt >= %s
198 - GROUP BY d
383 + GROUP BY d, post_id
199 384 ORDER BY d ASC",
200 - $user_id,
201 - 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 ) ) )
202 386 ),
203 387 ARRAY_A
204 388 );
205 - $update_by_day = array();
206 - foreach ( (array) $update_rows as $row ) {
207 - $update_by_day[ (string) $row['d'] ] = (int) $row['n'];
208 - }
389 + $update_by_day = openstation_my_wordpress_footprint_visible_counts( $update_rows, false, $verdicts )['by_day'];
209 390
210 391 $daily = array();
211 392 for ( $i = 0; $i < $days; ++$i ) {
212 393 $ts = strtotime( '+' . $i . ' days', $from_ts );
@@ -363,9 +544,15 @@
363 544 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
364 545 WHERE r.post_author = %d
365 546 AND r.post_type = 'revision'
366 547 AND r.post_status = 'inherit'
367 - 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 + )
368 555 AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
369 556 GROUP BY r.post_parent
370 557 ORDER BY last_save DESC
371 558 LIMIT 30",
@@ -373,16 +560,32 @@
373 560 ),
374 561 ARRAY_A
375 562 );
376 563 $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.
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 + }
382 585 foreach ( (array) $timeline_posts as $p ) {
383 586 $pid = (int) $p['ID'];
384 - if ( 'publish' !== (string) $p['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
587 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
385 588 continue;
386 589 }
387 590 $timeline[] = array(
388 591 'kind' => 'post',
@@ -395,13 +598,9 @@
395 598 );
396 599 }
397 600 foreach ( (array) $timeline_comments as $c ) {
398 601 $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 ) ) {
602 + if ( ! openstation_my_wordpress_footprint_can_see_post( $pid > 0 ? get_post( $pid ) : null, true ) ) {
404 603 continue;
405 604 }
406 605 $timeline[] = array(
407 606 'kind' => 'comment',
@@ -413,9 +612,9 @@
413 612 );
414 613 }
415 614 foreach ( (array) $timeline_updates as $u ) {
416 615 $pid = (int) $u['parent_id'];
417 - if ( 'publish' !== (string) $u['post_status'] && ! current_user_can( 'read_post', $pid ) ) {
616 + if ( ! openstation_my_wordpress_footprint_can_see_post( get_post( $pid ) ) ) {
418 617 continue;
419 618 }
420 619 $timeline[] = array(
421 620 'kind' => 'post-update',
@@ -435,47 +634,76 @@
435 634 );
436 635 $timeline = array_slice( $timeline, 0, 30 );
437 636
438 637 // ---- Totals + most-prolific month -----------------------------------
439 - $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(
440 645 $wpdb->prepare(
441 - "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}
442 650 WHERE post_author = %d
443 - AND post_type = 'post'
444 - AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
445 - $user_id
446 - )
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
447 657 );
448 - $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(
449 669 $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' )",
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",
454 676 $user_id
455 - )
677 + ),
678 + ARRAY_A
456 679 );
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 - );
680 + $totals_comments = openstation_my_wordpress_footprint_visible_counts( $comment_totals, true, $verdicts )['total'];
464 681 // Lifetime updates = revisions this user saved after the initial
465 682 // creation of the parent post. Matches the per-day `updates`
466 683 // definition so the hero stat and heatmap rollups agree.
467 - $totals_updates = (int) $wpdb->get_var(
684 + $update_totals = $wpdb->get_results(
468 685 $wpdb->prepare(
469 - "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
470 689 INNER JOIN {$wpdb->posts} p ON r.post_parent = p.ID
471 690 WHERE r.post_author = %d
472 691 AND r.post_type = 'revision'
473 692 AND r.post_status = 'inherit'
474 - AND r.post_date_gmt > p.post_date_gmt",
475 - $user_id
476 - )
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
477 704 );
705 + $totals_updates = openstation_my_wordpress_footprint_visible_counts( $update_totals, false, $verdicts )['total'];
478 706 $month_row = $wpdb->get_row(
479 707 $wpdb->prepare(
480 708 "SELECT DATE_FORMAT(post_date_gmt, '%%Y-%%m') AS ym, COUNT(*) AS n
481 709 FROM {$wpdb->posts}