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 +316 -82 0.9.6 → 1.1.13 View file →
@@ -12,12 +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 - * @since 0.9.4
19 - * @package WPDesktopMode
18 + * @package OpenStation
20 19 */
21 20
22 21 defined( 'ABSPATH' ) || exit;
23 22
@@ -41,14 +40,21 @@
41 40 * `post_parent` when attached.
42 41 * - `comment.php` (comment edit / moderation) — `comment`, rooted at
43 42 * the parent post. The URL alone can't answer this one; only real
44 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`.
45 53 *
46 - * @since 0.9.4
47 - *
48 54 * @return array|null Identity array, or `null` when none applies.
49 55 */
50 -function desktop_mode_build_content_identity() {
56 +function openstation_build_content_identity() {
51 57 $identity = null;
52 58 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
53 59 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
54 60
@@ -59,9 +65,9 @@
59 65 if ( $comment ) {
60 66 $identity = array(
61 67 'type' => 'comment',
62 68 'id' => (int) $comment->comment_ID,
63 - 'label' => wp_trim_words( $comment->comment_content, 10 ),
69 + 'label' => wp_trim_words( openstation_strip_all_tags( $comment->comment_content ), 10 ),
64 70 );
65 71
66 72 $post_id = (int) $comment->comment_post_ID;
67 73 $post_type = $post_id ? get_post_type( $post_id ) : false;
@@ -71,8 +77,39 @@
71 77 'id' => $post_id,
72 78 );
73 79 }
74 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 + }
75 112 } elseif ( $screen && 'post' === $screen->base && 'add' !== $screen->action ) {
76 113 $post = get_post();
77 114 if ( $post instanceof WP_Post && $post->ID > 0 ) {
78 115 if ( 'attachment' === $post->post_type ) {
@@ -101,13 +138,24 @@
101 138 // media, and assigned terms. When a window showing a
102 139 // referenced object is open, the shell draws a directed
103 140 // tie toward it (mutual links collapse into one
104 141 // bidirectional arrow).
105 - $links = desktop_mode_window_links_extract_references( $post );
142 + $links = openstation_window_links_extract_references( $post );
106 143 if ( ! empty( $links ) ) {
107 144 $identity['links'] = $links;
108 145 }
109 146
147 + $preview_url = openstation_window_preview_url( $post );
148 + if ( '' !== $preview_url ) {
149 + $identity['previewUrl'] = $preview_url;
150 + }
151 +
152 + $revisions = openstation_window_revisions( $post );
153 + if ( '' !== $revisions['url'] ) {
154 + $identity['revisionsUrl'] = $revisions['url'];
155 + $identity['revisionCount'] = $revisions['count'];
156 + }
157 +
110 158 // Source for the built-in related-entity items attached
111 159 // after the identity filter below.
112 160 $related_source_post = $post;
113 161 }
@@ -175,8 +223,24 @@
175 223 'id' => (int) $term->term_id,
176 224 'label' => $term->name,
177 225 );
178 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 + }
179 243 }
180 244
181 245 /**
182 246 * Filters the content identity announced for the current admin screen.
@@ -186,14 +250,12 @@
186 250 * built-in detection. The shape must match the JS
187 251 * `WindowContentRef`: `type` (lowercase slug), `id` (int|string),
188 252 * optional `label`, optional `root => array( 'type', 'id' )`.
189 253 *
190 - * @since 0.9.4
191 - *
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 );
@@ -206,14 +268,178 @@
206 268 );
207 269 }
208 270
209 271 /**
272 + * Build the front-end preview URL for a post — the target of the
273 + * shell's "Preview" (eye) title-bar button.
274 + *
275 + * Wraps `get_preview_post_link()` with the same query args core's own
276 + * `post_preview()` passes (`preview_id` + a `post_preview_{ID}` nonce),
277 + * so `_set_preview()` swaps in the newest autosave revision on the
278 + * front end. Required for published/scheduled/private posts, whose
279 + * autosaves land in a revision; harmless for drafts, where autosave
280 + * writes the post itself and `_set_preview()` no-ops.
281 + *
282 + * Nonce freshness is bounded by save cadence: the content identity is
283 + * rebuilt on every chromeless page render AND on the editor
284 + * save-watcher's REST recompute, so a long-lived editor window always
285 + * holds a recent nonce.
286 + *
287 + * @param WP_Post $post The post being edited.
288 + * @return string Preview URL, or `''` when the post has no front-end
289 + * preview (non-viewable type, attachment, insufficient
290 + * capability) or a filter suppressed it.
291 + */
292 +function openstation_window_preview_url( $post ) {
293 + $preview_url = '';
294 +
295 + if (
296 + $post instanceof WP_Post &&
297 + $post->ID > 0 &&
298 + 'attachment' !== $post->post_type &&
299 + is_post_type_viewable( get_post_type_object( $post->post_type ) ) &&
300 + current_user_can( 'edit_post', $post->ID )
301 + ) {
302 + $link = get_preview_post_link(
303 + $post,
304 + array(
305 + 'preview_id' => $post->ID,
306 + 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post->ID ),
307 + )
308 + );
309 + if ( is_string( $link ) ) {
310 + $preview_url = $link;
311 + }
312 + }
313 +
314 + /**
315 + * Filters the front-end preview URL attached to a post-editor
316 + * content identity (the target of the shell's "Preview" eye
317 + * title-bar button).
318 + *
319 + * Return `''` to suppress the preview button for this post, or
320 + * rewrite the URL to point somewhere else (a headless front end,
321 + * a staging domain). Note the shell only accepts same-origin URLs;
322 + * a cross-origin rewrite hides the button.
323 + *
324 + * @param string $preview_url Preview URL, `''` when none applies.
325 + * @param WP_Post $post The post being edited.
326 + */
327 + return (string) apply_filters( 'openstation_window_preview_url', $preview_url, $post );
328 +}
329 +
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 +/**
210 434 * The related-entity pass: attach the `related` navigation items to a
211 435 * (post-identity-filter) content identity. Shared by the page-render
212 436 * builder above and the REST recompute endpoint the editor
213 - * 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.
214 441 *
215 - * @since 0.9.6
216 442 * @internal
217 443 *
218 444 * @param array|null $identity Filtered identity, or `null`.
219 445 * @param WP_Post|null $post The detected source post, when the
@@ -221,12 +447,15 @@
221 447 * @param WP_Screen|null $screen The current screen, when available.
222 448 * @return array|null The identity with `related` attached (or the
223 449 * input untouched when it was `null`).
224 450 */
225 -function desktop_mode_window_related_attach( $identity, $post, $screen ) {
451 +function openstation_window_related_attach( $identity, $post, $screen ) {
226 452 if ( ! is_array( $identity ) ) {
227 453 return $identity;
228 454 }
455 + if ( isset( $identity['label'] ) && is_string( $identity['label'] ) ) {
456 + $identity['label'] = openstation_plain_text_title( $identity['label'] );
457 + }
229 458
230 459 $related = array();
231 460 if (
232 461 $post instanceof WP_Post &&
@@ -239,9 +468,9 @@
239 468 isset( $identity['type'], $identity['id'] ) &&
240 469 sanitize_key( $post->post_type ) === $identity['type'] &&
241 470 (int) $post->ID === (int) $identity['id']
242 471 ) {
243 - $related = desktop_mode_window_related_entities_for_post( $post );
472 + $related = openstation_window_related_entities_for_post( $post );
244 473 }
245 474 if ( isset( $identity['related'] ) && is_array( $identity['related'] ) ) {
246 475 // An identity filter may ship related items with its own
247 476 // identity — fold them in so they reach the related filter
@@ -278,16 +507,14 @@
278 507 * Runs during the chromeless page render AND on the
279 508 * `desktop-mode/v1/content-identity` REST recompute the editor
280 509 * save-watcher triggers — in the REST context `$screen` is `null`.
281 510 *
282 - * @since 0.9.6
283 - *
284 511 * @param array[] $related Related-entity items.
285 512 * @param array $identity The resolved content identity.
286 513 * @param WP_Screen|null $screen The current screen, when available.
287 514 */
288 - $related = apply_filters( 'desktop_mode_window_related_entities', $related, $identity, $screen );
289 - $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 );
290 517 // The related pass is the single authority over the key — an
291 518 // identity filter smuggling its own `related` would bypass the
292 519 // sanitizer above.
293 520 unset( $identity['related'] );
@@ -317,14 +544,12 @@
317 544 *
318 545 * Deduped by type:id and capped so a link-farm post can't flood the
319 546 * shell.
320 547 *
321 - * @since 0.9.4
322 - *
323 548 * @param WP_Post $post Source post.
324 549 * @return array[] Reference entries, possibly empty.
325 550 */
326 -function desktop_mode_window_links_extract_references( $post ) {
551 +function openstation_window_links_extract_references( $post ) {
327 552 $links = array();
328 553 $seen = array();
329 554 $push = static function ( $type, $id, $rel = '' ) use ( &$links, &$seen ) {
330 555 $key = $type . ':' . $id;
@@ -346,10 +571,10 @@
346 571 };
347 572
348 573 // 1. Internal hyperlinks → posts. Guarded: the content-graph
349 574 // 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 );
575 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
576 + $ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
352 577 foreach ( array_slice( $ids, 0, 32 ) as $target_id ) {
353 578 $target_id = (int) $target_id;
354 579 if ( $target_id === (int) $post->ID ) {
355 580 continue;
@@ -422,16 +647,14 @@
422 647 * are excluded.
423 648 *
424 649 * Built-ins deliberately cover `post` and `page` only; other post
425 650 * types (and non-post screens) join via the
426 - * `desktop_mode_window_related_entities` filter.
651 + * `openstation_window_related_entities` filter.
427 652 *
428 - * @since 0.9.6
429 - *
430 653 * @param WP_Post $post Source post.
431 654 * @return array[] Related-entity items, possibly empty.
432 655 */
433 -function desktop_mode_window_related_entities_for_post( $post ) {
656 +function openstation_window_related_entities_for_post( $post ) {
434 657 if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
435 658 return array();
436 659 }
437 660
@@ -539,10 +762,10 @@
539 762 // 4. Linked posts — internal hyperlinks resolving to another post
540 763 // on this site. Guarded: the extractor lives in the content-graph
541 764 // include. Capped tighter than the reference extractor (10) to
542 765 // 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 );
766 + if ( function_exists( 'openstation_content_graph_extract_internal_links' ) ) {
767 + $link_ids = openstation_content_graph_extract_internal_links( (string) $post->post_content );
545 768 $count = 0;
546 769 foreach ( $link_ids as $target_id ) {
547 770 if ( $count >= 10 ) {
548 771 break;
@@ -577,20 +800,19 @@
577 800
578 801 /**
579 802 * Drop malformed related-entity items and whitelist their fields.
580 803 *
581 - * Runs on the `desktop_mode_window_related_entities` filter output
804 + * Runs on the `openstation_window_related_entities` filter output
582 805 * before the payload is announced: a plugin returning one bad entry
583 806 * must not invalidate the whole identity client-side (the JS engine
584 807 * validates the ref as a unit and would discard everything).
585 808 *
586 - * @since 0.9.6
587 809 * @internal
588 810 *
589 811 * @param mixed $related Filter output.
590 812 * @return array[] Well-formed items, reindexed.
591 813 */
592 -function desktop_mode_window_related_entities_sanitize( $related ) {
814 +function openstation_window_related_entities_sanitize( $related ) {
593 815 if ( ! is_array( $related ) ) {
594 816 return array();
595 817 }
596 818
@@ -598,8 +820,16 @@
598 820 foreach ( $related as $item ) {
599 821 if ( ! is_array( $item ) ) {
600 822 continue;
601 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 + }
602 832 foreach ( array( 'id', 'group', 'label', 'url' ) as $required ) {
603 833 // Mirror the JS engine's validation exactly (`.trim() !== ''`):
604 834 // a whitespace-only value passing here would fail validateRef
605 835 // client-side, which rejects the ref AS A UNIT — one bad item
@@ -635,28 +865,27 @@
635 865 *
636 866 * Recomputes a post's content identity — label, outbound `links`
637 867 * references, and the `related` navigation items — outside a page
638 868 * render. The chromeless bridge's editor save-watcher hits this
639 - * 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
640 871 * without reloading, so the page-render announcement alone would go
641 872 * stale the moment the user adds a category or an image) and
642 873 * re-announces the fresh identity to the parent shell.
643 874 *
644 - * Both public filters (`desktop_mode_window_content_identity`,
645 - * `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
646 877 * do at page render, with `$screen = null` — there is no WP_Screen
647 878 * in REST context.
648 - *
649 - * @since 0.9.6
650 879 */
651 -function desktop_mode_register_content_identity_route() {
880 +function openstation_register_content_identity_route() {
652 881 register_rest_route(
653 882 'desktop-mode/v1',
654 883 '/content-identity',
655 884 array(
656 885 'methods' => 'GET',
657 - 'callback' => 'desktop_mode_rest_content_identity',
658 - 'permission_callback' => 'desktop_mode_rest_content_identity_permission',
886 + 'callback' => 'openstation_rest_content_identity',
887 + 'permission_callback' => 'openstation_rest_content_identity_permission',
659 888 'args' => array(
660 889 'post' => array(
661 890 'description' => __( 'Post ID to recompute the content identity for.', 'desktop-mode' ),
662 891 'type' => 'integer',
@@ -666,22 +895,20 @@
666 895 ),
667 896 )
668 897 );
669 898 }
670 -add_action( 'rest_api_init', 'desktop_mode_register_content_identity_route' );
899 +add_action( 'rest_api_init', 'openstation_register_content_identity_route' );
671 900
672 901 /**
673 - * Permission: desktop mode enabled AND the caller can edit the post —
902 + * Permission: OpenStation enabled AND the caller can edit the post —
674 903 * the identity carries the post title, term names, and media labels,
675 904 * which is exactly what the edit screen itself exposes.
676 905 *
677 - * @since 0.9.6
678 - *
679 906 * @param WP_REST_Request $request REST request.
680 907 * @return true|WP_Error
681 908 */
682 -function desktop_mode_rest_content_identity_permission( $request ) {
683 - $enabled = desktop_mode_rest_require_enabled();
909 +function openstation_rest_content_identity_permission( $request ) {
910 + $enabled = openstation_rest_require_enabled();
684 911 if ( true !== $enabled ) {
685 912 return $enabled;
686 913 }
687 914 if ( ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
@@ -697,18 +924,16 @@
697 924 /**
698 925 * REST handler — rebuild the post-editor identity the same way the
699 926 * page-render builder's `post.php` branch does, filters included.
700 927 *
701 - * @since 0.9.6
702 - *
703 928 * @param WP_REST_Request $request REST request.
704 929 * @return WP_REST_Response|WP_Error
705 930 */
706 -function desktop_mode_rest_content_identity( $request ) {
931 +function openstation_rest_content_identity( $request ) {
707 932 $post = get_post( (int) $request['post'] );
708 933 if ( ! $post instanceof WP_Post || 'attachment' === $post->post_type ) {
709 934 return new WP_Error(
710 - 'desktop_mode_no_identity',
935 + 'openstation_no_identity',
711 936 __( 'No content identity for this object.', 'desktop-mode' ),
712 937 array( 'status' => 404 )
713 938 );
714 939 }
@@ -717,16 +942,31 @@
717 942 'type' => sanitize_key( $post->post_type ),
718 943 'id' => (int) $post->ID,
719 944 'label' => get_the_title( $post ),
720 945 );
721 - $links = desktop_mode_window_links_extract_references( $post );
946 + $links = openstation_window_links_extract_references( $post );
722 947 if ( ! empty( $links ) ) {
723 948 $identity['links'] = $links;
724 949 }
725 950
951 + $preview_url = openstation_window_preview_url( $post );
952 + if ( '' !== $preview_url ) {
953 + $identity['previewUrl'] = $preview_url;
954 + }
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 +
726 966 /** 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 );
967 + $identity = apply_filters( 'openstation_window_content_identity', $identity, null );
968 + $identity = openstation_window_related_attach( $identity, $post, null );
729 969
730 970 return rest_ensure_response( array( 'identity' => $identity ) );
731 971 }
732 972
@@ -741,9 +981,9 @@
741 981 * surfaces its renderer in OS Settings → Effects → Window links
742 982 * immediately, no F5 needed.
743 983 *
744 984 * Renderers themselves are declared JS-side via
745 - * `wp.desktop.registerWindowLinkRenderer( … )` — the mount callback
985 + * `wp.os.registerWindowLinkRenderer( … )` — the mount callback
746 986 * and label live in the plugin's JavaScript. The built-in
747 987 * `svg-splines` is registered through the very same JS hook (see
748 988 * `src/window-links/renderers/svg-splines.ts`).
749 989 *
@@ -753,15 +993,15 @@
753 993 * add_action( 'admin_enqueue_scripts', function () {
754 994 * wp_register_script(
755 995 * 'my-plugin-link-renderer',
756 996 * plugins_url( 'js/link-renderer.js', __FILE__ ),
757 - * array( 'desktop-mode' ),
997 + * array( 'openstation' ),
758 998 * '1.0.0',
759 999 * true
760 1000 * );
761 1001 * wp_enqueue_script( 'my-plugin-link-renderer' );
762 1002 * } );
763 - * desktop_mode_register_window_link_renderer_script( 'my-plugin-link-renderer' );
1003 + * openstation_register_window_link_renderer_script( 'my-plugin-link-renderer' );
764 1004 * ```
765 1005 *
766 1006 * For live unregistration on deactivation, the plugin's JS should set
767 1007 * `owner: 'my-plugin-link-renderer'` on each
@@ -767,32 +1007,28 @@
767 1007 * `owner: 'my-plugin-link-renderer'` on each
768 1008 * `registerWindowLinkRenderer` call. Otherwise the renderer stays
769 1009 * until the next page reload — graceful backwards-compat.
770 1010 *
771 - * @since 0.9.4
772 - *
773 1011 * @param string $handle WP-registered script handle.
774 1012 * @return true|WP_Error `true` on success; `WP_Error` on validation failure.
775 1013 */
776 -function desktop_mode_register_window_link_renderer_script( $handle ) {
1014 +function openstation_register_window_link_renderer_script( $handle ) {
777 1015 $handle = (string) $handle;
778 1016 if ( '' === $handle ) {
779 - return desktop_mode_registration_error(
780 - 'desktop_mode_missing_handle',
1017 + return openstation_registration_error(
1018 + 'openstation_missing_handle',
781 1019 __( 'Window-link renderer script registration requires a non-empty script handle.', 'desktop-mode' )
782 1020 );
783 1021 }
784 1022
785 - desktop_mode_window_link_renderer_script_registry( $handle, true );
1023 + openstation_window_link_renderer_script_registry( $handle, true );
786 1024
787 1025 /**
788 1026 * Fires after a window-link renderer script handle is registered.
789 1027 *
790 - * @since 0.9.4
791 - *
792 1028 * @param string $handle The registered script handle.
793 1029 */
794 - do_action( 'desktop_mode_window_link_renderer_script_registered', $handle );
1030 + do_action( 'openstation_window_link_renderer_script_registered', $handle );
795 1031
796 1032 return true;
797 1033 }
798 1034
@@ -798,9 +1034,8 @@
798 1034
799 1035 /**
800 1036 * Internal module-level registry for window-link renderer script handles.
801 1037 *
802 - * @since 0.9.4
803 1038 * @internal
804 1039 *
805 1040 * @param string $handle Script handle to read or write.
806 1041 * @param bool|null $value Pass `true` to register; `null` to read only.
@@ -805,9 +1040,9 @@
805 1040 * @param string $handle Script handle to read or write.
806 1041 * @param bool|null $value Pass `true` to register; `null` to read only.
807 1042 * @return array|bool When called with no args returns the full store.
808 1043 */
809 -function desktop_mode_window_link_renderer_script_registry( $handle = '', $value = null ) {
1044 +function openstation_window_link_renderer_script_registry( $handle = '', $value = null ) {
810 1045 static $store = array();
811 1046
812 1047 if ( '__flush__' === (string) $handle ) {
813 1048 $store = array();
@@ -823,14 +1058,12 @@
823 1058 }
824 1059
825 1060 /**
826 1061 * Test-only: clear the registry between PHPUnit cases. See
827 - * {@see desktop_mode_flush_script_handle_registries()}.
828 - *
829 - * @since 0.9.4
1062 + * {@see openstation_flush_script_handle_registries()}.
830 1063 */
831 -function desktop_mode_flush_window_link_renderer_script_registry() {
832 - 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__' );
833 1066 }
834 1067
835 1068 /**
836 1069 * Build the script-handle payload fed to the shell. Handles that
@@ -835,14 +1068,12 @@
835 1068 /**
836 1069 * Build the script-handle payload fed to the shell. Handles that
837 1070 * aren't currently enqueued resolve to an empty URL and are dropped.
838 1071 *
839 - * @since 0.9.4
840 - *
841 1072 * @return array[] List of `{ handle, scriptUrl, … }` entries.
842 1073 */
843 -function desktop_mode_build_window_link_renderer_scripts_payload() {
844 - $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();
845 1076 if ( ! is_array( $registry ) || empty( $registry ) ) {
846 1077 return array();
847 1078 }
848 1079
@@ -851,15 +1082,15 @@
851 1082 foreach ( $registry as $handle => $active ) {
852 1083 if ( ! $active || isset( $seen[ $handle ] ) ) {
853 1084 continue;
854 1085 }
855 - $payload = desktop_mode_resolve_script_payload( $handle );
1086 + $payload = openstation_resolve_script_payload( $handle );
856 1087 if ( '' === $payload['url'] ) {
857 1088 // Loud diagnostic — visible under WP_DEBUG. Deduped by
858 - // `desktop_mode_warn_unresolvable_script_handle` so the
1089 + // `openstation_warn_unresolvable_script_handle` so the
859 1090 // notice fires once per handle per request.
860 - desktop_mode_warn_unresolvable_script_handle(
861 - 'desktop_mode_register_window_link_renderer_script',
1091 + openstation_warn_unresolvable_script_handle(
1092 + 'openstation_register_window_link_renderer_script',
862 1093 'Window-link renderer',
863 1094 (string) $handle
864 1095 );
865 1096 continue;
@@ -870,8 +1101,11 @@
870 1101 'scriptBefore' => $payload['before'],
871 1102 'scriptAfter' => $payload['after'],
872 1103 'scriptL10n' => $payload['l10n'],
873 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 ),
874 1108 );
875 1109 $seen[ $handle ] = true;
876 1110 }
877 1111 return $out;