PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.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 / my-wordpress / user-stats.php

user-stats.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/my-wordpress/user-stats.php

358 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — My WordPress: per-user stats endpoint.
4 *
5 * `GET /desktop-mode/v1/user-stats/<id>` returns an aggregated
6 * profile + activity blob for the requested user. The right
7 * preview pane in the My WordPress folder uses it to paint a rich
8 * dossier (post / page / comment counts, recent posts, top
9 * categories, role + member-since) without forcing the client to
10 * make N parallel REST calls.
11 *
12 * Permissions: anyone with `list_users` — or the subject user
13 * viewing their own dossier — sees full data; everyone else sees
14 * the public subset (display name, avatar, post archive link,
15 * published-only counts and recent posts). Sensitive fields
16 * (email, registered date, role) are gated on the cap.
17 *
18 * @package WPDesktopMode
19 * @since 0.8.0
20 */
21
22 defined( 'ABSPATH' ) || exit;
23
24 /**
25 * Register the route.
26 *
27 * @since 0.8.0
28 */
29 function desktop_mode_my_wordpress_register_user_stats_route() {
30 register_rest_route(
31 'desktop-mode/v1',
32 '/user-stats/(?P<id>\d+)',
33 array(
34 'methods' => WP_REST_Server::READABLE,
35 'callback' => 'desktop_mode_my_wordpress_user_stats_callback',
36 'permission_callback' => static function () {
37 // Logged-in users only — author archives are public,
38 // but the dossier mixes counts that aren't.
39 return is_user_logged_in();
40 },
41 'args' => array(
42 'id' => array(
43 'required' => true,
44 'type' => 'integer',
45 'sanitize_callback' => 'absint',
46 ),
47 ),
48 )
49 );
50 }
51 add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_user_stats_route' );
52
53 /**
54 * Aggregator callback. Returns the dossier shape (see file
55 * docblock above for fields).
56 *
57 * @since 0.8.0
58 *
59 * @param WP_REST_Request $request REST request.
60 * @return array|WP_Error
61 */
62 function desktop_mode_my_wordpress_user_stats_callback( $request ) {
63 global $wpdb;
64 $user_id = (int) $request->get_param( 'id' );
65 $user = get_userdata( $user_id );
66 if ( ! $user ) {
67 return new WP_Error(
68 'desktop_mode_user_not_found',
69 __( 'User not found.', 'desktop-mode' ),
70 array( 'status' => 404 )
71 );
72 }
73
74 $can_see_private = current_user_can( 'list_users' )
75 || ( get_current_user_id() === $user_id );
76
77 // ----- Profile -----------------------------------------------------
78 $profile = array(
79 'id' => (int) $user->ID,
80 'name' => $user->display_name,
81 'description' => (string) $user->description,
82 'link' => get_author_posts_url( $user->ID ),
83 'website' => esc_url_raw( $user->user_url ),
84 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 192 ) ),
85 );
86 if ( $can_see_private ) {
87 $profile['email'] = $user->user_email;
88 $profile['username'] = $user->user_login;
89 $profile['registered'] = mysql2date( 'c', $user->user_registered, false );
90 $profile['roles'] = array_values( (array) $user->roles );
91 $role_labels = array();
92 if ( function_exists( 'wp_roles' ) ) {
93 $wp_roles = wp_roles();
94 foreach ( (array) $user->roles as $slug ) {
95 $role_labels[] = isset( $wp_roles->role_names[ $slug ] )
96 ? translate_user_role( $wp_roles->role_names[ $slug ] )
97 : $slug;
98 }
99 }
100 $profile['roleLabels'] = $role_labels;
101 }
102
103 // ----- Counts ------------------------------------------------------
104 // Posts (the post type) by status.
105 $post_status_rows = $wpdb->get_results(
106 $wpdb->prepare(
107 "SELECT post_status, COUNT(*) AS n
108 FROM {$wpdb->posts}
109 WHERE post_author = %d
110 AND post_type = 'post'
111 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
112 GROUP BY post_status",
113 $user_id
114 ),
115 ARRAY_A
116 );
117 $post_counts = array(
118 'publish' => 0,
119 'draft' => 0,
120 'pending' => 0,
121 'private' => 0,
122 'future' => 0,
123 'total' => 0,
124 );
125 foreach ( (array) $post_status_rows as $row ) {
126 $status = (string) $row['post_status'];
127 $n = (int) $row['n'];
128 $post_counts['total'] += $n;
129 if ( isset( $post_counts[ $status ] ) ) {
130 $post_counts[ $status ] = $n;
131 }
132 }
133
134 // Pages by status.
135 $page_status_rows = $wpdb->get_results(
136 $wpdb->prepare(
137 "SELECT post_status, COUNT(*) AS n
138 FROM {$wpdb->posts}
139 WHERE post_author = %d
140 AND post_type = 'page'
141 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
142 GROUP BY post_status",
143 $user_id
144 ),
145 ARRAY_A
146 );
147 $page_counts = array(
148 'publish' => 0,
149 'draft' => 0,
150 'total' => 0,
151 );
152 foreach ( (array) $page_status_rows as $row ) {
153 $status = (string) $row['post_status'];
154 $n = (int) $row['n'];
155 $page_counts['total'] += $n;
156 if ( isset( $page_counts[ $status ] ) ) {
157 $page_counts[ $status ] = $n;
158 }
159 }
160
161 if ( ! $can_see_private ) {
162 // Viewers without `list_users` (and who aren't the subject)
163 // only get published counts — see the permissions note in
164 // the file docblock.
165 $post_counts = array(
166 'publish' => $post_counts['publish'],
167 'total' => $post_counts['publish'],
168 );
169 $page_counts = array(
170 'publish' => $page_counts['publish'],
171 'total' => $page_counts['publish'],
172 );
173 }
174
175 // Comments received on posts authored by this user, approved only.
176 // Non-privileged viewers only see engagement on published content.
177 $received_status_sql = $can_see_private
178 ? "p.post_status NOT IN ( 'auto-draft', 'trash' )"
179 : "p.post_status = 'publish'";
180 $comments_received = (int) $wpdb->get_var(
181 $wpdb->prepare(
182 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
183 "SELECT COUNT(c.comment_ID)
184 FROM {$wpdb->comments} c
185 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
186 WHERE p.post_author = %d
187 AND c.comment_approved = '1'
188 AND {$received_status_sql}",
189 $user_id
190 )
191 );
192
193 // Comments left BY this user (regardless of post author).
194 $comments_left = (int) $wpdb->get_var(
195 $wpdb->prepare(
196 "SELECT COUNT(*)
197 FROM {$wpdb->comments}
198 WHERE user_id = %d
199 AND comment_approved = '1'",
200 $user_id
201 )
202 );
203
204 // Total content (posts + pages + any custom public post types).
205 // Same gating as above: published-only unless privileged.
206 $cpt_status_sql = $can_see_private
207 ? "post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )"
208 : "post_status = 'publish'";
209 $cpt_count = (int) $wpdb->get_var(
210 $wpdb->prepare(
211 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
212 "SELECT COUNT(*)
213 FROM {$wpdb->posts}
214 WHERE post_author = %d
215 AND post_type NOT IN ( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' )
216 AND {$cpt_status_sql}",
217 $user_id
218 )
219 );
220
221 $counts = array(
222 'posts' => $post_counts,
223 'pages' => $page_counts,
224 'commentsReceived' => $comments_received,
225 'commentsLeft' => $comments_left,
226 'cpt' => $cpt_count,
227 );
228
229 // ----- Recent posts (latest 5) -------------------------------------
230 // Privileged viewers also see private/scheduled/draft/pending;
231 // everyone else gets published posts only.
232 $recent_posts = get_posts(
233 array(
234 'author' => $user_id,
235 'post_type' => array( 'post', 'page' ),
236 'post_status' => $can_see_private
237 ? array( 'publish', 'private', 'future', 'draft', 'pending' )
238 : array( 'publish' ),
239 'posts_per_page' => 5,
240 'orderby' => 'date',
241 'order' => 'DESC',
242 'suppress_filters' => false,
243 )
244 );
245 $recent = array();
246 foreach ( (array) $recent_posts as $p ) {
247 if ( ! ( $p instanceof WP_Post ) ) {
248 continue;
249 }
250 $recent[] = array(
251 'id' => (int) $p->ID,
252 'title' => get_the_title( $p ),
253 'date' => mysql2date( 'c', $p->post_date_gmt, false ),
254 'status' => (string) $p->post_status,
255 'type' => (string) $p->post_type,
256 'link' => get_permalink( $p ),
257 );
258 }
259
260 // ----- Top categories (most used by this author) -------------------
261 $top_term_rows = $wpdb->get_results(
262 $wpdb->prepare(
263 "SELECT t.term_id, t.name, t.slug, tt.taxonomy, COUNT(*) AS n
264 FROM {$wpdb->term_relationships} tr
265 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
266 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
267 INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
268 WHERE p.post_author = %d
269 AND p.post_type = 'post'
270 AND p.post_status = 'publish'
271 AND tt.taxonomy IN ( 'category', 'post_tag' )
272 GROUP BY t.term_id, tt.taxonomy
273 ORDER BY n DESC
274 LIMIT 5",
275 $user_id
276 ),
277 ARRAY_A
278 );
279 $top_terms = array();
280 foreach ( (array) $top_term_rows as $row ) {
281 $top_terms[] = array(
282 'id' => (int) $row['term_id'],
283 'name' => (string) $row['name'],
284 'slug' => (string) $row['slug'],
285 'taxonomy' => (string) $row['taxonomy'],
286 'count' => (int) $row['n'],
287 );
288 }
289
290 // ----- Activity (posts published per month, last 12 months) --------
291 // Lightweight sparkline source. Exclude trash + auto-draft, group by
292 // year-month so the JS can fill gaps.
293 $activity_rows = $wpdb->get_results(
294 $wpdb->prepare(
295 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS ym, COUNT(*) AS n
296 FROM {$wpdb->posts}
297 WHERE post_author = %d
298 AND post_type IN ( 'post', 'page' )
299 AND post_status = 'publish'
300 AND post_date_gmt >= DATE_SUB( NOW(), INTERVAL 12 MONTH )
301 GROUP BY ym
302 ORDER BY ym ASC",
303 $user_id
304 ),
305 ARRAY_A
306 );
307 $activity = array();
308 foreach ( (array) $activity_rows as $row ) {
309 $activity[] = array(
310 'ym' => (string) $row['ym'],
311 'count' => (int) $row['n'],
312 );
313 }
314
315 // ----- First & last published --------------------------------------
316 // (Streak math lives in the separate user-footprint endpoint.)
317 $first_post = $wpdb->get_var(
318 $wpdb->prepare(
319 "SELECT MIN(post_date_gmt) FROM {$wpdb->posts}
320 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
321 $user_id
322 )
323 );
324 $last_post = $wpdb->get_var(
325 $wpdb->prepare(
326 "SELECT MAX(post_date_gmt) FROM {$wpdb->posts}
327 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
328 $user_id
329 )
330 );
331 $milestones = array(
332 'firstPublished' => $first_post ? mysql2date( 'c', $first_post, false ) : null,
333 'lastPublished' => $last_post ? mysql2date( 'c', $last_post, false ) : null,
334 );
335
336 $payload = array(
337 'profile' => $profile,
338 'counts' => $counts,
339 'recent' => $recent,
340 'topTerms' => $top_terms,
341 'activity' => $activity,
342 'milestones' => $milestones,
343 );
344
345 /**
346 * Filter the per-user stats payload before it's returned to
347 * the My WordPress folder window. Plugins can drop additional
348 * stat sections (badges, milestones, contribution streaks)
349 * here without forking the JS render.
350 *
351 * @since 0.8.0
352 *
353 * @param array $payload Stats payload.
354 * @param int $user_id Subject user id.
355 */
356 return apply_filters( 'desktop_mode_my_wordpress_user_stats', $payload, $user_id );
357 }
358