From 38e23f00ae9dd4a8b71ae59bf6dfca63d348f24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Sun, 13 Sep 2026 18:27:18 +0200 Subject: [PATCH] Initial: ChurchDesk-Terminimport aus loessnitz-kirchspiel.de ausgelagert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commands straightsoft:api_events:retrieve und mummertmedia:events-import - Zusatzfelder tl_calendar_events (DCA + deutsche Sprachdatei) - Fix: DBAL-4-kompatibles Löschen entfernter Termine (ArrayParameterType::STRING) - Fix: stabile Aliase (eigener Datensatz wird bei Eindeutigkeitsprüfung ausgenommen) - ChurchDesk-Organisation und Partner-Token über ENV statt im Code Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + README.md | 37 +++ composer.json | 35 +++ config/services.yaml | 26 ++ contao/dca/tl_calendar_events.php | 183 +++++++++++++ contao/languages/de/tl_calendar_events.php | 32 +++ src/Command/ImportEventsCommand.php | 250 ++++++++++++++++++ src/Command/RetrieveApiEventsCommand.php | 82 ++++++ src/Contao/Manager/Plugin.php | 23 ++ .../LoessnitzExtension.php | 19 ++ src/LoessnitzBundle.php | 15 ++ 11 files changed, 705 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/services.yaml create mode 100644 contao/dca/tl_calendar_events.php create mode 100644 contao/languages/de/tl_calendar_events.php create mode 100644 src/Command/ImportEventsCommand.php create mode 100644 src/Command/RetrieveApiEventsCommand.php create mode 100644 src/Contao/Manager/Plugin.php create mode 100644 src/DependencyInjection/LoessnitzExtension.php create mode 100644 src/LoessnitzBundle.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f19e98c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +composer.lock +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..199ebdf --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# mummert/loessnitz-bundle + +Projektspezifische Contao-5.7-Erweiterungen für loessnitz-kirchspiel.de. + +## Konsolenbefehle + +| Befehl | Zweck | +|---|---| +| `straightsoft:api_events:retrieve` | Lädt alle öffentlichen Termine aus der ChurchDesk-API nach `files/churchdesk-import/events.json` | +| `mummertmedia:events-import` | Überträgt die JSON-Datei nach `tl_calendar_events` (Kalender-ID 6), aktualisiert per `externalId` und löscht Termine, die nicht mehr im Feed sind | + +Beide Befehle laufen per Cronjob. + +## Konfiguration (`.env.local` im Projekt) + +``` +CHURCHDESK_ORGANIZATION_ID=... +CHURCHDESK_PARTNER_TOKEN=... +``` + +## Zusatzfelder `tl_calendar_events` + +`contao/dca/tl_calendar_events.php` legt die vom Import und den Templates +genutzten Felder an: `externalId`, `url`, `contributors`, `pastor`, `price`, +`summary`, `street`, `street2`, `postal`, `city`, `longitude`, `latitude`, +`allDay`, `showEndTime`, `formLink`, `formEmbed`. + +## Installation + +```json +"repositories": [ + { "type": "vcs", "url": "ssh://git@git.mummert.media:3245/mummert/loessnitz-bundle.git" } +], +"require": { + "mummert/loessnitz-bundle": "^1.0" +} +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a3eeffa --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "mummert/loessnitz-bundle", + "description": "Projektspezifische Erweiterungen für loessnitz-kirchspiel.de (ChurchDesk-Terminimport).", + "type": "contao-bundle", + "license": "proprietary", + "keywords": [ + "contao", + "contao-bundle", + "churchdesk" + ], + "require": { + "php": "^8.3", + "contao/calendar-bundle": "^5.7", + "contao/core-bundle": "^5.7", + "contao/manager-plugin": "^2.0", + "doctrine/dbal": "^4.0", + "guzzlehttp/guzzle": "^7.0", + "symfony/console": "^7.0", + "symfony/uid": "^7.0" + }, + "autoload": { + "psr-4": { + "Mummert\\LoessnitzBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "Mummert\\LoessnitzBundle\\Contao\\Manager\\Plugin" + }, + "prefer-stable": true, + "config": { + "allow-plugins": { + "contao/manager-plugin": true + } + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..a53bed7 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,26 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Mummert\LoessnitzBundle\: + resource: ../src/ + exclude: + - ../src/DependencyInjection/ + - ../src/Contao/Manager/ + - ../src/LoessnitzBundle.php + + mummert_loessnitz.http_client: + class: GuzzleHttp\Client + + Mummert\LoessnitzBundle\Command\ImportEventsCommand: + arguments: + $client: '@mummert_loessnitz.http_client' + $filesStorage: '@contao.filesystem.virtual.files' + $db: '@database_connection' + + Mummert\LoessnitzBundle\Command\RetrieveApiEventsCommand: + arguments: + $vfs: '@contao.filesystem.virtual.files' + $organizationId: '%env(CHURCHDESK_ORGANIZATION_ID)%' + $partnerToken: '%env(CHURCHDESK_PARTNER_TOKEN)%' diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..257c3b3 --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,183 @@ +addField('externalId', 'title') + ->addField('url', 'location') + ->addField('contributors', 'location') + ->addField('pastor', 'location') + ->addField('price', 'location') + ->addField('city', 'location') + ->addField('postal', 'location') + ->addField('street2', 'location') + ->addField('street', 'location') + ->addField('longitude', 'location') + ->addField('latitude', 'location') + ->addField('summary', 'teaser') + ->addField('allDay', 'endDate') + ->addField('showEndTime', 'endDate') + ->addField('formLink', 'author') + ->addField('formEmbed', 'author') + ->applyToPalette('default', 'tl_calendar_events'); + + +/** + * Fields + */ +$dca['fields']['url'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50', 'rgxp' => 'httpurl', 'placeholder' => 'im Format https://...'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], +]; +$dca['fields']['formLink'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 512, 'tl_class' => 'w50', 'rgxp' => 'httpurl', 'placeholder' => 'im Format https://...'], + 'sql' => ['type' => 'string', 'length' => 512, 'default' => ''], +]; + + +$dca['fields']['formEmbed'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'textarea', + 'eval' => ['tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], +]; + + +$dca['fields']['contributors'] = [ + 'exclude' => false, + 'search' => true, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], +]; + + +$dca['fields']['pastor'] = [ + 'exclude' => false, + 'search' => true, + 'sorting' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], +]; + + +$dca['fields']['price'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], +]; + + +$dca['fields']['summary'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], +]; + +$dca['fields']['street'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], +]; + +$dca['fields']['street2'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], +]; + +$dca['fields']['postal'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 16, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], +]; + +$dca['fields']['city'] = [ + 'exclude' => false, + 'search' => false, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], +]; + +$dca['fields']['longitude'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['longitude'], + 'search' => false, + 'sorting' => false, + 'filter' => false, + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + + + +$dca['fields']['latitude'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['latitude'], + 'search' => false, + 'sorting' => false, + 'filter' => false, + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$dca['fields']['externalId'] = [ + 'exclude' => false, + 'search' => true, + 'sorting' => false, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50', 'readonly' => true], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], +]; + +$dca['fields']['allDay'] = [ + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => "char(1) NOT NULL default ''" +]; + +$dca['fields']['showEndTime'] = [ + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => "char(1) NOT NULL default ''" +]; diff --git a/contao/languages/de/tl_calendar_events.php b/contao/languages/de/tl_calendar_events.php new file mode 100644 index 0000000..cb2973e --- /dev/null +++ b/contao/languages/de/tl_calendar_events.php @@ -0,0 +1,32 @@ + 12, 163927 => 10, 163930 => 11, 163929 => 9, 85900 => 13, + 86192 => 14, 86778 => 15, 86189 => 16, 85891 => 2, 85893 => 17, + 85899 => 18, 130629 => 19, 86191 => 20, 85892 => 21, 85895 => 22, + 85897 => 3, 146195 => 23, 86455 => 24, 89723 => 25, 86188 => 26, + 86187 => 27, 174938 => 28, 177613 => 29, 85898 => 30, 86196 => 4, + ]; + + private array $parishCategoryMapping = [ + 33000 => 5, 32996 => 6, 33001 => 8, 33220 => 7, + ]; + + public function __construct( + private readonly ContaoFramework $framework, + private readonly Connection $db, + private readonly Client $client, + private readonly VirtualFilesystemInterface $filesStorage + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $this->framework->initialize(); + + $stringUtil = $this->framework->getAdapter(StringUtil::class); + + // ------------------------------------------------------------- + // JSON einlesen + // ------------------------------------------------------------- + + $jsonFileUuid = new Uuid(self::JSON_FILE_UUID); + + if (!$this->filesStorage->fileExists($jsonFileUuid)) { + $io->error('JSON-Datei existiert nicht.'); + return Command::FAILURE; + } + + $json = $this->filesStorage->read($jsonFileUuid); + $events = json_decode($json, true); + + if (!$events) { + $io->error('JSON leer oder ungültig.'); + return Command::FAILURE; + } + + $importedIds = []; + + // ------------------------------------------------------------- + // Events durchlaufen + // ------------------------------------------------------------- + foreach ($events as $event) { + + $externalId = $event['id'] ?? ''; + + $startTime = strtotime($event['startDate'] ?? ''); + $endTime = strtotime($event['endDate'] ?? ''); + $hasEnd = !empty($event['showEndtime']) ? $endTime : $startTime; + + $location = $event['location'] ?? ''; + $address = $event['locationObj']['address'] ?? ''; + $postal = $event['locationObj']['zipcode'] ?? ''; + $city = $event['locationObj']['city'] ?? ''; + $longitude = $event['locationObj']['longitude'] ?? ''; + $latitude = $event['locationObj']['latitude'] ?? ''; + $price = $event['price'] ?? ''; + $contributor = $event['contributor'] ?? ''; + $teaser = strip_tags($event['description'] ?? '', '


'); + $summary = strip_tags($event['summary'] ?? ''); + $description = $summary; + + // ------------------------------------------------------------- + // Bildverarbeitung + // ------------------------------------------------------------- + + $singleSRC = null; + $addImage = 0; + + if (!empty($event['image']['span12_16-9'])) { + + $imageUrl = $event['image']['span12_16-9']; + $pathInfo = parse_url($imageUrl, PHP_URL_PATH); + $ext = pathinfo($pathInfo, PATHINFO_EXTENSION); + + $year = date('Y', $startTime); + + $targetName = sprintf( + 'churchdesk-import/images/%s/%s.%s', + $year, + $event['id'], + $ext + ); + + // Datei existiert? + if ($this->filesStorage->fileExists($targetName)) { + $file = $this->filesStorage->get($targetName); + $singleSRC = $stringUtil->uuidToBin($file->getUuid()); + } else { + $response = $this->client->get($imageUrl); + $this->filesStorage->write($targetName, $response->getBody()->getContents()); + $file = $this->filesStorage->get($targetName); + $singleSRC = $stringUtil->uuidToBin($file->getUuid()); + } + + $addImage = 1; + } + + // ------------------------------------------------------------- + // Alias + // ------------------------------------------------------------- + + $aliasBase = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $event['title']), '-')) + . '-' . date('Y-m-d', $startTime); + + $existing = $this->db->fetchAssociative( + "SELECT id, alias FROM tl_calendar_events WHERE externalId=?", + [$externalId] + ); + + // Bestehenden Alias behalten, solange Titel und Datum gleich sind (stabile URLs) + if ($existing && preg_match('/^' . preg_quote($aliasBase, '/') . '(-\d+)?$/', $existing['alias'])) { + $alias = $existing['alias']; + } else { + $alias = $aliasBase; + $counter = 1; + + // eigenen Datensatz bei der Eindeutigkeitsprüfung ausnehmen + while ($this->db->fetchOne( + "SELECT id FROM tl_calendar_events WHERE alias=? AND id<>?", + [$alias, $existing['id'] ?? 0] + )) { + $alias = $aliasBase . '-' . $counter++; + } + } + + // ------------------------------------------------------------- + // Kategorien + // ------------------------------------------------------------- + + $categoryIds = []; + + if (!empty($event['categories'])) { + foreach ($event['categories'] as $cat) { + if (isset($this->categoryMapping[$cat['id']])) { + $categoryIds[] = $this->categoryMapping[$cat['id']]; + } + } + } + + if (!empty($event['parishes'])) { + foreach ($event['parishes'] as $p) { + if (isset($this->parishCategoryMapping[$p['id']])) { + $categoryIds[] = $this->parishCategoryMapping[$p['id']]; + } + } + } + + // ------------------------------------------------------------- + // Daten für tl_calendar_events + // ------------------------------------------------------------- + + $row = [ + 'pid' => self::TARGET_CALENDAR_PID, + 'tstamp' => time(), + 'title' => $event['title'], + 'alias' => $alias, + 'author' => 1, + 'published' => 1, + 'addTime' => !empty($event['allDay']) ? 0 : 1, + + 'startTime' => $startTime, + 'endTime' => $hasEnd, + 'startDate' => $startTime, + 'endDate' => $hasEnd, + + 'location' => $location, + 'address' => $address, + 'postal' => $postal, + 'city' => $city, + 'longitude' => $longitude, + 'latitude' => $latitude, + 'price' => $price, + 'contributors'=> $contributor, + + 'teaser' => $teaser, + 'summary' => $summary, + 'description' => $description, + + 'addImage' => $addImage, + 'singleSRC' => $singleSRC, + + 'externalId' => $externalId, + 'categories' => serialize($categoryIds), + ]; + + // ------------------------------------------------------------- + // Insert / Update + // ------------------------------------------------------------- + + if ($existing) { + $this->db->update('tl_calendar_events', $row, ['id' => $existing['id']]); + } else { + $this->db->insert('tl_calendar_events', $row); + } + + $importedIds[] = $externalId; + } + + // ------------------------------------------------------------- + // DELETE removed events + // ------------------------------------------------------------- + if ($importedIds) { + $this->db->executeStatement( + "DELETE FROM tl_calendar_events WHERE externalId IS NOT NULL AND externalId NOT IN (?)", + [$importedIds], + [ArrayParameterType::STRING] + ); + } + + $io->success('Alle Events wurden erfolgreich importiert.'); + return Command::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Command/RetrieveApiEventsCommand.php b/src/Command/RetrieveApiEventsCommand.php new file mode 100644 index 0000000..c79b096 --- /dev/null +++ b/src/Command/RetrieveApiEventsCommand.php @@ -0,0 +1,82 @@ +lock()) { + $output->writeln('The command is already running.'); + return Command::SUCCESS; + } + + $io = new SymfonyStyle($input, $output); + $client = new Client(); + + $events = []; + $page = 0; + + do { + $io->text('Retrieving page ' . ($page + 1) . '...'); + + $response = $client->get( + RetrieveApiEventsCommand::EVENT_URL + . '&organizationId=' . urlencode($this->organizationId) + . '&partnerToken=' . urlencode($this->partnerToken) + . '&pageMarker=' . $page + ); + + $page++; + + $data = json_decode($response->getBody()->getContents(), true); + + if (!empty($data)) { + $events = array_merge($events, $data); + $io->success(count($data) . ' events found.'); + } else { + $io->success('0 events found. Finishing.'); + } + + } while (!empty($data)); + + // write JSON file via VFS (Contao 5.6) + $path = 'churchdesk-import/events.json'; + $this->vfs->write($path, json_encode($events, JSON_PRETTY_PRINT)); + + $io->success('Events saved: ' . $path); + + $this->release(); + + return Command::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..a9fabd6 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,23 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } +} diff --git a/src/DependencyInjection/LoessnitzExtension.php b/src/DependencyInjection/LoessnitzExtension.php new file mode 100644 index 0000000..85a6bd2 --- /dev/null +++ b/src/DependencyInjection/LoessnitzExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/LoessnitzBundle.php b/src/LoessnitzBundle.php new file mode 100644 index 0000000..61a4564 --- /dev/null +++ b/src/LoessnitzBundle.php @@ -0,0 +1,15 @@ +