This commit is contained in:
Javier Casares 2026-10-12 13:25:09 +00:00
commit e1f5a47c96
22 changed files with 3155 additions and 0 deletions

View file

@ -0,0 +1,211 @@
<?php
/**
* MITECO / MINETUR ServiciosRestCarburantes API client.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Fetches and normalizes province station data and the product catalog.
*/
final class Fontdelbosc_Combustible_Api {
/**
* Hardcoded province ID: Barcelona (08).
*/
public const PROVINCE_ID = '08';
/**
* Primary API base URL.
*/
private const BASE_PRIMARY = 'https://energia.serviciosmin.gob.es/ServiciosRestCarburantes/PreciosCarburantes/';
/**
* Fallback API base URL.
*/
private const BASE_FALLBACK = 'https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/';
/**
* Per-request timeout in seconds. Worst case (primary + fallback) stays bounded.
*/
private const TIMEOUT = 8;
/**
* Fetches all stations of the province, normalized with distance.
*
* @return array{fecha:string,stations:array<int,array<string,mixed>>,fetched_at:int,duration:float}|null Null on total failure.
*/
public static function fetch_province(): ?array {
$started = microtime( true );
$body = self::get( 'EstacionesTerrestres/FiltroProvincia/' . self::PROVINCE_ID );
if ( null === $body ) {
return null;
}
$decoded = json_decode( $body, true );
if ( ! is_array( $decoded ) ) {
return null;
}
$fecha = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $decoded, 'Fecha' ) ) ?? '';
$raw_list = Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $decoded, 'ListaEESSPrecio' ) );
$stations = array();
foreach ( $raw_list as $raw ) {
$station = self::normalize_station( $raw );
if ( null !== $station ) {
$stations[] = $station;
}
}
return array(
'fecha' => $fecha,
'stations' => $stations,
'fetched_at' => time(),
'duration' => microtime( true ) - $started,
);
}
/**
* Fetches the petroleum product catalog.
*
* @return array<int,array{id:int,name:string,abbr:string}>|null Null on total failure.
*/
public static function fetch_products(): ?array {
$body = self::get( 'Listados/ProductosPetroliferos/' );
if ( null === $body ) {
return null;
}
$decoded = json_decode( $body, true );
if ( ! is_array( $decoded ) ) {
return null;
}
$products = array();
foreach ( $decoded as $entry ) {
$id = Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $entry, 'IDProducto' ) );
if ( null === $id ) {
continue;
}
$products[] = array(
'id' => $id,
'name' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'NombreProducto' ) ) ?? (string) $id,
'abbr' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'NombreProductoAbreviatura' ) ) ?? '',
);
}
return $products;
}
/**
* GETs a path against the primary base URL, falling back to the legacy host.
*
* @param string $path API path relative to the base URL.
* @return string|null Response body, or null when both hosts fail.
*/
private static function get( string $path ): ?string {
foreach ( array( self::BASE_PRIMARY, self::BASE_FALLBACK ) as $base ) {
$response = wp_remote_get(
$base . $path,
array(
'timeout' => self::TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) ) {
continue;
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
continue;
}
$body = wp_remote_retrieve_body( $response );
if ( '' !== $body ) {
return $body;
}
}
return null;
}
/**
* Normalizes one raw station entry; null when coordinates are unusable.
*
* @param mixed $raw Raw station.
* @return array<string,mixed>|null
*/
private static function normalize_station( mixed $raw ): ?array {
if ( ! is_array( $raw ) ) {
return null;
}
$lat = self::coord( Fontdelbosc_Combustible_Payload::get( $raw, 'Latitud' ) );
$lon = self::coord( Fontdelbosc_Combustible_Payload::get( $raw, 'Longitud (WGS84)' ) );
if ( null === $lat || null === $lon ) {
return null;
}
$prices = array();
foreach ( $raw as $key => $value ) {
if ( ! is_string( $key ) || ! str_starts_with( $key, 'Precio ' ) ) {
continue;
}
$text = Fontdelbosc_Combustible_Payload::str( $value );
$price = null === $text ? null : Fontdelbosc_Combustible_Payload::float( str_replace( ',', '.', $text ) );
if ( null !== $price && $price > 0.0 ) {
$prices[ substr( $key, 7 ) ] = $price;
}
}
return array(
'id' => Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $raw, 'IDEESS' ) ) ?? 0,
'brand' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Rótulo' ) ) ?? '',
'address' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Dirección' ) ) ?? '',
'locality' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Localidad' ) ) ?? '',
'municipality' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Municipio' ) ) ?? '',
'cp' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'C.P.' ) ) ?? '',
'schedule' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Horario' ) ) ?? '',
'tipo_venta' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'Tipo Venta' ) ) ?? '',
'lat' => $lat,
'lon' => $lon,
'distance' => Fontdelbosc_Combustible_Geo::haversine( $lat, $lon ),
'prices' => $prices,
);
}
/**
* Parses a comma-decimal coordinate string.
*
* @param mixed $value Raw coordinate value.
*/
private static function coord( mixed $value ): ?float {
$text = Fontdelbosc_Combustible_Payload::str( $value );
$float = null === $text ? null : Fontdelbosc_Combustible_Payload::float( str_replace( ',', '.', $text ) );
if ( null === $float || abs( $float ) > 180.0 ) {
return null;
}
return $float;
}
}

View file

@ -0,0 +1,407 @@
<?php
/**
* Orchestration: caching, filtering and sorting of station data.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Caches API payloads and builds the final station selection.
*/
final class Fontdelbosc_Combustible_Data_Manager {
/**
* Transient holding the live province payload.
*/
public const PROVINCE_TRANSIENT = 'fontdelbosc_combustible_province_08';
/**
* Transient holding the stale-fallback copy of the province payload.
*/
public const STALE_TRANSIENT = 'fontdelbosc_combustible_province_08_stale';
/**
* Transient holding the product catalog.
*/
public const PRODUCTS_TRANSIENT = 'fontdelbosc_combustible_products';
/**
* Option holding fetch status for the admin screen.
*/
public const STATUS_OPTION = 'fontdelbosc_combustible_status';
/**
* Option holding plugin settings.
*/
public const OPTIONS_OPTION = 'fontdelbosc_combustible_options';
/**
* Stale copy lifetime: 25 hours.
*/
private const STALE_TTL = 90000;
/**
* Product catalog lifetime: 7 days.
*/
private const PRODUCTS_TTL = 604800;
/**
* Default settings.
*
* @return array<string,mixed>
*/
public static function defaults(): array {
return array(
'search_radius_km' => 15,
'max_stations' => 10,
'fuel_types' => Fontdelbosc_Combustible_Fuels::DEFAULT_IDS,
'show_only_public' => true,
'cache_ttl' => 3600,
'show_distance' => true,
'show_schedule' => true,
'sort_by' => 'distance',
'delete_data_on_uninstall' => false,
);
}
/**
* Returns the merged settings.
*
* @return array<string,mixed>
*/
public static function options(): array {
$clean = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( get_option( self::OPTIONS_OPTION, array() ) ) as $key => $value ) {
if ( is_string( $key ) ) {
$clean[ $key ] = $value;
}
}
return array_merge( self::defaults(), $clean );
}
/**
* Returns the cached province payload, refreshing it when stale.
*
* @param bool $force_refresh Skip the live cache.
* @return array{fecha:string,stations:array<int,array<mixed>>,fetched_at:int,duration:float,stale?:bool}|null
*/
public static function get_payload( bool $force_refresh = false ): ?array {
if ( ! $force_refresh ) {
$cached = get_transient( self::PROVINCE_TRANSIENT );
if ( is_array( $cached ) ) {
return self::shape( $cached );
}
}
$payload = Fontdelbosc_Combustible_Api::fetch_province();
if ( null === $payload ) {
$stale = get_transient( self::STALE_TRANSIENT );
if ( is_array( $stale ) ) {
$shaped = self::shape( $stale );
$shaped['stale'] = true;
return $shaped;
}
return null;
}
set_transient( self::PROVINCE_TRANSIENT, $payload, max( 60, Fontdelbosc_Combustible_Payload::int( self::options()['cache_ttl'] ?? null ) ?? 3600 ) );
set_transient( self::STALE_TRANSIENT, $payload, self::STALE_TTL );
self::record_status( $payload );
return $payload;
}
/**
* Returns the cached product catalog, possibly empty.
*
* @return array<int,array{id:int,name:string,abbr:string}>
*/
public static function get_products(): array {
$cached = get_transient( self::PRODUCTS_TRANSIENT );
if ( is_array( $cached ) ) {
return self::shape_products( $cached );
}
$products = Fontdelbosc_Combustible_Api::fetch_products();
if ( null === $products ) {
return array();
}
set_transient( self::PRODUCTS_TRANSIENT, $products, self::PRODUCTS_TTL );
return $products;
}
/**
* Builds the final station selection.
*
* @param array<string,mixed> $args Overrides: radius, max, fuel, sort, only_public.
* @return array{stations:array<int,array<mixed>>,fecha:string,stale:bool,radius:int,fuels:array<int>,fields:array<int,string>}
*/
public static function get_stations( array $args = array() ): array {
$options = self::options();
$opt_radius = Fontdelbosc_Combustible_Payload::int( $options['search_radius_km'] ?? null ) ?? 15;
$opt_max = Fontdelbosc_Combustible_Payload::int( $options['max_stations'] ?? null ) ?? 10;
$opt_sort = Fontdelbosc_Combustible_Payload::str( $options['sort_by'] ?? null ) ?? 'distance';
$opt_public = ! empty( $options['show_only_public'] );
$only_public = $opt_public && ( $args['only_public'] ?? true );
$radius = max( 5, min( 50, Fontdelbosc_Combustible_Payload::int( $args['radius'] ?? null ) ?? $opt_radius ) );
$max = max( 1, min( 20, Fontdelbosc_Combustible_Payload::int( $args['max'] ?? null ) ?? $opt_max ) );
$fuels = self::fuel_ids( Fontdelbosc_Combustible_Payload::str( $args['fuel'] ?? null ) ?? '', $options );
$sort_att = Fontdelbosc_Combustible_Payload::str( $args['sort'] ?? null );
$sort = ( $sort_att ?? $opt_sort ) === 'price_asc' ? 'price_asc' : 'distance';
$stations = array();
$fields = array();
$stale = false;
$fecha = '';
$payload = self::get_payload();
if ( null !== $payload ) {
$stale = ! empty( $payload['stale'] );
$fecha = $payload['fecha'];
$selected = self::select( $payload['stations'], $radius, $fuels, $sort, $only_public );
$stations = $selected['stations'];
$fields = $selected['fields'];
}
return array(
'stations' => array_slice( $stations, 0, $max ),
'fecha' => $fecha,
'stale' => $stale,
'radius' => $radius,
'fuels' => $fuels,
'fields' => $fields,
);
}
/**
* Deletes every cache entry.
*/
public static function flush_cache(): void {
delete_transient( self::PROVINCE_TRANSIENT );
delete_transient( self::STALE_TRANSIENT );
delete_transient( self::PRODUCTS_TRANSIENT );
}
/**
* Filters, sorts and dedupes the normalized station list.
*
* @param array<int,array<mixed>> $stations Stations.
* @param int $radius Radius in km.
* @param array<int> $fuels Product IDs.
* @param string $sort distance|price_asc.
* @param bool $only_public Keep Tipo Venta "P" only.
* @return array{stations:array<int,array<mixed>>,fields:array<int,string>}
*/
private static function select( array $stations, int $radius, array $fuels, string $sort, bool $only_public ): array {
$labels = array();
foreach ( $stations as $station ) {
foreach ( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $station, 'prices' ) ) as $label => $_unused ) {
if ( is_string( $label ) ) {
$labels[ $label ] = true;
}
}
}
$fields = self::fuel_fields( $fuels, array_keys( $labels ) );
$keep = array();
foreach ( $stations as $station ) {
$tipo = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $station, 'tipo_venta' ) ) ?? '';
if ( $only_public && 'P' !== $tipo ) {
continue;
}
$distance = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $station, 'distance' ) );
if ( null === $distance || $distance > (float) $radius ) {
continue;
}
$prices = array();
foreach ( $fields as $field ) {
$price = Fontdelbosc_Combustible_Payload::float(
Fontdelbosc_Combustible_Payload::get( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $station, 'prices' ) ), $field )
);
if ( null !== $price ) {
$prices[ $field ] = $price;
}
}
if ( array() === $prices ) {
continue;
}
$station['selected_prices'] = $prices;
$station['best_price'] = min( $prices );
$keep[] = $station;
}
usort(
$keep,
static function ( array $a, array $b ) use ( $sort ): int {
$a_price = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $a, 'best_price' ) );
$b_price = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $b, 'best_price' ) );
$a_key = 'price_asc' === $sort ? ( $a_price ?? INF ) : Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $a, 'distance' ) ) ?? INF;
$b_key = 'price_asc' === $sort ? ( $b_price ?? INF ) : Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $b, 'distance' ) ) ?? INF;
return $a_key <=> $b_key;
}
);
return array(
'stations' => $keep,
'fields' => $fields,
);
}
/**
* Resolves the fuel selection from a shortcode attribute or the options.
*
* @param string $att Raw "1,4" style attribute.
* @param array<string,mixed> $options Settings.
* @return array<int>
*/
private static function fuel_ids( string $att, array $options ): array {
if ( '' !== $att ) {
$ids = array();
foreach ( explode( ',', $att ) as $piece ) {
$id = Fontdelbosc_Combustible_Payload::int( trim( $piece ) );
if ( null !== $id && $id > 0 ) {
$ids[] = $id;
}
}
if ( array() !== $ids ) {
return array_values( array_unique( $ids ) );
}
}
$saved = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( $options['fuel_types'] ?? null ) as $id ) {
$int = Fontdelbosc_Combustible_Payload::int( $id );
if ( null !== $int && $int > 0 ) {
$saved[] = $int;
}
}
return array() === $saved ? Fontdelbosc_Combustible_Fuels::DEFAULT_IDS : array_values( array_unique( $saved ) );
}
/**
* Maps product IDs to station price fields using the catalog.
*
* @param array<int> $ids Product IDs.
* @param array<string> $labels Price field labels present in the payload.
* @return array<int,string>
*/
private static function fuel_fields( array $ids, array $labels ): array {
$names = array();
foreach ( self::get_products() as $product ) {
$names[ $product['id'] ] = $product['name'];
}
$fields = array();
foreach ( $ids as $id ) {
$name = $names[ (int) $id ] ?? null;
$field = null === $name ? null : Fontdelbosc_Combustible_Fuels::station_field( $name, $labels );
if ( null !== $field ) {
$fields[ (int) $id ] = $field;
}
}
return $fields;
}
/**
* Coerces a cached payload back into its typed shape.
*
* @param array<mixed> $raw Cached value.
* @return array{fecha:string,stations:array<int,array<mixed>>,fetched_at:int,duration:float,stale?:bool}
*/
private static function shape( array $raw ): array {
$stations = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $raw, 'stations' ) ) as $station ) {
if ( is_array( $station ) ) {
$stations[] = $station;
}
}
return array(
'fecha' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'fecha' ) ) ?? '',
'stations' => $stations,
'fetched_at' => Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $raw, 'fetched_at' ) ) ?? time(),
'duration' => Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $raw, 'duration' ) ) ?? 0.0,
);
}
/**
* Coerces a cached product catalog back into its typed shape.
*
* @param array<mixed> $raw Cached value.
* @return array<int,array{id:int,name:string,abbr:string}>
*/
private static function shape_products( array $raw ): array {
$products = array();
foreach ( $raw as $entry ) {
$id = Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $entry, 'IDProducto' ) )
?? Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $entry, 'id' ) );
if ( null === $id ) {
continue;
}
$products[] = array(
'id' => $id,
'name' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'name' ) ) ?? (string) $id,
'abbr' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'abbr' ) ) ?? '',
);
}
return $products;
}
/**
* Persists fetch status for the admin screen.
*
* @param array<string,mixed> $payload Fresh payload.
*/
private static function record_status( array $payload ): void {
update_option(
self::STATUS_OPTION,
array(
'last_success' => Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $payload, 'fetched_at' ) ) ?? time(),
'last_duration' => Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $payload, 'duration' ) ) ?? 0.0,
'last_data_date' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $payload, 'fecha' ) ) ?? '',
),
false
);
}
}

View file

@ -0,0 +1,100 @@
<?php
/**
* Fuel catalog bridge between MITECO product IDs and station price fields.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Resolves product IDs to station price field names and translated labels.
*/
final class Fontdelbosc_Combustible_Fuels {
/**
* Default products: Gasolina 95 E5 (1), Gasolina 98 E5 (3),
* Gasóleo A habitual (4), Gasóleo Premium (5). Verified against the
* live catalog; the spec's older ID table is outdated.
*/
public const DEFAULT_IDS = array( 1, 3, 4, 5 );
/**
* Catalog product names whose station payload key differs from
* the "Precio " + name join (accents or wording).
*
* @var array<string,string>
*/
private const NAME_ALIASES = array(
'Gasóleo A habitual' => 'Gasoleo A',
'Gasóleo B' => 'Gasoleo B',
'Gasóleo C' => 'Gasoleo C',
'Gasóleo Premium' => 'Gasoleo Premium',
'Biodiésel' => 'Biodiesel',
'Hidrógeno' => 'Hidrogeno',
);
/**
* Resolves a catalog product name to the matching station price field.
*
* @param string $product_name Catalog NombreProducto.
* @param array<string> $labels Price field labels present in the payload.
*/
public static function station_field( string $product_name, array $labels ): ?string {
$alias = self::NAME_ALIASES[ $product_name ] ?? $product_name;
$want = self::norm( $alias );
foreach ( $labels as $label ) {
if ( self::norm( $label ) === $want ) {
return $label;
}
}
return null;
}
/**
* Returns a translated display label for a product name,
* falling back to the raw API name for unknown products.
*
* @param string $product_name Catalog NombreProducto or station field label.
*/
public static function label( string $product_name ): string {
$name = self::NAME_ALIASES[ $product_name ] ?? $product_name;
return match ( self::norm( $name ) ) {
'gasolina 95 e5' => __( 'Gasoline 95 E5', 'fontdelbosc-combustible' ),
'gasolina 95 e5 premium' => __( 'Gasoline 95 E5 Premium', 'fontdelbosc-combustible' ),
'gasolina 98 e5' => __( 'Gasoline 98 E5', 'fontdelbosc-combustible' ),
'gasoleo a' => __( 'Diesel A', 'fontdelbosc-combustible' ),
'gasoleo premium' => __( 'Premium Diesel', 'fontdelbosc-combustible' ),
'gasoleo b' => __( 'Diesel B', 'fontdelbosc-combustible' ),
'gases licuados del petroleo' => __( 'LPG', 'fontdelbosc-combustible' ),
'gas natural comprimido' => __( 'CNG', 'fontdelbosc-combustible' ),
'gas natural licuado' => __( 'LNG', 'fontdelbosc-combustible' ),
'hidrogeno' => __( 'Hydrogen', 'fontdelbosc-combustible' ),
'biodiesel' => __( 'Biodiesel', 'fontdelbosc-combustible' ),
'diesel renovable' => __( 'Renewable Diesel', 'fontdelbosc-combustible' ),
'gasolina renovable' => __( 'Renewable Gasoline', 'fontdelbosc-combustible' ),
'adblue' => __( 'AdBlue', 'fontdelbosc-combustible' ),
default => $product_name,
};
}
/**
* Normalizes a name for comparison: lowercase, accents stripped.
*
* @param string $name Raw name.
*/
private static function norm( string $name ): string {
$ascii = iconv( 'UTF-8', 'ASCII//TRANSLIT//IGNORE', $name );
if ( false === $ascii || '' === $ascii ) {
$ascii = $name;
}
return trim( strtolower( $ascii ) );
}
}

View file

@ -0,0 +1,42 @@
<?php
/**
* Haversine distance from Font del Bosc.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Distance calculations against the fixed origin point.
*/
final class Fontdelbosc_Combustible_Geo {
/**
* Reference latitude of Font del Bosc.
*/
public const ORIGIN_LAT = 41.4819;
/**
* Reference longitude of Font del Bosc.
*/
public const ORIGIN_LON = 1.6483;
/**
* Great-circle distance in km from Font del Bosc to a point.
*
* @param float $lat2 Target latitude.
* @param float $lon2 Target longitude.
*/
public static function haversine( float $lat2, float $lon2 ): float {
$r = 6371;
$dlat = deg2rad( $lat2 - self::ORIGIN_LAT );
$dlon = deg2rad( $lon2 - self::ORIGIN_LON );
$a = ( sin( $dlat / 2 ) ** 2 )
+ ( cos( deg2rad( self::ORIGIN_LAT ) ) * cos( deg2rad( $lat2 ) ) * ( sin( $dlon / 2 ) ** 2 ) );
return 2 * $r * asin( sqrt( $a ) );
}
}

View file

@ -0,0 +1,93 @@
<?php
/**
* Typed access helpers for mixed JSON payload values.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Static helper to read mixed API values with predictable types.
*/
final class Fontdelbosc_Combustible_Payload {
/**
* Returns an array value for a possibly missing or mixed key.
*
* @param mixed $value Raw value.
* @param array<mixed> $default Fallback.
* @return array<mixed>
*/
public static function arr( mixed $value, array $default = array() ): array {
return is_array( $value ) ? $value : $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 a float value for a possibly missing or mixed key.
*
* @param mixed $value Raw value.
*/
public static function float( mixed $value ): ?float {
if ( is_float( $value ) ) {
return $value;
}
if ( is_int( $value ) ) {
return (float) $value;
}
if ( is_string( $value ) && is_numeric( trim( $value ) ) ) {
return (float) trim( $value );
}
return null;
}
/**
* Returns a key from an array with a default, null-safe.
*
* @param mixed $value Haystack.
* @param string $key Key.
* @param mixed $default Fallback.
*/
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;
}
}

View file

@ -0,0 +1,76 @@
<?php
/**
* Plugin bootstrap singleton.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Wires hooks and loads the textdomain.
*/
final class Fontdelbosc_Combustible_Plugin {
/**
* Singleton instance.
*
* @var self|null
*/
private static ?self $instance = null;
/**
* Returns the singleton.
*/
public static function instance(): self {
return self::$instance ??= new self();
}
/**
* Constructor: wires every hook.
*/
private function __construct() {
add_action( 'plugins_loaded', array( self::class, 'load_textdomain' ) );
add_action( 'wp_enqueue_scripts', array( self::class, 'register_assets' ) );
add_action( 'init', array( 'Fontdelbosc_Combustible_Shortcode', 'init' ) );
add_action( 'widgets_init', array( self::class, 'register_widget' ) );
if ( is_admin() ) {
Fontdelbosc_Combustible_Admin::init();
}
}
/**
* Loads the plugin textdomain.
*/
public static function load_textdomain(): void {
load_plugin_textdomain(
'fontdelbosc-combustible',
false,
dirname( plugin_basename( FONTDELBOSC_COMBUSTIBLE_FILE ) ) . '/languages/'
);
}
/**
* Registers the public stylesheet.
*/
public static function register_assets(): void {
$css = FONTDELBOSC_COMBUSTIBLE_DIR . 'public/css/fdbc-fuel.css';
wp_register_style(
'fdbc-fuel',
plugin_dir_url( FONTDELBOSC_COMBUSTIBLE_FILE ) . 'public/css/fdbc-fuel.css',
array(),
(string) filemtime( $css )
);
}
/**
* Registers the widget.
*/
public static function register_widget(): void {
register_widget( 'Fontdelbosc_Combustible_Widget' );
}
}

View file

@ -0,0 +1,73 @@
<?php
/**
* Shortcode [fontdelbosc_combustible] and shared view renderer.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Renders the station list via the templates.
*/
final class Fontdelbosc_Combustible_Shortcode {
/**
* Registers the shortcode.
*/
public static function init(): void {
add_shortcode( 'fontdelbosc_combustible', array( self::class, 'render' ) );
}
/**
* Renders the shortcode.
*
* @param array<string> $atts Shortcode attributes.
*/
public static function render( $atts = array() ): string {
$atts = shortcode_atts(
array(
'radius' => '',
'max' => '',
'fuel' => '',
'sort' => '',
),
$atts,
'fontdelbosc_combustible'
);
wp_enqueue_style( 'fdbc-fuel' );
$data = Fontdelbosc_Combustible_Data_Manager::get_stations(
array(
'radius' => $atts['radius'],
'max' => $atts['max'],
'fuel' => $atts['fuel'],
'sort' => $atts['sort'],
)
);
return self::view( 'station-list', $data );
}
/**
* Renders a template by name with the given data.
*
* @param string $name Template name.
* @param array<string,mixed> $fontdelbosc_data Template data.
*/
public static function view( string $name, array $fontdelbosc_data ): string {
$file = FONTDELBOSC_COMBUSTIBLE_DIR . 'templates/' . $name . '.php';
if ( ! is_file( $file ) ) {
return '';
}
ob_start();
include $file;
return (string) ob_get_clean();
}
}

View file

@ -0,0 +1,156 @@
<?php
/**
* Sidebar widget: nearest stations for one fuel type.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Compact list of the three nearest stations.
*/
final class Fontdelbosc_Combustible_Widget extends WP_Widget {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'fontdelbosc_combustible',
__( 'Font del Bosc — Fuel Prices', 'fontdelbosc-combustible' ),
array(
'description' => __( 'Nearest petrol stations with the price of one fuel type.', 'fontdelbosc-combustible' ),
)
);
}
/**
* Renders the widget.
*
* @param array<string,mixed> $args Sidebar args.
* @param array<string,mixed> $instance Saved settings.
*/
public function widget( $args, $instance ): void {
$fuel = max( 1, Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $instance, 'fuel' ) ) ?? Fontdelbosc_Combustible_Fuels::DEFAULT_IDS[0] );
$data = Fontdelbosc_Combustible_Data_Manager::get_stations(
array(
'fuel' => (string) $fuel,
'max' => 3,
'sort' => 'distance',
)
);
$field = $data['fields'][ $fuel ] ?? null;
wp_enqueue_style( 'fdbc-fuel' );
$before_widget = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $args, 'before_widget' ) ) ?? '';
$after_widget = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $args, 'after_widget' ) ) ?? '';
$before_title = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $args, 'before_title' ) ) ?? '';
$after_title = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $args, 'after_title' ) ) ?? '';
$title = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $instance, 'title' ) ) ?? '';
$title = '' !== $title ? Fontdelbosc_Combustible_Payload::str( apply_filters( 'widget_title', $title, $instance, $this->id_base ) ) ?? $title : '';
echo wp_kses_post( $before_widget );
if ( '' !== $title ) {
echo wp_kses_post( $before_title . esc_html( $title ) . $after_title );
}
if ( array() === $data['stations'] ) {
echo '<p class="fdbc-widget__empty">' . esc_html__( 'Data unavailable.', 'fontdelbosc-combustible' ) . '</p>';
} else {
echo '<ul class="fdbc-widget__list">';
foreach ( $data['stations'] as $station ) {
$brand = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $station, 'brand' ) ) ?? '';
$muni = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $station, 'municipality' ) ) ?? '';
$dist = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $station, 'distance' ) ) ?? 0.0;
$price = null === $field ? null : Fontdelbosc_Combustible_Payload::float(
Fontdelbosc_Combustible_Payload::get( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $station, 'prices' ) ), $field )
);
echo '<li class="fdbc-widget__item">';
echo '<span class="fdbc-widget__brand">' . esc_html( $brand ) . '</span> ';
echo '<span class="fdbc-widget__meta">' . esc_html( $muni ) . '</span>';
if ( null !== $price ) {
echo ' <span class="fdbc-widget__price">' . esc_html( number_format_i18n( $price, 3 ) ) . '</span>';
}
/* translators: %s: distance in kilometers. */
echo ' <span class="fdbc-widget__dist">' . esc_html( sprintf( __( '%s km away', 'fontdelbosc-combustible' ), number_format_i18n( $dist, 1 ) ) ) . '</span>';
echo '</li>';
}
echo '</ul>';
}
echo wp_kses_post( $after_widget );
}
/**
* Renders the settings form.
*
* @param array<string,mixed> $instance Saved settings.
*/
public function form( $instance ): ?string {
$title = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $instance, 'title' ) ) ?? __( 'Fuel prices near Font del Bosc', 'fontdelbosc-combustible' );
$fuel = Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $instance, 'fuel' ) ) ?? Fontdelbosc_Combustible_Fuels::DEFAULT_IDS[0];
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title', 'fontdelbosc-combustible' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>" />
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'fuel' ) ); ?>"><?php esc_html_e( 'Fuel type', 'fontdelbosc-combustible' ); ?></label>
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'fuel' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'fuel' ) ); ?>">
<?php foreach ( $this->fuel_choices() as $id => $label ) : ?>
<option value="<?php echo esc_attr( (string) $id ); ?>" <?php selected( $id, $fuel ); ?>><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
</p>
<?php
return null;
}
/**
* Saves the 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_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $new_instance, 'title' ) ) ?? '' );
$fuel = Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $new_instance, 'fuel' ) );
if ( null !== $fuel && $fuel > 0 ) {
$instance['fuel'] = $fuel;
}
return $instance;
}
/**
* Returns fuel choices for the form select.
*
* @return array<int,string>
*/
private function fuel_choices(): array {
$choices = array();
foreach ( Fontdelbosc_Combustible_Data_Manager::get_products() as $product ) {
$choices[ $product['id'] ] = Fontdelbosc_Combustible_Fuels::label( $product['name'] );
}
if ( array() === $choices ) {
foreach ( Fontdelbosc_Combustible_Fuels::DEFAULT_IDS as $id ) {
$choices[ $id ] = (string) $id;
}
}
return $choices;
}
}