Initial standalone bundle release (sanitized history)

This commit is contained in:
Jürgen Mummert
2026-04-01 11:05:34 +02:00
commit 42a94a2dd9
21 changed files with 1924 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+224
View File
@@ -0,0 +1,224 @@
# mummert/ks-nossenerland-bundle
Contao-Bundle fuer projektspezifische Erweiterungen rund um Veranstaltungen im Kirchspiel Nossener Land.
Ziel dieses Bundles ist die Kapselung bestehender, produktiver Logik (ohne fachliche Aenderung) als installierbares Paket.
## Funktionsueberblick
Das Bundle erweitert vor allem die Event-Verwaltung und den Event-Output in Contao:
- DCA-Erweiterung fuer `tl_calendar_events` (zusatzliche Felder, Palettenanpassung, Callback fuer Aliasbildung)
- Template-Hooks zur Anreicherung von Event-, Orts- und Angebotsdaten
- iCal-Hook zur Anpassung von VEvent-Daten
- CLI-Commands fuer
- Export von Events zum EVLKS-Kalender (SOAP + Aufraeumen nicht mehr vorhandener Eintraege)
- Export von Events in eine JSON-Datei fuer externe Nutzung
## Technische Bestandteile
### 1) DCA-Anpassungen fuer Kalender-Events
Datei: `contao/dca/tl_calendar_events.php`
- Fuegt u. a. Felder hinzu:
- `catalog_ort`
- `catalog_kontakt`
- `catalog_pfarrbereiche`
- `godi_options`
- `subtitle`
- `contributors`
- `evlkscalendar`
- `export`
- `external_location`
- Passt Paletten an (Legenden/Felder) und entfernt Standard-Location-Felder aus der Default-Palette.
- Aktiviert Alias-Neuaufbau ueber `onsubmit_callback`.
### 2) Alias-Bildung fuer Events
Datei: `src/EventListener/CalendarAliasListener.php`
- Erzeugt beim Speichern eines Events einen sprechenden Alias aus:
- Eventtitel
- Ortstitel (`ctlg_orte`)
- Datum
- Nutzt Transliterations-/Kuerzungslogik und sorgt fuer Eindeutigkeit durch Suffix `-1`, `-2`, ...
### 3) Backend-Listing/Options-Callbacks
Datei: `src/DataContainer/CalendarEvents.php`
- Formatiert Kinddatensaetze im Event-Backend-Listing.
- Liefert Select-Optionen aus:
- `ctlg_orte`
- `ctlg_contacts`
- `ctlg_districts`
### 4) Template-Hooks (parseTemplate)
Dateien:
- `src/EventListener/EventFullListener.php`
- Reichert `event_full` um Ort, Alias, Koordinaten, District-Informationen an.
- `src/EventListener/EventJsonDataListener.php`
- Laedt JSON aus `https://ics.mummert.dev/kirchenjahr.json` und injiziert passenden Datensatz nach Datum in `event_full` und `event_upcoming_all`.
- `src/EventListener/OfferListener.php`
- Reichert Angebots-/Kontakt-Templates (`cm_master_angebote`, `cm_master_contacts`, `cm_listing_angebote`) mit Gemeinde-/District-Daten an.
- `src/EventListener/PlaceListener.php`
- Reichert Orts-Listing (`cm_listing_orte`) mit Gemeinde-IDs und District-Aliases an.
### 5) iCal-Anpassung
Datei: `src/EventListener/ModifyIcalDataListener.php`
Hook: `editVEvent`
- Setzt/normalisiert iCal-Felder wie `DTSTART`, `DESCRIPTION`, `LOCATION`, `GEO`, `ORGANIZER`.
- Verarbeitet Kontakt- und Mitwirkendeninformationen aus Eventfeldern.
### 6) Event-Export zum EVLKS-Kalender
Dateien:
- `src/Command/ExportEventsCommand.php`
- `src/Service/SoapClientService.php`
Command: `nossener-land:export-events`
Ablauf:
1. Holt zukuenftige Events aus `tl_calendar_events` (derzeit `pid IN (1,2,3)` und `evlkscalendar != 1`).
2. Mappt Eventdaten fuer EVLKS (inkl. Place-Mapping, Eventtyp, Personen/Kontakt, Zeiten).
3. Exportiert per SOAP.
4. Holt Remote-Events per JSON (`vid`) und loescht externe Events, die lokal nicht mehr im Exportlauf enthalten sind.
Hinweis:
- Das Place-Mapping ist statisch in `SoapClientService` hinterlegt.
- Der SOAP-Zugang ist aktuell in `config/services.yml` konfiguriert.
### 7) JSON-Export fuer Website/Consumer
Datei: `src/Command/ExportEventsJsonCommand.php`
Command: `nossener-land:export-eventstojson`
Ablauf:
1. Liest Events mit `export=1 AND published=1`.
2. Wandelt `singleSRC` UUID in absolute URL um.
3. Vergibt bei Bedarf Alias.
4. Schreibt JSON nach:
- `/srv/www/ks-nossener-land/public/kirchspiel-nossener-land.de/public/events.json`
## Voraussetzungen
- Contao 5.7
- PHP >= 8.3
- Tabellen aus dem Umfeld von Catalog Manager, u. a.:
- `ctlg_orte`
- `ctlg_contacts`
- `ctlg_gemeinden`
- `ctlg_districts`
- `ctlg_angebote`
- Netzwerkzugriff auf:
- EVLKS SOAP/JSON Endpunkte
- JSON-Quelle `ics.mummert.dev`
## Konfiguration per Environment-Variablen
Die folgenden Variablen werden fuer produktiven Betrieb erwartet:
- `KS_NOSSENERLAND_SOAP_WSDL_URL`
- `KS_NOSSENERLAND_SOAP_ENDPOINT_URL`
- `KS_NOSSENERLAND_SOAP_API_KEY`
- `KS_NOSSENERLAND_SOAP_VID`
- `KS_NOSSENERLAND_KIRCHENJAHR_JSON_URL` (optional, Default vorhanden)
- `KS_NOSSENERLAND_EXTERNAL_DB_DSN`
- `KS_NOSSENERLAND_EXTERNAL_DB_USER`
- `KS_NOSSENERLAND_EXTERNAL_DB_PASSWORD`
Beispiel DSN:
```text
mysql:host=localhost;dbname=nossener-land_1;charset=utf8mb4
```
## Console-Commands (manuell)
Im Contao-Projektverzeichnis ausfuehren:
```bash
php bin/console nossener-land:export-events
php bin/console nossener-land:export-eventstojson
```
## Cronjobs
Die folgenden Beispiele sind direkt anwendbar. Pfade bitte auf das jeweilige Projekt anpassen.
### A) EVLKS-Export (inkrementell, inkl. Aufraeumen)
Empfohlenes Intervall: alle 15 Minuten.
```cron
*/15 * * * * cd /srv/www/contao57 && /usr/bin/flock -n /tmp/ks-nossenerland-export-events.lock php bin/console nossener-land:export-events --env=prod --no-interaction >> var/log/cron-export-events.log 2>&1
```
Warum 15 Minuten:
- gute Balance zwischen Aktualitaet und Last
- robust bei redaktionellen Aenderungen waehrend des Tages
- vermeidet zu haeufige externe API-Aufrufe
Alternative Intervalle:
- alle 5 Minuten: wenn sehr zeitkritische Publikation noetig ist
- alle 30 Minuten: wenn Last reduziert werden soll
### B) JSON-Export fuer externe Anzeige
Empfohlenes Intervall: alle 30 Minuten.
```cron
*/30 * * * * cd /srv/www/contao57 && /usr/bin/flock -n /tmp/ks-nossenerland-export-json.lock php bin/console nossener-land:export-eventstojson --env=prod --no-interaction >> var/log/cron-export-json.log 2>&1
```
Warum 30 Minuten:
- Daten aendern sich typischerweise weniger haeufig als EVLKS-Sync-Anforderungen
- reduziert I/O und Dateischreiblast
Alternative Intervalle:
- stuendlich: fuer sehr geringe Aenderungsfrequenz
- alle 10-15 Minuten: wenn JSON als nahezu Live-Feed genutzt wird
### C) Startzeiten staffeln
Empfehlung fuer stabilen Betrieb:
- EVLKS-Export auf Viertelstunden
- JSON-Export zeitversetzt, z. B. Minute 7 und 37
Beispiel:
```cron
*/15 * * * * cd /srv/www/contao57 && /usr/bin/flock -n /tmp/ks-nossenerland-export-events.lock php bin/console nossener-land:export-events --env=prod --no-interaction >> var/log/cron-export-events.log 2>&1
7,37 * * * * cd /srv/www/contao57 && /usr/bin/flock -n /tmp/ks-nossenerland-export-json.lock php bin/console nossener-land:export-eventstojson --env=prod --no-interaction >> var/log/cron-export-json.log 2>&1
```
## Empfohlene Betriebsregeln
- Immer `flock` nutzen, um Ueberlappungen bei langen Laufzeiten zu verhindern.
- Ausgabe in getrennte Logdateien schreiben.
- Nach Deployment einmal manuell beide Commands ausfuehren.
- Bei API-Problemen zuerst Logs pruefen und dann betroffene Command-Intervalle temporaer erhoehen.
## Bekannte projektbezogene Punkte (kein Bugfix in diesem Schritt)
- In `ExternalLocationModel` sind externe DB-Zugangsdaten fest hinterlegt.
- `ExportEventsJsonCommand` nutzt einen fest codierten Zielpfad fuer `events.json`.
- SOAP-API-Zugangsdaten liegen aktuell in `config/services.yml`.
Diese Punkte sind bewusst unveraendert geblieben (Ziel: keine Funktionsaenderung beim Bundle-Umbau).
+30
View File
@@ -0,0 +1,30 @@
{
"name": "mummert/ks-nossenerland-bundle",
"description": "Contao bundle for Kirchspiel Nossener Land integrations and event export.",
"type": "contao-bundle",
"license": "proprietary",
"require": {
"php": "^8.3",
"contao/core-bundle": "^5.7",
"contao/calendar-bundle": "5.7.*",
"contao/manager-plugin": "^2.0",
"guzzlehttp/guzzle": "^7.9",
"alnv/catalog-manager-bundle": "^3.0",
"janborg/contao-ical-bundle": "^0.5.4"
},
"autoload": {
"psr-4": {
"Mummert\\KsNossenerlandBundle\\": "src/"
}
},
"extra": {
"contao-manager-plugin": "Mummert\\KsNossenerlandBundle\\Contao\\Manager\\Plugin"
},
"prefer-stable": true,
"config": {
"allow-plugins": {
"contao-components/installer": true,
"contao/manager-plugin": true
}
}
}
+73
View File
@@ -0,0 +1,73 @@
parameters:
ks_nossenerland.soap_wsdl_url_fallback: 'http://kalender.evlks.de/soap?WSDL'
ks_nossenerland.soap_endpoint_url_fallback: 'http://kalender.evlks.de/soap'
ks_nossenerland.soap_api_key_fallback: ''
ks_nossenerland.soap_vid_fallback: ''
ks_nossenerland.kirchenjahr_json_url_fallback: 'https://ics.mummert.dev/kirchenjahr.json'
services:
_defaults:
autowire: true
autoconfigure: true
public: true
GuzzleHttp\Client:
class: GuzzleHttp\Client
arguments: []
Mummert\KsNossenerlandBundle\:
resource: '../src/*'
exclude:
- '../src/Contao'
- '../src/DependencyInjection'
- '../src/KsNossenerlandBundle.php'
Mummert\KsNossenerlandBundle\Command\ExportEventsJsonCommand:
tags:
- { name: 'console.command', command: 'nossener-land:export-eventstojson' }
Mummert\KsNossenerlandBundle\Command\ExportEventsCommand:
tags:
- { name: 'console.command', command: 'nossener-land:export-events' }
Mummert\KsNossenerlandBundle\Service\SoapClientService:
arguments:
$wsdlUrl: '%env(default:ks_nossenerland.soap_wsdl_url_fallback:KS_NOSSENERLAND_SOAP_WSDL_URL)%'
$endpointUrl: '%env(default:ks_nossenerland.soap_endpoint_url_fallback:KS_NOSSENERLAND_SOAP_ENDPOINT_URL)%'
$apiKey: '%env(default:ks_nossenerland.soap_api_key_fallback:KS_NOSSENERLAND_SOAP_API_KEY)%'
$vid: '%env(default:ks_nossenerland.soap_vid_fallback:KS_NOSSENERLAND_SOAP_VID)%'
Mummert\KsNossenerlandBundle\EventListener\ModifyIcalDataListener:
tags:
- { name: kernel.event_listener, event: editVEvent }
Mummert\KsNossenerlandBundle\EventListener\EventJsonDataListener:
arguments:
$jsonUrl: '%env(default:ks_nossenerland.kirchenjahr_json_url_fallback:KS_NOSSENERLAND_KIRCHENJAHR_JSON_URL)%'
tags:
- { name: contao.hook, hook: parseTemplate, priority: 100 }
Mummert\KsNossenerlandBundle\EventListener\EventFullListener:
arguments:
$logger: '@logger'
$connection: '@database_connection'
tags:
- { name: contao.hook, hook: parseTemplate, method: onParseTemplate }
Mummert\KsNossenerlandBundle\EventListener\PlaceListener:
arguments:
$logger: '@logger'
$connection: '@database_connection'
tags:
- { name: contao.hook, hook: parseTemplate, method: onParseTemplate }
Mummert\KsNossenerlandBundle\EventListener\OfferListener:
arguments:
$logger: '@logger'
$connection: '@database_connection'
tags:
- { name: contao.hook, hook: parseTemplate, method: onParseTemplate }
Mummert\KsNossenerlandBundle\EventListener\CalendarAliasListener:
tags:
- { name: contao.callback, table: tl_calendar_events, target: config.oncreate, method: onSubmitCallback }
+22
View File
@@ -0,0 +1,22 @@
<?php
$GLOBALS['TL_DCA']['exported_events'] = [
'config' => [
'sql' => [
'keys' => [
'externalid' => 'primary'
]
]
],
'fields' => [
'externalid' => [
'sql' => "VARCHAR(255) NOT NULL"
],
'last_export_date' => [
'sql' => "DATETIME NOT NULL"
],
'status' => [
'sql' => "VARCHAR(10) NOT NULL DEFAULT 'ok'"
]
]
];
+206
View File
@@ -0,0 +1,206 @@
<?php
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Mummert\KsNossenerlandBundle\DataContainer\CalendarEvents;
use Mummert\KsNossenerlandBundle\Model\ExternalLocationModel;
$dca['fields']['featured']['filter'] = false;
$dca['fields']['recurring']['filter'] = false;
$dca['fields']['published']['filter'] = false;
$dca['fields']['startTime']['filter'] = false;
$fields = [
'featured', 'addTime', 'addImage', 'overwriteMeta', 'fullsize', 'recurring', 'addEnclosure', 'target',
'published', 'socialImage', 'pid', 'tstamp', 'startTime', 'endTime', 'startDate',
'endDate', 'pageTitle', 'robots', 'description', 'canonicalLink', 'location', 'address', 'singleSRC',
'alt', 'imageTitle', 'size', 'imageUrl', 'caption', 'floating', 'repeatEach', 'repeatEnd', 'recurrences', 'enclosure',
'source', 'linkText', 'jumpTo', 'articleId', 'url', 'cssClass', 'start', 'stop', 'languageMain', 'catalog_ort',
'catalog_kontakt', 'godi_options', 'catalog_pfarrbereiche', 'styleManager', 'export',
'external_location', 'evlkscalendar'
];
foreach ($fields as $field) {
$dca['fields'][$field]['search'] = false;
}
$dca['list']['sorting']['child_record_callback'] = [
'Mummert\KsNossenerlandBundle\DataContainer\CalendarEvents',
'listEvents'
];
// Subpalettes
$dca['palettes']['__selector__'][] = 'export';
$dca['subpalettes']['export'] = 'external_location';
/**
* Felder
*/
$dca['fields']['catalog_ort'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['catalog_ort'],
'exclude' => false,
'filter' => true,
'inputType' => 'select',
'options_callback' => [CalendarEvents::class, 'getCtlgOrteOptions'],
'eval' => [
'mandatory' => true,
'includeBlankOption' => true,
'blankOptionLabel' => '-', // Explizites leeres Label für HTML5-Validierung
'tl_class' => 'w50',
],
'sql' => "int(10) unsigned NOT NULL default '0'", // Falls nötig, hier default '0' entfernen
];
$dca['fields']['catalog_kontakt'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['catalog_kontakt'],
'exclude' => false,
'filter' => true,
'inputType' => 'select',
'options_callback' => [CalendarEvents::class, 'getCtlgKontaktOptions'],
'eval' => [
'mandatory' => false,
'includeBlankOption' => true,
'multiple' => true,
'chosen' => true,
'csv' => ',',
'tl_class' => 'w50',
],
'sql' => "text NULL",
];
$dca['fields']['catalog_pfarrbereiche'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['catalog_pfarrbereiche'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => [CalendarEvents::class, 'getCtlgPfarrbereicheOptions'],
'eval' => [
'mandatory' => false,
'includeBlankOption' => true,
'multiple' => true,
'chosen' => true,
'csv' => ',',
'tl_class' => 'w50',
],
'sql' => "text NULL", // Speichert die Werte als kommaseparierte Liste
];
$dca['fields']['godi_options'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['godi_options'],
'exclude' => false,
'inputType' => 'checkboxWizard',
'options' => [
1 => 'Abendmahl',
2 => 'Kindergottesdienst',
3 => 'Kirchenkaffee',
4 => 'Taufe',
],
'eval' => [
'mandatory' => false,
'multiple' => true,
'csv' => ',',
'tl_class' => 'clr',
],
'sql' => "text NULL",
];
$dca['fields']['subtitle'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['subtitle'],
'exclude' => false,
'search' => false,
'sorting' => false,
'filter' => false,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) DEFAULT NULL",
];
$dca['fields']['contributors'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['contributors'],
'exclude' => false,
'search' => false,
'sorting' => false,
'filter' => false,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) DEFAULT NULL",
];
$dca['fields']['evlkscalendar'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['evlkscalendar'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50'],
'sql' => "char(1) NOT NULL default ''",
];
$dca['fields']['export'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['export'],
'inputType' => 'checkbox',
'eval' => ['submitOnChange' => true],
'sql' => "char(1) NOT NULL default ''",
];
$dca['fields']['external_location'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['external_location'],
'inputType' => 'select',
'options_callback' => function () {
try {
$locations = ExternalLocationModel::findAllLocations();
if (empty($locations)) {
return ['debug' => "Keine Einträge in der Tabelle 'tl_location' gefunden."];
}
$options = [];
foreach ($locations as $location) {
$options[$location['id']] = $location['title'];
}
return $options;
} catch (\Exception $e) {
return ['debug' => "Fehler: " . $e->getMessage()];
}
},
'eval' => [
'mandatory' => true,
'includeBlankOption' => true,
'tl_class' => 'w50',
],
'sql' => "int(10) unsigned NOT NULL default '0'",
];
PaletteManipulator::create()
->addLegend('godioptions_legend', 'details_legend', PaletteManipulator::POSITION_AFTER)
->addLegend('nossenerland_legend', 'details_legend', PaletteManipulator::POSITION_AFTER)
->addField('godi_options', 'godioptions_legend', PaletteManipulator::POSITION_APPEND)
->addField('subtitle', 'title_legend', PaletteManipulator::POSITION_APPEND)
->addField('evlkscalendar', 'nossenerland_legend', PaletteManipulator::POSITION_APPEND)
->addField('export', 'nossenerland_legend', PaletteManipulator::POSITION_APPEND)
->addField('contributors', 'address')
->addField('catalog_kontakt', 'address')
->addField('catalog_pfarrbereiche', 'address')
->addField('catalog_ort', 'address')
->applyToPalette('default', 'tl_calendar_events');
PaletteManipulator::create()
->removeField('location')
->removeField('address')
->removeField('location_name')
->removeField('location_str')
->removeField('location_ort')
->applyToPalette('default', 'tl_calendar_events');
$GLOBALS['TL_DCA']['tl_calendar_events']['config']['onsubmit_callback'][] = [
'Mummert\KsNossenerlandBundle\EventListener\CalendarAliasListener',
'onSubmitCallback'
];
@@ -0,0 +1,35 @@
<?php
$lang = &$GLOBALS['TL_LANG']['tl_calendar_events'];
$lang['catalog_ort'][0] = 'Veranstaltungsort';
$lang['catalog_ort'][1] = 'bitte aus der Datenbank wählen';
$lang['catalog_kontakt'][0] = 'verantwortliche Person(en)';
$lang['catalog_kontakt'][1] = 'bitte aus der Datenbank wählen';
$lang['contributors'][0] = 'Mitwirkende';
$lang['contributors'][1] = 'weitere Mitwirkende angeben, sofern nicht in der Datenbank';
$lang['subtitle'][0] = 'Untertitel';
$lang['subtitle'][1] = 'z.B. Thema des Gottesdienstes';
$lang['godi_options'][0] = 'Gottesdienst mit:';
$lang['godi_options'][1] = 'Eigenschaften angeben';
$lang['export'][0] = 'Veranstaltung auf nossener-land.de anzeigen';
$lang['export'][1] = 'auswählen wenn die Veranstaltung auch auf nossener-land.de angezeigt werden soll';
$lang['evlkscalendar'][0] = 'Veranstaltung nicht auf EVLKS Kaelnder zeigen';
$lang['evlkscalendar'][1] = 'wenn ausgewählt, wird die Veranstaltung nicht zum EVLKS Kalender exportiert';
$lang['external_location'][0] = 'Veranstaltungsort aus nossener-land.de Datenbank';
$lang['external_location'][1] = 'Export funktioniert nur mit Veranstaltungsort aus nossener-land.de Datenbank';
$lang['catalog_pfarrbereiche'][0] = 'Pfarrbereiche';
$lang['catalog_pfarrbereiche'][1] = 'nur bei überregionalen Veranstaltungen angeben oder wenn Event für mehr als einen Pfarrbereich relevant, ansonsten erfolgt Zuweisung bereits über Veranstaltungsort';
$lang['godioptions_legend'] = 'Gottesdienst-Eigenschaften';
$lang['nossenerland_legend'] = 'Veranstaltung exportieren';
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Mummert\KsNossenerlandBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Doctrine\DBAL\Connection;
use Mummert\KsNossenerlandBundle\Service\SoapClientService;
class ExportEventsCommand extends Command
{
private $connection;
private $soapClientService;
public function __construct(Connection $connection, SoapClientService $soapClientService)
{
parent::__construct();
$this->connection = $connection;
$this->soapClientService = $soapClientService;
}
protected function configure(): void
{
$this->setHelp('Dieses Kommando exportiert zukünftige Events nach EVLKS und löscht nicht mehr vorhandene.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$today = (new \DateTimeImmutable('today'))->getTimestamp();
$stmt = $this->connection->executeQuery(
'SELECT * FROM tl_calendar_events WHERE pid IN (1, 2, 3) AND evlkscalendar != 1 AND (
(endDate IS NOT NULL AND endDate >= ?) OR (endDate IS NULL AND startDate >= ?)
)',
[$today, $today]
);
$exportedIds = [];
$exportCount = 0;
while ($event = $stmt->fetchAssociative()) {
$response = $this->soapClientService->sendEventToSoapAPI($event);
if ($response) {
$exportedIds[] = (string) $event['id'];
$exportCount++;
}
}
$remoteExternalIds = $this->soapClientService->fetchRemoteExternalIds();
$deletedCount = 0;
foreach ($remoteExternalIds as $externalId) {
if (!in_array($externalId, $exportedIds, true)) {
$response = $this->soapClientService->deleteEventByExternalId($externalId);
if ($response) {
$output->writeln("🗑️ gelöscht: $externalId");
$deletedCount++;
} else {
$output->writeln("❌ Fehler beim Löschen von Event $externalId");
}
}
}
$output->writeln('Export Summary:');
$output->writeln('----------------');
$output->writeln('Date: ' . date('Y-m-d H:i:s'));
$output->writeln("Number of exported events: $exportCount");
$output->writeln("Number of deleted events: $deletedCount");
return Command::SUCCESS;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Mummert\KsNossenerlandBundle\Command;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\FilesModel;
use Contao\Database;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ExportEventsJsonCommand extends Command
{
private ContaoFramework $framework;
public function __construct(ContaoFramework $framework)
{
parent::__construct();
$this->framework = $framework;
}
protected function configure(): void
{
$this
->setDescription('Exportiert Events mit export=1 und published=1 als JSON-Datei.')
->setHelp('Dieser Command exportiert alle Events, die für den Export und die Veröffentlichung markiert sind.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Contao Framework initialisieren
$this->framework->initialize();
$db = Database::getInstance();
// Events abrufen
$events = $db->prepare("
SELECT id, addTime, addImage, title, startTime, endTime, startDate, endDate, description, teaser, singleSRC, external_location, alias
FROM tl_calendar_events
WHERE export = 1 AND published = 1
")->execute()->fetchAllAssoc();
if (empty($events)) {
$io->warning('Keine Events mit export=1 und published=1 gefunden.');
return Command::SUCCESS;
}
$baseUrl = 'https://kirchspiel-nossener-land.de'; // Basis-URL deiner Website
// Events verarbeiten
foreach ($events as &$event) {
// singleSRC: UUID in vollständige URL umwandeln
if (!empty($event['singleSRC'])) {
$file = FilesModel::findByUuid($event['singleSRC']);
if ($file) {
$event['singleSRC'] = $baseUrl . '/' . $file->path;
} else {
$event['singleSRC'] = null; // Setze auf null, falls die Datei nicht gefunden wurde
}
}
// alias: sicherstellen, dass ein Alias vorhanden ist
if (empty($event['alias'])) {
$event['alias'] = strtolower(preg_replace('/[^a-z0-9]+/', '-', trim($event['title']))) . '-' . date('Y-m-d', $event['startDate']);
}
// Zusätzliche Transformationen (falls nötig) hier einfügen
}
// JSON erstellen
$json = json_encode($events, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// JSON-Datei speichern
$filePath = '/srv/www/ks-nossener-land/public/kirchspiel-nossener-land.de/public/events.json';
file_put_contents($filePath, $json);
$io->success("Export erfolgreich! Datei gespeichert unter: $filePath");
return Command::SUCCESS;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mummert\KsNossenerlandBundle\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\KsNossenerlandBundle\KsNossenerlandBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(KsNossenerlandBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
];
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Mummert\KsNossenerlandBundle\DataContainer;
use Contao\Database;
use Contao\Date;
use Contao\StringUtil;
use Contao\System;
use Contao\Calendar;
use Contao\Config;
class CalendarEvents
{
public function listEvents($arrRow)
{
$database = Database::getInstance();
// Ortstitel aus `ctlg_orte`-Tabelle holen
$ort = $database->prepare("SELECT ortTitle FROM ctlg_orte WHERE id=?")
->execute($arrRow['catalog_ort'])
->fetchAssoc();
$ortTitle = $ort ? $ort['ortTitle'] : '-';
// Event-Datum formatieren
$span = Calendar::calculateSpan($arrRow['startTime'], $arrRow['endTime']);
if ($span > 0) {
$date = Date::parse(Config::get(($arrRow['addTime'] ? 'datimFormat' : 'dateFormat')), $arrRow['startTime']) .
$GLOBALS['TL_LANG']['MSC']['cal_timeSeparator'] .
Date::parse(Config::get(($arrRow['addTime'] ? 'datimFormat' : 'dateFormat')), $arrRow['endTime']);
} elseif ($arrRow['startTime'] == $arrRow['endTime']) {
$date = Date::parse(Config::get('dateFormat'), $arrRow['startTime']) .
($arrRow['addTime'] ? ' ' . Date::parse(Config::get('timeFormat'), $arrRow['startTime']) : '');
} else {
$date = Date::parse(Config::get('dateFormat'), $arrRow['startTime']) .
($arrRow['addTime'] ? ' ' . Date::parse(Config::get('timeFormat'), $arrRow['startTime']) .
$GLOBALS['TL_LANG']['MSC']['cal_timeSeparator'] .
Date::parse(Config::get('timeFormat'), $arrRow['endTime']) : '');
}
// Rückgabe ohne <strong>-Tag
return '<div class="tl_content_left">' .
$arrRow['title'] .
' <span style="color:#999;padding-left:3px">[' . $ortTitle . ' ' . $date . ']</span>' .
'</div>';
}
public function getCtlgOrteOptions(): array
{
$options = [];
$result = Database::getInstance()->prepare("SELECT id, ortTitle FROM ctlg_orte ORDER BY ortTitle")->execute();
while ($result->next()) {
$options[$result->id] = $result->ortTitle;
}
return $options;
}
public function getCtlgPfarrbereicheOptions(): array
{
$options = [];
// Daten aus ctlg_districts sortiert nach districtsTitle laden
$result = Database::getInstance()->prepare("
SELECT id, districtsTitle
FROM ctlg_districts
ORDER BY districtsTitle
")->execute();
while ($result->next()) {
$options[$result->id] = $result->districtsTitle;
}
return $options;
}
public function getCtlgKontaktOptions(): array
{
$options = [];
// Sortierung nach lastname, contactsTerm, firstname
$result = Database::getInstance()->prepare("
SELECT id, contactsTerm, contactsFirstname, contactsLastname, contactsFunction
FROM ctlg_contacts
ORDER BY contactsLastname, contactsTerm, contactsFirstname
")->execute();
while ($result->next()) {
// Aufbau des Labels
if (empty($result->contactsLastname) && empty($result->contactsFirstname)) {
// Wenn lastname und firstname fehlen
$label = $result->contactsTerm;
} else {
// Wenn lastname oder firstname vorhanden
$label = $result->contactsLastname;
if (!empty($result->contactsFirstname)) {
$label .= ', ' . $result->contactsFirstname;
}
if (!empty($result->contactsFunction)) {
$label .= ' (' . $result->contactsFunction . ')';
}
}
// Nur Labels hinzufügen, die tatsächlich Werte haben
if (!empty($label)) {
$options[$result->id] = $label;
}
}
return $options;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mummert\KsNossenerlandBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class KsNossenerlandExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yml');
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\Database;
use Contao\DataContainer;
use Contao\StringUtil;
class CalendarAliasListener
{
public function onSubmitCallback($dc): void
{
// Sicherstellen, dass der Callback mit einem gültigen DataContainer aufgerufen wurde
if (!$dc instanceof DataContainer || !$dc->id) {
return;
}
// Event-Daten aus der Datenbank holen
$event = Database::getInstance()
->prepare("SELECT title, catalog_ort, startDate FROM tl_calendar_events WHERE id=?")
->execute($dc->id)
->fetchAssoc();
if (!$event) {
return;
}
// Sicherstellen, dass startDate nicht 0 ist
$startDate = (int) $event['startDate'];
if ($startDate <= 0) {
return;
}
// Event-Titel und Ort holen und verarbeiten
$title = $this->replaceUmlauts($event['title']);
$ortTitle = $this->getOrtTitle($event['catalog_ort']);
$date = date('Y-m-d', $startDate);
// Kürzen und Alias generieren
$shortTitle = $this->shortenTitle($title, 25);
$shortOrt = $this->shortenTitle($ortTitle, 20);
$baseAlias = StringUtil::generateAlias("$shortTitle-$shortOrt-$date");
// Falls Alias bereits existiert: Zahl anhängen (-1, -2, -3 ...)
$finalAlias = $this->getUniqueAlias($baseAlias, $dc->id);
// Alias direkt in der Datenbank speichern
Database::getInstance()
->prepare("UPDATE tl_calendar_events SET alias=? WHERE id=?")
->execute($finalAlias, $dc->id);
}
private function getOrtTitle(int $catalogOrtId): string
{
$result = Database::getInstance()
->prepare("SELECT ortTitle FROM ctlg_orte WHERE id=?")
->execute($catalogOrtId);
return $this->replaceUmlauts($result->ortTitle);
}
private function replaceUmlauts(string $text): string
{
$umlauts = [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue',
'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss'
];
return str_replace(array_keys($umlauts), array_values($umlauts), $text);
}
private function shortenTitle(string $title, int $maxLength): string
{
if (mb_strlen($title) <= $maxLength) {
return StringUtil::generateAlias($title);
}
$words = explode(' ', $title);
$shortTitle = '';
foreach ($words as $word) {
if (mb_strlen($shortTitle . ' ' . $word) > $maxLength) {
break;
}
$shortTitle .= (empty($shortTitle) ? '' : '-') . $word;
}
return StringUtil::generateAlias($shortTitle);
}
private function getUniqueAlias(string $baseAlias, int $eventId): string
{
$db = Database::getInstance();
$alias = $baseAlias;
$counter = 1;
while ($db->prepare("SELECT id FROM tl_calendar_events WHERE alias=? AND id!=?")
->execute($alias, $eventId)->numRows > 0) {
$alias = $baseAlias . '-' . $counter;
$counter++;
}
return $alias;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\Template;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Contao\StringUtil;
class EventFullListener
{
private Connection $connection;
private LoggerInterface $logger;
public function __construct(Connection $connection, LoggerInterface $logger)
{
$this->connection = $connection;
$this->logger = $logger;
}
/**
* @Hook("parseTemplate")
*/
public function onParseTemplate(Template $template): void
{
if ($template->getName() !== 'event_full') {
return;
}
$ortId = (int) ($template->external_location ?? 0);
$template->eventPlace = '';
$template->eventAlias = '';
$template->eventLatitude = '';
$template->eventLongitude = '';
$template->eventDistricts = [];
// Veranstaltungsort-Daten laden
if ($ortId > 0) {
$ort = $this->connection->fetchAssociative('SELECT * FROM ctlg_orte WHERE id = ?', [$ortId]);
if ($ort) {
$template->eventPlace = $ort['ortTitle'] ?? '';
$template->eventAlias = $ort['alias'] ?? '';
$template->eventLatitude = $ort['ortLatitude'] ?? '';
$template->eventLongitude = $ort['ortLongitude'] ?? '';
$gemeindeId = $ort['ortGemeinde'] ?? null;
if ($gemeindeId) {
$gemeinde = $this->connection->fetchAssociative('SELECT * FROM ctlg_gemeinden WHERE id = ?', [$gemeindeId]);
if ($gemeinde && !empty($gemeinde['gemeindeBereich'])) {
$district = $this->connection->fetchAssociative('SELECT * FROM ctlg_districts WHERE id = ?', [$gemeinde['gemeindeBereich']]);
if ($district) {
$template->eventDistricts[] = [
'gemeindeTitel' => $gemeinde['gemeindeTitel'],
'title' => $district['districtsTitle'],
'alias' => $district['districtsAlias'],
];
}
}
}
}
}
}
}
@@ -0,0 +1,77 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\Template;
use GuzzleHttp\Client;
class EventJsonDataListener
{
private static $jsonData = null; // Statische Variable, um die JSON-Daten zu cachen
private string $jsonUrl;
private Client $httpClient;
public function __construct(Client $httpClient, string $jsonUrl)
{
$this->httpClient = $httpClient;
$this->jsonUrl = $jsonUrl;
}
public function onParseTemplate(Template $template): void
{
// Unterstützte Templates
$supportedTemplates = ['event_full', 'event_upcoming_all'];
// Nur für unterstützte Templates ausführen
if (!in_array($template->getName(), $supportedTemplates, true)) {
return;
}
// Lade die JSON-Daten (nur einmal)
if (self::$jsonData === null) {
self::$jsonData = $this->fetchJsonData();
}
// JSON-Daten sind nicht verfügbar
if (self::$jsonData === null) {
return;
}
// Event-Datum auslesen (als `start` im JSON)
$eventDate = $template->startDate ? date('Y-m-d', $template->startDate) : null;
if (!$eventDate) {
return;
}
// Passenden Datensatz anhand des Datums suchen
$eventData = array_filter(self::$jsonData, fn($event) => $event['start'] === $eventDate);
if (!$eventData) {
return;
}
// Daten ans Template übergeben
$template->jsonEventData = reset($eventData);
}
private function fetchJsonData(): ?array
{
try {
$response = $this->httpClient->request('GET', $this->jsonUrl, [
'timeout' => 3.0,
'connect_timeout' => 2.0,
'http_errors' => false,
]);
if (200 !== $response->getStatusCode()) {
return null;
}
$json = (string) $response->getBody();
return $json ? json_decode($json, true) : null;
} catch (\Exception $e) {
return null;
}
}
}
@@ -0,0 +1,120 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\CalendarEventsModel;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Kigkonsult\Icalcreator\Vevent;
use Contao\System;
use Doctrine\DBAL\Connection;
#[AsHook('editVEvent')]
class ModifyIcalDataListener
{
private Connection $connection;
public function __construct(Connection $connection)
{
$this->connection = $connection;
}
public function __invoke(Vevent $vEvent, CalendarEventsModel $objEvent): Vevent
{
// Loggen, ob der Listener getriggert wird
System::getContainer()->get('monolog.logger.contao')->info(
'ModifyIcalDataListener triggered for event ID ' . $objEvent->id
);
// Zeitzone setzen
if (!empty($objEvent->startTime)) {
$startDate = (new \DateTime())->setTimestamp($objEvent->startTime)->setTimezone(new \DateTimeZone('Europe/Berlin'));
$vEvent->setDtstart($startDate);
}
// Beschreibung (teaser) setzen, HTML-Tags entfernen
$description = strip_tags($objEvent->teaser);
// Zusätzlicher Beschreibungstext für Kontakte & Contributors
$additionalDescription = '';
// Teilnehmer aus catalog_kontakt (Mehrfach-IDs) und Organisator ermitteln
$organizerEmail = 'ksp.nossener-land@evlks.de';
$organizerName = 'Ev.-Luth. Kirchspiel Nossener Land';
if (!empty($objEvent->catalog_kontakt)) {
$contactIds = array_filter(array_map('trim', explode(',', $objEvent->catalog_kontakt)));
if (!empty($contactIds)) {
$query = $this->connection->createQueryBuilder()
->select('contactsTerm', 'contactsFirstname', 'contactsLastname', 'contactsEmail')
->from('ctlg_contacts')
->where('id IN (:ids)')
->setParameter('ids', $contactIds, Connection::PARAM_INT_ARRAY)
->executeQuery();
$contacts = $query->fetchAllAssociative();
$contactStrings = [];
foreach ($contacts as $index => $contact) {
$contactName = trim("{$contact['contactsTerm']} {$contact['contactsFirstname']} {$contact['contactsLastname']}");
if (!empty($contactName)) {
$contactStrings[] = $contactName;
}
// Falls es der erste Eintrag ist und eine E-Mail vorhanden ist, als Organisator setzen
if ($index === 0 && !empty($contact['contactsEmail'])) {
$organizerEmail = $contact['contactsEmail'];
$organizerName = $contactName;
}
}
if (!empty($contactStrings)) {
$additionalDescription .= "mit " . implode("\n", $contactStrings);
}
}
}
// Falls das Feld contributors ausgefüllt ist, ebenfalls hinzufügen
if (!empty($objEvent->contributors)) {
$contributorList = array_map('trim', explode(',', $objEvent->contributors));
if (!empty($contributorList)) {
$additionalDescription .= "mit " . implode("\n", $contributorList);
}
}
// Falls teaser bereits existiert, trennen wir die zusätzlichen Infos mit einem Zeilenumbruch
if (!empty($description) && !empty($additionalDescription)) {
$description .= "\n\n" . $additionalDescription;
} elseif (!empty($additionalDescription)) {
$description = $additionalDescription;
}
// Beschreibung setzen
$vEvent->setDescription($description);
// LOCATION und GEO aus ctlg_orte abrufen
if (!empty($objEvent->catalog_ort)) {
$query = $this->connection->createQueryBuilder()
->select('ortTitle', 'ortLatitude', 'ortLongitude')
->from('ctlg_orte')
->where('id = :id')
->setParameter('id', $objEvent->catalog_ort)
->executeQuery();
$locationData = $query->fetchAssociative();
if ($locationData) {
$vEvent->setLocation($locationData['ortTitle']);
if (!empty($locationData['ortLatitude']) && !empty($locationData['ortLongitude'])) {
$vEvent->setGeo($locationData['ortLatitude'], $locationData['ortLongitude']);
}
}
}
// ORGANIZER setzen
$vEvent->setOrganizer("mailto:{$organizerEmail}", ['CN' => $organizerName]);
return $vEvent;
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\Template;
use Psr\Log\LoggerInterface;
use Doctrine\DBAL\Connection;
use Contao\StringUtil;
class OfferListener
{
private LoggerInterface $logger;
private Connection $connection;
public function __construct(LoggerInterface $logger, Connection $connection)
{
$this->logger = $logger;
$this->connection = $connection;
}
/**
* @Hook("parseTemplate")
*/
public function onParseTemplate(Template $template): void
{
// Einzelansicht
if ($template->getName() === 'cm_master_angebote') {
$angebotId = $template->id;
$angebot = $this->connection->fetchAssociative('SELECT * FROM ctlg_angebote WHERE id = ?', [$angebotId]);
if ($angebot) {
$gemeindeIds = StringUtil::deserialize($angebot['angebotGemeinde'], true);
$gemeinden = [];
if (!empty($gemeindeIds)) {
$gemeindenRaw = $this->connection->fetchAllAssociative(
'SELECT * FROM ctlg_gemeinden WHERE id IN (?)',
[$gemeindeIds],
[Connection::PARAM_INT_ARRAY]
);
foreach ($gemeindenRaw as $gemeinde) {
$bereichId = $gemeinde['gemeindeBereich'] ?? null;
$district = null;
if ($bereichId) {
$districtRaw = $this->connection->fetchAssociative(
'SELECT * FROM ctlg_districts WHERE id = ?',
[$bereichId]
);
if ($districtRaw) {
$district = [
'id' => $bereichId,
'title' => $districtRaw['districtsTitle'],
'alias' => $districtRaw['districtsAlias'],
];
}
}
$gemeinden[] = [
'titel' => $gemeinde['gemeindeTitel'],
'einheit' => $gemeinde['gemeindeEinheit'],
'district' => $district,
];
}
}
$template->angebotGemeinden = $gemeinden;
}
}
// Kontaktansicht
if ($template->getName() === 'cm_master_contacts') {
$currentContactId = $template->id;
$angebote = $this->connection->fetchAllAssociative('SELECT * FROM ctlg_angebote');
$filteredOffers = [];
foreach ($angebote as $angebot) {
$angebotContacts = StringUtil::deserialize($angebot['angeboteContacts']);
if (is_array($angebotContacts) && in_array($currentContactId, $angebotContacts)) {
$gemeindeIds = StringUtil::deserialize($angebot['angebotGemeinde'], true);
$gemeinden = [];
if (!empty($gemeindeIds)) {
$gemeindenRaw = $this->connection->fetchAllAssociative(
'SELECT * FROM ctlg_gemeinden WHERE id IN (?)',
[$gemeindeIds],
[Connection::PARAM_INT_ARRAY]
);
foreach ($gemeindenRaw as $gemeinde) {
$bereichId = $gemeinde['gemeindeBereich'] ?? null;
$district = null;
if ($bereichId) {
$districtRaw = $this->connection->fetchAssociative(
'SELECT * FROM ctlg_districts WHERE id = ?',
[$bereichId]
);
if ($districtRaw) {
$district = [
'id' => $bereichId,
'title' => $districtRaw['districtsTitle'],
'alias' => $districtRaw['districtsAlias'],
];
}
}
$gemeinden[] = [
'titel' => $gemeinde['gemeindeTitel'],
'einheit' => $gemeinde['gemeindeEinheit'],
'district' => $district,
];
}
}
$angebot['angebotGemeinden'] = $gemeinden;
$angebot['angebotGemeindeIds'] = $gemeindeIds;
// Nur Aliase extrahieren
$angebot['angebotDistrictIds'] = array_values(array_unique(array_map(
fn($g) => $g['district']['alias'] ?? null,
array_filter($gemeinden, fn($g) => !empty($g['district']['alias']))
)));
$filteredOffers[] = $angebot;
}
}
$orte = $this->connection->fetchAllAssociative('SELECT * FROM ctlg_orte');
$filteredOrte = [];
foreach ($orte as $ort) {
$orteContacts = StringUtil::deserialize($ort['ortContacts']);
if (is_array($orteContacts) && in_array($currentContactId, $orteContacts)) {
$ortDistrictTitles = [];
$gemeindeId = (int) $ort['ortGemeinde'];
if ($gemeindeId > 0) {
$bereichId = $this->connection->fetchOne(
'SELECT gemeindeBereich FROM ctlg_gemeinden WHERE id = ?',
[$gemeindeId]
);
if ($bereichId) {
$title = $this->connection->fetchOne(
'SELECT districtsTitle FROM ctlg_districts WHERE id = ?',
[$bereichId]
);
if ($title) {
$ortDistrictTitles[] = $title;
}
}
}
$ort['orteDistrictsTitles'] = $ortDistrictTitles;
$filteredOrte[] = $ort;
}
}
$template->angebote = $filteredOffers;
$template->orte = $filteredOrte;
}
// Listenansicht
if ($template->getName() === 'cm_listing_angebote') {
$raw = StringUtil::deserialize($template->angebotGemeinde, true);
$gemeindeIds = [];
foreach ($raw as $item) {
if (is_array($item) && isset($item['value'])) {
$gemeindeIds[] = (string) $item['value'];
} elseif (is_scalar($item)) {
$gemeindeIds[] = (string) $item;
}
}
$template->angebotGemeindeIds = $gemeindeIds;
// Gemeindebezogene Aliase der Districts laden
$districtIds = [];
if (!empty($gemeindeIds)) {
$rows = $this->connection->fetchAllAssociative(
'SELECT g.gemeindeBereich, d.districtsAlias
FROM ctlg_gemeinden g
LEFT JOIN ctlg_districts d ON g.gemeindeBereich = d.id
WHERE g.id IN (?)',
[$gemeindeIds],
[Connection::PARAM_INT_ARRAY]
);
foreach ($rows as $row) {
if (!empty($row['districtsAlias'])) {
$districtIds[] = $row['districtsAlias'];
}
}
}
$template->angebotDistrictIds = array_values(array_unique($districtIds));
}
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Mummert\KsNossenerlandBundle\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\Template;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
class PlaceListener
{
private LoggerInterface $logger;
private Connection $connection;
public function __construct(LoggerInterface $logger, Connection $connection)
{
$this->logger = $logger;
$this->connection = $connection;
}
/**
* @Hook("parseTemplate")
*/
public function onParseTemplate(Template $template): void
{
// Nur für Template cm_listing_orte.html5
if ($template->getName() !== 'cm_listing_orte') {
return;
}
$gemeindenRaw = StringUtil::deserialize($template->ortGemeinde, true);
// Gemeindelisten extrahieren
$gemeindeIds = array_map(function ($item) {
if (is_array($item) && isset($item['value'])) {
return (string) $item['value'];
}
return (string) $item;
}, $gemeindenRaw);
$template->ortGemeindeIds = $gemeindeIds;
// Passende District-Aliase ermitteln
$districtAliases = [];
if (!empty($gemeindeIds)) {
$rows = $this->connection->fetchAllAssociative(
'SELECT d.districtsAlias
FROM ctlg_gemeinden g
LEFT JOIN ctlg_districts d ON g.gemeindeBereich = d.id
WHERE g.id IN (?)',
[$gemeindeIds],
[Connection::PARAM_INT_ARRAY]
);
foreach ($rows as $row) {
if (!empty($row['districtsAlias'])) {
$districtAliases[] = (string) $row['districtsAlias'];
}
}
}
$template->ortDistrictIds = array_values(array_unique($districtAliases));
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mummert\KsNossenerlandBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class KsNossenerlandBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Mummert\KsNossenerlandBundle\Model;
use PDO;
class ExternalLocationModel
{
/**
* Erstellt die PDO-Verbindung zur externen Datenbank.
*
* @return PDO
*/
private static function getConnection()
{
$dsn = getenv('KS_NOSSENERLAND_EXTERNAL_DB_DSN') ?: '';
$user = getenv('KS_NOSSENERLAND_EXTERNAL_DB_USER') ?: '';
$password = getenv('KS_NOSSENERLAND_EXTERNAL_DB_PASSWORD') ?: '';
if ('' === $dsn || '' === $user) {
throw new \PDOException('Missing external DB env vars for location lookup.');
}
return new PDO(
$dsn,
$user,
$password,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
}
/**
* Holt alle Einträge aus der Tabelle 'tl_location' und sortiert sie nach title (A-Z).
*
* @return array
*/
public static function findAllLocations()
{
try {
$pdo = self::getConnection();
// Alphabetisch nach `title` sortieren
$stmt = $pdo->query('SELECT id, title FROM tl_location ORDER BY title ASC');
$locations = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($locations)) {
error_log("Die Tabelle 'tl_location' ist leer.");
} else {
foreach ($locations as $location) {
error_log("Gefundene Location: ID={$location['id']}, Title={$location['title']}");
}
}
return $locations;
} catch (\PDOException $e) {
error_log("PDO-Fehler: " . $e->getMessage());
return [];
}
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace Mummert\KsNossenerlandBundle\Service;
use SoapClient;
use SoapHeader;
use Exception;
use StdClass;
use Doctrine\DBAL\Connection;
class SoapClientService
{
private ?SoapClient $client = null;
private string $apiKey;
private string $vid;
private Connection $connection;
public function __construct(Connection $connection, string $wsdlUrl, string $endpointUrl, string $apiKey, string $vid)
{
$this->connection = $connection;
$this->apiKey = $apiKey;
$this->vid = $vid;
if ('' === trim($this->apiKey) || '' === trim($this->vid)) {
error_log('SOAP Client initialization skipped: missing API key or vid.');
return;
}
$options = [
'location' => $endpointUrl,
'trace' => false,
'exception' => true,
];
try {
$this->client = new SoapClient($wsdlUrl, $options);
$auth = new StdClass;
$auth->apiKey = $this->apiKey;
$auth->vid = $this->vid;
$header = new SoapHeader($endpointUrl, "APIValidate", $auth);
$this->client->__setSoapHeaders($header);
} catch (Exception $e) {
error_log('SOAP Client initialization failed: ' . $e->getMessage());
}
}
private function getEventTypeByPid($pid)
{
return match ($pid) {
1 => '1',
2 => '4',
3 => '9',
default => '9',
};
}
private function transformEventData(array $eventData): array
{
// ggf. verkürzt für Übersichtlichkeit hier bleibt deine Mapping-Logik erhalten
$placeIdMapping = [
65 => 4694, // Diakoniestation Dittmannsdorf
61 => 4695, // Ev. Kindergarten Dittmannsdorf
53 => 4699, // Friedhof Bieberstein
52 => 4678, // Friedhof Burkhardswalde
51 => 4672, // Friedhof Deutschenbora
50 => 4696, // Friedhof Dittmannsdorf
49 => 4681, // Friedhof Heynitz
48 => 4702, // Friedhof Hirschfeld
47 => 4685, // Friedhof Krögis
34 => 4660, // Friedhof Leuben
81 => 4688, // Friedhof Miltitz
46 => 4706, // Friedhof Neukirchen
44 => 4667, // Friedhof Nossen
43 => 4709, // Friedhof Obergruna
36 => 4663, // Friedhof Planitz
31 => 4656, // Friedhof Raußlitz
42 => 4712, // Friedhof Reinsberg
41 => 4675, // Friedhof Rothschönberg
33 => 4648, // Friedhof Rüsseina
39 => 4715, // Friedhof Siebenlehn
38 => 4690, // Friedhof Tanneberg
37 => 4692, // Friedhof Taubenheim
30 => 4651, // Friedhof Wendischbora
35 => 4665, // Friedhof Ziegenhain
45 => 4668, // Friedhofskapelle Nossen
68 => 4673, // Gemeindehaus Deutschenbora
27 => 4652, // Gemeindehaus Wendischbora
73 => 4700, // Gemeinderaum Bieberstein
71 => 4697, // Gemeinderaum Dittmannsdorf
63 => 4682, // Gemeinderaum Heynitz
77 => 4703, // Gemeinderaum Hirschfeld
55 => 4686, // Gemeinderaum in der Kirche Krögis
78 => 4707, // Gemeinderaum Neukirchen
67 => 4669, // Gemeinderaum Nossen
72 => 4710, // Gemeinderaum Obergruna
26 => 4657, // Gemeinderaum Raußlitz
75 => 4713, // Gemeinderaum Reinsberg
69 => 4676, // Gemeinderaum Rothschönberg
60 => 4716, // Gemeinderaum Siebenlehn
28 => 4653, // Großer Gemeinderaum Wendischbora
70 => 4719, // Heimatstube
14 => 4701, // Kirche Bieberstein
8 => 4679, // Kirche Burkhardswalde
2 => 4674, // Kirche Deutschenbora
15 => 4698, // Kirche Dittmannsdorf
5 => 4683, // Kirche Heynitz
22 => 4704, // Kirche Hirschfeld
6 => 4687, // Kirche Krögis
10 => 4661, // Kirche Leuben
4 => 1409, // Kirche Miltitz
16 => 4708, // Kirche Neukirchen
1 => 4670, // Kirche Nossen
21 => 4711, // Kirche Obergruna
12 => 4664, // Kirche Planitz
18 => 4658, // Kirche Raußlitz
13 => 4714, // Kirche Reinsberg
3 => 4677, // Kirche Rothschönberg
17 => 4647, // Kirche Rüsseina
20 => 4717, // Kirche Siebenlehn
9 => 4691, // Kirche Tanneberg
7 => 4693, // Kirche Taubenheim
19 => 4654, // Kirche Wendischbora
11 => 4666, // Kirche Ziegenhain
32 => 4649, // Kirchfriedhof Rüsseina
29 => 4655, // Kleiner Gemeinderaum Wendischbora
83 => 4720, // Ludwig-Richter-Saal
56 => 4680, // Pfarrhaus Burkhardswalde
62 => 4684, // Pfarrhaus Heynitz
74 => 4705, // Pfarrhaus Hirschfeld
58 => 4662, // Pfarrhaus Leuben
59 => 4689, // Pfarrhaus Miltitz
54 => 4671, // Pfarrhaus Nossen
25 => 4659, // Pfarrhaus Raußlitz
23 => 4650, // Pfarrhaus Rüsseina
76 => 4718, // Pfarrhaus Siebenlehn
96 => 5032, // BadePark Reinsberg
95 => 5191, // Kloster Altzella
101 => 5508, // Schlosskapelle Rothschönberg
];
$placeid = $placeIdMapping[$eventData['catalog_ort']] ?? null;
$eventType = isset($eventData['pid']) ? $this->getEventTypeByPid($eventData['pid']) : '9';
$kontaktIds = explode(',', $eventData['catalog_kontakt'] ?? '');
$contacts = [];
$email = '';
foreach ($kontaktIds as $index => $kontaktId) {
$kontaktId = trim($kontaktId);
if ($kontaktId) {
$contactData = $this->connection->fetchAssociative(
'SELECT contactsTerm, contactsFirstname, contactsLastname, contactsEmail FROM ctlg_contacts WHERE id = ?',
[$kontaktId]
);
if ($contactData) {
$contacts[] = $contactData['contactsTerm'] . ' ' . $contactData['contactsFirstname'] . ' ' . $contactData['contactsLastname'];
if ($index === 0 && !empty($contactData['contactsEmail'])) {
$email = $contactData['contactsEmail'];
}
}
}
}
$contributors = !empty($eventData['contributors']) ? explode(',', $eventData['contributors']) : [];
$allPeople = array_merge($contacts, $contributors);
$people = !empty($allPeople) ? implode(', ', $allPeople) : '';
return [
'placeid' => $placeid,
'eventType' => $eventType,
'people' => $people,
'email' => $email
];
}
public function sendEventToSoapAPI($eventData)
{
if (!$this->client instanceof SoapClient) {
error_log('SOAP client unavailable: skipping event export for ID ' . ($eventData['id'] ?? 'unknown'));
return null;
}
if ($eventData['published'] == '1') {
$destination = 'extern';
$status = 'ok';
} else {
$destination = 'intern';
$status = 'standby';
}
$transformed = $this->transformEventData($eventData);
$placeid = $transformed['placeid'];
$eventType = $transformed['eventType'];
$people = $transformed['people'];
$email = $transformed['email'];
if ($placeid === null) {
error_log('❌ Keine gültige placeid für Event ' . $eventData['id']);
return null;
}
if (isset($eventData['addTime']) && $eventData['addTime'] == '1') {
$start = date('Y-m-d', $eventData['startDate']) . ' ' . (!empty($eventData['startTime']) ? date('H:i:s', $eventData['startTime']) : '00:00:00');
$end = date('Y-m-d', $eventData['endDate']) . ' ' . (!empty($eventData['endTime']) ? date('H:i:s', $eventData['endTime']) : '00:00:00');
} else {
$start = date('Y-m-d', $eventData['startDate']) . ' 00:00:00';
$end = date('Y-m-d', $eventData['endDate']) . ' 00:00:00';
}
$link = 'https://kirchspiel-nossener-land.de/termine/' . $eventData['alias'];
$menue1 = (isset($eventData['godi_options']) && in_array('2', explode(',', $eventData['godi_options']))) ? 100 : null;
$event = [
'externalid' => strval($eventData['id']),
'eventexternalid' => strval($eventData['id']),
'start' => $start,
'end' => $end,
'title' => html_entity_decode($eventData['title'], ENT_QUOTES, 'UTF-8'),
'inputmaskid' => 1,
'personid' => 2001,
'destination' => $destination,
'placeid' => $placeid,
'status' => $status,
'shortdescription' => html_entity_decode(strip_tags($eventData['subtitle'] ?? ($eventData['subTitle'] ?? '')), ENT_QUOTES, 'UTF-8'),
'longdescription' => html_entity_decode(strip_tags($eventData['teaser'] ?? ''), ENT_QUOTES, 'UTF-8'),
'link' => $link,
'email' => $email,
'eventtype' => $eventType,
'kat' => '3',
'kat2' => null,
'textline1' => $people,
'people' => null,
'menue1' => $menue1,
'kollekte' => 0,
];
try {
return $this->client->saveEvent(array_merge(['apiKey' => $this->apiKey], $event));
} catch (Exception $e) {
error_log('❌ Fehler bei Export von Event ' . $eventData['id'] . ': ' . $e->getMessage());
return null;
}
}
public function deleteEventByExternalId(string $externalId): bool
{
if (!$this->client instanceof SoapClient) {
error_log("SOAP client unavailable: cannot delete event $externalId");
return false;
}
try {
$response = $this->client->deleteEvent($externalId, true);
// Optional prüfen, ob eine success-Eigenschaft existiert
if (is_object($response) && property_exists($response, 'success')) {
return $response->success === true;
}
// Falls keine success-Eigenschaft, dann success annehmen, wenn kein Fehler auftrat
return true;
} catch (Exception $e) {
error_log("❌ Fehler beim Löschen von Event $externalId: " . $e->getMessage());
return false;
}
}
public function fetchRemoteExternalIds(): array
{
$url = 'https://kalender.evlks.de/json?vid=' . $this->vid;
$json = @file_get_contents($url);
if (!$json) {
error_log("❌ Fehler beim Abrufen der JSON-Daten von $url");
return [];
}
$data = json_decode($json, true);
if (!is_array($data)) {
error_log("❌ Ungültiges JSON-Format für Kalenderdaten");
return [];
}
$externalIds = [];
foreach ($data as $entry) {
if (!empty($entry['Veranstaltung']['_event_EXTERNAL_ID'])) {
$externalIds[] = $entry['Veranstaltung']['_event_EXTERNAL_ID'];
}
}
return $externalIds;
}
}