| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — 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 |
* (`os.<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 |
* `os-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 OpenStation |
| 37 |
*/ |
| 38 |
|
| 39 |
defined( 'ABSPATH' ) || exit; |
| 40 |
|
| 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'; |
| 49 |
|
| 50 |
/** |
| 51 |
* Milliseconds of heartbeat-changelog history to retain. |
| 52 |
*/ |
| 53 |
const OPENSTATION_CONTENT_CHANGES_LOG_WINDOW_MS = 300000; |
| 54 |
|
| 55 |
/** |
| 56 |
* Maximum retained heartbeat-changelog entries. |
| 57 |
*/ |
| 58 |
const OPENSTATION_CONTENT_CHANGES_LOG_MAX = 100; |
| 59 |
|
| 60 |
/** |
| 61 |
* TTL for the redirect-surviving per-user changelog buffer. |
| 62 |
*/ |
| 63 |
const OPENSTATION_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 |
* @return array Per-request state, by reference. |
| 80 |
*/ |
| 81 |
function &openstation_content_changes_state() { |
| 82 |
static $state = null; |
| 83 |
if ( null === $state ) { |
| 84 |
$state = array( |
| 85 |
'log' => array(), |
| 86 |
'seen' => array(), |
| 87 |
'staged' => array(), |
| 88 |
'flushed' => false, |
| 89 |
); |
| 90 |
} |
| 91 |
return $state; |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Resets the per-request state. Test isolation only. |
| 96 |
* |
| 97 |
* @internal |
| 98 |
*/ |
| 99 |
function openstation_content_changes_reset() { |
| 100 |
$state = &openstation_content_changes_state(); |
| 101 |
$state = array( |
| 102 |
'log' => array(), |
| 103 |
'seen' => array(), |
| 104 |
'staged' => array(), |
| 105 |
'flushed' => false, |
| 106 |
); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Records a content change into the per-request changelog. |
| 111 |
* |
| 112 |
* This is the public entry point for third-party plugins with their |
| 113 |
* own storage (an HPOS-style custom table, a settings screen, …): |
| 114 |
* call it from your mutation path and every open window listing your |
| 115 |
* type refreshes, exactly like core content. Pair it with a |
| 116 |
* `openstation_soft_reload_rules` filter entry if your list screen |
| 117 |
* is not a standard `edit.php?post_type=<type>` page. |
| 118 |
* |
| 119 |
* Dedupe is first-writer-wins per `type:id` within the request: core |
| 120 |
* fires the trash verbs (`wp_trash_post`, `untrash_post`) BEFORE the |
| 121 |
* internal status write reaches `wp_after_insert_post`, so the more |
| 122 |
* specific verb is recorded and the follow-up `updated` for the same |
| 123 |
* id is dropped. The same mechanism collapses WooCommerce legacy-mode |
| 124 |
* double-fires (post hooks + `woocommerce_update_order`). |
| 125 |
* |
| 126 |
* @param string $type Content type slug — a post type, `comment`, |
| 127 |
* or `shop_order`. Becomes the broadcast topic |
| 128 |
* `os.<type>.changed`. |
| 129 |
* @param int $id Mutated object id. |
| 130 |
* @param string $action One of 'created', 'updated', 'trashed', |
| 131 |
* 'untrashed', 'deleted'. |
| 132 |
* @return bool Whether the change was recorded. |
| 133 |
*/ |
| 134 |
function openstation_content_changes_record( $type, $id, $action ) { |
| 135 |
$type = (string) $type; |
| 136 |
$id = (int) $id; |
| 137 |
$action = (string) $action; |
| 138 |
|
| 139 |
if ( '' === $type || $id <= 0 || '' === $action ) { |
| 140 |
return false; |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* Filters whether a content change is recorded into the realtime |
| 145 |
* changelog. |
| 146 |
* |
| 147 |
* Return false to keep a mutation out of the cross-window refresh |
| 148 |
* system entirely (footer broadcast AND heartbeat log) — e.g. a |
| 149 |
* high-churn internal type whose list windows manage their own |
| 150 |
* realtime. |
| 151 |
* |
| 152 |
* @param bool $record Whether to record. Default true. |
| 153 |
* @param string $type Content type slug. |
| 154 |
* @param int $id Mutated object id. |
| 155 |
* @param string $action Verb (created/updated/trashed/untrashed/deleted). |
| 156 |
*/ |
| 157 |
if ( ! apply_filters( 'openstation_content_changes_should_record', true, $type, $id, $action ) ) { |
| 158 |
return false; |
| 159 |
} |
| 160 |
|
| 161 |
$state = &openstation_content_changes_state(); |
| 162 |
$key = $type . ':' . $id; |
| 163 |
if ( isset( $state['seen'][ $key ] ) ) { |
| 164 |
return false; |
| 165 |
} |
| 166 |
$state['seen'][ $key ] = true; |
| 167 |
|
| 168 |
if ( ! isset( $state['log'][ $type ] ) ) { |
| 169 |
$state['log'][ $type ] = array(); |
| 170 |
} |
| 171 |
if ( ! isset( $state['log'][ $type ][ $action ] ) ) { |
| 172 |
$state['log'][ $type ][ $action ] = array(); |
| 173 |
} |
| 174 |
$state['log'][ $type ][ $action ][] = $id; |
| 175 |
|
| 176 |
$state['staged'][] = array( |
| 177 |
'type' => $type, |
| 178 |
'action' => $action, |
| 179 |
'id' => $id, |
| 180 |
); |
| 181 |
|
| 182 |
/** |
| 183 |
* Fires after a content change is recorded into the realtime |
| 184 |
* changelog. |
| 185 |
* |
| 186 |
* Subscribers can push their own real-time signal (websocket, |
| 187 |
* SSE, …) without re-hooking every mutation path individually. |
| 188 |
* |
| 189 |
* @param string $type Content type slug. |
| 190 |
* @param int $id Mutated object id. |
| 191 |
* @param string $action Verb (created/updated/trashed/untrashed/deleted). |
| 192 |
*/ |
| 193 |
do_action( 'openstation_content_change_recorded', $type, $id, $action ); |
| 194 |
|
| 195 |
return true; |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Returns the per-request changelog: `[ type ][ action ] = int[] ids`. |
| 200 |
* |
| 201 |
* @return array |
| 202 |
*/ |
| 203 |
function openstation_content_changes_log() { |
| 204 |
$state = &openstation_content_changes_state(); |
| 205 |
return $state['log']; |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Merges changelog `$b` into changelog `$a` (both |
| 210 |
* `[ type ][ action ] = int[] ids`). |
| 211 |
* |
| 212 |
* @param array $a Base changelog. |
| 213 |
* @param array $b Changelog to merge in. |
| 214 |
* @return array |
| 215 |
*/ |
| 216 |
function openstation_content_changes_merge( $a, $b ) { |
| 217 |
foreach ( (array) $b as $type => $by_action ) { |
| 218 |
foreach ( (array) $by_action as $action => $ids ) { |
| 219 |
$existing = isset( $a[ $type ][ $action ] ) ? (array) $a[ $type ][ $action ] : array(); |
| 220 |
$a[ $type ][ $action ] = array_values( array_unique( array_merge( $existing, array_map( 'intval', (array) $ids ) ) ) ); |
| 221 |
} |
| 222 |
} |
| 223 |
return $a; |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* Transient key of the redirect-surviving changelog buffer for a user. |
| 228 |
* |
| 229 |
* @param int $user_id User id. |
| 230 |
* @return string |
| 231 |
*/ |
| 232 |
function openstation_content_changes_buffer_key( $user_id ) { |
| 233 |
return 'openstation_content_buf_' . (int) $user_id; |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* `wp_after_insert_post` handler — posts, pages, and every `show_ui` |
| 238 |
* custom post type. |
| 239 |
* |
| 240 |
* `wp_after_insert_post` (not `save_post`) so terms and meta are |
| 241 |
* already persisted when subscribers refetch. Trash-status writes are |
| 242 |
* skipped — the Recycle Bin hooks own the trash verbs and record them |
| 243 |
* first (`wp_trash_post` fires before the internal status update |
| 244 |
* reaches this hook). |
| 245 |
* |
| 246 |
* Post types without `show_ui` are skipped: they have no list screen |
| 247 |
* to refresh, and internal types (notes, …) run their own realtime. |
| 248 |
* Plugins that want one tracked anyway can call |
| 249 |
* `openstation_content_changes_record()` from their own hooks. |
| 250 |
* |
| 251 |
* @param int $post_id Post id. |
| 252 |
* @param WP_Post $post Saved post. |
| 253 |
* @param bool $update Whether this is an update. |
| 254 |
* @param WP_Post|null $post_before Pre-save post, null on creation. |
| 255 |
*/ |
| 256 |
function openstation_content_changes_on_after_insert_post( $post_id, $post, $update, $post_before ) { |
| 257 |
if ( ! $post instanceof WP_Post ) { |
| 258 |
return; |
| 259 |
} |
| 260 |
if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { |
| 261 |
return; |
| 262 |
} |
| 263 |
if ( function_exists( 'wp_doing_autosave' ) && wp_doing_autosave() ) { |
| 264 |
return; |
| 265 |
} |
| 266 |
if ( in_array( $post->post_status, array( 'auto-draft', 'trash' ), true ) ) { |
| 267 |
return; |
| 268 |
} |
| 269 |
$post_type_object = get_post_type_object( $post->post_type ); |
| 270 |
if ( ! $post_type_object || empty( $post_type_object->show_ui ) ) { |
| 271 |
return; |
| 272 |
} |
| 273 |
|
| 274 |
// The first real save of a new post arrives as an "update" of the |
| 275 |
// auto-draft shell `post-new.php` created — report it as created. |
| 276 |
$is_created = ! $update || ( $post_before instanceof WP_Post && 'auto-draft' === $post_before->post_status ); |
| 277 |
|
| 278 |
openstation_content_changes_record( $post->post_type, (int) $post_id, $is_created ? 'created' : 'updated' ); |
| 279 |
} |
| 280 |
|
| 281 |
/** |
| 282 |
* `transition_comment_status` handler. |
| 283 |
* |
| 284 |
* Trash transitions are skipped in both directions: the Recycle Bin's |
| 285 |
* `trashed_comment` / `untrashed_comment` hooks record those verbs, |
| 286 |
* and they fire AFTER the transition — without the skip the dedupe |
| 287 |
* set would keep this handler's less-specific `updated`. |
| 288 |
* |
| 289 |
* @param string $new_status New comment status. |
| 290 |
* @param string $old_status Old comment status. |
| 291 |
* @param WP_Comment $comment Comment object. |
| 292 |
*/ |
| 293 |
function openstation_content_changes_on_comment_transition( $new_status, $old_status, $comment ) { |
| 294 |
if ( 'trash' === $new_status || 'trash' === $old_status ) { |
| 295 |
return; |
| 296 |
} |
| 297 |
if ( ! $comment instanceof WP_Comment ) { |
| 298 |
return; |
| 299 |
} |
| 300 |
openstation_content_changes_record( 'comment', (int) $comment->comment_ID, 'updated' ); |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* Wires the WooCommerce order hooks. No-op unless WooCommerce is |
| 305 |
* active. |
| 306 |
* |
| 307 |
* The `woocommerce_*` family is required for HPOS, where orders live |
| 308 |
* in custom tables and none of the post hooks fire. Under legacy |
| 309 |
* (posts-table) storage the post hooks fire too; the recorder's |
| 310 |
* per-request dedupe collapses the double-fire. The type is always |
| 311 |
* recorded as `shop_order` so one broadcast topic |
| 312 |
* (`os.shop_order.changed`) serves both storage modes. |
| 313 |
* |
| 314 |
* @return bool Whether the hooks were registered. |
| 315 |
*/ |
| 316 |
function openstation_content_changes_register_wc_hooks() { |
| 317 |
if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_get_order' ) ) { |
| 318 |
return false; |
| 319 |
} |
| 320 |
|
| 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 |
); |
| 333 |
// Some AJAX status-flip paths reach `woocommerce_order_status_changed` |
| 334 |
// without `woocommerce_update_order`; the dedupe set absorbs the |
| 335 |
// overlap when both fire. |
| 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 |
); |
| 360 |
|
| 361 |
return true; |
| 362 |
} |
| 363 |
|
| 364 |
/** |
| 365 |
* Emits the chromeless-footer broadcast script. |
| 366 |
* |
| 367 |
* Merges the in-memory changelog with the redirect-surviving buffer |
| 368 |
* (consumed on read), builds one broadcast envelope per |
| 369 |
* `[ type ][ action ]`, and prints one inline script that postMessages |
| 370 |
* each to the parent shell. The parent's broadcast receiver fans them |
| 371 |
* out as `os.<type>.changed` — iframe list pages soft-reload |
| 372 |
* and native list windows refetch. |
| 373 |
* |
| 374 |
* Runs at `admin_footer` priority 100, same slot as the Recycle Bin's |
| 375 |
* bin-specific ts signal. |
| 376 |
*/ |
| 377 |
function openstation_content_changes_emit_footer() { |
| 378 |
if ( ! function_exists( 'openstation_is_chromeless_request' ) || ! openstation_is_chromeless_request() ) { |
| 379 |
return; |
| 380 |
} |
| 381 |
|
| 382 |
$state = &openstation_content_changes_state(); |
| 383 |
$log = $state['log']; |
| 384 |
|
| 385 |
$user_id = get_current_user_id(); |
| 386 |
if ( $user_id > 0 ) { |
| 387 |
$key = openstation_content_changes_buffer_key( $user_id ); |
| 388 |
$buffered = get_transient( $key ); |
| 389 |
if ( is_array( $buffered ) && ! empty( $buffered ) ) { |
| 390 |
delete_transient( $key ); |
| 391 |
$log = openstation_content_changes_merge( $buffered, $log ); |
| 392 |
} |
| 393 |
} |
| 394 |
|
| 395 |
// The in-memory log is consumed regardless of whether anything is |
| 396 |
// emitted — the shutdown handler must not re-buffer what the |
| 397 |
// footer already had the chance to flush. |
| 398 |
$state['flushed'] = true; |
| 399 |
|
| 400 |
if ( empty( $log ) ) { |
| 401 |
return; |
| 402 |
} |
| 403 |
|
| 404 |
$broadcasts = array(); |
| 405 |
foreach ( $log as $type => $by_action ) { |
| 406 |
foreach ( $by_action as $action => $ids ) { |
| 407 |
/** |
| 408 |
* Filters the broadcast topic for a content-change type. |
| 409 |
* |
| 410 |
* @param string $topic Default `os.<type>.changed`. |
| 411 |
* @param string $type Content type slug. |
| 412 |
* @param string $action Verb for this envelope. |
| 413 |
*/ |
| 414 |
$topic = (string) apply_filters( 'openstation_content_change_topic', 'os.' . $type . '.changed', $type, $action ); |
| 415 |
|
| 416 |
$broadcasts[] = array( |
| 417 |
'topic' => $topic, |
| 418 |
'payload' => array( |
| 419 |
'source' => 'admin', |
| 420 |
'action' => (string) $action, |
| 421 |
'ids' => array_values( array_unique( array_map( 'intval', (array) $ids ) ) ), |
| 422 |
), |
| 423 |
); |
| 424 |
} |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* Filters the full set of content-change broadcast envelopes just |
| 429 |
* before the chromeless footer emits them. |
| 430 |
* |
| 431 |
* Each entry is `array( 'topic' => string, 'payload' => array )`. |
| 432 |
* Return an empty array to suppress the emit. |
| 433 |
* |
| 434 |
* @param array $broadcasts Broadcast envelopes. |
| 435 |
*/ |
| 436 |
$broadcasts = (array) apply_filters( 'openstation_content_changes_broadcasts', $broadcasts ); |
| 437 |
if ( empty( $broadcasts ) ) { |
| 438 |
return; |
| 439 |
} |
| 440 |
|
| 441 |
$broadcasts_json = wp_json_encode( array_values( $broadcasts ) ); |
| 442 |
if ( ! $broadcasts_json ) { |
| 443 |
return; |
| 444 |
} |
| 445 |
|
| 446 |
?> |
| 447 |
<script id="os-content-changes-signal"> |
| 448 |
( function () { |
| 449 |
if ( window.parent === window ) { |
| 450 |
return; |
| 451 |
} |
| 452 |
var origin = window.location.origin; |
| 453 |
var broadcasts = <?php echo $broadcasts_json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output. ?>; |
| 454 |
for ( var i = 0; i < broadcasts.length; i++ ) { |
| 455 |
try { |
| 456 |
window.parent.postMessage( { |
| 457 |
type: 'os-broadcast', |
| 458 |
topic: broadcasts[ i ].topic, |
| 459 |
payload: broadcasts[ i ].payload |
| 460 |
}, origin ); |
| 461 |
} catch ( _err ) { /* parent gone */ } |
| 462 |
} |
| 463 |
} )(); |
| 464 |
</script> |
| 465 |
<?php |
| 466 |
|
| 467 |
/** |
| 468 |
* Fires after the chromeless footer emitted the content-change |
| 469 |
* broadcast envelopes. |
| 470 |
* |
| 471 |
* @param array $broadcasts Emitted broadcast envelopes. |
| 472 |
*/ |
| 473 |
do_action( 'openstation_content_changes_emitted', $broadcasts ); |
| 474 |
} |
| 475 |
|
| 476 |
/** |
| 477 |
* `shutdown` handler — persists the heartbeat changelog and buffers |
| 478 |
* an unflushed in-request changelog across the coming redirect. |
| 479 |
* |
| 480 |
* One `update_option` per mutating request; requests that recorded |
| 481 |
* nothing pay a static-array read and return. |
| 482 |
*/ |
| 483 |
function openstation_content_changes_on_shutdown() { |
| 484 |
$state = &openstation_content_changes_state(); |
| 485 |
|
| 486 |
if ( ! empty( $state['staged'] ) ) { |
| 487 |
$now = (int) round( microtime( true ) * 1000 ); |
| 488 |
|
| 489 |
// Group staged rows into per-[type][action] heartbeat entries. |
| 490 |
$grouped = array(); |
| 491 |
foreach ( $state['staged'] as $row ) { |
| 492 |
$grouped[ $row['type'] . '|' . $row['action'] ][] = (int) $row['id']; |
| 493 |
} |
| 494 |
|
| 495 |
$log = get_option( OPENSTATION_CONTENT_CHANGES_LOG_OPTION, array() ); |
| 496 |
$entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array(); |
| 497 |
|
| 498 |
foreach ( $grouped as $group_key => $ids ) { |
| 499 |
list( $type, $action ) = explode( '|', $group_key, 2 ); |
| 500 |
$entries[] = array( |
| 501 |
'ts' => $now, |
| 502 |
'type' => $type, |
| 503 |
'action' => $action, |
| 504 |
'ids' => array_values( array_unique( $ids ) ), |
| 505 |
); |
| 506 |
} |
| 507 |
|
| 508 |
// Prune: drop entries older than the retention window, cap the |
| 509 |
// tail. The option stays small no matter how chatty the site. |
| 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 ); |
| 521 |
} |
| 522 |
|
| 523 |
update_option( |
| 524 |
OPENSTATION_CONTENT_CHANGES_LOG_OPTION, |
| 525 |
array( |
| 526 |
'ts' => $now, |
| 527 |
'entries' => $entries, |
| 528 |
), |
| 529 |
false |
| 530 |
); |
| 531 |
} |
| 532 |
|
| 533 |
if ( $state['flushed'] || empty( $state['log'] ) ) { |
| 534 |
return; |
| 535 |
} |
| 536 |
$user_id = get_current_user_id(); |
| 537 |
if ( $user_id <= 0 ) { |
| 538 |
return; |
| 539 |
} |
| 540 |
|
| 541 |
$key = openstation_content_changes_buffer_key( $user_id ); |
| 542 |
$existing = get_transient( $key ); |
| 543 |
$merged = openstation_content_changes_merge( is_array( $existing ) ? $existing : array(), $state['log'] ); |
| 544 |
set_transient( $key, $merged, OPENSTATION_CONTENT_CHANGES_BUFFER_TTL ); |
| 545 |
} |
| 546 |
|
| 547 |
/** |
| 548 |
* Heartbeat handler — answers "which content changed since you last |
| 549 |
* heard from me?". |
| 550 |
* |
| 551 |
* Opt-in via the client-sent `openstation_content_changes_seen_ts` |
| 552 |
* key; requests without it early-return so non-desktop tabs pay zero |
| 553 |
* per tick. The response carries the server high-water mark plus the |
| 554 |
* entries newer than the client's seen ts; the shell re-broadcasts |
| 555 |
* each as `os.<type>.changed`. |
| 556 |
* |
| 557 |
* @param array $response Heartbeat response. |
| 558 |
* @param array $data Client-sent payload. |
| 559 |
* @return array |
| 560 |
*/ |
| 561 |
function openstation_content_changes_heartbeat_received( $response, $data ) { |
| 562 |
if ( ! is_array( $response ) ) { |
| 563 |
$response = array(); |
| 564 |
} |
| 565 |
if ( ! isset( $data['openstation_content_changes_seen_ts'] ) ) { |
| 566 |
return $response; |
| 567 |
} |
| 568 |
|
| 569 |
$seen = (int) $data['openstation_content_changes_seen_ts']; |
| 570 |
$log = get_option( OPENSTATION_CONTENT_CHANGES_LOG_OPTION, array() ); |
| 571 |
|
| 572 |
$ts = ( is_array( $log ) && isset( $log['ts'] ) ) ? (int) $log['ts'] : 0; |
| 573 |
$entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array(); |
| 574 |
|
| 575 |
$fresh = array(); |
| 576 |
foreach ( $entries as $entry ) { |
| 577 |
if ( isset( $entry['ts'] ) && (int) $entry['ts'] > $seen ) { |
| 578 |
$fresh[] = $entry; |
| 579 |
} |
| 580 |
} |
| 581 |
|
| 582 |
$response['openstation_content_changes'] = array( |
| 583 |
'ts' => $ts, |
| 584 |
'entries' => $fresh, |
| 585 |
); |
| 586 |
|
| 587 |
return $response; |
| 588 |
} |
| 589 |
|
| 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 |
/** |
| 690 |
* Wires every content-change hook. |
| 691 |
* |
| 692 |
* One bootstrap so the wiring is auditable — |
| 693 |
* `grep openstation_content_changes_record` finds every emitter. |
| 694 |
*/ |
| 695 |
function openstation_content_changes_register_hooks() { |
| 696 |
add_action( 'wp_after_insert_post', 'openstation_content_changes_on_after_insert_post', 10, 4 ); |
| 697 |
|
| 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 ); |
| 711 |
|
| 712 |
openstation_content_changes_register_wc_hooks(); |
| 713 |
openstation_content_changes_register_plugin_hooks(); |
| 714 |
|
| 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 ); |
| 718 |
} |
| 719 |
add_action( 'init', 'openstation_content_changes_register_hooks', 5 ); |
| 720 |
|