` 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(), // First-open intro dialog wiring — see `includes/seen-intros.php`. // `introSeen` is the boot-time snapshot; the bundle marks the // intro seen via `introUrl` after the user dismisses the dialog. 'introSeen' => openstation_has_seen_intro( get_current_user_id(), 'posts' ), 'introUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) ), ), ); /** * 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' ); 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' ); 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 ) ) ); }