v1.0.0
This commit is contained in:
commit
06153e387d
47 changed files with 5116 additions and 0 deletions
440
includes/class-fontdelbosc-api-aemet.php
Normal file
440
includes/class-fontdelbosc-api-aemet.php
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
<?php
|
||||
/**
|
||||
* AEMET OpenData API handler.
|
||||
*
|
||||
* Endpoints used:
|
||||
* - Daily municipal forecast: GET /prediccion/especifica/municipio/diaria/08122
|
||||
* - Hourly municipal forecast: GET /prediccion/especifica/municipio/horaria/08122
|
||||
*
|
||||
* AEMET uses a double-call pattern: the first request returns a JSON with a
|
||||
* temporary "datos" URL; the second request returns the actual data. Data
|
||||
* responses are encoded as ISO-8859-15, so a UTF-8 re-decode fallback is
|
||||
* applied.
|
||||
*
|
||||
* Response shape (both endpoints): the payload is an array whose first
|
||||
* element carries "origen" metadata and a "prediccion" object holding the
|
||||
* "dia" array. Period formats: "00-24" (daily), "08" (single hour) and
|
||||
* "0814" (hour range). Sky-state codes may carry a trailing "n" for the
|
||||
* night variant.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Handles AEMET municipal forecast requests.
|
||||
*/
|
||||
final class Fontdelbosc_Api_Aemet {
|
||||
|
||||
/**
|
||||
* API base URL.
|
||||
*/
|
||||
private const BASE_URL = 'https://opendata.aemet.es/opendata/api';
|
||||
|
||||
/**
|
||||
* INE municipal code for Mediona.
|
||||
*/
|
||||
private const MUNICIPAL_CODE = '08122';
|
||||
|
||||
/**
|
||||
* Returns the normalized daily forecast.
|
||||
*
|
||||
* @param string $api_key AEMET API key.
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_daily( string $api_key ): ?array {
|
||||
$payload = self::fetch_data( '/prediccion/especifica/municipio/diaria/' . self::MUNICIPAL_CODE, $api_key );
|
||||
$dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) );
|
||||
$days = array();
|
||||
|
||||
foreach ( $dias as $day ) {
|
||||
$date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) );
|
||||
|
||||
if ( null === $date ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = substr( $date, 0, 10 );
|
||||
$pop = self::period_value( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ) );
|
||||
$velocity = self::period_value( Fontdelbosc_Payload::get( $day, 'viento' ), 'velocidad' );
|
||||
|
||||
$days[ $date ] = array(
|
||||
'date' => $date,
|
||||
'condition' => self::sky_condition( Fontdelbosc_Payload::get( $day, 'estadoCielo' ) ),
|
||||
'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'maxima' ) ),
|
||||
'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'minima' ) ),
|
||||
'precipitation' => null,
|
||||
'pop' => Fontdelbosc_Payload::int( $pop ),
|
||||
'wind_max' => Fontdelbosc_Payload::float( $velocity ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $days ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ksort( $days );
|
||||
|
||||
return array(
|
||||
'source' => 'aemet',
|
||||
'days' => array_values( $days ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized hourly forecast, from the current hour onwards.
|
||||
*
|
||||
* @param string $api_key AEMET API key.
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_hourly( string $api_key ): ?array {
|
||||
$payload = self::fetch_data( '/prediccion/especifica/municipio/horaria/' . self::MUNICIPAL_CODE, $api_key );
|
||||
$dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) );
|
||||
|
||||
$temperatures = array();
|
||||
$pops = array();
|
||||
$cielos = array();
|
||||
$winds = array();
|
||||
|
||||
foreach ( $dias as $day ) {
|
||||
$date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) );
|
||||
|
||||
if ( null === $date ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = substr( $date, 0, 10 );
|
||||
|
||||
self::collect_entries( Fontdelbosc_Payload::get( $day, 'temperatura' ), $date, 'value', $temperatures );
|
||||
self::collect_entries( Fontdelbosc_Payload::get( $day, 'estadoCielo' ), $date, 'value', $cielos );
|
||||
self::collect_entries( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ), $date, 'value', $pops );
|
||||
self::collect_entries( Fontdelbosc_Payload::get( $day, 'vientoAndRachaMax' ), $date, 'velocidad', $winds );
|
||||
}
|
||||
|
||||
$keys = array_unique( array_merge( array_keys( $temperatures ), array_keys( $cielos ) ) );
|
||||
sort( $keys );
|
||||
|
||||
$now = ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
|
||||
$hours = array();
|
||||
|
||||
foreach ( $keys as $key ) {
|
||||
if ( $key < $now ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hours[] = array(
|
||||
'time' => self::key_timestamp( $key ),
|
||||
'hour' => (int) substr( $key, 11, 2 ),
|
||||
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $temperatures, $key ) ),
|
||||
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $pops, $key ) ),
|
||||
'condition' => self::condition( Fontdelbosc_Payload::get( $cielos, $key ) ),
|
||||
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $winds, $key ) ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $hours ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => 'aemet',
|
||||
'hours' => $hours,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects period-based values into an hour-keyed map. Handles the
|
||||
* single-hour ("08"), hour-range ("0814") and dashed ("00-24") period
|
||||
* formats; ranges are applied end-exclusive.
|
||||
*
|
||||
* @param mixed $entries Raw element entries.
|
||||
* @param string $date Day prefix (Y-m-d).
|
||||
* @param string $value_key Key holding the value.
|
||||
* @param array<string,mixed> $map Output map, modified by reference.
|
||||
* @return void
|
||||
*/
|
||||
private static function collect_entries( mixed $entries, string $date, string $value_key, array &$map ): void {
|
||||
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
||||
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
||||
$range = null === $period ? null : self::expand_period( $period );
|
||||
|
||||
if ( null === $range ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ( $hour = $range[0]; $hour < $range[1]; $hour++ ) {
|
||||
if ( $hour < 0 || $hour > 23 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[ sprintf( '%s %02d:00', $date, $hour ) ] = Fontdelbosc_Payload::get( $entry, $value_key );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands an AEMET period string to an end-exclusive hour range.
|
||||
*
|
||||
* @param string $period Period ("08", "0814" or "00-24").
|
||||
* @return array{0:int,1:int}|null
|
||||
*/
|
||||
private static function expand_period( string $period ): ?array {
|
||||
$period = str_replace( '-', '', $period );
|
||||
|
||||
if ( 2 === strlen( $period ) ) {
|
||||
return array( (int) $period, (int) $period + 1 );
|
||||
}
|
||||
|
||||
if ( 4 === strlen( $period ) ) {
|
||||
return array( (int) substr( $period, 0, 2 ), (int) substr( $period, 2, 2 ) );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the "00-24" value of a daily element, falling back to the first entry.
|
||||
*
|
||||
* @param mixed $entries Raw element entries.
|
||||
* @param string $value_key Key holding the value.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function period_value( mixed $entries, string $value_key = 'value' ): mixed {
|
||||
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
||||
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
||||
|
||||
if ( '00-24' === $period ) {
|
||||
return Fontdelbosc_Payload::get( $entry, $value_key );
|
||||
}
|
||||
}
|
||||
|
||||
return Fontdelbosc_Payload::path( $entries, 0, $value_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the best sky-state code from daily estadoCielo entries: the
|
||||
* "00-24" period when present, otherwise the first non-empty value.
|
||||
* Empty values (distant days, night-only periods) are skipped.
|
||||
*
|
||||
* @param mixed $entries estadoCielo entries.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function sky_condition( mixed $entries ): ?string {
|
||||
$fallback = null;
|
||||
|
||||
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
||||
$value = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'value' ) ) ?? '';
|
||||
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
||||
|
||||
if ( '' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null === $fallback ) {
|
||||
$fallback = $value;
|
||||
}
|
||||
|
||||
if ( '00-24' === $period ) {
|
||||
return self::condition( $value );
|
||||
}
|
||||
}
|
||||
|
||||
return null === $fallback ? null : self::condition( $fallback );
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an AEMET sky-state code to a condition key. Codes may carry a
|
||||
* trailing "n" (night variant), which is stripped first.
|
||||
*
|
||||
* @param mixed $value AEMET sky-state code.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function condition( mixed $value ): ?string {
|
||||
$code_str = is_string( $value ) || is_numeric( $value ) ? rtrim( (string) $value, 'n' ) : '';
|
||||
|
||||
if ( ! is_numeric( $code_str ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = (int) $code_str;
|
||||
|
||||
if ( 11 === $code ) {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
if ( 12 === $code ) {
|
||||
return 'mainly_clear';
|
||||
}
|
||||
|
||||
if ( 13 === $code ) {
|
||||
return 'partly_cloudy';
|
||||
}
|
||||
|
||||
if ( 14 === $code || 15 === $code ) {
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
if ( 16 === $code || 17 === $code ) {
|
||||
return 'overcast';
|
||||
}
|
||||
|
||||
if ( 18 === $code || 19 === $code ) {
|
||||
return 'fog';
|
||||
}
|
||||
|
||||
if ( $code >= 20 && $code <= 27 ) {
|
||||
return 'rain_light';
|
||||
}
|
||||
|
||||
if ( ( $code >= 28 && $code <= 37 ) || ( $code >= 61 && $code <= 66 ) ) {
|
||||
return 'thunderstorm';
|
||||
}
|
||||
|
||||
if ( $code >= 38 && $code <= 44 ) {
|
||||
return 'rain';
|
||||
}
|
||||
|
||||
if ( ( $code >= 45 && $code <= 47 ) || ( $code >= 70 && $code <= 77 ) ) {
|
||||
return 'snow';
|
||||
}
|
||||
|
||||
if ( $code >= 51 && $code <= 56 ) {
|
||||
return 'drizzle';
|
||||
}
|
||||
|
||||
if ( 81 === $code || 82 === $code ) {
|
||||
return 'fog';
|
||||
}
|
||||
|
||||
return 'cloudy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the AEMET double call and returns the decoded data payload.
|
||||
*
|
||||
* @param string $path API path.
|
||||
* @param string $api_key AEMET API key.
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private static function fetch_data( string $path, string $api_key ): ?array {
|
||||
if ( '' === $api_key ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = self::request_json( self::BASE_URL . $path . '?api_key=' . rawurlencode( $api_key ), false );
|
||||
$data_url = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $meta, 'datos' ) ) ?? '';
|
||||
|
||||
if ( '' === $data_url ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::request_json( $data_url, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a GET request and decodes the JSON response, with an
|
||||
* ISO-8859-1 to UTF-8 fallback for AEMET data payloads. Transient
|
||||
* gateway 5xx errors are retried once.
|
||||
*
|
||||
* @param string $url Request URL.
|
||||
* @param bool $latin1_fallback Whether to retry with converted encoding.
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private static function request_json( string $url, bool $latin1_fallback ): ?array {
|
||||
$response = self::http_get( $url );
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( $code >= 500 && wp_doing_cron() ) {
|
||||
sleep( 1 );
|
||||
|
||||
$response = self::http_get( $url );
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
self::log( 'AEMET request failed: ' . $response->get_error_message() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 200 !== $code ) {
|
||||
self::log( 'AEMET returned HTTP ' . $code );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = (string) wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
if ( null === $data && $latin1_fallback ) {
|
||||
$converted = self::to_utf8( $body );
|
||||
$data = null === $converted ? null : json_decode( $converted, true );
|
||||
}
|
||||
|
||||
return is_array( $data ) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the HTTP GET request.
|
||||
*
|
||||
* @param string $url Request URL.
|
||||
* @return array<string,mixed>|WP_Error
|
||||
*/
|
||||
private static function http_get( string $url ): array|WP_Error {
|
||||
return wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ISO-8859-1 payload to UTF-8 using the first available
|
||||
* extension (mbstring, then iconv).
|
||||
*
|
||||
* @param string $body Raw body.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function to_utf8( string $body ): ?string {
|
||||
if ( function_exists( 'mb_convert_encoding' ) ) {
|
||||
return mb_convert_encoding( $body, 'UTF-8', 'ISO-8859-1' );
|
||||
}
|
||||
|
||||
if ( function_exists( 'iconv' ) ) {
|
||||
$converted = iconv( 'ISO-8859-1', 'UTF-8//IGNORE', $body );
|
||||
|
||||
return false === $converted ? null : $converted;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an hour key to a Unix timestamp.
|
||||
*
|
||||
* @param string $key Hour key (Y-m-d H:00).
|
||||
* @return int
|
||||
*/
|
||||
private static function key_timestamp( string $key ): int {
|
||||
try {
|
||||
return ( new DateTimeImmutable( $key, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp();
|
||||
} catch ( Exception ) {
|
||||
return time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a message to the error log with the plugin prefix.
|
||||
*
|
||||
* @param string $message Message.
|
||||
* @return void
|
||||
*/
|
||||
private static function log( string $message ): void {
|
||||
error_log( '[fontdelbosc-eltemps] ' . $message );
|
||||
}
|
||||
}
|
||||
398
includes/class-fontdelbosc-api-meteocat.php
Normal file
398
includes/class-fontdelbosc-api-meteocat.php
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
<?php
|
||||
/**
|
||||
* Meteocat API handler (Servei Meteorològic de Catalunya).
|
||||
*
|
||||
* Endpoints used:
|
||||
* - Predicció municipal: GET /prediccio/v1/municipi/081228/prediccio
|
||||
* - XEMA station data: GET /xema/v1/estacions/{codi}/dades/{variable}
|
||||
* - XEMA station catalog: GET /xema/v1/estacions/metadades
|
||||
*
|
||||
* Requires an API key passed as the X-Api-Key header.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Handles Meteocat municipal forecast and XEMA station requests.
|
||||
*/
|
||||
final class Fontdelbosc_Api_Meteocat {
|
||||
|
||||
/**
|
||||
* API base URL.
|
||||
*/
|
||||
private const BASE_URL = 'https://api.meteo.cat';
|
||||
|
||||
/**
|
||||
* IDESCAT municipal code for Mediona.
|
||||
*/
|
||||
private const MUNICIPAL_CODE = '081228';
|
||||
|
||||
/**
|
||||
* Returns the normalized hourly forecast from the municipal prediction,
|
||||
* from the current hour onwards.
|
||||
*
|
||||
* @param string $api_key Meteocat API key.
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_hourly( string $api_key ): ?array {
|
||||
$payload = self::request( '/prediccio/v1/municipi/' . self::MUNICIPAL_CODE . '/prediccio', $api_key );
|
||||
$series = array();
|
||||
|
||||
foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccions' ) ) as $prediction ) {
|
||||
foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $prediction, 'variables' ) ) as $name => $points ) {
|
||||
foreach ( Fontdelbosc_Payload::arr( $points ) as $point ) {
|
||||
$iso = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'data' ) );
|
||||
$key = null === $iso ? null : self::hour_key( $iso );
|
||||
|
||||
if ( null !== $key ) {
|
||||
$series[ (string) $name ][ $key ] = $point;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array();
|
||||
|
||||
foreach ( $series as $points ) {
|
||||
$keys = array_merge( $keys, array_keys( $points ) );
|
||||
}
|
||||
|
||||
$keys = array_values( array_unique( $keys ) );
|
||||
sort( $keys );
|
||||
|
||||
$now = self::now_key();
|
||||
$hours = array();
|
||||
|
||||
foreach ( $keys as $key ) {
|
||||
if ( $key < $now ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hours[] = array(
|
||||
'time' => self::key_timestamp( $key ),
|
||||
'hour' => (int) substr( $key, 11, 2 ),
|
||||
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'temperatura', $key, 'valor' ) ),
|
||||
'pop' => Fontdelbosc_Payload::int( self::coalesce( Fontdelbosc_Payload::path( $series, 'probabilitatPrecipitacio', $key, 'valor' ), Fontdelbosc_Payload::path( $series, 'probabilitatDePrecipitacio', $key, 'valor' ) ) ),
|
||||
'condition' => self::condition( Fontdelbosc_Payload::path( $series, 'cel', $key ) ),
|
||||
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'vent', $key, 'valor' ) ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $hours ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => 'meteocat',
|
||||
'hours' => $hours,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns normalized current conditions from the configured XEMA station.
|
||||
*
|
||||
* @param string $api_key Meteocat API key.
|
||||
* @param string $station_code XEMA station code.
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_current( string $api_key, string $station_code ): ?array {
|
||||
if ( '' === $station_code ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$temperature = self::station_variable( $api_key, $station_code, 't' );
|
||||
$humidity = self::station_variable( $api_key, $station_code, 'hr' );
|
||||
$wind = self::station_variable( $api_key, $station_code, 'v' );
|
||||
|
||||
if ( null === $temperature && null === $humidity && null === $wind ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'temperature' => $temperature,
|
||||
'apparent' => null,
|
||||
'humidity' => $humidity,
|
||||
'precipitation' => null,
|
||||
'wind_speed' => null === $wind ? null : round( $wind * 3.6, 1 ),
|
||||
'wind_direction' => null,
|
||||
'condition' => null,
|
||||
'source' => 'meteocat',
|
||||
'time' => time(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the XEMA station closest to Font del Bosc.
|
||||
*
|
||||
* @param string $api_key Meteocat API key.
|
||||
* @return array{codi:string,nom:string,distance_km:float}|null
|
||||
*/
|
||||
public static function find_nearest_station( string $api_key ): ?array {
|
||||
$stations = self::request( '/xema/v1/estacions/metadades', $api_key );
|
||||
|
||||
if ( null === $stations ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$best = null;
|
||||
$distance = INF;
|
||||
|
||||
foreach ( Fontdelbosc_Payload::arr( $stations ) as $station ) {
|
||||
$codi = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'codi' ) );
|
||||
$lat = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'latitud' ) );
|
||||
$lon = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'longitud' ) );
|
||||
|
||||
if ( null === $codi || '' === $codi || null === $lat || null === $lon ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = self::haversine( FONTDELBOSC_ELTEMPS_LAT, FONTDELBOSC_ELTEMPS_LON, $lat, $lon );
|
||||
|
||||
if ( $current < $distance ) {
|
||||
$distance = $current;
|
||||
$best = array(
|
||||
'codi' => $codi,
|
||||
'nom' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'nom' ) ) ?? '',
|
||||
'distance_km' => round( $current, 1 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest numeric value of a XEMA station variable.
|
||||
*
|
||||
* @param string $api_key Meteocat API key.
|
||||
* @param string $station Station code.
|
||||
* @param string $variable Variable code (t, hr, v).
|
||||
* @return float|null
|
||||
*/
|
||||
private static function station_variable( string $api_key, string $station, string $variable ): ?float {
|
||||
$data = self::request( '/xema/v1/estacions/' . rawurlencode( $station ) . '/dades/' . rawurlencode( $variable ), $api_key );
|
||||
$measures = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'variables', 0, 'mesures' ) );
|
||||
|
||||
foreach ( array_reverse( $measures ) as $measure ) {
|
||||
$value = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $measure, 'valor' ) );
|
||||
|
||||
if ( null !== $value ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Meteocat "cel" data point to a condition key, preferring the
|
||||
* descriptive Catalan "estat" text over the numeric symbol.
|
||||
*
|
||||
* @param mixed $point Cel data point.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function condition( mixed $point ): ?string {
|
||||
if ( ! is_array( $point ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$estat = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'estat' ) ) ?? '';
|
||||
|
||||
if ( '' !== $estat ) {
|
||||
$estat = mb_strtolower( $estat );
|
||||
$keywords = array(
|
||||
'tempesta' => 'thunderstorm',
|
||||
'calabruix' => 'thunderstorm_hail',
|
||||
'aiguaneu' => 'snow',
|
||||
'neu' => 'snow',
|
||||
'plugim' => 'drizzle',
|
||||
'ruixat' => 'rain_showers',
|
||||
'pluja' => 'rain',
|
||||
'boira' => 'fog',
|
||||
'parcialment' => 'partly_cloudy',
|
||||
'molt ennuvolat' => 'overcast',
|
||||
'cobert' => 'overcast',
|
||||
'ennuvolat' => 'cloudy',
|
||||
'núvol' => 'cloudy',
|
||||
'sol' => 'clear',
|
||||
'clar' => 'clear',
|
||||
);
|
||||
|
||||
foreach ( $keywords as $needle => $condition ) {
|
||||
if ( str_contains( $estat, $needle ) ) {
|
||||
return $condition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $point, 'simbol' ) );
|
||||
|
||||
if ( null === $symbol ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$map = array(
|
||||
1 => 'clear',
|
||||
2 => 'partly_cloudy',
|
||||
3 => 'partly_cloudy',
|
||||
4 => 'cloudy',
|
||||
5 => 'drizzle',
|
||||
6 => 'drizzle',
|
||||
7 => 'drizzle',
|
||||
8 => 'overcast',
|
||||
9 => 'rain_light',
|
||||
10 => 'rain',
|
||||
11 => 'thunderstorm',
|
||||
12 => 'thunderstorm',
|
||||
13 => 'rain_showers',
|
||||
14 => 'rain_showers',
|
||||
15 => 'rain_showers',
|
||||
16 => 'rain',
|
||||
);
|
||||
|
||||
return $map[ $symbol ] ?? 'cloudy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first non-null value of a pair.
|
||||
*
|
||||
* @param mixed $first First value.
|
||||
* @param mixed $second Second value.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function coalesce( mixed $first, mixed $second ): mixed {
|
||||
return null !== $first ? $first : $second;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Meteocat ISO date-time to an hour key in local time.
|
||||
*
|
||||
* @param string $iso ISO date-time string.
|
||||
* @return string|null Format: Y-m-d H:00.
|
||||
*/
|
||||
private static function hour_key( string $iso ): ?string {
|
||||
try {
|
||||
return ( new DateTimeImmutable( $iso ) )->setTimezone( new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) )->format( 'Y-m-d H:00' );
|
||||
} catch ( Exception ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an hour key to a Unix timestamp.
|
||||
*
|
||||
* @param string $key Hour key (Y-m-d H:00).
|
||||
* @return int
|
||||
*/
|
||||
private static function key_timestamp( string $key ): int {
|
||||
try {
|
||||
return ( new DateTimeImmutable( $key, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp();
|
||||
} catch ( Exception ) {
|
||||
return time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current hour key used to filter hourly series.
|
||||
*
|
||||
* @return string Format: Y-m-d H:00.
|
||||
*/
|
||||
private static function now_key(): string {
|
||||
return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a coordinate value to float, handling decimal commas.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return float|null
|
||||
*/
|
||||
private static function to_float( mixed $value ): ?float {
|
||||
if ( is_int( $value ) || is_float( $value ) ) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
if ( is_string( $value ) ) {
|
||||
$normalized = str_replace( ',', '.', trim( $value ) );
|
||||
|
||||
return is_numeric( $normalized ) ? (float) $normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance in kilometers between two coordinates.
|
||||
*
|
||||
* @param float $lat1 Latitude A.
|
||||
* @param float $lon1 Longitude A.
|
||||
* @param float $lat2 Latitude B.
|
||||
* @param float $lon2 Longitude B.
|
||||
* @return float
|
||||
*/
|
||||
private static function haversine( float $lat1, float $lon1, float $lat2, float $lon2 ): float {
|
||||
$radians = M_PI / 180;
|
||||
$radius = 6371;
|
||||
|
||||
$dlat = ( $lat2 - $lat1 ) * $radians;
|
||||
$dlon = ( $lon2 - $lon1 ) * $radians;
|
||||
|
||||
$a = ( sin( $dlat / 2 ) ** 2 ) + ( cos( $lat1 * $radians ) * cos( $lat2 * $radians ) * ( sin( $dlon / 2 ) ** 2 ) );
|
||||
|
||||
return 2 * $radius * asin( sqrt( $a ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated GET request and decodes the JSON response.
|
||||
*
|
||||
* @param string $path API path.
|
||||
* @param string $api_key Meteocat API key.
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private static function request( string $path, string $api_key ): ?array {
|
||||
if ( '' === $api_key ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
self::BASE_URL . $path,
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'headers' => array(
|
||||
'X-Api-Key' => $api_key,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
self::log( 'Meteocat request failed: ' . $response->get_error_message() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $code ) {
|
||||
self::log( 'Meteocat returned HTTP ' . $code . ' for ' . $path );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
return is_array( $data ) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a message to the error log with the plugin prefix.
|
||||
*
|
||||
* @param string $message Message.
|
||||
* @return void
|
||||
*/
|
||||
private static function log( string $message ): void {
|
||||
error_log( '[fontdelbosc-eltemps] ' . $message );
|
||||
}
|
||||
}
|
||||
379
includes/class-fontdelbosc-api-openmeteo.php
Normal file
379
includes/class-fontdelbosc-api-openmeteo.php
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
<?php
|
||||
/**
|
||||
* Open-Meteo API handler. No API key required; used as universal fallback
|
||||
* and as the only source for historical (ERA5) data.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Handles Open-Meteo forecast and ERA5 archive requests.
|
||||
*/
|
||||
final class Fontdelbosc_Api_OpenMeteo {
|
||||
|
||||
/**
|
||||
* Forecast endpoint.
|
||||
*/
|
||||
private const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
|
||||
/**
|
||||
* ERA5 archive endpoint.
|
||||
*/
|
||||
private const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
|
||||
/**
|
||||
* WMO weather interpretation code to condition key.
|
||||
*
|
||||
* @var array<int,string>
|
||||
*/
|
||||
private const WMO_MAP = array(
|
||||
0 => 'clear',
|
||||
1 => 'mainly_clear',
|
||||
2 => 'partly_cloudy',
|
||||
3 => 'overcast',
|
||||
45 => 'fog',
|
||||
48 => 'fog',
|
||||
51 => 'drizzle_light',
|
||||
53 => 'drizzle',
|
||||
55 => 'drizzle_heavy',
|
||||
61 => 'rain_light',
|
||||
63 => 'rain',
|
||||
65 => 'rain_heavy',
|
||||
71 => 'snow_light',
|
||||
73 => 'snow',
|
||||
75 => 'snow_heavy',
|
||||
80 => 'rain_showers_light',
|
||||
81 => 'rain_showers',
|
||||
82 => 'rain_showers_heavy',
|
||||
95 => 'thunderstorm',
|
||||
96 => 'thunderstorm_hail',
|
||||
99 => 'thunderstorm_hail',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns normalized current conditions.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_current(): ?array {
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
|
||||
'longitude' => FONTDELBOSC_ELTEMPS_LON,
|
||||
'current' => 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m',
|
||||
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
|
||||
'forecast_days' => 1,
|
||||
),
|
||||
self::FORECAST_URL
|
||||
);
|
||||
$data = self::request( $url );
|
||||
$time = Fontdelbosc_Payload::str( Fontdelbosc_Payload::path( $data, 'current', 'time' ) );
|
||||
|
||||
if ( null === $time ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'temperature_2m' ) ),
|
||||
'apparent' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'apparent_temperature' ) ),
|
||||
'humidity' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'relative_humidity_2m' ) ),
|
||||
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'precipitation' ) ),
|
||||
'wind_speed' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'wind_speed_10m' ) ),
|
||||
'wind_direction' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'wind_direction_10m' ) ),
|
||||
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'current', 'weather_code' ) ),
|
||||
'source' => 'open-meteo',
|
||||
'time' => self::timestamp( $time ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized hourly forecast, from the current hour onwards.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_hourly(): ?array {
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
|
||||
'longitude' => FONTDELBOSC_ELTEMPS_LON,
|
||||
'hourly' => 'temperature_2m,precipitation_probability,weather_code,wind_speed_10m',
|
||||
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
|
||||
'forecast_days' => 3,
|
||||
),
|
||||
self::FORECAST_URL
|
||||
);
|
||||
$data = self::request( $url );
|
||||
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'hourly', 'time' ) );
|
||||
$now = self::now_key();
|
||||
$hours = array();
|
||||
|
||||
foreach ( $times as $index => $iso_time ) {
|
||||
$raw = Fontdelbosc_Payload::str( $iso_time );
|
||||
|
||||
if ( null === $raw ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$time = str_replace( 'T', ' ', $raw );
|
||||
|
||||
if ( $time < $now ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hours[] = array(
|
||||
'time' => self::timestamp( $time ),
|
||||
'hour' => (int) substr( $time, 11, 2 ),
|
||||
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'temperature_2m', $index ) ),
|
||||
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'hourly', 'precipitation_probability', $index ) ),
|
||||
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'hourly', 'weather_code', $index ) ),
|
||||
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'wind_speed_10m', $index ) ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $hours ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => 'open-meteo',
|
||||
'hours' => $hours,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized daily forecast.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_daily(): ?array {
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
|
||||
'longitude' => FONTDELBOSC_ELTEMPS_LON,
|
||||
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max',
|
||||
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
|
||||
'forecast_days' => 7,
|
||||
),
|
||||
self::FORECAST_URL
|
||||
);
|
||||
$data = self::request( $url );
|
||||
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) );
|
||||
$days = array();
|
||||
|
||||
foreach ( $times as $index => $date ) {
|
||||
$date = Fontdelbosc_Payload::str( $date );
|
||||
|
||||
if ( null === $date ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$days[] = array(
|
||||
'date' => $date,
|
||||
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'daily', 'weather_code', $index ) ),
|
||||
'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) ),
|
||||
'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) ),
|
||||
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ),
|
||||
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_probability_max', $index ) ),
|
||||
'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $days ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => 'open-meteo',
|
||||
'days' => $days,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized daily history for a given month (ERA5).
|
||||
*
|
||||
* @param int $year Year (1940 or later).
|
||||
* @param int $month Month (1-12).
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_historical( int $year, int $month ): ?array {
|
||||
try {
|
||||
$start = new DateTimeImmutable( sprintf( '%04d-%02d-01', $year, $month ), new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) );
|
||||
} catch ( Exception ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
|
||||
'longitude' => FONTDELBOSC_ELTEMPS_LON,
|
||||
'start_date' => $start->format( 'Y-m-d' ),
|
||||
'end_date' => $start->modify( 'last day of this month' )->format( 'Y-m-d' ),
|
||||
'daily' => 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
|
||||
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
|
||||
),
|
||||
self::ARCHIVE_URL
|
||||
);
|
||||
$data = self::request( $url );
|
||||
|
||||
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) );
|
||||
$days = array();
|
||||
|
||||
foreach ( $times as $index => $date ) {
|
||||
$date = Fontdelbosc_Payload::str( $date );
|
||||
$max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) );
|
||||
$min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) );
|
||||
|
||||
if ( null === $date || ( null === $max && null === $min ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$days[] = array(
|
||||
'date' => $date,
|
||||
'temp_max' => $max,
|
||||
'temp_min' => $min,
|
||||
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ),
|
||||
'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( array() === $days ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => 'open-meteo',
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'days' => $days,
|
||||
'stats' => self::stats( $days ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes monthly statistics for a set of normalized history days.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $days Normalized days.
|
||||
* @return array<string,float|null>
|
||||
*/
|
||||
private static function stats( array $days ): array {
|
||||
$maxes = array();
|
||||
$mins = array();
|
||||
$precip_sum = 0.0;
|
||||
$wind_max = null;
|
||||
|
||||
foreach ( $days as $day ) {
|
||||
$max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_max' ) );
|
||||
$min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_min' ) );
|
||||
$pre = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'precipitation' ) );
|
||||
$win = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'wind_max' ) );
|
||||
|
||||
if ( null !== $max ) {
|
||||
$maxes[] = $max;
|
||||
}
|
||||
|
||||
if ( null !== $min ) {
|
||||
$mins[] = $min;
|
||||
}
|
||||
|
||||
if ( null !== $pre ) {
|
||||
$precip_sum += $pre;
|
||||
}
|
||||
|
||||
if ( null !== $win ) {
|
||||
$wind_max = max( (float) $wind_max, $win );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'avg_max' => array() === $maxes ? null : round( array_sum( $maxes ) / count( $maxes ), 1 ),
|
||||
'avg_min' => array() === $mins ? null : round( array_sum( $mins ) / count( $mins ), 1 ),
|
||||
'total_precip' => round( $precip_sum, 1 ),
|
||||
'max_wind' => $wind_max,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a WMO code to a condition key.
|
||||
*
|
||||
* @param mixed $code WMO weather code.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function condition( mixed $code ): ?string {
|
||||
if ( ! is_numeric( $code ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::WMO_MAP[ (int) $code ] ?? 'cloudy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a local (Europe/Madrid) ISO string to a Unix timestamp.
|
||||
*
|
||||
* @param string $iso ISO date-time string.
|
||||
* @return int
|
||||
*/
|
||||
private static function timestamp( string $iso ): int {
|
||||
try {
|
||||
return ( new DateTimeImmutable( $iso, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp();
|
||||
} catch ( Exception ) {
|
||||
return time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current hour key used to filter hourly series.
|
||||
*
|
||||
* @return string Format: Y-m-d H:00.
|
||||
*/
|
||||
private static function now_key(): string {
|
||||
return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a GET request and decodes the JSON response.
|
||||
*
|
||||
* @param string $url Request URL.
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private static function request( string $url ): ?array {
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
self::log( 'Open-Meteo request failed: ' . $response->get_error_message() );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $code ) {
|
||||
self::log( 'Open-Meteo returned HTTP ' . $code );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
return is_array( $data ) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a message to the error log with the plugin prefix.
|
||||
*
|
||||
* @param string $message Message.
|
||||
* @return void
|
||||
*/
|
||||
private static function log( string $message ): void {
|
||||
error_log( '[fontdelbosc-eltemps] ' . $message );
|
||||
}
|
||||
}
|
||||
148
includes/class-fontdelbosc-conditions.php
Normal file
148
includes/class-fontdelbosc-conditions.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
/**
|
||||
* Shared weather condition keys: labels (translated), Meteocons icons,
|
||||
* day/night detection and localized calendar names.
|
||||
*
|
||||
* Icons are Meteocons (https://github.com/basmilius/meteocons), MIT license,
|
||||
* bundled under public/icons/flat/.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Maps condition keys to translated labels, Meteocons icons and
|
||||
* localized calendar names.
|
||||
*/
|
||||
final class Fontdelbosc_Conditions {
|
||||
|
||||
/**
|
||||
* Condition key to Meteocons file names, indexed [ day, night ].
|
||||
*
|
||||
* @var array<string,array{0:string,1:string}>
|
||||
*/
|
||||
private const ICONS = array(
|
||||
'clear' => array( 'clear-day.svg', 'clear-night.svg' ),
|
||||
'mainly_clear' => array( 'clear-day.svg', 'clear-night.svg' ),
|
||||
'partly_cloudy' => array( 'partly-cloudy-day.svg', 'partly-cloudy-night.svg' ),
|
||||
'cloudy' => array( 'cloudy.svg', 'cloudy.svg' ),
|
||||
'overcast' => array( 'overcast.svg', 'overcast.svg' ),
|
||||
'fog' => array( 'fog-day.svg', 'fog-night.svg' ),
|
||||
'drizzle_light' => array( 'drizzle.svg', 'drizzle.svg' ),
|
||||
'drizzle' => array( 'drizzle.svg', 'drizzle.svg' ),
|
||||
'drizzle_heavy' => array( 'drizzle.svg', 'drizzle.svg' ),
|
||||
'rain_light' => array( 'overcast-rain.svg', 'overcast-rain.svg' ),
|
||||
'rain' => array( 'rain.svg', 'rain.svg' ),
|
||||
'rain_heavy' => array( 'extreme-rain.svg', 'extreme-rain.svg' ),
|
||||
'rain_showers_light' => array( 'partly-cloudy-day-rain.svg', 'partly-cloudy-night-rain.svg' ),
|
||||
'rain_showers' => array( 'partly-cloudy-day-rain.svg', 'partly-cloudy-night-rain.svg' ),
|
||||
'rain_showers_heavy' => array( 'rain.svg', 'rain.svg' ),
|
||||
'snow_light' => array( 'snow.svg', 'snow.svg' ),
|
||||
'snow' => array( 'snow.svg', 'snow.svg' ),
|
||||
'snow_heavy' => array( 'extreme-snow.svg', 'extreme-snow.svg' ),
|
||||
'thunderstorm' => array( 'thunderstorms-day-rain.svg', 'thunderstorms-night-rain.svg' ),
|
||||
'thunderstorm_hail' => array( 'thunderstorms-day-hail.svg', 'thunderstorms-night-hail.svg' ),
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the translated label for a condition key.
|
||||
*
|
||||
* @param string $key Condition key.
|
||||
* @return string
|
||||
*/
|
||||
public static function label( string $key ): string {
|
||||
$labels = array(
|
||||
'clear' => __( 'Clear sky', 'fontdelbosc-eltemps' ),
|
||||
'mainly_clear' => __( 'Clear sky', 'fontdelbosc-eltemps' ),
|
||||
'partly_cloudy' => __( 'Partly cloudy', 'fontdelbosc-eltemps' ),
|
||||
'cloudy' => __( 'Cloudy', 'fontdelbosc-eltemps' ),
|
||||
'overcast' => __( 'Overcast', 'fontdelbosc-eltemps' ),
|
||||
'fog' => __( 'Fog', 'fontdelbosc-eltemps' ),
|
||||
'drizzle_light' => __( 'Drizzle', 'fontdelbosc-eltemps' ),
|
||||
'drizzle' => __( 'Drizzle', 'fontdelbosc-eltemps' ),
|
||||
'drizzle_heavy' => __( 'Drizzle', 'fontdelbosc-eltemps' ),
|
||||
'rain_light' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'rain' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'rain_heavy' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'rain_showers_light' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'rain_showers' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'rain_showers_heavy' => __( 'Rain', 'fontdelbosc-eltemps' ),
|
||||
'snow_light' => __( 'Snow', 'fontdelbosc-eltemps' ),
|
||||
'snow' => __( 'Snow', 'fontdelbosc-eltemps' ),
|
||||
'snow_heavy' => __( 'Snow', 'fontdelbosc-eltemps' ),
|
||||
'thunderstorm' => __( 'Thunderstorm', 'fontdelbosc-eltemps' ),
|
||||
'thunderstorm_hail' => __( 'Thunderstorm', 'fontdelbosc-eltemps' ),
|
||||
);
|
||||
|
||||
return $labels[ $key ] ?? $labels['cloudy'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bundled Meteocons icon URL for a condition key.
|
||||
*
|
||||
* @param string $key Condition key.
|
||||
* @param bool $night Whether the night variant should be used.
|
||||
* @return string
|
||||
*/
|
||||
public static function icon( string $key, bool $night = false ): string {
|
||||
$files = self::ICONS[ $key ] ?? self::ICONS['cloudy'];
|
||||
|
||||
return FONTDELBOSC_ELTEMPS_PLUGIN_URL . 'public/icons/flat/' . $files[ $night ? 1 : 0 ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough day/night detection for icon selection.
|
||||
*
|
||||
* @param int $hour Local hour (0-23).
|
||||
* @return bool True when night.
|
||||
*/
|
||||
public static function is_night( int $hour ): bool {
|
||||
return $hour < 7 || $hour >= 21;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated weekday name for an ISO-8601 weekday number.
|
||||
*
|
||||
* @param int $number Weekday number (1 = Monday, 7 = Sunday).
|
||||
* @return string
|
||||
*/
|
||||
public static function weekday( int $number ): string {
|
||||
$weekdays = array(
|
||||
1 => __( 'Monday', 'fontdelbosc-eltemps' ),
|
||||
2 => __( 'Tuesday', 'fontdelbosc-eltemps' ),
|
||||
3 => __( 'Wednesday', 'fontdelbosc-eltemps' ),
|
||||
4 => __( 'Thursday', 'fontdelbosc-eltemps' ),
|
||||
5 => __( 'Friday', 'fontdelbosc-eltemps' ),
|
||||
6 => __( 'Saturday', 'fontdelbosc-eltemps' ),
|
||||
7 => __( 'Sunday', 'fontdelbosc-eltemps' ),
|
||||
);
|
||||
|
||||
return $weekdays[ $number ] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated month name for a month number.
|
||||
*
|
||||
* @param int $number Month number (1-12).
|
||||
* @return string
|
||||
*/
|
||||
public static function month( int $number ): string {
|
||||
$months = array(
|
||||
1 => __( 'January', 'fontdelbosc-eltemps' ),
|
||||
2 => __( 'February', 'fontdelbosc-eltemps' ),
|
||||
3 => __( 'March', 'fontdelbosc-eltemps' ),
|
||||
4 => __( 'April', 'fontdelbosc-eltemps' ),
|
||||
5 => __( 'May', 'fontdelbosc-eltemps' ),
|
||||
6 => __( 'June', 'fontdelbosc-eltemps' ),
|
||||
7 => __( 'July', 'fontdelbosc-eltemps' ),
|
||||
8 => __( 'August', 'fontdelbosc-eltemps' ),
|
||||
9 => __( 'September', 'fontdelbosc-eltemps' ),
|
||||
10 => __( 'October', 'fontdelbosc-eltemps' ),
|
||||
11 => __( 'November', 'fontdelbosc-eltemps' ),
|
||||
12 => __( 'December', 'fontdelbosc-eltemps' ),
|
||||
);
|
||||
|
||||
return $months[ $number ] ?? '';
|
||||
}
|
||||
}
|
||||
260
includes/class-fontdelbosc-data-manager.php
Normal file
260
includes/class-fontdelbosc-data-manager.php
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
/**
|
||||
* Data manager. Orchestrates the API sources with per-view fallback chains
|
||||
* and Transients API caching (stale-while-error pattern).
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Orchestrates API sources, caching and fallback chains.
|
||||
*/
|
||||
final class Fontdelbosc_Data_Manager {
|
||||
|
||||
/**
|
||||
* Warms or refreshes every cache, regardless of who triggers it.
|
||||
* Hooked to the hourly fontdelbosc_refresh_weather WP-Cron event so
|
||||
* visitors are never served by a cold cache.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function refresh(): void {
|
||||
self::get_current();
|
||||
self::get_hourly( 24 );
|
||||
self::get_daily();
|
||||
|
||||
$now = new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) );
|
||||
$previous = $now->modify( 'first day of previous month' );
|
||||
|
||||
self::get_historical( (int) $previous->format( 'Y' ), (int) $previous->format( 'n' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns normalized current conditions.
|
||||
*
|
||||
* Chain: Meteocat XEMA station (when key and station are configured)
|
||||
* → Open-Meteo current.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_current(): ?array {
|
||||
$options = Fontdelbosc_Plugin::get_options();
|
||||
$data = self::cached(
|
||||
'fontdelbosc_current',
|
||||
$options['cache_ttl_current'],
|
||||
static function () use ( $options ): ?array {
|
||||
if ( '' !== $options['meteocat_api_key'] && '' !== $options['meteocat_station_code'] ) {
|
||||
$current = Fontdelbosc_Api_Meteocat::get_current( $options['meteocat_api_key'], $options['meteocat_station_code'] );
|
||||
|
||||
if ( null !== $current ) {
|
||||
$hourly = Fontdelbosc_Data_Manager::get_hourly( 1 );
|
||||
$firsthour = Fontdelbosc_Payload::path( $hourly, 'hours', 0 );
|
||||
|
||||
if ( is_array( $firsthour ) && null !== Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $firsthour, 'condition' ) ) ) {
|
||||
$current['condition'] = Fontdelbosc_Payload::get( $firsthour, 'condition' );
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
|
||||
return Fontdelbosc_Api_OpenMeteo::get_current();
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! is_array( $data ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'temperature' => Fontdelbosc_Payload::get( $data, 'temperature' ),
|
||||
'apparent' => Fontdelbosc_Payload::get( $data, 'apparent' ),
|
||||
'humidity' => Fontdelbosc_Payload::get( $data, 'humidity' ),
|
||||
'precipitation' => Fontdelbosc_Payload::get( $data, 'precipitation' ),
|
||||
'wind_speed' => Fontdelbosc_Payload::get( $data, 'wind_speed' ),
|
||||
'wind_direction' => Fontdelbosc_Payload::get( $data, 'wind_direction' ),
|
||||
'condition' => Fontdelbosc_Payload::get( $data, 'condition' ),
|
||||
'source' => Fontdelbosc_Payload::get( $data, 'source' ),
|
||||
'time' => Fontdelbosc_Payload::get( $data, 'time' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hourly forecast sliced to the requested number of hours.
|
||||
*
|
||||
* Chain: Meteocat municipal → AEMET hourly → Open-Meteo hourly.
|
||||
*
|
||||
* @param int $hours Number of hours (1-48).
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_hourly( int $hours = 24 ): ?array {
|
||||
$options = Fontdelbosc_Plugin::get_options();
|
||||
$data = self::cached(
|
||||
'fontdelbosc_forecast_hourly',
|
||||
$options['cache_ttl_forecast'],
|
||||
static function () use ( $options ): ?array {
|
||||
if ( '' !== $options['meteocat_api_key'] ) {
|
||||
$data = Fontdelbosc_Api_Meteocat::get_hourly( $options['meteocat_api_key'] );
|
||||
|
||||
if ( null !== $data ) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
if ( '' !== $options['aemet_api_key'] ) {
|
||||
$data = Fontdelbosc_Api_Aemet::get_hourly( $options['aemet_api_key'] );
|
||||
|
||||
if ( null !== $data ) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return Fontdelbosc_Api_OpenMeteo::get_hourly();
|
||||
}
|
||||
);
|
||||
|
||||
$entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'hours' ) ), 'is_array' ) );
|
||||
$source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) );
|
||||
|
||||
if ( '' === $source || array() === $entries ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => $source,
|
||||
'hours' => array_slice( $entries, 0, max( 1, min( 48, $hours ) ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the daily forecast sliced to the requested number of days.
|
||||
*
|
||||
* Chain: AEMET daily → Open-Meteo daily.
|
||||
*
|
||||
* @param int $days Number of days (1-7).
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_daily( int $days = 7 ): ?array {
|
||||
$options = Fontdelbosc_Plugin::get_options();
|
||||
$data = self::cached(
|
||||
'fontdelbosc_forecast_daily',
|
||||
$options['cache_ttl_forecast'],
|
||||
static function () use ( $options ): ?array {
|
||||
if ( '' !== $options['aemet_api_key'] ) {
|
||||
$data = Fontdelbosc_Api_Aemet::get_daily( $options['aemet_api_key'] );
|
||||
|
||||
if ( null !== $data ) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return Fontdelbosc_Api_OpenMeteo::get_daily();
|
||||
}
|
||||
);
|
||||
|
||||
$entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'days' ) ), 'is_array' ) );
|
||||
$source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) );
|
||||
|
||||
if ( '' === $source || array() === $entries ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => $source,
|
||||
'days' => array_slice( $entries, 0, max( 1, min( 7, $days ) ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the daily history for a month (Open-Meteo ERA5 archive).
|
||||
*
|
||||
* @param int $year Year (1940 onwards).
|
||||
* @param int $month Month (1-12).
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function get_historical( int $year, int $month ): ?array {
|
||||
$year = max( 1940, min( (int) gmdate( 'Y' ), $year ) );
|
||||
$month = max( 1, min( 12, $month ) );
|
||||
|
||||
$options = Fontdelbosc_Plugin::get_options();
|
||||
$data = self::cached(
|
||||
'fontdelbosc_historical_' . $year . '_' . $month,
|
||||
$options['cache_ttl_historical'],
|
||||
static function () use ( $year, $month ): ?array {
|
||||
return Fontdelbosc_Api_OpenMeteo::get_historical( $year, $month );
|
||||
}
|
||||
);
|
||||
|
||||
$entries = array_values( array_filter( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'days' ) ), 'is_array' ) );
|
||||
$source = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $data, 'source' ) );
|
||||
|
||||
if ( '' === $source || array() === $entries ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'source' => $source,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'days' => $entries,
|
||||
'stats' => Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $data, 'stats' ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the license attribution line for a data source slug.
|
||||
*
|
||||
* @param string $source Source slug (meteocat, aemet, open-meteo).
|
||||
* @return string
|
||||
*/
|
||||
public static function attribution( string $source ): string {
|
||||
$map = array(
|
||||
'meteocat' => '© Servei Meteorològic de Catalunya. Generalitat de Catalunya',
|
||||
'aemet' => '© AEMET. Gobierno de España',
|
||||
'open-meteo' => 'Weather data by Open-Meteo.com (CC BY 4.0)',
|
||||
);
|
||||
|
||||
return $map[ $source ] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache helper implementing stale-while-error. Fresh data is served
|
||||
* until its TTL expires; on fetch failure the last cached payload is
|
||||
* served for up to one extra day.
|
||||
*
|
||||
* @param string $key Transient key.
|
||||
* @param int $ttl Time-to-live in seconds.
|
||||
* @param callable $fetch Fetch callback returning array|null.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function cached( string $key, int $ttl, callable $fetch ): mixed {
|
||||
$cache = get_transient( $key );
|
||||
|
||||
if ( is_array( $cache ) && (int) Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $cache, 'expires' ) ?? 0 ) > time() ) {
|
||||
return Fontdelbosc_Payload::get( $cache, 'data' );
|
||||
}
|
||||
|
||||
$data = $fetch();
|
||||
|
||||
if ( null !== $data ) {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'expires' => time() + $ttl,
|
||||
'data' => $data,
|
||||
),
|
||||
$ttl + DAY_IN_SECONDS
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ( is_array( $cache ) ) {
|
||||
return Fontdelbosc_Payload::get( $cache, 'data' );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
88
includes/class-fontdelbosc-payload.php
Normal file
88
includes/class-fontdelbosc-payload.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
/**
|
||||
* Typed helpers to navigate decoded JSON payloads without casting mixed values.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Narrowing helpers for mixed JSON values.
|
||||
*/
|
||||
final class Fontdelbosc_Payload {
|
||||
|
||||
/**
|
||||
* Walks a nested array path and returns the value at the end, or null.
|
||||
*
|
||||
* @param mixed $value Starting value.
|
||||
* @param int|string|null ...$keys Path keys.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function path( mixed $value, int|string|null ...$keys ): mixed {
|
||||
foreach ( $keys as $key ) {
|
||||
if ( null === $key || ! is_array( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $value[ $key ] ?? null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value at a key, or null when the holder is not an array.
|
||||
*
|
||||
* @param mixed $value Holder.
|
||||
* @param int|string $key Key.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get( mixed $value, int|string $key ): mixed {
|
||||
if ( ! is_array( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value[ $key ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as an array, or an empty array.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public static function arr( mixed $value ): array {
|
||||
return is_array( $value ) ? $value : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as a string, or null.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return string|null
|
||||
*/
|
||||
public static function str( mixed $value ): ?string {
|
||||
return is_string( $value ) ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as an integer, or null.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return int|null
|
||||
*/
|
||||
public static function int( mixed $value ): ?int {
|
||||
return is_numeric( $value ) ? (int) round( (float) $value ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as a float, or null.
|
||||
*
|
||||
* @param mixed $value Raw value.
|
||||
* @return float|null
|
||||
*/
|
||||
public static function float( mixed $value ): ?float {
|
||||
return is_numeric( $value ) ? (float) $value : null;
|
||||
}
|
||||
}
|
||||
168
includes/class-fontdelbosc-plugin.php
Normal file
168
includes/class-fontdelbosc-plugin.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
/**
|
||||
* Bootstrap class. Single entry point that wires every component.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Wires every plugin component and holds the typed options accessor.
|
||||
*/
|
||||
final class Fontdelbosc_Plugin {
|
||||
|
||||
/**
|
||||
* Options shape.
|
||||
*
|
||||
* @var array{
|
||||
* aemet_api_key: string,
|
||||
* meteocat_api_key: string,
|
||||
* meteocat_station_code: string,
|
||||
* cache_ttl_current: int,
|
||||
* cache_ttl_forecast: int,
|
||||
* cache_ttl_historical: int,
|
||||
* display_attribution: bool,
|
||||
* delete_data_on_uninstall: bool
|
||||
* }|null
|
||||
*/
|
||||
private static ?array $options = null;
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var Fontdelbosc_Plugin|null
|
||||
*/
|
||||
private static ?Fontdelbosc_Plugin $instance = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton instance.
|
||||
*
|
||||
* @return Fontdelbosc_Plugin
|
||||
*/
|
||||
public static function instance(): Fontdelbosc_Plugin {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers every hook. Private constructor: use instance().
|
||||
*/
|
||||
private function __construct() {
|
||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||
add_action( 'init', array( $this, 'init_components' ) );
|
||||
add_action( 'widgets_init', array( $this, 'register_widgets' ) );
|
||||
add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
|
||||
add_action( 'fontdelbosc_refresh_weather', array( 'Fontdelbosc_Data_Manager', 'refresh' ) );
|
||||
|
||||
if ( is_admin() ) {
|
||||
require_once FONTDELBOSC_ELTEMPS_PLUGIN_DIR . 'admin/class-fontdelbosc-admin.php';
|
||||
Fontdelbosc_Admin::init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the plugin text domain. Hooked on init to avoid the
|
||||
* just-in-time loading notice on WordPress 6.7+.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function load_textdomain(): void {
|
||||
load_plugin_textdomain(
|
||||
'fontdelbosc-eltemps',
|
||||
false,
|
||||
dirname( plugin_basename( FONTDELBOSC_ELTEMPS_PLUGIN_FILE ) ) . '/languages/'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the frontend components.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init_components(): void {
|
||||
Fontdelbosc_Shortcode::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the sidebar widget.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_widgets(): void {
|
||||
register_widget( 'Fontdelbosc_Widget' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers (not enqueues) the public stylesheet, so it is only
|
||||
* printed on pages that actually render weather output.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_assets(): void {
|
||||
wp_register_style(
|
||||
'fdb-weather',
|
||||
FONTDELBOSC_ELTEMPS_PLUGIN_URL . 'public/css/fdb-weather.css',
|
||||
array(),
|
||||
FONTDELBOSC_ELTEMPS_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the hourly cache-refresh event on activation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activate(): void {
|
||||
if ( ! wp_next_scheduled( 'fontdelbosc_refresh_weather' ) ) {
|
||||
wp_schedule_event( time(), 'hourly', 'fontdelbosc_refresh_weather' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the scheduled cache-refresh event on deactivation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
wp_clear_scheduled_hook( 'fontdelbosc_refresh_weather' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the plugin options merged with their defaults, with every
|
||||
* value narrowed to its documented type.
|
||||
*
|
||||
* @return array{
|
||||
* aemet_api_key: string,
|
||||
* meteocat_api_key: string,
|
||||
* meteocat_station_code: string,
|
||||
* cache_ttl_current: int,
|
||||
* cache_ttl_forecast: int,
|
||||
* cache_ttl_historical: int,
|
||||
* display_attribution: bool,
|
||||
* delete_data_on_uninstall: bool
|
||||
* }
|
||||
*/
|
||||
public static function get_options(): array {
|
||||
if ( null === self::$options ) {
|
||||
$stored = get_option( 'fontdelbosc_options', array() );
|
||||
$stored = is_array( $stored ) ? $stored : array();
|
||||
|
||||
self::$options = array(
|
||||
'aemet_api_key' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'aemet_api_key' ) ) ?? '',
|
||||
'meteocat_api_key' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'meteocat_api_key' ) ) ?? '',
|
||||
'meteocat_station_code' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $stored, 'meteocat_station_code' ) ) ?? '',
|
||||
'cache_ttl_current' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_current' ) ) ?? 600,
|
||||
'cache_ttl_forecast' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_forecast' ) ) ?? 3600,
|
||||
'cache_ttl_historical' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $stored, 'cache_ttl_historical' ) ) ?? 86400,
|
||||
'display_attribution' => array_key_exists( 'display_attribution', $stored ) ? ! empty( $stored['display_attribution'] ) : true,
|
||||
'delete_data_on_uninstall' => ! empty( $stored['delete_data_on_uninstall'] ),
|
||||
);
|
||||
}
|
||||
|
||||
return self::$options;
|
||||
}
|
||||
}
|
||||
145
includes/class-fontdelbosc-shortcode.php
Normal file
145
includes/class-fontdelbosc-shortcode.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
/**
|
||||
* Shortcode [fontdelbosc_eltemps] and the shared view renderer used by
|
||||
* both the shortcode and the widget.
|
||||
*
|
||||
* Attributes:
|
||||
* - view: current (default), hourly, daily, historical
|
||||
* - hours: 1-48, hours to show in the hourly view (default 24)
|
||||
* - days: 1-7, days to show in the daily view (default 7)
|
||||
* - year/month: month shown in the historical view (default: current)
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Renders the weather views for the shortcode and the widget.
|
||||
*/
|
||||
final class Fontdelbosc_Shortcode {
|
||||
|
||||
/**
|
||||
* Registers the shortcode hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
add_shortcode( 'fontdelbosc_eltemps', array( self::class, 'render' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the shortcode.
|
||||
*
|
||||
* @param array<string> $atts Shortcode attributes.
|
||||
* @param string|null $content Enclosed content (unused).
|
||||
* @param string $tag Shortcode tag (unused).
|
||||
* @return string
|
||||
*/
|
||||
public static function render( $atts = array(), ?string $content = null, string $tag = '' ): string {
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'view' => 'current',
|
||||
'hours' => 24,
|
||||
'days' => 7,
|
||||
'year' => 0,
|
||||
'month' => 0,
|
||||
),
|
||||
$atts,
|
||||
'fontdelbosc_eltemps'
|
||||
);
|
||||
|
||||
$view = sanitize_key( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $atts, 'view' ) ) ?? 'current' );
|
||||
|
||||
if ( ! in_array( $view, array( 'current', 'hourly', 'daily', 'historical' ), true ) ) {
|
||||
$view = 'current';
|
||||
}
|
||||
|
||||
return self::render_view(
|
||||
$view,
|
||||
array(
|
||||
'hours' => max( 1, min( 48, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'hours' ) ) ?? 24 ) ),
|
||||
'days' => max( 1, min( 7, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'days' ) ) ?? 7 ) ),
|
||||
'year' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'year' ) ) ?? 0,
|
||||
'month' => max( 0, min( 12, Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $atts, 'month' ) ) ?? 0 ) ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a weather view. Shared by shortcode and widget.
|
||||
*
|
||||
* @param string $view View name.
|
||||
* @param array<string,mixed> $args Optional view arguments.
|
||||
* @return string
|
||||
*/
|
||||
public static function render_view( string $view, array $args = array() ): string {
|
||||
$args = wp_parse_args(
|
||||
$args,
|
||||
array(
|
||||
'hours' => 24,
|
||||
'days' => 7,
|
||||
'year' => 0,
|
||||
'month' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
$template = 'current.php';
|
||||
$data = null;
|
||||
$now = new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) );
|
||||
|
||||
switch ( $view ) {
|
||||
case 'hourly':
|
||||
$template = 'forecast-hourly.php';
|
||||
$data = Fontdelbosc_Data_Manager::get_hourly( Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'hours' ) ) ?? 24 );
|
||||
break;
|
||||
case 'daily':
|
||||
$template = 'forecast-daily.php';
|
||||
$data = Fontdelbosc_Data_Manager::get_daily( Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'days' ) ) ?? 7 );
|
||||
break;
|
||||
case 'historical':
|
||||
$template = 'historical.php';
|
||||
$year = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'year' ) ) ?? 0;
|
||||
$month = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $args, 'month' ) ) ?? 0;
|
||||
|
||||
if ( $year > 0 || $month > 0 ) {
|
||||
$data = Fontdelbosc_Data_Manager::get_historical(
|
||||
$year > 0 ? $year : (int) $now->format( 'Y' ),
|
||||
$month > 0 ? $month : (int) $now->format( 'n' )
|
||||
);
|
||||
} else {
|
||||
$previous = $now->modify( 'first day of previous month' );
|
||||
|
||||
$data = Fontdelbosc_Data_Manager::get_historical(
|
||||
(int) $previous->format( 'Y' ),
|
||||
(int) $previous->format( 'n' )
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$data = Fontdelbosc_Data_Manager::get_current();
|
||||
}
|
||||
|
||||
if ( null === $data ) {
|
||||
return '<p class="fdb-weather fdb-unavailable">' . esc_html__( 'Weather data unavailable.', 'fontdelbosc-eltemps' ) . '</p>';
|
||||
}
|
||||
|
||||
wp_enqueue_style( 'fdb-weather' );
|
||||
|
||||
return self::load_template( $template, $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers and returns a template.
|
||||
*
|
||||
* @param string $template Template file name.
|
||||
* @param array<string,mixed> $data Normalized view data.
|
||||
* @return string
|
||||
*/
|
||||
private static function load_template( string $template, array $data ): string {
|
||||
ob_start();
|
||||
include FONTDELBOSC_ELTEMPS_PLUGIN_DIR . 'templates/' . $template;
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
99
includes/class-fontdelbosc-widget.php
Normal file
99
includes/class-fontdelbosc-widget.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
/**
|
||||
* Sidebar widget wrapping the shared view renderer.
|
||||
*
|
||||
* @package FontdelboscEltemps
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Sidebar widget for current conditions or today's forecast.
|
||||
*/
|
||||
final class Fontdelbosc_Widget extends WP_Widget {
|
||||
|
||||
/**
|
||||
* Registers the widget with WordPress.
|
||||
*/
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'fontdelbosc_weather',
|
||||
'Font del Bosc — El Temps',
|
||||
array(
|
||||
'description' => __( 'Current weather and forecast for Font del Bosc (Mediona).', 'fontdelbosc-eltemps' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the widget output.
|
||||
*
|
||||
* @param array<string,mixed> $args Widget arguments.
|
||||
* @param array<string,mixed> $instance Saved widget values.
|
||||
* @return void
|
||||
*/
|
||||
public function widget( $args, $instance ): void {
|
||||
$title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'title' ) ) ?? '';
|
||||
$title = '' !== $title ? Fontdelbosc_Payload::str( apply_filters( 'widget_title', $title, $instance, $this->id_base ) ) ?? $title : '';
|
||||
$view = 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'view' ) ) ? 'today' : 'current';
|
||||
|
||||
$before_widget = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'before_widget' ) ) ?? '';
|
||||
$after_widget = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'after_widget' ) ) ?? '';
|
||||
$before_title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $args, 'before_title' ) ) ?? '';
|
||||
$after_title = Fontdelbosc_Payload::str( Fontdelbosc_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 ( 'today' === $view ) {
|
||||
echo wp_kses_post( Fontdelbosc_Shortcode::render_view( 'daily', array( 'days' => 1 ) ) );
|
||||
} else {
|
||||
echo wp_kses_post( Fontdelbosc_Shortcode::render_view( 'current' ) );
|
||||
}
|
||||
|
||||
echo wp_kses_post( $after_widget );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the widget settings form.
|
||||
*
|
||||
* @param array<string,mixed> $instance Saved widget values.
|
||||
* @return string|null
|
||||
*/
|
||||
public function form( $instance ): ?string {
|
||||
$title = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'title' ) ) ?? '';
|
||||
$view = 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $instance, 'view' ) ) ? 'today' : 'current';
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title', 'fontdelbosc-eltemps' ); ?></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( 'view' ) ); ?>"><?php esc_html_e( 'View', 'fontdelbosc-eltemps' ); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'view' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'view' ) ); ?>">
|
||||
<option value="current" <?php selected( $view, 'current' ); ?>><?php esc_html_e( 'Current conditions', 'fontdelbosc-eltemps' ); ?></option>
|
||||
<option value="today" <?php selected( $view, 'today' ); ?>><?php esc_html_e( "Today's forecast", 'fontdelbosc-eltemps' ); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<?php
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the widget settings on save.
|
||||
*
|
||||
* @param array<string,mixed> $new_instance New values.
|
||||
* @param array<string,mixed> $old_instance Old values.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function update( $new_instance, $old_instance ): array {
|
||||
return array(
|
||||
'title' => sanitize_text_field( Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $new_instance, 'title' ) ) ?? '' ),
|
||||
'view' => 'today' === Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $new_instance, 'view' ) ) ? 'today' : 'current',
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue