*/ private const REQUIRED_KEYS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- List of top-level postmeta record keys, same pattern as Experiments::EXPERIMENT_CLASSES. '@context', 'schema_version', 'captured_at', 'duration_ms', 'source', 'traditional', 'c2pa', 'errors', ); /** * Persists the record for $attachment_id. Returns true on success. * * Validation is intentionally lenient: missing keys are filled with * defaults rather than rejected, so the fail-open boundary in the * feature class always produces *some* record. Encoding errors are * the only hard failure. * * @since 1.5.0 * * @param int $attachment_id Attachment ID. * @param array $record Record array. * @return bool */ public static function store( int $attachment_id, array $record ): bool { if ( $attachment_id <= 0 ) { return false; } $normalized = self::normalize( $record ); $encoded = wp_json_encode( $normalized ); if ( false === $encoded ) { return false; } $stored = false !== update_post_meta( $attachment_id, C2pa_Monitor::POSTMETA_KEY, wp_slash( $encoded ) ); // Write a lightweight sort key so the Media Library column can ORDER BY. update_post_meta( $attachment_id, C2pa_Monitor::SORT_META_KEY, isset( $normalized['c2pa']['present'] ) && $normalized['c2pa']['present'] ? 1 : 0 ); return $stored; } /** * Loads and decodes the stored record, or null if not present. * * Convenience accessor used by tests and PR 2 / PR 3 consumers. * * @since 1.5.0 * * @param int $attachment_id Attachment ID. * @return array|null */ public static function load( int $attachment_id ): ?array { $raw = get_post_meta( $attachment_id, C2pa_Monitor::POSTMETA_KEY, true ); if ( ! is_string( $raw ) || '' === $raw ) { return null; } $decoded = json_decode( $raw, true ); return is_array( $decoded ) ? $decoded : null; } /** * Fills missing keys with defaults so persisted records always match the * documented schema shape. * * @since 1.5.0 * * @param array $record Input record. * @return array */ private static function normalize( array $record ): array { foreach ( self::REQUIRED_KEYS as $key ) { if ( array_key_exists( $key, $record ) ) { continue; } $record[ $key ] = self::default_for( $key ); } $record['source'] = is_array( $record['source'] ) ? $record['source'] : self::default_for( 'source' ); $record['traditional'] = is_array( $record['traditional'] ) ? $record['traditional'] : self::default_for( 'traditional' ); $record['c2pa'] = is_array( $record['c2pa'] ) ? $record['c2pa'] : self::default_for( 'c2pa' ); $record['errors'] = is_array( $record['errors'] ) ? $record['errors'] : array(); return $record; } /** * Returns the default value for a top-level record key. * * @since 1.5.0 * * @param string $key Key name. * @return mixed */ private static function default_for( string $key ) { switch ( $key ) { case '@context': return array( 'https://schema.org/', C2pa_Monitor::CONTEXT_URL ); case 'schema_version': return C2pa_Monitor::SCHEMA_VERSION; case 'captured_at': return gmdate( 'Y-m-d\TH:i:s\Z' ); case 'duration_ms': return 0; case 'source': return array( 'attachment_id' => 0, 'original_path_relative' => '', 'size_bytes' => 0, 'mime' => '', ); case 'traditional': return array( 'exif' => array(), 'iptc' => array(), 'xmp' => array(), ); case 'c2pa': return array( 'present' => false, 'format' => null, ); case 'errors': return array(); } return null; } }