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 / term-stats.php

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

442 lines 14.7 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-term stats endpoint.
4 *
5 * `GET /desktop-mode/v1/term-stats/<taxonomy>/<id>` returns an
6 * aggregated profile for a single category or tag — counts, recent
7 * posts in the term, top authors, co-occurring terms, 12-month
8 * activity sparkline, milestones. Powers the right preview pane in
9 * the My WordPress folder when a term is selected.
10 *
11 * Permissions: any logged-in user with `read` (default for most
12 * roles) — terms are public-facing data on the WP site, so the same
13 * cap that lets you read the front-end is enough to inspect their
14 * stats. Author archives are also public so listing top authors is
15 * not new disclosure.
16 *
17 * That reasoning covers the term row and the aggregates over its
18 * *published* posts; it does not carry to the unpublished posts inside
19 * the term, nor to terms of a non-viewable taxonomy. So hidden
20 * taxonomies answer 400 unless the caller can manage their terms,
21 * every post-level query is scoped to the statuses the caller may
22 * read — resolved from each status's registered visibility flags and
23 * the post type's cap map, plus the caller's own posts — and the
24 * recent list is gated per row with `read_post`. Otherwise a
25 * subscriber could read an administrator's private and draft post
26 * titles, authors and dates, and the per-status counts would leak how
27 * many hidden posts a term holds. The readable-status clause is built
28 * in the callback, right above the queries that splice it in.
29 *
30 * @package OpenStation
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Register the route.
37 */
38 function openstation_my_wordpress_register_term_stats_route() {
39 register_rest_route(
40 'desktop-mode/v1',
41 '/term-stats/(?P<taxonomy>[a-zA-Z0-9_-]+)/(?P<id>\d+)',
42 array(
43 'methods' => WP_REST_Server::READABLE,
44 'callback' => 'openstation_my_wordpress_term_stats_callback',
45 'permission_callback' => static function () {
46 return is_user_logged_in() && current_user_can( 'read' );
47 },
48 'args' => array(
49 'taxonomy' => array(
50 'required' => true,
51 'type' => 'string',
52 'sanitize_callback' => 'sanitize_key',
53 ),
54 'id' => array(
55 'required' => true,
56 'type' => 'integer',
57 'sanitize_callback' => 'absint',
58 ),
59 ),
60 )
61 );
62 }
63 add_action( 'rest_api_init', 'openstation_my_wordpress_register_term_stats_route' );
64
65 /**
66 * Aggregator callback. See file docblock for return shape.
67 *
68 * @param WP_REST_Request $request REST request.
69 * @return array|WP_Error
70 */
71 function openstation_my_wordpress_term_stats_callback( $request ) {
72 global $wpdb;
73 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
74 $term_id = (int) $request->get_param( 'id' );
75
76 $tax_obj = get_taxonomy( $taxonomy );
77 // A registered-but-hidden taxonomy (nav_menu, link_category, a
78 // plugin's internal one) is not public-facing data the way
79 // categories and tags are, so the file docblock's `read` reasoning
80 // does not cover it: answer exactly as if it were unregistered
81 // unless the caller can manage its terms.
82 if ( ! $tax_obj || ( ! is_taxonomy_viewable( $tax_obj ) && ! current_user_can( $tax_obj->cap->manage_terms ) ) ) {
83 return new WP_Error(
84 'openstation_invalid_taxonomy',
85 __( 'Unknown taxonomy.', 'desktop-mode' ),
86 array( 'status' => 400 )
87 );
88 }
89
90 $term = get_term( $term_id, $taxonomy );
91 if ( ! $term || is_wp_error( $term ) ) {
92 return new WP_Error(
93 'openstation_term_not_found',
94 __( 'Term not found.', 'desktop-mode' ),
95 array( 'status' => 404 )
96 );
97 }
98
99 // ----- Profile -----------------------------------------------------
100 $profile = array(
101 'id' => (int) $term->term_id,
102 'name' => $term->name,
103 'slug' => $term->slug,
104 'taxonomy' => $term->taxonomy,
105 'taxonomyLabel' => isset( $tax_obj->labels->singular_name )
106 ? (string) $tax_obj->labels->singular_name
107 : $taxonomy,
108 'description' => (string) $term->description,
109 'link' => get_term_link( $term ) instanceof WP_Error
110 ? ''
111 : (string) get_term_link( $term ),
112 'parent' => (int) $term->parent,
113 'storedCount' => (int) $term->count, // core's published-only count
114 );
115 if ( $term->parent > 0 ) {
116 $parent = get_term( $term->parent, $taxonomy );
117 if ( $parent && ! is_wp_error( $parent ) ) {
118 $profile['parentName'] = $parent->name;
119 }
120 }
121
122 $tt_id = (int) $term->term_taxonomy_id;
123
124 // Every query below that can touch unpublished posts is scoped to
125 // the statuses the caller may read (the remaining aggregates are
126 // publish-only). The endpoint gates on the term (public), but the
127 // posts inside it are not: without this, a subscriber gets the
128 // titles, authors and dates of administrator-owned drafts/private
129 // posts, and the per-status counts become an oracle for content
130 // they cannot see.
131 //
132 // The sets come from the registered status objects, so a plugin's
133 // custom status follows its own visibility flags: public statuses
134 // for everyone; private-flagged ones with the post type's
135 // read_private_posts; the remaining non-internal statuses (draft,
136 // pending, future and any registered workflow status — trash and
137 // auto-draft are internal) with edit_others_posts, because core
138 // maps reading them to editing them, plus edit_published_posts for
139 // a scheduled post, mirroring map_meta_cap(); and the caller's own
140 // posts in any of those statuses, since core grants an author read
141 // on their own post whatever its status. The clause is a close
142 // approximation of read_post used where a per-row gate is
143 // impossible (the counts); the recent list re-checks read_post per
144 // row as the authoritative gate. It is built inline, from literal
145 // %s/%d placeholder lists only, so its values are visibly bound
146 // through prepare() at both use sites.
147 $type = get_post_type_object( 'post' );
148 $statuses = array_values( get_post_stati( array( 'public' => true ) ) );
149 $private_stati = array_values( get_post_stati( array( 'private' => true ) ) );
150 $hidden_stati = array_values(
151 get_post_stati(
152 array(
153 'internal' => false,
154 'public' => false,
155 'private' => false,
156 )
157 )
158 );
159 if ( current_user_can( $type->cap->read_private_posts ) ) {
160 $statuses = array_merge( $statuses, $private_stati );
161 }
162 if ( current_user_can( $type->cap->edit_others_posts ) ) {
163 foreach ( $hidden_stati as $status ) {
164 if ( 'future' === $status && ! current_user_can( $type->cap->edit_published_posts ) ) {
165 continue;
166 }
167 $statuses[] = $status;
168 }
169 }
170
171 $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
172 $status_clause = "p.post_status IN ( {$placeholders} )";
173 $status_args = $statuses;
174
175 $user_id = get_current_user_id();
176 $own = array_values( array_diff( array_merge( $private_stati, $hidden_stati ), $statuses ) );
177 if ( $user_id > 0 && $own ) {
178 $own_ph = implode( ', ', array_fill( 0, count( $own ), '%s' ) );
179 $status_clause = "( {$status_clause} OR ( p.post_author = %d AND p.post_status IN ( {$own_ph} ) ) )";
180 $status_args = array_merge( $status_args, array( $user_id ), $own );
181 }
182
183 // ----- Counts ------------------------------------------------------
184 // Post-status breakdown, restricted to the readable set so the
185 // counts never reveal how many hidden posts a term holds.
186 $status_rows = $wpdb->get_results(
187 $wpdb->prepare(
188 "SELECT p.post_status, COUNT(DISTINCT p.ID) AS n
189 FROM {$wpdb->posts} p
190 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
191 WHERE tr.term_taxonomy_id = %d
192 AND p.post_type = 'post'
193 AND {$status_clause}
194 GROUP BY p.post_status",
195 array_merge( array( $tt_id ), $status_args )
196 ),
197 ARRAY_A
198 );
199 $post_counts = array(
200 'publish' => 0,
201 'draft' => 0,
202 'pending' => 0,
203 'private' => 0,
204 'future' => 0,
205 'total' => 0,
206 );
207 foreach ( (array) $status_rows as $row ) {
208 $status = (string) $row['post_status'];
209 $n = (int) $row['n'];
210 $post_counts['total'] += $n;
211 if ( isset( $post_counts[ $status ] ) ) {
212 $post_counts[ $status ] = $n;
213 }
214 }
215
216 // Comments on posts in this term (approved only).
217 $comments_received = (int) $wpdb->get_var(
218 $wpdb->prepare(
219 "SELECT COUNT(c.comment_ID)
220 FROM {$wpdb->comments} c
221 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
222 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
223 WHERE tr.term_taxonomy_id = %d
224 AND p.post_status = 'publish'
225 AND c.comment_approved = '1'",
226 $tt_id
227 )
228 );
229
230 // Distinct authors using this term.
231 $distinct_authors = (int) $wpdb->get_var(
232 $wpdb->prepare(
233 "SELECT COUNT( DISTINCT p.post_author )
234 FROM {$wpdb->posts} p
235 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
236 WHERE tr.term_taxonomy_id = %d
237 AND p.post_status = 'publish'",
238 $tt_id
239 )
240 );
241
242 $counts = array(
243 'posts' => $post_counts,
244 'commentsReceived' => $comments_received,
245 'distinctAuthors' => $distinct_authors,
246 );
247
248 // ----- Recent posts (5 most recent the caller may read) ------------
249 // The clause narrows the pool to readable statuses; the per-row
250 // read_post gate below is authoritative (it resolves the exact meta
251 // cap per post, and it is the hook where membership plugins restrict
252 // even published posts). Fetch headroom past 5 because the gate may
253 // drop rows the coarse clause admitted.
254 $recent_rows = $wpdb->get_results(
255 $wpdb->prepare(
256 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
257 FROM {$wpdb->posts} p
258 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
259 WHERE tr.term_taxonomy_id = %d
260 AND {$status_clause}
261 AND p.post_type = 'post'
262 ORDER BY p.post_date_gmt DESC
263 LIMIT 15",
264 array_merge( array( $tt_id ), $status_args )
265 ),
266 ARRAY_A
267 );
268 $recent = array();
269 if ( $recent_rows ) {
270 // Bulk-warm the post cache — the read_post checks,
271 // get_the_title() and get_permalink() below all read from it.
272 _prime_post_caches( array_map( 'intval', wp_list_pluck( $recent_rows, 'ID' ) ), false, false );
273 }
274 foreach ( (array) $recent_rows as $row ) {
275 $post_id = (int) $row['ID'];
276 if ( ! current_user_can( 'read_post', $post_id ) ) {
277 continue;
278 }
279 $author_id = (int) $row['post_author'];
280 $author = $author_id > 0 ? get_userdata( $author_id ) : null;
281 $author_arr = $author
282 ? array(
283 'id' => (int) $author->ID,
284 'name' => $author->display_name,
285 'avatarUrl' => get_avatar_url( $author->ID, array( 'size' => 48 ) ),
286 )
287 : null;
288 $recent[] = array(
289 'id' => $post_id,
290 'title' => get_the_title( $post_id ),
291 'date' => mysql2date( 'c', (string) $row['post_date_gmt'], false ),
292 'status' => (string) $row['post_status'],
293 'type' => (string) $row['post_type'],
294 'link' => (string) get_permalink( $post_id ),
295 'author' => $author_arr,
296 );
297 if ( count( $recent ) >= 5 ) {
298 break;
299 }
300 }
301
302 // ----- Top authors (most posts in this term) -----------------------
303 $top_author_rows = $wpdb->get_results(
304 $wpdb->prepare(
305 "SELECT p.post_author, COUNT( DISTINCT p.ID ) AS n
306 FROM {$wpdb->posts} p
307 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
308 WHERE tr.term_taxonomy_id = %d
309 AND p.post_status = 'publish'
310 AND p.post_author > 0
311 GROUP BY p.post_author
312 ORDER BY n DESC
313 LIMIT 5",
314 $tt_id
315 ),
316 ARRAY_A
317 );
318 $top_authors = array();
319 foreach ( (array) $top_author_rows as $row ) {
320 $user_id = (int) $row['post_author'];
321 $u = get_userdata( $user_id );
322 if ( ! $u ) {
323 continue;
324 }
325 $top_authors[] = array(
326 'userId' => (int) $u->ID,
327 'userName' => (string) $u->display_name,
328 'userAvatarUrl' => (string) get_avatar_url( $u->ID, array( 'size' => 48 ) ),
329 'count' => (int) $row['n'],
330 );
331 }
332
333 // ----- Co-occurring terms (most frequent siblings in same tax) -----
334 $co_term_rows = $wpdb->get_results(
335 $wpdb->prepare(
336 "SELECT t.term_id, t.name, t.slug, COUNT(*) AS n
337 FROM {$wpdb->term_relationships} tr1
338 INNER JOIN {$wpdb->term_relationships} tr2 ON tr1.object_id = tr2.object_id
339 INNER JOIN {$wpdb->term_taxonomy} tt2 ON tr2.term_taxonomy_id = tt2.term_taxonomy_id
340 INNER JOIN {$wpdb->terms} t ON tt2.term_id = t.term_id
341 INNER JOIN {$wpdb->posts} p ON p.ID = tr1.object_id
342 WHERE tr1.term_taxonomy_id = %d
343 AND tt2.taxonomy = %s
344 AND tt2.term_id != %d
345 AND p.post_status = 'publish'
346 GROUP BY t.term_id
347 ORDER BY n DESC
348 LIMIT 5",
349 $tt_id,
350 $taxonomy,
351 $term_id
352 ),
353 ARRAY_A
354 );
355 $co_terms = array();
356 foreach ( (array) $co_term_rows as $row ) {
357 $co_terms[] = array(
358 'id' => (int) $row['term_id'],
359 'name' => (string) $row['name'],
360 'slug' => (string) $row['slug'],
361 'count' => (int) $row['n'],
362 );
363 }
364
365 // ----- 12-month activity sparkline ---------------------------------
366 $activity_rows = $wpdb->get_results(
367 $wpdb->prepare(
368 "SELECT DATE_FORMAT( p.post_date_gmt, '%%Y-%%m' ) AS ym, COUNT( DISTINCT p.ID ) AS n
369 FROM {$wpdb->posts} p
370 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
371 WHERE tr.term_taxonomy_id = %d
372 AND p.post_type = 'post'
373 AND p.post_status = 'publish'
374 AND p.post_date_gmt >= DATE_SUB( NOW(), INTERVAL 12 MONTH )
375 GROUP BY ym
376 ORDER BY ym ASC",
377 $tt_id
378 ),
379 ARRAY_A
380 );
381 $activity = array();
382 foreach ( (array) $activity_rows as $row ) {
383 $activity[] = array(
384 'ym' => (string) $row['ym'],
385 'count' => (int) $row['n'],
386 );
387 }
388
389 // ----- First & last post in this term ------------------------------
390 $first_post_date = $wpdb->get_var(
391 $wpdb->prepare(
392 "SELECT MIN( p.post_date_gmt )
393 FROM {$wpdb->posts} p
394 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
395 WHERE tr.term_taxonomy_id = %d
396 AND p.post_status = 'publish'
397 AND p.post_type = 'post'",
398 $tt_id
399 )
400 );
401 $last_post_date = $wpdb->get_var(
402 $wpdb->prepare(
403 "SELECT MAX( p.post_date_gmt )
404 FROM {$wpdb->posts} p
405 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
406 WHERE tr.term_taxonomy_id = %d
407 AND p.post_status = 'publish'
408 AND p.post_type = 'post'",
409 $tt_id
410 )
411 );
412 $milestones = array(
413 'firstPosted' => $first_post_date ? mysql2date( 'c', $first_post_date, false ) : null,
414 'lastPosted' => $last_post_date ? mysql2date( 'c', $last_post_date, false ) : null,
415 );
416
417 $payload = array(
418 'profile' => $profile,
419 'counts' => $counts,
420 'recent' => $recent,
421 'topAuthors' => $top_authors,
422 'coTerms' => $co_terms,
423 'activity' => $activity,
424 'milestones' => $milestones,
425 );
426
427 /**
428 * Filter the per-term stats payload before it returns to the
429 * My WordPress folder window.
430 *
431 * @param array $payload Stats payload.
432 * @param string $taxonomy Taxonomy slug.
433 * @param int $term_id Term id.
434 */
435 return apply_filters(
436 'openstation_my_wordpress_term_stats',
437 $payload,
438 $taxonomy,
439 $term_id
440 );
441 }
442