PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/content-changes.php +231 -137 0.9.61.1.10 View file →
@@ -1,12 +1,12 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — generic content-change realtime layer.
3 + * OpenStation — generic content-change realtime layer.
4 4 *
5 5 * Records create/update/trash mutations of posts, pages, CPTs,
6 6 * comments, and WooCommerce orders into a per-request changelog and
7 7 * relays them to the parent shell as cross-window broadcasts
8 - * (`desktop-mode.<type>.changed`, payload `{ source, action, ids }`),
8 + * (`os.<type>.changed`, payload `{ source, action, ids }`),
9 9 * so every window listing that content type can refresh without an F5.
10 10 *
11 11 * Three delivery paths, mirroring the Recycle Bin realtime layer
12 12 * (`includes/recycle-bin/realtime.php`) which delegates its own
@@ -13,9 +13,9 @@
13 13 * changelog into this module:
14 14 *
15 15 * 1. **Chromeless footer — fast path.** At `admin_footer` on a
16 16 * chromeless render, one inline script postMessages a
17 - * `desktop-mode-broadcast` envelope per `[type][action]` entry
17 + * `os-broadcast` envelope per `[type][action]` entry
18 18 * to the parent shell. Because the dominant admin mutation flow
19 19 * is form-POST → 302 → GET (the mutating request renders no
20 20 * footer), the changelog is buffered across the redirect in a
21 21 * short-TTL per-user transient and flushed on the next
@@ -32,36 +32,36 @@
32 32 * "entries newer than your seen ts" for opted-in shells. Covers
33 33 * Quick Edit, AJAX status flips, other browser tabs, REST and
34 34 * WP-CLI mutations within one tick (15–60 s).
35 35 *
36 - * @package WPDesktopMode
37 - * @since 0.9.7
36 + * @package OpenStation
38 37 */
39 38
40 39 defined( 'ABSPATH' ) || exit;
41 40
42 -const DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION = '_desktop_mode_content_changes_log';
41 +/**
42 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
43 + * persisted or externally-visible identifier, so renaming it would
44 + * orphan data already written by live installs (or break a live
45 + * URL). The mismatch between this constant's name and its value is
46 + * deliberate — it is NOT a half-finished rename.
47 + */
48 +const OPENSTATION_CONTENT_CHANGES_LOG_OPTION = '_desktop_mode_content_changes_log';
43 49
44 50 /**
45 51 * Milliseconds of heartbeat-changelog history to retain.
46 - *
47 - * @since 0.9.7
48 52 */
49 -const DESKTOP_MODE_CONTENT_CHANGES_LOG_WINDOW_MS = 300000;
53 +const OPENSTATION_CONTENT_CHANGES_LOG_WINDOW_MS = 300000;
50 54
51 55 /**
52 56 * Maximum retained heartbeat-changelog entries.
53 - *
54 - * @since 0.9.7
55 57 */
56 -const DESKTOP_MODE_CONTENT_CHANGES_LOG_MAX = 100;
58 +const OPENSTATION_CONTENT_CHANGES_LOG_MAX = 100;
57 59
58 60 /**
59 61 * TTL for the redirect-surviving per-user changelog buffer.
60 - *
61 - * @since 0.9.7
62 62 */
63 -const DESKTOP_MODE_CONTENT_CHANGES_BUFFER_TTL = 60;
63 +const OPENSTATION_CONTENT_CHANGES_BUFFER_TTL = 60;
64 64
65 65 /**
66 66 * Per-request state shared by the recorder, the footer emitter, and
67 67 * the shutdown handler.
@@ -75,13 +75,11 @@
75 75 * - `staged` — flat `{ type, action, id }` rows for the heartbeat log.
76 76 * - `flushed` — true once the footer emitter consumed `log`, so the
77 77 * shutdown handler doesn't buffer it a second time.
78 78 *
79 - * @since 0.9.7
80 - *
81 79 * @return array Per-request state, by reference.
82 80 */
83 -function &desktop_mode_content_changes_state() {
81 +function &openstation_content_changes_state() {
84 82 static $state = null;
85 83 if ( null === $state ) {
86 84 $state = array(
87 85 'log' => array(),
@@ -95,13 +93,12 @@
95 93
96 94 /**
97 95 * Resets the per-request state. Test isolation only.
98 96 *
99 - * @since 0.9.7
100 97 * @internal
101 98 */
102 -function desktop_mode_content_changes_reset() {
103 - $state = &desktop_mode_content_changes_state();
99 +function openstation_content_changes_reset() {
100 + $state = &openstation_content_changes_state();
104 101 $state = array(
105 102 'log' => array(),
106 103 'seen' => array(),
107 104 'staged' => array(),
@@ -115,9 +112,9 @@
115 112 * This is the public entry point for third-party plugins with their
116 113 * own storage (an HPOS-style custom table, a settings screen, …):
117 114 * call it from your mutation path and every open window listing your
118 115 * type refreshes, exactly like core content. Pair it with a
119 - * `desktop_mode_soft_reload_rules` filter entry if your list screen
116 + * `openstation_soft_reload_rules` filter entry if your list screen
120 117 * is not a standard `edit.php?post_type=<type>` page.
121 118 *
122 119 * Dedupe is first-writer-wins per `type:id` within the request: core
123 120 * fires the trash verbs (`wp_trash_post`, `untrash_post`) BEFORE the
@@ -125,19 +122,17 @@
125 122 * specific verb is recorded and the follow-up `updated` for the same
126 123 * id is dropped. The same mechanism collapses WooCommerce legacy-mode
127 124 * double-fires (post hooks + `woocommerce_update_order`).
128 125 *
129 - * @since 0.9.7
130 - *
131 126 * @param string $type Content type slug — a post type, `comment`,
132 127 * or `shop_order`. Becomes the broadcast topic
133 - * `desktop-mode.<type>.changed`.
128 + * `os.<type>.changed`.
134 129 * @param int $id Mutated object id.
135 130 * @param string $action One of 'created', 'updated', 'trashed',
136 131 * 'untrashed', 'deleted'.
137 132 * @return bool Whether the change was recorded.
138 133 */
139 -function desktop_mode_content_changes_record( $type, $id, $action ) {
134 +function openstation_content_changes_record( $type, $id, $action ) {
140 135 $type = (string) $type;
141 136 $id = (int) $id;
142 137 $action = (string) $action;
143 138
@@ -153,20 +148,18 @@
153 148 * system entirely (footer broadcast AND heartbeat log) — e.g. a
154 149 * high-churn internal type whose list windows manage their own
155 150 * realtime.
156 151 *
157 - * @since 0.9.7
158 - *
159 152 * @param bool $record Whether to record. Default true.
160 153 * @param string $type Content type slug.
161 154 * @param int $id Mutated object id.
162 155 * @param string $action Verb (created/updated/trashed/untrashed/deleted).
163 156 */
164 - if ( ! apply_filters( 'desktop_mode_content_changes_should_record', true, $type, $id, $action ) ) {
157 + if ( ! apply_filters( 'openstation_content_changes_should_record', true, $type, $id, $action ) ) {
165 158 return false;
166 159 }
167 160
168 - $state = &desktop_mode_content_changes_state();
161 + $state = &openstation_content_changes_state();
169 162 $key = $type . ':' . $id;
170 163 if ( isset( $state['seen'][ $key ] ) ) {
171 164 return false;
172 165 }
@@ -192,15 +185,13 @@
192 185 *
193 186 * Subscribers can push their own real-time signal (websocket,
194 187 * SSE, …) without re-hooking every mutation path individually.
195 188 *
196 - * @since 0.9.7
197 - *
198 189 * @param string $type Content type slug.
199 190 * @param int $id Mutated object id.
200 191 * @param string $action Verb (created/updated/trashed/untrashed/deleted).
201 192 */
202 - do_action( 'desktop_mode_content_change_recorded', $type, $id, $action );
193 + do_action( 'openstation_content_change_recorded', $type, $id, $action );
203 194
204 195 return true;
205 196 }
206 197
@@ -206,14 +197,12 @@
206 197
207 198 /**
208 199 * Returns the per-request changelog: `[ type ][ action ] = int[] ids`.
209 200 *
210 - * @since 0.9.7
211 - *
212 201 * @return array
213 202 */
214 -function desktop_mode_content_changes_log() {
215 - $state = &desktop_mode_content_changes_state();
203 +function openstation_content_changes_log() {
204 + $state = &openstation_content_changes_state();
216 205 return $state['log'];
217 206 }
218 207
219 208 /**
@@ -219,18 +208,16 @@
219 208 /**
220 209 * Merges changelog `$b` into changelog `$a` (both
221 210 * `[ type ][ action ] = int[] ids`).
222 211 *
223 - * @since 0.9.7
224 - *
225 212 * @param array $a Base changelog.
226 213 * @param array $b Changelog to merge in.
227 214 * @return array
228 215 */
229 -function desktop_mode_content_changes_merge( $a, $b ) {
216 +function openstation_content_changes_merge( $a, $b ) {
230 217 foreach ( (array) $b as $type => $by_action ) {
231 218 foreach ( (array) $by_action as $action => $ids ) {
232 - $existing = isset( $a[ $type ][ $action ] ) ? (array) $a[ $type ][ $action ] : array();
219 + $existing = isset( $a[ $type ][ $action ] ) ? (array) $a[ $type ][ $action ] : array();
233 220 $a[ $type ][ $action ] = array_values( array_unique( array_merge( $existing, array_map( 'intval', (array) $ids ) ) ) );
234 221 }
235 222 }
236 223 return $a;
@@ -238,15 +225,13 @@
238 225
239 226 /**
240 227 * Transient key of the redirect-surviving changelog buffer for a user.
241 228 *
242 - * @since 0.9.7
243 - *
244 229 * @param int $user_id User id.
245 230 * @return string
246 231 */
247 -function desktop_mode_content_changes_buffer_key( $user_id ) {
248 - return 'desktop_mode_content_buf_' . (int) $user_id;
232 +function openstation_content_changes_buffer_key( $user_id ) {
233 + return 'openstation_content_buf_' . (int) $user_id;
249 234 }
250 235
251 236 /**
252 237 * `wp_after_insert_post` handler — posts, pages, and every `show_ui`
@@ -260,18 +245,16 @@
260 245 *
261 246 * Post types without `show_ui` are skipped: they have no list screen
262 247 * to refresh, and internal types (notes, …) run their own realtime.
263 248 * Plugins that want one tracked anyway can call
264 - * `desktop_mode_content_changes_record()` from their own hooks.
249 + * `openstation_content_changes_record()` from their own hooks.
265 250 *
266 - * @since 0.9.7
267 - *
268 251 * @param int $post_id Post id.
269 252 * @param WP_Post $post Saved post.
270 253 * @param bool $update Whether this is an update.
271 254 * @param WP_Post|null $post_before Pre-save post, null on creation.
272 255 */
273 -function desktop_mode_content_changes_on_after_insert_post( $post_id, $post, $update, $post_before ) {
256 +function openstation_content_changes_on_after_insert_post( $post_id, $post, $update, $post_before ) {
274 257 if ( ! $post instanceof WP_Post ) {
275 258 return;
276 259 }
277 260 if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
@@ -291,9 +274,9 @@
291 274 // The first real save of a new post arrives as an "update" of the
292 275 // auto-draft shell `post-new.php` created — report it as created.
293 276 $is_created = ! $update || ( $post_before instanceof WP_Post && 'auto-draft' === $post_before->post_status );
294 277
295 - desktop_mode_content_changes_record( $post->post_type, (int) $post_id, $is_created ? 'created' : 'updated' );
278 + openstation_content_changes_record( $post->post_type, (int) $post_id, $is_created ? 'created' : 'updated' );
296 279 }
297 280
298 281 /**
299 282 * `transition_comment_status` handler.
@@ -302,15 +285,13 @@
302 285 * `trashed_comment` / `untrashed_comment` hooks record those verbs,
303 286 * and they fire AFTER the transition — without the skip the dedupe
304 287 * set would keep this handler's less-specific `updated`.
305 288 *
306 - * @since 0.9.7
307 - *
308 289 * @param string $new_status New comment status.
309 290 * @param string $old_status Old comment status.
310 291 * @param WP_Comment $comment Comment object.
311 292 */
312 -function desktop_mode_content_changes_on_comment_transition( $new_status, $old_status, $comment ) {
293 +function openstation_content_changes_on_comment_transition( $new_status, $old_status, $comment ) {
313 294 if ( 'trash' === $new_status || 'trash' === $old_status ) {
314 295 return;
315 296 }
316 297 if ( ! $comment instanceof WP_Comment ) {
@@ -315,9 +296,9 @@
315 296 }
316 297 if ( ! $comment instanceof WP_Comment ) {
317 298 return;
318 299 }
319 - desktop_mode_content_changes_record( 'comment', (int) $comment->comment_ID, 'updated' );
300 + openstation_content_changes_record( 'comment', (int) $comment->comment_ID, 'updated' );
320 301 }
321 302
322 303 /**
323 304 * Wires the WooCommerce order hooks. No-op unless WooCommerce is
@@ -327,40 +308,56 @@
327 308 * in custom tables and none of the post hooks fire. Under legacy
328 309 * (posts-table) storage the post hooks fire too; the recorder's
329 310 * per-request dedupe collapses the double-fire. The type is always
330 311 * recorded as `shop_order` so one broadcast topic
331 - * (`desktop-mode.shop_order.changed`) serves both storage modes.
312 + * (`os.shop_order.changed`) serves both storage modes.
332 313 *
333 - * @since 0.9.7
334 - *
335 314 * @return bool Whether the hooks were registered.
336 315 */
337 -function desktop_mode_content_changes_register_wc_hooks() {
316 +function openstation_content_changes_register_wc_hooks() {
338 317 if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_get_order' ) ) {
339 318 return false;
340 319 }
341 320
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 - } );
321 + add_action(
322 + 'woocommerce_new_order',
323 + function ( $order_id ) {
324 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'created' );
325 + }
326 + );
327 + add_action(
328 + 'woocommerce_update_order',
329 + function ( $order_id ) {
330 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'updated' );
331 + }
332 + );
348 333 // Some AJAX status-flip paths reach `woocommerce_order_status_changed`
349 334 // without `woocommerce_update_order`; the dedupe set absorbs the
350 335 // 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 - } );
336 + add_action(
337 + 'woocommerce_order_status_changed',
338 + function ( $order_id ) {
339 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'updated' );
340 + }
341 + );
342 + add_action(
343 + 'woocommerce_trash_order',
344 + function ( $order_id ) {
345 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'trashed' );
346 + }
347 + );
348 + add_action(
349 + 'woocommerce_untrash_order',
350 + function ( $order_id ) {
351 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'untrashed' );
352 + }
353 + );
354 + add_action(
355 + 'woocommerce_delete_order',
356 + function ( $order_id ) {
357 + openstation_content_changes_record( 'shop_order', (int) $order_id, 'deleted' );
358 + }
359 + );
363 360
364 361 return true;
365 362 }
366 363
@@ -370,31 +367,29 @@
370 367 * Merges the in-memory changelog with the redirect-surviving buffer
371 368 * (consumed on read), builds one broadcast envelope per
372 369 * `[ type ][ action ]`, and prints one inline script that postMessages
373 370 * each to the parent shell. The parent's broadcast receiver fans them
374 - * out as `desktop-mode.<type>.changed` — iframe list pages soft-reload
371 + * out as `os.<type>.changed` — iframe list pages soft-reload
375 372 * and native list windows refetch.
376 373 *
377 374 * Runs at `admin_footer` priority 100, same slot as the Recycle Bin's
378 375 * bin-specific ts signal.
379 - *
380 - * @since 0.9.7
381 376 */
382 -function desktop_mode_content_changes_emit_footer() {
383 - if ( ! function_exists( 'desktop_mode_is_chromeless_request' ) || ! desktop_mode_is_chromeless_request() ) {
377 +function openstation_content_changes_emit_footer() {
378 + if ( ! function_exists( 'openstation_is_chromeless_request' ) || ! openstation_is_chromeless_request() ) {
384 379 return;
385 380 }
386 381
387 - $state = &desktop_mode_content_changes_state();
382 + $state = &openstation_content_changes_state();
388 383 $log = $state['log'];
389 384
390 385 $user_id = get_current_user_id();
391 386 if ( $user_id > 0 ) {
392 - $key = desktop_mode_content_changes_buffer_key( $user_id );
387 + $key = openstation_content_changes_buffer_key( $user_id );
393 388 $buffered = get_transient( $key );
394 389 if ( is_array( $buffered ) && ! empty( $buffered ) ) {
395 390 delete_transient( $key );
396 - $log = desktop_mode_content_changes_merge( $buffered, $log );
391 + $log = openstation_content_changes_merge( $buffered, $log );
397 392 }
398 393 }
399 394
400 395 // The in-memory log is consumed regardless of whether anything is
@@ -411,15 +406,13 @@
411 406 foreach ( $by_action as $action => $ids ) {
412 407 /**
413 408 * Filters the broadcast topic for a content-change type.
414 409 *
415 - * @since 0.9.7
416 - *
417 - * @param string $topic Default `desktop-mode.<type>.changed`.
410 + * @param string $topic Default `os.<type>.changed`.
418 411 * @param string $type Content type slug.
419 412 * @param string $action Verb for this envelope.
420 413 */
421 - $topic = (string) apply_filters( 'desktop_mode_content_change_topic', 'desktop-mode.' . $type . '.changed', $type, $action );
414 + $topic = (string) apply_filters( 'openstation_content_change_topic', 'os.' . $type . '.changed', $type, $action );
422 415
423 416 $broadcasts[] = array(
424 417 'topic' => $topic,
425 418 'payload' => array(
@@ -437,13 +430,11 @@
437 430 *
438 431 * Each entry is `array( 'topic' => string, 'payload' => array )`.
439 432 * Return an empty array to suppress the emit.
440 433 *
441 - * @since 0.9.7
442 - *
443 434 * @param array $broadcasts Broadcast envelopes.
444 435 */
445 - $broadcasts = (array) apply_filters( 'desktop_mode_content_changes_broadcasts', $broadcasts );
436 + $broadcasts = (array) apply_filters( 'openstation_content_changes_broadcasts', $broadcasts );
446 437 if ( empty( $broadcasts ) ) {
447 438 return;
448 439 }
449 440
@@ -452,9 +443,9 @@
452 443 return;
453 444 }
454 445
455 446 ?>
456 - <script id="desktop-mode-content-changes-signal">
447 + <script id="os-content-changes-signal">
457 448 ( function () {
458 449 if ( window.parent === window ) {
459 450 return;
460 451 }
@@ -462,9 +453,9 @@
462 453 var broadcasts = <?php echo $broadcasts_json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output. ?>;
463 454 for ( var i = 0; i < broadcasts.length; i++ ) {
464 455 try {
465 456 window.parent.postMessage( {
466 - type: 'desktop-mode-broadcast',
457 + type: 'os-broadcast',
467 458 topic: broadcasts[ i ].topic,
468 459 payload: broadcasts[ i ].payload
469 460 }, origin );
470 461 } catch ( _err ) { /* parent gone */ }
@@ -476,13 +467,11 @@
476 467 /**
477 468 * Fires after the chromeless footer emitted the content-change
478 469 * broadcast envelopes.
479 470 *
480 - * @since 0.9.7
481 - *
482 471 * @param array $broadcasts Emitted broadcast envelopes.
483 472 */
484 - do_action( 'desktop_mode_content_changes_emitted', $broadcasts );
473 + do_action( 'openstation_content_changes_emitted', $broadcasts );
485 474 }
486 475
487 476 /**
488 477 * `shutdown` handler — persists the heartbeat changelog and buffers
@@ -489,13 +478,11 @@
489 478 * an unflushed in-request changelog across the coming redirect.
490 479 *
491 480 * One `update_option` per mutating request; requests that recorded
492 481 * nothing pay a static-array read and return.
493 - *
494 - * @since 0.9.7
495 482 */
496 -function desktop_mode_content_changes_on_shutdown() {
497 - $state = &desktop_mode_content_changes_state();
483 +function openstation_content_changes_on_shutdown() {
484 + $state = &openstation_content_changes_state();
498 485
499 486 if ( ! empty( $state['staged'] ) ) {
500 487 $now = (int) round( microtime( true ) * 1000 );
501 488
@@ -504,9 +491,9 @@
504 491 foreach ( $state['staged'] as $row ) {
505 492 $grouped[ $row['type'] . '|' . $row['action'] ][] = (int) $row['id'];
506 493 }
507 494
508 - $log = get_option( DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION, array() );
495 + $log = get_option( OPENSTATION_CONTENT_CHANGES_LOG_OPTION, array() );
509 496 $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array();
510 497
511 498 foreach ( $grouped as $group_key => $ids ) {
512 499 list( $type, $action ) = explode( '|', $group_key, 2 );
@@ -519,18 +506,23 @@
519 506 }
520 507
521 508 // Prune: drop entries older than the retention window, cap the
522 509 // 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 );
510 + $cutoff = $now - OPENSTATION_CONTENT_CHANGES_LOG_WINDOW_MS;
511 + $entries = array_values(
512 + array_filter(
513 + $entries,
514 + function ( $entry ) use ( $cutoff ) {
515 + return isset( $entry['ts'] ) && (int) $entry['ts'] >= $cutoff;
516 + }
517 + )
518 + );
519 + if ( count( $entries ) > OPENSTATION_CONTENT_CHANGES_LOG_MAX ) {
520 + $entries = array_slice( $entries, -OPENSTATION_CONTENT_CHANGES_LOG_MAX );
529 521 }
530 522
531 523 update_option(
532 - DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION,
524 + OPENSTATION_CONTENT_CHANGES_LOG_OPTION,
533 525 array(
534 526 'ts' => $now,
535 527 'entries' => $entries,
536 528 ),
@@ -545,12 +537,12 @@
545 537 if ( $user_id <= 0 ) {
546 538 return;
547 539 }
548 540
549 - $key = desktop_mode_content_changes_buffer_key( $user_id );
541 + $key = openstation_content_changes_buffer_key( $user_id );
550 542 $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 );
543 + $merged = openstation_content_changes_merge( is_array( $existing ) ? $existing : array(), $state['log'] );
544 + set_transient( $key, $merged, OPENSTATION_CONTENT_CHANGES_BUFFER_TTL );
553 545 }
554 546
555 547 /**
556 548 * Heartbeat handler — answers "which content changed since you last
@@ -555,30 +547,28 @@
555 547 /**
556 548 * Heartbeat handler — answers "which content changed since you last
557 549 * heard from me?".
558 550 *
559 - * Opt-in via the client-sent `desktop_mode_content_changes_seen_ts`
551 + * Opt-in via the client-sent `openstation_content_changes_seen_ts`
560 552 * key; requests without it early-return so non-desktop tabs pay zero
561 553 * per tick. The response carries the server high-water mark plus the
562 554 * entries newer than the client's seen ts; the shell re-broadcasts
563 - * each as `desktop-mode.<type>.changed`.
555 + * each as `os.<type>.changed`.
564 556 *
565 - * @since 0.9.7
566 - *
567 557 * @param array $response Heartbeat response.
568 558 * @param array $data Client-sent payload.
569 559 * @return array
570 560 */
571 -function desktop_mode_content_changes_heartbeat_received( $response, $data ) {
561 +function openstation_content_changes_heartbeat_received( $response, $data ) {
572 562 if ( ! is_array( $response ) ) {
573 563 $response = array();
574 564 }
575 - if ( ! isset( $data['desktop_mode_content_changes_seen_ts'] ) ) {
565 + if ( ! isset( $data['openstation_content_changes_seen_ts'] ) ) {
576 566 return $response;
577 567 }
578 568
579 - $seen = (int) $data['desktop_mode_content_changes_seen_ts'];
580 - $log = get_option( DESKTOP_MODE_CONTENT_CHANGES_LOG_OPTION, array() );
569 + $seen = (int) $data['openstation_content_changes_seen_ts'];
570 + $log = get_option( OPENSTATION_CONTENT_CHANGES_LOG_OPTION, array() );
581 571
582 572 $ts = ( is_array( $log ) && isset( $log['ts'] ) ) ? (int) $log['ts'] : 0;
583 573 $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array();
584 574
@@ -588,9 +578,9 @@
588 578 $fresh[] = $entry;
589 579 }
590 580 }
591 581
592 - $response['desktop_mode_content_changes'] = array(
582 + $response['openstation_content_changes'] = array(
593 583 'ts' => $ts,
594 584 'entries' => $fresh,
595 585 );
596 586
@@ -597,29 +587,133 @@
597 587 return $response;
598 588 }
599 589
600 590 /**
591 + * Converts a plugin file path to a stable positive integer suitable
592 + * for use as the `$id` parameter of
593 + * `openstation_content_changes_record()`.
594 + *
595 + * The record function requires a positive integer for its ID slot (used
596 + * for per-request deduplication keyed as `type:id`). Plugin files are
597 + * strings (`akismet/akismet.php`), so we derive a deterministic integer
598 + * via `crc32`. Using the actual hash rather than a fixed value (e.g. 1)
599 + * prevents every distinct plugin in a bulk operation from collapsing to
600 + * the same dedup key.
601 + *
602 + * @param string $plugin_file Plugin file path (relative to plugins dir).
603 + * @return int Positive integer ID.
604 + */
605 +function openstation_content_changes_plugin_id( $plugin_file ) {
606 + $hash = abs( crc32( (string) $plugin_file ) );
607 + return max( 1, $hash );
608 +}
609 +
610 +/**
611 + * Wires plugin lifecycle hooks so installs, activations, deactivations,
612 + * and deletions are recorded into the realtime changelog.
613 + *
614 + * Every open window listing plugins (native Plugins window Installed tab,
615 + * classic `plugins.php`) will then refresh via the
616 + * `os.plugin.changed` broadcast — the same mechanism
617 + * posts/pages use for `os.post.changed`.
618 + */
619 +function openstation_content_changes_register_plugin_hooks() {
620 + add_action(
621 + 'activated_plugin',
622 + function ( $plugin_file ) {
623 + openstation_content_changes_record(
624 + 'plugin',
625 + openstation_content_changes_plugin_id( $plugin_file ),
626 + 'activated'
627 + );
628 + }
629 + );
630 +
631 + add_action(
632 + 'deactivated_plugin',
633 + function ( $plugin_file ) {
634 + openstation_content_changes_record(
635 + 'plugin',
636 + openstation_content_changes_plugin_id( $plugin_file ),
637 + 'deactivated'
638 + );
639 + }
640 + );
641 +
642 + add_action(
643 + 'deleted_plugin',
644 + function ( $plugin_file, $deleted ) {
645 + if ( $deleted ) {
646 + openstation_content_changes_record(
647 + 'plugin',
648 + openstation_content_changes_plugin_id( $plugin_file ),
649 + 'deleted'
650 + );
651 + }
652 + },
653 + 10,
654 + 2
655 + );
656 +
657 + // `upgrader_process_complete` covers installs from wp-admin/plugin-install.php
658 + // (AJAX path, no page navigation) and bulk installs from update.php.
659 + add_action(
660 + 'upgrader_process_complete',
661 + function ( $upgrader, $options ) {
662 + if (
663 + ! isset( $options['type'], $options['action'] ) ||
664 + 'plugin' !== $options['type'] ||
665 + 'install' !== $options['action']
666 + ) {
667 + return;
668 + }
669 + $plugins = ! empty( $options['plugins'] ) ? (array) $options['plugins'] : array();
670 + if ( empty( $plugins ) && is_callable( array( $upgrader, 'plugin_info' ) ) ) {
671 + $info = $upgrader->plugin_info();
672 + if ( $info ) {
673 + $plugins = array( $info );
674 + }
675 + }
676 + foreach ( $plugins as $plugin_file ) {
677 + openstation_content_changes_record(
678 + 'plugin',
679 + openstation_content_changes_plugin_id( (string) $plugin_file ),
680 + 'installed'
681 + );
682 + }
683 + },
684 + 10,
685 + 2
686 + );
687 +}
688 +
689 +/**
601 690 * Wires every content-change hook.
602 691 *
603 692 * One bootstrap so the wiring is auditable —
604 - * `grep desktop_mode_content_changes_record` finds every emitter.
605 - *
606 - * @since 0.9.7
693 + * `grep openstation_content_changes_record` finds every emitter.
607 694 */
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 );
695 +function openstation_content_changes_register_hooks() {
696 + add_action( 'wp_after_insert_post', 'openstation_content_changes_on_after_insert_post', 10, 4 );
610 697
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 );
698 + add_action(
699 + 'wp_insert_comment',
700 + function ( $comment_id ) {
701 + openstation_content_changes_record( 'comment', (int) $comment_id, 'created' );
702 + }
703 + );
704 + add_action(
705 + 'edit_comment',
706 + function ( $comment_id ) {
707 + openstation_content_changes_record( 'comment', (int) $comment_id, 'updated' );
708 + }
709 + );
710 + add_action( 'transition_comment_status', 'openstation_content_changes_on_comment_transition', 10, 3 );
618 711
619 - desktop_mode_content_changes_register_wc_hooks();
712 + openstation_content_changes_register_wc_hooks();
713 + openstation_content_changes_register_plugin_hooks();
620 714
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 );
715 + add_action( 'admin_footer', 'openstation_content_changes_emit_footer', 100 );
716 + add_action( 'shutdown', 'openstation_content_changes_on_shutdown' );
717 + add_filter( 'heartbeat_received', 'openstation_content_changes_heartbeat_received', 10, 2 );
624 718 }
625 -add_action( 'init', 'desktop_mode_content_changes_register_hooks', 5 );
719 +add_action( 'init', 'openstation_content_changes_register_hooks', 5 );