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

418 lines 14.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 }
111 } elseif ( 'upload.php' === $pagenow ) {
112 // Media Library grid with a details modal open —
113 // `upload.php?item=N`. The classic attachment-edit screen
114 // (`post.php` on an attachment) is handled above; this covers
115 // the far more common grid path. Only the item present at page
116 // load is announced — the modal navigates client-side without
117 // reloading, which is fine for the primary "open this media"
118 // flow the shell produces.
119 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
120 $item_id = isset( $_GET['item'] ) ? absint( $_GET['item'] ) : 0;
121 $item = $item_id ? get_post( $item_id ) : null;
122 if ( $item instanceof WP_Post && 'attachment' === $item->post_type ) {
123 $identity = array(
124 'type' => 'media',
125 'id' => (int) $item->ID,
126 'label' => get_the_title( $item ),
127 );
128
129 $parent_id = (int) $item->post_parent;
130 $parent_type = $parent_id ? get_post_type( $parent_id ) : false;
131 if ( $parent_type ) {
132 $identity['root'] = array(
133 'type' => sanitize_key( $parent_type ),
134 'id' => $parent_id,
135 );
136 }
137 }
138 } elseif ( 'term.php' === $pagenow ) {
139 // Term edit screen — `term.php?taxonomy=category&tag_ID=N`.
140 // A term is its own root (`term/{taxonomy}`); posts assigned to
141 // it reference it through their identity's `links`, so an open
142 // post window and its category/tag window tie together.
143 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
144 $term_id = isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0;
145 $term = $term_id ? get_term( $term_id ) : null;
146 if ( $term instanceof WP_Term ) {
147 $identity = array(
148 'type' => 'term/' . sanitize_key( $term->taxonomy ),
149 'id' => (int) $term->term_id,
150 'label' => $term->name,
151 );
152 }
153 }
154
155 /**
156 * Filters the content identity announced for the current admin screen.
157 *
158 * Plugins add identities for their own admin screens (an order
159 * editor, a form-entry viewer) or return `null` to suppress the
160 * built-in detection. The shape must match the JS
161 * `WindowContentRef`: `type` (lowercase slug), `id` (int|string),
162 * optional `label`, optional `root => array( 'type', 'id' )`.
163 *
164 * @since 0.9.4
165 *
166 * @param array|null $identity Identity array, or `null` for none.
167 * @param WP_Screen|null $screen The current screen, when available.
168 */
169 return apply_filters( 'desktop_mode_window_content_identity', $identity, $screen );
170 }
171
172 /**
173 * Resolve a post's outbound references for the identity's `links`
174 * array — everything this post's window should tie to when a window
175 * showing it is open:
176 *
177 * 1. Internal hyperlinks in `post_content` that resolve to another
178 * post (via the content-graph extractor). Attachment pages and
179 * self-links are skipped.
180 * 2. Media EMBEDDED in the content, harvested from the
181 * `wp-image-{id}` class both the block and classic editors stamp
182 * on inserted images. Deliberate: inserting an existing library
183 * image does NOT set `post_parent` (only uploading while editing
184 * attaches), so parent-based linking alone misses most in-content
185 * media.
186 * 3. Assigned terms of every public taxonomy, as `term/{taxonomy}`
187 * refs — ties the post to open category/tag windows.
188 *
189 * Deduped by type:id and capped so a link-farm post can't flood the
190 * shell.
191 *
192 * @since 0.9.4
193 *
194 * @param WP_Post $post Source post.
195 * @return array[] Reference entries, possibly empty.
196 */
197 function desktop_mode_window_links_extract_references( $post ) {
198 $links = array();
199 $seen = array();
200 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
201 $key = $type . ':' . $id;
202 if ( isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
203 return;
204 }
205 $seen[ $key ] = true;
206 $entry = array(
207 'type' => $type,
208 'id' => (int) $id,
209 );
210 if ( 'child' === $rel ) {
211 // Arrow semantics: `child` reverses the tie — the linked
212 // object BELONGS TO this post (arrow media → post), unlike
213 // the default `references` (arrow post → target).
214 $entry['rel'] = 'child';
215 }
216 $links[] = $entry;
217 };
218
219 // 1. Internal hyperlinks → posts. Guarded: the content-graph
220 // extractor lives in a separate include.
221 if ( function_exists( 'desktop_mode_content_graph_extract_internal_links' ) ) {
222 $ids = desktop_mode_content_graph_extract_internal_links( (string) $post->post_content );
223 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
224 $target_id = (int) $target_id;
225 if ( $target_id === (int) $post->ID ) {
226 continue;
227 }
228 $target_type = get_post_type( $target_id );
229 if ( ! $target_type || 'attachment' === $target_type ) {
230 continue;
231 }
232 $push( sanitize_key( $target_type ), $target_id );
233 }
234 }
235
236 // 2. Embedded media — `wp-image-{id}` classes — plus the featured
237 // image, which never appears in `post_content` at all. Declared as
238 // `child` refs: the image BELONGS TO the post, so the arrow runs
239 // media → post, matching attached media (`post_parent` roots) —
240 // the same visible relationship must never flip direction over an
241 // invisible technicality like attachment state.
242 if ( preg_match_all( '/\bwp-image-(\d+)\b/', (string) $post->post_content, $matches ) ) {
243 foreach ( array_slice( array_unique( $matches[1] ), 0, 32 ) as $media_id ) {
244 $media_id = (int) $media_id;
245 if ( $media_id > 0 && 'attachment' === get_post_type( $media_id ) ) {
246 $push( 'media', $media_id, 'child' );
247 }
248 }
249 }
250 $thumbnail_id = (int) get_post_thumbnail_id( $post );
251 if ( $thumbnail_id > 0 && 'attachment' === get_post_type( $thumbnail_id ) ) {
252 $push( 'media', $thumbnail_id, 'child' );
253 }
254
255 // 3. Assigned terms of public taxonomies.
256 foreach ( get_object_taxonomies( $post, 'objects' ) as $taxonomy ) {
257 if ( empty( $taxonomy->public ) ) {
258 continue;
259 }
260 $terms = get_the_terms( $post, $taxonomy->name );
261 if ( ! is_array( $terms ) ) {
262 continue;
263 }
264 foreach ( array_slice( $terms, 0, 32 ) as $term ) {
265 $push( 'term/' . sanitize_key( $taxonomy->name ), (int) $term->term_id );
266 }
267 }
268
269 return $links;
270 }
271
272 /**
273 * Declare a WP-registered script handle as a window-link renderer
274 * provider.
275 *
276 * Mirrors the unfocus-effect / command script registration pattern:
277 * minimum-ceremony PHP opt-in tells the shell which enqueued scripts
278 * contribute window-link renderers. The shell injects the script URL
279 * into the live-refresh payload so a plugin activated mid-session
280 * surfaces its renderer in OS Settings → Effects → Window links
281 * immediately, no F5 needed.
282 *
283 * Renderers themselves are declared JS-side via
284 * `wp.desktop.registerWindowLinkRenderer( … )` — the mount callback
285 * and label live in the plugin's JavaScript. The built-in
286 * `svg-splines` is registered through the very same JS hook (see
287 * `src/window-links/renderers/svg-splines.ts`).
288 *
289 * Example:
290 *
291 * ```php
292 * add_action( 'admin_enqueue_scripts', function () {
293 * wp_register_script(
294 * 'my-plugin-link-renderer',
295 * plugins_url( 'js/link-renderer.js', __FILE__ ),
296 * array( 'desktop-mode' ),
297 * '1.0.0',
298 * true
299 * );
300 * wp_enqueue_script( 'my-plugin-link-renderer' );
301 * } );
302 * desktop_mode_register_window_link_renderer_script( 'my-plugin-link-renderer' );
303 * ```
304 *
305 * For live unregistration on deactivation, the plugin's JS should set
306 * `owner: 'my-plugin-link-renderer'` on each
307 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
308 * until the next page reload — graceful backwards-compat.
309 *
310 * @since 0.9.4
311 *
312 * @param string $handle WP-registered script handle.
313 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
314 */
315 function desktop_mode_register_window_link_renderer_script( $handle ) {
316 $handle = (string) $handle;
317 if ( '' === $handle ) {
318 return desktop_mode_registration_error(
319 'desktop_mode_missing_handle',
320 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
321 );
322 }
323
324 desktop_mode_window_link_renderer_script_registry( $handle, true );
325
326 /**
327 * Fires after a window-link renderer script handle is registered.
328 *
329 * @since 0.9.4
330 *
331 * @param string $handle The registered script handle.
332 */
333 do_action( 'desktop_mode_window_link_renderer_script_registered', $handle );
334
335 return true;
336 }
337
338 /**
339 * Internal module-level registry for window-link renderer script handles.
340 *
341 * @since 0.9.4
342 * @internal
343 *
344 * @param string $handle Script handle to read or write.
345 * @param bool|null $value Pass `true` to register; `null` to read only.
346 * @return array|bool When called with no args returns the full store.
347 */
348 function desktop_mode_window_link_renderer_script_registry( $handle = '', $value = null ) {
349 static $store = array();
350
351 if ( '__flush__' === (string) $handle ) {
352 $store = array();
353 return array();
354 }
355 if ( '' === (string) $handle ) {
356 return $store;
357 }
358 if ( null !== $value ) {
359 $store[ (string) $handle ] = (bool) $value;
360 }
361 return isset( $store[ (string) $handle ] ) ? $store[ (string) $handle ] : false;
362 }
363
364 /**
365 * Test-only: clear the registry between PHPUnit cases. See
366 * {@see desktop_mode_flush_script_handle_registries()}.
367 *
368 * @since 0.9.4
369 */
370 function desktop_mode_flush_window_link_renderer_script_registry() {
371 desktop_mode_window_link_renderer_script_registry( '__flush__' );
372 }
373
374 /**
375 * Build the script-handle payload fed to the shell. Handles that
376 * aren't currently enqueued resolve to an empty URL and are dropped.
377 *
378 * @since 0.9.4
379 *
380 * @return array[] List of `{ handle, scriptUrl, … }` entries.
381 */
382 function desktop_mode_build_window_link_renderer_scripts_payload() {
383 $registry = desktop_mode_window_link_renderer_script_registry();
384 if ( ! is_array( $registry ) || empty( $registry ) ) {
385 return array();
386 }
387
388 $out = array();
389 $seen = array();
390 foreach ( $registry as $handle => $active ) {
391 if ( ! $active || isset( $seen[ $handle ] ) ) {
392 continue;
393 }
394 $payload = desktop_mode_resolve_script_payload( $handle );
395 if ( '' === $payload['url'] ) {
396 // Loud diagnostic — visible under WP_DEBUG. Deduped by
397 // `desktop_mode_warn_unresolvable_script_handle` so the
398 // notice fires once per handle per request.
399 desktop_mode_warn_unresolvable_script_handle(
400 'desktop_mode_register_window_link_renderer_script',
401 'Window-link renderer',
402 (string) $handle
403 );
404 continue;
405 }
406 $out[] = array(
407 'handle' => (string) $handle,
408 'scriptUrl' => $payload['url'],
409 'scriptBefore' => $payload['before'],
410 'scriptAfter' => $payload['after'],
411 'scriptL10n' => $payload['l10n'],
412 'scriptTranslations' => $payload['translations'],
413 );
414 $seen[ $handle ] = true;
415 }
416 return $out;
417 }
418