From 4d4baa11242d269fa56f11141e62d0d6b8931c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 21 Oct 2026 21:31:20 +0200 Subject: [PATCH] feat: port the snow report import (ep:import-snowreport) Replaces the TYPO3 10 scheduler task, which stopped with PHP 7.4. Product pages rendered by TYPO3 13 show the snow report of their region, so its data has to be kept current here. Plain DBAL instead of Extbase persistence; records are matched by vendor_uid and updated in place, so the regions keep their snow report. Importing the feed TYPO3 10 imported last leaves every column unchanged. The feed is kept in var/transient (shared) instead of fileadmin, which still belongs to the TYPO3 10 installation. Co-Authored-By: Claude Opus 5.5 --- .../Classes/Command/SnowreportCommand.php | 38 ++ .../Service/SnowreportImportService.php | 331 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 packages/ep_products/Classes/Command/SnowreportCommand.php create mode 100644 packages/ep_products/Classes/Service/SnowreportImportService.php diff --git a/packages/ep_products/Classes/Command/SnowreportCommand.php b/packages/ep_products/Classes/Command/SnowreportCommand.php new file mode 100644 index 0000000..53ff318 --- /dev/null +++ b/packages/ep_products/Classes/Command/SnowreportCommand.php @@ -0,0 +1,38 @@ +addOption('file', null, InputOption::VALUE_REQUIRED, 'Import this feed file instead of downloading'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $file = $input->getOption('file'); + if (null === $file && !$this->snowreportImportService->downloadFeed()) { + $output->writeln('Download failed, importing the previous feed (see the TYPO3 log).'); + } + + $count = $this->snowreportImportService->import($file); + $output->writeln(sprintf('%d snow reports imported.', $count)); + + return $count > 0 ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/packages/ep_products/Classes/Service/SnowreportImportService.php b/packages/ep_products/Classes/Service/SnowreportImportService.php new file mode 100644 index 0000000..78737ef --- /dev/null +++ b/packages/ep_products/Classes/Service/SnowreportImportService.php @@ -0,0 +1,331 @@ + column and type. XML has no types, so each field is declared here and converted + * strictly; unexpected values are logged and fall back to the type's empty value. + */ + private const MAP = [ + 'region_id' => ['column' => 'vendor_uid', 'type' => self::TYPE_INT], + 'region_name' => ['column' => 'region_name', 'type' => self::TYPE_STRING], + 'region_offen' => ['column' => 'opened', 'type' => self::TYPE_BOOL], + 'gletschergebiet' => ['column' => 'glacier', 'type' => self::TYPE_BOOL], + 'schneehoehe_berg' => ['column' => 'snow_level_mountain', 'type' => self::TYPE_INT], + 'schneehoehe_tal' => ['column' => 'snow_level_valley', 'type' => self::TYPE_INT], + 'schneequalitaet' => ['column' => 'snow_quality', 'type' => self::TYPE_STRING], + 'letzter_schneefall' => ['column' => 'last_snowfall', 'type' => self::TYPE_DATE], + 'lifte_gesamt' => ['column' => 'lifts_total', 'type' => self::TYPE_INT], + 'offene_lifte' => ['column' => 'lifts_opened', 'type' => self::TYPE_INT], + 'letzte_aktualisierung' => ['column' => 'last_update', 'type' => self::TYPE_DATE], + 'hoehemax' => ['column' => 'height_max', 'type' => self::TYPE_INT], + 'hoehemin' => ['column' => 'height_min', 'type' => self::TYPE_INT], + 'datum_saisonstart' => ['column' => 'season_start', 'type' => self::TYPE_DATE], + 'datum_saisonende' => ['column' => 'season_end', 'type' => self::TYPE_DATE], + ]; + + private const BOOLEANS = [ + 'true' => true, '1' => true, 'ja' => true, 'yes' => true, + 'false' => false, '0' => false, 'nein' => false, 'no' => false, + ]; + + /** @var array distinct conversion problems of the current import */ + private array $conversionWarnings = []; + + public function __construct( + private readonly ConnectionPool $connectionPool, + private readonly RequestFactory $requestFactory, + private readonly ExtensionConfiguration $extensionConfiguration, + private readonly CacheManager $cacheManager, + ) {} + + /** + * Last downloaded feed; kept in the shared var/transient so a failed download imports the previous one + */ + public function getFeedPath(): string + { + return Environment::getVarPath() . '/transient/ep_products_import/snowreport.xml'; + } + + /** + * Replaces the local feed only with a usable XML document; any failure keeps the existing file + */ + public function downloadFeed(): bool + { + $path = $this->getFeedPath(); + + try { + $response = $this->requestFactory->request(self::FEED_URL, 'GET', [ + 'connect_timeout' => self::HTTP_CONNECT_TIMEOUT, + 'timeout' => self::HTTP_TIMEOUT, + 'headers' => ['Accept' => 'application/xml, text/xml'], + 'http_errors' => false, + ]); + if (200 !== $response->getStatusCode()) { + throw new \RuntimeException(sprintf('unexpected status code %d', $response->getStatusCode()), 1758000001); + } + $xmlContent = (string)$response->getBody(); + $this->parseFeed($xmlContent); + } catch (\Throwable $e) { + $this->logger?->error(sprintf('Snowreport feed download failed (%s), keeping the existing file.', $e->getMessage())); + return false; + } + + // Written next to the target and moved into place: the file is either the old or the new document + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0775, true); + } + $temporaryPath = $path . '.tmp'; + if (false === file_put_contents($temporaryPath, $xmlContent) || !rename($temporaryPath, $path)) { + @unlink($temporaryPath); + $this->logger?->error(sprintf('Snowreport feed could not be written to "%s".', $path)); + return false; + } + + return true; + } + + /** + * @return int number of snow reports imported + */ + public function import(?string $file = null): int + { + $file ??= $this->getFeedPath(); + if (!is_file($file)) { + $this->logger?->error(sprintf('Snowreport import failed: file "%s" does not exist.', $file)); + return 0; + } + + try { + $xmlData = $this->parseFeed((string)file_get_contents($file)); + } catch (\RuntimeException $e) { + $this->logger?->error(sprintf('Snowreport import failed: %s', $e->getMessage())); + return 0; + } + + $this->conversionWarnings = []; + $pid = (int)($this->extensionConfiguration->get('ep_products')['importStoragePid'] ?? 0); + $now = time(); + $snowreports = $this->loadIndex(self::SNOWREPORT_TABLE); + $webcams = $this->loadIndex(self::WEBCAM_TABLE); + $validSnowreports = []; + $validWebcams = []; + $snowreportConnection = $this->connectionPool->getConnectionForTable(self::SNOWREPORT_TABLE); + $webcamConnection = $this->connectionPool->getConnectionForTable(self::WEBCAM_TABLE); + + $count = 0; + foreach ($xmlData->schneemeldung as $reportXml) { + $row = []; + foreach (self::MAP as $key => $field) { + if (!isset($reportXml->{$key})) { + $this->conversionWarnings[sprintf('Element <%s> missing in feed.', $key)] = true; + $row[$field['column']] = $this->getEmptyValue($field['type']); + continue; + } + $row[$field['column']] = $this->convertValue((string)$reportXml->{$key}, $field['type'], $key); + } + $vendorUid = (int)$row['vendor_uid']; + $row = array_map(fn ($value) => is_bool($value) ? (int)$value : $value, $row); + $row['tstamp'] = $now; + + if (isset($snowreports[$vendorUid])) { + $snowreportUid = $snowreports[$vendorUid]; + $snowreportConnection->update(self::SNOWREPORT_TABLE, $row, ['uid' => $snowreportUid]); + } else { + $snowreportConnection->insert(self::SNOWREPORT_TABLE, $row + ['pid' => $pid, 'crdate' => $now]); + $snowreportUid = (int)$snowreportConnection->lastInsertId(); + $snowreports[$vendorUid] = $snowreportUid; + } + $validSnowreports[] = $vendorUid; + + foreach ($reportXml->webcam ? $reportXml->webcam->children() : [] as $item) { + $include = $this->convertValue((string)$item->aktuell, self::TYPE_BOOL, 'aktuell'); + $available = $this->convertValue((string)$item->verf, self::TYPE_BOOL, 'verf'); + $webcamVendorUid = (string)(int)$item->uid; + + // Cams that are neither current nor already known are skipped + if (!isset($webcams[$webcamVendorUid]) && !$include) { + continue; + } + $webcamRow = [ + 'url' => (string)$item->url, + 'name' => (string)$item->dt, + 'available' => (int)($available && $include), + 'tstamp' => $now, + ]; + if (isset($webcams[$webcamVendorUid])) { + // Known cams stay with the snow report they were first imported for, as in TYPO3 10 + $webcamConnection->update(self::WEBCAM_TABLE, $webcamRow, ['uid' => $webcams[$webcamVendorUid]]); + } else { + $webcamConnection->insert(self::WEBCAM_TABLE, $webcamRow + [ + 'pid' => $pid, + 'crdate' => $now, + 'vendor_uid' => $webcamVendorUid, + 'snowreport' => $snowreportUid, + ]); + $webcams[$webcamVendorUid] = (int)$webcamConnection->lastInsertId(); + } + $validWebcams[] = $webcamVendorUid; + } + + $count++; + } + + $this->flagObsolete(self::WEBCAM_TABLE, array_keys($webcams), $validWebcams, $now); + $this->flagObsolete(self::SNOWREPORT_TABLE, array_keys($snowreports), $validSnowreports, $now); + // Inline child counter, which the plain SQL above does not maintain + $snowreportConnection->executeStatement( + 'UPDATE ' . self::SNOWREPORT_TABLE . ' s SET s.webcams = (' + . ' SELECT COUNT(*) FROM ' . self::WEBCAM_TABLE . ' w WHERE w.snowreport = s.uid AND w.deleted = 0)' + ); + + // Product pages show the snow report of their region + $this->cacheManager->flushCachesInGroup('pages'); + + if ([] !== $this->conversionWarnings) { + $messages = array_keys($this->conversionWarnings); + $this->logger?->warning(sprintf('Snowreport import: %d unexpected value(s) in feed: %s', count($messages), implode(' | ', array_slice($messages, 0, 10)))); + } + $this->logger?->info(sprintf('Snowreport import successful (%d records).', $count)); + + return $count; + } + + /** + * Rejects unusable documents: an empty or truncated feed would otherwise flag every record as deleted + */ + private function parseFeed(string $xmlContent): \SimpleXMLElement + { + if ('' === trim($xmlContent)) { + throw new \RuntimeException('response is empty', 1758000002); + } + + $useInternalErrors = libxml_use_internal_errors(true); + libxml_clear_errors(); + $xmlData = simplexml_load_string($xmlContent); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($useInternalErrors); + + if (false === $xmlData) { + $message = count($errors) > 0 ? trim($errors[0]->message) : 'unknown error'; + throw new \RuntimeException(sprintf('response is not valid XML (%s)', $message), 1758000003); + } + if (0 === $xmlData->schneemeldung->count()) { + throw new \RuntimeException('response contains no elements', 1758000004); + } + + return $xmlData; + } + + /** + * Not deleted records (hidden ones included: the import still manages them) as vendor_uid => uid + */ + private function loadIndex(string $table): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction()); + $index = []; + foreach ($queryBuilder->select('uid', 'vendor_uid')->from($table)->orderBy('uid')->executeQuery()->fetchAllAssociative() as $row) { + $index[(string)$row['vendor_uid']] ??= (int)$row['uid']; + } + + return $index; + } + + private function flagObsolete(string $table, array $currentVendorUids, array $validVendorUids, int $now): void + { + $obsolete = array_values(array_diff(array_map('strval', $currentVendorUids), array_map('strval', $validVendorUids))); + if ([] === $obsolete) { + return; + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder + ->update($table) + ->set('deleted', 1) + ->set('tstamp', $now) + ->where($queryBuilder->expr()->in('vendor_uid', $queryBuilder->createNamedParameter($obsolete, Connection::PARAM_STR_ARRAY))) + ->executeStatement(); + } + + private function convertValue(string $raw, string $type, string $key): int|string|bool + { + $raw = trim($raw); + + // Empty elements mean "not available yet" (snow levels out of season), which is expected + if ('' === $raw) { + return $this->getEmptyValue($type); + } + + switch ($type) { + case self::TYPE_STRING: + return $raw; + case self::TYPE_INT: + if (!preg_match('/^-?\d+$/', $raw)) { + $this->conversionWarnings[sprintf('<%s> expected an integer, got "%s".', $key, $raw)] = true; + return 0; + } + return (int)$raw; + case self::TYPE_BOOL: + $normalized = strtolower($raw); + if (!array_key_exists($normalized, self::BOOLEANS)) { + $this->conversionWarnings[sprintf('<%s> expected a boolean, got "%s".', $key, $raw)] = true; + return false; + } + return self::BOOLEANS[$normalized]; + case self::TYPE_DATE: + if (!preg_match('/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$/', $raw)) { + $this->conversionWarnings[sprintf('<%s> expected a date, got "%s".', $key, $raw)] = true; + return 0; + } + return (new \DateTime($raw))->getTimestamp(); + } + + throw new \InvalidArgumentException(sprintf('Unknown field type "%s" declared for <%s>.', $type, $key), 1758000000); + } + + private function getEmptyValue(string $type): int|string|bool + { + return match ($type) { + self::TYPE_STRING => '', + self::TYPE_BOOL => false, + default => 0, + }; + } +}