diff options
Diffstat (limited to 'apps/weather_status/lib')
6 files changed, 727 insertions, 0 deletions
diff --git a/apps/weather_status/lib/AppInfo/Application.php b/apps/weather_status/lib/AppInfo/Application.php new file mode 100644 index 00000000000..147d69ee543 --- /dev/null +++ b/apps/weather_status/lib/AppInfo/Application.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WeatherStatus\AppInfo; + +use OCA\WeatherStatus\Capabilities; +use OCA\WeatherStatus\Listeners\BeforeTemplateRenderedListener; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; + +/** + * Class Application + * + * @package OCA\WeatherStatus\AppInfo + */ +class Application extends App implements IBootstrap { + + /** @var string */ + public const APP_ID = 'weather_status'; + + /** + * Application constructor. + * + * @param array $urlParams + */ + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + /** + * @inheritDoc + */ + public function register(IRegistrationContext $context): void { + // Register OCS Capabilities + $context->registerCapability(Capabilities::class); + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + } + + public function boot(IBootContext $context): void { + } +} diff --git a/apps/weather_status/lib/Capabilities.php b/apps/weather_status/lib/Capabilities.php new file mode 100644 index 00000000000..953b40036f3 --- /dev/null +++ b/apps/weather_status/lib/Capabilities.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WeatherStatus; + +use OCA\WeatherStatus\AppInfo\Application; + +use OCP\Capabilities\ICapability; + +/** + * Class Capabilities + * + * @package OCA\UserStatus + */ +class Capabilities implements ICapability { + + /** + * Capabilities constructor. + * + */ + public function __construct() { + } + + /** + * @return array{weather_status: array{enabled: bool}} + */ + public function getCapabilities() { + return [ + Application::APP_ID => [ + 'enabled' => true, + ], + ]; + } +} diff --git a/apps/weather_status/lib/Controller/WeatherStatusController.php b/apps/weather_status/lib/Controller/WeatherStatusController.php new file mode 100644 index 00000000000..c56ea3b97b3 --- /dev/null +++ b/apps/weather_status/lib/Controller/WeatherStatusController.php @@ -0,0 +1,135 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WeatherStatus\Controller; + +use OCA\WeatherStatus\ResponseDefinitions; +use OCA\WeatherStatus\Service\WeatherStatusService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +/** + * @psalm-import-type WeatherStatusForecast from ResponseDefinitions + * @psalm-import-type WeatherStatusSuccess from ResponseDefinitions + * @psalm-import-type WeatherStatusLocation from ResponseDefinitions + * @psalm-import-type WeatherStatusLocationWithSuccess from ResponseDefinitions + * @psalm-import-type WeatherStatusLocationWithMode from ResponseDefinitions + */ +class WeatherStatusController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private WeatherStatusService $service, + private ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * Try to use the address set in user personal settings as weather location + * + * @return DataResponse<Http::STATUS_OK, WeatherStatusLocationWithSuccess, array{}> + * + * 200: Address updated + */ + #[NoAdminRequired] + public function usePersonalAddress(): DataResponse { + return new DataResponse($this->service->usePersonalAddress()); + } + + /** + * Change the weather status mode. There are currently 2 modes: + * - ask the browser + * - use the user defined address + * + * @param int $mode New mode + * @return DataResponse<Http::STATUS_OK, WeatherStatusSuccess, array{}> + * + * 200: Weather status mode updated + */ + #[NoAdminRequired] + public function setMode(int $mode): DataResponse { + return new DataResponse($this->service->setMode($mode)); + } + + /** + * Set address and resolve it to get coordinates + * or directly set coordinates and get address with reverse geocoding + * + * @param string|null $address Any approximative or exact address + * @param float|null $lat Latitude in decimal degree format + * @param float|null $lon Longitude in decimal degree format + * @return DataResponse<Http::STATUS_OK, WeatherStatusLocationWithSuccess, array{}> + * + * 200: Location updated + */ + #[NoAdminRequired] + public function setLocation(?string $address, ?float $lat, ?float $lon): DataResponse { + $currentWeather = $this->service->setLocation($address, $lat, $lon); + return new DataResponse($currentWeather); + } + + /** + * Get stored user location + * + * @return DataResponse<Http::STATUS_OK, WeatherStatusLocationWithMode, array{}> + * + * 200: Location returned + */ + #[NoAdminRequired] + public function getLocation(): DataResponse { + $location = $this->service->getLocation(); + return new DataResponse($location); + } + + /** + * Get forecast for current location + * + * @return DataResponse<Http::STATUS_OK, list<WeatherStatusForecast>|array{error: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, WeatherStatusSuccess, array{}> + * + * 200: Forecast returned + * 404: Forecast not found + */ + #[NoAdminRequired] + public function getForecast(): DataResponse { + $forecast = $this->service->getForecast(); + if (isset($forecast['success']) && $forecast['success'] === false) { + return new DataResponse($forecast, Http::STATUS_NOT_FOUND); + } else { + return new DataResponse($forecast); + } + } + + /** + * Get favorites list + * + * @return DataResponse<Http::STATUS_OK, list<string>, array{}> + * + * 200: Favorites returned + */ + #[NoAdminRequired] + public function getFavorites(): DataResponse { + return new DataResponse($this->service->getFavorites()); + } + + /** + * Set favorites list + * + * @param list<string> $favorites Favorite addresses + * @return DataResponse<Http::STATUS_OK, WeatherStatusSuccess, array{}> + * + * 200: Favorites updated + */ + #[NoAdminRequired] + public function setFavorites(array $favorites): DataResponse { + return new DataResponse($this->service->setFavorites($favorites)); + } +} diff --git a/apps/weather_status/lib/Listeners/BeforeTemplateRenderedListener.php b/apps/weather_status/lib/Listeners/BeforeTemplateRenderedListener.php new file mode 100644 index 00000000000..5d3b76626b6 --- /dev/null +++ b/apps/weather_status/lib/Listeners/BeforeTemplateRenderedListener.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WeatherStatus\Listeners; + +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** + * @template-implements IEventListener<BeforeTemplateRenderedEvent> + */ +class BeforeTemplateRenderedListener implements IEventListener { + + /** + * Inject our status widget script when the dashboard is loaded + * We need to do it like this because there is currently no PHP API for registering "status widgets" + */ + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + // Only handle the dashboard + if ($event->getResponse()->getApp() !== 'dashboard') { + return; + } + + Util::addScript('weather_status', 'weather-status'); + } +} diff --git a/apps/weather_status/lib/ResponseDefinitions.php b/apps/weather_status/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..17d7cebf76e --- /dev/null +++ b/apps/weather_status/lib/ResponseDefinitions.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\WeatherStatus; + +/** + * https://api.met.no/doc/ForecastJSON compact format according to https://docs.api.met.no/doc/locationforecast/datamodel + * @psalm-type WeatherStatusForecast = array{ + * time: string, + * data: array{ + * instant: array{ + * details: array{ + * air_pressure_at_sea_level: numeric, + * air_temperature: numeric, + * cloud_area_fraction: numeric, + * relative_humidity: numeric, + * wind_from_direction: numeric, + * wind_speed: numeric, + * }, + * }, + * next_12_hours: array{ + * summary: array{ + * symbol_code: string, + * }, + * details: array{ + * precipitation_amount?: numeric, + * }, + * }, + * next_1_hours: array{ + * summary: array{ + * symbol_code: string, + * }, + * details: array{ + * precipitation_amount?: numeric, + * }, + * }, + * next_6_hours: array{ + * summary: array{ + * symbol_code: string, + * }, + * details: array{ + * precipitation_amount?: numeric, + * }, + * }, + * }, + * } + * + * @psalm-type WeatherStatusSuccess = array{ + * success: bool, + * } + * + * @psalm-type WeatherStatusMode = array{ + * mode: int, + * } + * @psalm-type WeatherStatusLocation = array{ + * lat?: string, + * lon?: string, + * address?: ?string, + * } + * + * @psalm-type WeatherStatusLocationWithSuccess = WeatherStatusLocation&WeatherStatusSuccess + * + * @psalm-type WeatherStatusLocationWithMode = WeatherStatusLocation&WeatherStatusMode + */ +class ResponseDefinitions { +} diff --git a/apps/weather_status/lib/Service/WeatherStatusService.php b/apps/weather_status/lib/Service/WeatherStatusService.php new file mode 100644 index 00000000000..c93010e7f58 --- /dev/null +++ b/apps/weather_status/lib/Service/WeatherStatusService.php @@ -0,0 +1,394 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\WeatherStatus\Service; + +use OCA\WeatherStatus\AppInfo\Application; +use OCA\WeatherStatus\ResponseDefinitions; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\App\IAppManager; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +/** + * Class WeatherStatusService + * + * @package OCA\WeatherStatus\Service + * + * @psalm-import-type WeatherStatusForecast from ResponseDefinitions + * @psalm-import-type WeatherStatusSuccess from ResponseDefinitions + * @psalm-import-type WeatherStatusLocationWithSuccess from ResponseDefinitions + * @psalm-import-type WeatherStatusLocationWithMode from ResponseDefinitions + */ +class WeatherStatusService { + public const MODE_BROWSER_LOCATION = 1; + public const MODE_MANUAL_LOCATION = 2; + + private IClient $client; + private ICache $cache; + private string $version; + + public function __construct( + private IClientService $clientService, + private IConfig $config, + private IL10N $l10n, + private LoggerInterface $logger, + private IAccountManager $accountManager, + private IUserManager $userManager, + private IAppManager $appManager, + private ICacheFactory $cacheFactory, + private ?string $userId, + ) { + $this->version = $appManager->getAppVersion(Application::APP_ID); + $this->client = $clientService->newClient(); + $this->cache = $cacheFactory->createDistributed('weatherstatus'); + } + + /** + * Change the weather status mode. There are currently 2 modes: + * - ask the browser + * - use the user defined address + * @param int $mode New mode + * @return WeatherStatusSuccess success state + */ + public function setMode(int $mode): array { + $this->config->setUserValue($this->userId, Application::APP_ID, 'mode', strval($mode)); + return ['success' => true]; + } + + /** + * Get favorites list + * @return list<string> + */ + public function getFavorites(): array { + $favoritesJson = $this->config->getUserValue($this->userId, Application::APP_ID, 'favorites', ''); + return json_decode($favoritesJson, true) ?: []; + } + + /** + * Set favorites list + * @param list<string> $favorites + * @return WeatherStatusSuccess success state + */ + public function setFavorites(array $favorites): array { + $this->config->setUserValue($this->userId, Application::APP_ID, 'favorites', json_encode($favorites)); + return ['success' => true]; + } + + /** + * Try to use the address set in user personal settings as weather location + * + * @return WeatherStatusLocationWithSuccess with success state and address information + */ + public function usePersonalAddress(): array { + $account = $this->accountManager->getAccount($this->userManager->get($this->userId)); + try { + $address = $account->getProperty('address')->getValue(); + } catch (PropertyDoesNotExistException $e) { + return ['success' => false]; + } + if ($address === '') { + return ['success' => false]; + } + return $this->setAddress($address); + } + + /** + * Set address and resolve it to get coordinates + * or directly set coordinates and get address with reverse geocoding + * + * @param string|null $address Any approximative or exact address + * @param float|null $lat Latitude in decimal degree format + * @param float|null $lon Longitude in decimal degree format + * @return WeatherStatusLocationWithSuccess with success state and address information + */ + public function setLocation(?string $address, ?float $lat, ?float $lon): array { + if (!is_null($lat) && !is_null($lon)) { + // store coordinates + $this->config->setUserValue($this->userId, Application::APP_ID, 'lat', strval($lat)); + $this->config->setUserValue($this->userId, Application::APP_ID, 'lon', strval($lon)); + // resolve and store formatted address + $address = $this->resolveLocation($lat, $lon); + $address = $address ?: $this->l10n->t('Unknown address'); + $this->config->setUserValue($this->userId, Application::APP_ID, 'address', $address); + // get and store altitude + $altitude = $this->getAltitude($lat, $lon); + $this->config->setUserValue($this->userId, Application::APP_ID, 'altitude', strval($altitude)); + return [ + 'address' => $address, + 'success' => true, + ]; + } elseif ($address) { + return $this->setAddress($address); + } else { + return ['success' => false]; + } + } + + /** + * Provide address information from coordinates + * + * @param float $lat Latitude in decimal degree format + * @param float $lon Longitude in decimal degree format + */ + private function resolveLocation(float $lat, float $lon): ?string { + $params = [ + 'lat' => number_format($lat, 2), + 'lon' => number_format($lon, 2), + 'addressdetails' => 1, + 'format' => 'json', + ]; + $url = 'https://nominatim.openstreetmap.org/reverse'; + $result = $this->requestJSON($url, $params); + return $this->formatOsmAddress($result); + } + + /** + * Get altitude from coordinates + * + * @param float $lat Latitude in decimal degree format + * @param float $lon Longitude in decimal degree format + * @return float altitude in meter + */ + private function getAltitude(float $lat, float $lon): float { + $params = [ + 'locations' => $lat . ',' . $lon, + ]; + $url = 'https://api.opentopodata.org/v1/srtm30m'; + $result = $this->requestJSON($url, $params); + $altitude = 0; + if (isset($result['results']) && is_array($result['results']) && count($result['results']) > 0 + && is_array($result['results'][0]) && isset($result['results'][0]['elevation'])) { + $altitude = floatval($result['results'][0]['elevation']); + } + return $altitude; + } + + /** + * @return string Formatted address from JSON nominatim result + */ + private function formatOsmAddress(array $json): ?string { + if (isset($json['address']) && isset($json['display_name'])) { + $jsonAddr = $json['address']; + $cityAddress = ''; + // priority : city, town, village, municipality + if (isset($jsonAddr['city'])) { + $cityAddress .= $jsonAddr['city']; + } elseif (isset($jsonAddr['town'])) { + $cityAddress .= $jsonAddr['town']; + } elseif (isset($jsonAddr['village'])) { + $cityAddress .= $jsonAddr['village']; + } elseif (isset($jsonAddr['municipality'])) { + $cityAddress .= $jsonAddr['municipality']; + } else { + return $json['display_name']; + } + // post code + if (isset($jsonAddr['postcode'])) { + $cityAddress .= ', ' . $jsonAddr['postcode']; + } + // country + if (isset($jsonAddr['country'])) { + $cityAddress .= ', ' . $jsonAddr['country']; + return $cityAddress; + } else { + return $json['display_name']; + } + } elseif (isset($json['display_name'])) { + return $json['display_name']; + } + return null; + } + + /** + * Set address and resolve it to get coordinates + * + * @param string $address Any approximative or exact address + * @return WeatherStatusLocationWithSuccess with success state and address information (coordinates and formatted address) + */ + public function setAddress(string $address): array { + $addressInfo = $this->searchForAddress($address); + if (isset($addressInfo['display_name']) && isset($addressInfo['lat']) && isset($addressInfo['lon'])) { + $formattedAddress = $this->formatOsmAddress($addressInfo); + $this->config->setUserValue($this->userId, Application::APP_ID, 'address', $formattedAddress); + $this->config->setUserValue($this->userId, Application::APP_ID, 'lat', strval($addressInfo['lat'])); + $this->config->setUserValue($this->userId, Application::APP_ID, 'lon', strval($addressInfo['lon'])); + $this->config->setUserValue($this->userId, Application::APP_ID, 'mode', strval(self::MODE_MANUAL_LOCATION)); + // get and store altitude + $altitude = $this->getAltitude(floatval($addressInfo['lat']), floatval($addressInfo['lon'])); + $this->config->setUserValue($this->userId, Application::APP_ID, 'altitude', strval($altitude)); + return [ + 'lat' => $addressInfo['lat'], + 'lon' => $addressInfo['lon'], + 'address' => $formattedAddress, + 'success' => true, + ]; + } else { + return ['success' => false]; + } + } + + /** + * Ask nominatim information about an unformatted address + * + * @param string Unformatted address + * @return array{display_name?: string, lat?: string, lon?: string, error?: string} Full Nominatim result for the given address + */ + private function searchForAddress(string $address): array { + $params = [ + 'q' => $address, + 'format' => 'json', + 'addressdetails' => '1', + 'extratags' => '1', + 'namedetails' => '1', + 'limit' => '1', + ]; + $url = 'https://nominatim.openstreetmap.org/search'; + $results = $this->requestJSON($url, $params); + + if (isset($results['error'])) { + return ['error' => (string)$results['error']]; + } + + if (count($results) > 0 && is_array($results[0])) { + return [ + 'display_name' => (string)($results[0]['display_name'] ?? null), + 'lat' => (string)($results[0]['lat'] ?? null), + 'lon' => (string)($results[0]['lon'] ?? null), + ]; + } + + return ['error' => $this->l10n->t('No result.')]; + } + + /** + * Get stored user location + * + * @return WeatherStatusLocationWithMode which contains coordinates, formatted address and current weather status mode + */ + public function getLocation(): array { + $lat = $this->config->getUserValue($this->userId, Application::APP_ID, 'lat', ''); + $lon = $this->config->getUserValue($this->userId, Application::APP_ID, 'lon', ''); + $address = $this->config->getUserValue($this->userId, Application::APP_ID, 'address', ''); + $mode = $this->config->getUserValue($this->userId, Application::APP_ID, 'mode', self::MODE_MANUAL_LOCATION); + return [ + 'lat' => $lat, + 'lon' => $lon, + 'address' => $address, + 'mode' => intval($mode), + ]; + } + + /** + * Get forecast for current location + * + * @return list<WeatherStatusForecast>|array{error: string}|WeatherStatusSuccess which contains success state and filtered forecast data + */ + public function getForecast(): array { + $lat = $this->config->getUserValue($this->userId, Application::APP_ID, 'lat', ''); + $lon = $this->config->getUserValue($this->userId, Application::APP_ID, 'lon', ''); + $alt = $this->config->getUserValue($this->userId, Application::APP_ID, 'altitude', ''); + if (!is_numeric($alt)) { + $alt = 0; + } + if (is_numeric($lat) && is_numeric($lon)) { + return $this->forecastRequest(floatval($lat), floatval($lon), floatval($alt)); + } else { + return ['success' => false]; + } + } + + /** + * Actually make the request to the forecast service + * + * @param float $lat Latitude of requested forecast, in decimal degree format + * @param float $lon Longitude of requested forecast, in decimal degree format + * @param float $altitude Altitude of requested forecast, in meter + * @param int $nbValues Number of forecast values (hours) + * @return list<WeatherStatusForecast>|array{error: string} Filtered forecast data + */ + private function forecastRequest(float $lat, float $lon, float $altitude, int $nbValues = 10): array { + $params = [ + 'lat' => number_format($lat, 2), + 'lon' => number_format($lon, 2), + 'altitude' => $altitude, + ]; + $url = 'https://api.met.no/weatherapi/locationforecast/2.0/compact'; + $weather = $this->requestJSON($url, $params); + if (isset($weather['properties']) && isset($weather['properties']['timeseries']) && is_array($weather['properties']['timeseries'])) { + return array_slice($weather['properties']['timeseries'], 0, $nbValues); + } + return ['error' => $this->l10n->t('Malformed JSON data.')]; + } + + /** + * Make a HTTP GET request and parse JSON result. + * Request results are cached until the 'Expires' response header says so + * + * @param string $url Base URL to query + * @param array $params GET parameters + * @return array which contains the error message or the parsed JSON result + */ + private function requestJSON(string $url, array $params = []): array { + $cacheKey = $url . '|' . implode(',', $params) . '|' . implode(',', array_keys($params)); + $cacheValue = $this->cache->get($cacheKey); + if ($cacheValue !== null) { + return $cacheValue; + } + + try { + $options = [ + 'headers' => [ + 'User-Agent' => 'NextcloudWeatherStatus/' . $this->version . ' nextcloud.com' + ], + ]; + + $reqUrl = $url; + if (count($params) > 0) { + $paramsContent = http_build_query($params); + $reqUrl = $url . '?' . $paramsContent; + } + + $response = $this->client->get($reqUrl, $options); + $body = $response->getBody(); + $headers = $response->getHeaders(); + $respCode = $response->getStatusCode(); + + if ($respCode >= 400) { + return ['error' => $this->l10n->t('Error')]; + } else { + $json = json_decode($body, true); + + // default cache duration is one hour + $cacheDuration = 60 * 60; + if (isset($headers['Expires']) && count($headers['Expires']) > 0) { + // if the Expires response header is set, use it to define cache duration + $expireTs = (new \DateTime($headers['Expires'][0]))->getTimestamp(); + $nowTs = (new \DateTime())->getTimestamp(); + $duration = $expireTs - $nowTs; + if ($duration > $cacheDuration) { + $cacheDuration = $duration; + } + } + $this->cache->set($cacheKey, $json, $cacheDuration); + + return $json; + } + } catch (\Exception $e) { + $this->logger->warning($url . ' API error : ' . $e->getMessage(), ['exception' => $e]); + return ['error' => $e->getMessage()]; + } + } +} |