PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.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 / apps / posts / parts / terms-rest.php

terms-rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.7, at apps/posts/parts/terms-rest.php

533 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Posts app — the taxonomy surface the Categories and Tags canvases
4 * read: the `openstation_count` / `openstation_is_default` REST fields
5 * on category and post_tag, the shared terms cache version, and the
6 * `/desktop-mode/v1/term-counts` and `/tag-cooccurrence` routes.
7 *
8 * Plain PHP part of `apps/posts/posts.os.php`, pulled in with
9 * `require_once`; the function and hook names are the ones the docs
10 * list, unchanged.
11 *
12 * @package OpenStation
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * The permission callback both routes share: the surface is the Posts
19 * window's, so it opens to whoever can edit posts.
20 *
21 * @return bool
22 */
23 function openstation_posts_window_rest_permission() {
24 return current_user_can( 'edit_posts' );
25 }
26
27 /**
28 * Surface a "non-trashed posts" count alongside core's `count` field
29 * on category + post_tag REST responses.
30 *
31 * Core's `term_taxonomy.count` is updated by
32 * `_update_post_term_count()`, which only includes posts in the
33 * `publish` status by default. The Categories + Tags tabs in the
34 * native Posts window want to surface DRAFT and PENDING posts too,
35 * so the user can see "this category has 3 unpublished drafts" — a
36 * detail core's count silently hides.
37 *
38 * The field is `openstation_count` and lives on the term object in
39 * REST view context. The per-term query is one cheap COUNT(*) on a
40 * pre-indexed join, so 50 terms = 50 light queries — acceptable for
41 * an admin UI.
42 */
43 function openstation_posts_window_register_count_field() {
44 foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
45 register_rest_field(
46 $taxonomy,
47 'openstation_count',
48 array(
49 'get_callback' => 'openstation_posts_window_term_count_any',
50 'schema' => array(
51 'description' => __( 'Number of non-trashed posts (any status) in this term.', 'desktop-mode' ),
52 'type' => 'integer',
53 'context' => array( 'view', 'embed' ),
54 'readonly' => true,
55 ),
56 )
57 );
58 // Mark the taxonomy's "default" term — the fallback that
59 // receives uncategorised posts. Localised installs rename
60 // the slug + name so a JS-side "uncategorized" string match
61 // fails (Spanish: "Sin categoría"); the option is the only
62 // reliable id.
63 register_rest_field(
64 $taxonomy,
65 'openstation_is_default',
66 array(
67 'get_callback' => 'openstation_posts_window_term_is_default',
68 'schema' => array(
69 'description' => __( 'Whether this term is the taxonomy\'s default (fallback) term.', 'desktop-mode' ),
70 'type' => 'boolean',
71 'context' => array( 'view', 'embed' ),
72 'readonly' => true,
73 ),
74 )
75 );
76 }
77 }
78 add_action( 'rest_api_init', 'openstation_posts_window_register_count_field' );
79
80 /**
81 * Shared site-wide cache version for any term-derived endpoint
82 * payload (bulk counts, tag cooccurrence, …). Stored in a non-
83 * autoloaded option and bumped by
84 * `openstation_posts_window_terms_cache_invalidate()` whenever a
85 * post/term change could move the derived data. The version is
86 * baked into every transient cache key, so a single
87 * `update_option()` retires the entire family of cached payloads
88 * in one stroke — no enumerating keys, no race window where an
89 * old entry might still be served. Stale entries fall out of the
90 * DB naturally via the transient TTL.
91 */
92 function openstation_posts_window_terms_cache_version() {
93 $v = (int) get_option( 'desktop_mode_terms_cache_version', 0 );
94 if ( $v <= 0 ) {
95 $v = 1;
96 // autoload = false so this doesn't ride on every page load.
97 update_option( 'desktop_mode_terms_cache_version', $v, false );
98 }
99 return $v;
100 }
101
102 /**
103 * Invalidate every cached terms-derived payload by incrementing
104 * the shared version. The next call to any cached endpoint will
105 * miss its transient lookup, recompute, and store under the new
106 * key.
107 *
108 * Hooked to a handful of "post→term graph changed" actions below.
109 * The signature is intentionally argument-tolerant — each hook
110 * passes different positional args (object_id, term_id, taxonomy,
111 * …) and PHP just ignores extras for a no-param target.
112 */
113 function openstation_posts_window_terms_cache_invalidate() {
114 $v = openstation_posts_window_terms_cache_version();
115 update_option(
116 'desktop_mode_terms_cache_version',
117 $v + 1,
118 false
119 );
120 }
121 // Direct post→term graph changes. `set_object_terms` fires whenever
122 // `wp_set_object_terms()` runs — covers post saves that change
123 // terms, term-delete cleanup, REST PATCH on a post's tags array,
124 // classic-editor flows, the lot. It's the ground truth.
125 add_action( 'set_object_terms', 'openstation_posts_window_terms_cache_invalidate' );
126 // Term identity changes — a renamed term doesn't shift pair counts
127 // but a deleted term does (its relationships go away). Invalidating
128 // on every term mutation costs one option write per edit, which is
129 // fine for the typical category/tag edit cadence.
130 add_action( 'created_term', 'openstation_posts_window_terms_cache_invalidate' );
131 add_action( 'edited_term', 'openstation_posts_window_terms_cache_invalidate' );
132 add_action( 'delete_term', 'openstation_posts_window_terms_cache_invalidate' );
133 // Status flips that change what the SQL counts. Both endpoints
134 // exclude 'trash', 'auto-draft', and 'inherit'; trashing or
135 // restoring a post adds and removes counts/pairs from the graph.
136 add_action( 'wp_trash_post', 'openstation_posts_window_terms_cache_invalidate' );
137 add_action( 'untrashed_post', 'openstation_posts_window_terms_cache_invalidate' );
138 // Pre-delete fires while term_relationships still exist; the row
139 // will be gone by the time the next query runs. Belt-and-braces
140 // alongside set_object_terms (which fires during delete cleanup
141 // on most modern WP versions).
142 add_action( 'before_delete_post', 'openstation_posts_window_terms_cache_invalidate' );
143
144 /**
145 * Bulk count endpoint — returns `{ term_id: count }` for every
146 * requested term in one query. The `openstation_count` REST field
147 * (per-term) is the canonical source, but on installs where the
148 * field isn't reaching the response (caching, REST middleware,
149 * stale `_fields` whitelist) the JS calls this endpoint as a
150 * defensive fallback so node labels never silently show 0.
151 *
152 * Cached: a single transient holds `{ term_id: count }` for EVERY
153 * term in the taxonomy. Each call projects the caller's requested
154 * IDs out of that map, so all clients hit the same cache entry
155 * regardless of which subset they ask for.
156 *
157 * GET `/desktop-mode/v1/term-counts?taxonomy=category&ids=1,4,7`
158 */
159 function openstation_posts_window_register_term_counts_route() {
160 register_rest_route(
161 'desktop-mode/v1',
162 '/term-counts',
163 array(
164 'methods' => 'GET',
165 'callback' => 'openstation_posts_window_term_counts_callback',
166 'permission_callback' => 'openstation_posts_window_rest_permission',
167 'args' => array(
168 'taxonomy' => array(
169 'required' => true,
170 'type' => 'string',
171 'sanitize_callback' => 'sanitize_key',
172 ),
173 'ids' => array(
174 'required' => true,
175 'type' => 'string',
176 ),
177 ),
178 )
179 );
180 }
181 add_action( 'rest_api_init', 'openstation_posts_window_register_term_counts_route' );
182
183 /**
184 * REST callback: return post counts for a batch of term IDs.
185 *
186 * Uses a cached full-taxonomy map so multiple windows with
187 * different ID subsets share the same transient.
188 *
189 * @param WP_REST_Request $request Request with `taxonomy` and `ids`.
190 * @return array|WP_Error Map of `term_id => count`, or error on bad taxonomy.
191 */
192 function openstation_posts_window_term_counts_callback( $request ) {
193 global $wpdb;
194 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
195 $tax_obj = get_taxonomy( $taxonomy );
196 if ( ! $tax_obj ) {
197 return new WP_Error(
198 'openstation_invalid_taxonomy',
199 __( 'Unknown taxonomy.', 'desktop-mode' ),
200 array( 'status' => 400 )
201 );
202 }
203 $raw = (string) $request->get_param( 'ids' );
204 $parts = array_map( 'intval', explode( ',', $raw ) );
205 $ids = array_values(
206 array_filter(
207 $parts,
208 function ( $id ) {
209 return $id > 0;
210 }
211 )
212 );
213 if ( count( $ids ) === 0 ) {
214 return array();
215 }
216 // Cap to avoid runaway IN-clauses if a malicious caller inflates
217 // the param. 500 is far above any plausible category/tag count.
218 $ids = array_slice( $ids, 0, 500 );
219
220 // Cache strategy: one transient holds the FULL taxonomy's
221 // { term_id => count } map. All clients project their requested
222 // ID subset out of the same cached map, so a window that asks
223 // for IDs [1, 4, 7] and one that asks for [4, 11, 22] share the
224 // same cache hit. Key shape: `dmtcnt_v<version>_<taxonomy>`.
225 $cache_version = openstation_posts_window_terms_cache_version();
226 $cache_key = sprintf( 'dmtcnt_v%d_%s', $cache_version, $taxonomy );
227 $counts = get_transient( $cache_key );
228 if ( ! is_array( $counts ) ) {
229 // Mirror WP core's `_update_post_term_count` filtering —
230 // limit to the taxonomy's `object_type` (e.g. `post` for
231 // category) and exclude statuses core treats as non-counting:
232 // - 'trash' + 'auto-draft' → user-not-published-and-never-will-be
233 // - 'inherit' → attachment-only status; excluded so attachments
234 // aren't double-counted via parent inheritance
235 // Everything else (publish, draft, pending, future, private)
236 // is included so the user sees a "real" post count, not
237 // WP's publish-only term_taxonomy.count.
238 $object_types = array_map(
239 'sanitize_key',
240 (array) $tax_obj->object_type
241 );
242 $object_types = array_filter( $object_types, 'post_type_exists' );
243 if ( empty( $object_types ) ) {
244 $object_types = array( 'post' );
245 }
246 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
247 $rows = $wpdb->get_results(
248 $wpdb->prepare(
249 "SELECT tt.term_id, COUNT(p.ID) AS cnt
250 FROM {$wpdb->term_taxonomy} tt
251 LEFT JOIN {$wpdb->term_relationships} tr
252 ON tr.term_taxonomy_id = tt.term_taxonomy_id
253 LEFT JOIN {$wpdb->posts} p
254 ON p.ID = tr.object_id
255 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
256 AND p.post_type IN ( $type_placeholders )
257 WHERE tt.taxonomy = %s
258 GROUP BY tt.term_id",
259 array_merge( $object_types, array( $taxonomy ) )
260 ),
261 ARRAY_A
262 );
263 $counts = array();
264 foreach ( (array) $rows as $row ) {
265 $counts[ (string) (int) $row['term_id'] ] = (int) $row['cnt'];
266 }
267 set_transient( $cache_key, $counts, DAY_IN_SECONDS );
268 }
269
270 // Project the caller's requested IDs out of the (cached or
271 // fresh) full map. Missing IDs return 0 to match the legacy
272 // shape — a caller looking up a term that no longer exists or
273 // has no associated posts still gets a zero, not a missing key.
274 $out = array();
275 foreach ( $ids as $id ) {
276 $key = (string) $id;
277 $out[ $key ] = isset( $counts[ $key ] ) ? (int) $counts[ $key ] : 0;
278 }
279 return $out;
280 }
281
282 /**
283 * REST: tag co-occurrence aggregator. Returns, for each tag, its
284 * top-N most frequently co-occurring sibling tags + the shared
285 * post count. Used by the Tags window to precluster the pixi
286 * cloud — tags that appear together on the same post get pulled
287 * toward each other after the spiral pack.
288 *
289 * GET `/desktop-mode/v1/tag-cooccurrence?taxonomy=post_tag&limit=8`
290 *
291 * Response:
292 * { "pairs": { "<tagId>": [ { "id": <neighborId>, "shared": N }, … ] } }
293 *
294 * Status filtering mirrors `_update_post_term_count` (exclude
295 * trash, auto-draft, inherit). Post type is bounded to the
296 * taxonomy's declared `object_type` so e.g. page-attached terms
297 * don't bleed into the post-tag graph.
298 */
299 function openstation_posts_window_register_tag_cooccurrence_route() {
300 register_rest_route(
301 'desktop-mode/v1',
302 '/tag-cooccurrence',
303 array(
304 'methods' => 'GET',
305 'callback' => 'openstation_posts_window_tag_cooccurrence_callback',
306 'permission_callback' => 'openstation_posts_window_rest_permission',
307 'args' => array(
308 'taxonomy' => array(
309 'required' => false,
310 'type' => 'string',
311 'default' => 'post_tag',
312 'sanitize_callback' => 'sanitize_key',
313 ),
314 'limit' => array(
315 'required' => false,
316 'type' => 'integer',
317 'default' => 8,
318 ),
319 ),
320 )
321 );
322 }
323 add_action( 'rest_api_init', 'openstation_posts_window_register_tag_cooccurrence_route' );
324
325 /**
326 * REST callback: return tag co-occurrence data for the pixi
327 * cloud. For each tag, returns its top-N most frequently
328 * co-occurring sibling tags with the shared post count.
329 *
330 * @param WP_REST_Request $request Request with `taxonomy` and `limit`.
331 * @return WP_REST_Response|WP_Error
332 */
333 function openstation_posts_window_tag_cooccurrence_callback( $request ) {
334 global $wpdb;
335
336 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
337 $tax_obj = get_taxonomy( $taxonomy );
338 if ( ! $tax_obj ) {
339 return new WP_Error(
340 'openstation_invalid_taxonomy',
341 __( 'Unknown taxonomy.', 'desktop-mode' ),
342 array( 'status' => 400 )
343 );
344 }
345
346 $limit = (int) $request->get_param( 'limit' );
347 if ( $limit <= 0 ) {
348 $limit = 8;
349 }
350 // Cap so a malicious caller can't fan out the response. 24 is
351 // far more neighbors than any layout pass actually consumes.
352 $limit = min( 24, $limit );
353
354 // Cache lookup. The key bakes in the current version so a single
355 // option bump makes every old entry unreachable without us
356 // having to enumerate keys. Taxonomy + limit are part of the key
357 // because they change the response shape.
358 $cache_version = openstation_posts_window_terms_cache_version();
359 $cache_key = sprintf(
360 'dmwco_v%d_%s_l%d',
361 $cache_version,
362 $taxonomy,
363 $limit
364 );
365 $cached = get_transient( $cache_key );
366 if ( is_array( $cached ) && isset( $cached['pairs'] ) ) {
367 return rest_ensure_response( $cached );
368 }
369
370 $object_types = array_map(
371 'sanitize_key',
372 (array) $tax_obj->object_type
373 );
374 $object_types = array_filter( $object_types, 'post_type_exists' );
375 if ( empty( $object_types ) ) {
376 $object_types = array( 'post' );
377 }
378 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
379
380 // One scan: every (post, term) row in the taxonomy on a
381 // non-trashed, non-attachment-inherited post of the right type.
382 // Sorted by post so we can walk in O(n) and emit pairs per post.
383 $rows = $wpdb->get_results(
384 $wpdb->prepare(
385 "SELECT tr.object_id, tt.term_id
386 FROM {$wpdb->term_relationships} tr
387 INNER JOIN {$wpdb->term_taxonomy} tt
388 ON tr.term_taxonomy_id = tt.term_taxonomy_id
389 INNER JOIN {$wpdb->posts} p
390 ON p.ID = tr.object_id
391 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
392 AND p.post_type IN ( $type_placeholders )
393 WHERE tt.taxonomy = %s
394 ORDER BY tr.object_id",
395 array_merge( $object_types, array( $taxonomy ) )
396 ),
397 ARRAY_A
398 );
399
400 // Group term ids per post, then emit pair counts. Sort each
401 // post's term list so we can walk i<j and double-write into the
402 // symmetric pair map (a→b and b→a both get incremented).
403 $pairs = array(); // term_id => array( neighbor_id => shared_count )
404 $current_id = 0;
405 $current_set = array();
406 $flush = function () use ( &$current_set, &$pairs ) {
407 $ids = array_values( array_unique( $current_set ) );
408 $n = count( $ids );
409 if ( $n < 2 ) {
410 return;
411 }
412 sort( $ids, SORT_NUMERIC );
413 for ( $i = 0; $i < $n; $i++ ) {
414 $a = $ids[ $i ];
415 for ( $j = $i + 1; $j < $n; $j++ ) {
416 $b = $ids[ $j ];
417 if ( ! isset( $pairs[ $a ][ $b ] ) ) {
418 $pairs[ $a ][ $b ] = 0;
419 }
420 if ( ! isset( $pairs[ $b ][ $a ] ) ) {
421 $pairs[ $b ][ $a ] = 0;
422 }
423 ++$pairs[ $a ][ $b ];
424 ++$pairs[ $b ][ $a ];
425 }
426 }
427 };
428 foreach ( (array) $rows as $row ) {
429 $post_id = (int) $row['object_id'];
430 $term_id = (int) $row['term_id'];
431 if ( $post_id !== $current_id ) {
432 $flush();
433 $current_id = $post_id;
434 $current_set = array();
435 }
436 $current_set[] = $term_id;
437 }
438 $flush();
439
440 // Trim each tag's neighbor list to top-N by shared count, then
441 // reshape into the response payload. Keep string keys so JSON
442 // preserves them as numeric-looking object properties.
443 $result = array();
444 foreach ( $pairs as $tag_id => $neighbors ) {
445 arsort( $neighbors, SORT_NUMERIC );
446 $top = array_slice( $neighbors, 0, $limit, true );
447 $list = array();
448 foreach ( $top as $neighbor_id => $shared ) {
449 $list[] = array(
450 'id' => (int) $neighbor_id,
451 'shared' => (int) $shared,
452 );
453 }
454 $result[ (string) (int) $tag_id ] = $list;
455 }
456
457 $payload = array( 'pairs' => $result );
458 // Store under the version-stamped key. TTL is a day; an
459 // invalidation hook bump retires the key sooner. The DAY ceiling
460 // is the floor on cache staleness if every invalidation hook
461 // somehow missed firing.
462 set_transient( $cache_key, $payload, DAY_IN_SECONDS );
463
464 return rest_ensure_response( $payload );
465 }
466
467 /**
468 * REST get_callback for `openstation_is_default`. Reads the
469 * taxonomy's default-term option (e.g. `default_category`) and
470 * compares against the current term's id.
471 *
472 * @param array $term Term array as serialized by core's REST term controller.
473 * @return bool
474 */
475 function openstation_posts_window_term_is_default( $term ) {
476 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
477 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
478 if ( '' === $taxonomy || $term_id <= 0 ) {
479 return false;
480 }
481 $option_key = 'default_' . $taxonomy; // 'default_category', 'default_post_tag', …
482 $default_id = (int) get_option( $option_key, 0 );
483 return $default_id > 0 && $default_id === $term_id;
484 }
485
486 /**
487 * REST get_callback for `openstation_count`. Counts every post in
488 * the term except trashed + auto-draft (mirrors what users
489 * conceptually mean by "posts in this category").
490 *
491 * @param array $term Term array as serialized by core's REST term controller.
492 * @return int
493 */
494 function openstation_posts_window_term_count_any( $term ) {
495 global $wpdb;
496 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
497 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
498 $tt_id = isset( $term['term_taxonomy_id'] ) ? (int) $term['term_taxonomy_id'] : 0;
499 if ( $tt_id <= 0 && $term_id > 0 && '' !== $taxonomy ) {
500 $tt_id = (int) $wpdb->get_var(
501 $wpdb->prepare(
502 "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s LIMIT 1",
503 $term_id,
504 $taxonomy
505 )
506 );
507 }
508 if ( $tt_id <= 0 ) {
509 return 0;
510 }
511 // Match WP core's `_update_post_term_count` filtering — limit to
512 // the taxonomy's registered object_type and exclude statuses
513 // core treats as non-counting (trash / auto-draft / inherit).
514 $tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null;
515 $object_types = $tax_obj
516 ? array_filter( (array) $tax_obj->object_type, 'post_type_exists' )
517 : array( 'post' );
518 if ( empty( $object_types ) ) {
519 $object_types = array( 'post' );
520 }
521 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
522 return (int) $wpdb->get_var(
523 $wpdb->prepare(
524 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
525 JOIN {$wpdb->posts} p ON p.ID = tr.object_id
526 WHERE tr.term_taxonomy_id = %d
527 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
528 AND p.post_type IN ( $type_placeholders )",
529 array_merge( array( $tt_id ), $object_types )
530 )
531 );
532 }
533