PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.0
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 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / window-links.php

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

939 lines 34.7 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 * - `user-edit.php` / `profile.php` — `user`, a root identity. What
45 * points at a person (a post's author, an order's customer) does so
46 * from its own `links`.
47 *
48 * @return array|null Identity array, or `null` when none applies.
49 */
50 function openstation_build_content_identity() {
51 $identity = null;
52 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
53 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
54
55 if ( 'comment.php' === $pagenow ) {
56 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
57 $comment_id = isset( $_GET['c'] ) ? absint( $_GET['c'] ) : 0;
58 $comment = $comment_id ? get_comment( $comment_id ) : null;
59 if ( $comment ) {
60 $identity = array(
61 'type' => 'comment',
62 'id' => (int) $comment->comment_ID,
63 'label' => wp_trim_words( $comment->comment_content, 10 ),
64 );
65
66 $post_id = (int) $comment->comment_post_ID;
67 $post_type = $post_id ? get_post_type( $post_id ) : false;
68 if ( $post_type ) {
69 $identity['root'] = array(
70 'type' => sanitize_key( $post_type ),
71 'id' => $post_id,
72 );
73 }
74 }
75 } elseif ( $screen && 'post' === $screen->base && 'add' !== $screen->action ) {
76 $post = get_post();
77 if ( $post instanceof WP_Post && $post->ID > 0 ) {
78 if ( 'attachment' === $post->post_type ) {
79 $identity = array(
80 'type' => 'media',
81 'id' => (int) $post->ID,
82 'label' => get_the_title( $post ),
83 );
84
85 $parent_id = (int) $post->post_parent;
86 $parent_type = $parent_id ? get_post_type( $parent_id ) : false;
87 if ( $parent_type ) {
88 $identity['root'] = array(
89 'type' => sanitize_key( $parent_type ),
90 'id' => $parent_id,
91 );
92 }
93 } else {
94 $identity = array(
95 'type' => sanitize_key( $post->post_type ),
96 'id' => (int) $post->ID,
97 'label' => get_the_title( $post ),
98 );
99
100 // Outbound references — internal hyperlinks, embedded
101 // media, and assigned terms. When a window showing a
102 // referenced object is open, the shell draws a directed
103 // tie toward it (mutual links collapse into one
104 // bidirectional arrow).
105 $links = openstation_window_links_extract_references( $post );
106 if ( ! empty( $links ) ) {
107 $identity['links'] = $links;
108 }
109
110 $preview_url = openstation_window_preview_url( $post );
111 if ( '' !== $preview_url ) {
112 $identity['previewUrl'] = $preview_url;
113 }
114
115 // Source for the built-in related-entity items attached
116 // after the identity filter below.
117 $related_source_post = $post;
118 }
119 }
120 } elseif ( 'upload.php' === $pagenow ) {
121 // Media Library grid with a details modal open —
122 // `upload.php?item=N`. The classic attachment-edit screen
123 // (`post.php` on an attachment) is handled above; this covers
124 // the far more common grid path. Only the item present at page
125 // load is announced — the modal navigates client-side without
126 // reloading, which is fine for the primary "open this media"
127 // flow the shell produces.
128 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
129 $item_id = isset( $_GET['item'] ) ? absint( $_GET['item'] ) : 0;
130 $item = $item_id ? get_post( $item_id ) : null;
131 if ( $item instanceof WP_Post && 'attachment' === $item->post_type ) {
132 $identity = array(
133 'type' => 'media',
134 'id' => (int) $item->ID,
135 'label' => get_the_title( $item ),
136 );
137
138 $parent_id = (int) $item->post_parent;
139 $parent_type = $parent_id ? get_post_type( $parent_id ) : false;
140 if ( $parent_type ) {
141 $identity['root'] = array(
142 'type' => sanitize_key( $parent_type ),
143 'id' => $parent_id,
144 );
145 }
146 }
147 } elseif ( 'edit-comments.php' === $pagenow ) {
148 // Comments list filtered to a single post —
149 // `edit-comments.php?p=N`, the target the Related menu's
150 // "Comments" item opens. One identity per post, rooted at the
151 // post, so the comments window and its post window tie
152 // together on the desktop. The unfiltered ALL-comments list
153 // stays identity-less.
154 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
155 $post_id = isset( $_GET['p'] ) ? absint( $_GET['p'] ) : 0;
156 $post = $post_id ? get_post( $post_id ) : null;
157 if ( $post instanceof WP_Post && 'attachment' !== $post->post_type ) {
158 $identity = array(
159 'type' => 'comments',
160 'id' => (int) $post->ID,
161 /* translators: %s: post title. */
162 'label' => sprintf( __( 'Comments on %s', 'desktop-mode' ), get_the_title( $post ) ),
163 'root' => array(
164 'type' => sanitize_key( $post->post_type ),
165 'id' => (int) $post->ID,
166 ),
167 );
168 }
169 } elseif ( 'term.php' === $pagenow ) {
170 // Term edit screen — `term.php?taxonomy=category&tag_ID=N`.
171 // A term is its own root (`term/{taxonomy}`); posts assigned to
172 // it reference it through their identity's `links`, so an open
173 // post window and its category/tag window tie together.
174 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
175 $term_id = isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0;
176 $term = $term_id ? get_term( $term_id ) : null;
177 if ( $term instanceof WP_Term ) {
178 $identity = array(
179 'type' => 'term/' . sanitize_key( $term->taxonomy ),
180 'id' => (int) $term->term_id,
181 'label' => $term->name,
182 );
183 }
184 } elseif ( 'user-edit.php' === $pagenow || 'profile.php' === $pagenow ) {
185 // Profile editor — `user-edit.php?user_id=N`, or `profile.php`
186 // for your own. A person is its own root: everything that
187 // points AT them (an order's customer, a post's author) does
188 // so through its identity's `links`, so an open profile window
189 // ties to whatever else on the desktop is about them.
190 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
191 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : get_current_user_id();
192 $user = $user_id ? get_userdata( $user_id ) : null;
193 if ( $user instanceof WP_User && current_user_can( 'edit_user', $user->ID ) ) {
194 $identity = array(
195 'type' => 'user',
196 'id' => (int) $user->ID,
197 'label' => $user->display_name ? $user->display_name : $user->user_login,
198 );
199 }
200 }
201
202 /**
203 * Filters the content identity announced for the current admin screen.
204 *
205 * Plugins add identities for their own admin screens (an order
206 * editor, a form-entry viewer) or return `null` to suppress the
207 * built-in detection. The shape must match the JS
208 * `WindowContentRef`: `type` (lowercase slug), `id` (int|string),
209 * optional `label`, optional `root => array( 'type', 'id' )`.
210 *
211 * @param array|null $identity Identity array, or `null` for none.
212 * @param WP_Screen|null $screen The current screen, when available.
213 */
214 $identity = apply_filters( 'openstation_window_content_identity', $identity, $screen );
215
216 // Related-entity navigation targets — what the title bar's
217 // "Related" button lists. Runs AFTER the identity filter so
218 // plugin-injected identities for custom screens get the related
219 // filter too, and only for a resolved identity: no identity, no
220 // related menu.
221 return openstation_window_related_attach(
222 $identity,
223 isset( $related_source_post ) && $related_source_post instanceof WP_Post ? $related_source_post : null,
224 $screen
225 );
226 }
227
228 /**
229 * Build the front-end preview URL for a post — the target of the
230 * shell's "Preview" (eye) title-bar button.
231 *
232 * Wraps `get_preview_post_link()` with the same query args core's own
233 * `post_preview()` passes (`preview_id` + a `post_preview_{ID}` nonce),
234 * so `_set_preview()` swaps in the newest autosave revision on the
235 * front end. Required for published/scheduled/private posts, whose
236 * autosaves land in a revision; harmless for drafts, where autosave
237 * writes the post itself and `_set_preview()` no-ops.
238 *
239 * Nonce freshness is bounded by save cadence: the content identity is
240 * rebuilt on every chromeless page render AND on the editor
241 * save-watcher's REST recompute, so a long-lived editor window always
242 * holds a recent nonce.
243 *
244 * @param WP_Post $post The post being edited.
245 * @return string Preview URL, or `''` when the post has no front-end
246 * preview (non-viewable type, attachment, insufficient
247 * capability) or a filter suppressed it.
248 */
249 function openstation_window_preview_url( $post ) {
250 $preview_url = '';
251
252 if (
253 $post instanceof WP_Post &&
254 $post->ID > 0 &&
255 'attachment' !== $post->post_type &&
256 is_post_type_viewable( get_post_type_object( $post->post_type ) ) &&
257 current_user_can( 'edit_post', $post->ID )
258 ) {
259 $link = get_preview_post_link(
260 $post,
261 array(
262 'preview_id' => $post->ID,
263 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post->ID ),
264 )
265 );
266 if ( is_string( $link ) ) {
267 $preview_url = $link;
268 }
269 }
270
271 /**
272 * Filters the front-end preview URL attached to a post-editor
273 * content identity (the target of the shell's "Preview" eye
274 * title-bar button).
275 *
276 * Return `''` to suppress the preview button for this post, or
277 * rewrite the URL to point somewhere else (a headless front end,
278 * a staging domain). Note the shell only accepts same-origin URLs;
279 * a cross-origin rewrite hides the button.
280 *
281 * @param string $preview_url Preview URL, `''` when none applies.
282 * @param WP_Post $post The post being edited.
283 */
284 return (string) apply_filters( 'openstation_window_preview_url', $preview_url, $post );
285 }
286
287 /**
288 * The related-entity pass: attach the `related` navigation items to a
289 * (post-identity-filter) content identity. Shared by the page-render
290 * builder above and the REST recompute endpoint the editor
291 * save-watcher hits (where `$screen` is `null`).
292 *
293 * @internal
294 *
295 * @param array|null $identity Filtered identity, or `null`.
296 * @param WP_Post|null $post The detected source post, when the
297 * screen showed one.
298 * @param WP_Screen|null $screen The current screen, when available.
299 * @return array|null The identity with `related` attached (or the
300 * input untouched when it was `null`).
301 */
302 function openstation_window_related_attach( $identity, $post, $screen ) {
303 if ( ! is_array( $identity ) ) {
304 return $identity;
305 }
306
307 $related = array();
308 if (
309 $post instanceof WP_Post &&
310 // Built-ins belong to THIS post. If the identity filter
311 // rewrote the identity to a different object (a gated post
312 // remapped to a minimal ref, a custom root scheme), the
313 // post's comments/terms/media must not tag along — that
314 // would leak labels and deep links the filter deliberately
315 // removed.
316 isset( $identity['type'], $identity['id'] ) &&
317 sanitize_key( $post->post_type ) === $identity['type'] &&
318 (int) $post->ID === (int) $identity['id']
319 ) {
320 $related = openstation_window_related_entities_for_post( $post );
321 }
322 if ( isset( $identity['related'] ) && is_array( $identity['related'] ) ) {
323 // An identity filter may ship related items with its own
324 // identity — fold them in so they reach the related filter
325 // (and the sanitizer) like everything else.
326 $related = array_merge( $related, $identity['related'] );
327 }
328
329 /**
330 * Filters the related-entity navigation items announced with the
331 * current screen's content identity.
332 *
333 * Each item becomes an entry in the window's title-bar "Related"
334 * menu; clicking it opens the target admin URL as its own
335 * desktop window. Built-ins cover posts and pages (comments,
336 * assigned terms, associated media, linked posts); plugins add
337 * items for their own screens or object types here. Item shape
338 * (mirrors the JS `RelatedEntityItem`):
339 *
340 * array(
341 * 'id' => 'comments', // unique in the list
342 * 'group' => 'comments', // section key; built-ins:
343 * // 'comments', 'terms/{tax}',
344 * // 'media', 'links'
345 * 'groupLabel' => __( 'Comments' ), // optional section header
346 * 'label' => __( 'Comments' ),
347 * 'icon' => 'dashicons-admin-comments', // optional
348 * 'url' => admin_url( 'edit-comments.php?p=123' ),
349 * 'count' => 4, // optional badge
350 * )
351 *
352 * Malformed entries (missing/empty `id`, `group`, `label`, or
353 * `url`) are dropped before the payload is announced.
354 *
355 * Runs during the chromeless page render AND on the
356 * `desktop-mode/v1/content-identity` REST recompute the editor
357 * save-watcher triggers — in the REST context `$screen` is `null`.
358 *
359 * @param array[] $related Related-entity items.
360 * @param array $identity The resolved content identity.
361 * @param WP_Screen|null $screen The current screen, when available.
362 */
363 $related = apply_filters( 'openstation_window_related_entities', $related, $identity, $screen );
364 $related = openstation_window_related_entities_sanitize( $related );
365 // The related pass is the single authority over the key — an
366 // identity filter smuggling its own `related` would bypass the
367 // sanitizer above.
368 unset( $identity['related'] );
369 if ( ! empty( $related ) ) {
370 $identity['related'] = $related;
371 }
372
373 return $identity;
374 }
375
376 /**
377 * Resolve a post's outbound references for the identity's `links`
378 * array — everything this post's window should tie to when a window
379 * showing it is open:
380 *
381 * 1. Internal hyperlinks in `post_content` that resolve to another
382 * post (via the content-graph extractor). Attachment pages and
383 * self-links are skipped.
384 * 2. Media EMBEDDED in the content, harvested from the
385 * `wp-image-{id}` class both the block and classic editors stamp
386 * on inserted images. Deliberate: inserting an existing library
387 * image does NOT set `post_parent` (only uploading while editing
388 * attaches), so parent-based linking alone misses most in-content
389 * media.
390 * 3. Assigned terms of every public taxonomy, as `term/{taxonomy}`
391 * refs — ties the post to open category/tag windows.
392 *
393 * Deduped by type:id and capped so a link-farm post can't flood the
394 * shell.
395 *
396 * @param WP_Post $post Source post.
397 * @return array[] Reference entries, possibly empty.
398 */
399 function openstation_window_links_extract_references( $post ) {
400 $links = array();
401 $seen = array();
402 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
403 $key = $type . ':' . $id;
404 if ( isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
405 return;
406 }
407 $seen[ $key ] = true;
408 $entry = array(
409 'type' => $type,
410 'id' => (int) $id,
411 );
412 if ( 'child' === $rel ) {
413 // Arrow semantics: `child` reverses the tie — the linked
414 // object BELONGS TO this post (arrow media → post), unlike
415 // the default `references` (arrow post → target).
416 $entry['rel'] = 'child';
417 }
418 $links[] = $entry;
419 };
420
421 // 1. Internal hyperlinks → posts. Guarded: the content-graph
422 // extractor lives in a separate include.
423 if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
424 $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
425 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
426 $target_id = (int) $target_id;
427 if ( $target_id === (int) $post->ID ) {
428 continue;
429 }
430 $target_type = get_post_type( $target_id );
431 if ( ! $target_type || 'attachment' === $target_type ) {
432 continue;
433 }
434 $push( sanitize_key( $target_type ), $target_id );
435 }
436 }
437
438 // 2. Embedded media — `wp-image-{id}` classes — plus the featured
439 // image, which never appears in `post_content` at all. Declared as
440 // `child` refs: the image BELONGS TO the post, so the arrow runs
441 // media → post, matching attached media (`post_parent` roots) —
442 // the same visible relationship must never flip direction over an
443 // invisible technicality like attachment state.
444 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
445 foreach ( array_slice( array_unique( $matches[1] ), 0, 32 ) as $media_id ) {
446 $media_id = (int) $media_id;
447 if ( $media_id > 0 && 'attachment' === get_post_type( $media_id ) ) {
448 $push( 'media', $media_id, 'child' );
449 }
450 }
451 }
452 $thumbnail_id = (int) get_post_thumbnail_id( $post );
453 if ( $thumbnail_id > 0 && 'attachment' === get_post_type( $thumbnail_id ) ) {
454 $push( 'media', $thumbnail_id, 'child' );
455 }
456
457 // 3. Assigned terms of public taxonomies.
458 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
459 if ( empty( $taxonomy->public ) ) {
460 continue;
461 }
462 $terms = get_the_terms( $post, $taxonomy->name );
463 if ( ! is_array( $terms ) ) {
464 continue;
465 }
466 foreach ( array_slice( $terms, 0, 32 ) as $term ) {
467 $push( 'term/' . sanitize_key( $taxonomy->name ), (int) $term->term_id );
468 }
469 }
470
471 return $links;
472 }
473
474 /**
475 * Build the built-in related-entity navigation items for a post or
476 * page — the entries the window's title-bar "Related" menu offers:
477 *
478 * 1. **Comments** — one item opening the Comments screen filtered to
479 * this post (`edit-comments.php?p={id}`), with the comment total
480 * as a count badge. Only when the post type supports comments AND
481 * at least one exists — an empty filtered list is a dead end.
482 * 2. **Assigned terms** — one item per term of every public
483 * taxonomy, opening that term's edit screen
484 * (`term.php?taxonomy={tax}&tag_ID={id}`), grouped per taxonomy.
485 * 3. **Media** — one item per associated attachment (featured image,
486 * `post_parent`-attached uploads, and `wp-image-{id}` embeds —
487 * the same three sources the reference extractor uses), opening
488 * the Media Library grid with that item's details modal
489 * (`upload.php?item={id}`). Core has no parent-filtered library
490 * view, so per-item deep links are the honest navigation.
491 * 4. **Linked posts** — one item per internal hyperlink in the
492 * content that resolves to another post on this site (same
493 * extractor the window ties use), opening that post's editor.
494 * Cross-site and external hrefs don't resolve to a post id and
495 * are excluded.
496 *
497 * Built-ins deliberately cover `post` and `page` only; other post
498 * types (and non-post screens) join via the
499 * `openstation_window_related_entities` filter.
500 *
501 * @param WP_Post $post Source post.
502 * @return array[] Related-entity items, possibly empty.
503 */
504 function openstation_window_related_entities_for_post( $post ) {
505 if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
506 return array();
507 }
508
509 $related = array();
510
511 // 1. Comments. Count approved + awaiting moderation — the filtered
512 // screen the item opens lists both, and the moderation queue is
513 // the flow this jump serves most. `get_comments_number()` would
514 // return the approved-only cached count, hiding the item exactly
515 // when every comment is pending and disagreeing with the opened
516 // list when counts are mixed.
517 $comment_totals = get_comment_count( $post->ID );
518 $comment_count = isset( $comment_totals['total_comments'] ) ? (int) $comment_totals['total_comments'] : 0;
519 if ( post_type_supports( $post->post_type, 'comments' ) && $comment_count > 0 ) {
520 $related[] = array(
521 'id' => 'comments',
522 'group' => 'comments',
523 'groupLabel' => __( 'Comments', 'desktop-mode' ),
524 'label' => __( 'Comments', 'desktop-mode' ),
525 'icon' => 'dashicons-admin-comments',
526 'url' => admin_url( 'edit-comments.php?p=' . $post->ID ),
527 'count' => $comment_count,
528 );
529 }
530
531 // 2. Assigned terms of public taxonomies. Budgeted at 32 items
532 // ACROSS taxonomies (not per taxonomy): the engine hard-caps the
533 // whole `related` list at 64, and an unbudgeted term flood would
534 // silently push the trailing groups past that cap. Worst case is
535 // 1 comments + 32 terms + 20 media + 10 links = 63 — built-ins
536 // can never hit the engine's truncation.
537 $term_budget = 32;
538 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
539 if ( empty( $taxonomy->public ) || $term_budget <= 0 ) {
540 continue;
541 }
542 $terms = get_the_terms( $post, $taxonomy->name );
543 if ( ! is_array( $terms ) ) {
544 continue;
545 }
546 foreach ( array_slice( $terms, 0, $term_budget ) as $term ) {
547 --$term_budget;
548 $tax_slug = sanitize_key( $taxonomy->name );
549 $related[] = array(
550 'id' => 'term-' . $tax_slug . '-' . (int) $term->term_id,
551 'group' => 'terms/' . $tax_slug,
552 'groupLabel' => (string) $taxonomy->labels->name,
553 'label' => $term->name,
554 'icon' => ! empty( $taxonomy->hierarchical ) ? 'dashicons-category' : 'dashicons-tag',
555 'url' => admin_url( 'term.php?taxonomy=' . rawurlencode( $taxonomy->name ) . '&tag_ID=' . (int) $term->term_id ),
556 );
557 }
558 }
559
560 // 3. Associated media — featured image first, then attached
561 // uploads, then in-content embeds. Deduped and capped so a
562 // gallery-heavy post can't turn the menu into a scroll marathon.
563 $media_ids = array();
564 $push_id = static function ( $media_id ) use ( &$media_ids ) {
565 $media_id = (int) $media_id;
566 if ( $media_id > 0 && ! in_array( $media_id, $media_ids, true ) && 'attachment' === get_post_type( $media_id ) ) {
567 $media_ids[] = $media_id;
568 }
569 };
570
571 $push_id( get_post_thumbnail_id( $post ) );
572 $attached = get_children(
573 array(
574 'post_parent' => $post->ID,
575 'post_type' => 'attachment',
576 'posts_per_page' => 20,
577 'orderby' => 'menu_order ID',
578 'order' => 'ASC',
579 'fields' => 'ids',
580 )
581 );
582 foreach ( $attached as $media_id ) {
583 $push_id( $media_id );
584 }
585 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
586 foreach ( array_unique( $matches[1] ) as $media_id ) {
587 $push_id( $media_id );
588 }
589 }
590
591 foreach ( array_slice( $media_ids, 0, 20 ) as $media_id ) {
592 $label = get_the_title( $media_id );
593 if ( '' === $label ) {
594 $label = wp_basename( (string) get_attached_file( $media_id ) );
595 }
596 if ( '' === $label ) {
597 /* translators: %d: attachment ID. */
598 $label = sprintf( __( 'Media item %d', 'desktop-mode' ), $media_id );
599 }
600 $related[] = array(
601 'id' => 'media-' . $media_id,
602 'group' => 'media',
603 'groupLabel' => __( 'Media', 'desktop-mode' ),
604 'label' => $label,
605 'icon' => 'dashicons-admin-media',
606 'url' => admin_url( 'upload.php?item=' . $media_id ),
607 );
608 }
609
610 // 4. Linked posts — internal hyperlinks resolving to another post
611 // on this site. Guarded: the extractor lives in the content-graph
612 // include. Capped tighter than the reference extractor (10) to
613 // stay inside the overall 64-item engine budget.
614 if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
615 $link_ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
616 $count = 0;
617 foreach ( $link_ids as $target_id ) {
618 if ( $count >= 10 ) {
619 break;
620 }
621 $target_id = (int) $target_id;
622 if ( $target_id === (int) $post->ID ) {
623 continue;
624 }
625 $target_type = get_post_type( $target_id );
626 if ( ! $target_type || 'attachment' === $target_type ) {
627 continue;
628 }
629 $label = get_the_title( $target_id );
630 if ( '' === $label ) {
631 /* translators: %d: post ID. */
632 $label = sprintf( __( 'Post %d', 'desktop-mode' ), $target_id );
633 }
634 $related[] = array(
635 'id' => 'link-' . $target_id,
636 'group' => 'links',
637 'groupLabel' => __( 'Linked posts', 'desktop-mode' ),
638 'label' => $label,
639 'icon' => 'dashicons-admin-links',
640 'url' => admin_url( 'post.php?post=' . $target_id . '&action=edit' ),
641 );
642 ++$count;
643 }
644 }
645
646 return $related;
647 }
648
649 /**
650 * Drop malformed related-entity items and whitelist their fields.
651 *
652 * Runs on the `openstation_window_related_entities` filter output
653 * before the payload is announced: a plugin returning one bad entry
654 * must not invalidate the whole identity client-side (the JS engine
655 * validates the ref as a unit and would discard everything).
656 *
657 * @internal
658 *
659 * @param mixed $related Filter output.
660 * @return array[] Well-formed items, reindexed.
661 */
662 function openstation_window_related_entities_sanitize( $related ) {
663 if ( ! is_array( $related ) ) {
664 return array();
665 }
666
667 $out = array();
668 foreach ( $related as $item ) {
669 if ( ! is_array( $item ) ) {
670 continue;
671 }
672 foreach ( array( 'id', 'group', 'label', 'url' ) as $required ) {
673 // Mirror the JS engine's validation exactly (`.trim() !== ''`):
674 // a whitespace-only value passing here would fail validateRef
675 // client-side, which rejects the ref AS A UNIT — one bad item
676 // would silently cost the window its whole identity. Not
677 // `empty()`: that would also drop the legitimate string '0'.
678 if ( ! isset( $item[ $required ] ) || ! is_string( $item[ $required ] ) || '' === trim( $item[ $required ] ) ) {
679 continue 2;
680 }
681 }
682 $entry = array(
683 'id' => $item['id'],
684 'group' => $item['group'],
685 'label' => $item['label'],
686 'url' => $item['url'],
687 );
688 if ( isset( $item['groupLabel'] ) && is_string( $item['groupLabel'] ) && '' !== trim( $item['groupLabel'] ) ) {
689 $entry['groupLabel'] = $item['groupLabel'];
690 }
691 if ( isset( $item['icon'] ) && is_string( $item['icon'] ) && '' !== trim( $item['icon'] ) ) {
692 $entry['icon'] = $item['icon'];
693 }
694 if ( isset( $item['count'] ) && is_numeric( $item['count'] ) ) {
695 $entry['count'] = (int) $item['count'];
696 }
697 $out[] = $entry;
698 }
699
700 return $out;
701 }
702
703 /**
704 * REST route: `GET /desktop-mode/v1/content-identity?post=N`.
705 *
706 * Recomputes a post's content identity — label, outbound `links`
707 * references, and the `related` navigation items — outside a page
708 * render. The chromeless bridge's editor save-watcher hits this
709 * after every non-autosave Gutenberg save (Gutenberg saves over REST
710 * without reloading, so the page-render announcement alone would go
711 * stale the moment the user adds a category or an image) and
712 * re-announces the fresh identity to the parent shell.
713 *
714 * Both public filters (`openstation_window_content_identity`,
715 * `openstation_window_related_entities`) run here exactly as they
716 * do at page render, with `$screen = null` — there is no WP_Screen
717 * in REST context.
718 */
719 function openstation_register_content_identity_route() {
720 register_rest_route(
721 'desktop-mode/v1',
722 '/content-identity',
723 array(
724 'methods' => 'GET',
725 'callback' => 'openstation_rest_content_identity',
726 'permission_callback' => 'openstation_rest_content_identity_permission',
727 'args' => array(
728 'post' => array(
729 'description' => __( 'Post ID to recompute the content identity for.', 'desktop-mode' ),
730 'type' => 'integer',
731 'required' => true,
732 'minimum' => 1,
733 ),
734 ),
735 )
736 );
737 }
738 add_action( 'rest_api_init', 'openstation_register_content_identity_route' );
739
740 /**
741 * Permission: OpenStation enabled AND the caller can edit the post —
742 * the identity carries the post title, term names, and media labels,
743 * which is exactly what the edit screen itself exposes.
744 *
745 * @param WP_REST_Request $request REST request.
746 * @return true|WP_Error
747 */
748 function openstation_rest_content_identity_permission( $request ) {
749 $enabled = openstation_rest_require_enabled();
750 if ( true !== $enabled ) {
751 return $enabled;
752 }
753 if ( ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
754 return new WP_Error(
755 'rest_forbidden',
756 __( 'You are not allowed to edit this post.', 'desktop-mode' ),
757 array( 'status' => 403 )
758 );
759 }
760 return true;
761 }
762
763 /**
764 * REST handler — rebuild the post-editor identity the same way the
765 * page-render builder's `post.php` branch does, filters included.
766 *
767 * @param WP_REST_Request $request REST request.
768 * @return WP_REST_Response|WP_Error
769 */
770 function openstation_rest_content_identity( $request ) {
771 $post = get_post( (int) $request['post'] );
772 if ( ! $post instanceof WP_Post || 'attachment' === $post->post_type ) {
773 return new WP_Error(
774 'openstation_no_identity',
775 __( 'No content identity for this object.', 'desktop-mode' ),
776 array( 'status' => 404 )
777 );
778 }
779
780 $identity = array(
781 'type' => sanitize_key( $post->post_type ),
782 'id' => (int) $post->ID,
783 'label' => get_the_title( $post ),
784 );
785 $links = openstation_window_links_extract_references( $post );
786 if ( ! empty( $links ) ) {
787 $identity['links'] = $links;
788 }
789
790 $preview_url = openstation_window_preview_url( $post );
791 if ( '' !== $preview_url ) {
792 $identity['previewUrl'] = $preview_url;
793 }
794
795 /** This filter is documented in includes/window-links.php */
796 $identity = apply_filters( 'openstation_window_content_identity', $identity, null );
797 $identity = openstation_window_related_attach( $identity, $post, null );
798
799 return rest_ensure_response( array( 'identity' => $identity ) );
800 }
801
802 /**
803 * Declare a WP-registered script handle as a window-link renderer
804 * provider.
805 *
806 * Mirrors the unfocus-effect / command script registration pattern:
807 * minimum-ceremony PHP opt-in tells the shell which enqueued scripts
808 * contribute window-link renderers. The shell injects the script URL
809 * into the live-refresh payload so a plugin activated mid-session
810 * surfaces its renderer in OS Settings → Effects → Window links
811 * immediately, no F5 needed.
812 *
813 * Renderers themselves are declared JS-side via
814 * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
815 * and label live in the plugin's JavaScript. The built-in
816 * `svg-splines` is registered through the very same JS hook (see
817 * `src/window-links/renderers/svg-splines.ts`).
818 *
819 * Example:
820 *
821 * ```php
822 * add_action( 'admin_enqueue_scripts', function () {
823 * wp_register_script(
824 * 'my-plugin-link-renderer',
825 * plugins_url( 'js/link-renderer.js', __FILE__ ),
826 * array( 'openstation' ),
827 * '1.0.0',
828 * true
829 * );
830 * wp_enqueue_script( 'my-plugin-link-renderer' );
831 * } );
832 * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
833 * ```
834 *
835 * For live unregistration on deactivation, the plugin's JS should set
836 * `owner: 'my-plugin-link-renderer'` on each
837 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
838 * until the next page reload — graceful backwards-compat.
839 *
840 * @param string $handle WP-registered script handle.
841 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
842 */
843 function openstation_register_window_link_renderer_script( $handle ) {
844 $handle = (string) $handle;
845 if ( '' === $handle ) {
846 return openstation_registration_error(
847 'openstation_missing_handle',
848 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
849 );
850 }
851
852 openstation_window_link_renderer_script_registry( $handle, true );
853
854 /**
855 * Fires after a window-link renderer script handle is registered.
856 *
857 * @param string $handle The registered script handle.
858 */
859 do_action( 'openstation_window_link_renderer_script_registered', $handle );
860
861 return true;
862 }
863
864 /**
865 * Internal module-level registry for window-link renderer script handles.
866 *
867 * @internal
868 *
869 * @param string $handle Script handle to read or write.
870 * @param bool|null $value Pass `true` to register; `null` to read only.
871 * @return array|bool When called with no args returns the full store.
872 */
873 function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
874 static $store = array();
875
876 if ( '__flush__' === (string) $handle ) {
877 $store = array();
878 return array();
879 }
880 if ( '' === (string) $handle ) {
881 return $store;
882 }
883 if ( null !== $value ) {
884 $store[ (string) $handle ] = (bool) $value;
885 }
886 return isset( $store[ (string) $handle ] ) ? $store[ (string) $handle ] : false;
887 }
888
889 /**
890 * Test-only: clear the registry between PHPUnit cases. See
891 * {@see openstation_flush_script_handle_registries()}.
892 */
893 function openstation_flush_window_link_renderer_script_registry() {
894 openstation_window_link_renderer_script_registry( '__flush__' );
895 }
896
897 /**
898 * Build the script-handle payload fed to the shell. Handles that
899 * aren't currently enqueued resolve to an empty URL and are dropped.
900 *
901 * @return array[] List of `{ handle, scriptUrl, … }` entries.
902 */
903 function openstation_build_window_link_renderer_scripts_payload() {
904 $registry = openstation_window_link_renderer_script_registry();
905 if ( ! is_array( $registry ) || empty( $registry ) ) {
906 return array();
907 }
908
909 $out = array();
910 $seen = array();
911 foreach ( $registry as $handle => $active ) {
912 if ( ! $active || isset( $seen[ $handle ] ) ) {
913 continue;
914 }
915 $payload = openstation_resolve_script_payload( $handle );
916 if ( '' === $payload['url'] ) {
917 // Loud diagnostic — visible under WP_DEBUG. Deduped by
918 // `openstation_warn_unresolvable_script_handle` so the
919 // notice fires once per handle per request.
920 openstation_warn_unresolvable_script_handle(
921 'openstation_register_window_link_renderer_script',
922 'Window-link renderer',
923 (string) $handle
924 );
925 continue;
926 }
927 $out[] = array(
928 'handle' => (string) $handle,
929 'scriptUrl' => $payload['url'],
930 'scriptBefore' => $payload['before'],
931 'scriptAfter' => $payload['after'],
932 'scriptL10n' => $payload['l10n'],
933 'scriptTranslations' => $payload['translations'],
934 );
935 $seen[ $handle ] = true;
936 }
937 return $out;
938 }
939