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, + }; + } +}