feat: add Contao 5.7 event import bundle
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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%'
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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__);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user