This commit is contained in:
Javier Casares 2026-10-12 15:30:04 +00:00
commit 1ef08bd731
27 changed files with 4854 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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 ] ?? '';
}
}

View 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 );
}
}

View 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() )
);
}
}

View 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();
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}