From 88af59e9a060a362d7a963acb2f0bcf4e3c4dda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Sun, 1 Mar 2026 22:17:53 +0100 Subject: [PATCH] feat: add Contao 5.7 event import bundle --- composer.json | 29 ++ config/services.yaml | 15 + contao/dca/tl_calendar_events.php | 22 ++ src/Command/ImportEventsCommand.php | 344 ++++++++++++++++++ src/Contao/Manager/Plugin.php | 23 ++ .../NossenerlandImportExtension.php | 19 + src/NossenerlandImportBundle.php | 15 + 7 files changed, 467 insertions(+) create mode 100644 composer.json create mode 100644 config/services.yaml create mode 100644 contao/dca/tl_calendar_events.php create mode 100644 src/Command/ImportEventsCommand.php create mode 100644 src/Contao/Manager/Plugin.php create mode 100644 src/DependencyInjection/NossenerlandImportExtension.php create mode 100644 src/NossenerlandImportBundle.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c4df1a2 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "eiswurm/nossenerland-import-bundle", + "description": "Nossener Land event import bundle for Contao 5.7.", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.4", + "contao/core-bundle": "^5.7", + "contao/calendar-bundle": "^5.7", + "contao/manager-plugin": "^2.0", + "symfony/http-client": "^7.3", + "symfony/string": "^7.3" + }, + "autoload": { + "psr-4": { + "Eiswurm\\NossenerlandImportBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "Eiswurm\\NossenerlandImportBundle\\Contao\\Manager\\Plugin" + }, + "config": { + "allow-plugins": { + "contao-components/installer": true, + "contao/manager-plugin": true + } + }, + "prefer-stable": true +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..fa6acfa --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,15 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Eiswurm\NossenerlandImportBundle\: + resource: ../src/ + exclude: + - ../src/DependencyInjection/ + - ../src/Contao/Manager/ + - ../src/NossenerlandImportBundle.php + + Eiswurm\NossenerlandImportBundle\Command\ImportEventsCommand: + arguments: + $projectDir: '%kernel.project_dir%' diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..5ad540e --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,22 @@ + ['External ID', 'Stabile externe ID für den Nossener-Land-Import.'], + 'exclude' => true, + 'search' => true, + 'sorting' => true, + 'filter' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ]; +} + +$GLOBALS['TL_DCA']['tl_calendar_events']['config']['sql']['keys'] ??= []; + +if (!isset($GLOBALS['TL_DCA']['tl_calendar_events']['config']['sql']['keys']['externalId'])) { + $GLOBALS['TL_DCA']['tl_calendar_events']['config']['sql']['keys']['externalId'] = 'index'; +} diff --git a/src/Command/ImportEventsCommand.php b/src/Command/ImportEventsCommand.php new file mode 100644 index 0000000..503ec21 --- /dev/null +++ b/src/Command/ImportEventsCommand.php @@ -0,0 +1,344 @@ +framework->initialize(); + + $events = $this->fetchEvents($io); + + if ($events === null) { + return Command::FAILURE; + } + + $incomingExternalIds = array_values(array_filter(array_map( + static fn (array $event): string => trim((string) ($event['id'] ?? '')), + $events + ))); + + $this->deleteRemovedEvents($incomingExternalIds, $io); + + foreach ($events as $event) { + $externalId = trim((string) ($event['id'] ?? '')); + + if ($externalId === '') { + $io->warning('Event ohne gültige externe ID übersprungen.'); + continue; + } + + try { + $this->upsertEvent($externalId, $event, $io); + } catch (\Throwable $exception) { + $io->error(sprintf('Fehler bei Event externalId %s: %s', $externalId, $exception->getMessage())); + } + } + + $io->success('Import abgeschlossen.'); + + return Command::SUCCESS; + } + + /** + * @return array>|null + */ + private function fetchEvents(SymfonyStyle $io): array|null + { + try { + $response = $this->httpClient->request('GET', self::JSON_URL); + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 400) { + $io->error(sprintf('Die JSON-Datei konnte nicht geladen werden (%d): %s', $statusCode, self::JSON_URL)); + + return null; + } + + $data = $response->getContent(); + } catch (TransportExceptionInterface $exception) { + $io->error(sprintf('Die JSON-Datei konnte nicht geladen werden: %s', $exception->getMessage())); + + return null; + } + + try { + $decoded = json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + $io->error('Fehler beim Parsen der JSON-Daten: '.$exception->getMessage()); + + return null; + } + + if (!\is_array($decoded)) { + $io->error('Ungültiges JSON-Format: Erwartet wurde ein Array von Events.'); + + return null; + } + + return array_values(array_filter($decoded, static fn (mixed $row): bool => \is_array($row))); + } + + /** + * @param string[] $incomingExternalIds + */ + private function deleteRemovedEvents(array $incomingExternalIds, SymfonyStyle $io): void + { + $result = $this->connection->fetchAllAssociative( + 'SELECT e.id, e.externalId + FROM tl_calendar_events e + INNER JOIN tl_calendar_events_organization eo ON eo.event_id = e.id + WHERE e.pid = :pid + AND eo.organization_id = :organizationId + AND e.externalId != ""', + [ + 'pid' => self::TARGET_CALENDAR_ID, + 'organizationId' => self::TARGET_ORGANIZATION_ID, + ] + ); + + foreach ($result as $existingEvent) { + $existingExternalId = (string) ($existingEvent['externalId'] ?? ''); + + if ($existingExternalId !== '' && \in_array($existingExternalId, $incomingExternalIds, true)) { + continue; + } + + $existingId = (int) ($existingEvent['id'] ?? 0); + + if ($existingId <= 0) { + continue; + } + + $this->connection->delete('tl_calendar_events_organization', ['event_id' => $existingId]); + $this->connection->delete('tl_calendar_events', ['id' => $existingId]); + + $io->warning(sprintf('Event gelöscht: externalId=%s', $existingExternalId)); + } + } + + /** + * @param array $event + */ + private function upsertEvent(string $externalId, array $event, SymfonyStyle $io): void + { + $title = trim((string) ($event['title'] ?? '')); + $startDate = (int) ($event['startDate'] ?? 0); + $preferredAlias = trim((string) ($event['alias'] ?? '')); + $existingEventId = $this->findEventIdByExternalId($externalId); + $alias = $this->generateAlias($title, $startDate, $preferredAlias, $existingEventId); + + $singleSrc = null; + + if (!empty($event['singleSRC']) && \is_string($event['singleSRC'])) { + $singleSrc = $this->processImage($event['singleSRC']); + } + + $row = [ + 'pid' => self::TARGET_CALENDAR_ID, + 'tstamp' => time(), + 'externalId' => $externalId, + 'title' => $title, + 'alias' => $alias, + 'author' => self::TARGET_AUTHOR_ID, + 'addTime' => (int) ($event['addTime'] ?? 1), + 'addImage' => (int) ($event['addImage'] ?? ($singleSrc !== null ? 1 : 0)), + 'startTime' => $this->toNullableInt($event['startTime'] ?? null), + 'endTime' => $this->toNullableInt($event['endTime'] ?? null), + 'startDate' => $this->toNullableInt($event['startDate'] ?? null), + 'endDate' => $this->toNullableInt($event['endDate'] ?? null), + 'description' => (string) ($event['description'] ?? ''), + 'teaser' => (string) ($event['teaser'] ?? ''), + 'singleSRC' => $singleSrc, + 'published' => 1, + 'floating' => 'above', + 'repeatEnd' => 0, + 'recurrences' => 0, + 'source' => 'external', + 'url' => sprintf('https://kirchspiel-nossener-land.de/termine/%s', $alias), + 'jumpTo' => 0, + 'articleId' => 0, + 'target' => 1, + 'termsAccepted' => '1', + 'tags' => serialize(self::DEFAULT_TAGS), + ]; + + if ($existingEventId !== null) { + $this->connection->update('tl_calendar_events', $row, ['id' => $existingEventId]); + $io->success('Event aktualisiert: '.$title); + $eventId = $existingEventId; + } else { + $this->connection->insert('tl_calendar_events', $row); + $io->success('Event importiert: '.$title); + $eventId = (int) $this->connection->lastInsertId(); + } + + $this->syncOrganization($eventId); + } + + private function findEventIdByExternalId(string $externalId): int|null + { + $result = $this->connection->fetchOne( + 'SELECT id FROM tl_calendar_events WHERE externalId = :externalId ORDER BY id ASC LIMIT 1', + ['externalId' => $externalId], + ['externalId' => ParameterType::STRING] + ); + + if ($result === false || $result === null || $result === '') { + return null; + } + + return (int) $result; + } + + private function syncOrganization(int $eventId): void + { + $this->connection->delete('tl_calendar_events_organization', ['event_id' => $eventId]); + + $this->connection->insert('tl_calendar_events_organization', [ + 'tstamp' => time(), + 'event_id' => $eventId, + 'organization_id' => self::TARGET_ORGANIZATION_ID, + ]); + } + + private function generateAlias(string $title, int $startDate, string $preferredAlias, int|null $eventId): string + { + $candidate = $preferredAlias !== '' ? $this->normalizeAlias($preferredAlias) : ''; + + if ($candidate === '') { + $dateSuffix = $startDate > 0 ? date('Y-m-d', $startDate) : date('Y-m-d'); + $baseTitle = $this->normalizeAlias($title); + $candidate = sprintf('%s-%s', $baseTitle !== '' ? $baseTitle : 'event', $dateSuffix); + } + + $baseAlias = $candidate; + $counter = 1; + + while ($this->aliasExistsForOtherEvent($candidate, $eventId)) { + $candidate = sprintf('%s-%d', $baseAlias, $counter++); + } + + return $candidate; + } + + private function normalizeAlias(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; + + return trim($value, '-'); + } + + private function aliasExistsForOtherEvent(string $alias, int|null $eventId): bool + { + if ($eventId !== null) { + $result = $this->connection->fetchOne( + 'SELECT id FROM tl_calendar_events WHERE alias = :alias AND id != :id', + ['alias' => $alias, 'id' => $eventId], + ['alias' => ParameterType::STRING, 'id' => ParameterType::INTEGER] + ); + + return $result !== false; + } + + $result = $this->connection->fetchOne( + 'SELECT id FROM tl_calendar_events WHERE alias = :alias', + ['alias' => $alias], + ['alias' => ParameterType::STRING] + ); + + return $result !== false; + } + + private function processImage(string $sourcePath): string|null + { + $relativeFolder = 'files/events/organization_142'; + $filename = basename(parse_url($sourcePath, \PHP_URL_PATH) ?: $sourcePath); + + if ($filename === '' || $filename === '.' || $filename === '..') { + return null; + } + + $relativePath = sprintf('%s/%s', $relativeFolder, $filename); + $absoluteFolder = sprintf('%s/%s', $this->projectDir, $relativeFolder); + $absolutePath = sprintf('%s/%s', $this->projectDir, $relativePath); + + $this->filesystem->mkdir($absoluteFolder); + + if (str_starts_with($sourcePath, 'http://') || str_starts_with($sourcePath, 'https://')) { + try { + $response = $this->httpClient->request('GET', $sourcePath); + if ($response->getStatusCode() >= 400) { + return null; + } + + $this->filesystem->dumpFile($absolutePath, $response->getContent()); + } catch (TransportExceptionInterface) { + return null; + } + } else { + if (!is_file($sourcePath)) { + return null; + } + + $this->filesystem->copy($sourcePath, $absolutePath, true); + } + + $dbafsAdapter = $this->framework->getAdapter(Dbafs::class); + $fileModel = $dbafsAdapter->addResource($relativePath); + + if ($fileModel === null || !isset($fileModel->uuid)) { + return null; + } + + return (string) $fileModel->uuid; + } + + private function toNullableInt(mixed $value): int|null + { + if ($value === null || $value === '') { + return null; + } + + return (int) $value; + } +} diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..e9c638c --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,23 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } +} diff --git a/src/DependencyInjection/NossenerlandImportExtension.php b/src/DependencyInjection/NossenerlandImportExtension.php new file mode 100644 index 0000000..a0a4a0f --- /dev/null +++ b/src/DependencyInjection/NossenerlandImportExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/NossenerlandImportBundle.php b/src/NossenerlandImportBundle.php new file mode 100644 index 0000000..43517b1 --- /dev/null +++ b/src/NossenerlandImportBundle.php @@ -0,0 +1,15 @@ +