v1.0.0
This commit is contained in:
commit
1ef08bd731
27 changed files with 4854 additions and 0 deletions
458
includes/class-fontdelbosc-transit-api-sct.php
Normal file
458
includes/class-fontdelbosc-transit-api-sct.php
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
<?php
|
||||
/**
|
||||
* SCT incident fetcher and parser: DATEX2 primary, RSS enricher/fallback.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Fetches and parses SCT incidents.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Api_Sct {
|
||||
|
||||
public const DATEX2_URL = 'https://infocar.dgt.es/datex2/sct/SituationPublication/all/content.xml';
|
||||
public const RSS_URL = 'https://www.gencat.cat/transit/opendata/incidenciesRSS.xml';
|
||||
|
||||
private const NS = 'http://datex2.eu/schema/1_0/1_0';
|
||||
|
||||
/**
|
||||
* Fetch and parse incidents: DATEX2 for structure, RSS for text.
|
||||
*
|
||||
* @return array{incidents: array<int, array<string, mixed>>, source: string, publication_time: string}|null
|
||||
*/
|
||||
public static function get_incidents(): ?array {
|
||||
$datex2 = self::fetch_datex2();
|
||||
$rss = self::fetch_rss();
|
||||
|
||||
if ( null === $datex2 ) {
|
||||
if ( null === $rss ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'incidents' => array_values( $rss['items'] ),
|
||||
'source' => 'rss',
|
||||
'publication_time' => $rss['publication_time'],
|
||||
);
|
||||
}
|
||||
|
||||
$by_reference = array();
|
||||
|
||||
if ( null !== $rss ) {
|
||||
foreach ( $rss['items'] as $item ) {
|
||||
$by_reference[ Fontdelbosc_Transit_Payload::str( $item['reference'] ?? null ) ?? '' ] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $datex2['incidents'] as &$incident ) {
|
||||
$match = $by_reference[ Fontdelbosc_Transit_Payload::str( $incident['reference'] ?? null ) ?? '' ] ?? null;
|
||||
if ( null !== $match ) {
|
||||
$incident['title'] = $match['title'];
|
||||
$incident['description'] = $match['description'];
|
||||
$incident['km'] = $match['km'];
|
||||
$incident['link'] = $match['link'];
|
||||
}
|
||||
}
|
||||
unset( $incident );
|
||||
|
||||
return array(
|
||||
'incidents' => array_values( $datex2['incidents'] ),
|
||||
'source' => 'datex2',
|
||||
'publication_time' => $datex2['publication_time'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the DATEX2 SituationPublication.
|
||||
*
|
||||
* @return array{incidents: array<int, array<string, mixed>>, publication_time: string}|null
|
||||
*/
|
||||
private static function fetch_datex2(): ?array {
|
||||
$body = self::http_get( self::DATEX2_URL );
|
||||
if ( null === $body ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$xml = self::parse_xml( $body );
|
||||
if ( null === $xml ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DATEX2 XML tag name.
|
||||
$payload = $xml->payloadPublication;
|
||||
if ( null === $payload ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$incidents = array();
|
||||
$publication_time = '';
|
||||
|
||||
foreach ( $payload->children( self::NS ) as $child ) {
|
||||
if ( 'situation' !== $child->getName() ) {
|
||||
if ( 'publicationTime' === $child->getName() ) {
|
||||
$publication_time = (string) $child;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( $child->children( self::NS ) as $record ) {
|
||||
if ( 'situationRecord' !== $record->getName() ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$incident = self::parse_record( $record );
|
||||
if ( null !== $incident ) {
|
||||
$incidents[] = $incident;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'incidents' => $incidents,
|
||||
'publication_time' => $publication_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one DATEX2 situationRecord into a normalized incident.
|
||||
*
|
||||
* @param SimpleXMLElement $record Situation record element.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function parse_record( SimpleXMLElement $record ): ?array {
|
||||
$type = self::type_of( $record );
|
||||
$attrs = $record->attributes();
|
||||
|
||||
$incident = array(
|
||||
'id' => isset( $attrs['id'] ) ? (string) $attrs['id'] : '',
|
||||
'reference' => self::child_value( $record, 'situationRecordCreationReference' ),
|
||||
'type' => $type,
|
||||
'status' => '',
|
||||
'start_time' => '',
|
||||
'end_time' => '',
|
||||
'road_name' => '',
|
||||
'town' => '',
|
||||
'direction' => '',
|
||||
'lat' => null,
|
||||
'lon' => null,
|
||||
'impact' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'km' => null,
|
||||
'link' => '',
|
||||
);
|
||||
|
||||
$validity = self::child_value_nested( $record, array( 'validity', 'validityStatus' ) );
|
||||
if ( null !== $validity ) {
|
||||
$incident['status'] = $validity;
|
||||
}
|
||||
|
||||
$start = self::child_value_nested( $record, array( 'validity', 'validityTimeSpecification', 'overallStartTime' ) );
|
||||
if ( null !== $start ) {
|
||||
$incident['start_time'] = $start;
|
||||
}
|
||||
|
||||
$end = self::child_value_nested( $record, array( 'validity', 'validityTimeSpecification', 'overallEndTime' ) );
|
||||
if ( null !== $end ) {
|
||||
$incident['end_time'] = $end;
|
||||
}
|
||||
|
||||
$impact = self::child_value_nested( $record, array( 'impact', 'impactOnTraffic' ) );
|
||||
if ( null !== $impact ) {
|
||||
$incident['impact'] = $impact;
|
||||
}
|
||||
|
||||
self::parse_location( $record, $incident );
|
||||
|
||||
if ( '' === $incident['id'] && '' === $incident['road_name'] && '' === $incident['start_time'] ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $incident;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract location data (road, town, direction, coordinates) from the record.
|
||||
*
|
||||
* @param SimpleXMLElement $record Situation record element.
|
||||
* @param array<string, mixed> $incident Incident array, modified by reference.
|
||||
*/
|
||||
private static function parse_location( SimpleXMLElement $record, array &$incident ): void {
|
||||
$tpeg = self::find_child_path(
|
||||
$record,
|
||||
array( 'groupOfLocations', 'locationContainedInGroup', 'tpeglinearLocation' )
|
||||
);
|
||||
|
||||
if ( null === $tpeg ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = self::find_child( $tpeg, 'tpegDirection' );
|
||||
if ( null !== $direction ) {
|
||||
$incident['direction'] = trim( (string) $direction );
|
||||
}
|
||||
|
||||
$from = self::find_child( $tpeg, 'from' );
|
||||
if ( null !== $from ) {
|
||||
self::parse_point( $from, $incident );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a TPEG point (road, town, coordinates).
|
||||
*
|
||||
* @param SimpleXMLElement $point TPEG point element.
|
||||
* @param array<string, mixed> $incident Incident array, modified by reference.
|
||||
*/
|
||||
private static function parse_point( SimpleXMLElement $point, array &$incident ): void {
|
||||
$coords = self::find_child( $point, 'pointCoordinates' );
|
||||
|
||||
if ( null !== $coords ) {
|
||||
$lat = self::find_child( $coords, 'latitude' );
|
||||
$lon = self::find_child( $coords, 'longitude' );
|
||||
if ( null !== $lat && null !== $lon ) {
|
||||
$incident['lat'] = (float) (string) $lat;
|
||||
$incident['lon'] = (float) (string) $lon;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $point->children( self::NS ) as $name ) {
|
||||
if ( 'name' !== $name->getName() ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = self::find_child( $name, 'tpegDescriptorType' );
|
||||
$value = self::find_child_path( $name, array( 'descriptor', 'value' ) );
|
||||
|
||||
if ( null === $type || null === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type_value = trim( (string) $type );
|
||||
$name_value = trim( (string) $value );
|
||||
|
||||
if ( 'linkName' === $type_value && '' === $incident['road_name'] ) {
|
||||
$incident['road_name'] = $name_value;
|
||||
} elseif ( 'townName' === $type_value && '' === $incident['town'] ) {
|
||||
$incident['town'] = $name_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the DATEX2 record type from the xsi:type attribute.
|
||||
*
|
||||
* @param SimpleXMLElement $record Situation record element.
|
||||
* @return string
|
||||
*/
|
||||
private static function type_of( SimpleXMLElement $record ): string {
|
||||
$attrs = $record->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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the SCT RSS feed.
|
||||
*
|
||||
* @return array{items: array<int, array<string, mixed>>, publication_time: string}|null
|
||||
*/
|
||||
private static function fetch_rss(): ?array {
|
||||
$body = self::http_get( self::RSS_URL );
|
||||
if ( null === $body ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$xml = self::parse_xml( $body );
|
||||
if ( null === $xml ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = array();
|
||||
$channel = $xml->channel;
|
||||
$publication_time = isset( $channel->pubDate ) ? (string) $channel->pubDate : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- RSS tag name.
|
||||
|
||||
foreach ( $xml->channel->item as $item ) {
|
||||
$guid = (string) $item->guid;
|
||||
$items[] = array(
|
||||
'reference' => '' !== $guid ? self::reference_from_guid( $guid ) : '',
|
||||
'title' => trim( (string) $item->title ),
|
||||
'description' => trim( (string) $item->description ),
|
||||
'town' => self::town_from_description( (string) $item->description ),
|
||||
'km' => self::km_from_description( (string) $item->description ),
|
||||
'link' => (string) $item->link,
|
||||
'pub_time' => (string) $item->pubDate, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- RSS tag name.
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'items' => $items,
|
||||
'publication_time' => $publication_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the DATEX2 creation reference from an RSS guid (e.g. 151732601 → 1517326).
|
||||
*
|
||||
* @param string $guid RSS guid.
|
||||
* @return string
|
||||
*/
|
||||
public static function reference_from_guid( string $guid ): string {
|
||||
return '' === $guid ? '' : substr( $guid, 0, -2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the town from an RSS description ("C-15 | CABRERA D' ANOIA | Sentit ...").
|
||||
*
|
||||
* @param string $description RSS item description.
|
||||
* @return string
|
||||
*/
|
||||
public static function town_from_description( string $description ): string {
|
||||
$parts = explode( '|', $description );
|
||||
|
||||
if ( count( $parts ) < 2 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$town = trim( $parts[1] );
|
||||
|
||||
return '' === $town ? '' : $town;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the kilometre point from an RSS description (e.g. "Punt km. 32.3-32.6").
|
||||
*
|
||||
* @param string $description RSS item description.
|
||||
* @return string
|
||||
*/
|
||||
public static function km_from_description( string $description ): string {
|
||||
if ( 1 === preg_match( '/km\.\s*([0-9]+(?:\.[0-9]+)?)/u', $description, $m ) ) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an XML string with error suppression, or null on failure.
|
||||
*
|
||||
* @param string $body Raw XML body.
|
||||
* @return SimpleXMLElement|null
|
||||
*/
|
||||
private static function parse_xml( string $body ): ?SimpleXMLElement {
|
||||
$previous = libxml_use_internal_errors( true );
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string( $body );
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors( $previous );
|
||||
}
|
||||
|
||||
return false === $xml ? null : $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string value of a direct namespaced child.
|
||||
*
|
||||
* @param SimpleXMLElement $element Parent element.
|
||||
* @param string $name Child name.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function child_value( SimpleXMLElement $element, string $name ): ?string {
|
||||
$child = self::find_child( $element, $name );
|
||||
if ( null === $child ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim( (string) $child );
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first namespaced child element by name.
|
||||
*
|
||||
* @param SimpleXMLElement $element Parent element.
|
||||
* @param string $name Child name.
|
||||
* @return SimpleXMLElement|null
|
||||
*/
|
||||
private static function find_child( SimpleXMLElement $element, string $name ): ?SimpleXMLElement {
|
||||
foreach ( $element->children( self::NS ) 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.
|
||||
* @return SimpleXMLElement|null
|
||||
*/
|
||||
private static function find_child_path( SimpleXMLElement $element, array $path ): ?SimpleXMLElement {
|
||||
$current = $element;
|
||||
|
||||
foreach ( $path as $name ) {
|
||||
$child = self::find_child( $current, $name );
|
||||
if ( null === $child ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $child;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a path of namespaced children and return the leaf string value.
|
||||
*
|
||||
* @param SimpleXMLElement $element Parent element.
|
||||
* @param array<int, string> $path Child names, outermost first.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function child_value_nested( SimpleXMLElement $element, array $path ): ?string {
|
||||
$leaf = self::find_child_path( $element, $path );
|
||||
if ( null === $leaf ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim( (string) $leaf );
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
446
includes/class-fontdelbosc-transit-api-zbe.php
Normal file
446
includes/class-fontdelbosc-transit-api-zbe.php
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
199
includes/class-fontdelbosc-transit-comarques.php
Normal file
199
includes/class-fontdelbosc-transit-comarques.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
/**
|
||||
* Comarca resolution: maps incident towns to the comarques around Font del Bosc.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Town-to-comarca mapping for Alt Penedès, Anoia, Baix Llobregat and Garraf.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Comarques {
|
||||
|
||||
/**
|
||||
* Supported comarca keys and their labels.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const COMARQUES = array(
|
||||
'altpenedes' => 'Alt Penedès',
|
||||
'anoia' => 'Anoia',
|
||||
'baixllobregat' => 'Baix Llobregat',
|
||||
'garraf' => 'Garraf',
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalized town name to comarca key.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const TOWN_COMARCA = array(
|
||||
/* Alt Penedès */
|
||||
'AVINYONETDELPENEDES' => 'altpenedes',
|
||||
'LESCABANES' => 'altpenedes',
|
||||
'CASTELLVIDELAMARCA' => 'altpenedes',
|
||||
'FONTRUBI' => 'altpenedes',
|
||||
'GELIDA' => 'altpenedes',
|
||||
'LAGRANADA' => 'altpenedes',
|
||||
'MEDIONA' => 'altpenedes',
|
||||
'OLERDOLA' => 'altpenedes',
|
||||
'OLESADEBONESVALLS' => 'altpenedes',
|
||||
'PACSDELPENEDES' => 'altpenedes',
|
||||
'PLADELPENEDES' => 'altpenedes',
|
||||
'PONTONS' => 'altpenedes',
|
||||
'PUIGDALBER' => 'altpenedes',
|
||||
'SANTCUGATSESGARRIGUES' => 'altpenedes',
|
||||
'SANTLLORENCDHORTONS' => 'altpenedes',
|
||||
'SANTMARTISARROCA' => 'altpenedes',
|
||||
'SANTPEREDERIUDEBITLLES' => 'altpenedes',
|
||||
'SANTQUINTIDEMEDIONA' => 'altpenedes',
|
||||
'SANTAFEDELPENEDES' => 'altpenedes',
|
||||
'SANTSADURNIDANOIA' => 'altpenedes',
|
||||
'SANTAMARGARIDAILSMONJOS' => 'altpenedes',
|
||||
'SANTAMARGARIDAIELSMONJOS' => 'altpenedes',
|
||||
'SUBIRATS' => 'altpenedes',
|
||||
'TORRELAVIT' => 'altpenedes',
|
||||
'TORRELLESDEFOIX' => 'altpenedes',
|
||||
'VILAFRANCADELPENEDES' => 'altpenedes',
|
||||
'VILOVIDELPENEDES' => 'altpenedes',
|
||||
/* Anoia */
|
||||
'IGUALADA' => 'anoia',
|
||||
'ODENA' => 'anoia',
|
||||
'VILANOVADELCAMI' => 'anoia',
|
||||
'SANTAMARGARIDADEMONTBUI' => 'anoia',
|
||||
'POBLADECLARAMUNT' => 'anoia',
|
||||
'JORBA' => 'anoia',
|
||||
'PIERA' => 'anoia',
|
||||
'CABRERADANOIA' => 'anoia',
|
||||
'CAPELLADES' => 'anoia',
|
||||
'CARME' => 'anoia',
|
||||
'BRUC' => 'anoia',
|
||||
'CASTELLFOLLITDERIUBREGOS' => 'anoia',
|
||||
'CASTELLOLI' => 'anoia',
|
||||
'COPONS' => 'anoia',
|
||||
'ESTARAS' => 'anoia',
|
||||
'RUBIO' => 'anoia',
|
||||
'SANTMARTISESGUEIOLES' => 'anoia',
|
||||
'SANTPERESALLAVINERA' => 'anoia',
|
||||
'PUJALT' => 'anoia',
|
||||
'PRATSDEREI' => 'anoia',
|
||||
'VECIANA' => 'anoia',
|
||||
'MONTMANEU' => 'anoia',
|
||||
'ARGENSOLA' => 'anoia',
|
||||
'BELLPRAT' => 'anoia',
|
||||
/* Baix Llobregat */
|
||||
'CORNELLADELLOBREGAT' => 'baixllobregat',
|
||||
'SANTBOIDELLOBREGAT' => 'baixllobregat',
|
||||
'SANTFEIUDELLOBREGAT' => 'baixllobregat',
|
||||
'SANTJUSTDESVERN' => 'baixllobregat',
|
||||
'ESPLUGUESDELLOBREGAT' => 'baixllobregat',
|
||||
'SANTJOANDESPI' => 'baixllobregat',
|
||||
'SANTVICENSDELSHORTS' => 'baixllobregat',
|
||||
'SANTACOLOMADECERVELLO' => 'baixllobregat',
|
||||
'SANTCLIMENTDELLOBREGAT' => 'baixllobregat',
|
||||
'TORRELLESDELLOBREGAT' => 'baixllobregat',
|
||||
'VILADECANS' => 'baixllobregat',
|
||||
'PRATDELLOBREGAT' => 'baixllobregat',
|
||||
'HOSPITALETDELLOBREGAT' => 'baixllobregat',
|
||||
'CERVELLO' => 'baixllobregat',
|
||||
'CORBERADELLOBREGAT' => 'baixllobregat',
|
||||
'PALMADECERVELLO' => 'baixllobregat',
|
||||
'PALLEJA' => 'baixllobregat',
|
||||
'PAPIOL' => 'baixllobregat',
|
||||
'MOLINSDEREI' => 'baixllobregat',
|
||||
'SANTANDREUDELABARCA' => 'baixllobregat',
|
||||
'CASTELLDEFELS' => 'baixllobregat',
|
||||
'GAVA' => 'baixllobregat',
|
||||
'BEGUES' => 'baixllobregat',
|
||||
'ABRERA' => 'baixllobregat',
|
||||
'MARTORELL' => 'baixllobregat',
|
||||
'SANTESTEVESESROVIRES' => 'baixllobregat',
|
||||
'CASTELLVIDEROSANES' => 'baixllobregat',
|
||||
'COLLBATO' => 'baixllobregat',
|
||||
'ESPARREGUERA' => 'baixllobregat',
|
||||
'OLESADEMONTSERRAT' => 'baixllobregat',
|
||||
'VALLIRANA' => 'baixllobregat',
|
||||
/* Garraf */
|
||||
'VILANOVAILAGELTRU' => 'garraf',
|
||||
'SITGES' => 'garraf',
|
||||
'CUBELLES' => 'garraf',
|
||||
'CANYELLES' => 'garraf',
|
||||
'SANTPEREDERIBES' => 'garraf',
|
||||
'OLIVELLA' => 'garraf',
|
||||
'GARRAF' => 'garraf',
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the comarca key of an incident from its town.
|
||||
*
|
||||
* @param array<mixed> $incident Normalized incident.
|
||||
* @return string Comarca key, or '' when unknown.
|
||||
*/
|
||||
public static function comarca_of( array $incident ): string {
|
||||
$town = Fontdelbosc_Transit_Payload::str( $incident['town'] ?? null ) ?? '';
|
||||
|
||||
if ( '' === $town ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::TOWN_COMARCA[ self::normalize_town( $town ) ] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a town name for lookup: uppercase, unaccented, letters only.
|
||||
*
|
||||
* @param string $town Raw town name.
|
||||
* @return string
|
||||
*/
|
||||
public static function normalize_town( string $town ): string {
|
||||
$ascii = iconv( 'UTF-8', 'ASCII//TRANSLIT', $town );
|
||||
|
||||
if ( false === $ascii ) {
|
||||
$ascii = $town;
|
||||
}
|
||||
|
||||
return preg_replace( '/[^A-Z]/', '', strtoupper( $ascii ) ) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated comarca attribute into valid, unique keys.
|
||||
*
|
||||
* @param string $attr Raw attribute value, e.g. "anoia, garraf".
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function keys_from_attr( string $attr ): array {
|
||||
$keys = array();
|
||||
|
||||
foreach ( explode( ',', $attr ) as $part ) {
|
||||
$key = sanitize_key( trim( $part ) );
|
||||
|
||||
if ( '' !== $key && self::is_valid( $key ) && ! in_array( $key, $keys, true ) ) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a comarca key is supported.
|
||||
*
|
||||
* @param string $key Comarca key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid( string $key ): bool {
|
||||
return isset( self::COMARQUES[ $key ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Label of a comarca key.
|
||||
*
|
||||
* @param string $key Comarca key.
|
||||
* @return string
|
||||
*/
|
||||
public static function label( string $key ): string {
|
||||
return self::COMARQUES[ $key ] ?? '';
|
||||
}
|
||||
}
|
||||
209
includes/class-fontdelbosc-transit-data-manager.php
Normal file
209
includes/class-fontdelbosc-transit-data-manager.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
/**
|
||||
* Orchestration: caching, stale-while-error, fetch status.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Coordinates API access with transients.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Data_Manager {
|
||||
|
||||
private const INCIDENTS_TRANSIENT = 'fontdelbosc_transit_incidents';
|
||||
private const INCIDENTS_STALE = 'fontdelbosc_transit_incidents_stale';
|
||||
private const ZBE_INDEX_TRANSIENT = 'fontdelbosc_transit_zbe_index';
|
||||
private const STALE_WINDOW = 6 * HOUR_IN_SECONDS;
|
||||
private const ZBE_STALE_WINDOW = 7 * DAY_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Get incidents (cached, stale-while-error).
|
||||
*
|
||||
* @return array{incidents: array<int, array<mixed>>, source: string, publication_time: string}|null
|
||||
*/
|
||||
public static function get_incidents(): ?array {
|
||||
$cached = get_transient( self::INCIDENTS_TRANSIENT );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
return self::shape_incidents( $cached );
|
||||
}
|
||||
|
||||
$fresh = Fontdelbosc_Transit_Api_Sct::get_incidents();
|
||||
|
||||
if ( null === $fresh ) {
|
||||
$stale = get_transient( self::INCIDENTS_STALE );
|
||||
|
||||
if ( is_array( $stale ) ) {
|
||||
return self::shape_incidents( $stale );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$options = Fontdelbosc_Transit_Plugin::get_options();
|
||||
$ttl = max( 60, Fontdelbosc_Transit_Payload::int( $options['incident_cache_ttl'] ?? null ) ?? 300 );
|
||||
|
||||
set_transient( self::INCIDENTS_TRANSIENT, $fresh, $ttl );
|
||||
set_transient( self::INCIDENTS_STALE, $fresh, self::STALE_WINDOW );
|
||||
self::record_status(
|
||||
'incidents',
|
||||
array(
|
||||
'last_fetch' => time(),
|
||||
'source' => $fresh['source'],
|
||||
'publication_time' => $fresh['publication_time'],
|
||||
'count' => count( $fresh['incidents'] ),
|
||||
)
|
||||
);
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a cached incidents payload back into its typed shape.
|
||||
*
|
||||
* @param array<mixed> $raw Cached value.
|
||||
* @return array{incidents: array<int, array<mixed>>, source: string, publication_time: string}
|
||||
*/
|
||||
private static function shape_incidents( array $raw ): array {
|
||||
$incidents = array();
|
||||
|
||||
foreach ( Fontdelbosc_Transit_Payload::arr( Fontdelbosc_Transit_Payload::get( $raw, 'incidents' ) ) as $incident ) {
|
||||
if ( is_array( $incident ) ) {
|
||||
$incidents[] = $incident;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'incidents' => $incidents,
|
||||
'source' => Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $raw, 'source' ) ) ?? '',
|
||||
'publication_time' => Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $raw, 'publication_time' ) ) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the zones selected in the options (cached, stale-while-error).
|
||||
*
|
||||
* @return array<int, array<mixed>>
|
||||
*/
|
||||
public static function get_zbes(): array {
|
||||
$options = Fontdelbosc_Transit_Plugin::get_options();
|
||||
$selected = Fontdelbosc_Transit_Payload::arr( $options['zbe_municipalities'] ?? Fontdelbosc_Transit_Api_Zbe::DEFAULT_CATALAN_ZONES );
|
||||
$ttl = max( HOUR_IN_SECONDS, Fontdelbosc_Transit_Payload::int( $options['zbe_cache_ttl'] ?? null ) ?? DAY_IN_SECONDS );
|
||||
$zones = array();
|
||||
|
||||
foreach ( $selected as $slug ) {
|
||||
$slug = Fontdelbosc_Transit_Api_Zbe::sanitize_slug( Fontdelbosc_Transit_Payload::str( $slug ) ?? '' );
|
||||
|
||||
if ( '' === $slug ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = 'fontdelbosc_transit_zbe_' . $slug;
|
||||
$cache = get_transient( $key );
|
||||
|
||||
if ( is_array( $cache ) ) {
|
||||
$cache['slug'] = $slug;
|
||||
$zones[] = $cache;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zone = Fontdelbosc_Transit_Api_Zbe::get_zone( $slug );
|
||||
|
||||
if ( null === $zone ) {
|
||||
$stale = get_transient( $key . '_stale' );
|
||||
|
||||
if ( is_array( $stale ) ) {
|
||||
$stale['slug'] = $slug;
|
||||
$zones[] = $stale;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$zone['slug'] = $slug;
|
||||
set_transient( $key, $zone, $ttl );
|
||||
set_transient( $key . '_stale', $zone, self::ZBE_STALE_WINDOW );
|
||||
$zones[] = $zone;
|
||||
}
|
||||
|
||||
self::record_status(
|
||||
'zbe',
|
||||
array(
|
||||
'last_fetch' => time(),
|
||||
'zones' => count( $zones ),
|
||||
)
|
||||
);
|
||||
|
||||
return $zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full zone index for the admin (cached 24 h).
|
||||
*
|
||||
* @return array<int, string>|null
|
||||
*/
|
||||
public static function get_zbe_index(): ?array {
|
||||
$cached = get_transient( self::ZBE_INDEX_TRANSIENT );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
$slugs = array();
|
||||
|
||||
foreach ( $cached as $slug ) {
|
||||
$slug = Fontdelbosc_Transit_Payload::str( $slug );
|
||||
|
||||
if ( null !== $slug ) {
|
||||
$slugs[] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
return $slugs;
|
||||
}
|
||||
|
||||
$index = Fontdelbosc_Transit_Api_Zbe::get_index();
|
||||
|
||||
if ( null !== $index ) {
|
||||
set_transient( self::ZBE_INDEX_TRANSIENT, $index, DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush every data transient.
|
||||
*/
|
||||
public static function flush(): void {
|
||||
delete_transient( self::INCIDENTS_TRANSIENT );
|
||||
delete_transient( self::INCIDENTS_STALE );
|
||||
delete_transient( self::ZBE_INDEX_TRANSIENT );
|
||||
|
||||
$options = Fontdelbosc_Transit_Plugin::get_options();
|
||||
$slugs = array_map(
|
||||
static fn( mixed $slug ): string => Fontdelbosc_Transit_Payload::str( $slug ) ?? '',
|
||||
array_merge(
|
||||
Fontdelbosc_Transit_Payload::arr( $options['zbe_municipalities'] ?? array() ),
|
||||
self::get_zbe_index() ?? array()
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( array_unique( $slugs ) as $slug ) {
|
||||
$key = 'fontdelbosc_transit_zbe_' . Fontdelbosc_Transit_Api_Zbe::sanitize_slug( Fontdelbosc_Transit_Payload::str( $slug ) ?? '' );
|
||||
delete_transient( $key );
|
||||
delete_transient( $key . '_stale' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a fetch status entry.
|
||||
*
|
||||
* @param string $stream Stream name (incidents, zbe).
|
||||
* @param array<string, mixed> $data Status payload.
|
||||
*/
|
||||
private static function record_status( string $stream, array $data ): void {
|
||||
$status = Fontdelbosc_Transit_Payload::arr( get_option( 'fontdelbosc_transit_status', array() ) );
|
||||
$status[ $stream ] = $data;
|
||||
|
||||
update_option( 'fontdelbosc_transit_status', $status, false );
|
||||
}
|
||||
}
|
||||
263
includes/class-fontdelbosc-transit-maps.php
Normal file
263
includes/class-fontdelbosc-transit-maps.php
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<?php
|
||||
/**
|
||||
* Label maps and helpers (types, directions, stickers, C-15 detection).
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Maps raw API values to display labels.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Maps {
|
||||
|
||||
/**
|
||||
* Translate a DATEX2 record type into a display label.
|
||||
*
|
||||
* @param string $type Raw type (local part of xsi:type).
|
||||
* @return string
|
||||
*/
|
||||
public static function type_label( string $type ): string {
|
||||
switch ( $type ) {
|
||||
case 'Accident':
|
||||
return __( 'Accident', 'fontdelbosc-transit' );
|
||||
case 'MaintenanceWorks':
|
||||
case 'RoadWorks':
|
||||
return __( 'Roadworks', 'fontdelbosc-transit' );
|
||||
case 'VehicleObstruction':
|
||||
case 'ObstructionOnRoad':
|
||||
return __( 'Obstruction', 'fontdelbosc-transit' );
|
||||
case 'WeatherRelatedRoadConditions':
|
||||
return __( 'Weather conditions', 'fontdelbosc-transit' );
|
||||
case 'RoadsideAssistance':
|
||||
return __( 'Roadside assistance', 'fontdelbosc-transit' );
|
||||
case 'AbnormalTraffic':
|
||||
case 'TrafficCongestion':
|
||||
return __( 'Congestion', 'fontdelbosc-transit' );
|
||||
case 'NetworkManagement':
|
||||
case 'GeneralNetworkManagement':
|
||||
return __( 'Traffic management', 'fontdelbosc-transit' );
|
||||
case 'Restrictions':
|
||||
return __( 'Restrictions', 'fontdelbosc-transit' );
|
||||
default:
|
||||
return __( 'Incident', 'fontdelbosc-transit' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a TPEG direction code into a display label.
|
||||
*
|
||||
* @param string $direction Raw direction code.
|
||||
* @return string
|
||||
*/
|
||||
public static function direction_label( string $direction ): string {
|
||||
switch ( $direction ) {
|
||||
case 'northBound':
|
||||
return __( 'Northbound', 'fontdelbosc-transit' );
|
||||
case 'southBound':
|
||||
return __( 'Southbound', 'fontdelbosc-transit' );
|
||||
case 'eastBound':
|
||||
return __( 'Eastbound', 'fontdelbosc-transit' );
|
||||
case 'westBound':
|
||||
return __( 'Westbound', 'fontdelbosc-transit' );
|
||||
case 'bothWays':
|
||||
case 'both':
|
||||
return __( 'Both directions', 'fontdelbosc-transit' );
|
||||
default:
|
||||
return $direction;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a DATEX2 impact code into a display label.
|
||||
*
|
||||
* @param string $impact Raw impact code.
|
||||
* @return string
|
||||
*/
|
||||
public static function impact_label( string $impact ): string {
|
||||
switch ( $impact ) {
|
||||
case 'heavy':
|
||||
case 'veryHeavy':
|
||||
return __( 'Heavy impact on traffic', 'fontdelbosc-transit' );
|
||||
case 'medium':
|
||||
return __( 'Medium impact on traffic', 'fontdelbosc-transit' );
|
||||
case 'light':
|
||||
case 'low':
|
||||
return __( 'Light impact on traffic', 'fontdelbosc-transit' );
|
||||
default:
|
||||
return $impact;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a validity status code into a display label.
|
||||
*
|
||||
* @param string $status Raw status code.
|
||||
* @return string
|
||||
*/
|
||||
public static function status_label( string $status ): string {
|
||||
switch ( $status ) {
|
||||
case 'active':
|
||||
return __( 'Active', 'fontdelbosc-transit' );
|
||||
case 'scheduled':
|
||||
return __( 'Scheduled', 'fontdelbosc-transit' );
|
||||
default:
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a DGT sticker value ("Sin distintivo", "B", "C"...) to a label.
|
||||
*
|
||||
* @param string $sticker Raw sticker value.
|
||||
* @return string
|
||||
*/
|
||||
public static function sticker_label( string $sticker ): string {
|
||||
switch ( $sticker ) {
|
||||
case 'Sin distintivo':
|
||||
case 'Sin etiqueta':
|
||||
return __( 'No label (pre-2000 petrol / pre-2006 diesel)', 'fontdelbosc-transit' );
|
||||
case 'B':
|
||||
return __( 'Label B', 'fontdelbosc-transit' );
|
||||
case 'C':
|
||||
return __( 'Label C', 'fontdelbosc-transit' );
|
||||
case 'ECO':
|
||||
return __( 'Label ECO', 'fontdelbosc-transit' );
|
||||
case '0':
|
||||
return __( 'Label 0 emissions', 'fontdelbosc-transit' );
|
||||
default:
|
||||
return $sticker;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a DATEX2 record type to a stable icon/badge key.
|
||||
*
|
||||
* @param string $type Raw type (local part of xsi:type).
|
||||
* @return string
|
||||
*/
|
||||
public static function type_key( string $type ): string {
|
||||
switch ( $type ) {
|
||||
case 'Accident':
|
||||
return 'accident';
|
||||
case 'MaintenanceWorks':
|
||||
case 'RoadWorks':
|
||||
return 'works';
|
||||
case 'VehicleObstruction':
|
||||
case 'ObstructionOnRoad':
|
||||
return 'obstruction';
|
||||
case 'WeatherRelatedRoadConditions':
|
||||
return 'weather';
|
||||
case 'RoadsideAssistance':
|
||||
return 'assistance';
|
||||
case 'AbnormalTraffic':
|
||||
case 'TrafficCongestion':
|
||||
return 'congestion';
|
||||
case 'NetworkManagement':
|
||||
case 'GeneralNetworkManagement':
|
||||
return 'management';
|
||||
case 'Restrictions':
|
||||
return 'restriction';
|
||||
default:
|
||||
return 'incident';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a DGT sticker value to a chip color key (real label colors).
|
||||
*
|
||||
* @param string $sticker Raw sticker value.
|
||||
* @return string
|
||||
*/
|
||||
public static function sticker_class( string $sticker ): string {
|
||||
switch ( $sticker ) {
|
||||
case 'Sin distintivo':
|
||||
case 'Sin etiqueta':
|
||||
return 'none';
|
||||
case 'B':
|
||||
return 'b';
|
||||
case 'C':
|
||||
return 'c';
|
||||
case 'ECO':
|
||||
return 'eco';
|
||||
case '0':
|
||||
return 'zero';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an incident belongs to the C-15 corridor (Eix Garraf-Anoia).
|
||||
*
|
||||
* @param array<mixed> $incident Normalized incident.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_c15( array $incident ): bool {
|
||||
$road = self::normalize_road( Fontdelbosc_Transit_Payload::str( $incident['road_name'] ?? null ) ?? '' );
|
||||
|
||||
if ( 'C15' === $road || 'EIXGARRAF' === substr( $road, 0, 9 ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$title = Fontdelbosc_Transit_Payload::str( $incident['title'] ?? null ) ?? '';
|
||||
$description = Fontdelbosc_Transit_Payload::str( $incident['description'] ?? null ) ?? '';
|
||||
$haystack = $title . ' ' . $description;
|
||||
|
||||
// Word boundary required: C-153 and C-155 are different roads.
|
||||
return 1 === preg_match( '/C-?15\b/i', $haystack ) || false !== stripos( $haystack, 'eix garraf' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a road name for comparison (uppercase, no spaces or hyphens).
|
||||
*
|
||||
* @param string $road Raw road name.
|
||||
* @return string
|
||||
*/
|
||||
private static function normalize_road( string $road ): string {
|
||||
return strtoupper( preg_replace( '/[\s\-]+/u', '', $road ) ?? '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO or RSS timestamp as a local date-time string.
|
||||
*
|
||||
* @param string $time Raw timestamp.
|
||||
* @return string Empty when unparseable.
|
||||
*/
|
||||
public static function format_time( string $time ): string {
|
||||
if ( '' === $time ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$ts = strtotime( $time );
|
||||
|
||||
if ( false === $ts ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$formatted = wp_date( 'j M Y H:i', $ts );
|
||||
|
||||
return is_string( $formatted ) ? $formatted : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Human "last updated" line from a raw timestamp.
|
||||
*
|
||||
* @param string $time Raw timestamp.
|
||||
* @return string Empty when unparseable.
|
||||
*/
|
||||
public static function last_updated( string $time ): string {
|
||||
if ( '' === $time ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$ts = strtotime( $time );
|
||||
|
||||
return false === $ts ? '' : sprintf(
|
||||
/* translators: %s: human-readable time difference. */
|
||||
__( 'Last updated: %s ago', 'fontdelbosc-transit' ),
|
||||
human_time_diff( $ts, time() )
|
||||
);
|
||||
}
|
||||
}
|
||||
70
includes/class-fontdelbosc-transit-payload.php
Normal file
70
includes/class-fontdelbosc-transit-payload.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
/**
|
||||
* Typed accessors for mixed data (arrays from cache, options, XML).
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Small typed accessor helpers.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Payload {
|
||||
|
||||
/**
|
||||
* Returns an array value, or the default.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @param string $key Key to look up.
|
||||
* @param mixed $default Default when the key is missing.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get( mixed $value, string $key, mixed $default = null ): mixed {
|
||||
if ( is_array( $value ) && array_key_exists( $key, $value ) ) {
|
||||
return $value[ $key ];
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string value for a possibly missing or mixed key.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
*/
|
||||
public static function str( mixed $value ): ?string {
|
||||
return is_string( $value ) ? trim( $value ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an int value for a possibly missing or mixed key.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
*/
|
||||
public static function int( mixed $value ): ?int {
|
||||
if ( is_int( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ( is_float( $value ) ) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if ( is_string( $value ) && preg_match( '/^-?\d+$/', trim( $value ) ) ) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array value.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public static function arr( mixed $value ): array {
|
||||
return is_array( $value ) ? $value : array();
|
||||
}
|
||||
}
|
||||
105
includes/class-fontdelbosc-transit-plugin.php
Normal file
105
includes/class-fontdelbosc-transit-plugin.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin bootstrap: hooks, assets, options.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Bootstraps the plugin.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Plugin {
|
||||
|
||||
private const OPTION_NAME = 'fontdelbosc_transit_options';
|
||||
|
||||
/**
|
||||
* Default options.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private static array $defaults = array(
|
||||
'incident_cache_ttl' => 300,
|
||||
'zbe_cache_ttl' => 86400,
|
||||
'default_view' => 'c15',
|
||||
'max_incidents' => 20,
|
||||
'zbe_municipalities' => null,
|
||||
'show_zbe_2026_notice' => true,
|
||||
'delete_data_on_uninstall' => false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook everything.
|
||||
*/
|
||||
public static function init(): void {
|
||||
add_action( 'init', array( __CLASS__, 'load_textdomain' ) );
|
||||
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'register_assets' ) );
|
||||
|
||||
Fontdelbosc_Transit_Shortcode::init();
|
||||
add_action( 'widgets_init', array( __CLASS__, 'register_widget' ) );
|
||||
|
||||
if ( is_admin() ) {
|
||||
require_once FONTDELBOSC_TRANSIT_DIR . 'admin/class-fontdelbosc-transit-admin.php';
|
||||
Fontdelbosc_Transit_Admin::init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations.
|
||||
*/
|
||||
public static function load_textdomain(): void {
|
||||
load_plugin_textdomain( 'fontdelbosc-transit', false, dirname( plugin_basename( FONTDELBOSC_TRANSIT_DIR . 'fontdelbosc-transit.php' ) ) . '/languages' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the front-end stylesheet.
|
||||
*/
|
||||
public static function register_assets(): void {
|
||||
$css = FONTDELBOSC_TRANSIT_DIR . 'public/css/fdbt-transit.css';
|
||||
|
||||
wp_register_style(
|
||||
'fdbt-transit',
|
||||
plugins_url( 'public/css/fdbt-transit.css', FONTDELBOSC_TRANSIT_DIR . 'fontdelbosc-transit.php' ),
|
||||
array(),
|
||||
(string) filemtime( $css )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the widget.
|
||||
*/
|
||||
public static function register_widget(): void {
|
||||
register_widget( 'Fontdelbosc_Transit_Widget' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options merged with defaults.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get_options(): array {
|
||||
$saved = get_option( self::OPTION_NAME, array() );
|
||||
$saved = is_array( $saved ) ? $saved : array();
|
||||
$options = array();
|
||||
|
||||
foreach ( self::$defaults as $key => $default ) {
|
||||
$options[ $key ] = array_key_exists( $key, $saved ) ? $saved[ $key ] : $default;
|
||||
}
|
||||
|
||||
if ( null === $options['zbe_municipalities'] ) {
|
||||
$options['zbe_municipalities'] = Fontdelbosc_Transit_Api_Zbe::DEFAULT_CATALAN_ZONES;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the options entry.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function option_name(): string {
|
||||
return self::OPTION_NAME;
|
||||
}
|
||||
}
|
||||
148
includes/class-fontdelbosc-transit-shortcode.php
Normal file
148
includes/class-fontdelbosc-transit-shortcode.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
/**
|
||||
* Shortcode [fontdelbosc_transit] handler.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Renders the transit views.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Shortcode {
|
||||
|
||||
/**
|
||||
* Register the shortcode.
|
||||
*/
|
||||
public static function init(): void {
|
||||
add_shortcode( 'fontdelbosc_transit', array( __CLASS__, 'render' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shortcode.
|
||||
*
|
||||
* @param array<string> $attrs Shortcode attributes.
|
||||
* @return string
|
||||
*/
|
||||
public static function render( $attrs = array() ): string {
|
||||
$options = Fontdelbosc_Transit_Plugin::get_options();
|
||||
$attrs = shortcode_atts(
|
||||
array(
|
||||
'view' => Fontdelbosc_Transit_Payload::str( $options['default_view'] ?? null ) ?? 'c15',
|
||||
'max' => (string) ( Fontdelbosc_Transit_Payload::int( $options['max_incidents'] ?? null ) ?? 20 ),
|
||||
'comarca' => '',
|
||||
),
|
||||
(array) $attrs,
|
||||
'fontdelbosc_transit'
|
||||
);
|
||||
|
||||
$view = sanitize_key( Fontdelbosc_Transit_Payload::str( $attrs['view'] ) ?? '' );
|
||||
$max = min( 50, max( 1, Fontdelbosc_Transit_Payload::int( $attrs['max'] ) ?? 20 ) );
|
||||
$comarcas = Fontdelbosc_Transit_Comarques::keys_from_attr( Fontdelbosc_Transit_Payload::str( $attrs['comarca'] ) ?? '' );
|
||||
|
||||
$comarca_labels = array_map(
|
||||
static fn( string $key ): string => Fontdelbosc_Transit_Comarques::label( $key ),
|
||||
$comarcas
|
||||
);
|
||||
$comarca_label = implode( ', ', $comarca_labels );
|
||||
|
||||
if ( 'zbe' === $view ) {
|
||||
return self::render_view( 'zbe-list', array( 'zones' => Fontdelbosc_Transit_Data_Manager::get_zbes() ) );
|
||||
}
|
||||
|
||||
$data = Fontdelbosc_Transit_Data_Manager::get_incidents();
|
||||
|
||||
if ( null === $data ) {
|
||||
return self::render_view(
|
||||
'c15' === $view ? 'incidents-c15' : 'incidents-all',
|
||||
array(
|
||||
'incidents' => array(),
|
||||
'meta' => array(),
|
||||
'unavailable' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$incidents = $data['incidents'];
|
||||
|
||||
if ( array() !== $comarcas ) {
|
||||
$incidents = array_values(
|
||||
array_filter(
|
||||
$incidents,
|
||||
static fn( array $incident ): bool => in_array( Fontdelbosc_Transit_Comarques::comarca_of( $incident ), $comarcas, true )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( 'c15' === $view ) {
|
||||
$incidents = array_values(
|
||||
array_filter(
|
||||
$incidents,
|
||||
static fn( array $incident ): bool => Fontdelbosc_Transit_Maps::is_c15( $incident )
|
||||
)
|
||||
);
|
||||
} else {
|
||||
usort(
|
||||
$incidents,
|
||||
static function ( array $a, array $b ): int {
|
||||
$a_c15 = Fontdelbosc_Transit_Maps::is_c15( $a ) ? 0 : 1;
|
||||
$b_c15 = Fontdelbosc_Transit_Maps::is_c15( $b ) ? 0 : 1;
|
||||
|
||||
if ( $a_c15 !== $b_c15 ) {
|
||||
return $a_c15 <=> $b_c15;
|
||||
}
|
||||
|
||||
return strcmp( Fontdelbosc_Transit_Payload::str( $b['start_time'] ?? null ) ?? '', Fontdelbosc_Transit_Payload::str( $a['start_time'] ?? null ) ?? '' );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return self::render_view(
|
||||
'c15' === $view ? 'incidents-c15' : 'incidents-all',
|
||||
array(
|
||||
'incidents' => array_slice( $incidents, 0, $max ),
|
||||
'meta' => array(
|
||||
'source' => $data['source'],
|
||||
'publication_time' => $data['publication_time'],
|
||||
),
|
||||
'comarca_label' => $comarca_label,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template by name.
|
||||
*
|
||||
* @param string $name Template name (file name without .php).
|
||||
* @param array<string, mixed> $fontdelbosc_data Template data.
|
||||
* @return string
|
||||
*/
|
||||
public static function render_view( string $name, array $fontdelbosc_data ): string {
|
||||
$file = FONTDELBOSC_TRANSIT_DIR . 'templates/' . $name . '.php';
|
||||
|
||||
if ( ! is_file( $file ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
include $file;
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the inline SVG markup for an icon key.
|
||||
*
|
||||
* @param string $key Icon key (see templates/icon.php).
|
||||
* @return string Trusted static SVG markup.
|
||||
*/
|
||||
public static function icon( string $key ): string {
|
||||
$fontdelbosc_icon = $key;
|
||||
|
||||
ob_start();
|
||||
include FONTDELBOSC_TRANSIT_DIR . 'templates/icon.php';
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
205
includes/class-fontdelbosc-transit-widget.php
Normal file
205
includes/class-fontdelbosc-transit-widget.php
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
/**
|
||||
* Sidebar widget: compact incidents or ZBE summary.
|
||||
*
|
||||
* @package Fontdelbosc_Transit
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Transit widget.
|
||||
*/
|
||||
class Fontdelbosc_Transit_Widget extends WP_Widget {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'fontdelbosc_transit',
|
||||
__( 'Font del Bosc — Trànsit', 'fontdelbosc-transit' ),
|
||||
array(
|
||||
'classname' => 'fdbt-widget',
|
||||
'description' => __( 'C-15 incidents or a Low Emission Zones summary.', 'fontdelbosc-transit' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the widget.
|
||||
*
|
||||
* @param array<string, mixed> $args Sidebar arguments.
|
||||
* @param array<string, mixed> $instance Widget settings.
|
||||
*/
|
||||
public function widget( $args, $instance ): void {
|
||||
$title = Fontdelbosc_Transit_Payload::str( apply_filters( 'widget_title', Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $instance, 'title' ) ) ?? '' ) ) ?? '';
|
||||
$view = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $instance, 'view' ) ) ?? 'c15';
|
||||
|
||||
$before_widget = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $args, 'before_widget' ) ) ?? '';
|
||||
$after_widget = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $args, 'after_widget' ) ) ?? '';
|
||||
$before_title = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $args, 'before_title' ) ) ?? '';
|
||||
$after_title = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $args, 'after_title' ) ) ?? '';
|
||||
|
||||
echo wp_kses_post( $before_widget );
|
||||
|
||||
if ( '' !== $title ) {
|
||||
echo wp_kses_post( $before_title . esc_html( $title ) . $after_title );
|
||||
}
|
||||
|
||||
if ( 'zbe' === $view ) {
|
||||
$this->render_zbe_summary();
|
||||
} else {
|
||||
$this->render_incidents();
|
||||
}
|
||||
|
||||
echo wp_kses_post( $after_widget );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the compact C-15 incident list.
|
||||
*/
|
||||
private function render_incidents(): void {
|
||||
$data = Fontdelbosc_Transit_Data_Manager::get_incidents();
|
||||
|
||||
if ( null === $data ) {
|
||||
echo '<p class="fdbt-widget__empty">' . esc_html__( 'Data unavailable.', 'fontdelbosc-transit' ) . '</p>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$incidents = array_filter(
|
||||
$data['incidents'],
|
||||
static fn( array $incident ): bool => Fontdelbosc_Transit_Maps::is_c15( $incident )
|
||||
);
|
||||
$incidents = array_slice( array_values( $incidents ), 0, 5 );
|
||||
|
||||
if ( array() === $incidents ) {
|
||||
echo '<p class="fdbt-widget__empty">' . esc_html__( 'No incidents on C-15.', 'fontdelbosc-transit' ) . '</p>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<ul class="fdbt-widget__list">';
|
||||
foreach ( $incidents as $incident ) {
|
||||
$type_key = Fontdelbosc_Transit_Maps::type_key( Fontdelbosc_Transit_Payload::str( $incident['type'] ?? null ) ?? '' );
|
||||
|
||||
echo '<li class="fdbt-widget__item">';
|
||||
echo '<span class="fdbt-badge fdbt-badge--' . esc_attr( $type_key ) . '">' . esc_html( Fontdelbosc_Transit_Maps::type_label( Fontdelbosc_Transit_Payload::str( $incident['type'] ?? null ) ?? '' ) ) . '</span>';
|
||||
echo '<span class="fdbt-widget__text">' . esc_html( $this->incident_summary( $incident ) ) . '</span>';
|
||||
echo '</li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-line summary of an incident for the widget.
|
||||
*
|
||||
* @param array<mixed> $incident Normalized incident.
|
||||
* @return string
|
||||
*/
|
||||
private function incident_summary( array $incident ): string {
|
||||
$parts = array();
|
||||
$km = Fontdelbosc_Transit_Payload::str( $incident['km'] ?? null );
|
||||
|
||||
if ( null !== $km && '' !== $km ) {
|
||||
$parts[] = sprintf(
|
||||
/* translators: %s: kilometre point. */
|
||||
__( 'km %s', 'fontdelbosc-transit' ),
|
||||
$km
|
||||
);
|
||||
}
|
||||
|
||||
$town = Fontdelbosc_Transit_Payload::str( $incident['town'] ?? null );
|
||||
|
||||
if ( null !== $town && '' !== $town ) {
|
||||
$parts[] = $town;
|
||||
}
|
||||
|
||||
$title = Fontdelbosc_Transit_Payload::str( $incident['title'] ?? null );
|
||||
|
||||
if ( null !== $title && '' !== $title ) {
|
||||
$parts[] = $title;
|
||||
}
|
||||
|
||||
if ( array() === $parts ) {
|
||||
return Fontdelbosc_Transit_Payload::str( $incident['road_name'] ?? null ) ?? '';
|
||||
}
|
||||
|
||||
return implode( ' — ', $parts );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the compact ZBE summary.
|
||||
*/
|
||||
private function render_zbe_summary(): void {
|
||||
$zones = Fontdelbosc_Transit_Data_Manager::get_zbes();
|
||||
|
||||
if ( array() === $zones ) {
|
||||
echo '<p class="fdbt-widget__empty">' . esc_html__( 'Data unavailable.', 'fontdelbosc-transit' ) . '</p>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<ul class="fdbt-widget__list">';
|
||||
foreach ( $zones as $zone ) {
|
||||
echo '<li class="fdbt-widget__item"><strong>' . esc_html( Fontdelbosc_Transit_Payload::str( $zone['name'] ?? null ) ?? '' ) . '</strong>';
|
||||
|
||||
$periods = Fontdelbosc_Transit_Payload::arr( $zone['periods'] ?? null );
|
||||
$first = $periods[0] ?? null;
|
||||
|
||||
if ( is_array( $first ) ) {
|
||||
$start = Fontdelbosc_Transit_Payload::str( $first['start'] ?? null ) ?? '';
|
||||
$end = Fontdelbosc_Transit_Payload::str( $first['end'] ?? null ) ?? '';
|
||||
$label = $start . '–' . $end;
|
||||
|
||||
if ( '' !== $start && '' !== $end ) {
|
||||
echo '<span class="fdbt-widget__text"> ' . esc_html( $label ) . '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
echo '</li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget settings form.
|
||||
*
|
||||
* @param array<string, mixed> $instance Widget settings.
|
||||
*/
|
||||
public function form( $instance ): ?string {
|
||||
$title = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $instance, 'title' ) ) ?? __( 'Traffic on C-15', 'fontdelbosc-transit' );
|
||||
$view = Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $instance, 'view' ) ) ?? 'c15';
|
||||
|
||||
echo '<p>';
|
||||
echo '<label for="' . esc_attr( $this->get_field_id( 'title' ) ) . '">' . esc_html__( 'Title:', 'fontdelbosc-transit' ) . ' </label>';
|
||||
echo '<input class="widefat" id="' . esc_attr( $this->get_field_id( 'title' ) ) . '" name="' . esc_attr( $this->get_field_name( 'title' ) ) . '" type="text" value="' . esc_attr( $title ) . '" />';
|
||||
echo '</p>';
|
||||
|
||||
echo '<p>';
|
||||
echo '<label for="' . esc_attr( $this->get_field_id( 'view' ) ) . '">' . esc_html__( 'View:', 'fontdelbosc-transit' ) . ' </label>';
|
||||
echo '<select class="widefat" id="' . esc_attr( $this->get_field_id( 'view' ) ) . '" name="' . esc_attr( $this->get_field_name( 'view' ) ) . '">';
|
||||
echo '<option value="c15"' . selected( $view, 'c15', false ) . '>' . esc_html__( 'Traffic on C-15', 'fontdelbosc-transit' ) . '</option>';
|
||||
echo '<option value="zbe"' . selected( $view, 'zbe', false ) . '>' . esc_html__( 'Low Emission Zones (ZBE)', 'fontdelbosc-transit' ) . '</option>';
|
||||
echo '</select>';
|
||||
echo '</p>';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save widget settings.
|
||||
*
|
||||
* @param array<string, mixed> $new_instance New settings.
|
||||
* @param array<string, mixed> $old_instance Old settings.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function update( $new_instance, $old_instance ): array {
|
||||
$instance = $old_instance;
|
||||
$instance['title'] = sanitize_text_field( Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $new_instance, 'title' ) ) ?? '' );
|
||||
$instance['view'] = 'zbe' === Fontdelbosc_Transit_Payload::str( Fontdelbosc_Transit_Payload::get( $new_instance, 'view' ) ) ? 'zbe' : 'c15';
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue