feat: the iCal contingent feed for the booking portals is back
/contingents/<hotel>.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 <[email protected]>
This commit is contained in:
1 parent
9aabff6b5b
commit
63e3ed4a78
7 files changed
+296
-7
No files matched your search
Generated
+109
-1
@@ -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": "[email protected]"
|
||||
}
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Controller;
|
||||
|
||||
use EP\EpProducts\Domain\Model\Hotel;
|
||||
use EP\EpProducts\Service\IcalService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use TYPO3\CMS\Core\Http\PropagateResponseException;
|
||||
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
|
||||
|
||||
class IcalController extends ActionController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IcalService $icalService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed is sent as a response of its own, past the page cache: a cached page would lose the
|
||||
* Content-Disposition header, and availability must not be older than the MyE&P snapshot
|
||||
*/
|
||||
public function contingentsAction(Hotel $hotel): ResponseInterface
|
||||
{
|
||||
$calendar = $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
use EP\EpProducts\Domain\Model\Hotel;
|
||||
use EP\EpProducts\MyEP\ApiClient;
|
||||
use EP\EpProducts\MyEP\ApiException;
|
||||
use Kigkonsult\Icalcreator\Vcalendar;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
|
||||
/**
|
||||
* Contingent feed of a hotel for the booking portals, with the events of TYPO3 10
|
||||
*
|
||||
* The availability comes from MyE&P (contingents/availability, the bpn-connect snapshot without the
|
||||
* price rule of contingents/calendar); TYPO3 10 read it from bpn-connect directly. Every run of
|
||||
* BLOCKED or ON_REQUEST days with one status becomes one "Unavailable" event, as bpn-connect's
|
||||
* ranges did.
|
||||
*/
|
||||
class IcalService implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
private const string SUMMARY_UNAVAILABLE = 'Unavailable';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null the calendar, null when the availability is unknown
|
||||
*/
|
||||
public function createContingentsFeed(Hotel $hotel): ?string
|
||||
{
|
||||
$calendarRows = [];
|
||||
$hotelCode = trim((string)$hotel->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<int, array{from: \DateTimeImmutable, to: \DateTimeImmutable}> 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user