` and
* clones it into the window body BEFORE the JS render callback fires.
* The `data-os-posts-*` hooks below are the contract the JS
* relies on — keep them intact (or rename via the filter) when
* customizing the layout.
*
* @package OpenStation
*/
defined( 'ABSPATH' ) || exit;
/**
* Echoes the native Posts window's template body.
*/
function openstation_posts_window_render_template() {
ob_start();
?>
s.nativePostsEnabled === true` in
// `src/desktop.ts`). Registering for every cap-eligible user is
// cheap — the script + template + REST nonce all live in the
// payload only, the actual fetch only happens when the user
// opens the window.
if ( ! openstation_posts_window_user_can_register() ) {
return;
}
$window_args = array(
'title' => __( 'Posts', 'desktop-mode' ),
'icon' => 'dashicons-admin-post',
'template' => 'openstation_posts_window_render_template',
'script' => 'os-posts-window',
'style' => 'os-posts-window',
'width' => 1100,
'height' => 720,
'min_width' => 720,
'min_height' => 480,
// `'none'` — no dock or wallpaper tile from this registration.
// The Posts dock tile lives in WordPress's `$menu` and the
// JS-side dock intercept routes its click to `openById` here
// when the opt-in is on. A separate tile would be a duplicate
// entry point.
'placement' => 'none',
'config' => array(
'restRoot' => esc_url_raw( rest_url() ),
'restNonce' => wp_create_nonce( 'wp_rest' ),
'postsUrl' => esc_url_raw( rest_url( 'wp/v2/posts' ) ),
'editPostUrlBase' => esc_url_raw( admin_url( 'post.php' ) ),
'newPostUrl' => esc_url_raw( admin_url( 'post-new.php' ) ),
'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
'currentUserId' => (int) get_current_user_id(),
'defaultPerPage' => 20,
'queryArgs' => openstation_posts_window_default_query_args(),
),
);
/**
* Filter the args used to register the native Posts window.
*
* @param array $window_args Args passed to `openstation_register_window()`.
*/
$window_args = (array) apply_filters( 'openstation_posts_window_args', $window_args );
$registered = openstation_register_window( 'desktop-mode-posts', $window_args );
if ( is_wp_error( $registered ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( '[openstation] Native Posts window registration failed: ' . $registered->get_error_message() );
}
}
add_action( 'init', 'openstation_posts_window_register_window', 20 );
/**
* Default REST query args the JS bundle uses on every list fetch.
*
* Filterable so a plugin can flip the post type to a custom CPT (or
* a list of CPTs) without forking the bundle. The JS bundle merges
* these on top of the page/per-page/search/status/sort args it
* generates per request.
*
* @return array
*/
function openstation_posts_window_default_query_args() {
$args = array(
// `_embed` pulls author + taxonomy + featured-media side-loads
// into `_embedded`, so the table can render avatars, term
// chips, and thumbnails without N extra round-trips per row.
'_embed' => 'author,wp:term,wp:featuredmedia',
// `openstation_lock` is the REST field registered by My WordPress'
// `lock.php` on every public post type — it tells us whether
// another user is currently editing the row. Surfacing it on the
// native Posts table means the title cell can paint a small lock
// icon without an extra fetch.
'_fields' =>
'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,openstation_lock,_links,_embedded',
);
/**
* Filter the default outbound REST query args for the Posts window.
*
* Drop in a `'post_type' => 'product'` to point the window at a
* CPT, or extend `_fields` to ship more columns. The bundle merges
* these on top of pagination/search/status/sort args.
*
* @param array $args Default args.
*/
return (array) apply_filters( 'openstation_posts_window_query_args', $args );
}
/**
* Switch the post-tag tax_query operator from the WP REST default `IN`
* (any-of, OR) to `AND` (every-of, intersection) when the Posts window
* client opts in via the `openstation_tags_match=all` URL flag.
*
* The flag is sent only when more than one tag is selected — single-
* tag queries are unaffected because AND with one term is identical
* to IN. The filter is scoped to GET `/wp/v2/posts`-style queries so
* other endpoints' tax_query semantics aren't touched.
*
* @param array $args `WP_Query` args after the REST controller
* prepared them.
* @param WP_REST_Request $request Active REST request.
* @return array Possibly-mutated args.
*/
function openstation_posts_window_tags_and_filter( $args, $request ) {
if ( ! ( $request instanceof WP_REST_Request ) ) {
return $args;
}
$flag = $request->get_param( 'openstation_tags_match' );
if ( 'all' !== $flag ) {
return $args;
}
if ( empty( $args['tax_query'] ) || ! is_array( $args['tax_query'] ) ) {
return $args;
}
foreach ( $args['tax_query'] as $key => $clause ) {
if ( ! is_array( $clause ) ) {
continue;
}
if ( isset( $clause['taxonomy'] ) && 'post_tag' === $clause['taxonomy'] ) {
$args['tax_query'][ $key ]['operator'] = 'AND';
}
}
return $args;
}
add_filter( 'rest_post_query', 'openstation_posts_window_tags_and_filter', 10, 2 );
/**
* Surface a "non-trashed posts" count alongside core's `count` field
* on category + post_tag REST responses.
*
* Core's `term_taxonomy.count` is updated by
* `_update_post_term_count()`, which only includes posts in the
* `publish` status by default. The Categories + Tags tabs in the
* native Posts window want to surface DRAFT and PENDING posts too,
* so the user can see "this category has 3 unpublished drafts" — a
* detail core's count silently hides.
*
* The field is `openstation_count` and lives on the term object in
* REST view context. The per-term query is one cheap COUNT(*) on a
* pre-indexed join, so 50 terms = 50 light queries — acceptable for
* an admin UI.
*/
function openstation_posts_window_register_count_field() {
foreach ( array( 'category', 'post_tag' ) as $taxonomy ) {
register_rest_field(
$taxonomy,
'openstation_count',
array(
'get_callback' => 'openstation_posts_window_term_count_any',
'schema' => array(
'description' => __( 'Number of non-trashed posts (any status) in this term.', 'desktop-mode' ),
'type' => 'integer',
'context' => array( 'view', 'embed' ),
'readonly' => true,
),
)
);
// Mark the taxonomy's "default" term — the fallback that
// receives uncategorised posts. Localised installs rename
// the slug + name so a JS-side "uncategorized" string match
// fails (Spanish: "Sin categoría"); the option is the only
// reliable id.
register_rest_field(
$taxonomy,
'openstation_is_default',
array(
'get_callback' => 'openstation_posts_window_term_is_default',
'schema' => array(
'description' => __( 'Whether this term is the taxonomy\'s default (fallback) term.', 'desktop-mode' ),
'type' => 'boolean',
'context' => array( 'view', 'embed' ),
'readonly' => true,
),
)
);
}
}
add_action( 'rest_api_init', 'openstation_posts_window_register_count_field' );
/**
* Shared site-wide cache version for any term-derived endpoint
* payload (bulk counts, tag cooccurrence, …). Stored in a non-
* autoloaded option and bumped by
* `openstation_posts_window_terms_cache_invalidate()` whenever a
* post/term change could move the derived data. The version is
* baked into every transient cache key, so a single
* `update_option()` retires the entire family of cached payloads
* in one stroke — no enumerating keys, no race window where an
* old entry might still be served. Stale entries fall out of the
* DB naturally via the transient TTL.
*/
function openstation_posts_window_terms_cache_version() {
$v = (int) get_option( 'desktop_mode_terms_cache_version', 0 );
if ( $v <= 0 ) {
$v = 1;
// autoload = false so this doesn't ride on every page load.
update_option( 'desktop_mode_terms_cache_version', $v, false );
}
return $v;
}
/**
* Invalidate every cached terms-derived payload by incrementing
* the shared version. The next call to any cached endpoint will
* miss its transient lookup, recompute, and store under the new
* key.
*
* Hooked to a handful of "post→term graph changed" actions below.
* The signature is intentionally argument-tolerant — each hook
* passes different positional args (object_id, term_id, taxonomy,
* …) and PHP just ignores extras for a no-param target.
*/
function openstation_posts_window_terms_cache_invalidate() {
$v = openstation_posts_window_terms_cache_version();
update_option(
'desktop_mode_terms_cache_version',
$v + 1,
false
);
}
// Direct post→term graph changes. `set_object_terms` fires whenever
// `wp_set_object_terms()` runs — covers post saves that change
// terms, term-delete cleanup, REST PATCH on a post's tags array,
// classic-editor flows, the lot. It's the ground truth.
add_action( 'set_object_terms', 'openstation_posts_window_terms_cache_invalidate' );
// Term identity changes — a renamed term doesn't shift pair counts
// but a deleted term does (its relationships go away). Invalidating
// on every term mutation costs one option write per edit, which is
// fine for the typical category/tag edit cadence.
add_action( 'created_term', 'openstation_posts_window_terms_cache_invalidate' );
add_action( 'edited_term', 'openstation_posts_window_terms_cache_invalidate' );
add_action( 'delete_term', 'openstation_posts_window_terms_cache_invalidate' );
// Status flips that change what the SQL counts. Both endpoints
// exclude 'trash', 'auto-draft', and 'inherit'; trashing or
// restoring a post adds and removes counts/pairs from the graph.
add_action( 'wp_trash_post', 'openstation_posts_window_terms_cache_invalidate' );
add_action( 'untrashed_post', 'openstation_posts_window_terms_cache_invalidate' );
// Pre-delete fires while term_relationships still exist; the row
// will be gone by the time the next query runs. Belt-and-braces
// alongside set_object_terms (which fires during delete cleanup
// on most modern WP versions).
add_action( 'before_delete_post', 'openstation_posts_window_terms_cache_invalidate' );
/**
* Bulk count endpoint — returns `{ term_id: count }` for every
* requested term in one query. The `openstation_count` REST field
* (per-term) is the canonical source, but on installs where the
* field isn't reaching the response (caching, REST middleware,
* stale `_fields` whitelist) the JS calls this endpoint as a
* defensive fallback so node labels never silently show 0.
*
* Cached: a single transient holds `{ term_id: count }` for EVERY
* term in the taxonomy. Each call projects the caller's requested
* IDs out of that map, so all clients hit the same cache entry
* regardless of which subset they ask for.
*
* GET `/desktop-mode/v1/term-counts?taxonomy=category&ids=1,4,7`
*/
function openstation_posts_window_register_term_counts_route() {
register_rest_route(
'desktop-mode/v1',
'/term-counts',
array(
'methods' => 'GET',
'callback' => 'openstation_posts_window_term_counts_callback',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'taxonomy' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
),
'ids' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
add_action( 'rest_api_init', 'openstation_posts_window_register_term_counts_route' );
/**
* REST callback: return post counts for a batch of term IDs.
*
* Uses a cached full-taxonomy map so multiple windows with
* different ID subsets share the same transient.
*
* @param WP_REST_Request $request Request with `taxonomy` and `ids`.
* @return array|WP_Error Map of `term_id => count`, or error on bad taxonomy.
*/
function openstation_posts_window_term_counts_callback( $request ) {
global $wpdb;
$taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
$tax_obj = get_taxonomy( $taxonomy );
if ( ! $tax_obj ) {
return new WP_Error(
'openstation_invalid_taxonomy',
__( 'Unknown taxonomy.', 'desktop-mode' ),
array( 'status' => 400 )
);
}
$raw = (string) $request->get_param( 'ids' );
$parts = array_map( 'intval', explode( ',', $raw ) );
$ids = array_values(
array_filter(
$parts,
function ( $id ) {
return $id > 0;
}
)
);
if ( count( $ids ) === 0 ) {
return array();
}
// Cap to avoid runaway IN-clauses if a malicious caller inflates
// the param. 500 is far above any plausible category/tag count.
$ids = array_slice( $ids, 0, 500 );
// Cache strategy: one transient holds the FULL taxonomy's
// { term_id => count } map. All clients project their requested
// ID subset out of the same cached map, so a window that asks
// for IDs [1, 4, 7] and one that asks for [4, 11, 22] share the
// same cache hit. Key shape: `dmtcnt_v_`.
$cache_version = openstation_posts_window_terms_cache_version();
$cache_key = sprintf( 'dmtcnt_v%d_%s', $cache_version, $taxonomy );
$counts = get_transient( $cache_key );
if ( ! is_array( $counts ) ) {
// Mirror WP core's `_update_post_term_count` filtering —
// limit to the taxonomy's `object_type` (e.g. `post` for
// category) and exclude statuses core treats as non-counting:
// - 'trash' + 'auto-draft' → user-not-published-and-never-will-be
// - 'inherit' → attachment-only status; excluded so attachments
// aren't double-counted via parent inheritance
// Everything else (publish, draft, pending, future, private)
// is included so the user sees a "real" post count, not
// WP's publish-only term_taxonomy.count.
$object_types = array_map(
'sanitize_key',
(array) $tax_obj->object_type
);
$object_types = array_filter( $object_types, 'post_type_exists' );
if ( empty( $object_types ) ) {
$object_types = array( 'post' );
}
$type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT tt.term_id, COUNT(p.ID) AS cnt
FROM {$wpdb->term_taxonomy} tt
LEFT JOIN {$wpdb->term_relationships} tr
ON tr.term_taxonomy_id = tt.term_taxonomy_id
LEFT JOIN {$wpdb->posts} p
ON p.ID = tr.object_id
AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
AND p.post_type IN ( $type_placeholders )
WHERE tt.taxonomy = %s
GROUP BY tt.term_id",
array_merge( $object_types, array( $taxonomy ) )
),
ARRAY_A
);
$counts = array();
foreach ( (array) $rows as $row ) {
$counts[ (string) (int) $row['term_id'] ] = (int) $row['cnt'];
}
set_transient( $cache_key, $counts, DAY_IN_SECONDS );
}
// Project the caller's requested IDs out of the (cached or
// fresh) full map. Missing IDs return 0 to match the legacy
// shape — a caller looking up a term that no longer exists or
// has no associated posts still gets a zero, not a missing key.
$out = array();
foreach ( $ids as $id ) {
$key = (string) $id;
$out[ $key ] = isset( $counts[ $key ] ) ? (int) $counts[ $key ] : 0;
}
return $out;
}
/**
* REST: tag co-occurrence aggregator. Returns, for each tag, its
* top-N most frequently co-occurring sibling tags + the shared
* post count. Used by the Tags window to precluster the pixi
* cloud — tags that appear together on the same post get pulled
* toward each other after the spiral pack.
*
* GET `/desktop-mode/v1/tag-cooccurrence?taxonomy=post_tag&limit=8`
*
* Response:
* { "pairs": { "": [ { "id": , "shared": N }, … ] } }
*
* Status filtering mirrors `_update_post_term_count` (exclude
* trash, auto-draft, inherit). Post type is bounded to the
* taxonomy's declared `object_type` so e.g. page-attached terms
* don't bleed into the post-tag graph.
*/
function openstation_posts_window_register_tag_cooccurrence_route() {
register_rest_route(
'desktop-mode/v1',
'/tag-cooccurrence',
array(
'methods' => 'GET',
'callback' => 'openstation_posts_window_tag_cooccurrence_callback',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'taxonomy' => array(
'required' => false,
'type' => 'string',
'default' => 'post_tag',
'sanitize_callback' => 'sanitize_key',
),
'limit' => array(
'required' => false,
'type' => 'integer',
'default' => 8,
),
),
)
);
}
add_action( 'rest_api_init', 'openstation_posts_window_register_tag_cooccurrence_route' );
/**
* REST callback: return tag co-occurrence data for the pixi
* cloud. For each tag, returns its top-N most frequently
* co-occurring sibling tags with the shared post count.
*
* @param WP_REST_Request $request Request with `taxonomy` and `limit`.
* @return WP_REST_Response|WP_Error
*/
function openstation_posts_window_tag_cooccurrence_callback( $request ) {
global $wpdb;
$taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
$tax_obj = get_taxonomy( $taxonomy );
if ( ! $tax_obj ) {
return new WP_Error(
'openstation_invalid_taxonomy',
__( 'Unknown taxonomy.', 'desktop-mode' ),
array( 'status' => 400 )
);
}
$limit = (int) $request->get_param( 'limit' );
if ( $limit <= 0 ) {
$limit = 8;
}
// Cap so a malicious caller can't fan out the response. 24 is
// far more neighbors than any layout pass actually consumes.
$limit = min( 24, $limit );
// Cache lookup. The key bakes in the current version so a single
// option bump makes every old entry unreachable without us
// having to enumerate keys. Taxonomy + limit are part of the key
// because they change the response shape.
$cache_version = openstation_posts_window_terms_cache_version();
$cache_key = sprintf(
'dmwco_v%d_%s_l%d',
$cache_version,
$taxonomy,
$limit
);
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && isset( $cached['pairs'] ) ) {
return rest_ensure_response( $cached );
}
$object_types = array_map(
'sanitize_key',
(array) $tax_obj->object_type
);
$object_types = array_filter( $object_types, 'post_type_exists' );
if ( empty( $object_types ) ) {
$object_types = array( 'post' );
}
$type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
// One scan: every (post, term) row in the taxonomy on a
// non-trashed, non-attachment-inherited post of the right type.
// Sorted by post so we can walk in O(n) and emit pairs per post.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT tr.object_id, tt.term_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt
ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN {$wpdb->posts} p
ON p.ID = tr.object_id
AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
AND p.post_type IN ( $type_placeholders )
WHERE tt.taxonomy = %s
ORDER BY tr.object_id",
array_merge( $object_types, array( $taxonomy ) )
),
ARRAY_A
);
// Group term ids per post, then emit pair counts. Sort each
// post's term list so we can walk i array( neighbor_id => shared_count )
$current_id = 0;
$current_set = array();
$flush = function () use ( &$current_set, &$pairs ) {
$ids = array_values( array_unique( $current_set ) );
$n = count( $ids );
if ( $n < 2 ) {
return;
}
sort( $ids, SORT_NUMERIC );
for ( $i = 0; $i < $n; $i++ ) {
$a = $ids[ $i ];
for ( $j = $i + 1; $j < $n; $j++ ) {
$b = $ids[ $j ];
if ( ! isset( $pairs[ $a ][ $b ] ) ) {
$pairs[ $a ][ $b ] = 0;
}
if ( ! isset( $pairs[ $b ][ $a ] ) ) {
$pairs[ $b ][ $a ] = 0;
}
++$pairs[ $a ][ $b ];
++$pairs[ $b ][ $a ];
}
}
};
foreach ( (array) $rows as $row ) {
$post_id = (int) $row['object_id'];
$term_id = (int) $row['term_id'];
if ( $post_id !== $current_id ) {
$flush();
$current_id = $post_id;
$current_set = array();
}
$current_set[] = $term_id;
}
$flush();
// Trim each tag's neighbor list to top-N by shared count, then
// reshape into the response payload. Keep string keys so JSON
// preserves them as numeric-looking object properties.
$result = array();
foreach ( $pairs as $tag_id => $neighbors ) {
arsort( $neighbors, SORT_NUMERIC );
$top = array_slice( $neighbors, 0, $limit, true );
$list = array();
foreach ( $top as $neighbor_id => $shared ) {
$list[] = array(
'id' => (int) $neighbor_id,
'shared' => (int) $shared,
);
}
$result[ (string) (int) $tag_id ] = $list;
}
$payload = array( 'pairs' => $result );
// Store under the version-stamped key. TTL is a day; an
// invalidation hook bump retires the key sooner. The DAY ceiling
// is the floor on cache staleness if every invalidation hook
// somehow missed firing.
set_transient( $cache_key, $payload, DAY_IN_SECONDS );
return rest_ensure_response( $payload );
}
/**
* REST get_callback for `openstation_is_default`. Reads the
* taxonomy's default-term option (e.g. `default_category`) and
* compares against the current term's id.
*
* @param array $term Term array as serialized by core's REST term controller.
* @return bool
*/
function openstation_posts_window_term_is_default( $term ) {
$taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
$term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
if ( '' === $taxonomy || $term_id <= 0 ) {
return false;
}
$option_key = 'default_' . $taxonomy; // 'default_category', 'default_post_tag', …
$default_id = (int) get_option( $option_key, 0 );
return $default_id > 0 && $default_id === $term_id;
}
/**
* REST get_callback for `openstation_count`. Counts every post in
* the term except trashed + auto-draft (mirrors what users
* conceptually mean by "posts in this category").
*
* @param array $term Term array as serialized by core's REST term controller.
* @return int
*/
function openstation_posts_window_term_count_any( $term ) {
global $wpdb;
$taxonomy = isset( $term['taxonomy'] ) ? (string) $term['taxonomy'] : '';
$term_id = isset( $term['id'] ) ? (int) $term['id'] : 0;
$tt_id = isset( $term['term_taxonomy_id'] ) ? (int) $term['term_taxonomy_id'] : 0;
if ( $tt_id <= 0 && $term_id > 0 && '' !== $taxonomy ) {
$tt_id = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE term_id = %d AND taxonomy = %s LIMIT 1",
$term_id,
$taxonomy
)
);
}
if ( $tt_id <= 0 ) {
return 0;
}
// Match WP core's `_update_post_term_count` filtering — limit to
// the taxonomy's registered object_type and exclude statuses
// core treats as non-counting (trash / auto-draft / inherit).
$tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null;
$object_types = $tax_obj
? array_filter( (array) $tax_obj->object_type, 'post_type_exists' )
: array( 'post' );
if ( empty( $object_types ) ) {
$object_types = array( 'post' );
}
$type_placeholders = implode( ',', array_fill( 0, count( $object_types ), '%s' ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->posts} p ON p.ID = tr.object_id
WHERE tr.term_taxonomy_id = %d
AND p.post_status NOT IN ( 'trash', 'auto-draft', 'inherit' )
AND p.post_type IN ( $type_placeholders )",
array_merge( array( $tt_id ), $object_types )
)
);
}