diff --git a/composer.lock b/composer.lock index 153379a..3ef2e09 100644 --- a/composer.lock +++ b/composer.lock @@ -907,9 +907,10 @@ "dist": { "type": "path", "url": "./packages/ep_products", - "reference": "291793cfe2a2d5f50bf5bee13f286e501d633a49" + "reference": "996ab0f6fc00a993ca97984d512bd2de6a8eabeb" }, "require": { + "kigkonsult/icalcreator": "^2.41", "league/oauth2-client": "^2.9", "league/period": "^4.12", "php": "^8.2", @@ -1634,6 +1635,113 @@ ], "time": "2026-10-17T23:57:19+00:00" }, + { + "name": "kigkonsult/icalcreator", + "version": "v2.41.92", + "source": { + "type": "git", + "url": "https://github.com/iCalcreator/iCalcreator.git", + "reference": "a4d35d7a58c08b816dc8a7778db19f461c1429bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iCalcreator/iCalcreator/zipball/a4d35d7a58c08b816dc8a7778db19f461c1429bd", + "reference": "a4d35d7a58c08b816dc8a7778db19f461c1429bd", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "ext-zlib": "*", + "php": ">=8.0" + }, + "require-dev": { + "degraciamathieu/php-arguments-detector": ">=0.5.0", + "ext-xdebug": "*", + "phpcompatibility/php-compatibility": ">=9.3.5", + "phpmd/phpmd": ">=2.13.0", + "phpstan/phpstan": ">=0.9.3", + "phpunit/phpunit": ">=6.5.13", + "squizlabs/php_codesniffer": ">=3.5.5", + "vimeo/psalm": "*" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "Kigkonsult\\Icalcreator\\": [ + "src/" + ] + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Kjell-Inge Gustafsson", + "email": "ical@kigkonsult.se" + } + ], + "description": "iCalcreator is the PHP implementation of rfc2445/rfc5545 and rfc updates, management of calendar information", + "keywords": [ + "alarm", + "availability", + "calendar", + "daylight", + "event", + "ical", + "json", + "location", + "management", + "participant", + "resource", + "rfc2445", + "rfc5545", + "rfc5870", + "rfc6321", + "rfc6868", + "rfc7529", + "rfc7808", + "rfc7953", + "rfc7986", + "rfc9073", + "rfc9074", + "standard", + "todo", + "vCalendar", + "valarm", + "vevent", + "vfreebusy", + "vjournal", + "vtimezone", + "vtodo", + "xml" + ], + "support": { + "issues": "https://github.com/iCalcreator/iCalcreator/issues", + "source": "https://github.com/iCalcreator/iCalcreator/tree/v2.41.92" + }, + "funding": [ + { + "url": "https://paypal.me/kigkonsult", + "type": "other" + }, + { + "url": "https://www.buymeacoffee.com/kigkonsult", + "type": "other" + } + ], + "time": "2025-01-17T10:06:29+00:00" + }, { "name": "league/oauth2-client", "version": "2.9.1", diff --git a/packages/ep_products/Classes/Controller/IcalController.php b/packages/ep_products/Classes/Controller/IcalController.php new file mode 100644 index 0000000..62a27ef --- /dev/null +++ b/packages/ep_products/Classes/Controller/IcalController.php @@ -0,0 +1,47 @@ +icalService->createContingentsFeed($hotel); + + // An empty calendar would show the portals every day as free; an error keeps their last import + if (null === $calendar) { + throw new PropagateResponseException( + $this->responseFactory->createResponse(503) + ->withHeader('Content-Type', 'text/plain; charset=utf-8') + ->withHeader('Cache-Control', 'private, no-store') + ->withHeader('Retry-After', '900') + ->withBody($this->streamFactory->createStream('Availability temporarily unavailable')), + 1761730800 + ); + } + + throw new PropagateResponseException( + $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/calendar;charset=UTF-8') + ->withHeader('Content-Disposition', sprintf('attachment; filename=%s.ical', $hotel->getPathSegment())) + ->withHeader('Cache-Control', 'private, no-store') + ->withBody($this->streamFactory->createStream($calendar)), + 1761730801 + ); + } +} diff --git a/packages/ep_products/Classes/MyEP/ApiClient.php b/packages/ep_products/Classes/MyEP/ApiClient.php index f9d8331..4736e06 100644 --- a/packages/ep_products/Classes/MyEP/ApiClient.php +++ b/packages/ep_products/Classes/MyEP/ApiClient.php @@ -207,6 +207,21 @@ class ApiClient implements LoggerAwareInterface ]); } + /** + * One entry per day the snapshot has ('date', 'status': OK, BLOCKED or ON_REQUEST), today until the + * end of the snapshot; unlike contingents/calendar, days without a price keep their status + * + * @throws ApiException + */ + public function getContingentAvailability(string $hotelCode): array + { + return $this->requestJson('GET', 'contingents/availability', [ + 'query' => [ + 'hotelCode' => $hotelCode, + ], + ]); + } + /** * @throws ApiException */ diff --git a/packages/ep_products/Classes/Service/IcalService.php b/packages/ep_products/Classes/Service/IcalService.php new file mode 100644 index 0000000..1877571 --- /dev/null +++ b/packages/ep_products/Classes/Service/IcalService.php @@ -0,0 +1,111 @@ +getCode()); + + if ('' !== $hotelCode) { + try { + $calendarRows = $this->apiClient->getContingentAvailability($hotelCode); + } catch (ApiException $e) { + $this->logger?->error(sprintf('iCal: no availability for hotel %s', $hotelCode), ['exception' => $e]); + return null; + } + } + + $vcalendar = Vcalendar::factory([Vcalendar::UNIQUE_ID => 'www.ep-reisen.de']); + $vcalendar->setXprop(Vcalendar::X_WR_TIMEZONE, 'Europe/Berlin'); + + foreach ($this->buildRanges($calendarRows) as $range) { + $vcalendar + ->newVevent() + ->setDtstart($range['from']) + ->setDtend($range['to']->modify('+1 day')) + ->setSummary(self::SUMMARY_UNAVAILABLE); + } + + return $vcalendar->vtimezonePopulate()->createCalendar(); + } + + /** + * @return array unavailable runs, both ends inclusive + */ + private function buildRanges(array $calendarRows): array + { + $rows = []; + foreach ($calendarRows as $row) { + if (!is_array($row) || !isset($row['date'])) { + continue; + } + $date = new \DateTimeImmutable($row['date']); + $rows[$date->format('Y-m-d')] = ['date' => $date, 'status' => $this->normalizeStatus((string)($row['status'] ?? 'OK'))]; + } + ksort($rows); + + $ranges = []; + $current = null; + $lastDate = null; + foreach ($rows as $row) { + $isGap = $lastDate instanceof \DateTimeImmutable && $row['date'] > $lastDate->modify('+1 day'); + if (null !== $current && ($isGap || 'OK' === $row['status'] || $current['status'] !== $row['status'])) { + $ranges[] = $current; + $current = null; + } + if ('OK' !== $row['status']) { + if (null === $current) { + $current = ['from' => $row['date'], 'to' => $row['date'], 'status' => $row['status']]; + } else { + $current['to'] = $row['date']; + } + } + $lastDate = $row['date']; + } + if (null !== $current) { + $ranges[] = $current; + } + + return array_map(fn (array $range): array => ['from' => $range['from'], 'to' => $range['to']], $ranges); + } + + private function normalizeStatus(string $status): string + { + $status = strtoupper(trim($status)); + if ('ONREQUEST' === $status) { + return 'ON_REQUEST'; + } + + return in_array($status, ['OK', 'ON_REQUEST', 'BLOCKED'], true) ? $status : 'OK'; + } +} diff --git a/packages/ep_products/Configuration/TypoScript/setup.typoscript b/packages/ep_products/Configuration/TypoScript/setup.typoscript index b80e7cc..d10d67c 100644 --- a/packages/ep_products/Configuration/TypoScript/setup.typoscript +++ b/packages/ep_products/Configuration/TypoScript/setup.typoscript @@ -232,6 +232,7 @@ tx_epproducts_ical { xhtml_cleaning = 0 admPanel = 0 debug = 0 + tx_staticfilecache.disableCache = 1 } 10 = USER @@ -239,14 +240,8 @@ tx_epproducts_ical { userFunc = TYPO3\CMS\Extbase\Core\Bootstrap->run extensionName = EpProducts pluginName = ical - vendorName = EP controller = Ical action = contingents - switchableControllerActions { - Ical { - 1 = contingents - } - } } } diff --git a/packages/ep_products/composer.json b/packages/ep_products/composer.json index 2319cc5..a3ed447 100644 --- a/packages/ep_products/composer.json +++ b/packages/ep_products/composer.json @@ -11,6 +11,7 @@ ], "require": { "php": "^8.2", + "kigkonsult/icalcreator": "^2.41", "league/oauth2-client": "^2.9", "league/period": "^4.12", "symfony/http-client": "^7.4", diff --git a/packages/ep_products/ext_localconf.php b/packages/ep_products/ext_localconf.php index af6587b..dc9c961 100644 --- a/packages/ep_products/ext_localconf.php +++ b/packages/ep_products/ext_localconf.php @@ -7,6 +7,7 @@ use EP\EpProducts\Controller\ConceptController; use EP\EpProducts\Controller\CountryController; use EP\EpProducts\Controller\FaqController; use EP\EpProducts\Controller\HotelController; +use EP\EpProducts\Controller\IcalController; use EP\EpProducts\Controller\MapController; use EP\EpProducts\Controller\NewsletterController; use EP\EpProducts\Controller\PricetableController; @@ -203,6 +204,17 @@ $boot = function (): void { ] ); + ExtensionUtility::configurePlugin( + 'EpProducts', + 'ical', + [ + IcalController::class => 'contingents', + ], + [ + IcalController::class => 'contingents', + ] + ); + ExtensionUtility::configurePlugin( 'EpProducts', 'searchresult',