From 63e3ed4a7831928a20a66f35392f8511c1e5fda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 29 Oct 2026 11:26:14 +0100 Subject: [PATCH] feat: the iCal contingent feed for the booking portals is back /contingents/.ical (page type 1710) serves the same events as on TYPO3 10: every run of BLOCKED or ON_REQUEST days with one status is one "Unavailable" event. External booking portals import it. The availability comes from MyE&P contingents/availability, the bpn-connect snapshot without the price rule of contingents/calendar (days without a price stay free here, as they did on TYPO3 10). TYPO3 10 read bpn-connect directly. Compared for SVS, DPW and DGS: the events are identical; the header differs only by the iCalcreator version (2.41 instead of 2.39) and its TZUNTIL, both RFC-conform. When MyE&P cannot answer, the feed returns 503 with Retry-After instead of an empty calendar, so the portals keep their last import rather than seeing every day as free. The response is sent past the page cache, which would drop Content-Disposition, and staticfilecache is off for the page type. Requires the MyE&P endpoint on my.ep-reisen.de before deploying; until then every feed answers 503. Co-Authored-By: Claude Opus 5.5 --- composer.lock | 110 ++++++++++++++++- .../Classes/Controller/IcalController.php | 47 ++++++++ .../ep_products/Classes/MyEP/ApiClient.php | 15 +++ .../Classes/Service/IcalService.php | 111 ++++++++++++++++++ .../Configuration/TypoScript/setup.typoscript | 7 +- packages/ep_products/composer.json | 1 + packages/ep_products/ext_localconf.php | 12 ++ 7 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 packages/ep_products/Classes/Controller/IcalController.php create mode 100644 packages/ep_products/Classes/Service/IcalService.php 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',