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

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