[plugin, label]. * * @var array */ private static array $keys = array(); /** * Watched options: option => [plugin, label]. * * @var array */ private static array $options = array(); /** * Values before this request's first write: post id => key => value. * * @var array> */ private static array $before = array(); /** * Option changes this request: option => [first old, last new]. * * @var array */ private static array $option_changes = array(); /** Whether the shared hooks are added. */ private static bool $hooked = false; /** * Watch a plugin's post meta keys and settings options. * * @param string $plugin The plugin's slug for details. * @param array $keys Meta key => label. * @param array $options Option name => label. */ public static function watch( string $plugin, array $keys, array $options ): void { foreach ( $keys as $key => $label ) { self::$keys[ $key ] = array( $plugin, $label ); } foreach ( $options as $option => $label ) { self::$options[ $option ] = array( $plugin, $label ); add_action( 'update_option_' . $option, array( __CLASS__, 'record_option' ), 10, 3 ); } if ( self::$hooked ) { return; } self::$hooked = true; foreach ( array( 'add_post_metadata', 'update_post_metadata', 'delete_post_metadata' ) as $filter ) { add_filter( $filter, array( __CLASS__, 'remember' ), 1, 3 ); } add_action( 'shutdown', array( __CLASS__, 'flush' ) ); PostChanges::source( 'seo', static fn( int $post_id ): bool => array() !== self::changes( $post_id ), array( __CLASS__, 'take' ) ); } /** * The metadata filters: keep a watched field's stored value before this * request first writes it. Returns `$check` untouched. * * @param mixed $check The short-circuit value; passed through. * @param mixed $object_id Post id. * @param mixed $meta_key Meta key. * @return mixed */ public static function remember( $check, $object_id, $meta_key ) { $key = is_string( $meta_key ) ? $meta_key : ''; $id = is_numeric( $object_id ) ? (int) $object_id : 0; if ( $id > 0 && isset( self::$keys[ $key ] ) && ! isset( self::$before[ $id ][ $key ] ) ) { self::$before[ $id ][ $key ] = self::text( get_post_meta( $id, $key, true ) ); } return $check; } /** * A post's watched fields that differ from their value before the * request: key => [label, before, after, plugin]. * * @param int $post_id Post id. * @return array */ public static function changes( int $post_id ): array { $changes = array(); foreach ( self::$before[ $post_id ] ?? array() as $key => $before ) { $after = self::text( get_post_meta( $post_id, $key, true ) ); if ( $after !== $before ) { $changes[ $key ] = array( 'label' => self::$keys[ $key ][1], 'before' => $before, 'after' => $after, 'plugin' => self::$keys[ $key ][0], ); } } return $changes; } /** * Hand a post's changes over (to its post_updated row) and forget them. * * @param int $post_id Post id. * @return array Label => before/after. */ public static function take( int $post_id ): array { $out = array(); foreach ( self::changes( $post_id ) as $change ) { $out[ $change['label'] ] = array( 'before' => $change['before'], 'after' => $change['after'], ); } unset( self::$before[ $post_id ] ); return $out; } /** * `update_option_{option}`. * * @param mixed $old_value Before. * @param mixed $value After. * @param string $option Option name. */ public static function record_option( $old_value, $value, $option ): void { $option = (string) $option; if ( ! isset( self::$options[ $option ] ) ) { return; } self::$option_changes[ $option ] = array( array_key_exists( $option, self::$option_changes ) ? self::$option_changes[ $option ][0] : $old_value, $value, ); } /** * Which keys of a settings array changed, with values unless a key is * named like a credential; a list option (Rank Math's modules) as * enabled/disabled entries. * * @param mixed $before Old value. * @param mixed $after New value. * @return array */ public static function settings_diff( $before, $after ): array { $before = is_array( $before ) ? $before : array(); $after = is_array( $after ) ? $after : array(); if ( self::is_list( $before ) && self::is_list( $after ) && ( array() !== $before || array() !== $after ) ) { $on = array_values( array_diff( array_map( 'strval', $after ), array_map( 'strval', $before ) ) ); $off = array_values( array_diff( array_map( 'strval', $before ), array_map( 'strval', $after ) ) ); $out = array(); if ( array() !== $on ) { $out['enabled'] = array_slice( $on, 0, self::MAX_KEYS ); } if ( array() !== $off ) { $out['disabled'] = array_slice( $off, 0, self::MAX_KEYS ); } return $out; } $changed = array(); foreach ( array_unique( array_merge( array_keys( $before ), array_keys( $after ) ) ) as $key ) { $old = self::text( $before[ $key ] ?? null ); $new = self::text( $after[ $key ] ?? null ); if ( $old === $new ) { continue; } $changed[ (string) $key ] = EventLogger::is_secret_setting( (string) $key ) ? array( 'values_withheld' => true ) : array( 'before' => $old, 'after' => $new, ); } ksort( $changed ); $out = array_slice( $changed, 0, self::MAX_KEYS, true ); if ( count( $changed ) > self::MAX_KEYS ) { $out['more'] = count( $changed ) - self::MAX_KEYS; } return $out; } /** `shutdown`: the posts' remaining field changes, then the settings. */ public static function flush(): void { foreach ( array_keys( self::$before ) as $post_id ) { $changes = self::changes( (int) $post_id ); unset( self::$before[ $post_id ] ); if ( array() === $changes ) { continue; } $plugins = array_values( array_unique( array_column( $changes, 'plugin' ) ) ); $details = array( 'plugin' => implode( ',', $plugins ) ); foreach ( $changes as $change ) { $details[ $change['label'] ] = array( 'before' => $change['before'], 'after' => $change['after'], ); } $post = get_post( (int) $post_id ); EventLogger::log( self::META_UPDATED, sprintf( 'SEO for "%1$s" updated: %2$s', $post ? EventLogger::bounded( (string) $post->post_title, 200 ) : '#' . $post_id, implode( ', ', array_column( $changes, 'label' ) ) ), $post ? (string) $post->post_type : 'post', (int) $post_id, $details ); } $options = self::$option_changes; self::$option_changes = array(); foreach ( $options as $option => $pair ) { $diff = self::settings_diff( $pair[0], $pair[1] ); if ( array() === $diff ) { continue; } EventLogger::log( self::SETTINGS_CHANGED, sprintf( '%1$s settings changed: %2$s', self::$options[ $option ][1], self::describe( $diff ) ), 'option', null, array( 'plugin' => self::$options[ $option ][0], 'option' => $option, ) + $diff ); } } /** * A settings diff's keys for a summary. * * @param array $diff From settings_diff(). */ private static function describe( array $diff ): string { if ( isset( $diff['enabled'] ) || isset( $diff['disabled'] ) ) { $parts = array(); if ( isset( $diff['enabled'] ) && is_array( $diff['enabled'] ) ) { $parts[] = 'enabled ' . implode( ', ', $diff['enabled'] ); } if ( isset( $diff['disabled'] ) && is_array( $diff['disabled'] ) ) { $parts[] = 'disabled ' . implode( ', ', $diff['disabled'] ); } return implode( '; ', $parts ); } $keys = array_values( array_diff( array_map( 'strval', array_keys( $diff ) ), array( 'more' ) ) ); $more = max( 0, count( $keys ) - 5 ) + ( isset( $diff['more'] ) ? (int) $diff['more'] : 0 ); return EventLogger::bounded( implode( ', ', array_slice( $keys, 0, 5 ) ), 300 ) . ( $more > 0 ? sprintf( ' and %d more', $more ) : '' ); } /** * Whether an array is a plain list (PHP 8.0 has no array_is_list()). * * @param array $value An array. */ private static function is_list( array $value ): bool { return array_keys( $value ) === range( 0, count( $value ) - 1 ) || array() === $value; } /** * A stored value as bounded text: scalars as themselves, lists joined, * anything else as JSON. * * @param mixed $value A stored value. */ public static function text( $value ): string { if ( null === $value || false === $value ) { return ''; } if ( is_bool( $value ) ) { return '1'; } if ( is_scalar( $value ) ) { return EventLogger::bounded( (string) $value, self::VALUE_LENGTH ); } if ( is_array( $value ) && self::is_list( $value ) && array() === array_filter( $value, static fn( $v ): bool => ! is_scalar( $v ) ) ) { return EventLogger::bounded( implode( ',', array_map( 'strval', $value ) ), self::VALUE_LENGTH ); } return EventLogger::bounded( (string) wp_json_encode( $value ), self::VALUE_LENGTH ); } /** Forget everything held for this request and every watch. Tests only. */ public static function reset(): void { self::$keys = array(); self::$options = array(); self::$before = array(); self::$option_changes = array(); self::$hooked = false; } }