commit 42a94a2dd9b2a1647ccfee035ce40f637c2e9218 Author: Jürgen Mummert Date: Wed Apr 1 11:05:34 2026 +0200 Initial standalone bundle release (sanitized history) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..757db61 --- /dev/null +++ b/README.md @@ -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). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f032786 --- /dev/null +++ b/composer.json @@ -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 + } + } +} \ No newline at end of file diff --git a/config/services.yml b/config/services.yml new file mode 100644 index 0000000..4729013 --- /dev/null +++ b/config/services.yml @@ -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 } \ No newline at end of file diff --git a/contao/dca/exported_events.php b/contao/dca/exported_events.php new file mode 100644 index 0000000..4920c7c --- /dev/null +++ b/contao/dca/exported_events.php @@ -0,0 +1,22 @@ + [ + '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'" + ] + ] +]; diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..015cf89 --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,206 @@ + &$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' +]; + diff --git a/contao/languages/de/tl_calendar_events.php b/contao/languages/de/tl_calendar_events.php new file mode 100644 index 0000000..b0a61b2 --- /dev/null +++ b/contao/languages/de/tl_calendar_events.php @@ -0,0 +1,35 @@ +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; + } +} \ No newline at end of file diff --git a/src/Command/ExportEventsJsonCommand.php b/src/Command/ExportEventsJsonCommand.php new file mode 100644 index 0000000..37b97d8 --- /dev/null +++ b/src/Command/ExportEventsJsonCommand.php @@ -0,0 +1,84 @@ +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; + } +} \ 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..05d46eb --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,23 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } +} \ No newline at end of file diff --git a/src/DataContainer/CalendarEvents.php b/src/DataContainer/CalendarEvents.php new file mode 100644 index 0000000..00eaedf --- /dev/null +++ b/src/DataContainer/CalendarEvents.php @@ -0,0 +1,117 @@ +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 -Tag + return '
' . + $arrRow['title'] . + ' [' . $ortTitle . ' ' . $date . ']' . + '
'; + } + + 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; + } +} \ No newline at end of file diff --git a/src/DependencyInjection/KsNossenerlandExtension.php b/src/DependencyInjection/KsNossenerlandExtension.php new file mode 100644 index 0000000..36f9dce --- /dev/null +++ b/src/DependencyInjection/KsNossenerlandExtension.php @@ -0,0 +1,19 @@ +load('services.yml'); + } +} \ No newline at end of file diff --git a/src/EventListener/CalendarAliasListener.php b/src/EventListener/CalendarAliasListener.php new file mode 100644 index 0000000..c18f9cf --- /dev/null +++ b/src/EventListener/CalendarAliasListener.php @@ -0,0 +1,104 @@ +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; + } +} \ No newline at end of file diff --git a/src/EventListener/EventFullListener.php b/src/EventListener/EventFullListener.php new file mode 100644 index 0000000..1d87716 --- /dev/null +++ b/src/EventListener/EventFullListener.php @@ -0,0 +1,69 @@ +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'], + ]; + } + } + } + } + } + } +} diff --git a/src/EventListener/EventJsonDataListener.php b/src/EventListener/EventJsonDataListener.php new file mode 100644 index 0000000..00f12d1 --- /dev/null +++ b/src/EventListener/EventJsonDataListener.php @@ -0,0 +1,77 @@ +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; + } + } +} \ No newline at end of file diff --git a/src/EventListener/ModifyIcalDataListener.php b/src/EventListener/ModifyIcalDataListener.php new file mode 100644 index 0000000..f470266 --- /dev/null +++ b/src/EventListener/ModifyIcalDataListener.php @@ -0,0 +1,120 @@ +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; + } +} diff --git a/src/EventListener/OfferListener.php b/src/EventListener/OfferListener.php new file mode 100644 index 0000000..9ea4c44 --- /dev/null +++ b/src/EventListener/OfferListener.php @@ -0,0 +1,210 @@ +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)); + } + } +} \ No newline at end of file diff --git a/src/EventListener/PlaceListener.php b/src/EventListener/PlaceListener.php new file mode 100644 index 0000000..fc3a5a5 --- /dev/null +++ b/src/EventListener/PlaceListener.php @@ -0,0 +1,66 @@ +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)); + } +} \ No newline at end of file diff --git a/src/KsNossenerlandBundle.php b/src/KsNossenerlandBundle.php new file mode 100644 index 0000000..45dcaea --- /dev/null +++ b/src/KsNossenerlandBundle.php @@ -0,0 +1,15 @@ + 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 []; + } + } +} \ No newline at end of file diff --git a/src/Service/SoapClientService.php b/src/Service/SoapClientService.php new file mode 100644 index 0000000..d7fce07 --- /dev/null +++ b/src/Service/SoapClientService.php @@ -0,0 +1,296 @@ +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; + } +} \ No newline at end of file