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

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

1,095 lines 41.5 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( $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`).
438 *
439 * @internal
440 *
441 * @param array|null $identity Filtered identity, or `null`.
442 * @param WP_Post|null $post The detected source post, when the
443 * screen showed one.
444 * @param WP_Screen|null $screen The current screen, when available.
445 * @return array|null The identity with `related` attached (or the
446 * input untouched when it was `null`).
447 */
448 function openstation_window_related_attach( $identity, $post, $screen ) {
449 if ( ! is_array( $identity ) ) {
450 return $identity;
451 }
452
453 $related = array();
454 if (
455 $post instanceof WP_Post &&
456 // Built-ins belong to THIS post. If the identity filter
457 // rewrote the identity to a different object (a gated post
458 // remapped to a minimal ref, a custom root scheme), the
459 // post's comments/terms/media must not tag along — that
460 // would leak labels and deep links the filter deliberately
461 // removed.
462 isset( $identity['type'], $identity['id'] ) &&
463 sanitize_key( $post->post_type ) === $identity['type'] &&
464 (int) $post->ID === (int) $identity['id']
465 ) {
466 $related = openstation_window_related_entities_for_post( $post );
467 }
468 if ( isset( $identity['related'] ) && is_array( $identity['related'] ) ) {
469 // An identity filter may ship related items with its own
470 // identity — fold them in so they reach the related filter
471 // (and the sanitizer) like everything else.
472 $related = array_merge( $related, $identity['related'] );
473 }
474
475 /**
476 * Filters the related-entity navigation items announced with the
477 * current screen's content identity.
478 *
479 * Each item becomes an entry in the window's title-bar "Related"
480 * menu; clicking it opens the target admin URL as its own
481 * desktop window. Built-ins cover posts and pages (comments,
482 * assigned terms, associated media, linked posts); plugins add
483 * items for their own screens or object types here. Item shape
484 * (mirrors the JS `RelatedEntityItem`):
485 *
486 * array(
487 * 'id' => 'comments', // unique in the list
488 * 'group' => 'comments', // section key; built-ins:
489 * // 'comments', 'terms/{tax}',
490 * // 'media', 'links'
491 * 'groupLabel' => __( 'Comments' ), // optional section header
492 * 'label' => __( 'Comments' ),
493 * 'icon' => 'dashicons-admin-comments', // optional
494 * 'url' => admin_url( 'edit-comments.php?p=123' ),
495 * 'count' => 4, // optional badge
496 * )
497 *
498 * Malformed entries (missing/empty `id`, `group`, `label`, or
499 * `url`) are dropped before the payload is announced.
500 *
501 * Runs during the chromeless page render AND on the
502 * `desktop-mode/v1/content-identity` REST recompute the editor
503 * save-watcher triggers — in the REST context `$screen` is `null`.
504 *
505 * @param array[] $related Related-entity items.
506 * @param array $identity The resolved content identity.
507 * @param WP_Screen|null $screen The current screen, when available.
508 */
509 $related = apply_filters( 'openstation_window_related_entities', $related, $identity, $screen );
510 $related = openstation_window_related_entities_sanitize( $related );
511 // The related pass is the single authority over the key — an
512 // identity filter smuggling its own `related` would bypass the
513 // sanitizer above.
514 unset( $identity['related'] );
515 if ( ! empty( $related ) ) {
516 $identity['related'] = $related;
517 }
518
519 return $identity;
520 }
521
522 /**
523 * Resolve a post's outbound references for the identity's `links`
524 * array — everything this post's window should tie to when a window
525 * showing it is open:
526 *
527 * 1. Internal hyperlinks in `post_content` that resolve to another
528 * post (via the content-graph extractor). Attachment pages and
529 * self-links are skipped.
530 * 2. Media EMBEDDED in the content, harvested from the
531 * `wp-image-{id}` class both the block and classic editors stamp
532 * on inserted images. Deliberate: inserting an existing library
533 * image does NOT set `post_parent` (only uploading while editing
534 * attaches), so parent-based linking alone misses most in-content
535 * media.
536 * 3. Assigned terms of every public taxonomy, as `term/{taxonomy}`
537 * refs — ties the post to open category/tag windows.
538 *
539 * Deduped by type:id and capped so a link-farm post can't flood the
540 * shell.
541 *
542 * @param WP_Post $post Source post.
543 * @return array[] Reference entries, possibly empty.
544 */
545 function openstation_window_links_extract_references( $post ) {
546 $links = array();
547 $seen = array();
548 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
549 $key = $type . ':' . $id;
550 if ( isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
551 return;
552 }
553 $seen[ $key ] = true;
554 $entry = array(
555 'type' => $type,
556 'id' => (int) $id,
557 );
558 if ( 'child' === $rel ) {
559 // Arrow semantics: `child` reverses the tie — the linked
560 // object BELONGS TO this post (arrow media → post), unlike
561 // the default `references` (arrow post → target).
562 $entry['rel'] = 'child';
563 }
564 $links[] = $entry;
565 };
566
567 // 1. Internal hyperlinks → posts. Guarded: the content-graph
568 // extractor lives in a separate include.
569 if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
570 $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
571 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
572 $target_id = (int) $target_id;
573 if ( $target_id === (int) $post->ID ) {
574 continue;
575 }
576 $target_type = get_post_type( $target_id );
577 if ( ! $target_type || 'attachment' === $target_type ) {
578 continue;
579 }
580 $push( sanitize_key( $target_type ), $target_id );
581 }
582 }
583
584 // 2. Embedded media — `wp-image-{id}` classes — plus the featured
585 // image, which never appears in `post_content` at all. Declared as
586 // `child` refs: the image BELONGS TO the post, so the arrow runs
587 // media → post, matching attached media (`post_parent` roots) —
588 // the same visible relationship must never flip direction over an
589 // invisible technicality like attachment state.
590 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
591 foreach ( array_slice( array_unique( $matches[1] ), 0, 32 ) as $media_id ) {
592 $media_id = (int) $media_id;
593 if ( $media_id > 0 && 'attachment' === get_post_type( $media_id ) ) {
594 $push( 'media', $media_id, 'child' );
595 }
596 }
597 }
598 $thumbnail_id = (int) get_post_thumbnail_id( $post );
599 if ( $thumbnail_id > 0 && 'attachment' === get_post_type( $thumbnail_id ) ) {
600 $push( 'media', $thumbnail_id, 'child' );
601 }
602
603 // 3. Assigned terms of public taxonomies.
604 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
605 if ( empty( $taxonomy->public ) ) {
606 continue;
607 }
608 $terms = get_the_terms( $post, $taxonomy->name );
609 if ( ! is_array( $terms ) ) {
610 continue;
611 }
612 foreach ( array_slice( $terms, 0, 32 ) as $term ) {
613 $push( 'term/' . sanitize_key( $taxonomy->name ), (int) $term->term_id );
614 }
615 }
616
617 return $links;
618 }
619
620 /**
621 * Build the built-in related-entity navigation items for a post or
622 * page — the entries the window's title-bar "Related" menu offers:
623 *
624 * 1. **Comments** — one item opening the Comments screen filtered to
625 * this post (`edit-comments.php?p={id}`), with the comment total
626 * as a count badge. Only when the post type supports comments AND
627 * at least one exists — an empty filtered list is a dead end.
628 * 2. **Assigned terms** — one item per term of every public
629 * taxonomy, opening that term's edit screen
630 * (`term.php?taxonomy={tax}&tag_ID={id}`), grouped per taxonomy.
631 * 3. **Media** — one item per associated attachment (featured image,
632 * `post_parent`-attached uploads, and `wp-image-{id}` embeds —
633 * the same three sources the reference extractor uses), opening
634 * the Media Library grid with that item's details modal
635 * (`upload.php?item={id}`). Core has no parent-filtered library
636 * view, so per-item deep links are the honest navigation.
637 * 4. **Linked posts** — one item per internal hyperlink in the
638 * content that resolves to another post on this site (same
639 * extractor the window ties use), opening that post's editor.
640 * Cross-site and external hrefs don't resolve to a post id and
641 * are excluded.
642 *
643 * Built-ins deliberately cover `post` and `page` only; other post
644 * types (and non-post screens) join via the
645 * `openstation_window_related_entities` filter.
646 *
647 * @param WP_Post $post Source post.
648 * @return array[] Related-entity items, possibly empty.
649 */
650 function openstation_window_related_entities_for_post( $post ) {
651 if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
652 return array();
653 }
654
655 $related = array();
656
657 // 1. Comments. Count approved + awaiting moderation — the filtered
658 // screen the item opens lists both, and the moderation queue is
659 // the flow this jump serves most. `get_comments_number()` would
660 // return the approved-only cached count, hiding the item exactly
661 // when every comment is pending and disagreeing with the opened
662 // list when counts are mixed.
663 $comment_totals = get_comment_count( $post->ID );
664 $comment_count = isset( $comment_totals['total_comments'] ) ? (int) $comment_totals['total_comments'] : 0;
665 if ( post_type_supports( $post->post_type, 'comments' ) && $comment_count > 0 ) {
666 $related[] = array(
667 'id' => 'comments',
668 'group' => 'comments',
669 'groupLabel' => __( 'Comments', 'desktop-mode' ),
670 'label' => __( 'Comments', 'desktop-mode' ),
671 'icon' => 'dashicons-admin-comments',
672 'url' => admin_url( 'edit-comments.php?p=' . $post->ID ),
673 'count' => $comment_count,
674 );
675 }
676
677 // 2. Assigned terms of public taxonomies. Budgeted at 32 items
678 // ACROSS taxonomies (not per taxonomy): the engine hard-caps the
679 // whole `related` list at 64, and an unbudgeted term flood would
680 // silently push the trailing groups past that cap. Worst case is
681 // 1 comments + 32 terms + 20 media + 10 links = 63 — built-ins
682 // can never hit the engine's truncation.
683 $term_budget = 32;
684 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
685 if ( empty( $taxonomy->public ) || $term_budget <= 0 ) {
686 continue;
687 }
688 $terms = get_the_terms( $post, $taxonomy->name );
689 if ( ! is_array( $terms ) ) {
690 continue;
691 }
692 foreach ( array_slice( $terms, 0, $term_budget ) as $term ) {
693 --$term_budget;
694 $tax_slug = sanitize_key( $taxonomy->name );
695 $related[] = array(
696 'id' => 'term-' . $tax_slug . '-' . (int) $term->term_id,
697 'group' => 'terms/' . $tax_slug,
698 'groupLabel' => (string) $taxonomy->labels->name,
699 'label' => $term->name,
700 'icon' => ! empty( $taxonomy->hierarchical ) ? 'dashicons-category' : 'dashicons-tag',
701 'url' => admin_url( 'term.php?taxonomy=' . rawurlencode( $taxonomy->name ) . '&tag_ID=' . (int) $term->term_id ),
702 );
703 }
704 }
705
706 // 3. Associated media — featured image first, then attached
707 // uploads, then in-content embeds. Deduped and capped so a
708 // gallery-heavy post can't turn the menu into a scroll marathon.
709 $media_ids = array();
710 $push_id = static function ( $media_id ) use ( &$media_ids ) {
711 $media_id = (int) $media_id;
712 if ( $media_id > 0 && ! in_array( $media_id, $media_ids, true ) && 'attachment' === get_post_type( $media_id ) ) {
713 $media_ids[] = $media_id;
714 }
715 };
716
717 $push_id( get_post_thumbnail_id( $post ) );
718 $attached = get_children(
719 array(
720 'post_parent' => $post->ID,
721 'post_type' => 'attachment',
722 'posts_per_page' => 20,
723 'orderby' => 'menu_order ID',
724 'order' => 'ASC',
725 'fields' => 'ids',
726 )
727 );
728 foreach ( $attached as $media_id ) {
729 $push_id( $media_id );
730 }
731 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
732 foreach ( array_unique( $matches[1] ) as $media_id ) {
733 $push_id( $media_id );
734 }
735 }
736
737 foreach ( array_slice( $media_ids, 0, 20 ) as $media_id ) {
738 $label = get_the_title( $media_id );
739 if ( '' === $label ) {
740 $label = wp_basename( (string) get_attached_file( $media_id ) );
741 }
742 if ( '' === $label ) {
743 /* translators: %d: attachment ID. */
744 $label = sprintf( __( 'Media item %d', 'desktop-mode' ), $media_id );
745 }
746 $related[] = array(
747 'id' => 'media-' . $media_id,
748 'group' => 'media',
749 'groupLabel' => __( 'Media', 'desktop-mode' ),
750 'label' => $label,
751 'icon' => 'dashicons-admin-media',
752 'url' => admin_url( 'upload.php?item=' . $media_id ),
753 );
754 }
755
756 // 4. Linked posts — internal hyperlinks resolving to another post
757 // on this site. Guarded: the extractor lives in the content-graph
758 // include. Capped tighter than the reference extractor (10) to
759 // stay inside the overall 64-item engine budget.
760 if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
761 $link_ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
762 $count = 0;
763 foreach ( $link_ids as $target_id ) {
764 if ( $count >= 10 ) {
765 break;
766 }
767 $target_id = (int) $target_id;
768 if ( $target_id === (int) $post->ID ) {
769 continue;
770 }
771 $target_type = get_post_type( $target_id );
772 if ( ! $target_type || 'attachment' === $target_type ) {
773 continue;
774 }
775 $label = get_the_title( $target_id );
776 if ( '' === $label ) {
777 /* translators: %d: post ID. */
778 $label = sprintf( __( 'Post %d', 'desktop-mode' ), $target_id );
779 }
780 $related[] = array(
781 'id' => 'link-' . $target_id,
782 'group' => 'links',
783 'groupLabel' => __( 'Linked posts', 'desktop-mode' ),
784 'label' => $label,
785 'icon' => 'dashicons-admin-links',
786 'url' => admin_url( 'post.php?post=' . $target_id . '&action=edit' ),
787 );
788 ++$count;
789 }
790 }
791
792 return $related;
793 }
794
795 /**
796 * Drop malformed related-entity items and whitelist their fields.
797 *
798 * Runs on the `openstation_window_related_entities` filter output
799 * before the payload is announced: a plugin returning one bad entry
800 * must not invalidate the whole identity client-side (the JS engine
801 * validates the ref as a unit and would discard everything).
802 *
803 * @internal
804 *
805 * @param mixed $related Filter output.
806 * @return array[] Well-formed items, reindexed.
807 */
808 function openstation_window_related_entities_sanitize( $related ) {
809 if ( ! is_array( $related ) ) {
810 return array();
811 }
812
813 $out = array();
814 foreach ( $related as $item ) {
815 if ( ! is_array( $item ) ) {
816 continue;
817 }
818 foreach ( array( 'id', 'group', 'label', 'url' ) as $required ) {
819 // Mirror the JS engine's validation exactly (`.trim() !== ''`):
820 // a whitespace-only value passing here would fail validateRef
821 // client-side, which rejects the ref AS A UNIT — one bad item
822 // would silently cost the window its whole identity. Not
823 // `empty()`: that would also drop the legitimate string '0'.
824 if ( ! isset( $item[ $required ] ) || ! is_string( $item[ $required ] ) || '' === trim( $item[ $required ] ) ) {
825 continue 2;
826 }
827 }
828 $entry = array(
829 'id' => $item['id'],
830 'group' => $item['group'],
831 'label' => $item['label'],
832 'url' => $item['url'],
833 );
834 if ( isset( $item['groupLabel'] ) && is_string( $item['groupLabel'] ) && '' !== trim( $item['groupLabel'] ) ) {
835 $entry['groupLabel'] = $item['groupLabel'];
836 }
837 if ( isset( $item['icon'] ) && is_string( $item['icon'] ) && '' !== trim( $item['icon'] ) ) {
838 $entry['icon'] = $item['icon'];
839 }
840 if ( isset( $item['count'] ) && is_numeric( $item['count'] ) ) {
841 $entry['count'] = (int) $item['count'];
842 }
843 $out[] = $entry;
844 }
845
846 return $out;
847 }
848
849 /**
850 * REST route: `GET /desktop-mode/v1/content-identity?post=N`.
851 *
852 * Recomputes a post's content identity — label, outbound `links`
853 * references, and the `related` navigation items — outside a page
854 * render. The chromeless bridge's editor save-watcher hits this
855 * after every non-autosave Gutenberg save (Gutenberg saves over REST
856 * without reloading, so the page-render announcement alone would go
857 * stale the moment the user adds a category or an image) and
858 * re-announces the fresh identity to the parent shell.
859 *
860 * Both public filters (`openstation_window_content_identity`,
861 * `openstation_window_related_entities`) run here exactly as they
862 * do at page render, with `$screen = null` — there is no WP_Screen
863 * in REST context.
864 */
865 function openstation_register_content_identity_route() {
866 register_rest_route(
867 'desktop-mode/v1',
868 '/content-identity',
869 array(
870 'methods' => 'GET',
871 'callback' => 'openstation_rest_content_identity',
872 'permission_callback' => 'openstation_rest_content_identity_permission',
873 'args' => array(
874 'post' => array(
875 'description' => __( 'Post ID to recompute the content identity for.', 'desktop-mode' ),
876 'type' => 'integer',
877 'required' => true,
878 'minimum' => 1,
879 ),
880 ),
881 )
882 );
883 }
884 add_action( 'rest_api_init', 'openstation_register_content_identity_route' );
885
886 /**
887 * Permission: OpenStation enabled AND the caller can edit the post —
888 * the identity carries the post title, term names, and media labels,
889 * which is exactly what the edit screen itself exposes.
890 *
891 * @param WP_REST_Request $request REST request.
892 * @return true|WP_Error
893 */
894 function openstation_rest_content_identity_permission( $request ) {
895 $enabled = openstation_rest_require_enabled();
896 if ( true !== $enabled ) {
897 return $enabled;
898 }
899 if ( ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
900 return new WP_Error(
901 'rest_forbidden',
902 __( 'You are not allowed to edit this post.', 'desktop-mode' ),
903 array( 'status' => 403 )
904 );
905 }
906 return true;
907 }
908
909 /**
910 * REST handler — rebuild the post-editor identity the same way the
911 * page-render builder's `post.php` branch does, filters included.
912 *
913 * @param WP_REST_Request $request REST request.
914 * @return WP_REST_Response|WP_Error
915 */
916 function openstation_rest_content_identity( $request ) {
917 $post = get_post( (int) $request['post'] );
918 if ( ! $post instanceof WP_Post || 'attachment' === $post->post_type ) {
919 return new WP_Error(
920 'openstation_no_identity',
921 __( 'No content identity for this object.', 'desktop-mode' ),
922 array( 'status' => 404 )
923 );
924 }
925
926 $identity = array(
927 'type' => sanitize_key( $post->post_type ),
928 'id' => (int) $post->ID,
929 'label' => get_the_title( $post ),
930 );
931 $links = openstation_window_links_extract_references( $post );
932 if ( ! empty( $links ) ) {
933 $identity['links'] = $links;
934 }
935
936 $preview_url = openstation_window_preview_url( $post );
937 if ( '' !== $preview_url ) {
938 $identity['previewUrl'] = $preview_url;
939 }
940
941 // The reason this recompute exists at all, for revisions: the FIRST
942 // save of a draft is what creates its first revision, so the "View
943 // revisions" row can only appear after a save — and a block-editor
944 // save never reloads the page.
945 $revisions = openstation_window_revisions( $post );
946 if ( '' !== $revisions['url'] ) {
947 $identity['revisionsUrl'] = $revisions['url'];
948 $identity['revisionCount'] = $revisions['count'];
949 }
950
951 /** This filter is documented in includes/window-links.php */
952 $identity = apply_filters( 'openstation_window_content_identity', $identity, null );
953 $identity = openstation_window_related_attach( $identity, $post, null );
954
955 return rest_ensure_response( array( 'identity' => $identity ) );
956 }
957
958 /**
959 * Declare a WP-registered script handle as a window-link renderer
960 * provider.
961 *
962 * Mirrors the unfocus-effect / command script registration pattern:
963 * minimum-ceremony PHP opt-in tells the shell which enqueued scripts
964 * contribute window-link renderers. The shell injects the script URL
965 * into the live-refresh payload so a plugin activated mid-session
966 * surfaces its renderer in OS Settings → Effects → Window links
967 * immediately, no F5 needed.
968 *
969 * Renderers themselves are declared JS-side via
970 * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
971 * and label live in the plugin's JavaScript. The built-in
972 * `svg-splines` is registered through the very same JS hook (see
973 * `src/window-links/renderers/svg-splines.ts`).
974 *
975 * Example:
976 *
977 * ```php
978 * add_action( 'admin_enqueue_scripts', function () {
979 * wp_register_script(
980 * 'my-plugin-link-renderer',
981 * plugins_url( 'js/link-renderer.js', __FILE__ ),
982 * array( 'openstation' ),
983 * '1.0.0',
984 * true
985 * );
986 * wp_enqueue_script( 'my-plugin-link-renderer' );
987 * } );
988 * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
989 * ```
990 *
991 * For live unregistration on deactivation, the plugin's JS should set
992 * `owner: 'my-plugin-link-renderer'` on each
993 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
994 * until the next page reload — graceful backwards-compat.
995 *
996 * @param string $handle WP-registered script handle.
997 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
998 */
999 function openstation_register_window_link_renderer_script( $handle ) {
1000 $handle = (string) $handle;
1001 if ( '' === $handle ) {
1002 return openstation_registration_error(
1003 'openstation_missing_handle',
1004 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
1005 );
1006 }
1007
1008 openstation_window_link_renderer_script_registry( $handle, true );
1009
1010 /**
1011 * Fires after a window-link renderer script handle is registered.
1012 *
1013 * @param string $handle The registered script handle.
1014 */
1015 do_action( 'openstation_window_link_renderer_script_registered', $handle );
1016
1017 return true;
1018 }
1019
1020 /**
1021 * Internal module-level registry for window-link renderer script handles.
1022 *
1023 * @internal
1024 *
1025 * @param string $handle Script handle to read or write.
1026 * @param bool|null $value Pass `true` to register; `null` to read only.
1027 * @return array|bool When called with no args returns the full store.
1028 */
1029 function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
1030 static $store = array();
1031
1032 if ( '__flush__' === (string) $handle ) {
1033 $store = array();
1034 return array();
1035 }
1036 if ( '' === (string) $handle ) {
1037 return $store;
1038 }
1039 if ( null !== $value ) {
1040 $store[ (string) $handle ] = (bool) $value;
1041 }
1042 return isset( $store[ (string) $handle ] ) ? $store[ (string) $handle ] : false;
1043 }
1044
1045 /**
1046 * Test-only: clear the registry between PHPUnit cases. See
1047 * {@see openstation_flush_script_handle_registries()}.
1048 */
1049 function openstation_flush_window_link_renderer_script_registry() {
1050 openstation_window_link_renderer_script_registry( '__flush__' );
1051 }
1052
1053 /**
1054 * Build the script-handle payload fed to the shell. Handles that
1055 * aren't currently enqueued resolve to an empty URL and are dropped.
1056 *
1057 * @return array[] List of `{ handle, scriptUrl, … }` entries.
1058 */
1059 function openstation_build_window_link_renderer_scripts_payload() {
1060 $registry = openstation_window_link_renderer_script_registry();
1061 if ( ! is_array( $registry ) || empty( $registry ) ) {
1062 return array();
1063 }
1064
1065 $out = array();
1066 $seen = array();
1067 foreach ( $registry as $handle => $active ) {
1068 if ( ! $active || isset( $seen[ $handle ] ) ) {
1069 continue;
1070 }
1071 $payload = openstation_resolve_script_payload( $handle );
1072 if ( '' === $payload['url'] ) {
1073 // Loud diagnostic — visible under WP_DEBUG. Deduped by
1074 // `openstation_warn_unresolvable_script_handle` so the
1075 // notice fires once per handle per request.
1076 openstation_warn_unresolvable_script_handle(
1077 'openstation_register_window_link_renderer_script',
1078 'Window-link renderer',
1079 (string) $handle
1080 );
1081 continue;
1082 }
1083 $out[] = array(
1084 'handle' => (string) $handle,
1085 'scriptUrl' => $payload['url'],
1086 'scriptBefore' => $payload['before'],
1087 'scriptAfter' => $payload['after'],
1088 'scriptL10n' => $payload['l10n'],
1089 'scriptTranslations' => $payload['translations'],
1090 );
1091 $seen[ $handle ] = true;
1092 }
1093 return $out;
1094 }
1095