446 lines
12 KiB
PHP
446 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* DGT NAP ZBE fetcher and parser (DATEX2 v3 controlled zones).
|
|
*
|
|
* @package Fontdelbosc_Transit
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Fetches and parses Low Emission Zone data.
|
|
*/
|
|
class Fontdelbosc_Transit_Api_Zbe {
|
|
|
|
public const BASE_URL = 'https://nap.dgt.es/datex2/v3/dgt/zbe/ControledZonePublication/';
|
|
|
|
private const NS_CONZ = 'http://levelC/schema/3/controlledZone';
|
|
private const NS_COM = 'http://levelC/schema/3/common';
|
|
private const NS_TRA = 'http://levelC/schema/3/trafficRegulation';
|
|
|
|
/**
|
|
* Catalan zones available at the NAP index, pre-selected by default.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
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<int, string>|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<string, mixed>|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<string, mixed>|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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, string> $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;
|
|
}
|
|
}
|