*/ public const DEFAULT_CATALAN_ZONES = array( 'Barcelona', 'CerdanyolaDelValles', 'ElPratDeLlobregat', 'Gava', 'Girona', 'Granollers', 'Lleida', 'Manlleu', 'Mataro', 'Reus', 'RondasDeBarcelona', 'Rubi', 'Sabadell', 'SantBoiDeLlobregat', 'SantCugatDelValles', 'SantJoanDespi', 'Tarragona', 'Terrasa', 'Viladecans', ); /** * Sanitize a zone slug keeping its case (NAP endpoints are case-sensitive). * * @param string $slug Raw slug. * @return string */ public static function sanitize_slug( string $slug ): string { return preg_replace( '/[^A-Za-z0-9\-]/', '', $slug ) ?? ''; } /** * Fetch the zone index (HTML directory listing) and return zone slugs. * * @return array|null */ public static function get_index(): ?array { $body = self::http_get( self::BASE_URL ); if ( null === $body ) { return null; } if ( 0 === preg_match_all( '/href="([A-Za-z0-9\-]+)\.xml"/', $body, $m ) ) { return null; } $slugs = array_values( array_unique( $m[1] ) ); sort( $slugs ); return $slugs; } /** * Fetch and parse one zone by slug. * * @param string $slug Zone slug (endpoint file name without .xml). * @return array|null */ public static function get_zone( string $slug ): ?array { $body = self::http_get( self::BASE_URL . rawurlencode( $slug ) . '.xml' ); if ( null === $body ) { return null; } $previous = libxml_use_internal_errors( true ); try { $xml = simplexml_load_string( $body ); } finally { libxml_clear_errors(); libxml_use_internal_errors( $previous ); } if ( false === $xml ) { return null; } $publication_time = ''; foreach ( $xml->children( self::NS_COM ) as $child ) { if ( 'publicationTime' === $child->getName() ) { $publication_time = (string) $child; } } $table = self::find_child( $xml, 'controlledZoneTable', self::NS_CONZ ); if ( null === $table ) { return null; } $zones = array(); foreach ( $table->children( self::NS_CONZ ) as $zone ) { if ( 'controlledZone' !== $zone->getName() ) { continue; } $parsed = self::parse_zone( $zone ); if ( null !== $parsed ) { $parsed['publication_time'] = $publication_time; $zones[] = $parsed; } } if ( array() === $zones ) { return null; } $result = $zones[0]; if ( count( $zones ) > 1 ) { $result['extra_zones'] = array_slice( $zones, 1 ); } return $result; } /** * Parse one controlledZone element into a normalized zone array. * * @param SimpleXMLElement $zone Controlled zone element. * @return array|null */ private static function parse_zone( SimpleXMLElement $zone ): ?array { $result = array( 'slug' => '', 'name' => '', 'description' => '', 'url' => '', 'status' => '', 'restriction_type' => '', 'sticker_negate' => false, 'stickers' => array(), 'periods' => array(), 'publication_time' => '', ); $result['name'] = self::localized_value( $zone, 'name' ); $description = self::localized_value( $zone, 'controlledZoneDescription' ); if ( null !== $description ) { $result['description'] = $description; } $url = self::direct_value( $zone, 'urlForFurtherInformation' ); if ( null !== $url ) { $result['url'] = $url; } $status = self::direct_value( $zone, 'status' ); if ( null !== $status ) { $result['status'] = $status; } $order = self::find_child( $zone, 'trafficRegulationOrder' ); if ( null === $order ) { return null; } foreach ( $order->children( self::NS_TRA ) as $regulation ) { if ( 'trafficRegulation' !== $regulation->getName() ) { continue; } self::parse_regulation( $regulation, $result ); } return $result; } /** * Extract restriction type, sticker conditions and periods from a regulation. * * @param SimpleXMLElement $regulation Traffic regulation element. * @param array $result Zone array, modified by reference. */ private static function parse_regulation( SimpleXMLElement $regulation, array &$result ): void { $type = self::find_child( $regulation, 'typeOfRegulation', self::NS_TRA ); if ( null !== $type ) { $restriction = self::direct_value( $type, 'accessRestrictionType', self::NS_TRA ); if ( null !== $restriction && '' === $result['restriction_type'] ) { $result['restriction_type'] = $restriction; } } foreach ( $regulation->children( self::NS_TRA ) as $child ) { if ( 'condition' !== $child->getName() ) { continue; } if ( 'ConditionSet' === self::type_of( $child ) ) { self::parse_condition_set( $child, $result ); } } } /** * Parse the conditions inside a ConditionSet (stickers and validity periods). * * ConditionSets can nest (outer set with location, sticker set, validity), * so this method recurses into nested sets. * * @param SimpleXMLElement $set Condition set element. * @param array $result Zone array, modified by reference. */ private static function parse_condition_set( SimpleXMLElement $set, array &$result ): void { $negate = self::direct_value( $set, 'negate', self::NS_TRA ); if ( 'true' === $negate ) { $result['sticker_negate'] = true; } foreach ( $set->children( self::NS_TRA ) as $child ) { if ( 'conditions' !== $child->getName() ) { continue; } $type_name = self::type_of( $child ); if ( 'NonCodableCondition' === $type_name ) { $condition = self::find_child( $child, 'condition' ); if ( null === $condition ) { continue; } $values = self::find_child_path( $condition, array( 'values', 'value' ), self::NS_COM ); if ( null !== $values ) { $value = trim( (string) $values ); if ( '' !== $value ) { $stickers = Fontdelbosc_Transit_Payload::arr( $result['stickers'] ?? null ); $stickers[] = $value; $result['stickers'] = $stickers; } } } elseif ( 'ConditionSet' === $type_name ) { self::parse_condition_set( $child, $result ); } elseif ( 'ValidityCondition' === $type_name ) { self::parse_validity( $child, $result ); } } } /** * Parse validity periods out of a ValidityCondition. * * @param SimpleXMLElement $element ValidityCondition element. * @param array $result Zone array, modified by reference. */ private static function parse_validity( SimpleXMLElement $element, array &$result ): void { $implemented = self::find_child( $element, 'implementedValidity', self::NS_TRA ); if ( null === $implemented ) { return; } $spec = self::find_child( $implemented, 'validityTimeSpecification', self::NS_COM ); if ( null === $spec ) { return; } foreach ( $spec->children( self::NS_COM ) as $valid_period ) { if ( 'validPeriod' !== $valid_period->getName() ) { continue; } $days = self::find_child_path( $valid_period, array( 'periodName', 'values', 'value' ), self::NS_COM ); $hours = self::find_child( $valid_period, 'recurringTimePeriodOfDay', self::NS_COM ); if ( null === $days || null === $hours ) { continue; } $start = self::direct_value( $hours, 'startTimeOfPeriod', self::NS_COM ); $end = self::direct_value( $hours, 'endTimeOfPeriod', self::NS_COM ); $periods = Fontdelbosc_Transit_Payload::arr( $result['periods'] ?? null ); $periods[] = array( 'days' => trim( (string) $days ), 'start' => null !== $start ? self::short_time( $start ) : '', 'end' => null !== $end ? self::short_time( $end ) : '', ); $result['periods'] = $periods; } } /** * Extract the local part of the xsi:type attribute. * * @param SimpleXMLElement $element Element with an xsi:type attribute. * @return string */ private static function type_of( SimpleXMLElement $element ): string { $attrs = $element->attributes( 'http://www.w3.org/2001/XMLSchema-instance' ); $type = isset( $attrs['type'] ) ? (string) $attrs['type'] : ''; $pos = strrpos( $type, ':' ); return false === $pos ? $type : substr( $type, $pos + 1 ); } /** * Shorten an xs:time value (07:00:00Z) to HH:MM. * * @param string $time Raw time. * @return string */ private static function short_time( string $time ): string { return strlen( $time ) >= 5 ? substr( $time, 0, 5 ) : $time; } /** * Get a localized com:value from a conz element (name, description...). * * @param SimpleXMLElement $element Parent element. * @param string $child_name Child name in the conz namespace. * @return string|null */ private static function localized_value( SimpleXMLElement $element, string $child_name ): ?string { $child = self::find_child( $element, $child_name ); if ( null === $child ) { return null; } $values = self::find_child_path( $child, array( 'values', 'value' ), self::NS_COM ); if ( null === $values ) { return null; } $value = trim( (string) $values ); return '' === $value ? null : $value; } /** * Get the direct (unprefixed-value) text of a namespaced child. * * @param SimpleXMLElement $element Parent element. * @param string $child_name Child name. * @param string|null $ns Namespace URI, null for the conz namespace. * @return string|null */ private static function direct_value( SimpleXMLElement $element, string $child_name, ?string $ns = null ): ?string { $child = self::find_child( $element, $child_name, $ns ?? self::NS_CONZ ); if ( null === $child ) { return null; } $value = trim( (string) $child ); return '' === $value ? null : $value; } /** * Find the first child element by name within a namespace. * * @param SimpleXMLElement $element Parent element. * @param string $name Child name. * @param string|null $ns Namespace URI, null for the conz namespace. * @return SimpleXMLElement|null */ private static function find_child( SimpleXMLElement $element, string $name, ?string $ns = null ): ?SimpleXMLElement { $uri = $ns ?? self::NS_CONZ; foreach ( $element->children( $uri ) as $child ) { if ( $name === $child->getName() ) { return $child; } } return null; } /** * Walk a path of namespaced children and return the last element. * * @param SimpleXMLElement $element Parent element. * @param array $path Child names, outermost first. * @param string|null $ns Namespace URI, null for the conz namespace. * @return SimpleXMLElement|null */ private static function find_child_path( SimpleXMLElement $element, array $path, ?string $ns = null ): ?SimpleXMLElement { $current = $element; foreach ( $path as $name ) { $child = self::find_child( $current, $name, $ns ); if ( null === $child ) { return null; } $current = $child; } return $current; } /** * Perform an HTTP GET and return the body, or null on failure. * * @param string $url Endpoint URL. * @return string|null */ private static function http_get( string $url ): ?string { $response = wp_remote_get( $url, array( 'timeout' => 15, 'headers' => array( 'Accept' => 'application/xml, text/xml', ), ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); return '' === $body ? null : $body; } }