PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
← All changes | includes/window-links.php +251 -58 0.9.8 → 1.1.13 View file →
@@ -12,11 +12,11 @@
12 12 * It runs inside the chromeless iframe request — real admin context,
13 13 * where `get_current_screen()` and the content globals are live — so
14 14 * relations the URL alone can't answer (which post a comment belongs
15 15 * to) resolve server-side and reach the shell via the chromeless
16 - * bridge's `desktop-mode-content-identity` postMessage.
16 + * bridge's `os-content-identity` postMessage.
17 17 *
18 - * @package WPDesktopMode
18 + * @package OpenStation
19 19 */
20 20
21 21 defined( 'ABSPATH' ) || exit;
22 22
@@ -40,12 +40,21 @@
40 40 * `post_parent` when attached.
41 41 * - `comment.php` (comment edit / moderation) — `comment`, rooted at
42 42 * the parent post. The URL alone can't answer this one; only real
43 43 * admin context can.
44 + * - `revision.php` (the revision browser) — `revisions`, rooted at
45 + * the post whose history it shows. Keyed by the PARENT post rather
46 + * than the revision on screen, because the browser's slider walks
47 + * revisions client-side (`history.replaceState`) without a reload:
48 + * a revision-keyed identity would go stale on the first drag, and
49 + * the window is "the history of post 123" throughout anyway.
50 + * - `user-edit.php` / `profile.php` — `user`, a root identity. What
51 + * points at a person (a post's author, an order's customer) does so
52 + * from its own `links`.
44 53 *
45 54 * @return array|null Identity array, or `null` when none applies.
46 55 */
47 -function desktop_mode_build_content_identity() {
56 +function openstation_build_content_identity() {
48 57 $identity = null;
49 58 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
50 59 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
51 60
@@ -56,9 +65,9 @@
56 65 if ( $comment ) {
57 66 $identity = array(
58 67 'type' => 'comment',
59 68 'id' => (int) $comment->comment_ID,
60 - 'label' => wp_trim_words( $comment->comment_content, 10 ),
69 + 'label' => wp_trim_words( openstation_strip_all_tags( $comment->comment_content ), 10 ),
61 70 );
62 71
63 72 $post_id = (int) $comment->comment_post_ID;
64 73 $post_type = $post_id ? get_post_type( $post_id ) : false;
@@ -68,8 +77,39 @@
68 77 'id' => $post_id,
69 78 );
70 79 }
71 80 }
81 + } elseif ( 'revision.php' === $pagenow ) {
82 + // Revision browser — `revision.php?revision=N`. Core falls back
83 + // to `?to=N` when `revision` is absent (the compare-two-revisions
84 + // form of the URL), so mirror that: both forms show the same
85 + // post's history and must announce the same identity.
86 + //
87 + // The identity is keyed by the PARENT post, not by the revision
88 + // on screen — see the "Detected screens" note above — which also
89 + // means the shell can seed it at open time knowing only the post
90 + // it opened the browser for, so the tie to the editor window
91 + // draws before the iframe has finished loading.
92 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
93 + $revision_id = isset( $_GET['revision'] ) ? absint( $_GET['revision'] ) : 0;
94 + if ( ! $revision_id ) {
95 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
96 + $revision_id = isset( $_GET['to'] ) ? absint( $_GET['to'] ) : 0;
97 + }
98 + $revision = $revision_id ? wp_get_post_revision( $revision_id ) : null;
99 + $parent = $revision ? get_post( (int) $revision->post_parent ) : null;
100 + if ( $parent instanceof WP_Post && current_user_can( 'edit_post', $parent->ID ) ) {
101 + $identity = array(
102 + 'type' => 'revisions',
103 + 'id' => (int) $parent->ID,
104 + /* translators: %s: post title. */
105 + 'label' => sprintf( __( 'Revisions of %s', 'desktop-mode' ), get_the_title( $parent ) ),
106 + 'root' => array(
107 + 'type' => sanitize_key( $parent->post_type ),
108 + 'id' => (int) $parent->ID,
109 + ),
110 + );
111 + }
72 112 } elseif ( $screen && 'post' === $screen->base && 'add' !== $screen->action ) {
73 113 $post = get_post();
74 114 if ( $post instanceof WP_Post && $post->ID > 0 ) {
75 115 if ( 'attachment' === $post->post_type ) {
@@ -98,18 +138,24 @@
98 138 // media, and assigned terms. When a window showing a
99 139 // referenced object is open, the shell draws a directed
100 140 // tie toward it (mutual links collapse into one
101 141 // bidirectional arrow).
102 - $links = desktop_mode_window_links_extract_references( $post );
142 + $links = openstation_window_links_extract_references( $post );
103 143 if ( ! empty( $links ) ) {
104 144 $identity['links'] = $links;
105 145 }
106 146
107 - $preview_url = desktop_mode_window_preview_url( $post );
147 + $preview_url = openstation_window_preview_url( $post );
108 148 if ( '' !== $preview_url ) {
109 149 $identity['previewUrl'] = $preview_url;
110 150 }
111 151
152 + $revisions = openstation_window_revisions( $post );
153 + if ( '' !== $revisions['url'] ) {
154 + $identity['revisionsUrl'] = $revisions['url'];
155 + $identity['revisionCount'] = $revisions['count'];
156 + }
157 +
112 158 // Source for the built-in related-entity items attached
113 159 // after the identity filter below.
114 160 $related_source_post = $post;
115 161 }
@@ -177,8 +223,24 @@
177 223 'id' => (int) $term->term_id,
178 224 'label' => $term->name,
179 225 );
180 226 }
227 + } elseif ( 'user-edit.php' === $pagenow || 'profile.php' === $pagenow ) {
228 + // Profile editor — `user-edit.php?user_id=N`, or `profile.php`
229 + // for your own. A person is its own root: everything that
230 + // points AT them (an order's customer, a post's author) does
231 + // so through its identity's `links`, so an open profile window
232 + // ties to whatever else on the desktop is about them.
233 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
234 + $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : get_current_user_id();
235 + $user = $user_id ? get_userdata( $user_id ) : null;
236 + if ( $user instanceof WP_User && current_user_can( 'edit_user', $user->ID ) ) {
237 + $identity = array(
238 + 'type' => 'user',
239 + 'id' => (int) $user->ID,
240 + 'label' => $user->display_name ? $user->display_name : $user->user_login,
241 + );
242 + }
181 243 }
182 244
183 245 /**
184 246 * Filters the content identity announced for the current admin screen.
@@ -191,9 +253,9 @@
191 253 *
192 254 * @param array|null $identity Identity array, or `null` for none.
193 255 * @param WP_Screen|null $screen The current screen, when available.
194 256 */
195 - $identity = apply_filters( 'desktop_mode_window_content_identity', $identity, $screen );
257 + $identity = apply_filters( 'openstation_window_content_identity', $identity, $screen );
196 258
197 259 // Related-entity navigation targets — what the title bar's
198 260 // "Related" button lists. Runs AFTER the identity filter so
199 261 // plugin-injected identities for custom screens get the related
@@ -198,9 +260,9 @@
198 260 // "Related" button lists. Runs AFTER the identity filter so
199 261 // plugin-injected identities for custom screens get the related
200 262 // filter too, and only for a resolved identity: no identity, no
201 263 // related menu.
202 - return desktop_mode_window_related_attach(
264 + return openstation_window_related_attach(
203 265 $identity,
204 266 isset( $related_source_post ) && $related_source_post instanceof WP_Post ? $related_source_post : null,
205 267 $screen
206 268 );
@@ -226,9 +288,9 @@
226 288 * @return string Preview URL, or `''` when the post has no front-end
227 289 * preview (non-viewable type, attachment, insufficient
228 290 * capability) or a filter suppressed it.
229 291 */
230 -function desktop_mode_window_preview_url( $post ) {
292 +function openstation_window_preview_url( $post ) {
231 293 $preview_url = '';
232 294
233 295 if (
234 296 $post instanceof WP_Post &&
@@ -261,16 +323,122 @@
261 323 *
262 324 * @param string $preview_url Preview URL, `''` when none applies.
263 325 * @param WP_Post $post The post being edited.
264 326 */
265 - return (string) apply_filters( 'desktop_mode_window_preview_url', $preview_url, $post );
327 + return (string) apply_filters( 'openstation_window_preview_url', $preview_url, $post );
266 328 }
267 329
268 330 /**
331 + * Build the revision-browser link and revision total for a post — the
332 + * target of the window ⋯ menu's "View revisions" row, and the count it
333 + * shows beside the label.
334 + *
335 + * Core's revision browser is a whole admin screen that the block editor
336 + * can only reach by navigating the editor away from itself; in a
337 + * desktop shell it is simply another window, opened beside the editor
338 + * and tied to it by a window link. That only needs two facts, and both
339 + * are cheap enough to compute on every identity build.
340 + *
341 + * `wp_get_post_revisions()` with `fields => ids` returns the flat,
342 + * newest-first id list straight out of `get_children()` — one query, no
343 + * post hydration — and already answers `wp_revisions_enabled()` for the
344 + * post type and the `WP_POST_REVISIONS` constant. Autosaves are
345 + * included in the total, exactly as they are in Core's own revisions
346 + * meta box and in the block editor's revisions panel, so the number
347 + * beside the menu row matches the number the browser will list.
348 + *
349 + * @param WP_Post $post The post being edited.
350 + * @return array {
351 + * Revision browser descriptor. `url` is `''` when the post has no
352 + * revisions to browse (revisions disabled for the type, none
353 + * written yet, insufficient capability) or a filter suppressed it.
354 + *
355 + * @type string $url Admin URL of the revision browser.
356 + * @type int $count Total revisions the browser will list.
357 + * }
358 + */
359 +function openstation_window_revisions( $post ) {
360 + $revisions = array(
361 + 'url' => '',
362 + 'count' => 0,
363 + );
364 +
365 + if (
366 + $post instanceof WP_Post &&
367 + $post->ID > 0 &&
368 + 'attachment' !== $post->post_type &&
369 + // An auto-draft has never been saved, so it cannot have a
370 + // revision — worth its own check because the REST recompute
371 + // takes any post id and would otherwise spend a guaranteed-
372 + // empty query on one.
373 + 'auto-draft' !== $post->post_status &&
374 + post_type_supports( $post->post_type, 'revisions' ) &&
375 + current_user_can( 'edit_post', $post->ID )
376 + ) {
377 + // One query, IDs only — `wp_get_post_revisions()` checks
378 + // `wp_revisions_enabled()` BEFORE querying, so a post type with
379 + // revisions turned off costs nothing here either.
380 + $ids = wp_get_post_revisions( $post->ID, array( 'fields' => 'ids' ) );
381 + if ( ! empty( $ids ) ) {
382 + /*
383 + * `get_edit_post_link()` maps the `revision` post type onto
384 + * `revision.php?revision=%d` and runs the `edit_post` meta
385 + * cap — which for a revision maps to its parent — so it is
386 + * the capability gate as much as the URL builder, and it
387 + * stays correct if a plugin re-points the revision screen
388 + * through the `get_edit_post_link` filter.
389 + *
390 + * Raw context deliberately: this URL is JSON-encoded into
391 + * the bridge payload and ends up as an iframe `src`, where
392 + * a display-escaped `&` would arrive as a literal.
393 + */
394 + $link = get_edit_post_link( (int) reset( $ids ), 'raw' );
395 + if ( is_string( $link ) && '' !== $link ) {
396 + $revisions['url'] = $link;
397 + $revisions['count'] = count( $ids );
398 + }
399 + }
400 + }
401 +
402 + /**
403 + * Filters the revision-browser descriptor attached to a post-editor
404 + * content identity (`revisionsUrl` / `revisionCount` — the target
405 + * of the window ⋯ menu's "View revisions" row).
406 + *
407 + * Return `array( 'url' => '', 'count' => 0 )` to hide the row for
408 + * this post, or rewrite `url` to point somewhere else (a custom
409 + * diff screen, a plugin's own history UI). Note the shell only
410 + * accepts same-origin URLs; a cross-origin rewrite hides the row.
411 + *
412 + * @param array $revisions {
413 + * @type string $url Admin URL of the revision browser, `''` when none applies.
414 + * @type int $count Total revisions the browser will list.
415 + * }
416 + * @param WP_Post $post The post being edited.
417 + */
418 + $revisions = apply_filters( 'openstation_window_revisions', $revisions, $post );
419 +
420 + if ( ! is_array( $revisions ) ) {
421 + return array(
422 + 'url' => '',
423 + 'count' => 0,
424 + );
425 + }
426 +
427 + return array(
428 + 'url' => isset( $revisions['url'] ) && is_string( $revisions['url'] ) ? $revisions['url'] : '',
429 + 'count' => isset( $revisions['count'] ) ? max( 0, (int) $revisions['count'] ) : 0,
430 + );
431 +}
432 +
433 +/**
269 434 * The related-entity pass: attach the `related` navigation items to a
270 435 * (post-identity-filter) content identity. Shared by the page-render
271 436 * builder above and the REST recompute endpoint the editor
272 - * save-watcher hits (where `$screen` is `null`).
437 + * save-watcher hits (where `$screen` is `null`). Also where the labels
438 + * become plain text: they name windows (Preview, Revisions, Related)
439 + * and are painted as text, where `get_the_title()`'s entities
440 + * (`’`) read literally.
273 441 *
274 442 * @internal
275 443 *
276 444 * @param array|null $identity Filtered identity, or `null`.
@@ -279,12 +447,15 @@
279 447 * @param WP_Screen|null $screen The current screen, when available.
280 448 * @return array|null The identity with `related` attached (or the
281 449 * input untouched when it was `null`).
282 450 */
283 -function desktop_mode_window_related_attach( $identity, $post, $screen ) {
451 +function openstation_window_related_attach( $identity, $post, $screen ) {
284 452 if ( ! is_array( $identity ) ) {
285 453 return $identity;
286 454 }
455 + if ( isset( $identity['label'] ) && is_string( $identity['label'] ) ) {
456 + $identity['label'] = openstation_plain_text_title( $identity['label'] );
457 + }
287 458
288 459 $related = array();
289 460 if (
290 461 $post instanceof WP_Post &&
@@ -297,9 +468,9 @@
297 468 isset( $identity['type'], $identity['id'] ) &&
298 469 sanitize_key( $post->post_type ) === $identity['type'] &&
299 470 (int) $post->ID === (int) $identity['id']
300 471 ) {
301 - $related = desktop_mode_window_related_entities_for_post( $post );
472 + $related = openstation_window_related_entities_for_post( $post );
302 473 }
303 474 if ( isset( $identity['related'] ) && is_array( $identity['related'] ) ) {
304 475 // An identity filter may ship related items with its own
305 476 // identity — fold them in so they reach the related filter
@@ -340,10 +511,10 @@
340 511 * @param array[] $related Related-entity items.
341 512 * @param array $identity The resolved content identity.
342 513 * @param WP_Screen|null $screen The current screen, when available.
343 514 */
344 - $related = apply_filters( 'desktop_mode_window_related_entities', $related, $identity, $screen );
345 - $related = desktop_mode_window_related_entities_sanitize( $related );
515 + $related = apply_filters( 'openstation_window_related_entities', $related, $identity, $screen );
516 + $related = openstation_window_related_entities_sanitize( $related );
346 517 // The related pass is the single authority over the key — an
347 518 // identity filter smuggling its own `related` would bypass the
348 519 // sanitizer above.
349 520 unset( $identity['related'] );
@@ -376,9 +547,9 @@
376 547 *
377 548 * @param WP_Post $post Source post.
378 549 * @return array[] Reference entries, possibly empty.
379 550 */
380 -function desktop_mode_window_links_extract_references( $post ) {
551 +function openstation_window_links_extract_references( $post ) {
381 552 $links = array();
382 553 $seen = array();
383 554 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
384 555 $key = $type . ':' . $id;
@@ -400,10 +571,10 @@
400 571 };
401 572
402 573 // 1. Internal hyperlinks → posts. Guarded: the content-graph
403 574 // 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 );
575 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
576 + $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
406 577 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
407 578 $target_id = (int) $target_id;
408 579 if ( $target_id === (int) $post->ID ) {
409 580 continue;
@@ -476,14 +647,14 @@
476 647 * are excluded.
477 648 *
478 649 * Built-ins deliberately cover `post` and `page` only; other post
479 650 * types (and non-post screens) join via the
480 - * `desktop_mode_window_related_entities` filter.
651 + * `openstation_window_related_entities` filter.
481 652 *
482 653 * @param WP_Post $post Source post.
483 654 * @return array[] Related-entity items, possibly empty.
484 655 */
485 -function desktop_mode_window_related_entities_for_post( $post ) {
656 +function openstation_window_related_entities_for_post( $post ) {
486 657 if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
487 658 return array();
488 659 }
489 660
@@ -591,10 +762,10 @@
591 762 // 4. Linked posts — internal hyperlinks resolving to another post
592 763 // on this site. Guarded: the extractor lives in the content-graph
593 764 // include. Capped tighter than the reference extractor (10) to
594 765 // 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 );
766 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
767 + $link_ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
597 768 $count = 0;
598 769 foreach ( $link_ids as $target_id ) {
599 770 if ( $count >= 10 ) {
600 771 break;
@@ -629,9 +800,9 @@
629 800
630 801 /**
631 802 * Drop malformed related-entity items and whitelist their fields.
632 803 *
633 - * Runs on the `desktop_mode_window_related_entities` filter output
804 + * Runs on the `openstation_window_related_entities` filter output
634 805 * before the payload is announced: a plugin returning one bad entry
635 806 * must not invalidate the whole identity client-side (the JS engine
636 807 * validates the ref as a unit and would discard everything).
637 808 *
@@ -639,9 +810,9 @@
639 810 *
640 811 * @param mixed $related Filter output.
641 812 * @return array[] Well-formed items, reindexed.
642 813 */
643 -function desktop_mode_window_related_entities_sanitize( $related ) {
814 +function openstation_window_related_entities_sanitize( $related ) {
644 815 if ( ! is_array( $related ) ) {
645 816 return array();
646 817 }
647 818
@@ -649,8 +820,16 @@
649 820 foreach ( $related as $item ) {
650 821 if ( ! is_array( $item ) ) {
651 822 continue;
652 823 }
824 + // Plain text (see `openstation_window_related_attach()`),
825 + // decoded before the checks below so a label that was only
826 + // markup is dropped here rather than failing the whole ref.
827 + foreach ( array( 'label', 'groupLabel' ) as $text ) {
828 + if ( isset( $item[ $text ] ) && is_string( $item[ $text ] ) ) {
829 + $item[ $text ] = openstation_plain_text_title( $item[ $text ] );
830 + }
831 + }
653 832 foreach ( array( 'id', 'group', 'label', 'url' ) as $required ) {
654 833 // Mirror the JS engine's validation exactly (`.trim() !== ''`):
655 834 // a whitespace-only value passing here would fail validateRef
656 835 // client-side, which rejects the ref AS A UNIT — one bad item
@@ -686,26 +865,27 @@
686 865 *
687 866 * Recomputes a post's content identity — label, outbound `links`
688 867 * references, and the `related` navigation items — outside a page
689 868 * render. The chromeless bridge's editor save-watcher hits this
690 - * after every non-autosave Gutenberg save (Gutenberg saves over REST
869 + * after every non-autosave Gutenberg save, and after a new post's first
870 + * autosave (Gutenberg saves over REST
691 871 * without reloading, so the page-render announcement alone would go
692 872 * stale the moment the user adds a category or an image) and
693 873 * re-announces the fresh identity to the parent shell.
694 874 *
695 - * Both public filters (`desktop_mode_window_content_identity`,
696 - * `desktop_mode_window_related_entities`) run here exactly as they
875 + * Both public filters (`openstation_window_content_identity`,
876 + * `openstation_window_related_entities`) run here exactly as they
697 877 * do at page render, with `$screen = null` — there is no WP_Screen
698 878 * in REST context.
699 879 */
700 -function desktop_mode_register_content_identity_route() {
880 +function openstation_register_content_identity_route() {
701 881 register_rest_route(
702 882 'desktop-mode/v1',
703 883 '/content-identity',
704 884 array(
705 885 'methods' => 'GET',
706 - 'callback' => 'desktop_mode_rest_content_identity',
707 - 'permission_callback' => 'desktop_mode_rest_content_identity_permission',
886 + 'callback' => 'openstation_rest_content_identity',
887 + 'permission_callback' => 'openstation_rest_content_identity_permission',
708 888 'args' => array(
709 889 'post' => array(
710 890 'description' => __( 'Post ID to recompute the content identity for.', 'desktop-mode' ),
711 891 'type' => 'integer',
@@ -715,12 +895,12 @@
715 895 ),
716 896 )
717 897 );
718 898 }
719 -add_action( 'rest_api_init', 'desktop_mode_register_content_identity_route' );
899 +add_action( 'rest_api_init', 'openstation_register_content_identity_route' );
720 900
721 901 /**
722 - * Permission: desktop mode enabled AND the caller can edit the post —
902 + * Permission: OpenStation enabled AND the caller can edit the post —
723 903 * the identity carries the post title, term names, and media labels,
724 904 * which is exactly what the edit screen itself exposes.
725 905 *
726 906 * @param WP_REST_Request $request REST request.
@@ -725,10 +905,10 @@
725 905 *
726 906 * @param WP_REST_Request $request REST request.
727 907 * @return true|WP_Error
728 908 */
729 -function desktop_mode_rest_content_identity_permission( $request ) {
730 - $enabled = desktop_mode_rest_require_enabled();
909 +function openstation_rest_content_identity_permission( $request ) {
910 + $enabled = openstation_rest_require_enabled();
731 911 if ( true !== $enabled ) {
732 912 return $enabled;
733 913 }
734 914 if ( ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
@@ -747,13 +927,13 @@
747 927 *
748 928 * @param WP_REST_Request $request REST request.
749 929 * @return WP_REST_Response|WP_Error
750 930 */
751 -function desktop_mode_rest_content_identity( $request ) {
931 +function openstation_rest_content_identity( $request ) {
752 932 $post = get_post( (int) $request['post'] );
753 933 if ( ! $post instanceof WP_Post || 'attachment' === $post->post_type ) {
754 934 return new WP_Error(
755 - 'desktop_mode_no_identity',
935 + 'openstation_no_identity',
756 936 __( 'No content identity for this object.', 'desktop-mode' ),
757 937 array( 'status' => 404 )
758 938 );
759 939 }
@@ -762,21 +942,31 @@
762 942 'type' => sanitize_key( $post->post_type ),
763 943 'id' => (int) $post->ID,
764 944 'label' => get_the_title( $post ),
765 945 );
766 - $links = desktop_mode_window_links_extract_references( $post );
946 + $links = openstation_window_links_extract_references( $post );
767 947 if ( ! empty( $links ) ) {
768 948 $identity['links'] = $links;
769 949 }
770 950
771 - $preview_url = desktop_mode_window_preview_url( $post );
951 + $preview_url = openstation_window_preview_url( $post );
772 952 if ( '' !== $preview_url ) {
773 953 $identity['previewUrl'] = $preview_url;
774 954 }
775 955
956 + // The reason this recompute exists at all, for revisions: the FIRST
957 + // save of a draft is what creates its first revision, so the "View
958 + // revisions" row can only appear after a save — and a block-editor
959 + // save never reloads the page.
960 + $revisions = openstation_window_revisions( $post );
961 + if ( '' !== $revisions['url'] ) {
962 + $identity['revisionsUrl'] = $revisions['url'];
963 + $identity['revisionCount'] = $revisions['count'];
964 + }
965 +
776 966 /** 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 );
967 + $identity = apply_filters( 'openstation_window_content_identity', $identity, null );
968 + $identity = openstation_window_related_attach( $identity, $post, null );
779 969
780 970 return rest_ensure_response( array( 'identity' => $identity ) );
781 971 }
782 972
@@ -791,9 +981,9 @@
791 981 * surfaces its renderer in OS Settings → Effects → Window links
792 982 * immediately, no F5 needed.
793 983 *
794 984 * Renderers themselves are declared JS-side via
795 - * `wp.desktop.registerWindowLinkRenderer( … )` — the mount callback
985 + * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
796 986 * and label live in the plugin's JavaScript. The built-in
797 987 * `svg-splines` is registered through the very same JS hook (see
798 988 * `src/window-links/renderers/svg-splines.ts`).
799 989 *
@@ -803,15 +993,15 @@
803 993 * add_action( 'admin_enqueue_scripts', function () {
804 994 * wp_register_script(
805 995 * 'my-plugin-link-renderer',
806 996 * plugins_url( 'js/link-renderer.js', __FILE__ ),
807 - * array( 'desktop-mode' ),
997 + * array( 'openstation' ),
808 998 * '1.0.0',
809 999 * true
810 1000 * );
811 1001 * wp_enqueue_script( 'my-plugin-link-renderer' );
812 1002 * } );
813 - * desktop_mode_register_window_link_renderer_script( 'my-plugin-link-renderer' );
1003 + * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
814 1004 * ```
815 1005 *
816 1006 * For live unregistration on deactivation, the plugin's JS should set
817 1007 * `owner: 'my-plugin-link-renderer'` on each
@@ -820,18 +1010,18 @@
820 1010 *
821 1011 * @param string $handle WP-registered script handle.
822 1012 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
823 1013 */
824 -function desktop_mode_register_window_link_renderer_script( $handle ) {
1014 +function openstation_register_window_link_renderer_script( $handle ) {
825 1015 $handle = (string) $handle;
826 1016 if ( '' === $handle ) {
827 - return desktop_mode_registration_error(
828 - 'desktop_mode_missing_handle',
1017 + return openstation_registration_error(
1018 + 'openstation_missing_handle',
829 1019 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
830 1020 );
831 1021 }
832 1022
833 - desktop_mode_window_link_renderer_script_registry( $handle, true );
1023 + openstation_window_link_renderer_script_registry( $handle, true );
834 1024
835 1025 /**
836 1026 * Fires after a window-link renderer script handle is registered.
837 1027 *
@@ -836,9 +1026,9 @@
836 1026 * Fires after a window-link renderer script handle is registered.
837 1027 *
838 1028 * @param string $handle The registered script handle.
839 1029 */
840 - do_action( 'desktop_mode_window_link_renderer_script_registered', $handle );
1030 + do_action( 'openstation_window_link_renderer_script_registered', $handle );
841 1031
842 1032 return true;
843 1033 }
844 1034
@@ -850,9 +1040,9 @@
850 1040 * @param string $handle Script handle to read or write.
851 1041 * @param bool|null $value Pass `true` to register; `null` to read only.
852 1042 * @return array|bool When called with no args returns the full store.
853 1043 */
854 -function desktop_mode_window_link_renderer_script_registry( $handle = '', $value = null ) {
1044 +function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
855 1045 static $store = array();
856 1046
857 1047 if ( '__flush__' === (string) $handle ) {
858 1048 $store = array();
@@ -868,12 +1058,12 @@
868 1058 }
869 1059
870 1060 /**
871 1061 * Test-only: clear the registry between PHPUnit cases. See
872 - * {@see desktop_mode_flush_script_handle_registries()}.
1062 + * {@see openstation_flush_script_handle_registries()}.
873 1063 */
874 -function desktop_mode_flush_window_link_renderer_script_registry() {
875 - desktop_mode_window_link_renderer_script_registry( '__flush__' );
1064 +function openstation_flush_window_link_renderer_script_registry() {
1065 + openstation_window_link_renderer_script_registry( '__flush__' );
876 1066 }
877 1067
878 1068 /**
879 1069 * Build the script-handle payload fed to the shell. Handles that
@@ -880,10 +1070,10 @@
880 1070 * aren't currently enqueued resolve to an empty URL and are dropped.
881 1071 *
882 1072 * @return array[] List of `{ handle, scriptUrl, … }` entries.
883 1073 */
884 -function desktop_mode_build_window_link_renderer_scripts_payload() {
885 - $registry = desktop_mode_window_link_renderer_script_registry();
1074 +function openstation_build_window_link_renderer_scripts_payload() {
1075 + $registry = openstation_window_link_renderer_script_registry();
886 1076 if ( ! is_array( $registry ) || empty( $registry ) ) {
887 1077 return array();
888 1078 }
889 1079
@@ -892,15 +1082,15 @@
892 1082 foreach ( $registry as $handle => $active ) {
893 1083 if ( ! $active || isset( $seen[ $handle ] ) ) {
894 1084 continue;
895 1085 }
896 - $payload = desktop_mode_resolve_script_payload( $handle );
1086 + $payload = openstation_resolve_script_payload( $handle );
897 1087 if ( '' === $payload['url'] ) {
898 1088 // Loud diagnostic — visible under WP_DEBUG. Deduped by
899 - // `desktop_mode_warn_unresolvable_script_handle` so the
1089 + // `openstation_warn_unresolvable_script_handle` so the
900 1090 // notice fires once per handle per request.
901 - desktop_mode_warn_unresolvable_script_handle(
902 - 'desktop_mode_register_window_link_renderer_script',
1091 + openstation_warn_unresolvable_script_handle(
1092 + 'openstation_register_window_link_renderer_script',
903 1093 'Window-link renderer',
904 1094 (string) $handle
905 1095 );
906 1096 continue;
@@ -911,8 +1101,11 @@
911 1101 'scriptBefore' => $payload['before'],
912 1102 'scriptAfter' => $payload['after'],
913 1103 'scriptL10n' => $payload['l10n'],
914 1104 'scriptTranslations' => $payload['translations'],
1105 + // The handle's dependency closure, replayed before the bundle
1106 + // on its lazy load — see `openstation_resolve_script_dependencies()`.
1107 + 'scriptDeps' => openstation_resolve_script_dependencies( $handle ),
915 1108 );
916 1109 $seen[ $handle ] = true;
917 1110 }
918 1111 return $out;