| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — My WordPress: per-user REST fields for the |
| 4 |
* Users-folder list view. |
| 5 |
* |
| 6 |
* Surfaces a small "summary" payload on every `/wp/v2/users` row so |
| 7 |
* the My WordPress folder window can paint a rich tile (avatar + |
| 8 |
* name + role chip + post count + member-since) without firing |
| 9 |
* N+1 dossier requests as the user scrolls. |
| 10 |
* |
| 11 |
* Fields: |
| 12 |
* |
| 13 |
* - `openstation_summary.postCount` int count of non-trash posts authored |
| 14 |
* - `openstation_summary.roleLabels` array translated role labels (gated on `list_users` OR self) |
| 15 |
* - `openstation_summary.registered` string ISO-8601 registered date (gated on `list_users` OR self) |
| 16 |
* - `openstation_summary.lastActive` string ISO-8601 of latest published post, or '' when unknown |
| 17 |
* |
| 18 |
* Each field is independently capability-gated so a viewer without |
| 19 |
* `list_users` still sees `postCount` (public counts) and a public |
| 20 |
* `lastActive` derived from publish dates, but doesn't see |
| 21 |
* `roleLabels` or `registered` (private). |
| 22 |
* |
| 23 |
* @package OpenStation |
| 24 |
*/ |
| 25 |
|
| 26 |
defined( 'ABSPATH' ) || exit; |
| 27 |
|
| 28 |
/** |
| 29 |
* The post types a summary counts. Kept in one place so the per-user |
| 30 |
* query and the grouped prefetch below can't drift apart — a tile |
| 31 |
* whose count changes depending on how the page was loaded is worse |
| 32 |
* than no count at all. |
| 33 |
* |
| 34 |
* @return string[] |
| 35 |
*/ |
| 36 |
function openstation_my_wordpress_user_summary_post_types() { |
| 37 |
return array( 'post', 'page' ); |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* Per-request store of prefetched post counts and last-active dates, |
| 42 |
* keyed by user id. Populated by |
| 43 |
* {@see openstation_my_wordpress_user_summary_prime()}; read by the |
| 44 |
* payload builder below. |
| 45 |
* |
| 46 |
* @param array|null $write Rows to merge in, or null to read. |
| 47 |
* @return array<int,array{postCount:int,lastActive:string}> |
| 48 |
*/ |
| 49 |
function &openstation_my_wordpress_user_summary_cache( $write = null ) { |
| 50 |
static $cache = array(); |
| 51 |
|
| 52 |
if ( is_array( $write ) ) { |
| 53 |
$cache = $cache + $write; |
| 54 |
} |
| 55 |
|
| 56 |
return $cache; |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Prefetch the two queried facts — authored-post count and latest |
| 61 |
* publish date — for a whole page of users in two grouped queries. |
| 62 |
* |
| 63 |
* Without this, a list view pays `count_user_posts()` plus a |
| 64 |
* `MAX(post_date_gmt)` lookup *per row*: 200 uncached queries for a |
| 65 |
* 100-row page. Call this once with the page's ids and the payload |
| 66 |
* builder answers from memory. |
| 67 |
* |
| 68 |
* @param int[] $user_ids User ids on the page. |
| 69 |
* @return void |
| 70 |
*/ |
| 71 |
function openstation_my_wordpress_user_summary_prime( $user_ids ) { |
| 72 |
global $wpdb; |
| 73 |
|
| 74 |
$cached = openstation_my_wordpress_user_summary_cache(); |
| 75 |
$ids = array(); |
| 76 |
foreach ( (array) $user_ids as $user_id ) { |
| 77 |
$user_id = (int) $user_id; |
| 78 |
if ( $user_id > 0 && ! isset( $cached[ $user_id ] ) ) { |
| 79 |
$ids[ $user_id ] = true; |
| 80 |
} |
| 81 |
} |
| 82 |
|
| 83 |
$ids = array_keys( $ids ); |
| 84 |
if ( empty( $ids ) ) { |
| 85 |
return; |
| 86 |
} |
| 87 |
|
| 88 |
$types = openstation_my_wordpress_user_summary_post_types(); |
| 89 |
$id_slots = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); |
| 90 |
$type_slots = implode( ',', array_fill( 0, count( $types ), '%s' ) ); |
| 91 |
|
| 92 |
$rows = array(); |
| 93 |
foreach ( $ids as $user_id ) { |
| 94 |
$rows[ $user_id ] = array( |
| 95 |
'postCount' => 0, |
| 96 |
'lastActive' => '', |
| 97 |
); |
| 98 |
} |
| 99 |
|
| 100 |
// The count `count_user_posts( $id, $types, true )` would produce, |
| 101 |
// for every id at once. Its `$public_only = true` resolves — via |
| 102 |
// `get_posts_by_author_sql()` — to exactly `post_status = |
| 103 |
// 'publish'`, so that is what this reproduces. |
| 104 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholder lists are built from counts, values are passed through prepare(). |
| 105 |
$counts = $wpdb->get_results( |
| 106 |
$wpdb->prepare( |
| 107 |
"SELECT post_author, COUNT(*) AS total FROM {$wpdb->posts} |
| 108 |
WHERE post_author IN ( {$id_slots} ) |
| 109 |
AND post_type IN ( {$type_slots} ) |
| 110 |
AND post_status = 'publish' |
| 111 |
GROUP BY post_author", |
| 112 |
array_merge( $ids, $types ) |
| 113 |
) |
| 114 |
); |
| 115 |
|
| 116 |
$actives = $wpdb->get_results( |
| 117 |
$wpdb->prepare( |
| 118 |
"SELECT post_author, MAX(post_date_gmt) AS latest FROM {$wpdb->posts} |
| 119 |
WHERE post_author IN ( {$id_slots} ) |
| 120 |
AND post_status = 'publish' |
| 121 |
AND post_type IN ( {$type_slots} ) |
| 122 |
GROUP BY post_author", |
| 123 |
array_merge( $ids, $types ) |
| 124 |
) |
| 125 |
); |
| 126 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 127 |
|
| 128 |
foreach ( (array) $counts as $row ) { |
| 129 |
$user_id = (int) $row->post_author; |
| 130 |
if ( isset( $rows[ $user_id ] ) ) { |
| 131 |
$rows[ $user_id ]['postCount'] = (int) $row->total; |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
foreach ( (array) $actives as $row ) { |
| 136 |
$user_id = (int) $row->post_author; |
| 137 |
if ( isset( $rows[ $user_id ] ) && $row->latest ) { |
| 138 |
$rows[ $user_id ]['lastActive'] = (string) mysql2date( 'c', $row->latest, false ); |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
openstation_my_wordpress_user_summary_cache( $rows ); |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Compute the summary payload for one user. |
| 147 |
* |
| 148 |
* Answers from the prefetch when |
| 149 |
* {@see openstation_my_wordpress_user_summary_prime()} has run for |
| 150 |
* this id, and falls back to the two per-user queries when it hasn't — |
| 151 |
* so a single-row caller stays correct without having to know about |
| 152 |
* the batch path. |
| 153 |
* |
| 154 |
* @param int $user_id User id. |
| 155 |
* @return array{postCount:int,roleLabels:array<int,string>,registered:string,lastActive:string} |
| 156 |
*/ |
| 157 |
function openstation_my_wordpress_user_summary_payload( $user_id ) { |
| 158 |
$user_id = (int) $user_id; |
| 159 |
if ( $user_id <= 0 ) { |
| 160 |
return array( |
| 161 |
'postCount' => 0, |
| 162 |
'roleLabels' => array(), |
| 163 |
'registered' => '', |
| 164 |
'lastActive' => '', |
| 165 |
); |
| 166 |
} |
| 167 |
|
| 168 |
$user = get_userdata( $user_id ); |
| 169 |
if ( ! $user ) { |
| 170 |
return array( |
| 171 |
'postCount' => 0, |
| 172 |
'roleLabels' => array(), |
| 173 |
'registered' => '', |
| 174 |
'lastActive' => '', |
| 175 |
); |
| 176 |
} |
| 177 |
|
| 178 |
$can_see_private = current_user_can( 'list_users' ) |
| 179 |
|| ( get_current_user_id() === $user_id ); |
| 180 |
|
| 181 |
$primed = openstation_my_wordpress_user_summary_cache(); |
| 182 |
$types = openstation_my_wordpress_user_summary_post_types(); |
| 183 |
|
| 184 |
// `count_user_posts` accepts an array of post types since 4.5; we |
| 185 |
// limit to public ones the dossier already counts so the tile and |
| 186 |
// the dossier agree. |
| 187 |
$post_count = isset( $primed[ $user_id ] ) |
| 188 |
? (int) $primed[ $user_id ]['postCount'] |
| 189 |
: (int) count_user_posts( $user_id, $types, true ); |
| 190 |
|
| 191 |
$role_labels = array(); |
| 192 |
if ( $can_see_private && function_exists( 'wp_roles' ) ) { |
| 193 |
$wp_roles = wp_roles(); |
| 194 |
foreach ( (array) $user->roles as $slug ) { |
| 195 |
$role_labels[] = isset( $wp_roles->role_names[ $slug ] ) |
| 196 |
? translate_user_role( $wp_roles->role_names[ $slug ] ) |
| 197 |
: $slug; |
| 198 |
} |
| 199 |
} |
| 200 |
|
| 201 |
$registered = ''; |
| 202 |
if ( $can_see_private && '' !== $user->user_registered ) { |
| 203 |
$registered = mysql2date( 'c', $user->user_registered, false ); |
| 204 |
} |
| 205 |
|
| 206 |
// Latest published post — useful at-a-glance "active recently?" |
| 207 |
// signal for the tile. Drawn from `post_date_gmt` so timezone |
| 208 |
// drift can't shift the displayed date. |
| 209 |
if ( isset( $primed[ $user_id ] ) ) { |
| 210 |
$last_active = (string) $primed[ $user_id ]['lastActive']; |
| 211 |
} else { |
| 212 |
global $wpdb; |
| 213 |
|
| 214 |
$type_slots = implode( ',', array_fill( 0, count( $types ), '%s' ) ); |
| 215 |
$last_active_raw = $wpdb->get_var( |
| 216 |
$wpdb->prepare( |
| 217 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholder list is built from a count, values pass through prepare(). |
| 218 |
"SELECT MAX(post_date_gmt) FROM {$wpdb->posts} |
| 219 |
WHERE post_author = %d |
| 220 |
AND post_status = 'publish' |
| 221 |
AND post_type IN ( {$type_slots} )", |
| 222 |
array_merge( array( $user_id ), $types ) |
| 223 |
) |
| 224 |
); |
| 225 |
$last_active = $last_active_raw ? mysql2date( 'c', $last_active_raw, false ) : ''; |
| 226 |
} |
| 227 |
|
| 228 |
return array( |
| 229 |
'postCount' => $post_count, |
| 230 |
'roleLabels' => $role_labels, |
| 231 |
'registered' => (string) $registered, |
| 232 |
'lastActive' => (string) $last_active, |
| 233 |
); |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Register the field on the `user` REST resource. The field is |
| 238 |
* read-only (no `update_callback`); `get_callback` receives the |
| 239 |
* user response array, from which we read `id`. |
| 240 |
*/ |
| 241 |
function openstation_my_wordpress_register_user_summary_field() { |
| 242 |
register_rest_field( |
| 243 |
'user', |
| 244 |
'openstation_summary', |
| 245 |
array( |
| 246 |
'get_callback' => static function ( $user ) { |
| 247 |
$id = isset( $user['id'] ) ? (int) $user['id'] : 0; |
| 248 |
return openstation_my_wordpress_user_summary_payload( $id ); |
| 249 |
}, |
| 250 |
'schema' => array( |
| 251 |
'description' => __( 'Compact user summary for the WP Explorer window.', 'desktop-mode' ), |
| 252 |
'type' => 'object', |
| 253 |
'context' => array( 'view', 'edit', 'embed' ), |
| 254 |
'readonly' => true, |
| 255 |
'properties' => array( |
| 256 |
'postCount' => array( |
| 257 |
'type' => 'integer', |
| 258 |
'description' => __( 'Count of posts and pages authored by this user.', 'desktop-mode' ), |
| 259 |
), |
| 260 |
'roleLabels' => array( |
| 261 |
'type' => 'array', |
| 262 |
'description' => __( 'Translated role labels visible to viewers with list_users.', 'desktop-mode' ), |
| 263 |
'items' => array( 'type' => 'string' ), |
| 264 |
), |
| 265 |
'registered' => array( |
| 266 |
'type' => 'string', |
| 267 |
'description' => __( 'ISO-8601 registration date (gated on list_users).', 'desktop-mode' ), |
| 268 |
), |
| 269 |
'lastActive' => array( |
| 270 |
'type' => 'string', |
| 271 |
'description' => __( 'ISO-8601 of latest publish; empty when the user has none.', 'desktop-mode' ), |
| 272 |
), |
| 273 |
), |
| 274 |
), |
| 275 |
) |
| 276 |
); |
| 277 |
} |
| 278 |
add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_summary_field' ); |
| 279 |
|