feat: restore the disturber content element, port the groups.swiss export

The 404 page (and other pages TYPO3 13 renders) contain disturber elements,
which logged 'no rendering definition' on every 404. The CType is back with
its TypoScript, templates and FlexForm; the TCA uses the TYPO3 13 API.

ep:groups-swiss replaces the TYPO3 10 scheduler tasks, which stopped with
PHP 7.4. It reads the calendar from MyE&P (contingents/calendar, synced from
bpn-connect) instead of bpn-connect directly, so it needs no new credentials,
and publishes all houses from groupsSwissHouses in secrets.php (or one house
given as arguments, as the scheduler tasks did). The ranges match the TYPO3 10
algorithm on 2000 random calendars; --dry-run shows them without sending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-21 21:25:26 +02:00
1 parent cdf04823b0
commit 42e02095ce
12 files changed
+575

No files matched your search

+5
View File
@@ -28,6 +28,11 @@ return [
'myEpApiClientSecret' => '',
'myEpApiUrlAuthorize' => '',
'myEpApiUrlToken' => '',
// ep:groups-swiss (cron, daily): availability of the group houses on groups.swiss.
// Production only; the keys are in the TYPO3 10 scheduler tasks 16-21.
// 'groupsSwissHouses' => [
// ['hotel' => 5, 'house' => '9850', 'key' => ''],
// ],
],
],
];
@@ -0,0 +1,72 @@
<?php
namespace EP\EpProducts\Command;
use EP\EpProducts\Service\GroupsSwissService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
/**
* Without arguments all houses from $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['ep_products']['groupsSwissHouses']
* (secrets.php) are published; with arguments a single one, as the TYPO3 10 scheduler tasks did
*/
#[AsCommand(name: 'ep:groups-swiss', description: 'Publishes group house availability (from MyE&P) to groups.swiss')]
class GroupsSwissCommand extends Command
{
public function __construct(
private readonly GroupsSwissService $groupsSwissService,
private readonly ExtensionConfiguration $extensionConfiguration,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('hotel', InputArgument::OPTIONAL, 'E&P hotel uid')
->addArgument('house', InputArgument::OPTIONAL, 'groups.swiss house number')
->addArgument('key', InputArgument::OPTIONAL, 'groups.swiss API key')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show the ranges, send nothing to groups.swiss');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (null !== $input->getArgument('hotel')) {
if (null === $input->getArgument('house') || null === $input->getArgument('key')) {
$output->writeln('<error>hotel, house and key are needed together.</error>');
return Command::INVALID;
}
$houses = [['hotel' => $input->getArgument('hotel'), 'house' => $input->getArgument('house'), 'key' => $input->getArgument('key')]];
} else {
$houses = $this->extensionConfiguration->get('ep_products')['groupsSwissHouses'] ?? [];
if (!is_array($houses) || [] === $houses) {
$output->writeln('No groups.swiss houses configured (groupsSwissHouses in secrets.php).');
return Command::SUCCESS;
}
}
$dryRun = (bool)$input->getOption('dry-run');
$failed = 0;
foreach ($houses as $house) {
$ranges = $this->groupsSwissService->publishContingents((int)($house['hotel'] ?? 0), (string)($house['house'] ?? ''), (string)($house['key'] ?? ''), $dryRun);
if (null === $ranges) {
$output->writeln(sprintf('<error>Hotel %s / house %s: failed, see the TYPO3 log.</error>', $house['hotel'] ?? '?', $house['house'] ?? '?'));
$failed++;
continue;
}
$output->writeln(sprintf('Hotel %s / house %s: %d ranges%s', $house['hotel'], $house['house'], count($ranges), $dryRun ? ' (dry run)' : ' sent'));
if ($output->isVerbose() || $dryRun) {
foreach ($ranges as $range) {
$output->writeln(sprintf(' %s – %s state %d', $range['from'], $range['to'], $range['state']));
}
}
}
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -187,6 +187,22 @@ class ApiClient implements LoggerAwareInterface
]);
}
/**
* One entry per day ('date', 'status': OK, BLOCKED or ON_REQUEST) for the given range
*
* @throws ApiException
*/
public function getContingentCalendar(string $hotelCode, \DateTimeInterface $dateFrom, \DateTimeInterface $dateTo): array
{
return $this->requestJson('GET', 'contingents/calendar', [
'query' => [
'hotelCode' => $hotelCode,
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
],
]);
}
/**
* @throws ApiException
*/
@@ -0,0 +1,165 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Repository\HotelRepository;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Publishes the availability of a group house to its groups.swiss availability chart
*
* The calendar comes from MyE&P (contingents/calendar, synced from bpn-connect); TYPO3 10 read it
* from bpn-connect directly. The chart is reset to "free" for the next year, then every run of
* BLOCKED / ON_REQUEST days is sent as one range.
*/
class GroupsSwissService implements LoggerAwareInterface
{
use LoggerAwareTrait;
private const API_URL = 'https://www.groups.swiss/api/1.0/json.php';
private const STATE_FREE = 0;
private const STATE_ON_REQUEST = 1;
private const STATE_BLOCKED = 2;
private HttpClientInterface $httpClient;
public function __construct(
private readonly HotelRepository $hotelRepository,
private readonly ApiClient $apiClient,
) {
$this->httpClient = HttpClient::create(['timeout' => 10, 'max_duration' => 30]);
}
/**
* @return array<int, array{from: string, to: string, state: int}>|null the ranges sent, null on failure
*/
public function publishContingents(int $hotelUid, string $houseNumber, string $apiKey, bool $dryRun = false): ?array
{
$hotel = $this->hotelRepository->findByUid($hotelUid);
if (null === $hotel) {
$this->logger?->error(sprintf('groups.swiss: hotel with uid %d not found', $hotelUid));
return null;
}
$hotelCode = trim((string)$hotel->getCode());
if ('' === $hotelCode) {
$this->logger?->error(sprintf('groups.swiss: hotel with uid %d has no hotel code', $hotelUid));
return null;
}
$from = new \DateTimeImmutable('today');
$to = $from->modify('+1 year');
try {
// The last day of the range is reset below but not asked for, as in TYPO3 10
$calendarRows = $this->apiClient->getContingentCalendar($hotelCode, $from, $to->modify('-1 day'));
} catch (ApiException $e) {
$this->logger?->error(sprintf('groups.swiss: no calendar for hotel %s', $hotelCode), ['exception' => $e]);
return null;
}
$ranges = [['from' => $from, 'to' => $to, 'state' => self::STATE_FREE]];
foreach ($this->buildRanges($calendarRows, $from, $to) as $range) {
$ranges[] = $range;
}
$sent = [];
foreach ($ranges as $range) {
$item = ['from' => $range['from']->format('Y-m-d'), 'to' => $range['to']->format('Y-m-d'), 'state' => $range['state']];
if (!$dryRun) {
$this->addItem($item, $houseNumber, $apiKey);
}
$sent[] = $item;
}
return $sent;
}
/**
* Consecutive days with the same status other than OK become one range; a gap in the
* calendar ends a range as well
*
* @return array<int, array{from: \DateTimeImmutable, to: \DateTimeImmutable, state: int}>
*/
private function buildRanges(array $calendarRows, \DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$rows = [];
foreach ($calendarRows as $row) {
if (!is_array($row) || !isset($row['date'])) {
continue;
}
$date = new \DateTimeImmutable($row['date']);
if ($date < $from || $date >= $to) {
continue;
}
$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 = null !== $lastDate && $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'],
'state' => 'ON_REQUEST' === $range['status'] ? self::STATE_ON_REQUEST : self::STATE_BLOCKED,
], $ranges);
}
private function addItem(array $item, string $houseNumber, string $apiKey): void
{
$this->logger?->info(sprintf('groups.swiss: state %d from %s to %s for house %s', $item['state'], $item['from'], $item['to'], $houseNumber));
try {
$response = $this->httpClient->request('GET', self::API_URL, [
'query' => [
'apiKey' => $apiKey,
'method' => 'setAvailabilityChart',
'house' => $houseNumber,
'from' => $item['from'],
'till' => $item['to'],
'state' => $item['state'],
],
]);
$this->logger?->info('groups.swiss: ' . $response->getContent(false));
} catch (ExceptionInterface $e) {
$this->logger?->error(sprintf('groups.swiss: request failed for house %s: %s', $houseNumber, $e->getMessage()));
}
}
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';
}
}
@@ -0,0 +1,64 @@
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<TCEforms>
<sheetTitle>Konfiguration</sheetTitle>
</TCEforms>
<type>array</type>
<el>
<buttons>
<section>1</section>
<type>array</type>
<el>
<container>
<title>Button</title>
<type>array</type>
<el>
<label>
<TCEforms>
<label>Label</label>
<config>
<type>input</type>
<size>32</size>
</config>
</TCEforms>
</label>
<link>
<TCEforms>
<exclude>1</exclude>
<label>Button</label>
<config>
<type>input</type>
<eval>trim</eval>
<size>30</size>
<wizards type="array">
<_PADDING type="integer">2</_PADDING>
<link type="array">
<type>popup</type>
<title>Link</title>
<icon>EXT:backend/Resources/Public/Images/FormFieldWizard/wizard_link.gif</icon>
<module>
<name>wizard_link</name>
<urlParameters>
<mode>wizard</mode>
</urlParameters>
</module>
<JSopenParams>height=300,width=500,status=0,menubar=0,scrollbars=1</JSopenParams>
</link>
</wizards>
</config>
</TCEforms>
</link>
</el>
</container>
</el>
</buttons>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>
@@ -0,0 +1,61 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
defined('TYPO3') or die();
call_user_func(function () {
$frontendLanguageFilePrefix = 'LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:';
ExtensionManagementUtility::addTcaSelectItem(
'tt_content',
'CType',
[
'label' => 'Störer',
'value' => 'disturber',
'icon' => 'content-image'
],
'teaser_product',
'after'
);
$GLOBALS['TCA']['tt_content']['types']['disturber'] = [
'showitem' => '
--palette--;' . $frontendLanguageFilePrefix . 'palette.general;general,
--palette--;' . $frontendLanguageFilePrefix . 'palette.header;header, bodytext, pi_flexform,
--div--;Bild,
assets,
--div--;' . $frontendLanguageFilePrefix . 'tabs.appearance,
layout;' . $frontendLanguageFilePrefix . 'layout_formlabel, tx_eptheme_background,
--div--;' . $frontendLanguageFilePrefix . 'tabs.access,
hidden;' . $frontendLanguageFilePrefix . 'field.default.hidden,
--div--;' . $frontendLanguageFilePrefix . 'tabs.extended
',
'columnsOverrides' => [
'bodytext' => [
'label' => 'Text',
'config' => [
'enableRichtext' => true,
]
],
'assets' => [
'label' => 'Bild',
'config' => [
'type' => 'file',
'allowed' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'],
'appearance' => [
'createNewRelationLinkTitle' => 'Hinzufügen'
],
'maxitems' => 1,
'minitems' => 0,
]
]
]
];
ExtensionManagementUtility::addPiFlexFormValue(
'*',
'FILE:EXT:ep_theme/Configuration/FlexForms/flexform_disturber.xml',
'disturber'
);
});
@@ -0,0 +1,8 @@
tt_content.disturber =< lib.contentElement
tt_content.disturber {
templateName = Disturber
dataProcessing {
10 = EP\EpTheme\DataProcessing\TeaserImageProcessor
20 = EP\EpTheme\DataProcessing\FlexFormProcessor
}
}
@@ -0,0 +1,64 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="{ep:themeClasses(classes: '{ep: \'bg-ep-primary-dark\', sbw: \'bg-sbw-primary/80\', snz: \'bg-snz-primary/80\', suz: \'bg-suz-primary/80\', uch: \'bg-uch-primary/80\'}', key: settings.themekey)} my-2">
<f:if condition="{settings.themeKey} == 'ep'">
<f:render partial="Disrupter/Outlines"
section="Top1"
arguments="{classes: 'hidden md:block -translate-y-2 {f:if(condition: \'{background} == 1 || {background} == 3\', then: \'text-ep-primary-bg\', else: \'text-white\')}'}"/>
</f:if>
<div class="relative container grid grid-cols-2 gap-8 py-8">
<div class="col-span-2 md:col-span-1">
<div class="relative max-w-md mx-auto mb-8">
<f:if condition="{teaserImage}">
<f:image image="{teaserImage}"
loading="lazy"
class="block w-full h-auto rounded-full"
width="400"
height="400c"
alt="{teaserImage.alternative}" />
</f:if>
<a href="tel:{settings.phoneNumber -> v:format.pregReplace(pattern: '/[\-\s]/', replacement: '')}"
rel="nofollow"
class="absolute bottom-0 left-1/2 -translate-x-1/2 -mb-8 rounded-full py-4 px-6 flex items-center space-x-2 {ep:themeClasses(classes: '{ep: \'bg-ep-secondary\', sbw: \'bg-sbw-secondary\', snz: \'bg-snz-secondary\', suz: \'bg-suz-secondary\', uch: \'bg-uch-secondary\'}', key: settings.themekey)}">
{ep:icon(icon: 'phone', class: 'w-10 h-10 text-white')}
<div class="text-center text-white">
<span class="block whitespace-nowrap">
Ihr habt Fragen?
</span>
<span class="block whitespace-nowrap">
Wir helfen euch gerne!
</span>
</div>
</a>
</div>
</div>
<div class="col-span-2 md:col-span-1" data-rte-content>
<span class="headline--secondary font-light text-white">{data.subheader}</span>
<span class="headline--secondary uppercase font-light text-white mb-4">{data.header}</span>
<div class="text-white" data-rte-content>
{data.bodytext -> f:format.html()}
</div>
<f:if condition="{data.flexForm.buttons}">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 lg:gap-8">
<f:for each="{data.flexForm.buttons}" as="button">
<f:link.typolink parameter="{button.container.link}"
class="button bg-button--secondary shadow-lg">
{button.container.label}
</f:link.typolink>
</f:for>
</div>
</f:if>
</div>
</div>
<f:if condition="{settings.themeKey} == 'ep'">
<f:render partial="Disrupter/Outlines"
section="Bottom1"
arguments="{classes: 'hidden md:block sbw:md:hidden translate-y-2 {f:if(condition: \'{background} == 2 || {background} == 3\', then: \'text-ep-primary-bg\', else: \'text-white\')}'}"/>
</f:if>
</div>
</html>
@@ -0,0 +1,21 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="container-fluid ep-disturber-last-chance">
<div data-rte-content>
{data.header}
</div>
<div data-rte-content>
{data.bodytext -> f:format.html()}
</div>
<div>
{data.flexForm.buttons -> v:iterator.first() -> v:variable.set(name: 'button')}
<a href="{f:uri.typolink(parameter: button.container.link)}" class="button bg-button--secondary">
{button.container.label -> f:format.raw()}
</a>
</div>
</div>
</html>
@@ -0,0 +1,32 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:alias map="{button: data.flexForm.buttons.0}">
<div class="container-fluid ep-disturber-reminder">
<div data-rte-content>
{data.header}
</div>
<div class="col-md-6">
<a href="{f:uri.typolink(parameter: button.container.link)}" title="{button.container.label -> f:format.raw()}">
<f:if condition="{teaserImage}">
<f:image image="{teaserImage}"
loading="lazy"
class="block w-full h-auto rounded-full"
width="400"
alt="{teaserImage.alternative}" />
</f:if>
</a>
</div>
<div class="col-md-6" data-rte-content>
<div>
{data.bodytext -> f:format.html()}
</div>
</div>
<a href="{f:uri.typolink(parameter: button.container.link)}" class="btn btn-info">
{button.container.label -> f:format.raw()}
</a>
</div>
</f:alias>
</html>
@@ -0,0 +1,42 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:ep="http://typo3.org/ns/EP/EpTheme/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<div class="{ep:themeClasses(classes: '{ep: \'bg-ep-primary-dark\', sbw: \'bg-sbw-primary/80\', snz: \'bg-snz-primary/80\', suz: \'bg-suz-primary/80\', uch: \'bg-uch-primary/80\'}', key: settings.themekey)} my-2">
<f:render partial="Disrupter/Outlines"
section="Top1"
arguments="{classes: 'hidden md:block -translate-y-2 {f:if(condition: \'{data.tx_eptheme_background} == 1 || {data.tx_eptheme_background} == 3\', then: \'text-ep-primary-bg\', else: \'text-white\')}'}"/>
<div class="max-w-screen-lg mx-auto">
<div class="md:flex md:items-start py-4">
<div class="px-16 pb-4 md:px-4 md:pb-0 md:w-1/6">
<f:if condition="{teaserImage}">
<f:image image="{teaserImage}"
loading="lazy"
class="block w-full h-auto rounded-full"
width="400"
height="400c"
alt="{teaserImage.alternative}" />
</f:if>
</div>
<div class="px-16 md:px-4 md:w-5/6 md:pl-8 text-center text-white md:text-left" data-rte-content>
<span class="headline--3 text-white uppercase">{data.header}</span>
{data.bodytext -> f:format.html()}
<div class="flex flex-col space-y-4 md:flex-row md:items-center md:space-y-0 md:space-x-4">
<f:for each="{data.flexForm.buttons}" as="button" iteration="iteration">
<f:if condition="{iteration.cycle} <= 3">
<f:link.typolink parameter="{button.container.link}" class="button bg-button--secondary shadow-lg">
{button.container.label}
</f:link.typolink>
</f:if>
</f:for>
</div>
</div>
</div>
</div>
<f:render partial="Disrupter/Outlines"
section="Bottom1"
arguments="{classes: 'hidden md:block translate-y-2 {f:if(condition: \'{data.tx_eptheme_background} == 2 || {data.tx_eptheme_background} == 3\', then: \'text-ep-primary-bg\', else: \'text-white\')}'}"/>
</div>
</html>
@@ -0,0 +1,25 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="http://typo3.org/ns/FluidTYPO3/Vhs/ViewHelpers"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:layout name="Default"/>
<f:section name="Main">
<f:switch expression="{data.layout}">
<f:case value="0">
<f:render partial="Disturber/Small" arguments="{_all}"/>
</f:case>
<f:case value="1">
<f:render partial="Disturber/Large" arguments="{_all}"/>
</f:case>
<f:case value="2">
<f:render partial="Disturber/LastChance" arguments="{_all}"/>
</f:case>
<f:case value="3">
<f:render partial="Disturber/Reminder" arguments="{_all}"/>
</f:case>
</f:switch>
</f:section>
</html>