PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.1
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 / posts-window / window.php

window.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.1, at includes/posts-window/window.php

804 lines 29.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native Posts Window: registration + template.
4 *
5 * Native window registered with `placement: 'none'` — the entry point
6 * is the existing Posts dock tile (built from WordPress's `$menu`),
7 * which the JS-side dock intercept rewrites to open this window when
8 * `nativePostsEnabled` is on.
9 *
10 * The shell wraps the template echoed by `openstation_posts_window_render_template()`
11 * in `<template id="os-native-window-desktop-mode-posts">` and
12 * clones it into the window body BEFORE the JS render callback fires.
13 * The `data-os-posts-*` hooks below are the contract the JS
14 * relies on — keep them intact (or rename via the filter) when
15 * customizing the layout.
16 *
17 * @package OpenStation
18 */
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Echoes the native Posts window's template body.
24 */
25 function openstation_posts_window_render_template() {
26 ob_start();
27 ?>
28 <div class="desktop-mode-posts" data-os-posts-root>
29 <os-tabs value="posts" class="os-posts__tabs">
30 <os-tab value="posts"><?php esc_html_e( 'All posts', 'desktop-mode' ); ?></os-tab>
31 <os-tab value="categories"><?php esc_html_e( 'Categories', 'desktop-mode' ); ?></os-tab>
32 <os-tab value="tags"><?php esc_html_e( 'Tags', 'desktop-mode' ); ?></os-tab>
33 </os-tabs>
34
35 <os-tabpanel for="posts" class="os-posts__panel">
36 <header class="os-posts__toolbar" data-os-posts-toolbar>
37 <div class="os-posts__toolbar-left">
38 <?php
39 // Status segments are populated by the JS bundle from the
40 // (filterable) `openstation.postsWindow.statusSegments` list,
41 // so a plugin can add CPT-specific statuses without forking
42 // this template. The empty-string `value` mirrors the "All"
43 // sentinel so the parent control paints it as selected on
44 // first frame.
45 ?>
46 <os-segmented data-os-posts-status value=""></os-segmented>
47 <os-text-field
48 data-os-posts-search
49 placeholder="<?php esc_attr_e( 'Search posts…', 'desktop-mode' ); ?>"
50 ></os-text-field>
51 </div>
52 <div class="os-posts__toolbar-right" data-os-posts-bulk hidden>
53 <span class="os-posts__count" data-os-posts-count></span>
54 <?php
55 // Bulk-action buttons rendered from the JS-side
56 // `openstation.postsWindow.bulkActions` registry — defaults
57 // ship "Move to trash"; plugins extend with Duplicate,
58 // Export, Bulk Publish, etc.
59 ?>
60 <span class="os-posts__bulk-actions" data-os-posts-bulk-actions></span>
61 </div>
62 <div class="os-posts__toolbar-trailing">
63 <?php
64 // Plugin-injected trailing buttons — rendered before the
65 // built-in Refresh + Add New so plugin actions sit close to
66 // the segmented control, with the framework's own buttons at
67 // the far edge where users expect them.
68 ?>
69 <span class="os-posts__toolbar-extras" data-os-posts-toolbar-extras></span>
70 <os-button variant="ghost" data-os-posts-refresh title="<?php esc_attr_e( 'Refresh', 'desktop-mode' ); ?>">
71 <span class="dashicons dashicons-update" aria-hidden="true"></span>
72 </os-button>
73 <os-button variant="primary" data-os-posts-new>
74 <span class="dashicons dashicons-plus" aria-hidden="true"></span>
75 <?php esc_html_e( 'Add New', 'desktop-mode' ); ?>
76 </os-button>
77 </div>
78 </header>
79 <div class="os-posts__body" data-os-posts-body>
80 <os-table
81 data-os-posts-table
82 selectable="multi"
83 sticky-header
84 sticky-columns="1"
85 hover
86 striped
87 bordered
88 loading
89 >
90 <div slot="empty" class="os-posts__empty">
91 <span class="dashicons dashicons-admin-post" aria-hidden="true"></span>
92 <p><?php esc_html_e( 'No posts found.', 'desktop-mode' ); ?></p>
93 <p class="os-posts__empty-hint">
94 <?php esc_html_e( 'Try a different search or change the status filter.', 'desktop-mode' ); ?>
95 </p>
96 </div>
97 </os-table>
98 </div>
99 <footer class="os-posts__pager" data-os-posts-pager>
100 <div class="os-posts__pager-meta">
101 <span data-os-posts-page-indicator></span>
102 </div>
103 <div class="os-posts__pager-nav">
104 <os-button variant="ghost" data-os-posts-prev disabled>
105 <span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>
106 <?php esc_html_e( 'Previous', 'desktop-mode' ); ?>
107 </os-button>
108 <os-button variant="ghost" data-os-posts-next disabled>
109 <?php esc_html_e( 'Next', 'desktop-mode' ); ?>
110 <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
111 </os-button>
112 <label class="os-posts__pager-perpage">
113 <?php esc_html_e( 'Per page', 'desktop-mode' ); ?>
114 <select data-os-posts-per-page>
115 <option value="10">10</option>
116 <option value="20" selected>20</option>
117 <option value="50">50</option>
118 <option value="100">100</option>
119 </select>
120 </label>
121 </div>
122 </footer>
123 </os-tabpanel>
124
125 <os-tabpanel for="categories" class="os-posts__panel">
126 <div data-os-posts-cats-host class="os-posts__terms-host"></div>
127 </os-tabpanel>
128
129 <os-tabpanel for="tags" class="os-posts__panel">
130 <div data-os-posts-tags-host class="os-posts__terms-host"></div>
131 </os-tabpanel>
132 </div>
133 <?php
134 $html = (string) ob_get_clean();
135
136 /**
137 * Filter the native Posts window's template HTML.
138 *
139 * Keep the `data-os-posts-*` hooks intact so the JS
140 * render callback can find its mount points, or rename them and
141 * update the matching constants in `src/posts-window/index.ts`.
142 *
143 * @param string $html Default template HTML.
144 */
145 $filtered = (string) apply_filters( 'openstation_posts_window_template_html', $html );
146
147 if ( function_exists( 'openstation_kses_native_window_template' ) ) {
148 echo openstation_kses_native_window_template( $filtered ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
149 } else {
150 echo wp_kses( $filtered, wp_kses_allowed_html( 'post' ) );
151 }
152 }
153
154 /**
155 * Register the native Posts window on `init` (priority 20, after
156 * `components.php` has bootstrapped the registry).
157 *
158 * Gated on `openstation_posts_window_user_can_register()` — cap-only,
159 * so when the user lacks `edit_posts` the window is simply not
160 * registered. The opt-in toggle is enforced at runtime by the JS-side
161 * URL remap, so flipping it mid-session never requires a reload.
162 */
163 function openstation_posts_window_register_window() {
164 // Cap-only gate so that flipping the opt-in mid-session doesn't
165 // require an F5. The opt-in is a runtime check on the JS-side
166 // remap (`enabled: ( s ) => s.nativePostsEnabled === true` in
167 // `src/desktop.ts`). Registering for every cap-eligible user is
168 // cheap — the script + template + REST nonce all live in the
169 // payload only, the actual fetch only happens when the user
170 // opens the window.
171 if ( ! openstation_posts_window_user_can_register() ) {
172 return;
173 }
174
175 $window_args = array(
176 'title' => __( 'Posts', 'desktop-mode' ),
177 'icon' => 'dashicons-admin-post',
178 'template' => 'openstation_posts_window_render_template',
179 'script' => 'os-posts-window',
180 'style' => 'os-posts-window',
181 'width' => 1100,
182 'height' => 720,
183 'min_width' => 720,
184 'min_height' => 480,
185 // `'none'` — no dock or wallpaper tile from this registration.
186 // The Posts dock tile lives in WordPress's `$menu` and the
187 // JS-side dock intercept routes its click to `openById` here
188 // when the opt-in is on. A separate tile would be a duplicate
189 // entry point.
190 'placement' => 'none',
191 'config' => array(
192 'restRoot' => esc_url_raw( rest_url() ),
193 'restNonce' => wp_create_nonce( 'wp_rest' ),
194 'postsUrl' => esc_url_raw( rest_url( 'wp/v2/posts' ) ),
195 'editPostUrlBase' => esc_url_raw( admin_url( 'post.php' ) ),
196 'newPostUrl' => esc_url_raw( admin_url( 'post-new.php' ) ),
197 'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
198 'currentUserId' => (int) get_current_user_id(),
199 'defaultPerPage' => 20,
200 'queryArgs' => openstation_posts_window_default_query_args(),
201 ),
202 );
203
204 /**
205 * Filter the args used to register the native Posts window.
206 *
207 * @param array $window_args Args passed to `openstation_register_window()`.
208 */
209 $window_args = (array) apply_filters( 'openstation_posts_window_args', $window_args );
210
211 $registered = openstation_register_window( 'desktop-mode-posts', $window_args );
212 if ( is_wp_error( $registered ) ) {
213 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
214 error_log( '[openstation] Native Posts window registration failed: ' . $registered->get_error_message() );
215 }
216 }
217 add_action( 'init', 'openstation_posts_window_register_window', 20 );
218
219 /**
220 * Default REST query args the JS bundle uses on every list fetch.
221 *
222 * Filterable so a plugin can flip the post type to a custom CPT (or
223 * a list of CPTs) without forking the bundle. The JS bundle merges
224 * these on top of the page/per-page/search/status/sort args it
225 * generates per request.
226 *
227 * @return array
228 */
229 function openstation_posts_window_default_query_args() {
230 $args = array(
231 // `_embed` pulls author + taxonomy + featured-media side-loads
232 // into `_embedded`, so the table can render avatars, term
233 // chips, and thumbnails without N extra round-trips per row.
234 '_embed' => 'author,wp:term,wp:featuredmedia',
235 // `openstation_lock` is the REST field registered by My WordPress'
236 // `lock.php` on every public post type — it tells us whether
237 // another user is currently editing the row. Surfacing it on the
238 // native Posts table means the title cell can paint a small lock
239 // icon without an extra fetch.
240 '_fields' =>
241 'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,openstation_lock,_links,_embedded',
242 );
243
244 /**
245 * Filter the default outbound REST query args for the Posts window.
246 *
247 * Drop in a `'post_type' => 'product'` to point the window at a
248 * CPT, or extend `_fields` to ship more columns. The bundle merges
249 * these on top of pagination/search/status/sort args.
250 *
251 * @param array $args Default args.
252 */
253 return (array) apply_filters( 'openstation_posts_window_query_args', $args );
254 }
255
256 /**
257 * Switch the post-tag tax_query operator from the WP REST default `IN`
258 * (any-of, OR) to `AND` (every-of, intersection) when the Posts window
259 * client opts in via the `openstation_tags_match=all` URL flag.
260 *
261 * The flag is sent only when more than one tag is selected — single-
262 * tag queries are unaffected because AND with one term is identical
263 * to IN. The filter is scoped to GET `/wp/v2/posts`-style queries so
264 * other endpoints' tax_query semantics aren't touched.
265 *
266 * @param array $args `WP_Query` args after the REST controller
267 * prepared them.
268 * @param WP_REST_Request $request Active REST request.
269 * @return array Possibly-mutated args.
270 */
271 function openstation_posts_window_tags_and_filter( $args, $request ) {
272 if ( ! ( $request instanceof WP_REST_Request ) ) {
273 return $args;
274 }
275 $flag = $request->get_param( 'openstation_tags_match' );
276 if ( 'all' !== $flag ) {
277 return $args;
278 }
279 if ( empty( $args['tax_query'] ) || ! is_array( $args['tax_query'] ) ) {
280 return $args;
281 }
282 foreach ( $args['tax_query'] as $key => $clause ) {
283 if ( ! is_array( $clause ) ) {
284 continue;
285 }
286 if ( isset( $clause['taxonomy'] ) && 'post_tag' === $clause['taxonomy'] ) {
287 $args['tax_query'][ $key ]['operator'] = 'AND';
288 }
289 }
290 return $args;
291 }
292 add_filter( 'rest_post_query', 'openstation_posts_window_tags_and_filter', 10, 2 );
293
294 /**
295 * Surface a "non-trashed posts" count alongside core's `count` field
296 * on category + post_tag REST responses.
297 *
298 * Core's `term_taxonomy.count` is updated by
299 * `_update_post_term_count()`, which only includes posts in the
300 * `publish` status by default. The Categories + Tags tabs in the
301 * native Posts window want to surface DRAFT and PENDING posts too,
302 * so the user can see "this category has 3 unpublished drafts" — a
303 * detail core's count silently hides.
304 *
305 * The field is `openstation_count` and lives on the term object in
306 * REST view context. The per-term query is one cheap COUNT(*) on a
307 * pre-indexed join, so 50 terms = 50 light queries — acceptable for
308 * an admin UI.
309 */
310 function openstation_posts_window_register_count_field() {
311 foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
312 register_rest_field(
313 $taxonomy,
314 'openstation_count',
315 array(
316 'get_callback' => 'openstation_posts_window_term_count_any',
317 'schema' => array(
318 'description' => __( 'Number of non-trashed posts (any status) in this term.', 'desktop-mode' ),
319 'type' => 'integer',
320 'context' => array( 'view', 'embed' ),
321 'readonly' => true,
322 ),
323 )
324 );
325 // Mark the taxonomy's "default" term — the fallback that
326 // receives uncategorised posts. Localised installs rename
327 // the slug + name so a JS-side "uncategorized" string match
328 // fails (Spanish: "Sin categoría"); the option is the only
329 // reliable id.
330 register_rest_field(
331 $taxonomy,
332 'openstation_is_default',
333 array(
334 'get_callback' => 'openstation_posts_window_term_is_default',
335 'schema' => array(
336 'description' => __( 'Whether this term is the taxonomy\'s default (fallback) term.', 'desktop-mode' ),
337 'type' => 'boolean',
338 'context' => array( 'view', 'embed' ),
339 'readonly' => true,
340 ),
341 )
342 );
343 }
344 }
345 add_action( 'rest_api_init', 'openstation_posts_window_register_count_field' );
346
347 /**
348 * Shared site-wide cache version for any term-derived endpoint
349 * payload (bulk counts, tag cooccurrence, …). Stored in a non-
350 * autoloaded option and bumped by
351 * `openstation_posts_window_terms_cache_invalidate()` whenever a
352 * post/term change could move the derived data. The version is
353 * baked into every transient cache key, so a single
354 * `update_option()` retires the entire family of cached payloads
355 * in one stroke — no enumerating keys, no race window where an
356 * old entry might still be served. Stale entries fall out of the
357 * DB naturally via the transient TTL.
358 */
359 function openstation_posts_window_terms_cache_version() {
360 $v = (int) get_option( 'desktop_mode_terms_cache_version', 0 );
361 if ( $v <= 0 ) {
362 $v = 1;
363 // autoload = false so this doesn't ride on every page load.
364 update_option( 'desktop_mode_terms_cache_version', $v, false );
365 }
366 return $v;
367 }
368
369 /**
370 * Invalidate every cached terms-derived payload by incrementing
371 * the shared version. The next call to any cached endpoint will
372 * miss its transient lookup, recompute, and store under the new
373 * key.
374 *
375 * Hooked to a handful of "post→term graph changed" actions below.
376 * The signature is intentionally argument-tolerant — each hook
377 * passes different positional args (object_id, term_id, taxonomy,
378 * …) and PHP just ignores extras for a no-param target.
379 */
380 function openstation_posts_window_terms_cache_invalidate() {
381 $v = openstation_posts_window_terms_cache_version();
382 update_option(
383 'desktop_mode_terms_cache_version',
384 $v + 1,
385 false
386 );
387 }
388 // Direct post→term graph changes. `set_object_terms` fires whenever
389 // `wp_set_object_terms()` runs — covers post saves that change
390 // terms, term-delete cleanup, REST PATCH on a post's tags array,
391 // classic-editor flows, the lot. It's the ground truth.
392 add_action( 'set_object_terms', 'openstation_posts_window_terms_cache_invalidate' );
393 // Term identity changes — a renamed term doesn't shift pair counts
394 // but a deleted term does (its relationships go away). Invalidating
395 // on every term mutation costs one option write per edit, which is
396 // fine for the typical category/tag edit cadence.
397 add_action( 'created_term', 'openstation_posts_window_terms_cache_invalidate' );
398 add_action( 'edited_term', 'openstation_posts_window_terms_cache_invalidate' );
399 add_action( 'delete_term', 'openstation_posts_window_terms_cache_invalidate' );
400 // Status flips that change what the SQL counts. Both endpoints
401 // exclude 'trash', 'auto-draft', and 'inherit'; trashing or
402 // restoring a post adds and removes counts/pairs from the graph.
403 add_action( 'wp_trash_post', 'openstation_posts_window_terms_cache_invalidate' );
404 add_action( 'untrashed_post', 'openstation_posts_window_terms_cache_invalidate' );
405 // Pre-delete fires while term_relationships still exist; the row
406 // will be gone by the time the next query runs. Belt-and-braces
407 // alongside set_object_terms (which fires during delete cleanup
408 // on most modern WP versions).
409 add_action( 'before_delete_post', 'openstation_posts_window_terms_cache_invalidate' );
410
411 /**
412 * Bulk count endpoint — returns `{ term_id: count }` for every
413 * requested term in one query. The `openstation_count` REST field
414 * (per-term) is the canonical source, but on installs where the
415 * field isn't reaching the response (caching, REST middleware,
416 * stale `_fields` whitelist) the JS calls this endpoint as a
417 * defensive fallback so node labels never silently show 0.
418 *
419 * Cached: a single transient holds `{ term_id: count }` for EVERY
420 * term in the taxonomy. Each call projects the caller's requested
421 * IDs out of that map, so all clients hit the same cache entry
422 * regardless of which subset they ask for.
423 *
424 * GET `/desktop-mode/v1/term-counts?taxonomy=category&ids=1,4,7`
425 */
426 function openstation_posts_window_register_term_counts_route() {
427 register_rest_route(
428 'desktop-mode/v1',
429 '/term-counts',
430 array(
431 'methods' => 'GET',
432 'callback' => 'openstation_posts_window_term_counts_callback',
433 'permission_callback' => function () {
434 return current_user_can( 'edit_posts' );
435 },
436 'args' => array(
437 'taxonomy' => array(
438 'required' => true,
439 'type' => 'string',
440 'sanitize_callback' => 'sanitize_key',
441 ),
442 'ids' => array(
443 'required' => true,
444 'type' => 'string',
445 ),
446 ),
447 )
448 );
449 }
450 add_action( 'rest_api_init', 'openstation_posts_window_register_term_counts_route' );
451
452 /**
453 * REST callback: return post counts for a batch of term IDs.
454 *
455 * Uses a cached full-taxonomy map so multiple windows with
456 * different ID subsets share the same transient.
457 *
458 * @param WP_REST_Request $request Request with `taxonomy` and `ids`.
459 * @return array|WP_Error Map of `term_id => count`, or error on bad taxonomy.
460 */
461 function openstation_posts_window_term_counts_callback( $request ) {
462 global $wpdb;
463 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
464 $tax_obj = get_taxonomy( $taxonomy );
465 if ( ! $tax_obj ) {
466 return new WP_Error(
467 'openstation_invalid_taxonomy',
468 __( 'Unknown taxonomy.', 'desktop-mode' ),
469 array( 'status' => 400 )
470 );
471 }
472 $raw = (string) $request->get_param( 'ids' );
473 $parts = array_map( 'intval', explode( ',', $raw ) );
474 $ids = array_values(
475 array_filter(
476 $parts,
477 function ( $id ) {
478 return $id > 0;
479 }
480 )
481 );
482 if ( count( $ids ) === 0 ) {
483 return array();
484 }
485 // Cap to avoid runaway IN-clauses if a malicious caller inflates
486 // the param. 500 is far above any plausible category/tag count.
487 $ids = array_slice( $ids, 0, 500 );
488
489 // Cache strategy: one transient holds the FULL taxonomy's
490 // { term_id => count } map. All clients project their requested
491 // ID subset out of the same cached map, so a window that asks
492 // for IDs [1, 4, 7] and one that asks for [4, 11, 22] share the
493 // same cache hit. Key shape: `dmtcnt_v<version>_<taxonomy>`.
494 $cache_version = openstation_posts_window_terms_cache_version();
495 $cache_key = sprintf( 'dmtcnt_v%d_%s', $cache_version, $taxonomy );
496 $counts = get_transient( $cache_key );
497 if ( ! is_array( $counts ) ) {
498 // Mirror WP core's `_update_post_term_count` filtering —
499 // limit to the taxonomy's `object_type` (e.g. `post` for
500 // category) and exclude statuses core treats as non-counting:
501 // - 'trash' + 'auto-draft' → user-not-published-and-never-will-be
502 // - 'inherit' → attachment-only status; excluded so attachments
503 // aren't double-counted via parent inheritance
504 // Everything else (publish, draft, pending, future, private)
505 // is included so the user sees a "real" post count, not
506 // WP's publish-only term_taxonomy.count.
507 $object_types = array_map(
508 'sanitize_key',
509 (array) $tax_obj->object_type
510 );
511 $object_types = array_filter( $object_types, 'post_type_exists' );
512 if ( empty( $object_types ) ) {
513 $object_types = array( 'post' );
514 }
515 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
516 $rows = $wpdb->get_results(
517 $wpdb->prepare(
518 "SELECT tt.term_id, COUNT(p.ID) AS cnt
519 FROM {$wpdb->term_taxonomy} tt
520 LEFT JOIN {$wpdb->term_relationships} tr
521 ON tr.term_taxonomy_id = tt.term_taxonomy_id
522 LEFT JOIN {$wpdb->posts} p
523 ON p.ID = tr.object_id
524 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
525 AND p.post_type IN ( $type_placeholders )
526 WHERE tt.taxonomy = %s
527 GROUP BY tt.term_id",
528 array_merge( $object_types, array( $taxonomy ) )
529 ),
530 ARRAY_A
531 );
532 $counts = array();
533 foreach ( (array) $rows as $row ) {
534 $counts[ (string) (int) $row['term_id'] ] = (int) $row['cnt'];
535 }
536 set_transient( $cache_key, $counts, DAY_IN_SECONDS );
537 }
538
539 // Project the caller's requested IDs out of the (cached or
540 // fresh) full map. Missing IDs return 0 to match the legacy
541 // shape — a caller looking up a term that no longer exists or
542 // has no associated posts still gets a zero, not a missing key.
543 $out = array();
544 foreach ( $ids as $id ) {
545 $key = (string) $id;
546 $out[ $key ] = isset( $counts[ $key ] ) ? (int) $counts[ $key ] : 0;
547 }
548 return $out;
549 }
550
551 /**
552 * REST: tag co-occurrence aggregator. Returns, for each tag, its
553 * top-N most frequently co-occurring sibling tags + the shared
554 * post count. Used by the Tags window to precluster the pixi
555 * cloud — tags that appear together on the same post get pulled
556 * toward each other after the spiral pack.
557 *
558 * GET `/desktop-mode/v1/tag-cooccurrence?taxonomy=post_tag&limit=8`
559 *
560 * Response:
561 * { "pairs": { "<tagId>": [ { "id": <neighborId>, "shared": N }, … ] } }
562 *
563 * Status filtering mirrors `_update_post_term_count` (exclude
564 * trash, auto-draft, inherit). Post type is bounded to the
565 * taxonomy's declared `object_type` so e.g. page-attached terms
566 * don't bleed into the post-tag graph.
567 */
568 function openstation_posts_window_register_tag_cooccurrence_route() {
569 register_rest_route(
570 'desktop-mode/v1',
571 '/tag-cooccurrence',
572 array(
573 'methods' => 'GET',
574 'callback' => 'openstation_posts_window_tag_cooccurrence_callback',
575 'permission_callback' => function () {
576 return current_user_can( 'edit_posts' );
577 },
578 'args' => array(
579 'taxonomy' => array(
580 'required' => false,
581 'type' => 'string',
582 'default' => 'post_tag',
583 'sanitize_callback' => 'sanitize_key',
584 ),
585 'limit' => array(
586 'required' => false,
587 'type' => 'integer',
588 'default' => 8,
589 ),
590 ),
591 )
592 );
593 }
594 add_action( 'rest_api_init', 'openstation_posts_window_register_tag_cooccurrence_route' );
595
596 /**
597 * REST callback: return tag co-occurrence data for the pixi
598 * cloud. For each tag, returns its top-N most frequently
599 * co-occurring sibling tags with the shared post count.
600 *
601 * @param WP_REST_Request $request Request with `taxonomy` and `limit`.
602 * @return WP_REST_Response|WP_Error
603 */
604 function openstation_posts_window_tag_cooccurrence_callback( $request ) {
605 global $wpdb;
606
607 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
608 $tax_obj = get_taxonomy( $taxonomy );
609 if ( ! $tax_obj ) {
610 return new WP_Error(
611 'openstation_invalid_taxonomy',
612 __( 'Unknown taxonomy.', 'desktop-mode' ),
613 array( 'status' => 400 )
614 );
615 }
616
617 $limit = (int) $request->get_param( 'limit' );
618 if ( $limit <= 0 ) {
619 $limit = 8;
620 }
621 // Cap so a malicious caller can't fan out the response. 24 is
622 // far more neighbors than any layout pass actually consumes.
623 $limit = min( 24, $limit );
624
625 // Cache lookup. The key bakes in the current version so a single
626 // option bump makes every old entry unreachable without us
627 // having to enumerate keys. Taxonomy + limit are part of the key
628 // because they change the response shape.
629 $cache_version = openstation_posts_window_terms_cache_version();
630 $cache_key = sprintf(
631 'dmwco_v%d_%s_l%d',
632 $cache_version,
633 $taxonomy,
634 $limit
635 );
636 $cached = get_transient( $cache_key );
637 if ( is_array( $cached ) && isset( $cached['pairs'] ) ) {
638 return rest_ensure_response( $cached );
639 }
640
641 $object_types = array_map(
642 'sanitize_key',
643 (array) $tax_obj->object_type
644 );
645 $object_types = array_filter( $object_types, 'post_type_exists' );
646 if ( empty( $object_types ) ) {
647 $object_types = array( 'post' );
648 }
649 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
650
651 // One scan: every (post, term) row in the taxonomy on a
652 // non-trashed, non-attachment-inherited post of the right type.
653 // Sorted by post so we can walk in O(n) and emit pairs per post.
654 $rows = $wpdb->get_results(
655 $wpdb->prepare(
656 "SELECT tr.object_id, tt.term_id
657 FROM {$wpdb->term_relationships} tr
658 INNER JOIN {$wpdb->term_taxonomy} tt
659 ON tr.term_taxonomy_id = tt.term_taxonomy_id
660 INNER JOIN {$wpdb->posts} p
661 ON p.ID = tr.object_id
662 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
663 AND p.post_type IN ( $type_placeholders )
664 WHERE tt.taxonomy = %s
665 ORDER BY tr.object_id",
666 array_merge( $object_types, array( $taxonomy ) )
667 ),
668 ARRAY_A
669 );
670
671 // Group term ids per post, then emit pair counts. Sort each
672 // post's term list so we can walk i<j and double-write into the
673 // symmetric pair map (a→b and b→a both get incremented).
674 $pairs = array(); // term_id => array( neighbor_id => shared_count )
675 $current_id = 0;
676 $current_set = array();
677 $flush = function () use ( &$current_set, &$pairs ) {
678 $ids = array_values( array_unique( $current_set ) );
679 $n = count( $ids );
680 if ( $n < 2 ) {
681 return;
682 }
683 sort( $ids, SORT_NUMERIC );
684 for ( $i = 0; $i < $n; $i++ ) {
685 $a = $ids[ $i ];
686 for ( $j = $i + 1; $j < $n; $j++ ) {
687 $b = $ids[ $j ];
688 if ( ! isset( $pairs[ $a ][ $b ] ) ) {
689 $pairs[ $a ][ $b ] = 0;
690 }
691 if ( ! isset( $pairs[ $b ][ $a ] ) ) {
692 $pairs[ $b ][ $a ] = 0;
693 }
694 ++$pairs[ $a ][ $b ];
695 ++$pairs[ $b ][ $a ];
696 }
697 }
698 };
699 foreach ( (array) $rows as $row ) {
700 $post_id = (int) $row['object_id'];
701 $term_id = (int) $row['term_id'];
702 if ( $post_id !== $current_id ) {
703 $flush();
704 $current_id = $post_id;
705 $current_set = array();
706 }
707 $current_set[] = $term_id;
708 }
709 $flush();
710
711 // Trim each tag's neighbor list to top-N by shared count, then
712 // reshape into the response payload. Keep string keys so JSON
713 // preserves them as numeric-looking object properties.
714 $result = array();
715 foreach ( $pairs as $tag_id => $neighbors ) {
716 arsort( $neighbors, SORT_NUMERIC );
717 $top = array_slice( $neighbors, 0, $limit, true );
718 $list = array();
719 foreach ( $top as $neighbor_id => $shared ) {
720 $list[] = array(
721 'id' => (int) $neighbor_id,
722 'shared' => (int) $shared,
723 );
724 }
725 $result[ (string) (int) $tag_id ] = $list;
726 }
727
728 $payload = array( 'pairs' => $result );
729 // Store under the version-stamped key. TTL is a day; an
730 // invalidation hook bump retires the key sooner. The DAY ceiling
731 // is the floor on cache staleness if every invalidation hook
732 // somehow missed firing.
733 set_transient( $cache_key, $payload, DAY_IN_SECONDS );
734
735 return rest_ensure_response( $payload );
736 }
737
738 /**
739 * REST get_callback for `openstation_is_default`. Reads the
740 * taxonomy's default-term option (e.g. `default_category`) and
741 * compares against the current term's id.
742 *
743 * @param array $term Term array as serialized by core's REST term controller.
744 * @return bool
745 */
746 function openstation_posts_window_term_is_default( $term ) {
747 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
748 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
749 if ( '' === $taxonomy || $term_id <= 0 ) {
750 return false;
751 }
752 $option_key = 'default_' . $taxonomy; // 'default_category', 'default_post_tag', …
753 $default_id = (int) get_option( $option_key, 0 );
754 return $default_id > 0 && $default_id === $term_id;
755 }
756
757 /**
758 * REST get_callback for `openstation_count`. Counts every post in
759 * the term except trashed + auto-draft (mirrors what users
760 * conceptually mean by "posts in this category").
761 *
762 * @param array $term Term array as serialized by core's REST term controller.
763 * @return int
764 */
765 function openstation_posts_window_term_count_any( $term ) {
766 global $wpdb;
767 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
768 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
769 $tt_id = isset( $term['term_taxonomy_id'] ) ? (int) $term['term_taxonomy_id'] : 0;
770 if ( $tt_id <= 0 && $term_id > 0 && '' !== $taxonomy ) {
771 $tt_id = (int) $wpdb->get_var(
772 $wpdb->prepare(
773 "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s LIMIT 1",
774 $term_id,
775 $taxonomy
776 )
777 );
778 }
779 if ( $tt_id <= 0 ) {
780 return 0;
781 }
782 // Match WP core's `_update_post_term_count` filtering — limit to
783 // the taxonomy's registered object_type and exclude statuses
784 // core treats as non-counting (trash / auto-draft / inherit).
785 $tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null;
786 $object_types = $tax_obj
787 ? array_filter( (array) $tax_obj->object_type, 'post_type_exists' )
788 : array( 'post' );
789 if ( empty( $object_types ) ) {
790 $object_types = array( 'post' );
791 }
792 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
793 return (int) $wpdb->get_var(
794 $wpdb->prepare(
795 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
796 JOIN {$wpdb->posts} p ON p.ID = tr.object_id
797 WHERE tr.term_taxonomy_id = %d
798 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
799 AND p.post_type IN ( $type_placeholders )",
800 array_merge( array( $tt_id ), $object_types )
801 )
802 );
803 }
804