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

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