PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
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
desktop-mode / includes / user-edit-window / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.7, at includes/user-edit-window/rest.php

676 lines 21.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Native User Edit Window: insights endpoint.
4 *
5 * `GET /desktop-mode/v1/users/<id>/insights` — returns a single
6 * payload with everything the Insights tab needs:
7 *
8 * - profileCompleteness: filled vs total core fields, percent
9 * - stats: posts / pages / comments / media / approved-comments
10 * received on own posts / days since registration / last login
11 * - contentByMonth: last 12 months of posts authored (for the
12 * mini activity chart)
13 * - recentPosts: last 5 posts the user authored, with status +
14 * comment count
15 * - recentComments: last 5 comments the user wrote (NOT comments
16 * ON their posts — comments BY them, including on their own
17 * content)
18 * - sessions: count of active session tokens (from
19 * `WP_Session_Tokens`); `current` flagged when known
20 * - applicationPasswords: count + most-recently-used summary
21 * - lastLoginAt: UTC unix timestamp from
22 * `_desktop_mode_last_login_at` user meta
23 *
24 * Server-side caching: each user's insights are computed at most
25 * once per minute (transient cache keyed by user_id). The numbers
26 * are eventually-consistent — cheaper than re-running 6 SQL
27 * aggregates on every form interaction.
28 *
29 * @package WPDesktopMode
30 * @since 0.18.0
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Register the route.
37 *
38 * @since 0.18.0
39 */
40 function desktop_mode_user_edit_window_register_rest_routes() {
41 register_rest_route(
42 'desktop-mode/v1',
43 '/users/(?P<id>\d+)/insights',
44 array(
45 'methods' => WP_REST_Server::READABLE,
46 'callback' => 'desktop_mode_user_edit_window_rest_insights',
47 'permission_callback' => static function ( $req ) {
48 $id = (int) $req->get_param( 'id' );
49 return desktop_mode_user_edit_window_can_edit(
50 (int) get_current_user_id(),
51 $id
52 );
53 },
54 'args' => array(
55 'id' => array(
56 'required' => true,
57 'type' => 'integer',
58 ),
59 'fresh' => array(
60 'type' => 'boolean',
61 ),
62 ),
63 )
64 );
65 }
66 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_register_rest_routes' );
67
68 /**
69 * Register the personal-options user-meta keys with `show_in_rest`
70 * so the Profile form can save them via core's
71 * `PUT /wp/v2/users/<id>` `meta` field.
72 *
73 * Without this, the meta keys exist (core uses them on the
74 * classic profile.php save) but the REST controller ignores
75 * `meta.rich_editing` etc. on update.
76 *
77 * @since 0.18.0
78 */
79 function desktop_mode_user_edit_window_register_meta() {
80 $keys = array(
81 'rich_editing' => 'string',
82 'syntax_highlighting' => 'string',
83 'admin_color' => 'string',
84 'comment_shortcuts' => 'string',
85 'show_admin_bar_front' => 'string',
86 );
87 foreach ( $keys as $meta_key => $type ) {
88 register_meta(
89 'user',
90 $meta_key,
91 array(
92 'type' => $type,
93 'single' => true,
94 'show_in_rest' => array(
95 'schema' => array(
96 'type' => $type,
97 'context' => array( 'view', 'edit' ),
98 ),
99 ),
100 'auth_callback' => static function ( $allowed, $meta_key2, $user_id ) {
101 unset( $meta_key2 );
102 return current_user_can( 'edit_user', (int) $user_id );
103 },
104 'sanitize_callback' => 'sanitize_text_field',
105 )
106 );
107 }
108 }
109 add_action( 'init', 'desktop_mode_user_edit_window_register_meta' );
110
111 /**
112 * `POST /users/<id>/destroy-other-sessions` — log out everywhere
113 * else (current device kept). Mirrors the WP-core
114 * `destroy-sessions` AJAX action.
115 *
116 * @since 0.18.0
117 */
118 function desktop_mode_user_edit_window_destroy_sessions_route() {
119 register_rest_route(
120 'desktop-mode/v1',
121 '/users/(?P<id>\d+)/destroy-sessions',
122 array(
123 'methods' => WP_REST_Server::CREATABLE,
124 'callback' => 'desktop_mode_user_edit_window_rest_destroy_sessions',
125 'permission_callback' => static function ( $req ) {
126 $id = (int) $req->get_param( 'id' );
127 return desktop_mode_user_edit_window_can_edit(
128 (int) get_current_user_id(),
129 $id
130 );
131 },
132 'args' => array(
133 'id' => array( 'required' => true, 'type' => 'integer' ),
134 'scope' => array( 'type' => 'string', 'default' => 'others' ),
135 ),
136 )
137 );
138 }
139 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_destroy_sessions_route' );
140
141 function desktop_mode_user_edit_window_rest_destroy_sessions( $req ) {
142 $id = (int) $req->get_param( 'id' );
143 $scope = (string) $req->get_param( 'scope' );
144 if ( ! class_exists( 'WP_Session_Tokens' ) ) {
145 return new WP_Error(
146 'desktop_mode_users_no_sessions',
147 __( 'Session manager unavailable.', 'desktop-mode' ),
148 array( 'status' => 500 )
149 );
150 }
151 $manager = WP_Session_Tokens::get_instance( $id );
152 if ( $scope === 'all' || $id !== (int) get_current_user_id() ) {
153 // Editing another user — destroy ALL of their sessions.
154 // Editing self with scope='all' — destroy all (including
155 // the current). Note the latter logs the requester out.
156 $manager->destroy_all();
157 } else {
158 $manager->destroy_others( wp_get_session_token() );
159 }
160 // Bust the insights cache so the sessions count refreshes.
161 delete_transient( 'dm_user_insights_' . $id );
162 return rest_ensure_response( array( 'ok' => true ) );
163 }
164
165 /**
166 * `GET /users/<id>/application-passwords` — list app passwords.
167 * `POST /users/<id>/application-passwords` — create new.
168 * `DELETE /users/<id>/application-passwords/<uuid>` — revoke one.
169 *
170 * Thin wrappers over `WP_Application_Passwords` so the form has a
171 * single REST surface to talk to.
172 *
173 * @since 0.18.0
174 */
175 function desktop_mode_user_edit_window_app_passwords_routes() {
176 register_rest_route(
177 'desktop-mode/v1',
178 '/users/(?P<id>\d+)/application-passwords',
179 array(
180 array(
181 'methods' => WP_REST_Server::READABLE,
182 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_list',
183 'permission_callback' => static function ( $req ) {
184 return desktop_mode_user_edit_window_can_edit(
185 (int) get_current_user_id(),
186 (int) $req->get_param( 'id' )
187 );
188 },
189 ),
190 array(
191 'methods' => WP_REST_Server::CREATABLE,
192 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_create',
193 'permission_callback' => static function ( $req ) {
194 return desktop_mode_user_edit_window_can_edit(
195 (int) get_current_user_id(),
196 (int) $req->get_param( 'id' )
197 );
198 },
199 'args' => array(
200 'name' => array( 'required' => true, 'type' => 'string' ),
201 ),
202 ),
203 )
204 );
205 register_rest_route(
206 'desktop-mode/v1',
207 '/users/(?P<id>\d+)/application-passwords/(?P<uuid>[a-f0-9-]+)',
208 array(
209 'methods' => WP_REST_Server::DELETABLE,
210 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_revoke',
211 'permission_callback' => static function ( $req ) {
212 return desktop_mode_user_edit_window_can_edit(
213 (int) get_current_user_id(),
214 (int) $req->get_param( 'id' )
215 );
216 },
217 )
218 );
219 }
220 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_app_passwords_routes' );
221
222 function desktop_mode_user_edit_window_rest_app_pw_list( $req ) {
223 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
224 return rest_ensure_response( array( 'items' => array() ) );
225 }
226 $id = (int) $req->get_param( 'id' );
227 $apps = (array) WP_Application_Passwords::get_user_application_passwords( $id );
228 return rest_ensure_response( array( 'items' => $apps ) );
229 }
230
231 function desktop_mode_user_edit_window_rest_app_pw_create( $req ) {
232 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
233 return new WP_Error(
234 'desktop_mode_users_app_pw_unavailable',
235 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
236 array( 'status' => 501 )
237 );
238 }
239 $id = (int) $req->get_param( 'id' );
240 $name = sanitize_text_field( (string) $req->get_param( 'name' ) );
241 if ( '' === $name ) {
242 return new WP_Error(
243 'desktop_mode_users_app_pw_name_required',
244 __( 'Application password name is required.', 'desktop-mode' ),
245 array( 'status' => 400 )
246 );
247 }
248 $created = WP_Application_Passwords::create_new_application_password( $id, array( 'name' => $name ) );
249 if ( is_wp_error( $created ) ) {
250 return $created;
251 }
252 list( $unhashed_password, $item ) = $created;
253 delete_transient( 'dm_user_insights_' . $id );
254 return rest_ensure_response(
255 array(
256 'ok' => true,
257 'password' => $unhashed_password,
258 'item' => $item,
259 )
260 );
261 }
262
263 function desktop_mode_user_edit_window_rest_app_pw_revoke( $req ) {
264 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
265 return new WP_Error(
266 'desktop_mode_users_app_pw_unavailable',
267 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
268 array( 'status' => 501 )
269 );
270 }
271 $id = (int) $req->get_param( 'id' );
272 $uuid = (string) $req->get_param( 'uuid' );
273 $ok = WP_Application_Passwords::delete_application_password( $id, $uuid );
274 if ( is_wp_error( $ok ) ) {
275 return $ok;
276 }
277 delete_transient( 'dm_user_insights_' . $id );
278 return rest_ensure_response( array( 'ok' => true ) );
279 }
280
281 /**
282 * `GET /users/<id>/insights` callback.
283 *
284 * @since 0.18.0
285 *
286 * @param WP_REST_Request $req
287 * @return WP_REST_Response|WP_Error
288 */
289 function desktop_mode_user_edit_window_rest_insights( $req ) {
290 $id = (int) $req->get_param( 'id' );
291 $user = $id > 0 ? get_userdata( $id ) : null;
292 if ( ! $user instanceof WP_User ) {
293 return new WP_Error(
294 'desktop_mode_users_not_found',
295 __( 'User not found.', 'desktop-mode' ),
296 array( 'status' => 404 )
297 );
298 }
299
300 $fresh = (bool) $req->get_param( 'fresh' );
301 $cache_key = 'dm_user_insights_' . $id;
302 $payload = null;
303 if ( ! $fresh ) {
304 $cached = get_transient( $cache_key );
305 if ( is_array( $cached ) ) {
306 $payload = $cached;
307 }
308 }
309 if ( null === $payload ) {
310 $payload = desktop_mode_user_edit_window_compute_insights( $user );
311 }
312
313 // Self-view override — the viewer is by definition logged in
314 // right now since they're staring at their own profile. If the
315 // `_desktop_mode_last_login_at` meta isn't populated yet
316 // (account predates the plugin install, or the wp_login hook
317 // fires after the first profile open on a single-tick login)
318 // the tile would read "Never" — contradicting the obvious. Pin
319 // to current time and lazily backfill the meta so future reads
320 // no longer depend on this override. Lives OUTSIDE
321 // `compute_insights` so it applies on cache hits AS WELL AS
322 // fresh computes.
323 $viewer_id = (int) get_current_user_id();
324 if ( $id === $viewer_id ) {
325 $now = time();
326 $stored = (int) get_user_meta(
327 $id,
328 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
329 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
330 : '_desktop_mode_last_login_at',
331 true
332 );
333 if ( $stored <= 0 ) {
334 update_user_meta(
335 $id,
336 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
337 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
338 : '_desktop_mode_last_login_at',
339 $now
340 );
341 $stored = $now;
342 }
343 // Always reflect the truth on the payload — cached payloads
344 // from before the meta-backfill would otherwise still carry
345 // the stale `null`.
346 if ( ! isset( $payload['stats'] ) || ! is_array( $payload['stats'] ) ) {
347 $payload['stats'] = array();
348 }
349 if ( empty( $payload['stats']['lastLoginAt'] ) ) {
350 $payload['stats']['lastLoginAt'] = $stored;
351 $payload['stats']['daysSinceLastLogin'] = max(
352 0,
353 (int) floor( ( $now - $stored ) / DAY_IN_SECONDS )
354 );
355 }
356 }
357
358 /**
359 * Filter the insights payload before it's returned and cached.
360 *
361 * Plugins can append their own metrics (security-event counts,
362 * subscription tier, last-orders-placed, …) by extending the
363 * `stats` map or adding new top-level keys. The JS bundle
364 * tolerates unknown keys — they're surfaced as plugin tiles
365 * when they match the expected shape.
366 *
367 * @since 0.18.0
368 *
369 * @param array $payload Insights payload.
370 * @param WP_User $user Target user.
371 */
372 $payload = (array) apply_filters( 'desktop_mode_user_edit_window_insights', $payload, $user );
373
374 set_transient( $cache_key, $payload, MINUTE_IN_SECONDS );
375
376 return rest_ensure_response( $payload );
377 }
378
379 /**
380 * Compute the insights payload for a user. Centralized so plugins
381 * can call it directly from a custom REST route or admin notice
382 * without going through the HTTP cycle.
383 *
384 * @since 0.18.0
385 *
386 * @param WP_User $user
387 * @return array
388 */
389 function desktop_mode_user_edit_window_compute_insights( WP_User $user ) {
390 $id = (int) $user->ID;
391
392 // ── Profile completeness — count which core fields are non-empty.
393 $completeness_fields = array(
394 'first_name' => (string) $user->first_name,
395 'last_name' => (string) $user->last_name,
396 'nickname' => (string) $user->nickname,
397 'description' => (string) $user->description,
398 'user_url' => (string) $user->user_url,
399 'user_email' => (string) $user->user_email,
400 );
401 $filled = 0;
402 foreach ( $completeness_fields as $value ) {
403 if ( '' !== trim( $value ) ) {
404 $filled++;
405 }
406 }
407 $total = count( $completeness_fields );
408 $percent = $total > 0 ? (int) round( ( $filled / $total ) * 100 ) : 0;
409
410 // ── Per-CPT post counts. `count_user_posts` does the cheap thing.
411 $post_count = (int) count_user_posts( $id, 'post', true );
412 $page_count = post_type_exists( 'page' )
413 ? (int) count_user_posts( $id, 'page', true )
414 : 0;
415 $attachment_count = (int) count_user_posts( $id, 'attachment', true );
416
417 // ── Comments authored by this user (not received).
418 $comment_count = (int) get_comments(
419 array(
420 'user_id' => $id,
421 'count' => true,
422 )
423 );
424
425 // ── Approved comments RECEIVED on this user's published posts.
426 // Cheap aggregate — one COUNT, no row hydration.
427 global $wpdb;
428 $received_comments = (int) $wpdb->get_var(
429 $wpdb->prepare(
430 "SELECT COUNT(c.comment_ID)
431 FROM {$wpdb->comments} c
432 INNER JOIN {$wpdb->posts} p
433 ON p.ID = c.comment_post_ID
434 WHERE p.post_author = %d
435 AND p.post_status = 'publish'
436 AND c.comment_approved = '1'",
437 $id
438 )
439 );
440
441 // ── Months for the activity sparkline. Bucket published posts
442 // by year-month for the last 12 months. SQL bucket → align in PHP.
443 $month_buckets = array();
444 $now = time();
445 for ( $i = 11; $i >= 0; $i-- ) {
446 $ts = strtotime( "-{$i} months", $now );
447 $key = gmdate( 'Y-m', $ts );
448 $month_buckets[ $key ] = 0;
449 }
450 $rows = (array) $wpdb->get_results(
451 $wpdb->prepare(
452 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS bucket,
453 COUNT(*) AS cnt
454 FROM {$wpdb->posts}
455 WHERE post_author = %d
456 AND post_status IN ( 'publish', 'private', 'future' )
457 AND post_date_gmt >= %s
458 GROUP BY bucket
459 ORDER BY bucket ASC",
460 $id,
461 gmdate( 'Y-m-01 00:00:00', strtotime( '-12 months', $now ) )
462 ),
463 ARRAY_A
464 );
465 foreach ( $rows as $row ) {
466 $bucket = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
467 if ( isset( $month_buckets[ $bucket ] ) ) {
468 $month_buckets[ $bucket ] = (int) $row['cnt'];
469 }
470 }
471 $content_by_month = array();
472 foreach ( $month_buckets as $bucket => $cnt ) {
473 $content_by_month[] = array(
474 'month' => $bucket,
475 'count' => $cnt,
476 );
477 }
478
479 // ── Recent posts (any status). Limit 5.
480 $recent_posts = array();
481 $recent = get_posts(
482 array(
483 'author' => $id,
484 'post_type' => 'any',
485 'post_status' => array(
486 'publish',
487 'draft',
488 'pending',
489 'future',
490 'private',
491 ),
492 'posts_per_page' => 5,
493 'orderby' => 'date',
494 'order' => 'DESC',
495 )
496 );
497 foreach ( $recent as $post ) {
498 // `post_date_gmt` is `'0000-00-00 00:00:00'` for drafts that
499 // have never been published — the JS Date.parse of that
500 // returns NaN, and the previous fallback rendered every
501 // draft's "recent activity" timestamp as "just now". Use
502 // `get_gmt_from_date( post_date )` to convert the always-set
503 // local `post_date` to UTC when the GMT field is zero.
504 $gmt = (string) $post->post_date_gmt;
505 if ( '' === $gmt || 0 === strpos( $gmt, '0000-00-00' ) ) {
506 $gmt = (string) get_gmt_from_date( (string) $post->post_date );
507 }
508 $recent_posts[] = array(
509 'id' => (int) $post->ID,
510 'title' => $post->post_title !== ''
511 ? $post->post_title
512 : __( '(no title)', 'desktop-mode' ),
513 'status' => (string) $post->post_status,
514 'type' => (string) $post->post_type,
515 'dateGmt' => $gmt,
516 'commentCount' => (int) $post->comment_count,
517 'permalink' => (string) get_permalink( $post ),
518 'editUrl' => (string) get_edit_post_link( $post->ID, 'raw' ),
519 );
520 }
521
522 // ── Recent comments authored by this user. Limit 5.
523 $recent_comments = array();
524 $comments = get_comments(
525 array(
526 'user_id' => $id,
527 'number' => 5,
528 'orderby' => 'comment_date_gmt',
529 'order' => 'DESC',
530 )
531 );
532 foreach ( (array) $comments as $comment ) {
533 $post_title = '';
534 if ( $comment->comment_post_ID ) {
535 $post = get_post( (int) $comment->comment_post_ID );
536 if ( $post instanceof WP_Post ) {
537 $post_title = $post->post_title !== ''
538 ? $post->post_title
539 : __( '(no title)', 'desktop-mode' );
540 }
541 }
542 // Same zero-date fallback as recent posts above.
543 $comment_gmt = (string) $comment->comment_date_gmt;
544 if ( '' === $comment_gmt || 0 === strpos( $comment_gmt, '0000-00-00' ) ) {
545 $comment_gmt = (string) get_gmt_from_date(
546 (string) $comment->comment_date
547 );
548 }
549 $recent_comments[] = array(
550 'id' => (int) $comment->comment_ID,
551 'postId' => (int) $comment->comment_post_ID,
552 'postTitle' => $post_title,
553 'excerpt' => wp_trim_words(
554 wp_strip_all_tags( (string) $comment->comment_content ),
555 24
556 ),
557 'dateGmt' => $comment_gmt,
558 'approved' => '1' === (string) $comment->comment_approved,
559 );
560 }
561
562 // ── Active sessions (`WP_Session_Tokens`). The token bag is a
563 // blob of metadata per device — UA / IP / login + expiration. We
564 // surface a per-session row plus a current-session flag.
565 $sessions = array();
566 if ( class_exists( 'WP_Session_Tokens' ) ) {
567 $manager = WP_Session_Tokens::get_instance( $id );
568 $current_token = wp_get_session_token();
569 // `get_all` returns the tokens-as-array but doesn't expose
570 // the token id — peek into the meta blob via the user meta
571 // key directly so we can flag the "current" session.
572 $raw_tokens = (array) get_user_meta( $id, 'session_tokens', true );
573 // Prune expired entries the same way `WP_Session_Tokens`
574 // does on its own write path (so a user who hasn't logged
575 // in for a while doesn't show stale device rows).
576 $now_ts = time();
577 foreach ( $raw_tokens as $hash => $info ) {
578 if ( ! is_array( $info ) ) {
579 continue;
580 }
581 $expires = isset( $info['expiration'] ) ? (int) $info['expiration'] : 0;
582 if ( $expires > 0 && $expires < $now_ts ) {
583 continue;
584 }
585 $sessions[] = array(
586 'expiration' => $expires,
587 'login' => isset( $info['login'] ) ? (int) $info['login'] : 0,
588 'ip' => isset( $info['ip'] ) ? (string) $info['ip'] : '',
589 'ua' => isset( $info['ua'] ) ? (string) $info['ua'] : '',
590 'current' => $current_token && $current_token === $hash,
591 );
592 }
593 unset( $manager ); // unused but instantiated for symmetry / future use.
594 }
595
596 // ── Application passwords (WordPress 5.6+). Stored as user meta.
597 $app_passwords_summary = array(
598 'total' => 0,
599 'lastUsedAt' => null,
600 'lastUsedName' => null,
601 );
602 if ( class_exists( 'WP_Application_Passwords' ) ) {
603 $apps = WP_Application_Passwords::get_user_application_passwords( $id );
604 $apps = is_array( $apps ) ? $apps : array();
605 $app_passwords_summary['total'] = count( $apps );
606 $best_used_ts = 0;
607 $best_used_name = null;
608 foreach ( $apps as $app ) {
609 $used = isset( $app['last_used'] ) ? (int) $app['last_used'] : 0;
610 if ( $used > $best_used_ts ) {
611 $best_used_ts = $used;
612 $best_used_name = isset( $app['name'] ) ? (string) $app['name'] : null;
613 }
614 }
615 if ( $best_used_ts > 0 ) {
616 $app_passwords_summary['lastUsedAt'] = $best_used_ts;
617 $app_passwords_summary['lastUsedName'] = $best_used_name;
618 }
619 }
620
621 // ── Misc / temporal stats.
622 $registered_ts = strtotime( (string) $user->user_registered . ' UTC' );
623 $days_since_registration = $registered_ts
624 ? max( 0, (int) floor( ( time() - $registered_ts ) / DAY_IN_SECONDS ) )
625 : null;
626 $last_login_ts = (int) get_user_meta(
627 $id,
628 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
629 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
630 : '_desktop_mode_last_login_at',
631 true
632 );
633 $last_login_ts = $last_login_ts > 0 ? $last_login_ts : null;
634 $days_since_last_login = $last_login_ts
635 ? max( 0, (int) floor( ( time() - $last_login_ts ) / DAY_IN_SECONDS ) )
636 : null;
637
638 // ── Roles + capabilities count for the profile chip strip.
639 $roles = array_values( (array) $user->roles );
640 $caps_count = is_array( $user->allcaps ) ? count(
641 array_filter( $user->allcaps, static function ( $v ) {
642 return (bool) $v;
643 } )
644 ) : 0;
645
646 return array(
647 'userId' => $id,
648 'displayName' => (string) $user->display_name,
649 'avatarUrl' => (string) get_avatar_url( $id, array( 'size' => 96 ) ),
650 'profileUrl' => (string) get_author_posts_url( $id ),
651 'roles' => $roles,
652 'capabilitiesCount' => $caps_count,
653 'profileCompleteness' => array(
654 'filled' => $filled,
655 'total' => $total,
656 'percent' => $percent,
657 ),
658 'stats' => array(
659 'posts' => $post_count,
660 'pages' => $page_count,
661 'attachments' => $attachment_count,
662 'commentsAuthored' => $comment_count,
663 'commentsReceived' => $received_comments,
664 'daysSinceRegistration' => $days_since_registration,
665 'lastLoginAt' => $last_login_ts,
666 'daysSinceLastLogin' => $days_since_last_login,
667 'registeredAt' => $registered_ts ?: null,
668 ),
669 'contentByMonth' => $content_by_month,
670 'recentPosts' => $recent_posts,
671 'recentComments' => $recent_comments,
672 'sessions' => $sessions,
673 'applicationPasswords' => $app_passwords_summary,
674 );
675 }
676