PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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.8, at includes/content-changes.php

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