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
desktop-mode / includes / window-links.php

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

1,112 lines 42.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop window content relations — server-side surface.
4 *
5 * A desktop window may carry a "content identity": the object the
6 * page inside it shows ("post 123", "comment 45 of post 123"). The
7 * shell groups windows sharing the same root object and draws visual
8 * ties between them (see `src/window-links/` and
9 * `docs/examples/window-links.md`).
10 *
11 * This file builds the authoritative identity for admin iframe pages.
12 * It runs inside the chromeless iframe request — real admin context,
13 * where `get_current_screen()` and the content globals are live — so
14 * relations the URL alone can't answer (which post a comment belongs
15 * to) resolve server-side and reach the shell via the chromeless
16 * bridge's `os-content-identity` postMessage.
17 *
18 * @package OpenStation
19 */
20
21 defined( 'ABSPATH' ) || exit;
22
23 /**
24 * Build the content identity for the current admin screen.
25 *
26 * Returns `null` when the screen shows no single identifiable object
27 * (list tables, dashboards, settings pages, `post-new.php` before the
28 * first save). Shape mirrors the JS `WindowContentRef`:
29 *
30 * array(
31 * 'type' => 'comment', // sanitize_key'd object type
32 * 'id' => 45,
33 * 'label' => 'Nice post! I especially liked…', // optional, for tooltips
34 * 'root' => array( 'type' => 'post', 'id' => 123 ), // omitted when this IS a root
35 * )
36 *
37 * Detected screens:
38 * - `post.php` (post / page / CPT edit) — a root identity.
39 * - `post.php` on an attachment (Media edit) — `media`, rooted at
40 * `post_parent` when attached.
41 * - `comment.php` (comment edit / moderation) — `comment`, rooted at
42 * the parent post. The URL alone can't answer this one; only real
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`.
53 *
54 * @return array|null Identity array, or `null` when none applies.
55 */
56 function openstation_build_content_identity() {
57 $identity = null;
58 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
59 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
60
61 if ( 'comment.php' === $pagenow ) {
62 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
63 $comment_id = isset( $_GET['c'] ) ? absint( $_GET['c'] ) : 0;
64 $comment = $comment_id ? get_comment( $comment_id ) : null;
65 if ( $comment ) {
66 $identity = array(
67 'type' => 'comment',
68 'id' => (int) $comment->comment_ID,
69 'label' => wp_trim_words( openstation_strip_all_tags( $comment->comment_content ), 10 ),
70 );
71
72 $post_id = (int) $comment->comment_post_ID;
73 $post_type = $post_id ? get_post_type( $post_id ) : false;
74 if ( $post_type ) {
75 $identity['root'] = array(
76 'type' => sanitize_key( $post_type ),
77 'id' => $post_id,
78 );
79 }
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 }
112 } elseif ( $screen && 'post' === $screen->base && 'add' !== $screen->action ) {
113 $post = get_post();
114 if ( $post instanceof WP_Post && $post->ID > 0 ) {
115 if ( 'attachment' === $post->post_type ) {
116 $identity = array(
117 'type' => 'media',
118 'id' => (int) $post->ID,
119 'label' => get_the_title( $post ),
120 );
121
122 $parent_id = (int) $post->post_parent;
123 $parent_type = $parent_id ? get_post_type( $parent_id ) : false;
124 if ( $parent_type ) {
125 $identity['root'] = array(
126 'type' => sanitize_key( $parent_type ),
127 'id' => $parent_id,
128 );
129 }
130 } else {
131 $identity = array(
132 'type' => sanitize_key( $post->post_type ),
133 'id' => (int) $post->ID,
134 'label' => get_the_title( $post ),
135 );
136
137 // Outbound references — internal hyperlinks, embedded
138 // media, and assigned terms. When a window showing a
139 // referenced object is open, the shell draws a directed
140 // tie toward it (mutual links collapse into one
141 // bidirectional arrow).
142 $links = openstation_window_links_extract_references( $post );
143 if ( ! empty( $links ) ) {
144 $identity['links'] = $links;
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;
161 }
162 }
163 } elseif ( 'upload.php' === $pagenow ) {
164 // Media Library grid with a details modal open —
165 // `upload.php?item=N`. The classic attachment-edit screen
166 // (`post.php` on an attachment) is handled above; this covers
167 // the far more common grid path. Only the item present at page
168 // load is announced — the modal navigates client-side without
169 // reloading, which is fine for the primary "open this media"
170 // flow the shell produces.
171 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
172 $item_id = isset( $_GET['item'] ) ? absint( $_GET['item'] ) : 0;
173 $item = $item_id ? get_post( $item_id ) : null;
174 if ( $item instanceof WP_Post && 'attachment' === $item->post_type ) {
175 $identity = array(
176 'type' => 'media',
177 'id' => (int) $item->ID,
178 'label' => get_the_title( $item ),
179 );
180
181 $parent_id = (int) $item->post_parent;
182 $parent_type = $parent_id ? get_post_type( $parent_id ) : false;
183 if ( $parent_type ) {
184 $identity['root'] = array(
185 'type' => sanitize_key( $parent_type ),
186 'id' => $parent_id,
187 );
188 }
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 }
212 } elseif ( 'term.php' === $pagenow ) {
213 // Term edit screen — `term.php?taxonomy=category&tag_ID=N`.
214 // A term is its own root (`term/{taxonomy}`); posts assigned to
215 // it reference it through their identity's `links`, so an open
216 // post window and its category/tag window tie together.
217 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
218 $term_id = isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0;
219 $term = $term_id ? get_term( $term_id ) : null;
220 if ( $term instanceof WP_Term ) {
221 $identity = array(
222 'type' => 'term/' . sanitize_key( $term->taxonomy ),
223 'id' => (int) $term->term_id,
224 'label' => $term->name,
225 );
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 }
243 }
244
245 /**
246 * Filters the content identity announced for the current admin screen.
247 *
248 * Plugins add identities for their own admin screens (an order
249 * editor, a form-entry viewer) or return `null` to suppress the
250 * built-in detection. The shape must match the JS
251 * `WindowContentRef`: `type` (lowercase slug), `id` (int|string),
252 * optional `label`, optional `root => array( 'type', 'id' )`.
253 *
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).
318 *
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 `&amp;` 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 * (`&#8217;`) 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.
513 * @param WP_Screen|null $screen The current screen, when available.
514 */
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;
526 }
527
528 /**
529 * Resolve a post's outbound references for the identity's `links`
530 * array — everything this post's window should tie to when a window
531 * showing it is open:
532 *
533 * 1. Internal hyperlinks in `post_content` that resolve to another
534 * post (via the content-graph extractor). Attachment pages and
535 * self-links are skipped.
536 * 2. Media EMBEDDED in the content, harvested from the
537 * `wp-image-{id}` class both the block and classic editors stamp
538 * on inserted images. Deliberate: inserting an existing library
539 * image does NOT set `post_parent` (only uploading while editing
540 * attaches), so parent-based linking alone misses most in-content
541 * media.
542 * 3. Assigned terms of every public taxonomy, as `term/{taxonomy}`
543 * refs — ties the post to open category/tag windows.
544 *
545 * Deduped by type:id and capped so a link-farm post can't flood the
546 * shell.
547 *
548 * @param WP_Post $post Source post.
549 * @return array[] Reference entries, possibly empty.
550 */
551 function openstation_window_links_extract_references( $post ) {
552 $links = array();
553 $seen = array();
554 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
555 $key = $type . ':' . $id;
556 if ( isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
557 return;
558 }
559 $seen[ $key ] = true;
560 $entry = array(
561 'type' => $type,
562 'id' => (int) $id,
563 );
564 if ( 'child' === $rel ) {
565 // Arrow semantics: `child` reverses the tie — the linked
566 // object BELONGS TO this post (arrow media → post), unlike
567 // the default `references` (arrow post → target).
568 $entry['rel'] = 'child';
569 }
570 $links[] = $entry;
571 };
572
573 // 1. Internal hyperlinks → posts. Guarded: the content-graph
574 // extractor lives in a separate include.
575 if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
576 $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
577 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
578 $target_id = (int) $target_id;
579 if ( $target_id === (int) $post->ID ) {
580 continue;
581 }
582 $target_type = get_post_type( $target_id );
583 if ( ! $target_type || 'attachment' === $target_type ) {
584 continue;
585 }
586 $push( sanitize_key( $target_type ), $target_id );
587 }
588 }
589
590 // 2. Embedded media — `wp-image-{id}` classes — plus the featured
591 // image, which never appears in `post_content` at all. Declared as
592 // `child` refs: the image BELONGS TO the post, so the arrow runs
593 // media → post, matching attached media (`post_parent` roots) —
594 // the same visible relationship must never flip direction over an
595 // invisible technicality like attachment state.
596 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
597 foreach ( array_slice( array_unique( $matches[1] ), 0, 32 ) as $media_id ) {
598 $media_id = (int) $media_id;
599 if ( $media_id > 0 && 'attachment' === get_post_type( $media_id ) ) {
600 $push( 'media', $media_id, 'child' );
601 }
602 }
603 }
604 $thumbnail_id = (int) get_post_thumbnail_id( $post );
605 if ( $thumbnail_id > 0 && 'attachment' === get_post_type( $thumbnail_id ) ) {
606 $push( 'media', $thumbnail_id, 'child' );
607 }
608
609 // 3. Assigned terms of public taxonomies.
610 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
611 if ( empty( $taxonomy->public ) ) {
612 continue;
613 }
614 $terms = get_the_terms( $post, $taxonomy->name );
615 if ( ! is_array( $terms ) ) {
616 continue;
617 }
618 foreach ( array_slice( $terms, 0, 32 ) as $term ) {
619 $push( 'term/' . sanitize_key( $taxonomy->name ), (int) $term->term_id );
620 }
621 }
622
623 return $links;
624 }
625
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 /**
973 * Declare a WP-registered script handle as a window-link renderer
974 * provider.
975 *
976 * Mirrors the unfocus-effect / command script registration pattern:
977 * minimum-ceremony PHP opt-in tells the shell which enqueued scripts
978 * contribute window-link renderers. The shell injects the script URL
979 * into the live-refresh payload so a plugin activated mid-session
980 * surfaces its renderer in OS Settings → Effects → Window links
981 * immediately, no F5 needed.
982 *
983 * Renderers themselves are declared JS-side via
984 * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
985 * and label live in the plugin's JavaScript. The built-in
986 * `svg-splines` is registered through the very same JS hook (see
987 * `src/window-links/renderers/svg-splines.ts`).
988 *
989 * Example:
990 *
991 * ```php
992 * add_action( 'admin_enqueue_scripts', function () {
993 * wp_register_script(
994 * 'my-plugin-link-renderer',
995 * plugins_url( 'js/link-renderer.js', __FILE__ ),
996 * array( 'openstation' ),
997 * '1.0.0',
998 * true
999 * );
1000 * wp_enqueue_script( 'my-plugin-link-renderer' );
1001 * } );
1002 * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
1003 * ```
1004 *
1005 * For live unregistration on deactivation, the plugin's JS should set
1006 * `owner: 'my-plugin-link-renderer'` on each
1007 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
1008 * until the next page reload — graceful backwards-compat.
1009 *
1010 * @param string $handle WP-registered script handle.
1011 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
1012 */
1013 function openstation_register_window_link_renderer_script( $handle ) {
1014 $handle = (string) $handle;
1015 if ( '' === $handle ) {
1016 return openstation_registration_error(
1017 'openstation_missing_handle',
1018 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
1019 );
1020 }
1021
1022 openstation_window_link_renderer_script_registry( $handle, true );
1023
1024 /**
1025 * Fires after a window-link renderer script handle is registered.
1026 *
1027 * @param string $handle The registered script handle.
1028 */
1029 do_action( 'openstation_window_link_renderer_script_registered', $handle );
1030
1031 return true;
1032 }
1033
1034 /**
1035 * Internal module-level registry for window-link renderer script handles.
1036 *
1037 * @internal
1038 *
1039 * @param string $handle Script handle to read or write.
1040 * @param bool|null $value Pass `true` to register; `null` to read only.
1041 * @return array|bool When called with no args returns the full store.
1042 */
1043 function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
1044 static $store = array();
1045
1046 if ( '__flush__' === (string) $handle ) {
1047 $store = array();
1048 return array();
1049 }
1050 if ( '' === (string) $handle ) {
1051 return $store;
1052 }
1053 if ( null !== $value ) {
1054 $store[ (string) $handle ] = (bool) $value;
1055 }
1056 return isset( $store[ (string) $handle ] ) ? $store[ (string) $handle ] : false;
1057 }
1058
1059 /**
1060 * Test-only: clear the registry between PHPUnit cases. See
1061 * {@see openstation_flush_script_handle_registries()}.
1062 */
1063 function openstation_flush_window_link_renderer_script_registry() {
1064 openstation_window_link_renderer_script_registry( '__flush__' );
1065 }
1066
1067 /**
1068 * Build the script-handle payload fed to the shell. Handles that
1069 * aren't currently enqueued resolve to an empty URL and are dropped.
1070 *
1071 * @return array[] List of `{ handle, scriptUrl, … }` entries.
1072 */
1073 function openstation_build_window_link_renderer_scripts_payload() {
1074 $registry = openstation_window_link_renderer_script_registry();
1075 if ( ! is_array( $registry ) || empty( $registry ) ) {
1076 return array();
1077 }
1078
1079 $out = array();
1080 $seen = array();
1081 foreach ( $registry as $handle => $active ) {
1082 if ( ! $active || isset( $seen[ $handle ] ) ) {
1083 continue;
1084 }
1085 $payload = openstation_resolve_script_payload( $handle );
1086 if ( '' === $payload['url'] ) {
1087 // Loud diagnostic — visible under WP_DEBUG. Deduped by
1088 // `openstation_warn_unresolvable_script_handle` so the
1089 // notice fires once per handle per request.
1090 openstation_warn_unresolvable_script_handle(
1091 'openstation_register_window_link_renderer_script',
1092 'Window-link renderer',
1093 (string) $handle
1094 );
1095 continue;
1096 }
1097 $out[] = array(
1098 'handle' => (string) $handle,
1099 'scriptUrl' => $payload['url'],
1100 'scriptBefore' => $payload['before'],
1101 'scriptAfter' => $payload['after'],
1102 'scriptL10n' => $payload['l10n'],
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 ),
1107 );
1108 $seen[ $handle ] = true;
1109 }
1110 return $out;
1111 }
1112