Initial: ChurchDesk-Terminimport aus loessnitz-kirchspiel.de ausgelagert

- 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 <noreply@anthropic.com>
This commit is contained in:
admin.juergenandClaude Opus 5 committed 2026-09-13 18:31:36 +02:00
commit 38e23f00ae
11 files changed
+705

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
/vendor/
composer.lock
.DS_Store
+37
View File
@@ -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"
}
```
+35
View File
@@ -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
}
}
}
+26
View File
@@ -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)%'
+183
View File
@@ -0,0 +1,183 @@
<?php
use Contao\CoreBundle\DataContainer\PaletteManipulator;
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
unset($dca['fields']['teaser']['eval']['rte']);
/**
* Palettes
*/
PaletteManipulator::create()
->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 ''"
];
@@ -0,0 +1,32 @@
<?php
$lang = &$GLOBALS['TL_LANG']['tl_calendar_events'];
/**
* Fields
*/
$lang['url'][0] = 'URL';
$lang['url'][1] = 'Link zu einer Detailseite mit weiteren Informationen';
$lang['contributors'][0] = 'Mitwirkende';
$lang['contributors'][1] = 'Künstler, Leiter Kindergottesdienst etc.';
$lang['pastor'][0] = 'PfarrerIn';
$lang['pastor'][1] = 'leitende(r) PfarrerIn, bei anderen Termin nicht angeben';
$lang['price'][0] = 'Preis';
$lang['price'][1] = 'Ticketpreise';
$lang['summary'][0] = 'Beschreibung';
$lang['summary'][1] = 'Langbeschreibung als Ergänzung zum Teasertext';
$lang['street'][0] = 'Straße';
$lang['street2'][0] = 'Ergänzende Adressangaben';
$lang['postal'][0] = 'PLZ';
$lang['city'][0] = 'Ort';
$lang['longitude'][0] = 'Längengrad';
$lang['longitude'][1] = 'in Deutschland irgendwas mit 13.xxxx';
$lang['latitude'][0] = 'Breitengrad';
$lang['latitude'][1] = 'in Deutschland irgendwas mit 52.xxxx';
$lang['externalId'][0] = 'Externe ID';
$lang['externalId'][1] = 'ID der externen Schnittstelle zum Datenabgleich';
$lang['parishes'][0] = 'Gemeinden';
$lang['allDay'][0] = 'Ganztägig';
$lang['showEndTime'][0] = 'Endzeit anzeigen';
$lang['formLink'][0] = 'A link to the form attached to the event';
$lang['formEmbed'][0] = 'A code to embed the form';
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Mummert\LoessnitzBundle\Command;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\StringUtil;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
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 GuzzleHttp\Client;
use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use Symfony\Component\Uid\Uuid;
#[AsCommand(
name: 'mummertmedia:events-import',
description: 'Import events from JSON file into tl_calendar_events.'
)]
class ImportEventsCommand extends Command
{
private const JSON_FILE_UUID = 'ea220a59-6f82-11ef-950a-96000156e216';
private const TARGET_CALENDAR_PID = 6; // Dein Kalender
private array $categoryMapping = [
163928 => 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'] ?? '', '<a><img><p><br>');
$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;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Mummert\LoessnitzBundle\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use GuzzleHttp\Client;
#[AsCommand(
name: 'straightsoft:api_events:retrieve',
description: 'Retrieve API events from ChurchDesk API and store JSON file.'
)]
class RetrieveApiEventsCommand extends Command
{
use LockableTrait;
const EVENT_URL = 'https://api2.churchdesk.com/api/v3.0.0/events?itemsNumber=100&imageFormat=span12_16-9';
const IMPORT_FOLDER_UUID = 'ea220a63-6f82-11ef-950a-96000156e216';
public function __construct(
private readonly VirtualFilesystemInterface $vfs,
private readonly string $organizationId,
private readonly string $partnerToken
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// prevent concurrent execution
if (!$this->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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mummert\LoessnitzBundle\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 Mummert\LoessnitzBundle\LoessnitzBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(LoessnitzBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
];
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mummert\LoessnitzBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class LoessnitzExtension 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 Mummert\LoessnitzBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class LoessnitzBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}