PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / my-wordpress / user-stats.php

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

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