PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.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.0.1, at includes/posts-window/window.php

792 lines 29.3 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 // First-open intro dialog wiring — see `includes/seen-intros.php`.
202 // `introSeen` is the boot-time snapshot; the bundle marks the
203 // intro seen via `introUrl` after the user dismisses the dialog.
204 'introSeen' => openstation_has_seen_intro( get_current_user_id(), 'posts' ),
205 'introUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) ),
206 ),
207 );
208
209 /**
210 * Filter the args used to register the native Posts window.
211 *
212 * @param array $window_args Args passed to `openstation_register_window()`.
213 */
214 $window_args = (array) apply_filters( 'openstation_posts_window_args', $window_args );
215
216 $registered = openstation_register_window( 'desktop-mode-posts', $window_args );
217 if ( is_wp_error( $registered ) ) {
218 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
219 error_log( '[openstation] Native Posts window registration failed: ' . $registered->get_error_message() );
220 }
221 }
222 add_action( 'init', 'openstation_posts_window_register_window', 20 );
223
224 /**
225 * Default REST query args the JS bundle uses on every list fetch.
226 *
227 * Filterable so a plugin can flip the post type to a custom CPT (or
228 * a list of CPTs) without forking the bundle. The JS bundle merges
229 * these on top of the page/per-page/search/status/sort args it
230 * generates per request.
231 *
232 * @return array
233 */
234 function openstation_posts_window_default_query_args() {
235 $args = array(
236 // `_embed` pulls author + taxonomy + featured-media side-loads
237 // into `_embedded`, so the table can render avatars, term
238 // chips, and thumbnails without N extra round-trips per row.
239 '_embed' => 'author,wp:term,wp:featuredmedia',
240 // `openstation_lock` is the REST field registered by My WordPress'
241 // `lock.php` on every public post type — it tells us whether
242 // another user is currently editing the row. Surfacing it on the
243 // native Posts table means the title cell can paint a small lock
244 // icon without an extra fetch.
245 '_fields' =>
246 'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,openstation_lock,_links,_embedded',
247 );
248
249 /**
250 * Filter the default outbound REST query args for the Posts window.
251 *
252 * Drop in a `'post_type' => 'product'` to point the window at a
253 * CPT, or extend `_fields` to ship more columns. The bundle merges
254 * these on top of pagination/search/status/sort args.
255 *
256 * @param array $args Default args.
257 */
258 return (array) apply_filters( 'openstation_posts_window_query_args', $args );
259 }
260
261 /**
262 * Switch the post-tag tax_query operator from the WP REST default `IN`
263 * (any-of, OR) to `AND` (every-of, intersection) when the Posts window
264 * client opts in via the `openstation_tags_match=all` URL flag.
265 *
266 * The flag is sent only when more than one tag is selected — single-
267 * tag queries are unaffected because AND with one term is identical
268 * to IN. The filter is scoped to GET `/wp/v2/posts`-style queries so
269 * other endpoints' tax_query semantics aren't touched.
270 *
271 * @param array $args `WP_Query` args after the REST controller
272 * prepared them.
273 * @param WP_REST_Request $request Active REST request.
274 * @return array Possibly-mutated args.
275 */
276 function openstation_posts_window_tags_and_filter( $args, $request ) {
277 if ( ! ( $request instanceof WP_REST_Request ) ) {
278 return $args;
279 }
280 $flag = $request->get_param( 'openstation_tags_match' );
281 if ( 'all' !== $flag ) {
282 return $args;
283 }
284 if ( empty( $args['tax_query'] ) || ! is_array( $args['tax_query'] ) ) {
285 return $args;
286 }
287 foreach ( $args['tax_query'] as $key => $clause ) {
288 if ( ! is_array( $clause ) ) {
289 continue;
290 }
291 if ( isset( $clause['taxonomy'] ) && 'post_tag' === $clause['taxonomy'] ) {
292 $args['tax_query'][ $key ]['operator'] = 'AND';
293 }
294 }
295 return $args;
296 }
297 add_filter( 'rest_post_query', 'openstation_posts_window_tags_and_filter', 10, 2 );
298
299 /**
300 * Surface a "non-trashed posts" count alongside core's `count` field
301 * on category + post_tag REST responses.
302 *
303 * Core's `term_taxonomy.count` is updated by
304 * `_update_post_term_count()`, which only includes posts in the
305 * `publish` status by default. The Categories + Tags tabs in the
306 * native Posts window want to surface DRAFT and PENDING posts too,
307 * so the user can see "this category has 3 unpublished drafts" — a
308 * detail core's count silently hides.
309 *
310 * The field is `openstation_count` and lives on the term object in
311 * REST view context. The per-term query is one cheap COUNT(*) on a
312 * pre-indexed join, so 50 terms = 50 light queries — acceptable for
313 * an admin UI.
314 */
315 function openstation_posts_window_register_count_field() {
316 foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
317 register_rest_field(
318 $taxonomy,
319 'openstation_count',
320 array(
321 'get_callback' => 'openstation_posts_window_term_count_any',
322 'schema' => array(
323 'description' => __( 'Number of non-trashed posts (any status) in this term.', 'desktop-mode' ),
324 'type' => 'integer',
325 'context' => array( 'view', 'embed' ),
326 'readonly' => true,
327 ),
328 )
329 );
330 // Mark the taxonomy's "default" term — the fallback that
331 // receives uncategorised posts. Localised installs rename
332 // the slug + name so a JS-side "uncategorized" string match
333 // fails (Spanish: "Sin categoría"); the option is the only
334 // reliable id.
335 register_rest_field(
336 $taxonomy,
337 'openstation_is_default',
338 array(
339 'get_callback' => 'openstation_posts_window_term_is_default',
340 'schema' => array(
341 'description' => __( 'Whether this term is the taxonomy\'s default (fallback) term.', 'desktop-mode' ),
342 'type' => 'boolean',
343 'context' => array( 'view', 'embed' ),
344 'readonly' => true,
345 ),
346 )
347 );
348 }
349 }
350 add_action( 'rest_api_init', 'openstation_posts_window_register_count_field' );
351
352 /**
353 * Shared site-wide cache version for any term-derived endpoint
354 * payload (bulk counts, tag cooccurrence, …). Stored in a non-
355 * autoloaded option and bumped by
356 * `openstation_posts_window_terms_cache_invalidate()` whenever a
357 * post/term change could move the derived data. The version is
358 * baked into every transient cache key, so a single
359 * `update_option()` retires the entire family of cached payloads
360 * in one stroke — no enumerating keys, no race window where an
361 * old entry might still be served. Stale entries fall out of the
362 * DB naturally via the transient TTL.
363 */
364 function openstation_posts_window_terms_cache_version() {
365 $v = (int) get_option( 'desktop_mode_terms_cache_version', 0 );
366 if ( $v <= 0 ) {
367 $v = 1;
368 // autoload = false so this doesn't ride on every page load.
369 update_option( 'desktop_mode_terms_cache_version', $v, false );
370 }
371 return $v;
372 }
373
374 /**
375 * Invalidate every cached terms-derived payload by incrementing
376 * the shared version. The next call to any cached endpoint will
377 * miss its transient lookup, recompute, and store under the new
378 * key.
379 *
380 * Hooked to a handful of "post→term graph changed" actions below.
381 * The signature is intentionally argument-tolerant — each hook
382 * passes different positional args (object_id, term_id, taxonomy,
383 * …) and PHP just ignores extras for a no-param target.
384 */
385 function openstation_posts_window_terms_cache_invalidate() {
386 $v = openstation_posts_window_terms_cache_version();
387 update_option(
388 'desktop_mode_terms_cache_version',
389 $v + 1,
390 false
391 );
392 }
393 // Direct post→term graph changes. `set_object_terms` fires whenever
394 // `wp_set_object_terms()` runs — covers post saves that change
395 // terms, term-delete cleanup, REST PATCH on a post's tags array,
396 // classic-editor flows, the lot. It's the ground truth.
397 add_action( 'set_object_terms', 'openstation_posts_window_terms_cache_invalidate' );
398 // Term identity changes — a renamed term doesn't shift pair counts
399 // but a deleted term does (its relationships go away). Invalidating
400 // on every term mutation costs one option write per edit, which is
401 // fine for the typical category/tag edit cadence.
402 add_action( 'created_term', 'openstation_posts_window_terms_cache_invalidate' );
403 add_action( 'edited_term', 'openstation_posts_window_terms_cache_invalidate' );
404 add_action( 'delete_term', 'openstation_posts_window_terms_cache_invalidate' );
405 // Status flips that change what the SQL counts. Both endpoints
406 // exclude 'trash', 'auto-draft', and 'inherit'; trashing or
407 // restoring a post adds and removes counts/pairs from the graph.
408 add_action( 'wp_trash_post', 'openstation_posts_window_terms_cache_invalidate' );
409 add_action( 'untrashed_post', 'openstation_posts_window_terms_cache_invalidate' );
410 // Pre-delete fires while term_relationships still exist; the row
411 // will be gone by the time the next query runs. Belt-and-braces
412 // alongside set_object_terms (which fires during delete cleanup
413 // on most modern WP versions).
414 add_action( 'before_delete_post', 'openstation_posts_window_terms_cache_invalidate' );
415
416 /**
417 * Bulk count endpoint — returns `{ term_id: count }` for every
418 * requested term in one query. The `openstation_count` REST field
419 * (per-term) is the canonical source, but on installs where the
420 * field isn't reaching the response (caching, REST middleware,
421 * stale `_fields` whitelist) the JS calls this endpoint as a
422 * defensive fallback so node labels never silently show 0.
423 *
424 * Cached: a single transient holds `{ term_id: count }` for EVERY
425 * term in the taxonomy. Each call projects the caller's requested
426 * IDs out of that map, so all clients hit the same cache entry
427 * regardless of which subset they ask for.
428 *
429 * GET `/desktop-mode/v1/term-counts?taxonomy=category&ids=1,4,7`
430 */
431 function openstation_posts_window_register_term_counts_route() {
432 register_rest_route(
433 'desktop-mode/v1',
434 '/term-counts',
435 array(
436 'methods' => 'GET',
437 'callback' => 'openstation_posts_window_term_counts_callback',
438 'permission_callback' => function () {
439 return current_user_can( 'edit_posts' );
440 },
441 'args' => array(
442 'taxonomy' => array(
443 'required' => true,
444 'type' => 'string',
445 'sanitize_callback' => 'sanitize_key',
446 ),
447 'ids' => array(
448 'required' => true,
449 'type' => 'string',
450 ),
451 ),
452 )
453 );
454 }
455 add_action( 'rest_api_init', 'openstation_posts_window_register_term_counts_route' );
456
457 function openstation_posts_window_term_counts_callback( $request ) {
458 global $wpdb;
459 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
460 $tax_obj = get_taxonomy( $taxonomy );
461 if ( ! $tax_obj ) {
462 return new WP_Error(
463 'openstation_invalid_taxonomy',
464 __( 'Unknown taxonomy.', 'desktop-mode' ),
465 array( 'status' => 400 )
466 );
467 }
468 $raw = (string) $request->get_param( 'ids' );
469 $parts = array_map( 'intval', explode( ',', $raw ) );
470 $ids = array_values(
471 array_filter(
472 $parts,
473 function ( $id ) {
474 return $id > 0;
475 }
476 )
477 );
478 if ( count( $ids ) === 0 ) {
479 return array();
480 }
481 // Cap to avoid runaway IN-clauses if a malicious caller inflates
482 // the param. 500 is far above any plausible category/tag count.
483 $ids = array_slice( $ids, 0, 500 );
484
485 // Cache strategy: one transient holds the FULL taxonomy's
486 // { term_id => count } map. All clients project their requested
487 // ID subset out of the same cached map, so a window that asks
488 // for IDs [1, 4, 7] and one that asks for [4, 11, 22] share the
489 // same cache hit. Key shape: `dmtcnt_v<version>_<taxonomy>`.
490 $cache_version = openstation_posts_window_terms_cache_version();
491 $cache_key = sprintf( 'dmtcnt_v%d_%s', $cache_version, $taxonomy );
492 $counts = get_transient( $cache_key );
493 if ( ! is_array( $counts ) ) {
494 // Mirror WP core's `_update_post_term_count` filtering —
495 // limit to the taxonomy's `object_type` (e.g. `post` for
496 // category) and exclude statuses core treats as non-counting:
497 // - 'trash' + 'auto-draft' → user-not-published-and-never-will-be
498 // - 'inherit' → attachment-only status; excluded so attachments
499 // aren't double-counted via parent inheritance
500 // Everything else (publish, draft, pending, future, private)
501 // is included so the user sees a "real" post count, not
502 // WP's publish-only term_taxonomy.count.
503 $object_types = array_map(
504 'sanitize_key',
505 (array) $tax_obj->object_type
506 );
507 $object_types = array_filter( $object_types, 'post_type_exists' );
508 if ( empty( $object_types ) ) {
509 $object_types = array( 'post' );
510 }
511 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
512 $rows = $wpdb->get_results(
513 $wpdb->prepare(
514 "SELECT tt.term_id, COUNT(p.ID) AS cnt
515 FROM {$wpdb->term_taxonomy} tt
516 LEFT JOIN {$wpdb->term_relationships} tr
517 ON tr.term_taxonomy_id = tt.term_taxonomy_id
518 LEFT JOIN {$wpdb->posts} p
519 ON p.ID = tr.object_id
520 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
521 AND p.post_type IN ( $type_placeholders )
522 WHERE tt.taxonomy = %s
523 GROUP BY tt.term_id",
524 array_merge( $object_types, array( $taxonomy ) )
525 ),
526 ARRAY_A
527 );
528 $counts = array();
529 foreach ( (array) $rows as $row ) {
530 $counts[ (string) (int) $row['term_id'] ] = (int) $row['cnt'];
531 }
532 set_transient( $cache_key, $counts, DAY_IN_SECONDS );
533 }
534
535 // Project the caller's requested IDs out of the (cached or
536 // fresh) full map. Missing IDs return 0 to match the legacy
537 // shape — a caller looking up a term that no longer exists or
538 // has no associated posts still gets a zero, not a missing key.
539 $out = array();
540 foreach ( $ids as $id ) {
541 $key = (string) $id;
542 $out[ $key ] = isset( $counts[ $key ] ) ? (int) $counts[ $key ] : 0;
543 }
544 return $out;
545 }
546
547 /**
548 * REST: tag co-occurrence aggregator. Returns, for each tag, its
549 * top-N most frequently co-occurring sibling tags + the shared
550 * post count. Used by the Tags window to precluster the pixi
551 * cloud — tags that appear together on the same post get pulled
552 * toward each other after the spiral pack.
553 *
554 * GET `/desktop-mode/v1/tag-cooccurrence?taxonomy=post_tag&limit=8`
555 *
556 * Response:
557 * { "pairs": { "<tagId>": [ { "id": <neighborId>, "shared": N }, … ] } }
558 *
559 * Status filtering mirrors `_update_post_term_count` (exclude
560 * trash, auto-draft, inherit). Post type is bounded to the
561 * taxonomy's declared `object_type` so e.g. page-attached terms
562 * don't bleed into the post-tag graph.
563 */
564 function openstation_posts_window_register_tag_cooccurrence_route() {
565 register_rest_route(
566 'desktop-mode/v1',
567 '/tag-cooccurrence',
568 array(
569 'methods' => 'GET',
570 'callback' => 'openstation_posts_window_tag_cooccurrence_callback',
571 'permission_callback' => function () {
572 return current_user_can( 'edit_posts' );
573 },
574 'args' => array(
575 'taxonomy' => array(
576 'required' => false,
577 'type' => 'string',
578 'default' => 'post_tag',
579 'sanitize_callback' => 'sanitize_key',
580 ),
581 'limit' => array(
582 'required' => false,
583 'type' => 'integer',
584 'default' => 8,
585 ),
586 ),
587 )
588 );
589 }
590 add_action( 'rest_api_init', 'openstation_posts_window_register_tag_cooccurrence_route' );
591
592 function openstation_posts_window_tag_cooccurrence_callback( $request ) {
593 global $wpdb;
594
595 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
596 $tax_obj = get_taxonomy( $taxonomy );
597 if ( ! $tax_obj ) {
598 return new WP_Error(
599 'openstation_invalid_taxonomy',
600 __( 'Unknown taxonomy.', 'desktop-mode' ),
601 array( 'status' => 400 )
602 );
603 }
604
605 $limit = (int) $request->get_param( 'limit' );
606 if ( $limit <= 0 ) {
607 $limit = 8;
608 }
609 // Cap so a malicious caller can't fan out the response. 24 is
610 // far more neighbors than any layout pass actually consumes.
611 $limit = min( 24, $limit );
612
613 // Cache lookup. The key bakes in the current version so a single
614 // option bump makes every old entry unreachable without us
615 // having to enumerate keys. Taxonomy + limit are part of the key
616 // because they change the response shape.
617 $cache_version = openstation_posts_window_terms_cache_version();
618 $cache_key = sprintf(
619 'dmwco_v%d_%s_l%d',
620 $cache_version,
621 $taxonomy,
622 $limit
623 );
624 $cached = get_transient( $cache_key );
625 if ( is_array( $cached ) && isset( $cached['pairs'] ) ) {
626 return rest_ensure_response( $cached );
627 }
628
629 $object_types = array_map(
630 'sanitize_key',
631 (array) $tax_obj->object_type
632 );
633 $object_types = array_filter( $object_types, 'post_type_exists' );
634 if ( empty( $object_types ) ) {
635 $object_types = array( 'post' );
636 }
637 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
638
639 // One scan: every (post, term) row in the taxonomy on a
640 // non-trashed, non-attachment-inherited post of the right type.
641 // Sorted by post so we can walk in O(n) and emit pairs per post.
642 $rows = $wpdb->get_results(
643 $wpdb->prepare(
644 "SELECT tr.object_id, tt.term_id
645 FROM {$wpdb->term_relationships} tr
646 INNER JOIN {$wpdb->term_taxonomy} tt
647 ON tr.term_taxonomy_id = tt.term_taxonomy_id
648 INNER JOIN {$wpdb->posts} p
649 ON p.ID = tr.object_id
650 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
651 AND p.post_type IN ( $type_placeholders )
652 WHERE tt.taxonomy = %s
653 ORDER BY tr.object_id",
654 array_merge( $object_types, array( $taxonomy ) )
655 ),
656 ARRAY_A
657 );
658
659 // Group term ids per post, then emit pair counts. Sort each
660 // post's term list so we can walk i<j and double-write into the
661 // symmetric pair map (a→b and b→a both get incremented).
662 $pairs = array(); // term_id => array( neighbor_id => shared_count )
663 $current_id = 0;
664 $current_set = array();
665 $flush = function () use ( &$current_set, &$pairs ) {
666 $ids = array_values( array_unique( $current_set ) );
667 $n = count( $ids );
668 if ( $n < 2 ) {
669 return;
670 }
671 sort( $ids, SORT_NUMERIC );
672 for ( $i = 0; $i < $n; $i++ ) {
673 $a = $ids[ $i ];
674 for ( $j = $i + 1; $j < $n; $j++ ) {
675 $b = $ids[ $j ];
676 if ( ! isset( $pairs[ $a ][ $b ] ) ) {
677 $pairs[ $a ][ $b ] = 0;
678 }
679 if ( ! isset( $pairs[ $b ][ $a ] ) ) {
680 $pairs[ $b ][ $a ] = 0;
681 }
682 ++$pairs[ $a ][ $b ];
683 ++$pairs[ $b ][ $a ];
684 }
685 }
686 };
687 foreach ( (array) $rows as $row ) {
688 $post_id = (int) $row['object_id'];
689 $term_id = (int) $row['term_id'];
690 if ( $post_id !== $current_id ) {
691 $flush();
692 $current_id = $post_id;
693 $current_set = array();
694 }
695 $current_set[] = $term_id;
696 }
697 $flush();
698
699 // Trim each tag's neighbor list to top-N by shared count, then
700 // reshape into the response payload. Keep string keys so JSON
701 // preserves them as numeric-looking object properties.
702 $result = array();
703 foreach ( $pairs as $tag_id => $neighbors ) {
704 arsort( $neighbors, SORT_NUMERIC );
705 $top = array_slice( $neighbors, 0, $limit, true );
706 $list = array();
707 foreach ( $top as $neighbor_id => $shared ) {
708 $list[] = array(
709 'id' => (int) $neighbor_id,
710 'shared' => (int) $shared,
711 );
712 }
713 $result[ (string) (int) $tag_id ] = $list;
714 }
715
716 $payload = array( 'pairs' => $result );
717 // Store under the version-stamped key. TTL is a day; an
718 // invalidation hook bump retires the key sooner. The DAY ceiling
719 // is the floor on cache staleness if every invalidation hook
720 // somehow missed firing.
721 set_transient( $cache_key, $payload, DAY_IN_SECONDS );
722
723 return rest_ensure_response( $payload );
724 }
725
726 /**
727 * REST get_callback for `openstation_is_default`. Reads the
728 * taxonomy's default-term option (e.g. `default_category`) and
729 * compares against the current term's id.
730 *
731 * @param array $term Term array as serialized by core's REST term controller.
732 * @return bool
733 */
734 function openstation_posts_window_term_is_default( $term ) {
735 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
736 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
737 if ( '' === $taxonomy || $term_id <= 0 ) {
738 return false;
739 }
740 $option_key = 'default_' . $taxonomy; // 'default_category', 'default_post_tag', …
741 $default_id = (int) get_option( $option_key, 0 );
742 return $default_id > 0 && $default_id === $term_id;
743 }
744
745 /**
746 * REST get_callback for `openstation_count`. Counts every post in
747 * the term except trashed + auto-draft (mirrors what users
748 * conceptually mean by "posts in this category").
749 *
750 * @param array $term Term array as serialized by core's REST term controller.
751 * @return int
752 */
753 function openstation_posts_window_term_count_any( $term ) {
754 global $wpdb;
755 $taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
756 $term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
757 $tt_id = isset( $term['term_taxonomy_id'] ) ? (int) $term['term_taxonomy_id'] : 0;
758 if ( $tt_id <= 0 && $term_id > 0 && '' !== $taxonomy ) {
759 $tt_id = (int) $wpdb->get_var(
760 $wpdb->prepare(
761 "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s LIMIT 1",
762 $term_id,
763 $taxonomy
764 )
765 );
766 }
767 if ( $tt_id <= 0 ) {
768 return 0;
769 }
770 // Match WP core's `_update_post_term_count` filtering — limit to
771 // the taxonomy's registered object_type and exclude statuses
772 // core treats as non-counting (trash / auto-draft / inherit).
773 $tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null;
774 $object_types = $tax_obj
775 ? array_filter( (array) $tax_obj->object_type, 'post_type_exists' )
776 : array( 'post' );
777 if ( empty( $object_types ) ) {
778 $object_types = array( 'post' );
779 }
780 $type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
781 return (int) $wpdb->get_var(
782 $wpdb->prepare(
783 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
784 JOIN {$wpdb->posts} p ON p.ID = tr.object_id
785 WHERE tr.term_taxonomy_id = %d
786 AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
787 AND p.post_type IN ( $type_placeholders )",
788 array_merge( array( $tt_id ), $object_types )
789 )
790 );
791 }
792