"\'\\\\\x00-\x1F\x7F]+$#iD', $value ); } /** * Whether a value is an RFC 822 / 2822 date as RSS requires. * * @param mixed $value Candidate. * @return bool */ public static function is_rss_date( $value ): bool { return is_string( $value ) && 1 === preg_match( '/^(?:[A-Z][a-z]{2}, )?\d{1,2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}(?::\d{2})? (?:[+-]\d{4}|GMT|UT|[A-Z]{1,5})$/D', $value ); } /** * RSS-specific configuration errors. * * @param Feed_Config $config Configuration. * @return string[] */ public static function validate( Feed_Config $config ): array { $errors = array(); $rss = $config->rss; if ( strlen( $rss['title'] ) > 200 ) { $errors[] = __( 'The channel title may be at most 200 characters.', 'feedweaver' ); } if ( strlen( $rss['description'] ) > 500 ) { $errors[] = __( 'The channel description may be at most 500 characters.', 'feedweaver' ); } if ( '' !== $rss['link'] && ! self::is_http_url( $rss['link'] ) ) { $errors[] = __( 'The channel link must be a full web address starting with http:// or https://.', 'feedweaver' ); } if ( '' !== $rss['language'] && 1 !== preg_match( '/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/D', $rss['language'] ) ) { $errors[] = __( 'The language must be a code such as en, en-us or pt-br.', 'feedweaver' ); } if ( strlen( $rss['copyright'] ) > 200 ) { $errors[] = __( 'The copyright notice may be at most 200 characters.', 'feedweaver' ); } if ( '' !== $rss['editor'] && 1 !== preg_match( '/^[^\s@<>()]{1,64}@[^\s@<>()]{1,190}(?: \([^()<>]{1,100}\))?$/D', $rss['editor'] ) ) { $errors[] = __( 'The managing editor must be an email address, optionally followed by a name in brackets: editor@example.com (Pat Example).', 'feedweaver' ); } $top_level = array(); foreach ( $config->mappings as $mapping ) { if ( isset( $mapping['path'] ) && is_string( $mapping['path'] ) ) { $top_level[] = trim( explode( '/', $mapping['path'] )[0] ); } } if ( ! in_array( 'title', $top_level, true ) && ! in_array( 'description', $top_level, true ) ) { $errors[] = __( 'An RSS item needs a title or a description. Map a field to the output path "title" or "description".', 'feedweaver' ); } if ( null !== $config->enclosure ) { $errors = array_merge( $errors, self::validate_enclosure( $config->enclosure ) ); if ( in_array( 'enclosure', $top_level, true ) ) { $errors[] = __( 'The output path "enclosure" is used by the media enclosure setting. Remove that field mapping or turn the enclosure off.', 'feedweaver' ); } } return $errors; } /** * Checks the enclosure setting: the media address, size and type must all be configured. * * @param array $enclosure Enclosure sources. * @return string[] */ private static function validate_enclosure( array $enclosure ): array { $errors = array(); $labels = array( 'url' => __( 'media address', 'feedweaver' ), 'length' => __( 'size', 'feedweaver' ), 'type' => __( 'type', 'feedweaver' ), ); foreach ( $labels as $part => $label ) { $spec = isset( $enclosure[ $part ] ) && is_array( $enclosure[ $part ] ) ? $enclosure[ $part ] : array(); $type = isset( $spec['type'] ) ? $spec['type'] : ''; $value = isset( $spec['value'] ) ? $spec['value'] : ''; if ( 'source' === $type ) { if ( ! Source_Catalog::is_scalar_source( $value ) ) { /* translators: %s: enclosure part (media address, size or type). */ $errors[] = sprintf( __( 'Enclosure: choose where the %s comes from.', 'feedweaver' ), $label ); } elseif ( Source_Catalog::is_meta( $value ) ) { foreach ( Meta_Policy::check( (string) Source_Catalog::meta_key( $value ) ) as $reason ) { /* translators: %s: reason. */ $errors[] = sprintf( __( 'Enclosure: %s', 'feedweaver' ), $reason ); } } } elseif ( 'static' === $type && 'type' === $part ) { if ( ! self::is_mime_type( $value ) ) { $errors[] = __( 'Enclosure: the fixed type must look like video/mp4 or audio/mpeg.', 'feedweaver' ); } } else { /* translators: %s: enclosure part (media address, size or type). */ $errors[] = sprintf( __( 'Enclosure: choose where the %s comes from.', 'feedweaver' ), $label ); } }//end foreach return $errors; } /** * Whether a value looks like a MIME type. * * @param mixed $value Candidate. * @return bool */ public static function is_mime_type( $value ): bool { return is_string( $value ) && 1 === preg_match( '#^[a-z0-9][a-z0-9.+-]{0,60}/[a-z0-9][a-z0-9.+-]{0,100}$#iD', $value ); } /** * The mappings and attributes RSS actually renders: the administrator's, plus the * enclosure (an empty "enclosure" element carrying url, length and type attributes) and * isPermaLink on the guid (true only when the guid is the permalink itself). * * @param Feed_Config $config Configuration. * @return array{mappings:array,attributes:array} */ public static function effective( Feed_Config $config ): array { $mappings = $config->mappings; $attributes = $config->attributes; if ( null !== $config->enclosure ) { $mappings[] = array( 'source' => 'none', 'path' => 'enclosure', ); foreach ( array( 'url', 'length', 'type' ) as $part ) { $attributes[] = array( 'element' => 'enclosure', 'name' => $part, 'type' => $config->enclosure[ $part ]['type'], 'value' => $config->enclosure[ $part ]['value'], ); } } foreach ( $config->mappings as $mapping ) { if ( 'guid' !== trim( (string) $mapping['path'] ) ) { continue; } $explicit = false; foreach ( $attributes as $attribute ) { if ( 'guid' === $attribute['element'] && 'isPermaLink' === $attribute['name'] ) { $explicit = true; } } if ( ! $explicit ) { $attributes[] = array( 'element' => 'guid', 'name' => 'isPermaLink', 'type' => 'static', // Only a guid that is the permalink itself is a permalink; a WordPress GUID or Post ID is not. 'value' => 'permalink' === $mapping['source'] ? 'true' : 'false', ); } } return array( 'mappings' => $mappings, 'attributes' => $attributes, ); } /** * The channel's elements, in the order they are written. Blank settings take the site's values. * * @param Feed_Config $config Configuration. * @param array{name:string,url:string,description:string,language:string} $site The site's defaults. * @return array Element name => text (empty ones omitted). */ public static function channel( Feed_Config $config, array $site ): array { $rss = $config->rss; $title = '' !== $rss['title'] ? $rss['title'] : $site['name']; $title = '' !== trim( $title ) ? $title : $site['url']; $description = '' !== $rss['description'] ? $rss['description'] : $site['description']; $description = '' !== trim( $description ) ? $description : $title; $channel = array( 'title' => $title, 'link' => '' !== $rss['link'] ? $rss['link'] : $site['url'], 'description' => $description, 'language' => '' !== $rss['language'] ? $rss['language'] : $site['language'], 'copyright' => $rss['copyright'], 'managingEditor' => $rss['editor'], ); return array_filter( $channel, static function ( $value ) { return '' !== trim( (string) $value ); } ); } /** * The text of an element value (a plain value, or the "@value" of one with attributes). * * @param mixed $node Node. * @return string */ private static function text_of( $node ): string { if ( is_array( $node ) && array_key_exists( Mapping_Engine::VALUE, $node ) ) { $node = $node[ Mapping_Engine::VALUE ]; } return ( is_string( $node ) || is_int( $node ) ) ? trim( (string) $node ) : ''; } /** * Checks one rendered item against RSS and returns it cleaned, or null when it cannot be * valid (no title and no description). See inspect_item() for what is removed. * * @param array $item Mapped item. * @return array|null */ public static function normalize_item( array $item ) { return self::inspect_item( $item )['item']; } /** * Like normalize_item(), and also says what was changed so the preview can explain it. * * Elements that would be invalid are removed: a link that is not an http(s) address, a pubDate * that is not RFC 822, an empty guid, and an enclosure whose address, size or type is not valid. * An item with neither title nor description is dropped. * * @param array $item Mapped item. * @return array{item:array|null,notes:string[]} Notes use the codes link_removed, date_removed, * guid_removed, enclosure_invalid and no_content. */ public static function inspect_item( array $item ): array { $notes = array(); if ( '' === self::text_of( isset( $item['title'] ) ? $item['title'] : null ) && '' === self::text_of( isset( $item['description'] ) ? $item['description'] : null ) ) { return array( 'item' => null, 'notes' => array( 'no_content' ), ); } if ( isset( $item['link'] ) && ! self::is_http_url( self::text_of( $item['link'] ) ) ) { unset( $item['link'] ); $notes[] = 'link_removed'; } if ( isset( $item['pubDate'] ) && ! self::is_rss_date( self::text_of( $item['pubDate'] ) ) ) { unset( $item['pubDate'] ); $notes[] = 'date_removed'; } if ( isset( $item['guid'] ) && '' === self::text_of( $item['guid'] ) ) { unset( $item['guid'] ); $notes[] = 'guid_removed'; } if ( isset( $item['enclosure'] ) ) { $attributes = is_array( $item['enclosure'] ) && isset( $item['enclosure'][ Mapping_Engine::ATTRIBUTES ] ) ? $item['enclosure'][ Mapping_Engine::ATTRIBUTES ] : array(); $ok = isset( $attributes['url'], $attributes['length'], $attributes['type'] ) && self::is_http_url( $attributes['url'] ) && 1 === preg_match( '/^[1-9][0-9]{0,15}$/D', (string) $attributes['length'] ) && self::is_mime_type( $attributes['type'] ); if ( ! $ok ) { unset( $item['enclosure'] ); $notes[] = 'enclosure_invalid'; } } foreach ( array( 'title', 'link', 'description', 'pubDate', 'guid' ) as $name ) { if ( isset( $item[ $name ] ) && $item[ $name ] instanceof Term_List ) { unset( $item[ $name ] ); } } return array( 'item' => $item, 'notes' => $notes, ); } }