feat: add Contao 5.7 event import bundle

This commit is contained in:
Jürgen Mummert
2026-03-01 22:17:53 +01:00
commit 88af59e9a0
7 changed files with 467 additions and 0 deletions
+29
View File
@@ -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
}
+15
View File
@@ -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%'
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
if (!isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['externalId'])) {
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['externalId'] = [
'label' => ['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';
}
+344
View File
@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Eiswurm\NossenerlandImportBundle\Command;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\Dbafs;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsCommand(
name: 'app:import-events',
description: 'Importiert Events aus einer JSON-Datei in die Contao-Datenbank.',
)]
class ImportEventsCommand extends Command
{
private const string JSON_URL = 'https://kirchspiel-nossener-land.de/events.json';
private const int TARGET_CALENDAR_ID = 1;
private const int TARGET_ORGANIZATION_ID = 142;
private const int TARGET_AUTHOR_ID = 8;
private const array DEFAULT_TAGS = [7, 8];
public function __construct(
private readonly ContaoFramework $framework,
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
private readonly Filesystem $filesystem,
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->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<int, array<string, mixed>>|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<string, mixed> $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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Eiswurm\NossenerlandImportBundle\Contao\Manager;
use Contao\CalendarBundle\ContaoCalendarBundle;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Eiswurm\NossenerlandImportBundle\NossenerlandImportBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(NossenerlandImportBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
];
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Eiswurm\NossenerlandImportBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class NossenerlandImportExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.yaml');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Eiswurm\NossenerlandImportBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class NossenerlandImportBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}