*/ private static $pending = array(); /** * Classifier. * * @var Classifier */ private $classifier; /** * Exporter for UIDs. * * @var Exporter */ private $exporter; /** * Constructor. * * @param Classifier $classifier Classifier. * @param Exporter $exporter Exporter. */ public function __construct( Classifier $classifier, Exporter $exporter ) { $this->classifier = $classifier; $this->exporter = $exporter; } /** * Registers the hooks. */ public function register(): void { add_action( 'save_post', array( $this, 'on_post' ), 99, 2 ); // Media do not fire save_post when uploaded or edited. add_action( 'add_attachment', array( $this, 'on_attachment' ), 99 ); add_action( 'edit_attachment', array( $this, 'on_attachment' ), 99 ); add_action( 'wp_trash_post', array( $this, 'on_post_removed' ) ); add_action( 'before_delete_post', array( $this, 'on_post_removed' ) ); add_action( 'created_term', array( $this, 'on_term' ), 99, 3 ); add_action( 'edited_term', array( $this, 'on_term' ), 99, 3 ); add_action( 'pre_delete_term', array( $this, 'on_term_removed' ), 99, 2 ); add_action( 'updated_option', array( $this, 'on_option' ), 99, 3 ); add_action( 'added_option', array( $this, 'on_option' ), 99 ); add_action( 'deleted_option', array( $this, 'on_option_removed' ), 99 ); add_action( 'shutdown', array( __CLASS__, 'flush' ) ); add_action( 'admin_init', array( __CLASS__, 'privacy_text' ) ); // Daily clean-up of entries past the retention period, also without new edits. add_action( self::PRUNE_HOOK, array( __CLASS__, 'prune' ) ); if ( ! wp_next_scheduled( self::PRUNE_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::PRUNE_HOOK ); } } /** * Cron: deletes expired entries. */ public static function prune(): void { self::flush( true ); } /** * Suggested text for the privacy policy guide. */ public static function privacy_text(): void { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'Wonderful Sync', '
' . esc_html__( 'Wonderful Sync keeps a log of edits to pages, layouts, menus and design settings so conflicts can be explained before a transfer. For each edited object it stores the time, the login name of the editor and the title of the object (at most 120 characters), never its content. Entries are deleted after 180 days, at most 2000 objects are kept, administrators can clear the log under Wonderful Sync > Transfer log, and it is removed when the plugin is deleted.', 'wonderful-sync' ) . '
' ); } } /** * Pauses recording (Wonderful Sync's own writes). */ public static function pause(): void { self::$paused = true; } /** * Resumes recording. */ public static function resume(): void { self::$paused = false; } /** * Post saved. * * @param int $id Post ID. * @param \WP_Post $post Post. */ public function on_post( $id, $post ): void { if ( self::$paused || ! $post instanceof \WP_Post || wp_is_post_revision( $id ) || wp_is_post_autosave( $id ) || 'auto-draft' === $post->post_status ) { return; } if ( Classifier::is_syncable( $this->classifier->post_type( $post->post_type ) ) ) { $this->record( 'post:' . $this->exporter->post_uid( (int) $id ), self::post_title( $post ), 'trash' === $post->post_status ? 'trashed' : 'edited' ); } } /** * Title for the log; menu items have none of their own. * * @param \WP_Post $post Post. */ private static function post_title( \WP_Post $post ): string { if ( '' !== $post->post_title || 'nav_menu_item' !== $post->post_type ) { return $post->post_title; } $target = (int) get_post_meta( $post->ID, '_menu_item_object_id', true ); $label = $target ? get_the_title( $target ) : ''; if ( '' === $label ) { return __( 'Menu item', 'wonderful-sync' ); } /* translators: %s: title the menu item points to. */ return sprintf( __( 'Menu item: %s', 'wonderful-sync' ), $label ); } /** * Media uploaded or edited. * * @param int $id Attachment ID. */ public function on_attachment( $id ): void { $post = get_post( (int) $id ); if ( $post instanceof \WP_Post ) { $this->on_post( (int) $id, $post ); } } /** * Post trashed or deleted. * * @param int $id Post ID. */ public function on_post_removed( $id ): void { $post = get_post( (int) $id ); if ( self::$paused || ! $post || ! Classifier::is_syncable( $this->classifier->post_type( $post->post_type ) ) ) { return; } $this->record( 'post:' . $this->exporter->post_uid( (int) $id ), self::post_title( $post ), 'deleted' ); } /** * Term created or edited. * * @param int $term_id Term ID. * @param int $tt_id Term taxonomy ID. * @param string $taxonomy Taxonomy. */ public function on_term( $term_id, $tt_id, $taxonomy ): void { if ( self::$paused || ! Classifier::is_syncable( $this->classifier->taxonomy( (string) $taxonomy ) ) ) { return; } $term = get_term( (int) $term_id ); $this->record( 'term:' . $this->exporter->term_uid( (int) $term_id ), $term instanceof \WP_Term ? $term->name : '', 'edited' ); } /** * Term about to be deleted. * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy. */ public function on_term_removed( $term_id, $taxonomy ): void { if ( self::$paused || ! Classifier::is_syncable( $this->classifier->taxonomy( (string) $taxonomy ) ) ) { return; } $term = get_term( (int) $term_id ); $this->record( 'term:' . $this->exporter->term_uid( (int) $term_id ), $term instanceof \WP_Term ? $term->name : '', 'deleted' ); } /** * Option added or changed. * * @param string $name Option name. */ public function on_option( $name, $old = null, $value = null ): void { $name = (string) $name; if ( self::$paused || 0 === strpos( $name, 'wonderful_sync' ) || ! Classifier::is_syncable( $this->classifier->option( $name ) ) ) { return; } // Themes update their own installation values (e.g. Divi's version keys) on every run; // a change that only touches never transferred keys is no edit. if ( 3 === func_num_args() ) { try { if ( Normalizer::hash( array( Exporter::mask_environment( Exporter::mask_secrets( $old ), $name ) ) ) === Normalizer::hash( array( Exporter::mask_environment( Exporter::mask_secrets( $value ), $name ) ) ) ) { return; } } catch ( \Throwable $e ) { unset( $e ); // Too large to compare: record it. } } $title = Exporter::option_title( $name ); $this->record( 'option:' . $name, '' !== $title ? $title : $name, 'edited' ); } /** * Option deleted. * * @param string $name Option name. */ public function on_option_removed( $name ): void { $name = (string) $name; if ( self::$paused || 0 === strpos( $name, 'wonderful_sync' ) || ! Classifier::is_syncable( $this->classifier->option( $name ) ) ) { return; } $this->record( 'option:' . $name, $name, 'deleted' ); } /** * Notes a change; written at the end of the request. * * @param string $key Object key. * @param string $title Title for lists. * @param string $action edited, trashed or deleted. */ private function record( string $key, string $title, string $action ): void { $user = wp_get_current_user(); self::$pending[ $key ] = array( // Microseconds: two edits within one second keep their order. 't' => round( microtime( true ), 6 ), 'u' => $user->exists() ? $user->user_login : ( defined( 'WP_CLI' ) && WP_CLI ? 'wp-cli' : '' ), 'a' => $action, 'n' => mb_substr( $title, 0, 120 ), ); } /** * Writes the collected changes (once per request). Compare-and-swap with a fresh read, so * parallel requests never drop each other's entries; per object the newest entry wins. */ public static function flush( bool $prune = false ): void { global $wpdb; if ( empty( self::$pending ) && ! $prune ) { return; } $pending = self::$pending; // Entries an earlier request could not write are merged in now. $queued = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 50", $wpdb->esc_like( self::QUEUE ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- small fallback queue. foreach ( (array) $queued as $row ) { foreach ( (array) maybe_unserialize( $row->option_value ) as $key => $entry ) { if ( is_array( $entry ) && ( ! isset( $pending[ $key ]['t'] ) || (float) $pending[ $key ]['t'] < (float) ( $entry['t'] ?? 0 ) ) ) { $pending[ $key ] = $entry; } } } for ( $try = 0; $try < 20; $try++ ) { if ( $try > 0 ) { usleep( wp_rand( 5000, 40000 ) ); } $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- fresh read for compare-and-swap. $log = null === $raw ? array() : maybe_unserialize( $raw ); $log = is_array( $log ) ? $log : array(); foreach ( $pending as $key => $entry ) { if ( ! isset( $log[ $key ]['t'] ) || (float) $log[ $key ]['t'] < (float) $entry['t'] ) { $log[ $key ] = $entry; } } $cut = time() - self::MAX_AGE; $log = array_filter( $log, static function ( $entry ) use ( $cut ) { return is_array( $entry ) && (int) ( $entry['t'] ?? 0 ) >= $cut; } ); uasort( $log, static function ( $a, $b ) { return (float) $a['t'] <=> (float) $b['t']; } ); if ( count( $log ) > self::MAX_KEYS ) { $log = array_slice( $log, -self::MAX_KEYS, null, true ); } if ( null === $raw ) { $done = add_option( self::OPTION, $log, '', false ); } else { $new = maybe_serialize( $log ); $done = $new === $raw || 1 === $wpdb->update( $wpdb->options, array( 'option_value' => $new ), array( 'option_name' => self::OPTION, 'option_value' => $raw ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- compare-and-swap. } if ( $done ) { self::forget( $pending ); wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); foreach ( (array) $queued as $row ) { delete_option( $row->option_name ); } return; } } // Still contended: keep the entries in a row of their own (an insert never conflicts); // the next request merges them. if ( ! $pending ) { return; } if ( add_option( self::QUEUE . wp_generate_password( 12, false, false ), $pending, '', false ) ) { self::forget( $pending ); return; } // Kept for the next flush in this process; reported in case none follows. error_log( sprintf( 'Wonderful Sync: %d activity log entries could not be saved yet.', count( $pending ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- report a storage problem. } /** * Removes saved entries from the pending list, keeping any newer ones recorded meanwhile. * * @param array $saved Saved entries. */ private static function forget( array $saved ): void { foreach ( $saved as $key => $entry ) { if ( isset( self::$pending[ $key ]['t'] ) && (float) self::$pending[ $key ]['t'] <= (float) ( $entry['t'] ?? 0 ) ) { unset( self::$pending[ $key ] ); } } } /** * Removes the whole log. */ public static function clear(): void { global $wpdb; self::$pending = array(); delete_option( self::OPTION ); foreach ( (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::QUEUE ) . '%' ) ) as $name ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- fallback queue. delete_option( $name ); } } /** * Last recorded change of an object, or null. * * @param string $key Object key. * @return array{t: int, u: string, a: string, n: string}|null */ public static function last( string $key ): ?array { $log = get_option( self::OPTION, array() ); if ( ! is_array( $log ) || ! isset( $log[ $key ]['t'] ) || (float) $log[ $key ]['t'] < time() - self::MAX_AGE ) { return null; } return $log[ $key ]; } /** * Most recent changes, newest first. * * @param int $limit Number of entries. * @return array