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 / content-changes.php

content-changes.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at includes/content-changes.php

626 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — generic content-change realtime layer.
4 *
5 * Records create/update/trash mutations of posts, pages, CPTs,
6 * comments, and WooCommerce orders into a per-request changelog and
7 * relays them to the parent shell as cross-window broadcasts
8 * (`desktop-mode.<type>.changed`, payload `{ source, action, ids }`),
9 * so every window listing that content type can refresh without an F5.
10 *
11 * Three delivery paths, mirroring the Recycle Bin realtime layer
12 * (`includes/recycle-bin/realtime.php`) which delegates its own
13 * changelog into this module:
14 *
15 * 1. **Chromeless footer — fast path.** At `admin_footer` on a
16 * chromeless render, one inline script postMessages a
17 * `desktop-mode-broadcast` envelope per `[type][action]` entry
18 * to the parent shell. Because the dominant admin mutation flow
19 * is form-POST → 302 → GET (the mutating request renders no
20 * footer), the changelog is buffered across the redirect in a
21 * short-TTL per-user transient and flushed on the next
22 * chromeless footer render (~500 ms after the click).
23 *
24 * 2. **Block editor — client-side.** Gutenberg saves over REST with
25 * no navigation; the chromeless bridge's save-watcher
26 * (`includes/render/chromeless-bridge.php`) posts the broadcast
27 * directly on save success. This module still records the REST
28 * save server-side for path 3.
29 *
30 * 3. **Heartbeat — catch-all.** Every record is appended to a
31 * pruned changelog option; the Heartbeat response answers
32 * "entries newer than your seen ts" for opted-in shells. Covers
33 * Quick Edit, AJAX status flips, other browser tabs, REST and
34 * WP-CLI mutations within one tick (15–60 s).
35 *
36 * @package WPDesktopMode
37 * @since 0.9.7
38 */
39
40 defined( 'ABSPATH' ) || exit;
41
42 const DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION = '_desktop_mode_content_changes_log';
43
44 /**
45 * Milliseconds of heartbeat-changelog history to retain.
46 *
47 * @since 0.9.7
48 */
49 const DESKTOP_MODE_CONTENT_CHANGES_LOG_WINDOW_MS = 300000;
50
51 /**
52 * Maximum retained heartbeat-changelog entries.
53 *
54 * @since 0.9.7
55 */
56 const DESKTOP_MODE_CONTENT_CHANGES_LOG_MAX = 100;
57
58 /**
59 * TTL for the redirect-surviving per-user changelog buffer.
60 *
61 * @since 0.9.7
62 */
63 const DESKTOP_MODE_CONTENT_CHANGES_BUFFER_TTL = 60;
64
65 /**
66 * Per-request state shared by the recorder, the footer emitter, and
67 * the shutdown handler.
68 *
69 * Function-static-by-reference so it survives across hook callbacks
70 * within one PHP request — same pattern as the Recycle Bin changelog.
71 *
72 * Shape:
73 * - `log` — `[ type ][ action ] = int[] ids` for this request.
74 * - `seen` — `"type:id" => true` first-writer-wins dedupe set.
75 * - `staged` — flat `{ type, action, id }` rows for the heartbeat log.
76 * - `flushed` — true once the footer emitter consumed `log`, so the
77 * shutdown handler doesn't buffer it a second time.
78 *
79 * @since 0.9.7
80 *
81 * @return array Per-request state, by reference.
82 */
83 function &desktop_mode_content_changes_state() {
84 static $state = null;
85 if ( null === $state ) {
86 $state = array(
87 'log' => array(),
88 'seen' => array(),
89 'staged' => array(),
90 'flushed' => false,
91 );
92 }
93 return $state;
94 }
95
96 /**
97 * Resets the per-request state. Test isolation only.
98 *
99 * @since 0.9.7
100 * @internal
101 */
102 function desktop_mode_content_changes_reset() {
103 $state = &desktop_mode_content_changes_state();
104 $state = array(
105 'log' => array(),
106 'seen' => array(),
107 'staged' => array(),
108 'flushed' => false,
109 );
110 }
111
112 /**
113 * Records a content change into the per-request changelog.
114 *
115 * This is the public entry point for third-party plugins with their
116 * own storage (an HPOS-style custom table, a settings screen, …):
117 * call it from your mutation path and every open window listing your
118 * type refreshes, exactly like core content. Pair it with a
119 * `desktop_mode_soft_reload_rules` filter entry if your list screen
120 * is not a standard `edit.php?post_type=<type>` page.
121 *
122 * Dedupe is first-writer-wins per `type:id` within the request: core
123 * fires the trash verbs (`wp_trash_post`, `untrash_post`) BEFORE the
124 * internal status write reaches `wp_after_insert_post`, so the more
125 * specific verb is recorded and the follow-up `updated` for the same
126 * id is dropped. The same mechanism collapses WooCommerce legacy-mode
127 * double-fires (post hooks + `woocommerce_update_order`).
128 *
129 * @since 0.9.7
130 *
131 * @param string $type Content type slug — a post type, `comment`,
132 * or `shop_order`. Becomes the broadcast topic
133 * `desktop-mode.<type>.changed`.
134 * @param int $id Mutated object id.
135 * @param string $action One of 'created', 'updated', 'trashed',
136 * 'untrashed', 'deleted'.
137 * @return bool Whether the change was recorded.
138 */
139 function desktop_mode_content_changes_record( $type, $id, $action ) {
140 $type = (string) $type;
141 $id = (int) $id;
142 $action = (string) $action;
143
144 if ( '' === $type || $id <= 0 || '' === $action ) {
145 return false;
146 }
147
148 /**
149 * Filters whether a content change is recorded into the realtime
150 * changelog.
151 *
152 * Return false to keep a mutation out of the cross-window refresh
153 * system entirely (footer broadcast AND heartbeat log) — e.g. a
154 * high-churn internal type whose list windows manage their own
155 * realtime.
156 *
157 * @since 0.9.7
158 *
159 * @param bool $record Whether to record. Default true.
160 * @param string $type Content type slug.
161 * @param int $id Mutated object id.
162 * @param string $action Verb (created/updated/trashed/untrashed/deleted).
163 */
164 if ( ! apply_filters( 'desktop_mode_content_changes_should_record', true, $type, $id, $action ) ) {
165 return false;
166 }
167
168 $state = &desktop_mode_content_changes_state();
169 $key = $type . ':' . $id;
170 if ( isset( $state['seen'][ $key ] ) ) {
171 return false;
172 }
173 $state['seen'][ $key ] = true;
174
175 if ( ! isset( $state['log'][ $type ] ) ) {
176 $state['log'][ $type ] = array();
177 }
178 if ( ! isset( $state['log'][ $type ][ $action ] ) ) {
179 $state['log'][ $type ][ $action ] = array();
180 }
181 $state['log'][ $type ][ $action ][] = $id;
182
183 $state['staged'][] = array(
184 'type' => $type,
185 'action' => $action,
186 'id' => $id,
187 );
188
189 /**
190 * Fires after a content change is recorded into the realtime
191 * changelog.
192 *
193 * Subscribers can push their own real-time signal (websocket,
194 * SSE, …) without re-hooking every mutation path individually.
195 *
196 * @since 0.9.7
197 *
198 * @param string $type Content type slug.
199 * @param int $id Mutated object id.
200 * @param string $action Verb (created/updated/trashed/untrashed/deleted).
201 */
202 do_action( 'desktop_mode_content_change_recorded', $type, $id, $action );
203
204 return true;
205 }
206
207 /**
208 * Returns the per-request changelog: `[ type ][ action ] = int[] ids`.
209 *
210 * @since 0.9.7
211 *
212 * @return array
213 */
214 function desktop_mode_content_changes_log() {
215 $state = &desktop_mode_content_changes_state();
216 return $state['log'];
217 }
218
219 /**
220 * Merges changelog `$b` into changelog `$a` (both
221 * `[ type ][ action ] = int[] ids`).
222 *
223 * @since 0.9.7
224 *
225 * @param array $a Base changelog.
226 * @param array $b Changelog to merge in.
227 * @return array
228 */
229 function desktop_mode_content_changes_merge( $a, $b ) {
230 foreach ( (array) $b as $type => $by_action ) {
231 foreach ( (array) $by_action as $action => $ids ) {
232 $existing = isset( $a[ $type ][ $action ] ) ? (array) $a[ $type ][ $action ] : array();
233 $a[ $type ][ $action ] = array_values( array_unique( array_merge( $existing, array_map( 'intval', (array) $ids ) ) ) );
234 }
235 }
236 return $a;
237 }
238
239 /**
240 * Transient key of the redirect-surviving changelog buffer for a user.
241 *
242 * @since 0.9.7
243 *
244 * @param int $user_id User id.
245 * @return string
246 */
247 function desktop_mode_content_changes_buffer_key( $user_id ) {
248 return 'desktop_mode_content_buf_' . (int) $user_id;
249 }
250
251 /**
252 * `wp_after_insert_post` handler — posts, pages, and every `show_ui`
253 * custom post type.
254 *
255 * `wp_after_insert_post` (not `save_post`) so terms and meta are
256 * already persisted when subscribers refetch. Trash-status writes are
257 * skipped — the Recycle Bin hooks own the trash verbs and record them
258 * first (`wp_trash_post` fires before the internal status update
259 * reaches this hook).
260 *
261 * Post types without `show_ui` are skipped: they have no list screen
262 * to refresh, and internal types (notes, …) run their own realtime.
263 * Plugins that want one tracked anyway can call
264 * `desktop_mode_content_changes_record()` from their own hooks.
265 *
266 * @since 0.9.7
267 *
268 * @param int $post_id Post id.
269 * @param WP_Post $post Saved post.
270 * @param bool $update Whether this is an update.
271 * @param WP_Post|null $post_before Pre-save post, null on creation.
272 */
273 function desktop_mode_content_changes_on_after_insert_post( $post_id, $post, $update, $post_before ) {
274 if ( ! $post instanceof WP_Post ) {
275 return;
276 }
277 if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
278 return;
279 }
280 if ( function_exists( 'wp_doing_autosave' ) && wp_doing_autosave() ) {
281 return;
282 }
283 if ( in_array( $post->post_status, array( 'auto-draft', 'trash' ), true ) ) {
284 return;
285 }
286 $post_type_object = get_post_type_object( $post->post_type );
287 if ( ! $post_type_object || empty( $post_type_object->show_ui ) ) {
288 return;
289 }
290
291 // The first real save of a new post arrives as an "update" of the
292 // auto-draft shell `post-new.php` created — report it as created.
293 $is_created = ! $update || ( $post_before instanceof WP_Post && 'auto-draft' === $post_before->post_status );
294
295 desktop_mode_content_changes_record( $post->post_type, (int) $post_id, $is_created ? 'created' : 'updated' );
296 }
297
298 /**
299 * `transition_comment_status` handler.
300 *
301 * Trash transitions are skipped in both directions: the Recycle Bin's
302 * `trashed_comment` / `untrashed_comment` hooks record those verbs,
303 * and they fire AFTER the transition — without the skip the dedupe
304 * set would keep this handler's less-specific `updated`.
305 *
306 * @since 0.9.7
307 *
308 * @param string $new_status New comment status.
309 * @param string $old_status Old comment status.
310 * @param WP_Comment $comment Comment object.
311 */
312 function desktop_mode_content_changes_on_comment_transition( $new_status, $old_status, $comment ) {
313 if ( 'trash' === $new_status || 'trash' === $old_status ) {
314 return;
315 }
316 if ( ! $comment instanceof WP_Comment ) {
317 return;
318 }
319 desktop_mode_content_changes_record( 'comment', (int) $comment->comment_ID, 'updated' );
320 }
321
322 /**
323 * Wires the WooCommerce order hooks. No-op unless WooCommerce is
324 * active.
325 *
326 * The `woocommerce_*` family is required for HPOS, where orders live
327 * in custom tables and none of the post hooks fire. Under legacy
328 * (posts-table) storage the post hooks fire too; the recorder's
329 * per-request dedupe collapses the double-fire. The type is always
330 * recorded as `shop_order` so one broadcast topic
331 * (`desktop-mode.shop_order.changed`) serves both storage modes.
332 *
333 * @since 0.9.7
334 *
335 * @return bool Whether the hooks were registered.
336 */
337 function desktop_mode_content_changes_register_wc_hooks() {
338 if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_get_order' ) ) {
339 return false;
340 }
341
342 add_action( 'woocommerce_new_order', function ( $order_id ) {
343 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'created' );
344 } );
345 add_action( 'woocommerce_update_order', function ( $order_id ) {
346 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'updated' );
347 } );
348 // Some AJAX status-flip paths reach `woocommerce_order_status_changed`
349 // without `woocommerce_update_order`; the dedupe set absorbs the
350 // overlap when both fire.
351 add_action( 'woocommerce_order_status_changed', function ( $order_id ) {
352 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'updated' );
353 } );
354 add_action( 'woocommerce_trash_order', function ( $order_id ) {
355 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'trashed' );
356 } );
357 add_action( 'woocommerce_untrash_order', function ( $order_id ) {
358 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'untrashed' );
359 } );
360 add_action( 'woocommerce_delete_order', function ( $order_id ) {
361 desktop_mode_content_changes_record( 'shop_order', (int) $order_id, 'deleted' );
362 } );
363
364 return true;
365 }
366
367 /**
368 * Emits the chromeless-footer broadcast script.
369 *
370 * Merges the in-memory changelog with the redirect-surviving buffer
371 * (consumed on read), builds one broadcast envelope per
372 * `[ type ][ action ]`, and prints one inline script that postMessages
373 * each to the parent shell. The parent's broadcast receiver fans them
374 * out as `desktop-mode.<type>.changed` — iframe list pages soft-reload
375 * and native list windows refetch.
376 *
377 * Runs at `admin_footer` priority 100, same slot as the Recycle Bin's
378 * bin-specific ts signal.
379 *
380 * @since 0.9.7
381 */
382 function desktop_mode_content_changes_emit_footer() {
383 if ( ! function_exists( 'desktop_mode_is_chromeless_request' ) || ! desktop_mode_is_chromeless_request() ) {
384 return;
385 }
386
387 $state = &desktop_mode_content_changes_state();
388 $log = $state['log'];
389
390 $user_id = get_current_user_id();
391 if ( $user_id > 0 ) {
392 $key = desktop_mode_content_changes_buffer_key( $user_id );
393 $buffered = get_transient( $key );
394 if ( is_array( $buffered ) && ! empty( $buffered ) ) {
395 delete_transient( $key );
396 $log = desktop_mode_content_changes_merge( $buffered, $log );
397 }
398 }
399
400 // The in-memory log is consumed regardless of whether anything is
401 // emitted — the shutdown handler must not re-buffer what the
402 // footer already had the chance to flush.
403 $state['flushed'] = true;
404
405 if ( empty( $log ) ) {
406 return;
407 }
408
409 $broadcasts = array();
410 foreach ( $log as $type => $by_action ) {
411 foreach ( $by_action as $action => $ids ) {
412 /**
413 * Filters the broadcast topic for a content-change type.
414 *
415 * @since 0.9.7
416 *
417 * @param string $topic Default `desktop-mode.<type>.changed`.
418 * @param string $type Content type slug.
419 * @param string $action Verb for this envelope.
420 */
421 $topic = (string) apply_filters( 'desktop_mode_content_change_topic', 'desktop-mode.' . $type . '.changed', $type, $action );
422
423 $broadcasts[] = array(
424 'topic' => $topic,
425 'payload' => array(
426 'source' => 'admin',
427 'action' => (string) $action,
428 'ids' => array_values( array_unique( array_map( 'intval', (array) $ids ) ) ),
429 ),
430 );
431 }
432 }
433
434 /**
435 * Filters the full set of content-change broadcast envelopes just
436 * before the chromeless footer emits them.
437 *
438 * Each entry is `array( 'topic' => string, 'payload' => array )`.
439 * Return an empty array to suppress the emit.
440 *
441 * @since 0.9.7
442 *
443 * @param array $broadcasts Broadcast envelopes.
444 */
445 $broadcasts = (array) apply_filters( 'desktop_mode_content_changes_broadcasts', $broadcasts );
446 if ( empty( $broadcasts ) ) {
447 return;
448 }
449
450 $broadcasts_json = wp_json_encode( array_values( $broadcasts ) );
451 if ( ! $broadcasts_json ) {
452 return;
453 }
454
455 ?>
456 <script id="desktop-mode-content-changes-signal">
457 ( function () {
458 if ( window.parent === window ) {
459 return;
460 }
461 var origin = window.location.origin;
462 var broadcasts = <?php echo $broadcasts_json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output. ?>;
463 for ( var i = 0; i < broadcasts.length; i++ ) {
464 try {
465 window.parent.postMessage( {
466 type: 'desktop-mode-broadcast',
467 topic: broadcasts[ i ].topic,
468 payload: broadcasts[ i ].payload
469 }, origin );
470 } catch ( _err ) { /* parent gone */ }
471 }
472 } )();
473 </script>
474 <?php
475
476 /**
477 * Fires after the chromeless footer emitted the content-change
478 * broadcast envelopes.
479 *
480 * @since 0.9.7
481 *
482 * @param array $broadcasts Emitted broadcast envelopes.
483 */
484 do_action( 'desktop_mode_content_changes_emitted', $broadcasts );
485 }
486
487 /**
488 * `shutdown` handler — persists the heartbeat changelog and buffers
489 * an unflushed in-request changelog across the coming redirect.
490 *
491 * One `update_option` per mutating request; requests that recorded
492 * nothing pay a static-array read and return.
493 *
494 * @since 0.9.7
495 */
496 function desktop_mode_content_changes_on_shutdown() {
497 $state = &desktop_mode_content_changes_state();
498
499 if ( ! empty( $state['staged'] ) ) {
500 $now = (int) round( microtime( true ) * 1000 );
501
502 // Group staged rows into per-[type][action] heartbeat entries.
503 $grouped = array();
504 foreach ( $state['staged'] as $row ) {
505 $grouped[ $row['type'] . '|' . $row['action'] ][] = (int) $row['id'];
506 }
507
508 $log = get_option( DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION, array() );
509 $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array();
510
511 foreach ( $grouped as $group_key => $ids ) {
512 list( $type, $action ) = explode( '|', $group_key, 2 );
513 $entries[] = array(
514 'ts' => $now,
515 'type' => $type,
516 'action' => $action,
517 'ids' => array_values( array_unique( $ids ) ),
518 );
519 }
520
521 // Prune: drop entries older than the retention window, cap the
522 // tail. The option stays small no matter how chatty the site.
523 $cutoff = $now - DESKTOP_MODE_CONTENT_CHANGES_LOG_WINDOW_MS;
524 $entries = array_values( array_filter( $entries, function ( $entry ) use ( $cutoff ) {
525 return isset( $entry['ts'] ) && (int) $entry['ts'] >= $cutoff;
526 } ) );
527 if ( count( $entries ) > DESKTOP_MODE_CONTENT_CHANGES_LOG_MAX ) {
528 $entries = array_slice( $entries, -DESKTOP_MODE_CONTENT_CHANGES_LOG_MAX );
529 }
530
531 update_option(
532 DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION,
533 array(
534 'ts' => $now,
535 'entries' => $entries,
536 ),
537 false
538 );
539 }
540
541 if ( $state['flushed'] || empty( $state['log'] ) ) {
542 return;
543 }
544 $user_id = get_current_user_id();
545 if ( $user_id <= 0 ) {
546 return;
547 }
548
549 $key = desktop_mode_content_changes_buffer_key( $user_id );
550 $existing = get_transient( $key );
551 $merged = desktop_mode_content_changes_merge( is_array( $existing ) ? $existing : array(), $state['log'] );
552 set_transient( $key, $merged, DESKTOP_MODE_CONTENT_CHANGES_BUFFER_TTL );
553 }
554
555 /**
556 * Heartbeat handler — answers "which content changed since you last
557 * heard from me?".
558 *
559 * Opt-in via the client-sent `desktop_mode_content_changes_seen_ts`
560 * key; requests without it early-return so non-desktop tabs pay zero
561 * per tick. The response carries the server high-water mark plus the
562 * entries newer than the client's seen ts; the shell re-broadcasts
563 * each as `desktop-mode.<type>.changed`.
564 *
565 * @since 0.9.7
566 *
567 * @param array $response Heartbeat response.
568 * @param array $data Client-sent payload.
569 * @return array
570 */
571 function desktop_mode_content_changes_heartbeat_received( $response, $data ) {
572 if ( ! is_array( $response ) ) {
573 $response = array();
574 }
575 if ( ! isset( $data['desktop_mode_content_changes_seen_ts'] ) ) {
576 return $response;
577 }
578
579 $seen = (int) $data['desktop_mode_content_changes_seen_ts'];
580 $log = get_option( DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION, array() );
581
582 $ts = ( is_array( $log ) && isset( $log['ts'] ) ) ? (int) $log['ts'] : 0;
583 $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array();
584
585 $fresh = array();
586 foreach ( $entries as $entry ) {
587 if ( isset( $entry['ts'] ) && (int) $entry['ts'] > $seen ) {
588 $fresh[] = $entry;
589 }
590 }
591
592 $response['desktop_mode_content_changes'] = array(
593 'ts' => $ts,
594 'entries' => $fresh,
595 );
596
597 return $response;
598 }
599
600 /**
601 * Wires every content-change hook.
602 *
603 * One bootstrap so the wiring is auditable —
604 * `grep desktop_mode_content_changes_record` finds every emitter.
605 *
606 * @since 0.9.7
607 */
608 function desktop_mode_content_changes_register_hooks() {
609 add_action( 'wp_after_insert_post', 'desktop_mode_content_changes_on_after_insert_post', 10, 4 );
610
611 add_action( 'wp_insert_comment', function ( $comment_id ) {
612 desktop_mode_content_changes_record( 'comment', (int) $comment_id, 'created' );
613 } );
614 add_action( 'edit_comment', function ( $comment_id ) {
615 desktop_mode_content_changes_record( 'comment', (int) $comment_id, 'updated' );
616 } );
617 add_action( 'transition_comment_status', 'desktop_mode_content_changes_on_comment_transition', 10, 3 );
618
619 desktop_mode_content_changes_register_wc_hooks();
620
621 add_action( 'admin_footer', 'desktop_mode_content_changes_emit_footer', 100 );
622 add_action( 'shutdown', 'desktop_mode_content_changes_on_shutdown' );
623 add_filter( 'heartbeat_received', 'desktop_mode_content_changes_heartbeat_received', 10, 2 );
624 }
625 add_action( 'init', 'desktop_mode_content_changes_register_hooks', 5 );
626