PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
← All changes | includes/window-links.php +737 -43 0.9.5 → 1.1.12 View file →
@@ -12,12 +12,11 @@
12 12 * It runs inside the chromeless iframe request — real admin context,
13 13 * where `get_current_screen()` and the content globals are live — so
14 14 * relations the URL alone can't answer (which post a comment belongs
15 15 * to) resolve server-side and reach the shell via the chromeless
16 - * bridge's `desktop-mode-content-identity` postMessage.
16 + * bridge's `os-content-identity` postMessage.
17 17 *
18 - * @since 0.9.4
19 - * @package WPDesktopMode
18 + * @package OpenStation
20 19 */
21 20
22 21 defined( 'ABSPATH' ) || exit;
23 22
@@ -41,14 +40,21 @@
41 40 * `post_parent` when attached.
42 41 * - `comment.php` (comment edit / moderation) — `comment`, rooted at
43 42 * the parent post. The URL alone can't answer this one; only real
44 43 * admin context can.
44 + * - `revision.php` (the revision browser) — `revisions`, rooted at
45 + * the post whose history it shows. Keyed by the PARENT post rather
46 + * than the revision on screen, because the browser's slider walks
47 + * revisions client-side (`history.replaceState`) without a reload:
48 + * a revision-keyed identity would go stale on the first drag, and
49 + * the window is "the history of post 123" throughout anyway.
50 + * - `user-edit.php` / `profile.php` — `user`, a root identity. What
51 + * points at a person (a post's author, an order's customer) does so
52 + * from its own `links`.
45 53 *
46 - * @since 0.9.4
47 - *
48 54 * @return array|null Identity array, or `null` when none applies.
49 55 */
50 -function desktop_mode_build_content_identity() {
56 +function openstation_build_content_identity() {
51 57 $identity = null;
52 58 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
53 59 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
54 60
@@ -59,9 +65,9 @@
59 65 if ( $comment ) {
60 66 $identity = array(
61 67 'type' => 'comment',
62 68 'id' => (int) $comment->comment_ID,
63 - 'label' => wp_trim_words( $comment->comment_content, 10 ),
69 + 'label' => wp_trim_words( openstation_strip_all_tags( $comment->comment_content ), 10 ),
64 70 );
65 71
66 72 $post_id = (int) $comment->comment_post_ID;
67 73 $post_type = $post_id ? get_post_type( $post_id ) : false;
@@ -71,8 +77,39 @@
71 77 'id' => $post_id,
72 78 );
73 79 }
74 80 }
81 + } elseif ( 'revision.php' === $pagenow ) {
82 + // Revision browser — `revision.php?revision=N`. Core falls back
83 + // to `?to=N` when `revision` is absent (the compare-two-revisions
84 + // form of the URL), so mirror that: both forms show the same
85 + // post's history and must announce the same identity.
86 + //
87 + // The identity is keyed by the PARENT post, not by the revision
88 + // on screen — see the "Detected screens" note above — which also
89 + // means the shell can seed it at open time knowing only the post
90 + // it opened the browser for, so the tie to the editor window
91 + // draws before the iframe has finished loading.
92 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
93 + $revision_id = isset( $_GET['revision'] ) ? absint( $_GET['revision'] ) : 0;
94 + if ( ! $revision_id ) {
95 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
96 + $revision_id = isset( $_GET['to'] ) ? absint( $_GET['to'] ) : 0;
97 + }
98 + $revision = $revision_id ? wp_get_post_revision( $revision_id ) : null;
99 + $parent = $revision ? get_post( (int) $revision->post_parent ) : null;
100 + if ( $parent instanceof WP_Post && current_user_can( 'edit_post', $parent->ID ) ) {
101 + $identity = array(
102 + 'type' => 'revisions',
103 + 'id' => (int) $parent->ID,
104 + /* translators: %s: post title. */
105 + 'label' => sprintf( __( 'Revisions of %s', 'desktop-mode' ), get_the_title( $parent ) ),
106 + 'root' => array(
107 + 'type' => sanitize_key( $parent->post_type ),
108 + 'id' => (int) $parent->ID,
109 + ),
110 + );
111 + }
75 112 } elseif ( $screen && 'post' === $screen->base && 'add' !== $screen->action ) {
76 113 $post = get_post();
77 114 if ( $post instanceof WP_Post && $post->ID > 0 ) {
78 115 if ( 'attachment' === $post->post_type ) {
@@ -101,12 +138,27 @@
101 138 // media, and assigned terms. When a window showing a
102 139 // referenced object is open, the shell draws a directed
103 140 // tie toward it (mutual links collapse into one
104 141 // bidirectional arrow).
105 - $links = desktop_mode_window_links_extract_references( $post );
142 + $links = openstation_window_links_extract_references( $post );
106 143 if ( ! empty( $links ) ) {
107 144 $identity['links'] = $links;
108 145 }
146 +
147 + $preview_url = openstation_window_preview_url( $post );
148 + if ( '' !== $preview_url ) {
149 + $identity['previewUrl'] = $preview_url;
150 + }
151 +
152 + $revisions = openstation_window_revisions( $post );
153 + if ( '' !== $revisions['url'] ) {
154 + $identity['revisionsUrl'] = $revisions['url'];
155 + $identity['revisionCount'] = $revisions['count'];
156 + }
157 +
158 + // Source for the built-in related-entity items attached
159 + // after the identity filter below.
160 + $related_source_post = $post;
109 161 }
110 162 }
111 163 } elseif ( 'upload.php' === $pagenow ) {
112 164 // Media Library grid with a details modal open —
@@ -134,8 +186,30 @@
134 186 'id' => $parent_id,
135 187 );
136 188 }
137 189 }
190 + } elseif ( 'edit-comments.php' === $pagenow ) {
191 + // Comments list filtered to a single post —
192 + // `edit-comments.php?p=N`, the target the Related menu's
193 + // "Comments" item opens. One identity per post, rooted at the
194 + // post, so the comments window and its post window tie
195 + // together on the desktop. The unfiltered ALL-comments list
196 + // stays identity-less.
197 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
198 + $post_id = isset( $_GET['p'] ) ? absint( $_GET['p'] ) : 0;
199 + $post = $post_id ? get_post( $post_id ) : null;
200 + if ( $post instanceof WP_Post && 'attachment' !== $post->post_type ) {
201 + $identity = array(
202 + 'type' => 'comments',
203 + 'id' => (int) $post->ID,
204 + /* translators: %s: post title. */
205 + 'label' => sprintf( __( 'Comments on %s', 'desktop-mode' ), get_the_title( $post ) ),
206 + 'root' => array(
207 + 'type' => sanitize_key( $post->post_type ),
208 + 'id' => (int) $post->ID,
209 + ),
210 + );
211 + }
138 212 } elseif ( 'term.php' === $pagenow ) {
139 213 // Term edit screen — `term.php?taxonomy=category&tag_ID=N`.
140 214 // A term is its own root (`term/{taxonomy}`); posts assigned to
141 215 // it reference it through their identity's `links`, so an open
@@ -149,8 +223,24 @@
149 223 'id' => (int) $term->term_id,
150 224 'label' => $term->name,
151 225 );
152 226 }
227 + } elseif ( 'user-edit.php' === $pagenow || 'profile.php' === $pagenow ) {
228 + // Profile editor — `user-edit.php?user_id=N`, or `profile.php`
229 + // for your own. A person is its own root: everything that
230 + // points AT them (an order's customer, a post's author) does
231 + // so through its identity's `links`, so an open profile window
232 + // ties to whatever else on the desktop is about them.
233 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
234 + $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : get_current_user_id();
235 + $user = $user_id ? get_userdata( $user_id ) : null;
236 + if ( $user instanceof WP_User && current_user_can( 'edit_user', $user->ID ) ) {
237 + $identity = array(
238 + 'type' => 'user',
239 + 'id' => (int) $user->ID,
240 + 'label' => $user->display_name ? $user->display_name : $user->user_login,
241 + );
242 + }
153 243 }
154 244
155 245 /**
156 246 * Filters the content identity announced for the current admin screen.
@@ -160,14 +250,280 @@
160 250 * built-in detection. The shape must match the JS
161 251 * `WindowContentRef`: `type` (lowercase slug), `id` (int|string),
162 252 * optional `label`, optional `root => array( 'type', 'id' )`.
163 253 *
164 - * @since 0.9.4
254 + * @param array|null $identity Identity array, or `null` for none.
255 + * @param WP_Screen|null $screen The current screen, when available.
256 + */
257 + $identity = apply_filters( 'openstation_window_content_identity', $identity, $screen );
258 +
259 + // Related-entity navigation targets — what the title bar's
260 + // "Related" button lists. Runs AFTER the identity filter so
261 + // plugin-injected identities for custom screens get the related
262 + // filter too, and only for a resolved identity: no identity, no
263 + // related menu.
264 + return openstation_window_related_attach(
265 + $identity,
266 + isset( $related_source_post ) && $related_source_post instanceof WP_Post ? $related_source_post : null,
267 + $screen
268 + );
269 +}
270 +
271 +/**
272 + * Build the front-end preview URL for a post — the target of the
273 + * shell's "Preview" (eye) title-bar button.
274 + *
275 + * Wraps `get_preview_post_link()` with the same query args core's own
276 + * `post_preview()` passes (`preview_id` + a `post_preview_{ID}` nonce),
277 + * so `_set_preview()` swaps in the newest autosave revision on the
278 + * front end. Required for published/scheduled/private posts, whose
279 + * autosaves land in a revision; harmless for drafts, where autosave
280 + * writes the post itself and `_set_preview()` no-ops.
281 + *
282 + * Nonce freshness is bounded by save cadence: the content identity is
283 + * rebuilt on every chromeless page render AND on the editor
284 + * save-watcher's REST recompute, so a long-lived editor window always
285 + * holds a recent nonce.
286 + *
287 + * @param WP_Post $post The post being edited.
288 + * @return string Preview URL, or `''` when the post has no front-end
289 + * preview (non-viewable type, attachment, insufficient
290 + * capability) or a filter suppressed it.
291 + */
292 +function openstation_window_preview_url( $post ) {
293 + $preview_url = '';
294 +
295 + if (
296 + $post instanceof WP_Post &&
297 + $post->ID > 0 &&
298 + 'attachment' !== $post->post_type &&
299 + is_post_type_viewable( get_post_type_object( $post->post_type ) ) &&
300 + current_user_can( 'edit_post', $post->ID )
301 + ) {
302 + $link = get_preview_post_link(
303 + $post,
304 + array(
305 + 'preview_id' => $post->ID,
306 + 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post->ID ),
307 + )
308 + );
309 + if ( is_string( $link ) ) {
310 + $preview_url = $link;
311 + }
312 + }
313 +
314 + /**
315 + * Filters the front-end preview URL attached to a post-editor
316 + * content identity (the target of the shell's "Preview" eye
317 + * title-bar button).
165 318 *
166 - * @param array|null $identity Identity array, or `null` for none.
319 + * Return `''` to suppress the preview button for this post, or
320 + * rewrite the URL to point somewhere else (a headless front end,
321 + * a staging domain). Note the shell only accepts same-origin URLs;
322 + * a cross-origin rewrite hides the button.
323 + *
324 + * @param string $preview_url Preview URL, `''` when none applies.
325 + * @param WP_Post $post The post being edited.
326 + */
327 + return (string) apply_filters( 'openstation_window_preview_url', $preview_url, $post );
328 +}
329 +
330 +/**
331 + * Build the revision-browser link and revision total for a post — the
332 + * target of the window ⋯ menu's "View revisions" row, and the count it
333 + * shows beside the label.
334 + *
335 + * Core's revision browser is a whole admin screen that the block editor
336 + * can only reach by navigating the editor away from itself; in a
337 + * desktop shell it is simply another window, opened beside the editor
338 + * and tied to it by a window link. That only needs two facts, and both
339 + * are cheap enough to compute on every identity build.
340 + *
341 + * `wp_get_post_revisions()` with `fields => ids` returns the flat,
342 + * newest-first id list straight out of `get_children()` — one query, no
343 + * post hydration — and already answers `wp_revisions_enabled()` for the
344 + * post type and the `WP_POST_REVISIONS` constant. Autosaves are
345 + * included in the total, exactly as they are in Core's own revisions
346 + * meta box and in the block editor's revisions panel, so the number
347 + * beside the menu row matches the number the browser will list.
348 + *
349 + * @param WP_Post $post The post being edited.
350 + * @return array {
351 + * Revision browser descriptor. `url` is `''` when the post has no
352 + * revisions to browse (revisions disabled for the type, none
353 + * written yet, insufficient capability) or a filter suppressed it.
354 + *
355 + * @type string $url Admin URL of the revision browser.
356 + * @type int $count Total revisions the browser will list.
357 + * }
358 + */
359 +function openstation_window_revisions( $post ) {
360 + $revisions = array(
361 + 'url' => '',
362 + 'count' => 0,
363 + );
364 +
365 + if (
366 + $post instanceof WP_Post &&
367 + $post->ID > 0 &&
368 + 'attachment' !== $post->post_type &&
369 + // An auto-draft has never been saved, so it cannot have a
370 + // revision — worth its own check because the REST recompute
371 + // takes any post id and would otherwise spend a guaranteed-
372 + // empty query on one.
373 + 'auto-draft' !== $post->post_status &&
374 + post_type_supports( $post->post_type, 'revisions' ) &&
375 + current_user_can( 'edit_post', $post->ID )
376 + ) {
377 + // One query, IDs only — `wp_get_post_revisions()` checks
378 + // `wp_revisions_enabled()` BEFORE querying, so a post type with
379 + // revisions turned off costs nothing here either.
380 + $ids = wp_get_post_revisions( $post->ID, array( 'fields' => 'ids' ) );
381 + if ( ! empty( $ids ) ) {
382 + /*
383 + * `get_edit_post_link()` maps the `revision` post type onto
384 + * `revision.php?revision=%d` and runs the `edit_post` meta
385 + * cap — which for a revision maps to its parent — so it is
386 + * the capability gate as much as the URL builder, and it
387 + * stays correct if a plugin re-points the revision screen
388 + * through the `get_edit_post_link` filter.
389 + *
390 + * Raw context deliberately: this URL is JSON-encoded into
391 + * the bridge payload and ends up as an iframe `src`, where
392 + * a display-escaped `&` would arrive as a literal.
393 + */
394 + $link = get_edit_post_link( (int) reset( $ids ), 'raw' );
395 + if ( is_string( $link ) && '' !== $link ) {
396 + $revisions['url'] = $link;
397 + $revisions['count'] = count( $ids );
398 + }
399 + }
400 + }
401 +
402 + /**
403 + * Filters the revision-browser descriptor attached to a post-editor
404 + * content identity (`revisionsUrl` / `revisionCount` — the target
405 + * of the window ⋯ menu's "View revisions" row).
406 + *
407 + * Return `array( 'url' => '', 'count' => 0 )` to hide the row for
408 + * this post, or rewrite `url` to point somewhere else (a custom
409 + * diff screen, a plugin's own history UI). Note the shell only
410 + * accepts same-origin URLs; a cross-origin rewrite hides the row.
411 + *
412 + * @param array $revisions {
413 + * @type string $url Admin URL of the revision browser, `''` when none applies.
414 + * @type int $count Total revisions the browser will list.
415 + * }
416 + * @param WP_Post $post The post being edited.
417 + */
418 + $revisions = apply_filters( 'openstation_window_revisions', $revisions, $post );
419 +
420 + if ( ! is_array( $revisions ) ) {
421 + return array(
422 + 'url' => '',
423 + 'count' => 0,
424 + );
425 + }
426 +
427 + return array(
428 + 'url' => isset( $revisions['url'] ) && is_string( $revisions['url'] ) ? $revisions['url'] : '',
429 + 'count' => isset( $revisions['count'] ) ? max( 0, (int) $revisions['count'] ) : 0,
430 + );
431 +}
432 +
433 +/**
434 + * The related-entity pass: attach the `related` navigation items to a
435 + * (post-identity-filter) content identity. Shared by the page-render
436 + * builder above and the REST recompute endpoint the editor
437 + * save-watcher hits (where `$screen` is `null`). Also where the labels
438 + * become plain text: they name windows (Preview, Revisions, Related)
439 + * and are painted as text, where `get_the_title()`'s entities
440 + * (`’`) read literally.
441 + *
442 + * @internal
443 + *
444 + * @param array|null $identity Filtered identity, or `null`.
445 + * @param WP_Post|null $post The detected source post, when the
446 + * screen showed one.
447 + * @param WP_Screen|null $screen The current screen, when available.
448 + * @return array|null The identity with `related` attached (or the
449 + * input untouched when it was `null`).
450 + */
451 +function openstation_window_related_attach( $identity, $post, $screen ) {
452 + if ( ! is_array( $identity ) ) {
453 + return $identity;
454 + }
455 + if ( isset( $identity['label'] ) && is_string( $identity['label'] ) ) {
456 + $identity['label'] = openstation_plain_text_title( $identity['label'] );
457 + }
458 +
459 + $related = array();
460 + if (
461 + $post instanceof WP_Post &&
462 + // Built-ins belong to THIS post. If the identity filter
463 + // rewrote the identity to a different object (a gated post
464 + // remapped to a minimal ref, a custom root scheme), the
465 + // post's comments/terms/media must not tag along — that
466 + // would leak labels and deep links the filter deliberately
467 + // removed.
468 + isset( $identity['type'], $identity['id'] ) &&
469 + sanitize_key( $post->post_type ) === $identity['type'] &&
470 + (int) $post->ID === (int) $identity['id']
471 + ) {
472 + $related = openstation_window_related_entities_for_post( $post );
473 + }
474 + if ( isset( $identity['related'] ) && is_array( $identity['related'] ) ) {
475 + // An identity filter may ship related items with its own
476 + // identity — fold them in so they reach the related filter
477 + // (and the sanitizer) like everything else.
478 + $related = array_merge( $related, $identity['related'] );
479 + }
480 +
481 + /**
482 + * Filters the related-entity navigation items announced with the
483 + * current screen's content identity.
484 + *
485 + * Each item becomes an entry in the window's title-bar "Related"
486 + * menu; clicking it opens the target admin URL as its own
487 + * desktop window. Built-ins cover posts and pages (comments,
488 + * assigned terms, associated media, linked posts); plugins add
489 + * items for their own screens or object types here. Item shape
490 + * (mirrors the JS `RelatedEntityItem`):
491 + *
492 + * array(
493 + * 'id' => 'comments', // unique in the list
494 + * 'group' => 'comments', // section key; built-ins:
495 + * // 'comments', 'terms/{tax}',
496 + * // 'media', 'links'
497 + * 'groupLabel' => __( 'Comments' ), // optional section header
498 + * 'label' => __( 'Comments' ),
499 + * 'icon' => 'dashicons-admin-comments', // optional
500 + * 'url' => admin_url( 'edit-comments.php?p=123' ),
501 + * 'count' => 4, // optional badge
502 + * )
503 + *
504 + * Malformed entries (missing/empty `id`, `group`, `label`, or
505 + * `url`) are dropped before the payload is announced.
506 + *
507 + * Runs during the chromeless page render AND on the
508 + * `desktop-mode/v1/content-identity` REST recompute the editor
509 + * save-watcher triggers — in the REST context `$screen` is `null`.
510 + *
511 + * @param array[] $related Related-entity items.
512 + * @param array $identity The resolved content identity.
167 513 * @param WP_Screen|null $screen The current screen, when available.
168 514 */
169 - return apply_filters( 'desktop_mode_window_content_identity', $identity, $screen );
515 + $related = apply_filters( 'openstation_window_related_entities', $related, $identity, $screen );
516 + $related = openstation_window_related_entities_sanitize( $related );
517 + // The related pass is the single authority over the key — an
518 + // identity filter smuggling its own `related` would bypass the
519 + // sanitizer above.
520 + unset( $identity['related'] );
521 + if ( ! empty( $related ) ) {
522 + $identity['related'] = $related;
523 + }
524 +
525 + return $identity;
170 526 }
171 527
172 528 /**
173 529 * Resolve a post's outbound references for the identity's `links`
@@ -188,14 +544,12 @@
188 544 *
189 545 * Deduped by type:id and capped so a link-farm post can't flood the
190 546 * shell.
191 547 *
192 - * @since 0.9.4
193 - *
194 548 * @param WP_Post $post Source post.
195 549 * @return array[] Reference entries, possibly empty.
196 550 */
197 -function desktop_mode_window_links_extract_references( $post ) {
551 +function openstation_window_links_extract_references( $post ) {
198 552 $links = array();
199 553 $seen = array();
200 554 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
201 555 $key = $type . ':' . $id;
@@ -217,10 +571,10 @@
217 571 };
218 572
219 573 // 1. Internal hyperlinks → posts. Guarded: the content-graph
220 574 // extractor lives in a separate include.
221 - if ( function_exists( 'desktop_mode_content_graph_extract_internal_links' ) ) {
222 - $ids = desktop_mode_content_graph_extract_internal_links( (string) $post->post_content );
575 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
576 + $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
223 577 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
224 578 $target_id = (int) $target_id;
225 579 if ( $target_id === (int) $post->ID ) {
226 580 continue;
@@ -269,8 +623,354 @@
269 623 return $links;
270 624 }
271 625
272 626 /**
627 + * Build the built-in related-entity navigation items for a post or
628 + * page — the entries the window's title-bar "Related" menu offers:
629 + *
630 + * 1. **Comments** — one item opening the Comments screen filtered to
631 + * this post (`edit-comments.php?p={id}`), with the comment total
632 + * as a count badge. Only when the post type supports comments AND
633 + * at least one exists — an empty filtered list is a dead end.
634 + * 2. **Assigned terms** — one item per term of every public
635 + * taxonomy, opening that term's edit screen
636 + * (`term.php?taxonomy={tax}&tag_ID={id}`), grouped per taxonomy.
637 + * 3. **Media** — one item per associated attachment (featured image,
638 + * `post_parent`-attached uploads, and `wp-image-{id}` embeds —
639 + * the same three sources the reference extractor uses), opening
640 + * the Media Library grid with that item's details modal
641 + * (`upload.php?item={id}`). Core has no parent-filtered library
642 + * view, so per-item deep links are the honest navigation.
643 + * 4. **Linked posts** — one item per internal hyperlink in the
644 + * content that resolves to another post on this site (same
645 + * extractor the window ties use), opening that post's editor.
646 + * Cross-site and external hrefs don't resolve to a post id and
647 + * are excluded.
648 + *
649 + * Built-ins deliberately cover `post` and `page` only; other post
650 + * types (and non-post screens) join via the
651 + * `openstation_window_related_entities` filter.
652 + *
653 + * @param WP_Post $post Source post.
654 + * @return array[] Related-entity items, possibly empty.
655 + */
656 +function openstation_window_related_entities_for_post( $post ) {
657 + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
658 + return array();
659 + }
660 +
661 + $related = array();
662 +
663 + // 1. Comments. Count approved + awaiting moderation — the filtered
664 + // screen the item opens lists both, and the moderation queue is
665 + // the flow this jump serves most. `get_comments_number()` would
666 + // return the approved-only cached count, hiding the item exactly
667 + // when every comment is pending and disagreeing with the opened
668 + // list when counts are mixed.
669 + $comment_totals = get_comment_count( $post->ID );
670 + $comment_count = isset( $comment_totals['total_comments'] ) ? (int) $comment_totals['total_comments'] : 0;
671 + if ( post_type_supports( $post->post_type, 'comments' ) && $comment_count > 0 ) {
672 + $related[] = array(
673 + 'id' => 'comments',
674 + 'group' => 'comments',
675 + 'groupLabel' => __( 'Comments', 'desktop-mode' ),
676 + 'label' => __( 'Comments', 'desktop-mode' ),
677 + 'icon' => 'dashicons-admin-comments',
678 + 'url' => admin_url( 'edit-comments.php?p=' . $post->ID ),
679 + 'count' => $comment_count,
680 + );
681 + }
682 +
683 + // 2. Assigned terms of public taxonomies. Budgeted at 32 items
684 + // ACROSS taxonomies (not per taxonomy): the engine hard-caps the
685 + // whole `related` list at 64, and an unbudgeted term flood would
686 + // silently push the trailing groups past that cap. Worst case is
687 + // 1 comments + 32 terms + 20 media + 10 links = 63 — built-ins
688 + // can never hit the engine's truncation.
689 + $term_budget = 32;
690 + foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
691 + if ( empty( $taxonomy->public ) || $term_budget <= 0 ) {
692 + continue;
693 + }
694 + $terms = get_the_terms( $post, $taxonomy->name );
695 + if ( ! is_array( $terms ) ) {
696 + continue;
697 + }
698 + foreach ( array_slice( $terms, 0, $term_budget ) as $term ) {
699 + --$term_budget;
700 + $tax_slug = sanitize_key( $taxonomy->name );
701 + $related[] = array(
702 + 'id' => 'term-' . $tax_slug . '-' . (int) $term->term_id,
703 + 'group' => 'terms/' . $tax_slug,
704 + 'groupLabel' => (string) $taxonomy->labels->name,
705 + 'label' => $term->name,
706 + 'icon' => ! empty( $taxonomy->hierarchical ) ? 'dashicons-category' : 'dashicons-tag',
707 + 'url' => admin_url( 'term.php?taxonomy=' . rawurlencode( $taxonomy->name ) . '&tag_ID=' . (int) $term->term_id ),
708 + );
709 + }
710 + }
711 +
712 + // 3. Associated media — featured image first, then attached
713 + // uploads, then in-content embeds. Deduped and capped so a
714 + // gallery-heavy post can't turn the menu into a scroll marathon.
715 + $media_ids = array();
716 + $push_id = static function ( $media_id ) use ( &$media_ids ) {
717 + $media_id = (int) $media_id;
718 + if ( $media_id > 0 && ! in_array( $media_id, $media_ids, true ) && 'attachment' === get_post_type( $media_id ) ) {
719 + $media_ids[] = $media_id;
720 + }
721 + };
722 +
723 + $push_id( get_post_thumbnail_id( $post ) );
724 + $attached = get_children(
725 + array(
726 + 'post_parent' => $post->ID,
727 + 'post_type' => 'attachment',
728 + 'posts_per_page' => 20,
729 + 'orderby' => 'menu_order ID',
730 + 'order' => 'ASC',
731 + 'fields' => 'ids',
732 + )
733 + );
734 + foreach ( $attached as $media_id ) {
735 + $push_id( $media_id );
736 + }
737 + if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
738 + foreach ( array_unique( $matches[1] ) as $media_id ) {
739 + $push_id( $media_id );
740 + }
741 + }
742 +
743 + foreach ( array_slice( $media_ids, 0, 20 ) as $media_id ) {
744 + $label = get_the_title( $media_id );
745 + if ( '' === $label ) {
746 + $label = wp_basename( (string) get_attached_file( $media_id ) );
747 + }
748 + if ( '' === $label ) {
749 + /* translators: %d: attachment ID. */
750 + $label = sprintf( __( 'Media item %d', 'desktop-mode' ), $media_id );
751 + }
752 + $related[] = array(
753 + 'id' => 'media-' . $media_id,
754 + 'group' => 'media',
755 + 'groupLabel' => __( 'Media', 'desktop-mode' ),
756 + 'label' => $label,
757 + 'icon' => 'dashicons-admin-media',
758 + 'url' => admin_url( 'upload.php?item=' . $media_id ),
759 + );
760 + }
761 +
762 + // 4. Linked posts — internal hyperlinks resolving to another post
763 + // on this site. Guarded: the extractor lives in the content-graph
764 + // include. Capped tighter than the reference extractor (10) to
765 + // stay inside the overall 64-item engine budget.
766 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
767 + $link_ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
768 + $count = 0;
769 + foreach ( $link_ids as $target_id ) {
770 + if ( $count >= 10 ) {
771 + break;
772 + }
773 + $target_id = (int) $target_id;
774 + if ( $target_id === (int) $post->ID ) {
775 + continue;
776 + }
777 + $target_type = get_post_type( $target_id );
778 + if ( ! $target_type || 'attachment' === $target_type ) {
779 + continue;
780 + }
781 + $label = get_the_title( $target_id );
782 + if ( '' === $label ) {
783 + /* translators: %d: post ID. */
784 + $label = sprintf( __( 'Post %d', 'desktop-mode' ), $target_id );
785 + }
786 + $related[] = array(
787 + 'id' => 'link-' . $target_id,
788 + 'group' => 'links',
789 + 'groupLabel' => __( 'Linked posts', 'desktop-mode' ),
790 + 'label' => $label,
791 + 'icon' => 'dashicons-admin-links',
792 + 'url' => admin_url( 'post.php?post=' . $target_id . '&action=edit' ),
793 + );
794 + ++$count;
795 + }
796 + }
797 +
798 + return $related;
799 +}
800 +
801 +/**
802 + * Drop malformed related-entity items and whitelist their fields.
803 + *
804 + * Runs on the `openstation_window_related_entities` filter output
805 + * before the payload is announced: a plugin returning one bad entry
806 + * must not invalidate the whole identity client-side (the JS engine
807 + * validates the ref as a unit and would discard everything).
808 + *
809 + * @internal
810 + *
811 + * @param mixed $related Filter output.
812 + * @return array[] Well-formed items, reindexed.
813 + */
814 +function openstation_window_related_entities_sanitize( $related ) {
815 + if ( ! is_array( $related ) ) {
816 + return array();
817 + }
818 +
819 + $out = array();
820 + foreach ( $related as $item ) {
821 + if ( ! is_array( $item ) ) {
822 + continue;
823 + }
824 + // Plain text (see `openstation_window_related_attach()`),
825 + // decoded before the checks below so a label that was only
826 + // markup is dropped here rather than failing the whole ref.
827 + foreach ( array( 'label', 'groupLabel' ) as $text ) {
828 + if ( isset( $item[ $text ] ) && is_string( $item[ $text ] ) ) {
829 + $item[ $text ] = openstation_plain_text_title( $item[ $text ] );
830 + }
831 + }
832 + foreach ( array( 'id', 'group', 'label', 'url' ) as $required ) {
833 + // Mirror the JS engine's validation exactly (`.trim() !== ''`):
834 + // a whitespace-only value passing here would fail validateRef
835 + // client-side, which rejects the ref AS A UNIT — one bad item
836 + // would silently cost the window its whole identity. Not
837 + // `empty()`: that would also drop the legitimate string '0'.
838 + if ( ! isset( $item[ $required ] ) || ! is_string( $item[ $required ] ) || '' === trim( $item[ $required ] ) ) {
839 + continue 2;
840 + }
841 + }
842 + $entry = array(
843 + 'id' => $item['id'],
844 + 'group' => $item['group'],
845 + 'label' => $item['label'],
846 + 'url' => $item['url'],
847 + );
848 + if ( isset( $item['groupLabel'] ) && is_string( $item['groupLabel'] ) && '' !== trim( $item['groupLabel'] ) ) {
849 + $entry['groupLabel'] = $item['groupLabel'];
850 + }
851 + if ( isset( $item['icon'] ) && is_string( $item['icon'] ) && '' !== trim( $item['icon'] ) ) {
852 + $entry['icon'] = $item['icon'];
853 + }
854 + if ( isset( $item['count'] ) && is_numeric( $item['count'] ) ) {
855 + $entry['count'] = (int) $item['count'];
856 + }
857 + $out[] = $entry;
858 + }
859 +
860 + return $out;
861 +}
862 +
863 +/**
864 + * REST route: `GET /desktop-mode/v1/content-identity?post=N`.
865 + *
866 + * Recomputes a post's content identity — label, outbound `links`
867 + * references, and the `related` navigation items — outside a page
868 + * render. The chromeless bridge's editor save-watcher hits this
869 + * after every non-autosave Gutenberg save (Gutenberg saves over REST
870 + * without reloading, so the page-render announcement alone would go
871 + * stale the moment the user adds a category or an image) and
872 + * re-announces the fresh identity to the parent shell.
873 + *
874 + * Both public filters (`openstation_window_content_identity`,
875 + * `openstation_window_related_entities`) run here exactly as they
876 + * do at page render, with `$screen = null` — there is no WP_Screen
877 + * in REST context.
878 + */
879 +function openstation_register_content_identity_route() {
880 + register_rest_route(
881 + 'desktop-mode/v1',
882 + '/content-identity',
883 + array(
884 + 'methods' => 'GET',
885 + 'callback' => 'openstation_rest_content_identity',
886 + 'permission_callback' => 'openstation_rest_content_identity_permission',
887 + 'args' => array(
888 + 'post' => array(
889 + 'description' => __( 'Post ID to recompute the content identity for.', 'desktop-mode' ),
890 + 'type' => 'integer',
891 + 'required' => true,
892 + 'minimum' => 1,
893 + ),
894 + ),
895 + )
896 + );
897 +}
898 +add_action( 'rest_api_init', 'openstation_register_content_identity_route' );
899 +
900 +/**
901 + * Permission: OpenStation enabled AND the caller can edit the post —
902 + * the identity carries the post title, term names, and media labels,
903 + * which is exactly what the edit screen itself exposes.
904 + *
905 + * @param WP_REST_Request $request REST request.
906 + * @return true|WP_Error
907 + */
908 +function openstation_rest_content_identity_permission( $request ) {
909 + $enabled = openstation_rest_require_enabled();
910 + if ( true !== $enabled ) {
911 + return $enabled;
912 + }
913 + if ( ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
914 + return new WP_Error(
915 + 'rest_forbidden',
916 + __( 'You are not allowed to edit this post.', 'desktop-mode' ),
917 + array( 'status' => 403 )
918 + );
919 + }
920 + return true;
921 +}
922 +
923 +/**
924 + * REST handler — rebuild the post-editor identity the same way the
925 + * page-render builder's `post.php` branch does, filters included.
926 + *
927 + * @param WP_REST_Request $request REST request.
928 + * @return WP_REST_Response|WP_Error
929 + */
930 +function openstation_rest_content_identity( $request ) {
931 + $post = get_post( (int) $request['post'] );
932 + if ( ! $post instanceof WP_Post || 'attachment' === $post->post_type ) {
933 + return new WP_Error(
934 + 'openstation_no_identity',
935 + __( 'No content identity for this object.', 'desktop-mode' ),
936 + array( 'status' => 404 )
937 + );
938 + }
939 +
940 + $identity = array(
941 + 'type' => sanitize_key( $post->post_type ),
942 + 'id' => (int) $post->ID,
943 + 'label' => get_the_title( $post ),
944 + );
945 + $links = openstation_window_links_extract_references( $post );
946 + if ( ! empty( $links ) ) {
947 + $identity['links'] = $links;
948 + }
949 +
950 + $preview_url = openstation_window_preview_url( $post );
951 + if ( '' !== $preview_url ) {
952 + $identity['previewUrl'] = $preview_url;
953 + }
954 +
955 + // The reason this recompute exists at all, for revisions: the FIRST
956 + // save of a draft is what creates its first revision, so the "View
957 + // revisions" row can only appear after a save — and a block-editor
958 + // save never reloads the page.
959 + $revisions = openstation_window_revisions( $post );
960 + if ( '' !== $revisions['url'] ) {
961 + $identity['revisionsUrl'] = $revisions['url'];
962 + $identity['revisionCount'] = $revisions['count'];
963 + }
964 +
965 + /** This filter is documented in includes/window-links.php */
966 + $identity = apply_filters( 'openstation_window_content_identity', $identity, null );
967 + $identity = openstation_window_related_attach( $identity, $post, null );
968 +
969 + return rest_ensure_response( array( 'identity' => $identity ) );
970 +}
971 +
972 +/**
273 973 * Declare a WP-registered script handle as a window-link renderer
274 974 * provider.
275 975 *
276 976 * Mirrors the unfocus-effect / command script registration pattern:
@@ -280,9 +980,9 @@
280 980 * surfaces its renderer in OS Settings → Effects → Window links
281 981 * immediately, no F5 needed.
282 982 *
283 983 * Renderers themselves are declared JS-side via
284 - * `wp.desktop.registerWindowLinkRenderer( … )` — the mount callback
984 + * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
285 985 * and label live in the plugin's JavaScript. The built-in
286 986 * `svg-splines` is registered through the very same JS hook (see
287 987 * `src/window-links/renderers/svg-splines.ts`).
288 988 *
@@ -292,15 +992,15 @@
292 992 * add_action( 'admin_enqueue_scripts', function () {
293 993 * wp_register_script(
294 994 * 'my-plugin-link-renderer',
295 995 * plugins_url( 'js/link-renderer.js', __FILE__ ),
296 - * array( 'desktop-mode' ),
996 + * array( 'openstation' ),
297 997 * '1.0.0',
298 998 * true
299 999 * );
300 1000 * wp_enqueue_script( 'my-plugin-link-renderer' );
301 1001 * } );
302 - * desktop_mode_register_window_link_renderer_script( 'my-plugin-link-renderer' );
1002 + * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
303 1003 * ```
304 1004 *
305 1005 * For live unregistration on deactivation, the plugin's JS should set
306 1006 * `owner: 'my-plugin-link-renderer'` on each
@@ -306,32 +1006,28 @@
306 1006 * `owner: 'my-plugin-link-renderer'` on each
307 1007 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
308 1008 * until the next page reload — graceful backwards-compat.
309 1009 *
310 - * @since 0.9.4
311 - *
312 1010 * @param string $handle WP-registered script handle.
313 1011 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
314 1012 */
315 -function desktop_mode_register_window_link_renderer_script( $handle ) {
1013 +function openstation_register_window_link_renderer_script( $handle ) {
316 1014 $handle = (string) $handle;
317 1015 if ( '' === $handle ) {
318 - return desktop_mode_registration_error(
319 - 'desktop_mode_missing_handle',
1016 + return openstation_registration_error(
1017 + 'openstation_missing_handle',
320 1018 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
321 1019 );
322 1020 }
323 1021
324 - desktop_mode_window_link_renderer_script_registry( $handle, true );
1022 + openstation_window_link_renderer_script_registry( $handle, true );
325 1023
326 1024 /**
327 1025 * Fires after a window-link renderer script handle is registered.
328 1026 *
329 - * @since 0.9.4
330 - *
331 1027 * @param string $handle The registered script handle.
332 1028 */
333 - do_action( 'desktop_mode_window_link_renderer_script_registered', $handle );
1029 + do_action( 'openstation_window_link_renderer_script_registered', $handle );
334 1030
335 1031 return true;
336 1032 }
337 1033
@@ -337,9 +1033,8 @@
337 1033
338 1034 /**
339 1035 * Internal module-level registry for window-link renderer script handles.
340 1036 *
341 - * @since 0.9.4
342 1037 * @internal
343 1038 *
344 1039 * @param string $handle Script handle to read or write.
345 1040 * @param bool|null $value Pass `true` to register; `null` to read only.
@@ -344,9 +1039,9 @@
344 1039 * @param string $handle Script handle to read or write.
345 1040 * @param bool|null $value Pass `true` to register; `null` to read only.
346 1041 * @return array|bool When called with no args returns the full store.
347 1042 */
348 -function desktop_mode_window_link_renderer_script_registry( $handle = '', $value = null ) {
1043 +function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
349 1044 static $store = array();
350 1045
351 1046 if ( '__flush__' === (string) $handle ) {
352 1047 $store = array();
@@ -362,14 +1057,12 @@
362 1057 }
363 1058
364 1059 /**
365 1060 * Test-only: clear the registry between PHPUnit cases. See
366 - * {@see desktop_mode_flush_script_handle_registries()}.
367 - *
368 - * @since 0.9.4
1061 + * {@see openstation_flush_script_handle_registries()}.
369 1062 */
370 -function desktop_mode_flush_window_link_renderer_script_registry() {
371 - desktop_mode_window_link_renderer_script_registry( '__flush__' );
1063 +function openstation_flush_window_link_renderer_script_registry() {
1064 + openstation_window_link_renderer_script_registry( '__flush__' );
372 1065 }
373 1066
374 1067 /**
375 1068 * Build the script-handle payload fed to the shell. Handles that
@@ -374,14 +1067,12 @@
374 1067 /**
375 1068 * Build the script-handle payload fed to the shell. Handles that
376 1069 * aren't currently enqueued resolve to an empty URL and are dropped.
377 1070 *
378 - * @since 0.9.4
379 - *
380 1071 * @return array[] List of `{ handle, scriptUrl, … }` entries.
381 1072 */
382 -function desktop_mode_build_window_link_renderer_scripts_payload() {
383 - $registry = desktop_mode_window_link_renderer_script_registry();
1073 +function openstation_build_window_link_renderer_scripts_payload() {
1074 + $registry = openstation_window_link_renderer_script_registry();
384 1075 if ( ! is_array( $registry ) || empty( $registry ) ) {
385 1076 return array();
386 1077 }
387 1078
@@ -390,15 +1081,15 @@
390 1081 foreach ( $registry as $handle => $active ) {
391 1082 if ( ! $active || isset( $seen[ $handle ] ) ) {
392 1083 continue;
393 1084 }
394 - $payload = desktop_mode_resolve_script_payload( $handle );
1085 + $payload = openstation_resolve_script_payload( $handle );
395 1086 if ( '' === $payload['url'] ) {
396 1087 // Loud diagnostic — visible under WP_DEBUG. Deduped by
397 - // `desktop_mode_warn_unresolvable_script_handle` so the
1088 + // `openstation_warn_unresolvable_script_handle` so the
398 1089 // notice fires once per handle per request.
399 - desktop_mode_warn_unresolvable_script_handle(
400 - 'desktop_mode_register_window_link_renderer_script',
1090 + openstation_warn_unresolvable_script_handle(
1091 + 'openstation_register_window_link_renderer_script',
401 1092 'Window-link renderer',
402 1093 (string) $handle
403 1094 );
404 1095 continue;
@@ -409,8 +1100,11 @@
409 1100 'scriptBefore' => $payload['before'],
410 1101 'scriptAfter' => $payload['after'],
411 1102 'scriptL10n' => $payload['l10n'],
412 1103 'scriptTranslations' => $payload['translations'],
1104 + // The handle's dependency closure, replayed before the bundle
1105 + // on its lazy load — see `openstation_resolve_script_dependencies()`.
1106 + 'scriptDeps' => openstation_resolve_script_dependencies( $handle ),
413 1107 );
414 1108 $seen[ $handle ] = true;
415 1109 }
416 1110 return $out;