| @@ -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 | } |