value, on a group) * and "@value" (the text of an element that also has attributes). The JSON renderer * drops both, so JSON output is the same with or without attributes. * * Order: children appear in the order of the mappings. A group is positioned where * its first member appears. Nothing is ever sorted. */ class Mapping_Engine { const MAX_MAPPINGS = 50; const ATTRIBUTES = '@attributes'; const VALUE = '@value'; /** @var array */ private $mappings; /** @var bool */ private $omit_empty; /** @var array */ private $attributes; /** @var array Output path => child element name for taxonomy lists. */ private $repeat_names = array(); /** * @param array $mappings Mappings. * @param bool $omit_empty Drop empty values instead of emitting them. * @param array $attributes XML attributes. * @param array $repeat_names Child element names for taxonomy lists. * @throws \InvalidArgumentException When the mappings are invalid. */ public function __construct( array $mappings, bool $omit_empty = false, array $attributes = array(), array $repeat_names = array() ) { $errors = self::validate( $mappings ); if ( $errors ) { throw new \InvalidArgumentException( esc_html( implode( ' ', $errors ) ) ); } $this->mappings = array_values( $mappings ); $this->omit_empty = $omit_empty; $this->attributes = array_values( $attributes ); foreach ( $repeat_names as $row ) { if ( isset( $row['path'], $row['name'] ) ) { $this->repeat_names[ trim( (string) $row['path'] ) ] = (string) $row['name']; } } } /** * Validates a list of mappings. * * @param array $mappings Mappings, each with "source" and "path". * @param array $context Optional: 'prefixes' (string[]) when namespace prefixes must be declared * (XML and RSS feeds). Without it, qualified names are accepted as written. * @return string[] Error messages; empty when valid. */ public static function validate( array $mappings, array $context = array() ): array { $errors = array(); if ( array() === $mappings ) { return array( __( 'Add at least one field mapping.', 'feedweaver' ) ); } if ( count( $mappings ) > self::MAX_MAPPINGS ) { /* translators: %d: maximum number of mappings. */ return array( sprintf( __( 'A feed may have at most %d mappings.', 'feedweaver' ), self::MAX_MAPPINGS ) ); } $prefixes = isset( $context['prefixes'] ) && is_array( $context['prefixes'] ) ? $context['prefixes'] : null; $seen = array(); foreach ( array_values( $mappings ) as $i => $mapping ) { $row = $i + 1; $source = isset( $mapping['source'] ) ? $mapping['source'] : ''; $path = isset( $mapping['path'] ) ? $mapping['path'] : ''; if ( ! Source_Catalog::is_known( $source ) ) { /* translators: %d: mapping row number. */ $errors[] = sprintf( __( 'Row %d: unknown source field.', 'feedweaver' ), $row ); } elseif ( Source_Catalog::is_meta( $source ) ) { foreach ( Meta_Policy::check( Source_Catalog::meta_key( $source ) ) as $reason ) { /* translators: 1: mapping row number, 2: reason. */ $errors[] = sprintf( __( 'Row %1$d: %2$s', 'feedweaver' ), $row, $reason ); } } $path_errors = Path_Validator::validate( $path, true ); foreach ( $path_errors as $reason ) { /* translators: 1: mapping row number, 2: reason. */ $errors[] = sprintf( __( 'Row %1$d: %2$s', 'feedweaver' ), $row, $reason ); } if ( ! $path_errors && null !== $prefixes ) { foreach ( Path_Validator::segments( $path ) as $segment ) { $prefix = Path_Validator::prefix_of( $segment ); if ( null !== $prefix && ! in_array( $prefix, $prefixes, true ) ) { /* translators: 1: mapping row number, 2: namespace prefix. */ $errors[] = sprintf( __( 'Row %1$d: the namespace prefix "%2$s" is not declared under XML Namespaces.', 'feedweaver' ), $row, $prefix ); $path_errors = array( 'prefix' ); break; } } } if ( $path_errors ) { continue; } $key = trim( $path ); if ( isset( $seen[ $key ] ) ) { /* translators: %s: output path. */ $errors[] = sprintf( __( 'The output path "%s" is used more than once.', 'feedweaver' ), $key ); continue; } // A path may not be both a value and the parent of another path. foreach ( array_keys( $seen ) as $other ) { if ( 0 === strpos( $other . '/', $key . '/' ) || 0 === strpos( $key . '/', $other . '/' ) ) { /* translators: 1: output path, 2: conflicting output path. */ $errors[] = sprintf( __( 'The output paths "%1$s" and "%2$s" conflict: one cannot be inside the other.', 'feedweaver' ), $key, $other ); break; } } $seen[ $key ] = true; }//end foreach return $errors; } /** * Source identifiers needed by this engine (mappings and attribute values). * * @return string[] */ public function required_sources(): array { $sources = array_column( $this->mappings, 'source' ); foreach ( $this->attributes as $attribute ) { if ( isset( $attribute['type'], $attribute['value'] ) && 'source' === $attribute['type'] ) { $sources[] = $attribute['value']; } } return array_values( array_unique( array_diff( $sources, array( 'none' ) ) ) ); } /** * Maps one item's resolved values into the output structure. * * @param array $values Source id => string|string[]|Term_List|null. * @return array */ public function map( array $values ): array { $out = array(); $no_value = array(); // Attribute-only elements: path => true. foreach ( $this->mappings as $mapping ) { $segments = Path_Validator::segments( $mapping['path'] ); $path = trim( $mapping['path'] ); if ( 'none' === $mapping['source'] ) { $no_value[ $path ] = true; self::set_path( $out, $segments, array() ); continue; } $raw = isset( $values[ $mapping['source'] ] ) ? $values[ $mapping['source'] ] : null; if ( $raw instanceof Term_List ) { if ( $raw->is_empty() && $this->omit_empty ) { continue; } if ( isset( $this->repeat_names[ $path ] ) ) { $raw = new Term_List( $raw->names, $this->repeat_names[ $path ] ); } self::set_path( $out, $segments, $raw ); continue; } $value = self::normalize( $raw ); if ( null === $value && $this->omit_empty ) { continue; } self::set_path( $out, $segments, $value ); }//end foreach $incomplete = array(); foreach ( $this->attributes as $attribute ) { $value = $this->attribute_value( $attribute, $values ); $element = trim( (string) $attribute['element'] ); if ( null === $value ) { if ( isset( $no_value[ $element ] ) ) { $incomplete[ $element ] = true; } continue; } self::set_attribute( $out, '' === $element ? array() : explode( '/', $element ), (string) $attribute['name'], $value ); } // An attribute-only element is kept only when it has every attribute it was given. foreach ( array_keys( $no_value ) as $path ) { $segments = explode( '/', $path ); $node = self::get_path( $out, $segments ); if ( isset( $incomplete[ $path ] ) || ! is_array( $node ) || ! isset( $node[ self::ATTRIBUTES ] ) ) { self::unset_path( $out, $segments ); } } return $out; } /** * The value of an attribute for one item: fixed text, or a single resolved field value. * * @param array $attribute Attribute definition. * @param array $values Resolved values. * @return string|null Null when the field has no value (the attribute is then left out). */ private function attribute_value( array $attribute, array $values ) { if ( 'static' === $attribute['type'] ) { return (string) $attribute['value']; } $raw = isset( $values[ $attribute['value'] ] ) ? $values[ $attribute['value'] ] : null; if ( is_int( $raw ) ) { return (string) $raw; } return ( is_string( $raw ) && '' !== $raw ) ? $raw : null; } /** * Normalises a resolved value to string, list of strings, or null (empty). * * @param mixed $value Raw value. * @return string|int|string[]|null */ private static function normalize( $value ) { if ( is_array( $value ) ) { $list = array(); foreach ( $value as $part ) { if ( is_scalar( $part ) && '' !== (string) $part ) { $list[] = (string) $part; } } return array() === $list ? null : $list; } if ( is_int( $value ) ) { return $value; // Typed values (Unix timestamps, post IDs) stay integers so JSON gets a number. } if ( is_bool( $value ) ) { return $value ? '1' : '0'; } if ( is_scalar( $value ) && '' !== (string) $value ) { return (string) $value; } return null; } /** * Writes a value at a nested path (paths were validated: no conflicts). * * @param array $target Output array, modified in place. * @param string[] $segments Path segments. * @param mixed $value Value to set. */ private static function set_path( array &$target, array $segments, $value ): void { $last = array_pop( $segments ); $node = &$target; foreach ( $segments as $segment ) { if ( ! isset( $node[ $segment ] ) || ! is_array( $node[ $segment ] ) ) { $node[ $segment ] = array(); } $node = &$node[ $segment ]; } $node[ $last ] = $value; } /** * Reads the node at a path, or null. * * @param array $target Output. * @param string[] $segments Path segments. * @return mixed */ private static function get_path( array $target, array $segments ) { $node = $target; foreach ( $segments as $segment ) { if ( ! is_array( $node ) || ! array_key_exists( $segment, $node ) ) { return null; } $node = $node[ $segment ]; } return $node; } /** * Removes the node at a path. * * @param array $target Output, modified in place. * @param string[] $segments Path segments. */ private static function unset_path( array &$target, array $segments ): void { $last = array_pop( $segments ); $node = &$target; foreach ( $segments as $segment ) { if ( ! isset( $node[ $segment ] ) || ! is_array( $node[ $segment ] ) ) { return; } $node = &$node[ $segment ]; } unset( $node[ $last ] ); } /** * Adds an attribute to the element at a path (an empty path is the item element). Does * nothing when that element is not in the output for this item. * * @param array $target Output, modified in place. * @param string[] $segments Path segments (empty for the item). * @param string $name Attribute name. * @param string $value Attribute value. */ private static function set_attribute( array &$target, array $segments, string $name, string $value ): void { if ( array() === $segments ) { $target[ self::ATTRIBUTES ][ $name ] = $value; return; } $last = array_pop( $segments ); $node = &$target; foreach ( $segments as $segment ) { if ( ! isset( $node[ $segment ] ) || ! is_array( $node[ $segment ] ) ) { return; } $node = &$node[ $segment ]; } if ( ! array_key_exists( $last, $node ) ) { return; } $child = &$node[ $last ]; if ( $child instanceof Term_List ) { $child->attributes[ $name ] = $value; } elseif ( is_array( $child ) && ( array() === $child || ! Text_Cleaner::is_list( $child ) ) ) { $child[ self::ATTRIBUTES ][ $name ] = $value; } else { // A text leaf (or a plain list of repeated values) becomes "value plus attributes". $child = array( self::VALUE => $child, self::ATTRIBUTES => array( $name => $value ), ); } } }