|
|
|
@@ -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;
|
|
|
|
|
}
|
|
|
|
|
}
|