[post, created, diff]. * * @var array}> */ private static array $pending = array(); /** * Owned posts trashed, restored or deleted this request. * * @var array */ private static array $ended = array(); /** * Claimed posts: id => the merged diff absorbed so far. * * @var array> */ private static array $claims = array(); /** * Extra-change sources: group => [has, take]. * * @var array}> */ private static array $sources = array(); /** Whether the shutdown flush is hooked. */ private static bool $hooked = false; /** * Register a source of extra changes for posts. * * @param string $group Key the changes are merged under. * @param callable(int): bool $has Whether the source holds changes for a post. * @param callable(int): array $take Hand them over and forget them. */ public static function source( string $group, callable $has, callable $take ): void { self::$sources[ $group ] = array( $has, $take ); } /** * A post was created. True when this class took it (an owned type). * * @param \WP_Post $post The new post. */ public static function created( \WP_Post $post ): bool { if ( null === Integrations::post_type( $post->post_type ) ) { return false; } self::hook(); self::$pending[ (int) $post->ID ] = array( 'post' => $post, 'created' => true, 'diff' => array(), ); return true; } /** * A post was updated. True when this class took it (an owned type, or a * claimed save). * * @param \WP_Post $post The post after the update. * @param array $diff Its tracked fields' changes. */ public static function updated( \WP_Post $post, array $diff ): bool { $id = (int) $post->ID; if ( isset( self::$claims[ $id ] ) ) { self::$claims[ $id ] = self::merge( self::$claims[ $id ], $diff ); return true; } if ( null === Integrations::post_type( $post->post_type ) ) { return false; } self::hook(); $pending = self::$pending[ $id ] ?? array( 'post' => $post, 'created' => false, 'diff' => array(), ); $pending['post'] = $post; $pending['diff'] = self::merge( $pending['diff'], $diff ); self::$pending[ $id ] = $pending; return true; } /** * A post was trashed, restored or deleted. True when this class logged * it (an owned type). * * @param string $action trashed, restored or deleted. * @param \WP_Post $post The post. */ public static function ended( string $action, \WP_Post $post ): bool { $map = Integrations::post_type( $post->post_type ); if ( null === $map ) { return false; } $id = (int) $post->ID; unset( self::$pending[ $id ] ); self::$ended[ $id ] = true; $verbs = array( 'trashed' => 'moved to trash', 'restored' => 'restored from trash', 'deleted' => 'deleted', ); EventLogger::log( $map['prefix'] . '_' . $action, sprintf( '%1$s "%2$s" %3$s', $map['noun'], self::title( $post ), $verbs[ $action ] ?? $action ), $post->post_type, $id, array( 'plugin' => $map['plugin'] ) ); return true; } /** * Hold a post's update rows: its saver writes the one row itself. * * @param int $post_id Post id. */ public static function claim( int $post_id ): void { if ( $post_id > 0 && ! isset( self::$claims[ $post_id ] ) ) { self::$claims[ $post_id ] = array(); } } /** * End a claim: the diff absorbed while it was held, plus any extra * changes recorded for the post, or null when it was not claimed. * * @param int $post_id Post id. * @return array|null */ public static function release( int $post_id ): ?array { if ( ! isset( self::$claims[ $post_id ] ) ) { return null; } $diff = self::$claims[ $post_id ]; unset( self::$claims[ $post_id ] ); return $diff + self::take_extra( $post_id ); } /** * Whether any source holds extra changes for a post. * * @param int $post_id Post id. */ public static function has_extra( int $post_id ): bool { foreach ( self::$sources as $source ) { if ( ( $source[0] )( $post_id ) ) { return true; } } return false; } /** * Every source's extra changes for a post, keyed by group; the sources * forget them. * * @param int $post_id Post id. * @return array */ public static function take_extra( int $post_id ): array { $extra = array(); foreach ( self::$sources as $group => $source ) { if ( ( $source[0] )( $post_id ) ) { $changes = ( $source[1] )( $post_id ); if ( array() !== $changes ) { $extra[ $group ] = $changes; } } } return $extra; } /** * `shutdown`: one row per owned post written this request and not * trashed, restored or deleted in it. */ public static function flush(): void { $pending = self::$pending; self::$pending = array(); foreach ( $pending as $id => $entry ) { if ( isset( self::$ended[ $id ] ) ) { continue; } $post = $entry['post']; $map = Integrations::post_type( $post->post_type ); if ( null === $map ) { continue; } $details = array( 'plugin' => $map['plugin'] ); if ( ! $entry['created'] && array() !== $entry['diff'] ) { $details += $entry['diff']; } EventLogger::log( $map['prefix'] . ( $entry['created'] ? '_created' : '_updated' ), sprintf( '%1$s "%2$s" %3$s', $map['noun'], self::title( $post ), $entry['created'] ? 'created' : 'updated' ), $post->post_type, (int) $id, $details ); } self::$ended = array(); } /** * Two diffs of the same post, as one: each field keeps its first * `before` and its last `after`, and a field that ends where it started * is dropped. * * @param array $first Earlier diff. * @param array $then Later diff. * @return array */ public static function merge( array $first, array $then ): array { foreach ( $then as $field => $change ) { if ( ! isset( $first[ $field ] ) || ! is_array( $first[ $field ] ) || ! is_array( $change ) ) { $first[ $field ] = $change; continue; } $merged = $first[ $field ]; foreach ( array( 'after', 'characters_after', 'revision_id' ) as $key ) { if ( array_key_exists( $key, $change ) ) { $merged[ $key ] = $change[ $key ]; } } $first[ $field ] = $merged; } foreach ( $first as $field => $change ) { if ( is_array( $change ) && array_key_exists( 'before', $change ) && array_key_exists( 'after', $change ) && $change['before'] === $change['after'] ) { unset( $first[ $field ] ); } } return $first; } /** * A post's title for a summary, bounded. * * @param \WP_Post $post The post. */ private static function title( \WP_Post $post ): string { return EventLogger::bounded( (string) $post->post_title, 200 ); } /** Hook the shutdown flush once. */ private static function hook(): void { if ( ! self::$hooked && function_exists( 'add_action' ) ) { add_action( 'shutdown', array( __CLASS__, 'flush' ) ); self::$hooked = true; } } /** Forget everything held for this request, sources included. Tests only. */ public static function reset(): void { self::$pending = array(); self::$ended = array(); self::$claims = array(); self::$sources = array(); self::$hooked = false; } }