PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 0.8.5 0.8.4 All 31 releases
desktop-mode / includes / content-changes.php

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

720 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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