[type, id, field name => label]. * * @var array}> */ private static array $changed = array(); public static function is_active(): bool { return Integrations::enabled( self::PLUGIN, class_exists( 'ACF', false ) ); } public static function event_types(): array { return array( 'acf_config_created' => 'ACF Configuration Created', 'acf_config_updated' => 'ACF Configuration Updated', 'acf_config_trashed' => 'ACF Configuration Trashed', 'acf_config_restored' => 'ACF Configuration Restored', 'acf_config_deleted' => 'ACF Configuration Deleted', self::FIELDS_UPDATED => 'Custom Fields Updated', ); } public static function register( ?bool $active = null ): void { if ( ! ( $active ?? self::is_active() ) ) { return; } add_filter( 'acf/update_value', array( __CLASS__, 'record_value' ), 999, 3 ); add_action( 'shutdown', array( __CLASS__, 'flush' ) ); PostChanges::source( 'custom_fields', static fn( int $post_id ): bool => isset( self::$changed[ 'post:' . $post_id ] ), array( __CLASS__, 'take' ) ); } public static function post_types(): array { $map = static fn( string $noun ): array => array( 'prefix' => 'acf_config', 'noun' => $noun, 'plugin' => self::PLUGIN, ); return array( 'acf-field-group' => $map( 'ACF field group' ), 'acf-post-type' => $map( 'ACF post type' ), 'acf-taxonomy' => $map( 'ACF taxonomy' ), 'acf-ui-options-page' => $map( 'ACF options page' ), ); } public static function internal_post_types(): array { return array( 'acf-field' ); } /** * `acf/update_value`, late: note the field when its value is changing. * Returns the value untouched. * * @param mixed $value The value about to be written. * @param mixed $post_id ACF's object id (123, 'user_5', 'term_3', 'options', 'block_…'). * @param mixed $field The field. * @return mixed */ public static function record_value( $value, $post_id, $field ) { if ( ! is_array( $field ) || ! isset( $field['name'] ) || ! is_string( $field['name'] ) || '' === $field['name'] ) { return $value; } $object = self::object( $post_id ); if ( null === $object ) { return $value; } $before = function_exists( 'acf_get_value' ) ? acf_get_value( $post_id, $field ) : null; if ( self::same( $before, $value ) ) { return $value; } $key = $object['type'] . ':' . (string) $object['id']; if ( ! isset( self::$changed[ $key ] ) ) { self::$changed[ $key ] = $object + array( 'fields' => array() ); } $name = EventLogger::bounded( $field['name'], self::NAME_LENGTH ); $label = isset( $field['label'] ) && is_string( $field['label'] ) && '' !== $field['label'] ? EventLogger::bounded( $field['label'], self::NAME_LENGTH ) : $name; self::$changed[ $key ]['fields'][ $name ] = $label; return $value; } /** * Which object an ACF id is: a post (its type is read later), user, * term, comment or the options page; null for a block or anything else. * * @param mixed $post_id ACF's object id. * @return array{type: string, id: int|null}|null */ public static function object( $post_id ): ?array { if ( is_int( $post_id ) || ( is_string( $post_id ) && ctype_digit( $post_id ) ) ) { return (int) $post_id > 0 ? array( 'type' => 'post', 'id' => (int) $post_id, ) : null; } if ( ! is_string( $post_id ) ) { return null; } if ( in_array( $post_id, array( 'option', 'options' ), true ) || 0 === strpos( $post_id, 'options_' ) ) { return array( 'type' => 'options', 'id' => null, ); } if ( 1 === preg_match( '/^(post|user|term|comment)_(\d+)$/', $post_id, $m ) ) { return array( 'type' => $m[1], 'id' => (int) $m[2], ); } return null; } /** * Whether two field values are the same value (form posts are strings, * stored values may not be). * * @param mixed $a One value. * @param mixed $b The other. */ public static function same( $a, $b ): bool { $norm = static function ( $v ) { if ( null === $v || false === $v || '' === $v || array() === $v ) { return ''; } if ( is_scalar( $v ) ) { return (string) $v; } if ( is_array( $v ) ) { array_walk_recursive( $v, static function ( &$item ): void { $item = is_scalar( $item ) ? (string) $item : $item; } ); } return wp_json_encode( $v ); }; return $norm( $a ) === $norm( $b ); } /** * Hand a post's changed fields over (to its post_updated row) and forget * them. * * @param int $post_id Post id. * @return array */ public static function take( int $post_id ): array { $entry = self::$changed[ 'post:' . $post_id ] ?? null; unset( self::$changed[ 'post:' . $post_id ] ); return null === $entry ? array() : self::listed( $entry['fields'] ); } /** `shutdown`: one row per object whose fields changed and were not reported with a post's row. */ public static function flush(): void { $changed = self::$changed; self::$changed = array(); foreach ( $changed as $entry ) { list( $object_type, $object_id, $name ) = self::describe_object( $entry['type'], $entry['id'] ); $labels = array_values( $entry['fields'] ); EventLogger::log( self::FIELDS_UPDATED, sprintf( 'Custom fields of %1$s updated: %2$s', $name, EventLogger::bounded( implode( ', ', array_slice( $labels, 0, 5 ) ), 300 ) . ( count( $labels ) > 5 ? sprintf( ' and %d more', count( $labels ) - 5 ) : '' ) ), $object_type, $object_id, array( 'plugin' => self::PLUGIN ) + self::listed( $entry['fields'] ) ); } } /** * @param array $fields Name => label. * @return array{fields: array, more?: int} */ private static function listed( array $fields ): array { $names = array_keys( $fields ); $out = array( 'fields' => array_slice( $names, 0, self::MAX_FIELDS ) ); if ( count( $names ) > self::MAX_FIELDS ) { $out['more'] = count( $names ) - self::MAX_FIELDS; } return $out; } /** * The row's object type, id, and how the summary names it. * * @param string $type post, user, term, comment or options. * @param int|null $id Its id. * @return array{0: string, 1: int|null, 2: string} */ private static function describe_object( string $type, ?int $id ): array { switch ( $type ) { case 'post': $post = get_post( (int) $id ); return array( $post ? (string) $post->post_type : 'post', $id, $post ? sprintf( '"%s"', EventLogger::bounded( (string) $post->post_title, 200 ) ) : '#' . $id ); case 'user': $user = get_userdata( (int) $id ); return array( 'user', $id, sprintf( 'user %s', $user ? (string) $user->user_login : '#' . $id ) ); case 'term': return array( 'term', $id, 'term #' . $id ); case 'comment': return array( 'comment', $id, 'comment #' . $id ); default: return array( 'acf_options', null, 'the options page' ); } } /** Forget this request's changes. Tests only. */ public static function reset(): void { self::$changed = array(); } }