See {@see self::shape()}. */ public static function for_post( \WP_Post $post ): array { $meta = get_post_meta( $post->ID ); return self::shape( is_array( $meta ) ? $meta : array(), self::definitions( $post ), self::active_plugins() ); } /** * Which field plugins are active, by name. * * @return array */ public static function active_plugins(): array { $out = array(); if ( class_exists( 'ACF' ) || function_exists( 'get_field_objects' ) ) { $out[] = 'ACF'; } if ( defined( 'PODS_VERSION' ) || function_exists( 'pods' ) ) { $out[] = 'Pods'; } if ( defined( 'RWMB_VER' ) || function_exists( 'rwmb_get_object_fields' ) ) { $out[] = 'Meta Box'; } if ( function_exists( 'jet_engine' ) ) { $out[] = 'JetEngine'; } if ( defined( 'TYPES_VERSION' ) || function_exists( 'types_render_field' ) ) { $out[] = 'Toolset Types'; } if ( function_exists( 'cptui_get_post_type_data' ) ) { $out[] = 'Custom Post Type UI'; } return $out; } /** * The fields the field plugins define for this post: meta key => label, * plugin, type and — only for Pods table storage, where nothing is in * post meta — the values Pods reads. Each plugin is asked defensively: a * plugin whose API differs from the one expected contributes nothing. * * @return array}> */ public static function definitions( \WP_Post $post ): array { $out = array(); // ACF: every field group that applies to this post. if ( function_exists( 'get_field_objects' ) ) { try { $objects = get_field_objects( $post->ID, false, false ); if ( is_array( $objects ) ) { foreach ( $objects as $name => $object ) { if ( is_string( $name ) && is_array( $object ) ) { $out[ $name ] = self::definition( 'ACF', $object['label'] ?? null, $object['type'] ?? null ); } } } } catch ( \Throwable ) { // A field plugin whose API differs contributes nothing; the generic meta read stands. } } // Pods: the pod of this post type, if there is one. if ( function_exists( 'pods' ) ) { try { $pod = pods( $post->post_type, $post->ID, true ); if ( is_object( $pod ) && method_exists( $pod, 'fields' ) ) { $storage = ''; if ( isset( $pod->pod_data ) && ( is_array( $pod->pod_data ) || $pod->pod_data instanceof \ArrayAccess ) ) { $storage = (string) ( $pod->pod_data['storage'] ?? '' ); } $fields = $pod->fields(); if ( is_array( $fields ) ) { foreach ( $fields as $name => $field ) { if ( ! is_string( $name ) || '' === $name ) { continue; } $label = ( is_array( $field ) || $field instanceof \ArrayAccess ) ? ( $field['label'] ?? null ) : null; $type = ( is_array( $field ) || $field instanceof \ArrayAccess ) ? ( $field['type'] ?? null ) : null; $entry = self::definition( 'Pods', $label, $type ); if ( 'table' === $storage && method_exists( $pod, 'display' ) ) { $entry['values'] = array( $pod->display( $name ) ); } $out[ $name ] = $entry; } } } } catch ( \Throwable ) { // A field plugin whose API differs contributes nothing; the generic meta read stands. } } // Meta Box: the fields registered for this post. if ( function_exists( 'rwmb_get_object_fields' ) ) { try { $fields = rwmb_get_object_fields( $post->ID ); if ( is_array( $fields ) ) { foreach ( $fields as $id => $field ) { if ( is_string( $id ) && is_array( $field ) ) { $out[ $id ] = self::definition( 'Meta Box', $field['name'] ?? null, $field['type'] ?? null ); } } } } catch ( \Throwable ) { // A field plugin whose API differs contributes nothing; the generic meta read stands. } } // JetEngine: meta boxes registered for this post type. if ( function_exists( 'jet_engine' ) ) { try { $engine = jet_engine(); $boxes = is_object( $engine ) && isset( $engine->meta_boxes ) ? $engine->meta_boxes : null; if ( is_object( $boxes ) && method_exists( $boxes, 'get_registered_fields' ) ) { $all = $boxes->get_registered_fields(); $own = is_array( $all ) && isset( $all[ $post->post_type ] ) && is_array( $all[ $post->post_type ] ) ? $all[ $post->post_type ] : array(); foreach ( $own as $field ) { if ( is_array( $field ) && isset( $field['name'] ) && is_string( $field['name'] ) && 'field' === ( $field['object_type'] ?? 'field' ) ) { $out[ $field['name'] ] = self::definition( 'JetEngine', $field['title'] ?? null, $field['type'] ?? null ); } } } } catch ( \Throwable ) { // A field plugin whose API differs contributes nothing; the generic meta read stands. } } // Toolset Types: its fields are stored as `wpcf-`. if ( function_exists( 'wpcf_admin_fields_get_fields' ) ) { try { $fields = wpcf_admin_fields_get_fields(); if ( is_array( $fields ) ) { foreach ( $fields as $slug => $field ) { if ( is_string( $slug ) && is_array( $field ) ) { $key = isset( $field['meta_key'] ) && is_string( $field['meta_key'] ) ? $field['meta_key'] : 'wpcf-' . $slug; // Defined for some post type; listed only where this post stores it. $entry = self::definition( 'Toolset Types', $field['name'] ?? null, $field['type'] ?? null ); $entry['only_stored'] = true; $out[ $key ] = $entry; } } } } catch ( \Throwable ) { // A field plugin whose API differs contributes nothing; the generic meta read stands. } } return $out; } /** * @param mixed $label * @param mixed $type * @return array{label?: string, plugin: string, type?: string} */ private static function definition( string $plugin, $label, $type ): array { $entry = array( 'plugin' => $plugin ); if ( is_string( $label ) && '' !== trim( $label ) ) { $entry['label'] = self::clean_text( $label, 120 ); } if ( is_string( $type ) && '' !== $type ) { $entry['type'] = substr( preg_replace( '/[^a-z0-9_\-]/i', '', $type ) ?? '', 0, 40 ); } return $entry; } /** * Shape a post's fields for transmission: public, readable, redacted, * bounded, declared. * * Pure — no WordPress calls — so `plugin/tests/custom-fields-test.php` * exercises it directly. `$meta` is `get_post_meta( $id )`'s shape: key → * list of (already unserialized) values. * * @param array> $meta * @param array> $definitions See {@see self::definitions()}. * @param array $plugins Active field plugins. * @return array{plugins: array, fields: array>, fields_total: int, filled: int, redactions: int, truncated: bool} */ public static function shape( array $meta, array $definitions, array $plugins ): array { $keys = array(); // Defined fields first: they are the ones the site's templates read. foreach ( $definitions as $key => $definition ) { if ( ! is_string( $key ) || ! self::is_public_key( $key ) ) { continue; } if ( ! empty( $definition['only_stored'] ) && ! array_key_exists( $key, $meta ) ) { continue; } $keys[ $key ] = true; } $rest = array(); foreach ( $meta as $key => $values ) { if ( is_string( $key ) && self::is_public_key( $key ) && ! isset( $keys[ $key ] ) && is_array( $values ) ) { $rest[] = $key; } } sort( $rest ); foreach ( $rest as $key ) { $keys[ $key ] = true; } $fields = array(); $redactions = 0; $truncated = false; $total = 0; $filled = 0; foreach ( array_keys( $keys ) as $key ) { $key = (string) $key; $definition = isset( $definitions[ $key ] ) && is_array( $definitions[ $key ] ) ? $definitions[ $key ] : array(); $values = array(); if ( isset( $meta[ $key ] ) && is_array( $meta[ $key ] ) ) { $values = $meta[ $key ]; } elseif ( isset( $definition['values'] ) && is_array( $definition['values'] ) ) { $values = $definition['values']; } $entry = array( 'key' => $key ); if ( isset( $definition['label'] ) ) { $entry['label'] = (string) $definition['label']; } if ( isset( $definition['plugin'] ) ) { $entry['plugin'] = (string) $definition['plugin']; } if ( isset( $definition['type'] ) ) { $entry['type'] = (string) $definition['type']; } if ( 'password' === ( $definition['type'] ?? '' ) ) { $entry['value'] = Scan_Policy::REDACTED; ++$redactions; } else { $texts = array(); foreach ( $values as $value ) { $redacted = Scan_Policy::redact_value( $key, $value ); $redactions += $redacted['redactions']; $text = self::text_of( $redacted['value'], 0 ); if ( null === $text ) { $entry['structured'] = true; continue; } if ( '' !== $text ) { $texts[] = $text; } } $value = implode( ' | ', $texts ); if ( self::length( $value ) > self::VALUE_CAP ) { $value = self::cut( $value, self::VALUE_CAP ) . '…'; $entry['truncated'] = true; $truncated = true; } $entry['value'] = $value; } $entry['empty'] = '' === $entry['value'] && empty( $entry['structured'] ); if ( ! $entry['empty'] ) { ++$filled; } $total += self::length( (string) $entry['value'] ); if ( count( $fields ) >= self::MAX_FIELDS || $total > self::TOTAL_CAP ) { $truncated = true; break; } $fields[] = $entry; } return array( 'plugins' => array_values( $plugins ), 'fields' => $fields, 'fields_total' => count( $keys ), 'filled' => $filled, 'redactions' => $redactions, 'truncated' => $truncated, ); } /** A meta key a reader may see: not underscore-private, not a known setting. */ public static function is_public_key( string $key ): bool { if ( '' === $key || '_' === $key[0] ) { return false; } foreach ( self::SETTING_PREFIXES as $prefix ) { if ( str_starts_with( $key, $prefix ) ) { return false; } } return true; } /** * A value as plain text: scalars as written, structures by their leaves. * Null for a value that cannot be read as text — PHP-serialized text, or * a structure deeper than the walk — which is reported as structured, * never sent. * * @param mixed $value */ private static function text_of( $value, int $depth ): ?string { if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } if ( null === $value ) { return ''; } if ( is_int( $value ) || is_float( $value ) ) { return (string) $value; } if ( is_string( $value ) ) { if ( preg_match( '/^(?:a|O|s|i|b|d):\d*[:;]/', ltrim( $value ) ) && preg_match( '/[;}]\s*$/', $value ) ) { return null; } return self::clean_text( $value, self::VALUE_CAP + 1 ); } if ( is_object( $value ) ) { $value = get_object_vars( $value ); } if ( ! is_array( $value ) || $depth >= self::WALK_DEPTH ) { return null; } $parts = array(); foreach ( $value as $child ) { $text = self::text_of( $child, $depth + 1 ); if ( null !== $text && '' !== $text ) { $parts[] = $text; } } return implode( '; ', $parts ); } /** Markup removed, entities decoded, whitespace collapsed, bounded. */ private static function clean_text( string $value, int $cap ): string { $text = preg_replace( '/<(script|style)\b[^>]*>.*?<\/\1>/is', ' ', $value ) ?? $value; $text = preg_replace( '/<(?:br|\/p|\/li|\/h[1-6]|\/div)\b[^>]*>/i', "\n", $text ) ?? $text; $text = preg_replace( '/<[^>]*>/', '', $text ) ?? $text; $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '/[ \t\x{00A0}]+/u', ' ', $text ) ?? $text; $text = preg_replace( '/\s*\n\s*/', "\n", $text ) ?? $text; $text = trim( $text ); return self::length( $text ) > $cap ? self::cut( $text, $cap ) : $text; } private static function length( string $text ): int { return function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text ); } private static function cut( string $text, int $length ): string { return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $length, 'UTF-8' ) : substr( $text, 0, $length ); } }