PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 0.9.8, at includes/window-links.php

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