PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / posts-window / window.php

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

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