This commit is contained in:
Javier Casares 2026-10-12 11:55:23 +00:00
commit 06153e387d
47 changed files with 5116 additions and 0 deletions

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

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

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

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

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

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

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

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

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