260 lines
7.7 KiB
PHP
260 lines
7.7 KiB
PHP
<?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;
|
|
}
|
|
}
|