Initial version of the W&K sync bundle for Contao 5.7
Import of W&K seminar exports with preview, booking form via form generator with event-dependent field rules, notification routing, inbox with folders and CSV export for W&K. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
142afa6e52
118 files changed
+8976
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
vendor/
|
||||
composer.lock
|
||||
var/
|
||||
.phpunit.cache/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,166 @@
|
||||
# W&K-Sync Bundle
|
||||
|
||||
Verbindet **Contao 5.7** mit der Seminarverwaltung **W&K** (Windows-Software ohne Schnittstelle) über CSV-Dateien:
|
||||
|
||||
1. **Import**: Die Redaktion exportiert in W&K die Seminarliste als CSV und lädt sie im Contao-Backend hoch. Vor dem Schreiben zeigt eine **Vorschau**, was sich ändert (neu, geändert, storniert, unverändert, übersprungen, Warnungen). Erst nach Bestätigung werden die Veranstaltungen im Kalender angelegt bzw. aktualisiert.
|
||||
2. **Online-Anmeldung**: Besucher melden sich über ein Formular des **Formulargenerators** zu einer Veranstaltung an. Das Bundle blendet je nach Veranstaltung Felder ein und aus, prüft Plätze und Anmeldeschluss, speichert die Anmeldung, verschickt die passende Benachrichtigung (Notification Center 2) und leitet auf die Bestätigungsseite weiter.
|
||||
3. **Posteingang**: Anmeldungen und Kontaktanfragen werden in Ordnern bearbeitet (Neu, Download-Liste, Bereits bearbeitet, Ignoriert/Spam, Exportiert).
|
||||
4. **Export**: Die Download-Liste wird als CSV für den W&K-Import erzeugt (ISO-8859-1, Semikolon, feste Spalten), die Datensätze werden als exportiert markiert und die freien Plätze der Veranstaltungen reduziert.
|
||||
|
||||
- Contao **5.7**, PHP **8.5**, Doctrine DBAL **4**
|
||||
- Keine Abhängigkeiten außer dem Contao-Core und dem Kalender-Bundle
|
||||
- **Notification Center 2** wird für Benachrichtigungen empfohlen (Typ „W&K-Anmeldung“), ist aber nicht erforderlich
|
||||
- Newsletter-Anmeldung (Double-Opt-in) mit dem Newsletter-Bundle, optional
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require mummert/wk-sync-bundle
|
||||
```
|
||||
|
||||
Das Paket liegt nicht auf Packagist; das Repository in der `composer.json` des Projekts eintragen:
|
||||
|
||||
```json
|
||||
"repositories": [
|
||||
{ "type": "vcs", "url": "ssh://git@git.mummert.media:3245/mummert/wk-sync-bundle.git" }
|
||||
]
|
||||
```
|
||||
|
||||
Danach die Datenbank aktualisieren:
|
||||
|
||||
```bash
|
||||
vendor/bin/contao-console contao:migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. **Kategorien anlegen** – Backend „W&K-Sync → Kategorien“ oder die Standardkategorien des TPI Moritzburg per Befehl:
|
||||
```bash
|
||||
vendor/bin/contao-console wk-sync:categories:seed
|
||||
```
|
||||
Jede Kategorie hat eine **Art** (Schule, Evangelische Schulen, Elementarbereich, Gemeinde, Gottesdienst mit Kindern, Tagungen, Ausbildung, Mentoring, Sonstiges) und die **W&K-Bezeichnungen** (Werte des Felds `kbsgebiet`, eine pro Zeile). Beim Import zählen genaue Treffer zuerst (alle Kategorien mit dieser Bezeichnung, z. B. „Alle Schularten“ für Grundschule *und* weiterführende Schule), sonst der erste Teilstring-Treffer in Sortierreihenfolge. Die erste zugeordnete Kategorie wird Hauptkategorie und steuert die Formularregeln und die Benachrichtigung.
|
||||
|
||||
2. **Anmeldeformular anlegen** – im Formulargenerator, am einfachsten per Befehl (legt alle Felder mit passenden Namen, Optionen und CSS-Klassen an und trägt es im Kalender ein):
|
||||
```bash
|
||||
vendor/bin/contao-console wk-sync:form:create --calendar=1
|
||||
```
|
||||
Die Feldnamen entsprechen den Spalten von `tl_wk_booking` (`gender`, `firstname`, `lastname`, `street`, `postal`, `city`, `email`, `phone`, `position`, `birthday`, `teacherPosition`, `schoolName`, `schoolCity`, `vegetarian`, `overnightStay`, `addDifferentInvoiceAddress`, `invoice…`, `addNotes`, `notes`, `newsletter`, `privacyAccepted`, `agbAccepted`). Spamschutz über das ALTCHA-Feld des Contao-Kerns. Das Formular braucht **keine** Weiterleitungsseite und **kein** „Daten in Tabelle speichern“ – beides übernimmt das Bundle.
|
||||
|
||||
3. **Kalender konfigurieren** – im Kalender „W&K-Sync aktivieren“ und einstellen:
|
||||
- Anmeldeformular, Seite mit dem Anmeldeformular, Bestätigungsseite, Seite mit dem Anfrageformular
|
||||
- Anmeldeschluss (Tage vor Beginn, Standard 1) und angezeigter Anmeldeschluss (Standard 31)
|
||||
- Eigene Veranstaltungsorte (Standard „TPI Moritzburg“; Orte, die so beginnen, gelten als „im eigenen Haus“)
|
||||
- Autor importierter Veranstaltungen, Mitgliedergruppen für Kursleitung und Referenten (leer = keine Mitglieder anlegen)
|
||||
- Newsletter-Verteiler und Aktivierungsseite (optional)
|
||||
- Benachrichtigungen: Standard sowie je Bedingung 1–12 (siehe unten)
|
||||
|
||||
4. **Seiten und Module**
|
||||
- Seite **Kurs** (`kurs`): Modul *Event-Reader* des Kerns plus Modul **W&K-Anmeldestatus** (freie Plätze, Anmeldeschluss, Link „Jetzt anmelden“, Hinweis bei externer Anmeldung, geschlossenem Teilnehmerkreis usw.)
|
||||
- Seite **Anmeldung** (`kurs/anmeldung`): Modul **W&K-Anmeldeformular** – wird wie der Event-Reader mit dem Alias der Veranstaltung aufgerufen (`/kurs/anmeldung/<alias>`)
|
||||
- Seite **Anmeldung eingegangen**: Text; der Link enthält `?booking=<Token>`, damit Folgeformulare (z. B. Workshop-Auswahl) die Anmeldung kennen (siehe unten)
|
||||
- Seite **Anfrage**: Formular des Formulargenerators mit „Daten in Tabelle speichern“ → `tl_wk_inquiry` (Feldnamen `gender`, `academicTitle`, `firstname`, `lastname`, `street`, `postal`, `city`, `email`, `phone`, `subject`, `notes`, `newsletter`, `privacy`). Der Link vom Anmeldestatus hängt `?event=<ID>` an; das Bundle trägt die Veranstaltung automatisch ein.
|
||||
- Kalender-Module des Kerns (Eventliste, Kalender, Eventmenü) erhalten das Feld **W&K-Kategorien**, um nur Veranstaltungen bestimmter Kategorien zu zeigen.
|
||||
|
||||
5. **Benachrichtigungen** (Notification Center 2): Benachrichtigungen vom Typ **W&K-Anmeldung** anlegen und im Kalender zuordnen. Tokens: `##recipient_email##`, `##subscription_*##` (alle Felder der Anmeldung, Auswahlfelder zusätzlich als `##subscription_gender_label##` usw., Ja/Nein-Felder als `##subscription_vegetarian_label##`), `##event_*##` (alle Felder der Veranstaltung, Datumsfelder formatiert), `##event_datetime##`, `##event_link##`, `##event_categories##`, `##event_speakers##`, `##subscription_event_wk_id##`, `##subscription_confirmation_page_url##`, `##calendar_*##`, `##admin_email##`. Die bisherigen Token-Namen `event_organizerCity` und `event_internalEventNumber` bleiben erhalten.
|
||||
|
||||
6. **Benutzergruppen**: Zugriff auf die Module „W&K-Sync“ (Import & Posteingang, Kategorien, Kontaktanfragen, Protokoll) und auf den Kalender (Anmeldungen je Veranstaltung) freigeben.
|
||||
|
||||
---
|
||||
|
||||
## Import aus W&K
|
||||
|
||||
**Backend → W&K-Sync → Import & Posteingang → Datei hochladen.** Erwartet wird der Seminarexport aus W&K (Semikolon, Anführungszeichen, Windows-1252 oder UTF-8, mehrzeilige Felder). Wichtig: in W&K das größtmögliche Zeitintervall exportieren – Seminare, die nicht in der Datei stehen, werden **storniert und ausgeblendet** (nur Veranstaltungen des Kalenders, die eine W&K-Nummer tragen; manuell angelegte Veranstaltungen ohne W&K-Nummer bleiben unberührt).
|
||||
|
||||
Regeln beim Import:
|
||||
|
||||
| W&K | Contao | Bemerkung |
|
||||
|---|---|---|
|
||||
| `kbnr` | `wkId` | Muss mit einer führenden Null beginnen, sonst wird der Datensatz übersprungen |
|
||||
| `kbvsnr` | `wkInternalEventNumber` | **Leer = nicht veröffentlichen** (übersprungen); „keine“ wird zu leer |
|
||||
| `kbtitel`, `kbutitel`, `kbrtitel` | `title`, `wkSubTitle`, `wkSubTitleAdditional` | Titel ist Pflicht |
|
||||
| `kbanreise`, `kbabreise`, `kbuanreise`, `kbuabreise` | `startDate`, `endDate`, `startTime`, `endTime`, `addTime` | Datum `TT.MM.JJJJ`, Uhrzeit `HH.MM`; wie im Contao-Kern (ganztägig bis 23:59:59) |
|
||||
| `kbstatus1` | `wkState` | 1–5; **5 (Storno) blendet aus**. Manuelles Ausblenden bleibt erhalten, bis sich der W&K-Status ändert |
|
||||
| `kbsgebiet` | `wkCategoryText`, `wkCategories`, `wkCategoryPrimary` | Keine Zuordnung möglich → Storno + Hinweis |
|
||||
| `kbpmemo`, `kbzusatz` | `teaser`, `wkTopics` | Text wird HTML-sicher in Absätze bzw. Listen (`- `) umgewandelt |
|
||||
| `kbwmemo` | `wkTags` | Kommagetrennt, dedupliziert |
|
||||
| `kbkln1`, `kbmref` | `wkInstructor`, `wkSpeakers` | Mitglieder nach Nachname; werden nur angelegt, wenn im Kalender Mitgliedergruppen gesetzt sind. „N.N.“ wird ignoriert |
|
||||
| `kbanzmax`, `kbanztat` | `wkMaxPlaces`, `wkPlacesLeft` | Freie Plätze = max − belegt |
|
||||
| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen |
|
||||
| `kbkd*` | `wkOrganizer*` | Straße und Landeskennzahl richtig zugeordnet (war vertauscht) |
|
||||
| `kbkdemail` | `wkOrganizerEmail` | E-Mail **oder** URL (Veranstaltungsart 3: Anmeldung beim Veranstalter) |
|
||||
| `kbvort`, `kbgruppe`, `kbvsart`, `kbztermin` | `location`, `wkAudience`, `wkEventType`, `wkAdditionalDates` | „geschlossener Teilnehmerkreis“ verhindert die Online-Anmeldung |
|
||||
|
||||
Das Euro-Zeichen, das W&K als „Ç“ exportiert, wird im Umfeld von Zahlen korrigiert. Aliase werden nur beim Anlegen erzeugt und bleiben danach stabil. Jeder Import wird protokolliert (W&K-Sync → Protokoll); die Datei liegt unter `var/wk-sync/imports/`.
|
||||
|
||||
Auf der Konsole:
|
||||
|
||||
```bash
|
||||
vendor/bin/contao-console wk-sync:import files/Import/stva.csv 1 # Vorschau
|
||||
vendor/bin/contao-console wk-sync:import files/Import/stva.csv 1 --apply # anwenden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Formularregeln
|
||||
|
||||
Welche Felder das Anmeldeformular zeigt, hängt von der Hauptkategorie (Art), dem Veranstaltungsort und der Veranstaltungsnummer ab:
|
||||
|
||||
- **Lehrertätigkeit** (`teacherPosition`, `schoolName`, `schoolCity`): nur bei den Arten Schule und Evangelische Schulen.
|
||||
- **Abweichende Rechnungsanschrift**: bei Schulkursen nur, wenn „Ich unterrichte an einer Schule in sonstiger Trägerschaft“ gewählt ist; sonst immer.
|
||||
- **Übernachtung**: nur bei mehrtägigen Veranstaltungen. Bei EXTP-Kursen und Elementarkursen im eigenen Haus erscheint stattdessen der Hinweis, dass Übernachtung und Verpflegung direkt im Gästehaus gebucht werden (Erklärungsfeld mit CSS-Klasse `wk-slot-no-overnight-stay`). Ausnahme: Lehrerkurse außer Haus zeigen das Feld.
|
||||
- **Vegetarisch**: nicht bei Online-Kursen und nicht bei mehrtägigen EXTP-Kursen oder Elementarkursen (außer Tagungen); Lehrerkurse außer Haus zeigen das Feld.
|
||||
- Abhängige Felder (Schule, Rechnungsanschrift, Nachricht) werden serverseitig nur validiert und gespeichert, wenn ihr steuerndes Feld sichtbar ist und den passenden Wert hat; ein kleines Skript (`bundles/contaowksync/wk-sync.js`) blendet sie im Browser ein und aus.
|
||||
|
||||
Zusätzlich prüft das Bundle vor dem Anzeigen und noch einmal beim Absenden: veröffentlicht, nicht storniert, kein geschlossener Teilnehmerkreis, keine externe Anmeldung (Veranstaltungsart 3 mit E-Mail/URL), Anmeldeschluss, freie Plätze (W&K-Stand minus offene Anmeldungen, zusätzlich maximale Plätze).
|
||||
|
||||
## Benachrichtigungs-Bedingungen
|
||||
|
||||
Vorrang: Benachrichtigung an der Veranstaltung → erste erfüllte Bedingung des Kalenders → Standard-Benachrichtigung des Kalenders.
|
||||
|
||||
| Nr. | Bedingung |
|
||||
|---|---|
|
||||
| 1 | Online-Kurs (Veranstaltungsort „Online“) |
|
||||
| 2 / 5 | Tages- / Mehrtagesveranstaltung, Art Schule, Nummer beginnt mit EXTP |
|
||||
| 3 / 7 | Tages- / Mehrtagesveranstaltung, Art Gemeinde, Gottesdienst mit Kindern oder Tagungen |
|
||||
| 4 / 6 | Tages- / Mehrtagesveranstaltung, Art Elementarbereich |
|
||||
| 8 / 9 | Tages- / Mehrtagesveranstaltung, Art Evangelische Schulen |
|
||||
| 10 / 11 | Tages- / Mehrtagesveranstaltung, Art Mentoring |
|
||||
| 12 | Mehrtägige Ausbildung |
|
||||
|
||||
Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt (Warnung im Systemprotokoll).
|
||||
|
||||
## Folgeformulare (z. B. Workshop-Auswahl)
|
||||
|
||||
Die Bestätigungsseite wird mit `?booking=<Token>` aufgerufen. Formulare des Formulargenerators auf dieser Seite erhalten beim Absenden automatisch die Felder `subscription` (ID der Anmeldung), `subscription_email`, `subscription_firstname`, `subscription_lastname`, `event`, `event_title` und `event_wk_id` – in Benachrichtigungen als `##form_subscription_email##` usw. verwendbar. Eine eigene Bestätigungsseite lässt sich je Veranstaltung hinterlegen.
|
||||
|
||||
## Posteingang und Export
|
||||
|
||||
**W&K-Sync → Import & Posteingang → Posteingang.** Neue Anmeldungen und Kontaktanfragen werden gesichtet und einzeln oder gesammelt verschoben. Doppelte Anmeldungen (gleiche E-Mail-Adresse, gleiche Veranstaltung) werden markiert. „Bearbeiten“ öffnet den Datensatz (Anmeldungen unter Kalender → Veranstaltung → Anmeldungen, Anfragen unter W&K-Sync → Kontaktanfragen).
|
||||
|
||||
**Export**: In der Download-Liste erzeugt „Download-Liste für W&K exportieren“ die Datei `JJJJ-MM-TT_website_kontaktanfragen_wk.csv` mit den Spalten `KBNR;KNKDAN;KNKDTITEL;KNKDN2;KNKDN1;KNKDSTR;KNKDPLZ;KNKDORT;KNGEBDAT;KDEMAIL;KDTEL1;KDFREI01;KDFREI02;KDFREI03;KDFREI04;KNUKAT;KNVEG;schoolName;schoolCity`. Kontaktanfragen stehen ohne Kursnummer in der Datei. Die Datensätze werden als „Exportiert“ markiert, die freien Plätze der Veranstaltungen um die Zahl der Anmeldungen reduziert (bis zum nächsten W&K-Import), und die Datei bleibt unter „Exporte für W&K“ zum erneuten Download.
|
||||
|
||||
## Eigene Templates
|
||||
|
||||
- Frontend-Module: `frontend_module/wk_booking_form.html.twig` und `frontend_module/wk_booking_status.html.twig` (Blöcke `booking_header`, `booking_status`, `booking_form` bzw. `status`, `inquiry`).
|
||||
- Twig-Funktionen für eigene Event-Templates: `wk_booking_info(event.id)` (Verfügbarkeit, freie Plätze, Anmeldeschluss, Links), `wk_event_categories(event)` (Kategorien), `wk_event_people(event)` (Kursleitung, Referenten).
|
||||
|
||||
## Sicherheit und Robustheit
|
||||
|
||||
- Alle schreibenden Backend-Aktionen laufen über POST mit Request-Token; Ausgaben sind escaped.
|
||||
- Upload nur `.csv`/`.txt`, Dateien liegen außerhalb des Web-Roots (`var/wk-sync/`).
|
||||
- Import in einer Transaktion; fehlerhafte Zeilen brechen den Import nicht ab, sondern werden gemeldet.
|
||||
- Verfügbarkeit wird beim Absenden erneut geprüft (Wettlauf um den letzten Platz).
|
||||
- Anmeldungen und Anfragen werden nur mit bekannten Spalten gespeichert.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
composer install
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
Die Tests decken CSV-Parsing, Zeilen-Zuordnung, Kategorie-Auflösung, Formularregeln, Benachrichtigungs-Routing, Textaufbereitung und den W&K-Export ab.
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "mummert/wk-sync-bundle",
|
||||
"description": "W&K-Sync für Contao 5.7: Kursimport aus der Seminarverwaltung W&K (CSV), Online-Anmeldungen mit Posteingang und CSV-Export zurück nach W&K",
|
||||
"type": "contao-bundle",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "mummert.media",
|
||||
"email": "juergen@mummert.media"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.5",
|
||||
"ext-iconv": "*",
|
||||
"ext-mbstring": "*",
|
||||
"contao/core-bundle": "^5.7",
|
||||
"contao/calendar-bundle": "^5.7",
|
||||
"doctrine/dbal": "^4.0",
|
||||
"symfony/config": "^7.4",
|
||||
"symfony/console": "^7.4",
|
||||
"symfony/dependency-injection": "^7.4",
|
||||
"symfony/event-dispatcher": "^7.4",
|
||||
"symfony/http-foundation": "^7.4",
|
||||
"symfony/http-kernel": "^7.4",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"contao/manager-plugin": "^2.13",
|
||||
"phpunit/phpunit": "^12.0"
|
||||
},
|
||||
"suggest": {
|
||||
"terminal42/notification_center": "Versand der Anmeldebestätigungen über das Notification Center 2 (Benachrichtigungstyp „W&K-Anmeldung“)",
|
||||
"contao/newsletter-bundle": "Newsletter-Anmeldung (Double-Opt-in) direkt aus dem Anmeldeformular"
|
||||
},
|
||||
"conflict": {
|
||||
"contao/manager-plugin": "<2.13",
|
||||
"terminal42/notification_center": "<2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Mummert\\ContaoWkSyncBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Mummert\\ContaoWkSyncBundle\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"contao-manager-plugin": "Mummert\\ContaoWkSyncBundle\\ContaoManager\\Plugin"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"contao-components/installer": false,
|
||||
"contao/manager-plugin": false,
|
||||
"php-http/discovery": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\EventListener\NotificationCenter\BookingNotificationType: ~
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Booking\NotificationCenterNotifier: ~
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NotificationCenterNotifier'
|
||||
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
bind:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
Mummert\ContaoWkSyncBundle\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/ContaoManager/'
|
||||
- '../src/ContaoWkSyncBundle.php'
|
||||
- '../src/EventListener/NotificationCenter/'
|
||||
- '../src/Booking/NotificationCenterNotifier.php'
|
||||
- '../src/Category/{Category,CategoryKind}.php'
|
||||
- '../src/Import/{ImportPlan,PlannedEvent,ImportMessage,WkRow}.php'
|
||||
- '../src/Booking/{Availability,BookingFields}.php'
|
||||
- '../src/Inbox/{InboxItem,InboxState}.php'
|
||||
- '../src/Sync/CalendarConfig.php'
|
||||
|
||||
# Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt
|
||||
Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NullNotifier'
|
||||
|
||||
# Das klassische Backend-Modul wird von Contao ohne Container instanziiert und holt sich diesen Dienst
|
||||
Mummert\ContaoWkSyncBundle\Backend\WkSyncBackend:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Booking\BookingAvailability:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Category\CategoryRepository:
|
||||
public: true
|
||||
|
||||
# Öffentlich, damit Projekt-Skripte und Templates darauf zugreifen können
|
||||
Mummert\ContaoWkSyncBundle\Booking\EventResolver:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Booking\BookingTokens:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Booking\NotificationRouter:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository:
|
||||
public: true
|
||||
|
||||
Mummert\ContaoWkSyncBundle\Import\ImportService:
|
||||
public: true
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\ArrayUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Backend\WkSyncModule;
|
||||
|
||||
// Backend-Bereich „W&K-Sync“ direkt nach „Inhalte“
|
||||
ArrayUtil::arrayInsert($GLOBALS['BE_MOD'], 1, [
|
||||
'wk_sync' => [
|
||||
'wk_sync_tools' => [
|
||||
'callback' => WkSyncModule::class,
|
||||
'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css',
|
||||
],
|
||||
'wk_sync_categories' => [
|
||||
'tables' => ['tl_wk_category'],
|
||||
],
|
||||
'wk_sync_inquiries' => [
|
||||
'tables' => ['tl_wk_inquiry'],
|
||||
],
|
||||
'wk_sync_log' => [
|
||||
'tables' => ['tl_wk_sync_log'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Anmeldungen werden als Untertabelle der Veranstaltungen im Kalender-Modul verwaltet
|
||||
$GLOBALS['BE_MOD']['content']['calendar']['tables'][] = 'tl_wk_booking';
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\NotificationRouter;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_calendar'];
|
||||
|
||||
$dca['palettes']['__selector__'][] = 'wkEnable';
|
||||
|
||||
$notificationFields = ['wkNotificationDefault'];
|
||||
|
||||
foreach (NotificationRouter::CONDITIONS as $condition) {
|
||||
$notificationFields[] = 'wkNotificationExplanation'.$condition;
|
||||
$notificationFields[] = 'wkNotification'.$condition;
|
||||
}
|
||||
|
||||
$dca['subpalettes']['wkEnable'] = implode(',', [
|
||||
'wkBookingForm', 'wkBookingPage', 'wkConfirmationPage', 'wkInquiryPage',
|
||||
'wkDeadlineDays', 'wkDeadlineDisplayDays', 'wkOwnVenues',
|
||||
'wkAuthor', 'wkInstructorGroup', 'wkSpeakerGroup',
|
||||
'wkNewsletterChannels', 'wkNewsletterPage', 'wkNewsletterText',
|
||||
...$notificationFields,
|
||||
]);
|
||||
|
||||
PaletteManipulator::create()
|
||||
->addLegend('wk_legend', 'title_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addField('wkEnable', 'wk_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->applyToPalette('default', 'tl_calendar')
|
||||
;
|
||||
|
||||
$fields = [
|
||||
'wkEnable' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['submitOnChange' => true, 'tl_class' => 'w50 m12'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'wkBookingForm' => [
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_form.title',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkBookingPage' => [
|
||||
'inputType' => 'pageTree',
|
||||
'foreignKey' => 'tl_page.title',
|
||||
'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkConfirmationPage' => [
|
||||
'inputType' => 'pageTree',
|
||||
'foreignKey' => 'tl_page.title',
|
||||
'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkInquiryPage' => [
|
||||
'inputType' => 'pageTree',
|
||||
'foreignKey' => 'tl_page.title',
|
||||
'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkDeadlineDays' => [
|
||||
'default' => 1,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 1],
|
||||
],
|
||||
'wkDeadlineDisplayDays' => [
|
||||
'default' => 31,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 31],
|
||||
],
|
||||
'wkOwnVenues' => [
|
||||
'default' => 'TPI Moritzburg',
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:60px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'wkAuthor' => [
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_user.name',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkInstructorGroup' => [
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_member_group.name',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkSpeakerGroup' => [
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_member_group.name',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkNewsletterChannels' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false],
|
||||
],
|
||||
'wkNewsletterPage' => [
|
||||
'inputType' => 'pageTree',
|
||||
'foreignKey' => 'tl_page.title',
|
||||
'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkNewsletterText' => [
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:120px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'wkNotificationDefault' => [
|
||||
'inputType' => 'select',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
];
|
||||
|
||||
foreach (NotificationRouter::CONDITIONS as $condition) {
|
||||
$fields['wkNotificationExplanation'.$condition] = [
|
||||
'inputType' => 'explanation',
|
||||
'eval' => [
|
||||
'class' => 'tl_info',
|
||||
'text' => &$GLOBALS['TL_LANG']['tl_calendar']['wkNotificationExplanation'.$condition],
|
||||
'tl_class' => 'long clr',
|
||||
],
|
||||
];
|
||||
|
||||
$fields['wkNotification'.$condition] = [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_calendar']['wkNotificationCondition'],
|
||||
'inputType' => 'select',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
$dca['fields'] = array_merge($dca['fields'], $fields);
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Mummert\ContaoWkSyncBundle\Import\WkState;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
|
||||
|
||||
$dca['config']['ctable'][] = 'tl_wk_booking';
|
||||
|
||||
$dca['list']['operations']['wk_bookings'] = [
|
||||
'href' => 'table=tl_wk_booking',
|
||||
'icon' => 'mgroup.svg',
|
||||
];
|
||||
|
||||
$manipulator = PaletteManipulator::create()
|
||||
->addLegend('wk_legend', 'title_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addLegend('wk_content_legend', 'wk_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addLegend('wk_people_legend', 'wk_content_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addLegend('wk_price_legend', 'wk_people_legend', PaletteManipulator::POSITION_AFTER, true)
|
||||
->addLegend('wk_organizer_legend', 'wk_price_legend', PaletteManipulator::POSITION_AFTER, true)
|
||||
->addLegend('wk_booking_legend', 'wk_organizer_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addField(['wkId', 'wkState', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft', 'wkImported'], 'wk_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->addField(['wkSubTitle', 'wkSubTitleAdditional', 'wkCategories', 'wkCategoryPrimary', 'wkCategoryText', 'wkAudience', 'wkTags', 'wkTopics', 'wkAdditionalDates'], 'wk_content_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->addField(['wkInstructor', 'wkSpeakers'], 'wk_people_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->addField(['wkPrice1', 'wkPriceType1', 'wkPrice2', 'wkPriceType2', 'wkPrice3', 'wkPriceType3', 'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6'], 'wk_price_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->addField(['wkOrganizer', 'wkOrganizerAdditional', 'wkOrganizerSalutation', 'wkOrganizerAcademicTitle', 'wkOrganizerFirstname', 'wkOrganizerLastname', 'wkOrganizerStreet', 'wkOrganizerPostal', 'wkOrganizerCity', 'wkOrganizerCountryCode', 'wkOrganizerEmail'], 'wk_organizer_legend', PaletteManipulator::POSITION_APPEND)
|
||||
->addField(['wkNotification', 'wkConfirmationPage'], 'wk_booking_legend', PaletteManipulator::POSITION_APPEND)
|
||||
;
|
||||
|
||||
foreach (array_keys($dca['palettes']) as $palette) {
|
||||
if ('__selector__' === $palette || !\is_string($dca['palettes'][$palette])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manipulator->applyToPalette($palette, 'tl_calendar_events');
|
||||
}
|
||||
|
||||
$text = static fn (int $length, string $class = 'w50', array $eval = []): array => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => $length, 'tl_class' => $class, ...$eval],
|
||||
'sql' => ['type' => 'string', 'length' => $length, 'default' => ''],
|
||||
];
|
||||
|
||||
$price = static fn (): array => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'prcnt', 'maxlength' => 10, 'tl_class' => 'w50'],
|
||||
'sql' => "decimal(10,2) NOT NULL default '0.00'",
|
||||
];
|
||||
|
||||
$fields = [
|
||||
'wkId' => [
|
||||
'search' => true,
|
||||
'filter' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 16, 'readonly' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'wkState' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => WkState::values(),
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_calendar_events']['wkStateRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 2, 'default' => ''],
|
||||
],
|
||||
'wkInternalEventNumber' => $text(64),
|
||||
'wkEventType' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkMaxPlaces' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkPlacesLeft' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkImported' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkSubTitle' => $text(255, 'clr long'),
|
||||
'wkSubTitleAdditional' => $text(255, 'long'),
|
||||
'wkCategories' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'foreignKey' => 'tl_wk_category.title',
|
||||
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false],
|
||||
],
|
||||
'wkCategoryPrimary' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_wk_category.title',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkCategoryText' => $text(255),
|
||||
'wkAudience' => $text(255),
|
||||
'wkTags' => $text(255),
|
||||
'wkTopics' => [
|
||||
'search' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'wkAdditionalDates' => $text(512, 'clr long'),
|
||||
'wkInstructor' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_member.CONCAT(lastname," ",firstname)',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkSpeakers' => [
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_member.CONCAT(lastname," ",firstname)',
|
||||
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false],
|
||||
],
|
||||
'wkPrice1' => $price(),
|
||||
'wkPriceType1' => $text(64),
|
||||
'wkPrice2' => $price(),
|
||||
'wkPriceType2' => $text(64),
|
||||
'wkPrice3' => $price(),
|
||||
'wkPriceType3' => $text(64),
|
||||
'wkPrice4' => $price(),
|
||||
'wkPriceType4' => $text(64),
|
||||
'wkPrice5' => $price(),
|
||||
'wkPriceType5' => $text(64),
|
||||
'wkPrice6' => $price(),
|
||||
'wkPriceType6' => $text(64),
|
||||
'wkOrganizer' => $text(128),
|
||||
'wkOrganizerAdditional' => $text(128),
|
||||
'wkOrganizerSalutation' => $text(64),
|
||||
'wkOrganizerAcademicTitle' => $text(64),
|
||||
'wkOrganizerFirstname' => $text(64),
|
||||
'wkOrganizerLastname' => $text(128),
|
||||
'wkOrganizerStreet' => $text(128),
|
||||
'wkOrganizerPostal' => $text(16),
|
||||
'wkOrganizerCity' => $text(128),
|
||||
'wkOrganizerCountryCode' => $text(16),
|
||||
'wkOrganizerEmail' => $text(255, 'w50', ['decodeEntities' => true]),
|
||||
'wkNotification' => [
|
||||
'inputType' => 'select',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkConfirmationPage' => [
|
||||
'inputType' => 'pageTree',
|
||||
'foreignKey' => 'tl_page.title',
|
||||
'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
];
|
||||
|
||||
$dca['fields'] = array_merge($dca['fields'], $fields);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Mummert\ContaoWkSyncBundle\Controller\FrontendModule\BookingFormController;
|
||||
use Mummert\ContaoWkSyncBundle\Controller\FrontendModule\BookingStatusController;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_module'];
|
||||
|
||||
// Eigene Module
|
||||
$dca['palettes'][BookingFormController::TYPE] = '{title_legend},name,headline,type;{config_legend},cal_calendar;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID';
|
||||
$dca['palettes'][BookingStatusController::TYPE] = '{title_legend},name,headline,type;{config_legend},cal_calendar;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID';
|
||||
|
||||
// Kategoriefilter für die Kalender-Module des Contao-Kerns
|
||||
$dca['fields']['wkCategories'] = [
|
||||
'inputType' => 'checkbox',
|
||||
'foreignKey' => 'tl_wk_category.title',
|
||||
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false],
|
||||
];
|
||||
|
||||
foreach (['eventlist', 'eventmenu', 'eventmenucal_day', 'calendar'] as $palette) {
|
||||
if (!isset($dca['palettes'][$palette])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PaletteManipulator::create()
|
||||
->addField('wkCategories', 'cal_calendar', PaletteManipulator::POSITION_AFTER)
|
||||
->applyToPalette($palette, 'tl_module')
|
||||
;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\DataContainer;
|
||||
use Contao\DC_Table;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingFields;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_wk_booking'] = [
|
||||
'config' => [
|
||||
'dataContainer' => DC_Table::class,
|
||||
'ptable' => 'tl_calendar_events',
|
||||
'enableVersioning' => true,
|
||||
'doNotCopyRecords' => true,
|
||||
'notCopyable' => true,
|
||||
'sql' => [
|
||||
'keys' => [
|
||||
'id' => 'primary',
|
||||
'pid' => 'index',
|
||||
'token' => 'unique',
|
||||
'state' => 'index',
|
||||
'email' => 'index',
|
||||
],
|
||||
],
|
||||
],
|
||||
'list' => [
|
||||
'sorting' => [
|
||||
'mode' => DataContainer::MODE_PARENT,
|
||||
'fields' => ['dateCreated DESC'],
|
||||
'headerFields' => ['title', 'wkId', 'startDate', 'endDate', 'wkPlacesLeft'],
|
||||
'panelLayout' => 'filter;search,limit',
|
||||
'disableGrouping' => true,
|
||||
],
|
||||
'global_operations' => [
|
||||
'all',
|
||||
],
|
||||
'operations' => [
|
||||
'edit',
|
||||
'delete',
|
||||
'show',
|
||||
],
|
||||
],
|
||||
'palettes' => [
|
||||
'__selector__' => ['addDifferentInvoiceAddress', 'addNotes', 'teacherPosition'],
|
||||
'default' => '{state_legend},state,dateCreated,notification;'
|
||||
.'{person_legend},gender,academicTitle,firstname,lastname,street,postal,city,email,phone,position,birthday;'
|
||||
.'{school_legend},teacherPosition;'
|
||||
.'{stay_legend},vegetarian,overnightStay;'
|
||||
.'{invoice_legend},addDifferentInvoiceAddress;'
|
||||
.'{notes_legend},addNotes;'
|
||||
.'{consent_legend},privacyAccepted,agbAccepted,newsletter',
|
||||
],
|
||||
'subpalettes' => [
|
||||
'addDifferentInvoiceAddress' => 'invoiceInstitution,invoiceGender,invoiceAcademicTitle,invoiceFirstname,invoiceLastname,invoiceStreet,invoicePostal,invoiceCity',
|
||||
'addNotes' => 'notes',
|
||||
'teacherPosition_state' => 'schoolName,schoolCity',
|
||||
'teacherPosition_other' => 'schoolName,schoolCity',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
|
||||
],
|
||||
'pid' => [
|
||||
'foreignKey' => 'tl_calendar_events.title',
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'belongsTo', 'load' => 'lazy'],
|
||||
],
|
||||
'tstamp' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'dateCreated' => [
|
||||
'filter' => true,
|
||||
'sorting' => true,
|
||||
'flag' => DataContainer::SORT_DAY_DESC,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'datim', 'datepicker' => true, 'doNotCopy' => true, 'tl_class' => 'w50 wizard'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'token' => [
|
||||
'eval' => ['doNotCopy' => true],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => '', 'platformOptions' => ['collation' => 'ascii_bin']],
|
||||
],
|
||||
'state' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => InboxState::values(),
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'blankOptionLabel' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef']['new'], 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'exportedAt' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'notification' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['readonly' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'gender' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::GENDERS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'academicTitle' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 64, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'firstname' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['mandatory' => true, 'maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'lastname' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['mandatory' => true, 'maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'street' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'postal' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 32, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
|
||||
],
|
||||
'city' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'email' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['mandatory' => true, 'rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'phone' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'phone', 'maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'position' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::POSITIONS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['positionRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
|
||||
],
|
||||
'birthday' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'date', 'maxlength' => 10, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 10, 'default' => ''],
|
||||
],
|
||||
'teacherPosition' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::TEACHER_POSITIONS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['teacherPositionRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'submitOnChange' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'schoolName' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'schoolCity' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'vegetarian' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'overnightStay' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::OVERNIGHT_STAYS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['overnightStayRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'addDifferentInvoiceAddress' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['submitOnChange' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'invoiceInstitution' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'invoiceGender' => [
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::GENDERS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'invoiceAcademicTitle' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 64, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'invoiceFirstname' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'invoiceLastname' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'invoiceStreet' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'invoicePostal' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 32, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
|
||||
],
|
||||
'invoiceCity' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'addNotes' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['submitOnChange' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'notes' => [
|
||||
'search' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'privacyAccepted' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'agbAccepted' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'newsletter' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\DataContainer;
|
||||
use Contao\DC_Table;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_wk_category'] = [
|
||||
'config' => [
|
||||
'dataContainer' => DC_Table::class,
|
||||
'enableVersioning' => true,
|
||||
'markAsCopy' => 'title',
|
||||
'sql' => [
|
||||
'keys' => [
|
||||
'id' => 'primary',
|
||||
'alias' => 'index',
|
||||
],
|
||||
],
|
||||
],
|
||||
'list' => [
|
||||
'sorting' => [
|
||||
'mode' => DataContainer::MODE_SORTED,
|
||||
'fields' => ['sorting'],
|
||||
'flag' => DataContainer::SORT_ASC,
|
||||
'panelLayout' => 'search,limit',
|
||||
],
|
||||
'label' => [
|
||||
'fields' => ['sorting', 'title', 'kind', 'wkNames'],
|
||||
'showColumns' => true,
|
||||
],
|
||||
'operations' => [
|
||||
'edit',
|
||||
'copy',
|
||||
'delete',
|
||||
'show',
|
||||
],
|
||||
],
|
||||
'palettes' => [
|
||||
'default' => '{title_legend},title,alias,sorting;{wk_legend},kind,wkNames',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
|
||||
],
|
||||
'tstamp' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'sorting' => [
|
||||
'inputType' => 'text',
|
||||
'sorting' => true,
|
||||
'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'title' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['mandatory' => true, 'basicEntities' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'alias' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => '', 'platformOptions' => ['collation' => 'ascii_bin']],
|
||||
],
|
||||
'kind' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => array_map(static fn (CategoryKind $kind): string => $kind->value, CategoryKind::cases()),
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_category']['kindRef'],
|
||||
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'tl_class' => 'w50 clr'],
|
||||
'sql' => ['type' => 'string', 'length' => 32, 'default' => CategoryKind::Other->value],
|
||||
],
|
||||
'wkNames' => [
|
||||
'search' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:80px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\DataContainer;
|
||||
use Contao\DC_Table;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingFields;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_wk_inquiry'] = [
|
||||
'config' => [
|
||||
'dataContainer' => DC_Table::class,
|
||||
'enableVersioning' => true,
|
||||
'notCopyable' => true,
|
||||
'sql' => [
|
||||
'keys' => [
|
||||
'id' => 'primary',
|
||||
'state' => 'index',
|
||||
'event' => 'index',
|
||||
],
|
||||
],
|
||||
],
|
||||
'list' => [
|
||||
'sorting' => [
|
||||
'mode' => DataContainer::MODE_SORTABLE,
|
||||
'fields' => ['dateCreated DESC'],
|
||||
'panelLayout' => 'filter;sort,search,limit',
|
||||
],
|
||||
'label' => [
|
||||
'fields' => ['dateCreated', 'lastname', 'firstname', 'subject', 'state'],
|
||||
'showColumns' => true,
|
||||
],
|
||||
'global_operations' => [
|
||||
'all',
|
||||
],
|
||||
'operations' => [
|
||||
'edit',
|
||||
'delete',
|
||||
'show',
|
||||
],
|
||||
],
|
||||
'palettes' => [
|
||||
'default' => '{state_legend},state,dateCreated,event;'
|
||||
.'{person_legend},gender,academicTitle,firstname,lastname,street,postal,city,email,phone;'
|
||||
.'{message_legend},subject,notes;'
|
||||
.'{consent_legend},newsletter,privacy',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
|
||||
],
|
||||
'tstamp' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'dateCreated' => [
|
||||
'filter' => true,
|
||||
'sorting' => true,
|
||||
'flag' => DataContainer::SORT_DAY_DESC,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'datim', 'datepicker' => true, 'tl_class' => 'w50 wizard'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'state' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => InboxState::values(),
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'blankOptionLabel' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef']['new'], 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'exportedAt' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'event' => [
|
||||
'filter' => true,
|
||||
'inputType' => 'select',
|
||||
'foreignKey' => 'tl_calendar_events.title',
|
||||
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'gender' => [
|
||||
'inputType' => 'select',
|
||||
'options' => BookingFields::GENDERS,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'],
|
||||
'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'academicTitle' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 64, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'firstname' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'lastname' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'street' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'postal' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 32, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
|
||||
],
|
||||
'city' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
|
||||
],
|
||||
'email' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'phone' => [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'subject' => [
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 255, 'tl_class' => 'clr long'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'notes' => [
|
||||
'search' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'newsletter' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
'privacy' => [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'boolean', 'default' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contao\DataContainer;
|
||||
use Contao\DC_Table;
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_wk_sync_log'] = [
|
||||
'config' => [
|
||||
'dataContainer' => DC_Table::class,
|
||||
'closed' => true,
|
||||
'notEditable' => true,
|
||||
'notCopyable' => true,
|
||||
'sql' => [
|
||||
'keys' => [
|
||||
'id' => 'primary',
|
||||
'type,tstamp' => 'index',
|
||||
],
|
||||
],
|
||||
],
|
||||
'list' => [
|
||||
'sorting' => [
|
||||
'mode' => DataContainer::MODE_SORTABLE,
|
||||
'fields' => ['tstamp DESC'],
|
||||
'panelLayout' => 'filter;limit',
|
||||
],
|
||||
'label' => [
|
||||
'fields' => ['tstamp', 'type', 'status', 'filename', 'user'],
|
||||
'showColumns' => true,
|
||||
],
|
||||
'operations' => [
|
||||
'show',
|
||||
'delete',
|
||||
],
|
||||
],
|
||||
'palettes' => [
|
||||
'default' => 'tstamp,type,status,user,calendar,filename,summary,messages',
|
||||
],
|
||||
'fields' => [
|
||||
'id' => [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
|
||||
],
|
||||
'tstamp' => [
|
||||
'sorting' => true,
|
||||
'flag' => DataContainer::SORT_DAY_DESC,
|
||||
'eval' => ['rgxp' => 'datim'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'type' => [
|
||||
'filter' => true,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_sync_log']['typeRef'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'status' => [
|
||||
'filter' => true,
|
||||
'reference' => &$GLOBALS['TL_LANG']['tl_wk_sync_log']['statusRef'],
|
||||
'sql' => ['type' => 'string', 'length' => 16, 'default' => ''],
|
||||
],
|
||||
'user' => [
|
||||
'foreignKey' => 'tl_user.name',
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'calendar' => [
|
||||
'foreignKey' => 'tl_calendar.title',
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'filename' => [
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'path' => [
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'summary' => [
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'messages' => [
|
||||
'sql' => ['type' => 'text', 'length' => 16777215, 'notnull' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['MSC'];
|
||||
|
||||
// Benachrichtigungstyp
|
||||
$GLOBALS['TL_LANG']['tl_nc_notification']['type']['wk_sync'] = 'W&K-Sync';
|
||||
$GLOBALS['TL_LANG']['tl_nc_notification']['type']['wk_booking'] = ['W&K-Anmeldung', 'Wird verschickt, wenn eine Anmeldung zu einer Veranstaltung eingeht.'];
|
||||
|
||||
// Newsletter-Bestätigung
|
||||
$lang['wk_newsletter_optin_text'] = "Sie haben den Newsletter abonniert. Bitte klicken Sie auf den folgenden Link, um Ihr Abonnement zu aktivieren:\n\n##link##\n\nDer Bestätigungslink ist 24 Stunden gültig. Sie können Ihr Abonnement jederzeit beenden.\n\nFalls Sie den Newsletter nicht selbst abonniert haben, ignorieren Sie diese E-Mail bitte.";
|
||||
|
||||
// Frontend
|
||||
$lang['wk_booking']['course_number'] = 'Kursnummer';
|
||||
$lang['wk_booking']['places_left'] = 'Noch %s freie Plätze verfügbar';
|
||||
$lang['wk_booking']['deadline'] = 'Anmeldung möglich bis %s';
|
||||
$lang['wk_booking']['book_now'] = 'Jetzt anmelden';
|
||||
$lang['wk_booking']['questions'] = 'Falls Sie vorher Fragen haben, verwenden Sie bitte unser';
|
||||
$lang['wk_booking']['inquiry_form'] = 'Anfrageformular';
|
||||
$lang['wk_booking']['late_inquiry'] = 'Eine kurzfristige Anmeldung ist eventuell dennoch möglich. Nutzen Sie dazu das';
|
||||
$lang['wk_booking']['status']['not_enabled'] = 'Für diese Veranstaltung ist keine Online-Anmeldung eingerichtet.';
|
||||
$lang['wk_booking']['status']['unpublished'] = 'Diese Veranstaltung ist nicht veröffentlicht.';
|
||||
$lang['wk_booking']['status']['cancelled'] = 'Diese Veranstaltung wurde abgesagt.';
|
||||
$lang['wk_booking']['status']['closed_audience'] = 'Zu dieser Veranstaltung ist keine Anmeldung möglich (geschlossener Teilnehmerkreis).';
|
||||
$lang['wk_booking']['status']['external_email'] = 'Bitte melden Sie sich für diesen Kurs über die E-Mail-Adresse';
|
||||
$lang['wk_booking']['status']['external_url'] = 'Bitte melden Sie sich für diesen Kurs über die Webseite';
|
||||
$lang['wk_booking']['status']['past'] = 'Diese Veranstaltung hat bereits begonnen oder ist vorbei.';
|
||||
$lang['wk_booking']['status']['deadline'] = 'Für diese Veranstaltung können keine Anmeldungen mehr entgegengenommen werden.';
|
||||
$lang['wk_booking']['status']['full'] = 'Leider sind keine freien Plätze mehr verfügbar.';
|
||||
$lang['wk_booking']['status']['no_form'] = 'Für diese Veranstaltung ist kein Anmeldeformular hinterlegt.';
|
||||
$lang['wk_booking']['error']['not_enabled'] = 'Für diese Veranstaltung ist keine Online-Anmeldung möglich.';
|
||||
$lang['wk_booking']['error']['unpublished'] = 'Diese Veranstaltung ist nicht mehr verfügbar.';
|
||||
$lang['wk_booking']['error']['cancelled'] = 'Diese Veranstaltung wurde inzwischen abgesagt.';
|
||||
$lang['wk_booking']['error']['closed_audience'] = 'Zu dieser Veranstaltung ist keine Anmeldung möglich.';
|
||||
$lang['wk_booking']['error']['external'] = 'Die Anmeldung zu dieser Veranstaltung erfolgt beim Veranstalter.';
|
||||
$lang['wk_booking']['error']['past'] = 'Diese Veranstaltung hat bereits begonnen.';
|
||||
$lang['wk_booking']['error']['deadline'] = 'Der Anmeldeschluss ist inzwischen erreicht.';
|
||||
$lang['wk_booking']['error']['full'] = 'Leider wurde der letzte freie Platz inzwischen vergeben.';
|
||||
$lang['wk_booking']['error']['no_form'] = 'Die Anmeldung ist derzeit nicht möglich.';
|
||||
|
||||
// Backend-Modul
|
||||
$lang['wk_sync']['nav']['import'] = 'Import aus W&K';
|
||||
$lang['wk_sync']['nav']['inbox'] = 'Posteingang';
|
||||
$lang['wk_sync']['nav']['exports'] = 'Exporte für W&K';
|
||||
$lang['wk_sync']['state']['new'] = 'Neu';
|
||||
$lang['wk_sync']['state']['download_list'] = 'Download-Liste';
|
||||
$lang['wk_sync']['state']['processed'] = 'Bereits bearbeitet';
|
||||
$lang['wk_sync']['state']['spam'] = 'Ignoriert/Spam';
|
||||
$lang['wk_sync']['state']['exported'] = 'Exportiert';
|
||||
$lang['wk_sync']['status']['preview'] = 'Vorschau, noch nicht angewendet';
|
||||
$lang['wk_sync']['status']['applied'] = 'Angewendet';
|
||||
$lang['wk_sync']['status']['failed'] = 'Verworfen/fehlgeschlagen';
|
||||
$lang['wk_sync']['status']['done'] = 'Erledigt';
|
||||
$lang['wk_sync']['action']['create'] = 'neue Seminare';
|
||||
$lang['wk_sync']['action']['update'] = 'geänderte Seminare';
|
||||
$lang['wk_sync']['action']['cancel'] = 'zu stornierende Seminare (nicht mehr in der Datei)';
|
||||
$lang['wk_sync']['action']['unchanged'] = 'unveränderte Seminare';
|
||||
$lang['wk_sync']['action']['skip'] = 'übersprungene Datensätze';
|
||||
$lang['wk_sync']['import']['headline'] = 'Seminare aus W&K importieren';
|
||||
$lang['wk_sync']['import']['calendar'] = 'Kalender';
|
||||
$lang['wk_sync']['import']['file'] = 'W&K-Exportdatei (CSV)';
|
||||
$lang['wk_sync']['import']['file_help'] = 'Die in W&K exportierte Seminarliste (Semikolon-getrennt). Maximale Dateigröße: %s MB.';
|
||||
$lang['wk_sync']['import']['hint'] = 'Wichtig: In W&K das größtmögliche Zeitintervall exportieren. Seminare, die nicht in der Datei stehen, werden als storniert markiert und ausgeblendet. Nach dem Hochladen erscheint zuerst eine Vorschau, erst nach Bestätigung wird etwas geändert.';
|
||||
$lang['wk_sync']['import']['upload_button'] = 'Datei hochladen und Vorschau anzeigen';
|
||||
$lang['wk_sync']['import']['recent'] = 'Letzte Importe';
|
||||
$lang['wk_sync']['import']['none'] = 'Bisher wurde noch nichts importiert.';
|
||||
$lang['wk_sync']['import']['no_categories'] = 'Es sind noch keine W&K-Kategorien angelegt. Ohne Kategorien werden alle Seminare storniert. Bitte zuerst unter „W&K-Sync → Kategorien“ anlegen (oder „wk-sync:categories:seed“ ausführen).';
|
||||
$lang['wk_sync']['import']['no_calendar'] = 'In keinem Kalender ist der W&K-Sync aktiviert. Bitte im gewünschten Kalender „W&K-Sync aktivieren“ einschalten.';
|
||||
$lang['wk_sync']['import']['applied'] = 'Der Import wurde angewendet: %s neu, %s geändert, %s storniert, %s unverändert.';
|
||||
$lang['wk_sync']['import']['not_found'] = 'Der Importvorgang wurde nicht gefunden.';
|
||||
$lang['wk_sync']['import']['discarded'] = 'Die Vorschau wurde verworfen. Es wurde nichts geändert.';
|
||||
$lang['wk_sync']['upload']['missing'] = 'Bitte eine CSV-Datei auswählen.';
|
||||
$lang['wk_sync']['upload']['too_large'] = 'Die Datei ist zu groß.';
|
||||
$lang['wk_sync']['upload']['wrong_type'] = 'Bitte eine CSV-Datei (.csv) hochladen.';
|
||||
$lang['wk_sync']['preview']['headline'] = 'Vorschau: %s → Kalender „%s“';
|
||||
$lang['wk_sync']['preview']['warnings'] = 'Warnungen';
|
||||
$lang['wk_sync']['preview']['errors'] = 'Fehler';
|
||||
$lang['wk_sync']['preview']['apply'] = 'Import jetzt anwenden';
|
||||
$lang['wk_sync']['preview']['confirm'] = 'Die angezeigten Änderungen werden jetzt übernommen. Seminare, die nicht in der Datei stehen, werden storniert und ausgeblendet. Fortfahren?';
|
||||
$lang['wk_sync']['preview']['discard'] = 'Vorschau verwerfen';
|
||||
$lang['wk_sync']['preview']['nothing'] = 'Die Datei enthält keine Änderungen gegenüber dem aktuellen Stand.';
|
||||
$lang['wk_sync']['preview']['messages'] = 'Meldungen';
|
||||
$lang['wk_sync']['log']['date'] = 'Datum';
|
||||
$lang['wk_sync']['log']['file'] = 'Datei';
|
||||
$lang['wk_sync']['log']['calendar'] = 'Kalender';
|
||||
$lang['wk_sync']['log']['user'] = 'Benutzer';
|
||||
$lang['wk_sync']['log']['status'] = 'Status';
|
||||
$lang['wk_sync']['log']['summary'] = 'Ergebnis';
|
||||
$lang['wk_sync']['log']['result'] = '%s neu, %s geändert, %s storniert';
|
||||
$lang['wk_sync']['log']['planned'] = 'geplant: %s neu, %s geändert, %s storniert, %s übersprungen';
|
||||
$lang['wk_sync']['inbox']['headline'] = 'Anmeldungen und Kontaktanfragen';
|
||||
$lang['wk_sync']['inbox']['empty'] = 'In diesem Ordner gibt es aktuell keine Datensätze.';
|
||||
$lang['wk_sync']['inbox']['date'] = 'Eingegangen';
|
||||
$lang['wk_sync']['inbox']['person'] = 'Person';
|
||||
$lang['wk_sync']['inbox']['data'] = 'Daten';
|
||||
$lang['wk_sync']['inbox']['type_booking'] = 'Anmeldung';
|
||||
$lang['wk_sync']['inbox']['type_inquiry'] = 'Kontaktanfrage';
|
||||
$lang['wk_sync']['inbox']['booking_for'] = 'Anmeldung zur Veranstaltung Nr. %s: %s';
|
||||
$lang['wk_sync']['inbox']['inquiry_for'] = 'Kontaktanfrage zur Veranstaltung Nr. %s: %s';
|
||||
$lang['wk_sync']['inbox']['edit'] = 'Bearbeiten';
|
||||
$lang['wk_sync']['inbox']['move_to'] = 'Verschieben nach „%s“';
|
||||
$lang['wk_sync']['inbox']['moved'] = '%s Datensätze wurden nach „%s“ verschoben.';
|
||||
$lang['wk_sync']['inbox']['invalid_target'] = 'Ungültiger Zielordner.';
|
||||
$lang['wk_sync']['inbox']['select_all'] = 'Alle auswählen';
|
||||
$lang['wk_sync']['inbox']['bulk_label'] = 'Ausgewählte verschieben nach';
|
||||
$lang['wk_sync']['inbox']['bulk_button'] = 'Verschieben';
|
||||
$lang['wk_sync']['inbox']['duplicate'] = 'evtl. doppelt';
|
||||
$lang['wk_sync']['inbox']['duplicate_help'] = 'Zu dieser E-Mail-Adresse gibt es bereits eine weitere Anmeldung für dieselbe Veranstaltung.';
|
||||
$lang['wk_sync']['export']['button'] = 'Download-Liste für W&K exportieren';
|
||||
$lang['wk_sync']['export']['help'] = 'Alle Datensätze der Download-Liste werden in eine CSV-Datei geschrieben, als „Exportiert“ markiert und die freien Plätze der Veranstaltungen entsprechend reduziert. Die Datei bitte zeitnah in W&K importieren.';
|
||||
$lang['wk_sync']['export']['confirm'] = 'Der Export kann nicht rückgängig gemacht werden. Die Datensätze werden als exportiert markiert. Fortfahren?';
|
||||
$lang['wk_sync']['export']['empty'] = 'Die Download-Liste ist leer.';
|
||||
$lang['wk_sync']['export']['done'] = 'Export erstellt: %s Anmeldungen und %s Kontaktanfragen. Die Datei kann jetzt heruntergeladen werden.';
|
||||
$lang['wk_sync']['exports']['headline'] = 'Exporte für W&K';
|
||||
$lang['wk_sync']['exports']['help'] = 'Jede Exportdatei kann hier erneut heruntergeladen werden, z. B. wenn der Download abgebrochen wurde.';
|
||||
$lang['wk_sync']['exports']['none'] = 'Bisher wurde noch nichts exportiert.';
|
||||
$lang['wk_sync']['exports']['content'] = 'Inhalt';
|
||||
$lang['wk_sync']['exports']['summary'] = '%s Anmeldungen, %s Kontaktanfragen';
|
||||
$lang['wk_sync']['exports']['download'] = 'CSV herunterladen';
|
||||
$lang['wk_sync']['exports']['missing'] = 'Datei nicht mehr vorhanden';
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync'] = 'W&K-Sync';
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_tools'] = ['Import & Posteingang', 'Seminare aus W&K importieren, Anmeldungen bearbeiten und für W&K exportieren'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_categories'] = ['Kategorien', 'W&K-Kategorien (Bereiche) und ihre Zuordnung pflegen'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_inquiries'] = ['Kontaktanfragen', 'Anfragen aus dem Anfrageformular'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_log'] = ['Protokoll', 'Protokoll der Importe und Exporte'];
|
||||
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_sync'] = 'W&K-Sync';
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_booking_form'] = ['W&K-Anmeldeformular', 'Anmeldeformular zur Veranstaltung aus der URL (Alias der Veranstaltung).'];
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_booking_status'] = ['W&K-Anmeldestatus', 'Freie Plätze, Anmeldeschluss und Link zur Anmeldung für die Veranstaltung aus der URL.'];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['recipient_email'] = 'E-Mail-Adresse der angemeldeten Person (für „Empfänger“)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['admin_email'] = 'Administrator-E-Mail-Adresse';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_email'] = 'E-Mail-Adresse aus der Anmeldung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_wkOrganizerEmail'] = 'E-Mail-Adresse des Hauptverantwortlichen (W&K)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_confirmation_page_url'] = 'Link zur Bestätigungsseite (mit Token der Anmeldung)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_event_wk_id'] = 'W&K-Nummer der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_link'] = 'Link zur Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_datetime'] = 'Datum und Uhrzeit der Veranstaltung, z. B. „24.09.2026 - 25.09.2026, 09:00 - 16:00“';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_categories'] = 'Kategorien der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_speakers'] = 'Referenten der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['wk_notification_conditions'] = 'Erfüllte Benachrichtigungs-Bedingungen (zur Kontrolle)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_*'] = 'Alle Felder der Anmeldung, z. B. ##subscription_firstname##, ##subscription_lastname##, ##subscription_street##, ##subscription_notes##; Auswahlfelder zusätzlich als ##subscription_gender_label##, ##subscription_overnightStay_label##, Ja/Nein-Felder als ##subscription_vegetarian_label##';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_*'] = 'Alle Felder der Veranstaltung, z. B. ##event_title##, ##event_location##, ##event_startDate##, ##event_wkInternalEventNumber##, ##event_wkOrganizerCity##';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['calendar_*'] = 'Alle Felder des Kalenders, z. B. ##calendar_title##';
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_calendar'];
|
||||
|
||||
$lang['wk_legend'] = 'W&K-Sync';
|
||||
|
||||
$lang['wkEnable'] = ['W&K-Sync aktivieren', 'Seminare aus W&K in diesen Kalender importieren und Online-Anmeldungen ermöglichen.'];
|
||||
$lang['wkBookingForm'] = ['Anmeldeformular', 'Formular aus dem Formulargenerator, das für Anmeldungen verwendet wird (Feldnamen wie in tl_wk_booking; anlegen mit „wk-sync:form:create“).'];
|
||||
$lang['wkBookingPage'] = ['Seite mit dem Anmeldeformular', 'Seite, auf der das Modul „W&K-Anmeldeformular“ liegt. Der Link „Jetzt anmelden“ führt dorthin.'];
|
||||
$lang['wkConfirmationPage'] = ['Bestätigungsseite', 'Seite nach erfolgreicher Anmeldung, z. B. „Anmeldung eingegangen“. Kann je Veranstaltung übersteuert werden.'];
|
||||
$lang['wkInquiryPage'] = ['Seite mit dem Anfrageformular', 'Seite mit dem Kontakt-/Anfrageformular; sie wird mit ?event=ID verlinkt.'];
|
||||
$lang['wkDeadlineDays'] = ['Anmeldeschluss (Tage vor Beginn)', 'Bis so viele Tage vor Beginn sind Anmeldungen technisch möglich. 1 = bis einen Tag vor Beginn.'];
|
||||
$lang['wkDeadlineDisplayDays'] = ['Angezeigter Anmeldeschluss (Tage vor Beginn)', 'Datum, das als „Anmeldung möglich bis“ angezeigt wird. 0 = wie der technische Anmeldeschluss.'];
|
||||
$lang['wkOwnVenues'] = ['Eigene Veranstaltungsorte', 'Eine Bezeichnung pro Zeile. Veranstaltungsorte, die so beginnen (z. B. „TPI Moritzburg (barrierefrei)“), gelten als „im eigenen Haus“ – relevant für die Felder Übernachtung und Verpflegung.'];
|
||||
$lang['wkAuthor'] = ['Autor importierter Veranstaltungen', 'Backend-Benutzer, der als Autor neuer Seminare eingetragen wird (leer = der importierende Benutzer).'];
|
||||
$lang['wkInstructorGroup'] = ['Mitgliedergruppe Kursleitung', 'Kursleitungen aus W&K werden als Mitglieder dieser Gruppe angelegt (leer = keine Mitglieder anlegen).'];
|
||||
$lang['wkSpeakerGroup'] = ['Mitgliedergruppe Referenten', 'Referenten aus W&K werden als Mitglieder dieser Gruppe angelegt (leer = keine Mitglieder anlegen).'];
|
||||
$lang['wkNewsletterChannels'] = ['Newsletter-Verteiler', 'Verteiler, die bei „Newsletter abonnieren“ mit Double-Opt-in abonniert werden.'];
|
||||
$lang['wkNewsletterPage'] = ['Seite zur Newsletter-Aktivierung', 'Seite mit dem Modul „Abonnieren“, die den Aktivierungslink (?token=) verarbeitet.'];
|
||||
$lang['wkNewsletterText'] = ['Text der Aktivierungsmail', 'Optional. Token: ##link##, ##channels##, ##domain##. Leer = Standardtext.'];
|
||||
$lang['wkNotificationDefault'] = ['Standard-Benachrichtigung', 'Wird verschickt, wenn keine der Bedingungen zutrifft und an der Veranstaltung nichts eingestellt ist.'];
|
||||
$lang['wkNotificationCondition'] = ['Benachrichtigung', 'Benachrichtigung vom Typ „W&K-Anmeldung“ für diese Bedingung.'];
|
||||
|
||||
$lang['wkNotificationExplanation1'] = '<strong>Bedingung 1: Online-Kurse</strong> – alle Kategorien, Veranstaltungsort „Online“.';
|
||||
$lang['wkNotificationExplanation2'] = '<strong>Bedingung 2: Tagesveranstaltung Schulfortbildung</strong> – Beginn = Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.';
|
||||
$lang['wkNotificationExplanation3'] = '<strong>Bedingung 3: Tagesveranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen</strong> – Beginn = Ende, Kategorie-Art „Gemeinde“, „Gottesdienst mit Kindern“ oder „Tagungen“.';
|
||||
$lang['wkNotificationExplanation4'] = '<strong>Bedingung 4: Tagesveranstaltung Elementarbereich</strong> – Beginn = Ende, Kategorie-Art „Elementarbereich“.';
|
||||
$lang['wkNotificationExplanation5'] = '<strong>Bedingung 5: Mehrtägige Schulfortbildung</strong> – Beginn ≠ Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.';
|
||||
$lang['wkNotificationExplanation6'] = '<strong>Bedingung 6: Mehrtägige Fortbildung Elementarbereich</strong> – Beginn ≠ Ende, Kategorie-Art „Elementarbereich“.';
|
||||
$lang['wkNotificationExplanation7'] = '<strong>Bedingung 7: Mehrtägige Veranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen</strong> – Beginn ≠ Ende.';
|
||||
$lang['wkNotificationExplanation8'] = '<strong>Bedingung 8: Tagesveranstaltung Evangelische Schulen</strong> – Beginn = Ende, Kategorie-Art „Evangelische Schulen“.';
|
||||
$lang['wkNotificationExplanation9'] = '<strong>Bedingung 9: Mehrtägige Veranstaltung Evangelische Schulen</strong> – Beginn ≠ Ende, Kategorie-Art „Evangelische Schulen“.';
|
||||
$lang['wkNotificationExplanation10'] = '<strong>Bedingung 10: Tagesveranstaltung Mentoring</strong> – Beginn = Ende, Kategorie-Art „Mentoring“.';
|
||||
$lang['wkNotificationExplanation11'] = '<strong>Bedingung 11: Mehrtägige Veranstaltung Mentoring</strong> – Beginn ≠ Ende, Kategorie-Art „Mentoring“.';
|
||||
$lang['wkNotificationExplanation12'] = '<strong>Bedingung 12: Mehrtägige Ausbildung</strong> – Beginn ≠ Ende, Kategorie-Art „Ausbildung“.';
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_calendar_events'];
|
||||
|
||||
$lang['wk_legend'] = 'W&K';
|
||||
$lang['wk_content_legend'] = 'W&K: Inhalte';
|
||||
$lang['wk_people_legend'] = 'W&K: Kursleitung und Referenten';
|
||||
$lang['wk_price_legend'] = 'W&K: Preise';
|
||||
$lang['wk_organizer_legend'] = 'W&K: Hauptverantwortlicher';
|
||||
$lang['wk_booking_legend'] = 'Anmeldung';
|
||||
|
||||
$lang['wkId'] = ['W&K-Nummer', 'Seminarnummer aus W&K (kbnr). Wird beim Import gesetzt.'];
|
||||
$lang['wkState'] = ['W&K-Status', 'Status aus W&K. „Storno“ blendet die Veranstaltung aus.'];
|
||||
$lang['wkInternalEventNumber'] = ['Interne Veranstaltungsnummer', 'Z. B. EXTP-Nummer des Kultusministeriums (kbvsnr).'];
|
||||
$lang['wkEventType'] = ['Veranstaltungsart', 'Zahl aus W&K (kbvsart). 3 = Anmeldung beim externen Veranstalter über die E-Mail-Adresse/Webseite des Hauptverantwortlichen.'];
|
||||
$lang['wkMaxPlaces'] = ['Maximale Plätze', 'Aus W&K (kbanzmax).'];
|
||||
$lang['wkPlacesLeft'] = ['Freie Plätze (W&K-Stand)', 'Maximale minus belegte Plätze laut W&K; wird bei jedem Export der Download-Liste reduziert.'];
|
||||
$lang['wkImported'] = ['Zuletzt importiert', 'Zeitpunkt des letzten W&K-Imports, in dem diese Veranstaltung enthalten war.'];
|
||||
$lang['wkSubTitle'] = ['Unterüberschrift', ''];
|
||||
$lang['wkSubTitleAdditional'] = ['Weitere Unterüberschrift', ''];
|
||||
$lang['wkCategories'] = ['Kategorien', 'Werden beim Import aus „kbsgebiet“ zugeordnet.'];
|
||||
$lang['wkCategoryPrimary'] = ['Hauptkategorie', 'Bestimmt die Formularregeln und die Benachrichtigung.'];
|
||||
$lang['wkCategoryText'] = ['Kategorie (W&K-Text)', 'Unveränderter Wert aus W&K (kbsgebiet).'];
|
||||
$lang['wkAudience'] = ['Zielgruppe', 'Aus W&K (kbgruppe). „geschlossener Teilnehmerkreis“ verhindert die Online-Anmeldung.'];
|
||||
$lang['wkTags'] = ['Schlagwörter', 'Kommagetrennt, aus W&K (kbwmemo).'];
|
||||
$lang['wkTopics'] = ['Schwerpunkte', ''];
|
||||
$lang['wkAdditionalDates'] = ['Zusätzliche Termine', ''];
|
||||
$lang['wkInstructor'] = ['Kursleitung', ''];
|
||||
$lang['wkSpeakers'] = ['Referenten', ''];
|
||||
$lang['wkPrice1'] = ['Preis 1', ''];
|
||||
$lang['wkPriceType1'] = ['Preistyp 1', ''];
|
||||
$lang['wkPrice2'] = ['Preis 2', ''];
|
||||
$lang['wkPriceType2'] = ['Preistyp 2', ''];
|
||||
$lang['wkPrice3'] = ['Preis 3', ''];
|
||||
$lang['wkPriceType3'] = ['Preistyp 3', ''];
|
||||
$lang['wkPrice4'] = ['Preis 4', ''];
|
||||
$lang['wkPriceType4'] = ['Preistyp 4', ''];
|
||||
$lang['wkPrice5'] = ['Preis 5', ''];
|
||||
$lang['wkPriceType5'] = ['Preistyp 5', ''];
|
||||
$lang['wkPrice6'] = ['Preis 6', ''];
|
||||
$lang['wkPriceType6'] = ['Preistyp 6', ''];
|
||||
$lang['wkOrganizer'] = ['Organisation', ''];
|
||||
$lang['wkOrganizerAdditional'] = ['Organisation (Zusatz)', ''];
|
||||
$lang['wkOrganizerSalutation'] = ['Anrede', ''];
|
||||
$lang['wkOrganizerAcademicTitle'] = ['Akademischer Titel', ''];
|
||||
$lang['wkOrganizerFirstname'] = ['Vorname', ''];
|
||||
$lang['wkOrganizerLastname'] = ['Nachname / Name', ''];
|
||||
$lang['wkOrganizerStreet'] = ['Straße', ''];
|
||||
$lang['wkOrganizerPostal'] = ['PLZ', ''];
|
||||
$lang['wkOrganizerCity'] = ['Ort', ''];
|
||||
$lang['wkOrganizerCountryCode'] = ['Landeskennzahl', ''];
|
||||
$lang['wkOrganizerEmail'] = ['E-Mail-Adresse / Webseite', 'Bei Veranstaltungsart 3 die Anlaufstelle für die Anmeldung.'];
|
||||
$lang['wkNotification'] = ['Benachrichtigung bei Anmeldung', 'Übersteuert die Bedingungen des Kalenders für diese Veranstaltung.'];
|
||||
$lang['wkConfirmationPage'] = ['Eigene Bestätigungsseite', 'Seite nach der Anmeldung, z. B. mit einer Workshop-Auswahl. Der Link erhält ?booking=Token.'];
|
||||
|
||||
$lang['wkStateRef']['1'] = 'Anfrage';
|
||||
$lang['wkStateRef']['2'] = 'Buchung';
|
||||
$lang['wkStateRef']['3'] = 'Check-In';
|
||||
$lang['wkStateRef']['4'] = 'Check-Out';
|
||||
$lang['wkStateRef']['5'] = 'Storno';
|
||||
|
||||
$lang['wk_bookings'] = ['Anmeldungen', 'Anmeldungen zur Veranstaltung ID %s verwalten'];
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_module']['wkCategories'] = ['W&K-Kategorien', 'Nur Veranstaltungen dieser Kategorien anzeigen (leer = alle).'];
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_booking'];
|
||||
|
||||
$lang['state_legend'] = 'Status';
|
||||
$lang['person_legend'] = 'Person';
|
||||
$lang['school_legend'] = 'Lehrertätigkeit';
|
||||
$lang['stay_legend'] = 'Verpflegung und Übernachtung';
|
||||
$lang['invoice_legend'] = 'Rechnungsanschrift';
|
||||
$lang['notes_legend'] = 'Nachricht';
|
||||
$lang['consent_legend'] = 'Einwilligungen';
|
||||
|
||||
$lang['state'] = ['Ordner', 'Bearbeitungsstand im Posteingang. „Exportiert“ wird nur durch den Export gesetzt.'];
|
||||
$lang['dateCreated'] = ['Eingegangen am', ''];
|
||||
$lang['notification'] = ['Verschickte Benachrichtigung', 'ID der Benachrichtigung, die bei der Anmeldung verschickt wurde (0 = keine).'];
|
||||
$lang['gender'] = ['Anrede', ''];
|
||||
$lang['academicTitle'] = ['Titel', ''];
|
||||
$lang['firstname'] = ['Vorname', ''];
|
||||
$lang['lastname'] = ['Nachname', ''];
|
||||
$lang['street'] = ['Straße & Hausnummer', ''];
|
||||
$lang['postal'] = ['PLZ', ''];
|
||||
$lang['city'] = ['Ort', ''];
|
||||
$lang['email'] = ['E-Mail-Adresse', ''];
|
||||
$lang['phone'] = ['Telefonnummer/Handy', ''];
|
||||
$lang['position'] = ['Tätigkeit', ''];
|
||||
$lang['birthday'] = ['Geburtstag', 'Im Format TT.MM.JJJJ.'];
|
||||
$lang['teacherPosition'] = ['Angaben zur Lehrertätigkeit', ''];
|
||||
$lang['schoolName'] = ['Name der Schule', ''];
|
||||
$lang['schoolCity'] = ['Ort der Schule', ''];
|
||||
$lang['vegetarian'] = ['Vegetarisches Essen', ''];
|
||||
$lang['overnightStay'] = ['Übernachtung', ''];
|
||||
$lang['addDifferentInvoiceAddress'] = ['Abweichende Rechnungsanschrift', ''];
|
||||
$lang['invoiceInstitution'] = ['Institution (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceGender'] = ['Anrede (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceAcademicTitle'] = ['Titel (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceFirstname'] = ['Vorname (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceLastname'] = ['Nachname (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceStreet'] = ['Straße & Hausnummer (Rechnungsempfänger)', ''];
|
||||
$lang['invoicePostal'] = ['PLZ (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceCity'] = ['Ort (Rechnungsempfänger)', ''];
|
||||
$lang['addNotes'] = ['Zusätzliche Frage oder Nachricht', ''];
|
||||
$lang['notes'] = ['Nachricht', ''];
|
||||
$lang['privacyAccepted'] = ['Datenschutzhinweise akzeptiert', ''];
|
||||
$lang['agbAccepted'] = ['AGB akzeptiert', ''];
|
||||
$lang['newsletter'] = ['Newsletter gewünscht', ''];
|
||||
|
||||
$lang['stateRef']['new'] = 'Neu';
|
||||
$lang['stateRef']['download_list'] = 'Download-Liste';
|
||||
$lang['stateRef']['processed'] = 'Bereits bearbeitet';
|
||||
$lang['stateRef']['spam'] = 'Ignoriert/Spam';
|
||||
$lang['stateRef']['exported'] = 'Exportiert';
|
||||
|
||||
$lang['genderRef']['male'] = 'Herr';
|
||||
$lang['genderRef']['female'] = 'Frau';
|
||||
$lang['genderRef']['divers'] = 'divers';
|
||||
$lang['genderRef']['none'] = 'keine Angabe';
|
||||
|
||||
$lang['positionRef']['church_employee'] = 'Kirchlicher Mitarbeiter';
|
||||
$lang['positionRef']['teacher'] = 'Lehrer';
|
||||
$lang['positionRef']['pastor'] = 'Pfarrer';
|
||||
$lang['positionRef']['volunteer'] = 'Ehrenamtlich';
|
||||
|
||||
$lang['teacherPositionRef']['state'] = 'Ich unterrichte an einer staatlichen Schule';
|
||||
$lang['teacherPositionRef']['other'] = 'Ich unterrichte an einer Schule in sonstiger Trägerschaft';
|
||||
$lang['teacherPositionRef']['none'] = 'Ich unterrichte nicht';
|
||||
|
||||
$lang['overnightStayRef']['none'] = 'keine Übernachtung benötigt';
|
||||
$lang['overnightStayRef']['single_room'] = 'Einzelzimmer';
|
||||
$lang['overnightStayRef']['double_room'] = 'Doppelzimmer';
|
||||
|
||||
$lang['new'] = ['Neue Anmeldung', 'Eine Anmeldung manuell anlegen'];
|
||||
$lang['edit'] = ['Anmeldung bearbeiten', 'Anmeldung ID %s bearbeiten'];
|
||||
$lang['delete'] = ['Anmeldung löschen', 'Anmeldung ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Anmeldung ID %s anzeigen'];
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_category'];
|
||||
|
||||
$lang['title_legend'] = 'Kategorie';
|
||||
$lang['wk_legend'] = 'Zuordnung aus W&K';
|
||||
|
||||
$lang['title'] = ['Titel', 'Name der Kategorie, z. B. „Grundschule/Förderschule“.'];
|
||||
$lang['alias'] = ['Alias', 'Wird automatisch aus dem Titel gebildet.'];
|
||||
$lang['sorting'] = ['Reihenfolge', 'Kleinere Zahlen zuerst. Bei mehreren passenden Kategorien wird die erste zur Hauptkategorie.'];
|
||||
$lang['kind'] = ['Art', 'Steuert, welche Felder das Anmeldeformular zeigt und welche Benachrichtigung verschickt wird.'];
|
||||
$lang['wkNames'] = ['W&K-Bezeichnungen', 'Werte des W&K-Felds „kbsgebiet“, eine Bezeichnung pro Zeile. Genaue Treffer zuerst, sonst genügt es, wenn die Bezeichnung im W&K-Wert enthalten ist. Eine Bezeichnung darf in mehreren Kategorien stehen (z. B. „Alle Schularten“).'];
|
||||
|
||||
$lang['kindRef']['school'] = 'Schule (Lehrkräfte)';
|
||||
$lang['kindRef']['evangelical_school'] = 'Evangelische Schulen';
|
||||
$lang['kindRef']['elementary'] = 'Elementarbereich';
|
||||
$lang['kindRef']['community'] = 'Gemeinde';
|
||||
$lang['kindRef']['children_service'] = 'Gottesdienst mit Kindern';
|
||||
$lang['kindRef']['conference'] = 'Tagungen';
|
||||
$lang['kindRef']['training'] = 'Ausbildung';
|
||||
$lang['kindRef']['mentoring'] = 'Mentoring';
|
||||
$lang['kindRef']['other'] = 'Sonstiges';
|
||||
|
||||
$lang['new'] = ['Neue Kategorie', 'Eine neue Kategorie anlegen'];
|
||||
$lang['edit'] = ['Kategorie bearbeiten', 'Kategorie ID %s bearbeiten'];
|
||||
$lang['copy'] = ['Kategorie duplizieren', 'Kategorie ID %s duplizieren'];
|
||||
$lang['delete'] = ['Kategorie löschen', 'Kategorie ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Kategorie ID %s anzeigen'];
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_inquiry'];
|
||||
|
||||
$lang['state_legend'] = 'Status';
|
||||
$lang['person_legend'] = 'Person';
|
||||
$lang['message_legend'] = 'Anfrage';
|
||||
$lang['consent_legend'] = 'Einwilligungen';
|
||||
|
||||
$lang['state'] = ['Ordner', 'Bearbeitungsstand im Posteingang.'];
|
||||
$lang['dateCreated'] = ['Eingegangen am', ''];
|
||||
$lang['event'] = ['Veranstaltung', 'Veranstaltung, auf die sich die Anfrage bezieht.'];
|
||||
$lang['gender'] = ['Anrede', ''];
|
||||
$lang['academicTitle'] = ['Titel', ''];
|
||||
$lang['firstname'] = ['Vorname', ''];
|
||||
$lang['lastname'] = ['Nachname', ''];
|
||||
$lang['street'] = ['Straße', ''];
|
||||
$lang['postal'] = ['PLZ', ''];
|
||||
$lang['city'] = ['Ort', ''];
|
||||
$lang['email'] = ['E-Mail-Adresse', ''];
|
||||
$lang['phone'] = ['Telefon', ''];
|
||||
$lang['subject'] = ['Betreff', ''];
|
||||
$lang['notes'] = ['Nachricht', ''];
|
||||
$lang['newsletter'] = ['Newsletter gewünscht', ''];
|
||||
$lang['privacy'] = ['Datenschutzhinweise akzeptiert', ''];
|
||||
|
||||
$lang['new'] = ['Neue Anfrage', 'Eine Anfrage manuell anlegen'];
|
||||
$lang['edit'] = ['Anfrage bearbeiten', 'Anfrage ID %s bearbeiten'];
|
||||
$lang['delete'] = ['Anfrage löschen', 'Anfrage ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Anfrage ID %s anzeigen'];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_sync_log'];
|
||||
|
||||
$lang['tstamp'] = ['Datum', ''];
|
||||
$lang['type'] = ['Vorgang', ''];
|
||||
$lang['status'] = ['Status', ''];
|
||||
$lang['user'] = ['Benutzer', ''];
|
||||
$lang['calendar'] = ['Kalender', ''];
|
||||
$lang['filename'] = ['Datei', ''];
|
||||
$lang['summary'] = ['Zusammenfassung', ''];
|
||||
$lang['messages'] = ['Meldungen', ''];
|
||||
|
||||
$lang['typeRef']['import'] = 'Import aus W&K';
|
||||
$lang['typeRef']['export'] = 'Export für W&K';
|
||||
$lang['statusRef']['preview'] = 'Vorschau';
|
||||
$lang['statusRef']['applied'] = 'Angewendet';
|
||||
$lang['statusRef']['failed'] = 'Verworfen/fehlgeschlagen';
|
||||
$lang['statusRef']['done'] = 'Erledigt';
|
||||
|
||||
$lang['show'] = ['Details', 'Details des Eintrags ID %s anzeigen'];
|
||||
$lang['delete'] = ['Eintrag löschen', 'Eintrag ID %s löschen'];
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['MSC'];
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_nc_notification']['type']['wk_sync'] = 'W&K sync';
|
||||
$GLOBALS['TL_LANG']['tl_nc_notification']['type']['wk_booking'] = ['W&K booking', 'Sent when a booking for an event is received.'];
|
||||
|
||||
$lang['wk_newsletter_optin_text'] = "You have subscribed to the newsletter. Please click the following link to activate your subscription:\n\n##link##\n\nThe link is valid for 24 hours. You can unsubscribe at any time.\n\nIf you did not subscribe yourself, please ignore this e-mail.";
|
||||
|
||||
$lang['wk_booking']['course_number'] = 'Course number';
|
||||
$lang['wk_booking']['places_left'] = '%s places left';
|
||||
$lang['wk_booking']['deadline'] = 'Booking possible until %s';
|
||||
$lang['wk_booking']['book_now'] = 'Book now';
|
||||
$lang['wk_booking']['questions'] = 'If you have questions, please use our';
|
||||
$lang['wk_booking']['inquiry_form'] = 'inquiry form';
|
||||
$lang['wk_booking']['late_inquiry'] = 'A late booking may still be possible. Please use the';
|
||||
$lang['wk_booking']['status']['not_enabled'] = 'Online booking is not available for this event.';
|
||||
$lang['wk_booking']['status']['unpublished'] = 'This event is not published.';
|
||||
$lang['wk_booking']['status']['cancelled'] = 'This event has been cancelled.';
|
||||
$lang['wk_booking']['status']['closed_audience'] = 'This event is not open for booking (closed group).';
|
||||
$lang['wk_booking']['status']['external_email'] = 'Please register for this course via e-mail:';
|
||||
$lang['wk_booking']['status']['external_url'] = 'Please register for this course via the website';
|
||||
$lang['wk_booking']['status']['past'] = 'This event has already started or is over.';
|
||||
$lang['wk_booking']['status']['deadline'] = 'Bookings for this event are no longer accepted.';
|
||||
$lang['wk_booking']['status']['full'] = 'Unfortunately there are no places left.';
|
||||
$lang['wk_booking']['status']['no_form'] = 'No booking form is configured for this event.';
|
||||
$lang['wk_booking']['error']['not_enabled'] = 'Online booking is not available for this event.';
|
||||
$lang['wk_booking']['error']['unpublished'] = 'This event is no longer available.';
|
||||
$lang['wk_booking']['error']['cancelled'] = 'This event has been cancelled in the meantime.';
|
||||
$lang['wk_booking']['error']['closed_audience'] = 'This event is not open for booking.';
|
||||
$lang['wk_booking']['error']['external'] = 'Bookings for this event are handled by the organiser.';
|
||||
$lang['wk_booking']['error']['past'] = 'This event has already started.';
|
||||
$lang['wk_booking']['error']['deadline'] = 'The booking deadline has passed.';
|
||||
$lang['wk_booking']['error']['full'] = 'Unfortunately the last free place has just been taken.';
|
||||
$lang['wk_booking']['error']['no_form'] = 'Booking is currently not possible.';
|
||||
|
||||
$lang['wk_sync']['nav']['import'] = 'Import from W&K';
|
||||
$lang['wk_sync']['nav']['inbox'] = 'Inbox';
|
||||
$lang['wk_sync']['nav']['exports'] = 'Exports for W&K';
|
||||
$lang['wk_sync']['state']['new'] = 'New';
|
||||
$lang['wk_sync']['state']['download_list'] = 'Download list';
|
||||
$lang['wk_sync']['state']['processed'] = 'Processed';
|
||||
$lang['wk_sync']['state']['spam'] = 'Ignored/spam';
|
||||
$lang['wk_sync']['state']['exported'] = 'Exported';
|
||||
$lang['wk_sync']['status']['preview'] = 'Preview, not applied yet';
|
||||
$lang['wk_sync']['status']['applied'] = 'Applied';
|
||||
$lang['wk_sync']['status']['failed'] = 'Discarded/failed';
|
||||
$lang['wk_sync']['status']['done'] = 'Done';
|
||||
$lang['wk_sync']['action']['create'] = 'new seminars';
|
||||
$lang['wk_sync']['action']['update'] = 'changed seminars';
|
||||
$lang['wk_sync']['action']['cancel'] = 'seminars to cancel (no longer in the file)';
|
||||
$lang['wk_sync']['action']['unchanged'] = 'unchanged seminars';
|
||||
$lang['wk_sync']['action']['skip'] = 'skipped records';
|
||||
$lang['wk_sync']['import']['headline'] = 'Import seminars from W&K';
|
||||
$lang['wk_sync']['import']['calendar'] = 'Calendar';
|
||||
$lang['wk_sync']['import']['file'] = 'W&K export file (CSV)';
|
||||
$lang['wk_sync']['import']['file_help'] = 'The seminar list exported from W&K (semicolon separated). Maximum file size: %s MB.';
|
||||
$lang['wk_sync']['import']['hint'] = 'Important: export the largest possible time range in W&K. Seminars missing from the file will be marked as cancelled and hidden. A preview is shown first; nothing changes until you confirm.';
|
||||
$lang['wk_sync']['import']['upload_button'] = 'Upload file and show preview';
|
||||
$lang['wk_sync']['import']['recent'] = 'Recent imports';
|
||||
$lang['wk_sync']['import']['none'] = 'Nothing has been imported yet.';
|
||||
$lang['wk_sync']['import']['no_categories'] = 'No W&K categories exist yet. Without categories all seminars would be cancelled. Please create them under "W&K sync → Categories" first (or run "wk-sync:categories:seed").';
|
||||
$lang['wk_sync']['import']['no_calendar'] = 'W&K sync is not enabled in any calendar. Please enable "W&K sync" in the calendar settings.';
|
||||
$lang['wk_sync']['import']['applied'] = 'The import has been applied: %s new, %s changed, %s cancelled, %s unchanged.';
|
||||
$lang['wk_sync']['import']['not_found'] = 'The import was not found.';
|
||||
$lang['wk_sync']['import']['discarded'] = 'The preview has been discarded. Nothing was changed.';
|
||||
$lang['wk_sync']['upload']['missing'] = 'Please select a CSV file.';
|
||||
$lang['wk_sync']['upload']['too_large'] = 'The file is too large.';
|
||||
$lang['wk_sync']['upload']['wrong_type'] = 'Please upload a CSV file (.csv).';
|
||||
$lang['wk_sync']['preview']['headline'] = 'Preview: %s → calendar "%s"';
|
||||
$lang['wk_sync']['preview']['warnings'] = 'warnings';
|
||||
$lang['wk_sync']['preview']['errors'] = 'errors';
|
||||
$lang['wk_sync']['preview']['apply'] = 'Apply import now';
|
||||
$lang['wk_sync']['preview']['confirm'] = 'The listed changes will be applied now. Seminars missing from the file will be cancelled and hidden. Continue?';
|
||||
$lang['wk_sync']['preview']['discard'] = 'Discard preview';
|
||||
$lang['wk_sync']['preview']['nothing'] = 'The file contains no changes compared to the current data.';
|
||||
$lang['wk_sync']['preview']['messages'] = 'Messages';
|
||||
$lang['wk_sync']['log']['date'] = 'Date';
|
||||
$lang['wk_sync']['log']['file'] = 'File';
|
||||
$lang['wk_sync']['log']['calendar'] = 'Calendar';
|
||||
$lang['wk_sync']['log']['user'] = 'User';
|
||||
$lang['wk_sync']['log']['status'] = 'Status';
|
||||
$lang['wk_sync']['log']['summary'] = 'Result';
|
||||
$lang['wk_sync']['log']['result'] = '%s new, %s changed, %s cancelled';
|
||||
$lang['wk_sync']['log']['planned'] = 'planned: %s new, %s changed, %s cancelled, %s skipped';
|
||||
$lang['wk_sync']['inbox']['headline'] = 'Bookings and inquiries';
|
||||
$lang['wk_sync']['inbox']['empty'] = 'There are currently no records in this folder.';
|
||||
$lang['wk_sync']['inbox']['date'] = 'Received';
|
||||
$lang['wk_sync']['inbox']['person'] = 'Person';
|
||||
$lang['wk_sync']['inbox']['data'] = 'Data';
|
||||
$lang['wk_sync']['inbox']['type_booking'] = 'Booking';
|
||||
$lang['wk_sync']['inbox']['type_inquiry'] = 'Inquiry';
|
||||
$lang['wk_sync']['inbox']['booking_for'] = 'Booking for event no. %s: %s';
|
||||
$lang['wk_sync']['inbox']['inquiry_for'] = 'Inquiry for event no. %s: %s';
|
||||
$lang['wk_sync']['inbox']['edit'] = 'Edit';
|
||||
$lang['wk_sync']['inbox']['move_to'] = 'Move to "%s"';
|
||||
$lang['wk_sync']['inbox']['moved'] = '%s records have been moved to "%s".';
|
||||
$lang['wk_sync']['inbox']['invalid_target'] = 'Invalid target folder.';
|
||||
$lang['wk_sync']['inbox']['select_all'] = 'Select all';
|
||||
$lang['wk_sync']['inbox']['bulk_label'] = 'Move selected to';
|
||||
$lang['wk_sync']['inbox']['bulk_button'] = 'Move';
|
||||
$lang['wk_sync']['inbox']['duplicate'] = 'possible duplicate';
|
||||
$lang['wk_sync']['inbox']['duplicate_help'] = 'There is another booking with this e-mail address for the same event.';
|
||||
$lang['wk_sync']['export']['button'] = 'Export download list for W&K';
|
||||
$lang['wk_sync']['export']['help'] = 'All records of the download list are written to a CSV file, marked as "exported" and the free places of the events are reduced accordingly. Please import the file into W&K soon.';
|
||||
$lang['wk_sync']['export']['confirm'] = 'The export cannot be undone. The records will be marked as exported. Continue?';
|
||||
$lang['wk_sync']['export']['empty'] = 'The download list is empty.';
|
||||
$lang['wk_sync']['export']['done'] = 'Export created: %s bookings and %s inquiries. The file can be downloaded now.';
|
||||
$lang['wk_sync']['exports']['headline'] = 'Exports for W&K';
|
||||
$lang['wk_sync']['exports']['help'] = 'Every export file can be downloaded again here, e.g. if the download was interrupted.';
|
||||
$lang['wk_sync']['exports']['none'] = 'Nothing has been exported yet.';
|
||||
$lang['wk_sync']['exports']['content'] = 'Content';
|
||||
$lang['wk_sync']['exports']['summary'] = '%s bookings, %s inquiries';
|
||||
$lang['wk_sync']['exports']['download'] = 'Download CSV';
|
||||
$lang['wk_sync']['exports']['missing'] = 'file no longer available';
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync'] = 'W&K sync';
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_tools'] = ['Import & inbox', 'Import seminars from W&K, process bookings and export them for W&K'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_categories'] = ['Categories', 'Manage W&K categories and their mapping'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_inquiries'] = ['Inquiries', 'Inquiries from the contact form'];
|
||||
$GLOBALS['TL_LANG']['MOD']['wk_sync_log'] = ['Log', 'Log of imports and exports'];
|
||||
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_sync'] = 'W&K sync';
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_booking_form'] = ['W&K booking form', 'Booking form for the event given in the URL.'];
|
||||
$GLOBALS['TL_LANG']['FMD']['wk_booking_status'] = ['W&K booking status', 'Free places, deadline and booking link for the event given in the URL.'];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['recipient_email'] = 'E-Mail-Adresse der angemeldeten Person (für „Empfänger“)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['admin_email'] = 'Administrator-E-Mail-Adresse';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_email'] = 'E-Mail-Adresse aus der Anmeldung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_wkOrganizerEmail'] = 'E-Mail-Adresse des Hauptverantwortlichen (W&K)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_confirmation_page_url'] = 'Link zur Bestätigungsseite (mit Token der Anmeldung)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_event_wk_id'] = 'W&K-Nummer der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_link'] = 'Link zur Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_datetime'] = 'Datum und Uhrzeit der Veranstaltung, z. B. „24.09.2026 - 25.09.2026, 09:00 - 16:00“';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_categories'] = 'Kategorien der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_speakers'] = 'Referenten der Veranstaltung';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['wk_notification_conditions'] = 'Erfüllte Benachrichtigungs-Bedingungen (zur Kontrolle)';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['subscription_*'] = 'Alle Felder der Anmeldung, z. B. ##subscription_firstname##, ##subscription_lastname##, ##subscription_street##, ##subscription_notes##; Auswahlfelder zusätzlich als ##subscription_gender_label##, ##subscription_overnightStay_label##, Ja/Nein-Felder als ##subscription_vegetarian_label##';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['event_*'] = 'Alle Felder der Veranstaltung, z. B. ##event_title##, ##event_location##, ##event_startDate##, ##event_wkInternalEventNumber##, ##event_wkOrganizerCity##';
|
||||
$GLOBALS['TL_LANG']['nc_tokens']['wk_booking']['calendar_*'] = 'Alle Felder des Kalenders, z. B. ##calendar_title##';
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_calendar'];
|
||||
|
||||
$lang['wk_legend'] = 'W&K-Sync';
|
||||
|
||||
$lang['wkEnable'] = ['W&K-Sync aktivieren', 'Seminare aus W&K in diesen Kalender importieren und Online-Anmeldungen ermöglichen.'];
|
||||
$lang['wkBookingForm'] = ['Anmeldeformular', 'Formular aus dem Formulargenerator, das für Anmeldungen verwendet wird (Feldnamen wie in tl_wk_booking; anlegen mit „wk-sync:form:create“).'];
|
||||
$lang['wkBookingPage'] = ['Seite mit dem Anmeldeformular', 'Seite, auf der das Modul „W&K-Anmeldeformular“ liegt. Der Link „Jetzt anmelden“ führt dorthin.'];
|
||||
$lang['wkConfirmationPage'] = ['Bestätigungsseite', 'Seite nach erfolgreicher Anmeldung, z. B. „Anmeldung eingegangen“. Kann je Veranstaltung übersteuert werden.'];
|
||||
$lang['wkInquiryPage'] = ['Seite mit dem Anfrageformular', 'Seite mit dem Kontakt-/Anfrageformular; sie wird mit ?event=ID verlinkt.'];
|
||||
$lang['wkDeadlineDays'] = ['Anmeldeschluss (Tage vor Beginn)', 'Bis so viele Tage vor Beginn sind Anmeldungen technisch möglich. 1 = bis einen Tag vor Beginn.'];
|
||||
$lang['wkDeadlineDisplayDays'] = ['Angezeigter Anmeldeschluss (Tage vor Beginn)', 'Datum, das als „Anmeldung möglich bis“ angezeigt wird. 0 = wie der technische Anmeldeschluss.'];
|
||||
$lang['wkOwnVenues'] = ['Eigene Veranstaltungsorte', 'Eine Bezeichnung pro Zeile. Veranstaltungsorte, die so beginnen (z. B. „TPI Moritzburg (barrierefrei)“), gelten als „im eigenen Haus“ – relevant für die Felder Übernachtung und Verpflegung.'];
|
||||
$lang['wkAuthor'] = ['Autor importierter Veranstaltungen', 'Backend-Benutzer, der als Autor neuer Seminare eingetragen wird (leer = der importierende Benutzer).'];
|
||||
$lang['wkInstructorGroup'] = ['Mitgliedergruppe Kursleitung', 'Kursleitungen aus W&K werden als Mitglieder dieser Gruppe angelegt (leer = keine Mitglieder anlegen).'];
|
||||
$lang['wkSpeakerGroup'] = ['Mitgliedergruppe Referenten', 'Referenten aus W&K werden als Mitglieder dieser Gruppe angelegt (leer = keine Mitglieder anlegen).'];
|
||||
$lang['wkNewsletterChannels'] = ['Newsletter-Verteiler', 'Verteiler, die bei „Newsletter abonnieren“ mit Double-Opt-in abonniert werden.'];
|
||||
$lang['wkNewsletterPage'] = ['Seite zur Newsletter-Aktivierung', 'Seite mit dem Modul „Abonnieren“, die den Aktivierungslink (?token=) verarbeitet.'];
|
||||
$lang['wkNewsletterText'] = ['Text der Aktivierungsmail', 'Optional. Token: ##link##, ##channels##, ##domain##. Leer = Standardtext.'];
|
||||
$lang['wkNotificationDefault'] = ['Standard-Benachrichtigung', 'Wird verschickt, wenn keine der Bedingungen zutrifft und an der Veranstaltung nichts eingestellt ist.'];
|
||||
$lang['wkNotificationCondition'] = ['Benachrichtigung', 'Benachrichtigung vom Typ „W&K-Anmeldung“ für diese Bedingung.'];
|
||||
|
||||
$lang['wkNotificationExplanation1'] = '<strong>Bedingung 1: Online-Kurse</strong> – alle Kategorien, Veranstaltungsort „Online“.';
|
||||
$lang['wkNotificationExplanation2'] = '<strong>Bedingung 2: Tagesveranstaltung Schulfortbildung</strong> – Beginn = Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.';
|
||||
$lang['wkNotificationExplanation3'] = '<strong>Bedingung 3: Tagesveranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen</strong> – Beginn = Ende, Kategorie-Art „Gemeinde“, „Gottesdienst mit Kindern“ oder „Tagungen“.';
|
||||
$lang['wkNotificationExplanation4'] = '<strong>Bedingung 4: Tagesveranstaltung Elementarbereich</strong> – Beginn = Ende, Kategorie-Art „Elementarbereich“.';
|
||||
$lang['wkNotificationExplanation5'] = '<strong>Bedingung 5: Mehrtägige Schulfortbildung</strong> – Beginn ≠ Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.';
|
||||
$lang['wkNotificationExplanation6'] = '<strong>Bedingung 6: Mehrtägige Fortbildung Elementarbereich</strong> – Beginn ≠ Ende, Kategorie-Art „Elementarbereich“.';
|
||||
$lang['wkNotificationExplanation7'] = '<strong>Bedingung 7: Mehrtägige Veranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen</strong> – Beginn ≠ Ende.';
|
||||
$lang['wkNotificationExplanation8'] = '<strong>Bedingung 8: Tagesveranstaltung Evangelische Schulen</strong> – Beginn = Ende, Kategorie-Art „Evangelische Schulen“.';
|
||||
$lang['wkNotificationExplanation9'] = '<strong>Bedingung 9: Mehrtägige Veranstaltung Evangelische Schulen</strong> – Beginn ≠ Ende, Kategorie-Art „Evangelische Schulen“.';
|
||||
$lang['wkNotificationExplanation10'] = '<strong>Bedingung 10: Tagesveranstaltung Mentoring</strong> – Beginn = Ende, Kategorie-Art „Mentoring“.';
|
||||
$lang['wkNotificationExplanation11'] = '<strong>Bedingung 11: Mehrtägige Veranstaltung Mentoring</strong> – Beginn ≠ Ende, Kategorie-Art „Mentoring“.';
|
||||
$lang['wkNotificationExplanation12'] = '<strong>Bedingung 12: Mehrtägige Ausbildung</strong> – Beginn ≠ Ende, Kategorie-Art „Ausbildung“.';
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_calendar_events'];
|
||||
|
||||
$lang['wk_legend'] = 'W&K';
|
||||
$lang['wk_content_legend'] = 'W&K: Inhalte';
|
||||
$lang['wk_people_legend'] = 'W&K: Kursleitung und Referenten';
|
||||
$lang['wk_price_legend'] = 'W&K: Preise';
|
||||
$lang['wk_organizer_legend'] = 'W&K: Hauptverantwortlicher';
|
||||
$lang['wk_booking_legend'] = 'Anmeldung';
|
||||
|
||||
$lang['wkId'] = ['W&K-Nummer', 'Seminarnummer aus W&K (kbnr). Wird beim Import gesetzt.'];
|
||||
$lang['wkState'] = ['W&K-Status', 'Status aus W&K. „Storno“ blendet die Veranstaltung aus.'];
|
||||
$lang['wkInternalEventNumber'] = ['Interne Veranstaltungsnummer', 'Z. B. EXTP-Nummer des Kultusministeriums (kbvsnr).'];
|
||||
$lang['wkEventType'] = ['Veranstaltungsart', 'Zahl aus W&K (kbvsart). 3 = Anmeldung beim externen Veranstalter über die E-Mail-Adresse/Webseite des Hauptverantwortlichen.'];
|
||||
$lang['wkMaxPlaces'] = ['Maximale Plätze', 'Aus W&K (kbanzmax).'];
|
||||
$lang['wkPlacesLeft'] = ['Freie Plätze (W&K-Stand)', 'Maximale minus belegte Plätze laut W&K; wird bei jedem Export der Download-Liste reduziert.'];
|
||||
$lang['wkImported'] = ['Zuletzt importiert', 'Zeitpunkt des letzten W&K-Imports, in dem diese Veranstaltung enthalten war.'];
|
||||
$lang['wkSubTitle'] = ['Unterüberschrift', ''];
|
||||
$lang['wkSubTitleAdditional'] = ['Weitere Unterüberschrift', ''];
|
||||
$lang['wkCategories'] = ['Kategorien', 'Werden beim Import aus „kbsgebiet“ zugeordnet.'];
|
||||
$lang['wkCategoryPrimary'] = ['Hauptkategorie', 'Bestimmt die Formularregeln und die Benachrichtigung.'];
|
||||
$lang['wkCategoryText'] = ['Kategorie (W&K-Text)', 'Unveränderter Wert aus W&K (kbsgebiet).'];
|
||||
$lang['wkAudience'] = ['Zielgruppe', 'Aus W&K (kbgruppe). „geschlossener Teilnehmerkreis“ verhindert die Online-Anmeldung.'];
|
||||
$lang['wkTags'] = ['Schlagwörter', 'Kommagetrennt, aus W&K (kbwmemo).'];
|
||||
$lang['wkTopics'] = ['Schwerpunkte', ''];
|
||||
$lang['wkAdditionalDates'] = ['Zusätzliche Termine', ''];
|
||||
$lang['wkInstructor'] = ['Kursleitung', ''];
|
||||
$lang['wkSpeakers'] = ['Referenten', ''];
|
||||
$lang['wkPrice1'] = ['Preis 1', ''];
|
||||
$lang['wkPriceType1'] = ['Preistyp 1', ''];
|
||||
$lang['wkPrice2'] = ['Preis 2', ''];
|
||||
$lang['wkPriceType2'] = ['Preistyp 2', ''];
|
||||
$lang['wkPrice3'] = ['Preis 3', ''];
|
||||
$lang['wkPriceType3'] = ['Preistyp 3', ''];
|
||||
$lang['wkPrice4'] = ['Preis 4', ''];
|
||||
$lang['wkPriceType4'] = ['Preistyp 4', ''];
|
||||
$lang['wkPrice5'] = ['Preis 5', ''];
|
||||
$lang['wkPriceType5'] = ['Preistyp 5', ''];
|
||||
$lang['wkPrice6'] = ['Preis 6', ''];
|
||||
$lang['wkPriceType6'] = ['Preistyp 6', ''];
|
||||
$lang['wkOrganizer'] = ['Organisation', ''];
|
||||
$lang['wkOrganizerAdditional'] = ['Organisation (Zusatz)', ''];
|
||||
$lang['wkOrganizerSalutation'] = ['Anrede', ''];
|
||||
$lang['wkOrganizerAcademicTitle'] = ['Akademischer Titel', ''];
|
||||
$lang['wkOrganizerFirstname'] = ['Vorname', ''];
|
||||
$lang['wkOrganizerLastname'] = ['Nachname / Name', ''];
|
||||
$lang['wkOrganizerStreet'] = ['Straße', ''];
|
||||
$lang['wkOrganizerPostal'] = ['PLZ', ''];
|
||||
$lang['wkOrganizerCity'] = ['Ort', ''];
|
||||
$lang['wkOrganizerCountryCode'] = ['Landeskennzahl', ''];
|
||||
$lang['wkOrganizerEmail'] = ['E-Mail-Adresse / Webseite', 'Bei Veranstaltungsart 3 die Anlaufstelle für die Anmeldung.'];
|
||||
$lang['wkNotification'] = ['Benachrichtigung bei Anmeldung', 'Übersteuert die Bedingungen des Kalenders für diese Veranstaltung.'];
|
||||
$lang['wkConfirmationPage'] = ['Eigene Bestätigungsseite', 'Seite nach der Anmeldung, z. B. mit einer Workshop-Auswahl. Der Link erhält ?booking=Token.'];
|
||||
|
||||
$lang['wkStateRef']['1'] = 'Anfrage';
|
||||
$lang['wkStateRef']['2'] = 'Buchung';
|
||||
$lang['wkStateRef']['3'] = 'Check-In';
|
||||
$lang['wkStateRef']['4'] = 'Check-Out';
|
||||
$lang['wkStateRef']['5'] = 'Storno';
|
||||
|
||||
$lang['wk_bookings'] = ['Anmeldungen', 'Anmeldungen zur Veranstaltung ID %s verwalten'];
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_module']['wkCategories'] = ['W&K-Kategorien', 'Nur Veranstaltungen dieser Kategorien anzeigen (leer = alle).'];
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_booking'];
|
||||
|
||||
$lang['state_legend'] = 'Status';
|
||||
$lang['person_legend'] = 'Person';
|
||||
$lang['school_legend'] = 'Lehrertätigkeit';
|
||||
$lang['stay_legend'] = 'Verpflegung und Übernachtung';
|
||||
$lang['invoice_legend'] = 'Rechnungsanschrift';
|
||||
$lang['notes_legend'] = 'Nachricht';
|
||||
$lang['consent_legend'] = 'Einwilligungen';
|
||||
|
||||
$lang['state'] = ['Ordner', 'Bearbeitungsstand im Posteingang. „Exportiert“ wird nur durch den Export gesetzt.'];
|
||||
$lang['dateCreated'] = ['Eingegangen am', ''];
|
||||
$lang['notification'] = ['Verschickte Benachrichtigung', 'ID der Benachrichtigung, die bei der Anmeldung verschickt wurde (0 = keine).'];
|
||||
$lang['gender'] = ['Anrede', ''];
|
||||
$lang['academicTitle'] = ['Titel', ''];
|
||||
$lang['firstname'] = ['Vorname', ''];
|
||||
$lang['lastname'] = ['Nachname', ''];
|
||||
$lang['street'] = ['Straße & Hausnummer', ''];
|
||||
$lang['postal'] = ['PLZ', ''];
|
||||
$lang['city'] = ['Ort', ''];
|
||||
$lang['email'] = ['E-Mail-Adresse', ''];
|
||||
$lang['phone'] = ['Telefonnummer/Handy', ''];
|
||||
$lang['position'] = ['Tätigkeit', ''];
|
||||
$lang['birthday'] = ['Geburtstag', 'Im Format TT.MM.JJJJ.'];
|
||||
$lang['teacherPosition'] = ['Angaben zur Lehrertätigkeit', ''];
|
||||
$lang['schoolName'] = ['Name der Schule', ''];
|
||||
$lang['schoolCity'] = ['Ort der Schule', ''];
|
||||
$lang['vegetarian'] = ['Vegetarisches Essen', ''];
|
||||
$lang['overnightStay'] = ['Übernachtung', ''];
|
||||
$lang['addDifferentInvoiceAddress'] = ['Abweichende Rechnungsanschrift', ''];
|
||||
$lang['invoiceInstitution'] = ['Institution (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceGender'] = ['Anrede (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceAcademicTitle'] = ['Titel (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceFirstname'] = ['Vorname (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceLastname'] = ['Nachname (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceStreet'] = ['Straße & Hausnummer (Rechnungsempfänger)', ''];
|
||||
$lang['invoicePostal'] = ['PLZ (Rechnungsempfänger)', ''];
|
||||
$lang['invoiceCity'] = ['Ort (Rechnungsempfänger)', ''];
|
||||
$lang['addNotes'] = ['Zusätzliche Frage oder Nachricht', ''];
|
||||
$lang['notes'] = ['Nachricht', ''];
|
||||
$lang['privacyAccepted'] = ['Datenschutzhinweise akzeptiert', ''];
|
||||
$lang['agbAccepted'] = ['AGB akzeptiert', ''];
|
||||
$lang['newsletter'] = ['Newsletter gewünscht', ''];
|
||||
|
||||
$lang['stateRef']['new'] = 'Neu';
|
||||
$lang['stateRef']['download_list'] = 'Download-Liste';
|
||||
$lang['stateRef']['processed'] = 'Bereits bearbeitet';
|
||||
$lang['stateRef']['spam'] = 'Ignoriert/Spam';
|
||||
$lang['stateRef']['exported'] = 'Exportiert';
|
||||
|
||||
$lang['genderRef']['male'] = 'Herr';
|
||||
$lang['genderRef']['female'] = 'Frau';
|
||||
$lang['genderRef']['divers'] = 'divers';
|
||||
$lang['genderRef']['none'] = 'keine Angabe';
|
||||
|
||||
$lang['positionRef']['church_employee'] = 'Kirchlicher Mitarbeiter';
|
||||
$lang['positionRef']['teacher'] = 'Lehrer';
|
||||
$lang['positionRef']['pastor'] = 'Pfarrer';
|
||||
$lang['positionRef']['volunteer'] = 'Ehrenamtlich';
|
||||
|
||||
$lang['teacherPositionRef']['state'] = 'Ich unterrichte an einer staatlichen Schule';
|
||||
$lang['teacherPositionRef']['other'] = 'Ich unterrichte an einer Schule in sonstiger Trägerschaft';
|
||||
$lang['teacherPositionRef']['none'] = 'Ich unterrichte nicht';
|
||||
|
||||
$lang['overnightStayRef']['none'] = 'keine Übernachtung benötigt';
|
||||
$lang['overnightStayRef']['single_room'] = 'Einzelzimmer';
|
||||
$lang['overnightStayRef']['double_room'] = 'Doppelzimmer';
|
||||
|
||||
$lang['new'] = ['Neue Anmeldung', 'Eine Anmeldung manuell anlegen'];
|
||||
$lang['edit'] = ['Anmeldung bearbeiten', 'Anmeldung ID %s bearbeiten'];
|
||||
$lang['delete'] = ['Anmeldung löschen', 'Anmeldung ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Anmeldung ID %s anzeigen'];
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_category'];
|
||||
|
||||
$lang['title_legend'] = 'Kategorie';
|
||||
$lang['wk_legend'] = 'Zuordnung aus W&K';
|
||||
|
||||
$lang['title'] = ['Titel', 'Name der Kategorie, z. B. „Grundschule/Förderschule“.'];
|
||||
$lang['alias'] = ['Alias', 'Wird automatisch aus dem Titel gebildet.'];
|
||||
$lang['sorting'] = ['Reihenfolge', 'Kleinere Zahlen zuerst. Bei mehreren passenden Kategorien wird die erste zur Hauptkategorie.'];
|
||||
$lang['kind'] = ['Art', 'Steuert, welche Felder das Anmeldeformular zeigt und welche Benachrichtigung verschickt wird.'];
|
||||
$lang['wkNames'] = ['W&K-Bezeichnungen', 'Werte des W&K-Felds „kbsgebiet“, eine Bezeichnung pro Zeile. Genaue Treffer zuerst, sonst genügt es, wenn die Bezeichnung im W&K-Wert enthalten ist. Eine Bezeichnung darf in mehreren Kategorien stehen (z. B. „Alle Schularten“).'];
|
||||
|
||||
$lang['kindRef']['school'] = 'Schule (Lehrkräfte)';
|
||||
$lang['kindRef']['evangelical_school'] = 'Evangelische Schulen';
|
||||
$lang['kindRef']['elementary'] = 'Elementarbereich';
|
||||
$lang['kindRef']['community'] = 'Gemeinde';
|
||||
$lang['kindRef']['children_service'] = 'Gottesdienst mit Kindern';
|
||||
$lang['kindRef']['conference'] = 'Tagungen';
|
||||
$lang['kindRef']['training'] = 'Ausbildung';
|
||||
$lang['kindRef']['mentoring'] = 'Mentoring';
|
||||
$lang['kindRef']['other'] = 'Sonstiges';
|
||||
|
||||
$lang['new'] = ['Neue Kategorie', 'Eine neue Kategorie anlegen'];
|
||||
$lang['edit'] = ['Kategorie bearbeiten', 'Kategorie ID %s bearbeiten'];
|
||||
$lang['copy'] = ['Kategorie duplizieren', 'Kategorie ID %s duplizieren'];
|
||||
$lang['delete'] = ['Kategorie löschen', 'Kategorie ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Kategorie ID %s anzeigen'];
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_inquiry'];
|
||||
|
||||
$lang['state_legend'] = 'Status';
|
||||
$lang['person_legend'] = 'Person';
|
||||
$lang['message_legend'] = 'Anfrage';
|
||||
$lang['consent_legend'] = 'Einwilligungen';
|
||||
|
||||
$lang['state'] = ['Ordner', 'Bearbeitungsstand im Posteingang.'];
|
||||
$lang['dateCreated'] = ['Eingegangen am', ''];
|
||||
$lang['event'] = ['Veranstaltung', 'Veranstaltung, auf die sich die Anfrage bezieht.'];
|
||||
$lang['gender'] = ['Anrede', ''];
|
||||
$lang['academicTitle'] = ['Titel', ''];
|
||||
$lang['firstname'] = ['Vorname', ''];
|
||||
$lang['lastname'] = ['Nachname', ''];
|
||||
$lang['street'] = ['Straße', ''];
|
||||
$lang['postal'] = ['PLZ', ''];
|
||||
$lang['city'] = ['Ort', ''];
|
||||
$lang['email'] = ['E-Mail-Adresse', ''];
|
||||
$lang['phone'] = ['Telefon', ''];
|
||||
$lang['subject'] = ['Betreff', ''];
|
||||
$lang['notes'] = ['Nachricht', ''];
|
||||
$lang['newsletter'] = ['Newsletter gewünscht', ''];
|
||||
$lang['privacy'] = ['Datenschutzhinweise akzeptiert', ''];
|
||||
|
||||
$lang['new'] = ['Neue Anfrage', 'Eine Anfrage manuell anlegen'];
|
||||
$lang['edit'] = ['Anfrage bearbeiten', 'Anfrage ID %s bearbeiten'];
|
||||
$lang['delete'] = ['Anfrage löschen', 'Anfrage ID %s löschen'];
|
||||
$lang['show'] = ['Details', 'Details der Anfrage ID %s anzeigen'];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$lang = &$GLOBALS['TL_LANG']['tl_wk_sync_log'];
|
||||
|
||||
$lang['tstamp'] = ['Datum', ''];
|
||||
$lang['type'] = ['Vorgang', ''];
|
||||
$lang['status'] = ['Status', ''];
|
||||
$lang['user'] = ['Benutzer', ''];
|
||||
$lang['calendar'] = ['Kalender', ''];
|
||||
$lang['filename'] = ['Datei', ''];
|
||||
$lang['summary'] = ['Zusammenfassung', ''];
|
||||
$lang['messages'] = ['Meldungen', ''];
|
||||
|
||||
$lang['typeRef']['import'] = 'Import aus W&K';
|
||||
$lang['typeRef']['export'] = 'Export für W&K';
|
||||
$lang['statusRef']['preview'] = 'Vorschau';
|
||||
$lang['statusRef']['applied'] = 'Angewendet';
|
||||
$lang['statusRef']['failed'] = 'Verworfen/fehlgeschlagen';
|
||||
$lang['statusRef']['done'] = 'Erledigt';
|
||||
|
||||
$lang['show'] = ['Details', 'Details des Eintrags ID %s anzeigen'];
|
||||
$lang['delete'] = ['Eintrag löschen', 'Eintrag ID %s löschen'];
|
||||
Whitespace-only changes.
@@ -0,0 +1,7 @@
|
||||
{% trans_default_domain 'contao_default' %}
|
||||
{{ messages|raw }}
|
||||
<nav class="wk-sync-nav">
|
||||
<a href="{{ urls.dashboard }}" class="tl_submit{{ current_key in ['', 'preview'] ? ' active' }}">{{ 'MSC.wk_sync.nav.import'|trans }}</a>
|
||||
<a href="{{ urls.inbox }}" class="tl_submit{{ current_key == 'inbox' ? ' active' }}">{{ 'MSC.wk_sync.nav.inbox'|trans }}{% if counts is defined and counts[''] is defined and counts[''] > 0 %} <span class="wk-sync-badge">{{ counts[''] }}</span>{% endif %}</a>
|
||||
<a href="{{ urls.exports }}" class="tl_submit{{ current_key == 'exports' ? ' active' }}">{{ 'MSC.wk_sync.nav.exports'|trans }}</a>
|
||||
</nav>
|
||||
@@ -0,0 +1,80 @@
|
||||
{% trans_default_domain 'contao_default' %}
|
||||
<div class="wk-sync">
|
||||
{{ include('@Contao/backend/wk_sync/_nav.html.twig') }}
|
||||
|
||||
<h2>{{ 'MSC.wk_sync.import.headline'|trans }}</h2>
|
||||
|
||||
{% if not has_categories %}
|
||||
<p class="tl_error">{{ 'MSC.wk_sync.import.no_categories'|trans }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if calendars is empty %}
|
||||
<p class="tl_error">{{ 'MSC.wk_sync.import.no_calendar'|trans }}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ urls.upload }}" enctype="multipart/form-data" class="tl_form wk-sync-upload">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<input type="hidden" name="MAX_FILE_SIZE" value="{{ max_upload }}">
|
||||
<div class="tl_formbody_edit">
|
||||
<div class="widget w50">
|
||||
<label for="wk_calendar"><strong>{{ 'MSC.wk_sync.import.calendar'|trans }}</strong></label>
|
||||
<select name="calendar" id="wk_calendar" class="tl_select">
|
||||
{% for calendar in calendars %}
|
||||
<option value="{{ calendar.id }}">{{ calendar.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="widget w50">
|
||||
<label for="wk_file"><strong>{{ 'MSC.wk_sync.import.file'|trans }}</strong></label>
|
||||
<input type="file" name="file" id="wk_file" class="tl_upload_field" accept=".csv,.txt,text/csv,text/plain" required>
|
||||
<p class="tl_help tl_tip">{{ 'MSC.wk_sync.import.file_help'|trans([(max_upload / 1048576)|round(1)]) }}</p>
|
||||
</div>
|
||||
<div class="widget clr">
|
||||
<p class="tl_help">{{ 'MSC.wk_sync.import.hint'|trans }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tl_formbody_submit">
|
||||
<div class="tl_submit_container">
|
||||
<button type="submit" class="tl_submit">{{ 'MSC.wk_sync.import.upload_button'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ 'MSC.wk_sync.import.recent'|trans }}</h2>
|
||||
{% if imports is empty %}
|
||||
<p>{{ 'MSC.wk_sync.import.none'|trans }}</p>
|
||||
{% else %}
|
||||
<table class="tl_listing wk-sync-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.date'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.file'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.calendar'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.user'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.status'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.summary'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in imports %}
|
||||
<tr class="{{ loop.index is odd ? 'odd' : 'even' }}">
|
||||
<td class="tl_file_list"><a href="{{ log.preview_url }}">{{ log.date }}</a></td>
|
||||
<td class="tl_file_list">{{ log.filename }}</td>
|
||||
<td class="tl_file_list">{{ log.calendarTitle }}</td>
|
||||
<td class="tl_file_list">{{ log.userName|default('') }}</td>
|
||||
<td class="tl_file_list"><span class="wk-sync-status wk-sync-status--{{ log.status }}">{{ ('MSC.wk_sync.status.' ~ log.status)|trans }}</span></td>
|
||||
<td class="tl_file_list">
|
||||
{% set r = log.summary.result|default(null) %}
|
||||
{% set p = log.summary.plan|default(null) %}
|
||||
{% if r %}
|
||||
{{ 'MSC.wk_sync.log.result'|trans([r.created, r.updated, r.cancelled]) }}
|
||||
{% elseif p %}
|
||||
{{ 'MSC.wk_sync.log.planned'|trans([p.create, p.update, p.cancel, p.skip]) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
{% trans_default_domain 'contao_default' %}
|
||||
<div class="wk-sync">
|
||||
{{ include('@Contao/backend/wk_sync/_nav.html.twig') }}
|
||||
|
||||
<h2>{{ 'MSC.wk_sync.exports.headline'|trans }}</h2>
|
||||
<p class="tl_help">{{ 'MSC.wk_sync.exports.help'|trans }}</p>
|
||||
|
||||
{% if exports is empty %}
|
||||
<p class="tl_info">{{ 'MSC.wk_sync.exports.none'|trans }}</p>
|
||||
{% else %}
|
||||
<table class="tl_listing wk-sync-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.date'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.file'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.log.user'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.exports.content'|trans }}</th>
|
||||
<th class="tl_folder_tlist"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in exports %}
|
||||
<tr class="{{ loop.index is odd ? 'odd' : 'even' }}{{ log.id == highlight ? ' wk-sync-highlight' }}">
|
||||
<td class="tl_file_list">{{ log.date }}</td>
|
||||
<td class="tl_file_list">{{ log.filename }}</td>
|
||||
<td class="tl_file_list">{{ log.userName|default('') }}</td>
|
||||
<td class="tl_file_list">{{ 'MSC.wk_sync.exports.summary'|trans([log.summary.bookings|default(0), log.summary.inquiries|default(0)]) }}</td>
|
||||
<td class="tl_file_list tl_right_nowrap">
|
||||
{% if log.file_exists %}
|
||||
<a href="{{ log.download_url }}" class="tl_submit">{{ 'MSC.wk_sync.exports.download'|trans }}</a>
|
||||
{% else %}
|
||||
<span class="wk-sync-muted">{{ 'MSC.wk_sync.exports.missing'|trans }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,107 @@
|
||||
{% trans_default_domain 'contao_default' %}
|
||||
<div class="wk-sync">
|
||||
{{ include('@Contao/backend/wk_sync/_nav.html.twig') }}
|
||||
|
||||
<h2>{{ 'MSC.wk_sync.inbox.headline'|trans }}</h2>
|
||||
|
||||
<nav class="wk-sync-folders">
|
||||
{% for s in states %}
|
||||
<a href="{{ inbox_urls[s.translationKey] }}" class="tl_submit{{ s == state ? ' active' }}">{{ ('MSC.wk_sync.state.' ~ s.translationKey)|trans }} <span class="wk-sync-badge">{{ counts[s.value]|default(0) }}</span></a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{% if can_export %}
|
||||
<form method="post" action="{{ urls.export }}" class="wk-sync-export" data-wk-confirm="{{ 'MSC.wk_sync.export.confirm'|trans }}">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<button type="submit" class="tl_submit wk-sync-primary">{{ 'MSC.wk_sync.export.button'|trans }}</button>
|
||||
<span class="tl_help">{{ 'MSC.wk_sync.export.help'|trans }}</span>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if items is empty %}
|
||||
<p class="tl_info">{{ 'MSC.wk_sync.inbox.empty'|trans }}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ urls.move }}" id="wk-sync-bulk">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<input type="hidden" name="list" value="{{ state.value }}">
|
||||
<table class="tl_listing wk-sync-table wk-sync-inbox">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tl_folder_tlist wk-sync-col-check">{% if targets is not empty %}<input type="checkbox" data-wk-check-all title="{{ 'MSC.wk_sync.inbox.select_all'|trans }}">{% endif %}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.inbox.date'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.inbox.person'|trans }}</th>
|
||||
<th class="tl_folder_tlist">{{ 'MSC.wk_sync.inbox.data'|trans }}</th>
|
||||
<th class="tl_folder_tlist tl_right_nowrap"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in items %}
|
||||
{% set item = row.item %}
|
||||
<tr class="{{ loop.index is odd ? 'odd' : 'even' }} wk-sync-item wk-sync-item--{{ item.type }}">
|
||||
<td class="tl_file_list wk-sync-col-check">
|
||||
{% if targets is not empty %}<input type="checkbox" name="{{ item.type }}[]" value="{{ item.id }}" class="tl_checkbox">{% endif %}
|
||||
</td>
|
||||
<td class="tl_file_list wk-sync-col-date">
|
||||
{{ item.dateCreated|date(contao.datim_format) }}<br>
|
||||
<span class="wk-sync-type">{{ ('MSC.wk_sync.inbox.type_' ~ item.type)|trans }}</span>
|
||||
{% if item.duplicate %}<br><span class="wk-sync-flag" title="{{ 'MSC.wk_sync.inbox.duplicate_help'|trans }}">{{ 'MSC.wk_sync.inbox.duplicate'|trans }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="tl_file_list wk-sync-col-person">
|
||||
{% for line in item.addressLines %}
|
||||
{% if loop.first %}<strong>{{ line }}</strong>{% else %}{{ line }}{% endif %}{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="tl_file_list wk-sync-col-data">
|
||||
{% if item.isBooking %}
|
||||
<strong>{{ 'MSC.wk_sync.inbox.booking_for'|trans([item.eventWkId, item.eventTitle]) }}</strong><br>
|
||||
{% elseif item.eventTitle %}
|
||||
<strong>{{ 'MSC.wk_sync.inbox.inquiry_for'|trans([item.eventWkId, item.eventTitle]) }}</strong><br>
|
||||
{% endif %}
|
||||
{% for line in item.dataLines %}{{ line|nl2br }}{% if not loop.last %}<br>{% endif %}{% endfor %}
|
||||
</td>
|
||||
<td class="tl_file_list tl_right_nowrap wk-sync-col-actions">
|
||||
<a href="{{ row.edit_url }}" class="wk-sync-action" title="{{ 'MSC.wk_sync.inbox.edit'|trans }}">{{ 'MSC.wk_sync.inbox.edit'|trans }}</a>
|
||||
{% for target in row.targets %}
|
||||
<button type="submit" form="wk-sync-single-{{ item.type }}-{{ item.id }}" name="target" value="{{ target.value }}" class="wk-sync-action wk-sync-action--{{ target.translationKey }}" title="{{ ('MSC.wk_sync.inbox.move_to'|trans([('MSC.wk_sync.state.' ~ target.translationKey)|trans])) }}">{{ ('MSC.wk_sync.state.' ~ target.translationKey)|trans }}</button>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if targets is not empty %}
|
||||
<div class="wk-sync-bulk">
|
||||
<label>{{ 'MSC.wk_sync.inbox.bulk_label'|trans }}
|
||||
<select name="target" class="tl_select">
|
||||
{% for target in targets %}
|
||||
<option value="{{ target.value }}">{{ ('MSC.wk_sync.state.' ~ target.translationKey)|trans }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="tl_submit">{{ 'MSC.wk_sync.inbox.bulk_button'|trans }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% for row in items %}
|
||||
{% if row.targets is not empty %}
|
||||
<form method="post" action="{{ urls.move }}" id="wk-sync-single-{{ row.item.type }}-{{ row.item.id }}" class="wk-sync-hidden-form">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<input type="hidden" name="list" value="{{ state.value }}">
|
||||
<input type="hidden" name="type" value="{{ row.item.type }}">
|
||||
<input type="hidden" name="id" value="{{ row.item.id }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('form[data-wk-confirm]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (!window.confirm(form.getAttribute('data-wk-confirm'))) event.preventDefault();
|
||||
});
|
||||
});
|
||||
var all = document.querySelector('[data-wk-check-all]');
|
||||
if (all) all.addEventListener('change', function () {
|
||||
document.querySelectorAll('#wk-sync-bulk input[type=checkbox].tl_checkbox').forEach(function (box) { box.checked = all.checked; });
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
{% trans_default_domain 'contao_default' %}
|
||||
<div class="wk-sync">
|
||||
{{ include('@Contao/backend/wk_sync/_nav.html.twig') }}
|
||||
|
||||
<h2>{{ 'MSC.wk_sync.preview.headline'|trans([log.filename, calendar.title]) }}</h2>
|
||||
|
||||
<p class="wk-sync-meta">
|
||||
{{ 'MSC.wk_sync.log.date'|trans }}: {{ log.date }} ·
|
||||
{{ 'MSC.wk_sync.log.status'|trans }}: <span class="wk-sync-status wk-sync-status--{{ log.status }}">{{ ('MSC.wk_sync.status.' ~ log.status)|trans }}</span>
|
||||
</p>
|
||||
|
||||
<ul class="wk-sync-summary">
|
||||
<li class="wk-sync-summary__create"><strong>{{ summary.create }}</strong> {{ 'MSC.wk_sync.action.create'|trans }}</li>
|
||||
<li class="wk-sync-summary__update"><strong>{{ summary.update }}</strong> {{ 'MSC.wk_sync.action.update'|trans }}</li>
|
||||
<li class="wk-sync-summary__cancel"><strong>{{ summary.cancel }}</strong> {{ 'MSC.wk_sync.action.cancel'|trans }}</li>
|
||||
<li class="wk-sync-summary__unchanged"><strong>{{ summary.unchanged }}</strong> {{ 'MSC.wk_sync.action.unchanged'|trans }}</li>
|
||||
<li class="wk-sync-summary__skip"><strong>{{ summary.skip }}</strong> {{ 'MSC.wk_sync.action.skip'|trans }}</li>
|
||||
<li class="wk-sync-summary__warnings"><strong>{{ summary.warnings }}</strong> {{ 'MSC.wk_sync.preview.warnings'|trans }}</li>
|
||||
<li class="wk-sync-summary__errors"><strong>{{ summary.errors }}</strong> {{ 'MSC.wk_sync.preview.errors'|trans }}</li>
|
||||
</ul>
|
||||
|
||||
{% if result %}
|
||||
<p class="tl_confirm">{{ 'MSC.wk_sync.import.applied'|trans([result.created, result.updated, result.cancelled, result.unchanged]) }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if is_open %}
|
||||
<div class="wk-sync-actions">
|
||||
{% if plan and plan.hasWrites %}
|
||||
<form method="post" action="{{ urls.apply }}" class="wk-sync-inline" data-wk-confirm="{{ 'MSC.wk_sync.preview.confirm'|trans }}">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<input type="hidden" name="id" value="{{ log.id }}">
|
||||
<button type="submit" class="tl_submit wk-sync-primary">{{ 'MSC.wk_sync.preview.apply'|trans }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="tl_info">{{ 'MSC.wk_sync.preview.nothing'|trans }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ urls.discard }}" class="wk-sync-inline">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
|
||||
<input type="hidden" name="id" value="{{ log.id }}">
|
||||
<button type="submit" class="tl_submit">{{ 'MSC.wk_sync.preview.discard'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if messages_list is not empty %}
|
||||
<h3>{{ 'MSC.wk_sync.preview.messages'|trans }}</h3>
|
||||
<ul class="wk-sync-messages">
|
||||
{% for message in messages_list %}
|
||||
<li class="wk-sync-message wk-sync-message--{{ message.type }}">{{ message.text }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% for action, entries in groups %}
|
||||
{% if entries is not empty %}
|
||||
<details class="wk-sync-group wk-sync-group--{{ action }}"{{ action in ['create', 'update', 'cancel'] ? ' open' }}>
|
||||
<summary><strong>{{ entries|length }}</strong> {{ ('MSC.wk_sync.action.' ~ action)|trans }}</summary>
|
||||
<table class="tl_listing wk-sync-table">
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr class="{{ loop.index is odd ? 'odd' : 'even' }}">
|
||||
<td class="tl_file_list wk-sync-col-id">{{ entry.event.wkId }}</td>
|
||||
<td class="tl_file_list wk-sync-col-title">
|
||||
<strong>{{ entry.event.title }}</strong>
|
||||
{% if entry.date %}<br><span class="wk-sync-muted">{{ entry.date }}</span>{% endif %}
|
||||
{% for message in entry.event.messages %}
|
||||
<br><span class="wk-sync-message wk-sync-message--{{ message.type }}">{{ message.text }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="tl_file_list wk-sync-col-changes">
|
||||
{% if entry.changes is not empty %}
|
||||
<dl class="wk-sync-changes">
|
||||
{% for change in entry.changes %}
|
||||
<dt>{{ change.label }}</dt>
|
||||
<dd><del>{{ change.old }}</del> → <ins>{{ change.new }}</ins></dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('form[data-wk-confirm]').forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (!window.confirm(form.getAttribute('data-wk-confirm'))) event.preventDefault();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends '@Contao/frontend_module/_base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% set attributes = attrs().set('data-wk-booking-rules', rules_config) %}
|
||||
<div class="wk-booking" {{ attributes }}>
|
||||
{% block booking_header %}
|
||||
<p class="wk-booking__course">{{ 'MSC.wk_booking.course_number'|trans({}, 'contao_default') }}: {{ event.wkId }}</p>
|
||||
<h2 class="wk-booking__title">{{ event.title }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block booking_status %}
|
||||
{% if availability.isBookable %}
|
||||
<p class="wk-booking__places">
|
||||
{{ 'MSC.wk_booking.places_left'|trans([places_left], 'contao_default') }}
|
||||
{% if deadline %}
|
||||
· {{ 'MSC.wk_booking.deadline'|trans([deadline|date(contao.date_format)], 'contao_default') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% elseif availability.isExternal %}
|
||||
<p class="wk-booking__external">
|
||||
{% if availability.externalContactIsEmail %}
|
||||
{{ 'MSC.wk_booking.status.external_email'|trans({}, 'contao_default') }}
|
||||
<a href="mailto:{{ availability.externalContact }}">{{ availability.externalContact }}</a>
|
||||
{% else %}
|
||||
{{ 'MSC.wk_booking.status.external_url'|trans({}, 'contao_default') }}
|
||||
<a href="{{ availability.externalContact }}" target="_blank" rel="noopener">{{ availability.externalContact }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="wk-booking__closed">{{ ('MSC.wk_booking.status.' ~ availability.reason)|trans({}, 'contao_default') }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block booking_form %}
|
||||
{% if form_html %}
|
||||
<div class="wk-booking__form">
|
||||
{{ form_html|raw }}
|
||||
</div>
|
||||
<script src="{{ asset('wk-sync.js', 'contao_wk_sync') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends '@Contao/frontend_module/_base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="wk-booking-status wk-booking-status--{{ availability.reason }}">
|
||||
{% block status %}
|
||||
{% if availability.isBookable %}
|
||||
<p class="wk-booking-status__places">{{ 'MSC.wk_booking.places_left'|trans([places_left], 'contao_default') }}</p>
|
||||
{% if deadline %}
|
||||
<p class="wk-booking-status__deadline">{{ 'MSC.wk_booking.deadline'|trans([deadline|date(contao.date_format)], 'contao_default') }}</p>
|
||||
{% endif %}
|
||||
{% if booking_url %}
|
||||
<p class="wk-booking-status__link"><a href="{{ booking_url }}" class="wk-booking-status__button">{{ 'MSC.wk_booking.book_now'|trans({}, 'contao_default') }}</a></p>
|
||||
{% endif %}
|
||||
{% elseif availability.isExternal %}
|
||||
<p class="wk-booking-status__external">
|
||||
{% if availability.externalContactIsEmail %}
|
||||
{{ 'MSC.wk_booking.status.external_email'|trans({}, 'contao_default') }}
|
||||
<a href="mailto:{{ availability.externalContact }}">{{ availability.externalContact }}</a>
|
||||
{% else %}
|
||||
{{ 'MSC.wk_booking.status.external_url'|trans({}, 'contao_default') }}
|
||||
<a href="{{ availability.externalContact }}" target="_blank" rel="noopener">{{ availability.externalContact }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="wk-booking-status__closed">{{ ('MSC.wk_booking.status.' ~ availability.reason)|trans({}, 'contao_default') }}</p>
|
||||
{% if inquiry_url and availability.reason in ['deadline', 'full'] %}
|
||||
<p class="wk-booking-status__inquiry">{{ 'MSC.wk_booking.late_inquiry'|trans({}, 'contao_default') }} <a href="{{ inquiry_url }}">{{ 'MSC.wk_booking.inquiry_form'|trans({}, 'contao_default') }}</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block inquiry %}
|
||||
{% if inquiry_url %}
|
||||
<p class="wk-booking-status__question">{{ 'MSC.wk_booking.questions'|trans({}, 'contao_default') }} <a href="{{ inquiry_url }}">{{ 'MSC.wk_booking.inquiry_form'|trans({}, 'contao_default') }}</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.0/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="wk-sync">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,46 @@
|
||||
.wk-sync { padding: 0 0 1rem; }
|
||||
.wk-sync h2 { margin: 1.5rem 0 .75rem; font-size: 1.1rem; }
|
||||
.wk-sync h3 { margin: 1.25rem 0 .5rem; }
|
||||
.wk-sync-nav, .wk-sync-folders { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 1rem; }
|
||||
.wk-sync-nav .active, .wk-sync-folders .active { background: var(--contao-primary, #f47c00); color: #fff; border-color: transparent; }
|
||||
.wk-sync-badge { display: inline-block; min-width: 1.4em; padding: 0 .35em; border-radius: 1em; background: rgba(0,0,0,.15); font-size: .8em; text-align: center; }
|
||||
.wk-sync-upload .widget { margin-bottom: 1rem; }
|
||||
.wk-sync-table { width: 100%; }
|
||||
.wk-sync-table td { vertical-align: top; }
|
||||
.wk-sync-col-id { width: 6rem; white-space: nowrap; }
|
||||
.wk-sync-col-check { width: 2rem; }
|
||||
.wk-sync-col-date { width: 9rem; }
|
||||
.wk-sync-col-person { width: 22%; }
|
||||
.wk-sync-col-actions { width: 11rem; }
|
||||
.wk-sync-col-actions .wk-sync-action { display: block; width: 100%; margin-bottom: .25rem; padding: .2rem .4rem; text-align: left; font-size: .85em; border: 1px solid #ccc; border-radius: 3px; background: #f6f6f6; color: inherit; cursor: pointer; text-decoration: none; }
|
||||
.wk-sync-col-actions .wk-sync-action:hover { background: #e8e8e8; }
|
||||
.wk-sync-summary { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; margin: 0 0 1rem; padding: 0; list-style: none; }
|
||||
.wk-sync-summary strong { font-size: 1.2em; }
|
||||
.wk-sync-summary__errors strong { color: #c33; }
|
||||
.wk-sync-summary__warnings strong { color: #b7791f; }
|
||||
.wk-sync-actions { display: flex; gap: .75rem; align-items: center; margin: 1rem 0; }
|
||||
.wk-sync-inline { display: inline; }
|
||||
.wk-sync-primary { font-weight: bold; }
|
||||
.wk-sync-messages { margin: 0 0 1rem; padding-left: 1.25rem; }
|
||||
.wk-sync-message--error { color: #c33; }
|
||||
.wk-sync-message--warning { color: #b7791f; }
|
||||
.wk-sync-message--info { color: #555; }
|
||||
.wk-sync-group { margin-bottom: 1rem; }
|
||||
.wk-sync-group summary { cursor: pointer; padding: .5rem 0; }
|
||||
.wk-sync-changes { margin: 0; }
|
||||
.wk-sync-changes dt { font-weight: bold; margin-top: .25rem; }
|
||||
.wk-sync-changes dd { margin: 0 0 .25rem; }
|
||||
.wk-sync-changes del { color: #c33; text-decoration: line-through; }
|
||||
.wk-sync-changes ins { color: #2a7; text-decoration: none; }
|
||||
.wk-sync-muted { color: #888; }
|
||||
.wk-sync-type { font-size: .85em; color: #888; }
|
||||
.wk-sync-flag { display: inline-block; padding: 0 .35em; border-radius: 3px; background: #fdecc8; color: #7a4b00; font-size: .8em; }
|
||||
.wk-sync-status { display: inline-block; padding: 0 .4em; border-radius: 3px; font-size: .85em; background: #eee; }
|
||||
.wk-sync-status--applied, .wk-sync-status--done { background: #d7f5dd; color: #1f6b34; }
|
||||
.wk-sync-status--preview { background: #fff2cc; color: #7a4b00; }
|
||||
.wk-sync-status--failed { background: #fbd9d9; color: #8a1f1f; }
|
||||
.wk-sync-export { margin: 0 0 1rem; display: flex; gap: .75rem; align-items: center; }
|
||||
.wk-sync-bulk { display: flex; gap: .75rem; align-items: center; margin: .75rem 0; }
|
||||
.wk-sync-hidden-form { display: none; }
|
||||
.wk-sync-highlight td { background: #fff8e1; }
|
||||
.wk-sync-meta { margin: 0 0 .75rem; }
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* W&K-Sync: blendet abhängige Felder des Anmeldeformulars ein und aus (wie Subpaletten im Backend).
|
||||
* Die Regeln kommen als JSON aus dem Modul (data-wk-booking-rules); serverseitig gelten dieselben Regeln.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function widgetOf(form, name) {
|
||||
var input = form.querySelector('[name="' + name + '"], [name="' + name + '[]"]');
|
||||
return input ? input.closest('.widget') || input.parentElement : null;
|
||||
}
|
||||
|
||||
function valueOf(form, name) {
|
||||
var inputs = form.querySelectorAll('[name="' + name + '"], [name="' + name + '[]"]');
|
||||
var value = '';
|
||||
|
||||
inputs.forEach(function (input) {
|
||||
if ((input.type === 'checkbox' || input.type === 'radio')) {
|
||||
if (input.checked) value = input.value;
|
||||
} else if (input.tagName === 'SELECT' || input.type !== 'hidden') {
|
||||
value = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function setVisible(widget, visible) {
|
||||
if (!widget) return;
|
||||
widget.hidden = !visible;
|
||||
widget.querySelectorAll('input, select, textarea').forEach(function (field) {
|
||||
field.disabled = !visible;
|
||||
});
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
var form = container.querySelector('form');
|
||||
var rules;
|
||||
|
||||
try {
|
||||
rules = JSON.parse(container.getAttribute('data-wk-booking-rules') || '{}');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form || !rules.dependencies) return;
|
||||
|
||||
var dependencies = rules.dependencies;
|
||||
var controllers = {};
|
||||
|
||||
Object.keys(dependencies).forEach(function (field) {
|
||||
var dependency = dependencies[field];
|
||||
controllers[dependency.field] = controllers[dependency.field] || [];
|
||||
controllers[dependency.field].push(field);
|
||||
});
|
||||
|
||||
function update() {
|
||||
Object.keys(dependencies).forEach(function (field) {
|
||||
var dependency = dependencies[field];
|
||||
var controllerWidget = widgetOf(form, dependency.field);
|
||||
var controllerVisible = controllerWidget ? !controllerWidget.hidden : true;
|
||||
var value = valueOf(form, dependency.field);
|
||||
var visible = controllerVisible && dependency.values.indexOf(String(value)) !== -1;
|
||||
setVisible(widgetOf(form, field), visible);
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(controllers).forEach(function (name) {
|
||||
form.querySelectorAll('[name="' + name + '"], [name="' + name + '[]"]').forEach(function (input) {
|
||||
input.addEventListener('change', update);
|
||||
});
|
||||
});
|
||||
|
||||
// Zweimal, damit verschachtelte Abhängigkeiten (Feld hängt von verstecktem Feld ab) stimmen
|
||||
update();
|
||||
update();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
document.querySelectorAll('[data-wk-booking-rules]').forEach(init);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Backend;
|
||||
|
||||
use Contao\BackendUser;
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\Csrf\ContaoCsrfTokenManager;
|
||||
use Contao\CoreBundle\Exception\AccessDeniedException;
|
||||
use Contao\CoreBundle\Exception\PageNotFoundException;
|
||||
use Contao\CoreBundle\Exception\RedirectResponseException;
|
||||
use Contao\CoreBundle\Exception\ResponseException;
|
||||
use Contao\CoreBundle\Framework\Adapter;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\Date;
|
||||
use Contao\Message;
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Export\BookingExporter;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportException;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportMessage;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportPlan;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportService;
|
||||
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
|
||||
use Mummert\ContaoWkSyncBundle\Import\SyncFileStorage;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxItem;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\SyncLog;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Backend-Modul „W&K-Sync“: Import mit Vorschau, Posteingang, Export.
|
||||
*/
|
||||
class WkSyncBackend
|
||||
{
|
||||
public const string MODULE = 'wk_sync_tools';
|
||||
|
||||
private const int MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Environment $twig,
|
||||
private readonly RouterInterface $router,
|
||||
private readonly ContaoCsrfTokenManager $csrfTokenManager,
|
||||
private readonly Security $security,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly ImportService $importService,
|
||||
private readonly SyncFileStorage $storage,
|
||||
private readonly SyncLog $log,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly InboxRepository $inbox,
|
||||
private readonly BookingExporter $exporter,
|
||||
private readonly CategoryRepository $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (null === $request) {
|
||||
throw new \LogicException('No request.');
|
||||
}
|
||||
|
||||
$this->framework->initialize();
|
||||
$key = (string) $request->query->get('key', '');
|
||||
|
||||
try {
|
||||
return match ($key) {
|
||||
'' => $this->dashboard($request),
|
||||
'upload' => $this->upload($request),
|
||||
'preview' => $this->preview($request),
|
||||
'apply' => $this->apply($request),
|
||||
'discard' => $this->discard($request),
|
||||
'inbox' => $this->inboxView($request),
|
||||
'move' => $this->move($request),
|
||||
'export' => $this->export($request),
|
||||
'exports' => $this->exports($request),
|
||||
'download' => $this->download($request),
|
||||
default => throw new PageNotFoundException('Unknown action "'.$key.'"'),
|
||||
};
|
||||
} catch (ImportException $e) {
|
||||
$this->message()->addError($e->getMessage());
|
||||
|
||||
throw new RedirectResponseException($this->url([]));
|
||||
}
|
||||
}
|
||||
|
||||
private function dashboard(Request $request): string
|
||||
{
|
||||
return $this->render('dashboard', [
|
||||
'calendars' => $this->calendars->findEnabled(),
|
||||
'imports' => array_map($this->decorateLog(...), $this->log->findLatest(SyncLog::TYPE_IMPORT, 10)),
|
||||
'counts' => $this->inbox->countByState(),
|
||||
'has_categories' => !$this->categories->isEmpty(),
|
||||
'max_upload' => $this->maxUploadBytes(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function upload(Request $request): string
|
||||
{
|
||||
$this->requirePost($request);
|
||||
|
||||
$file = $request->files->get('file');
|
||||
$calendarId = (int) $request->request->get('calendar', 0);
|
||||
|
||||
if (!$file instanceof UploadedFile || !$file->isValid()) {
|
||||
throw new ImportException($this->trans('upload.missing'));
|
||||
}
|
||||
|
||||
if ($file->getSize() > $this->maxUploadBytes()) {
|
||||
throw new ImportException($this->trans('upload.too_large'));
|
||||
}
|
||||
|
||||
if (!\in_array(strtolower((string) $file->getClientOriginalExtension()), ['csv', 'txt'], true)) {
|
||||
throw new ImportException($this->trans('upload.wrong_type'));
|
||||
}
|
||||
|
||||
$filename = (string) $file->getClientOriginalName();
|
||||
$path = $this->storage->storeUpload($file);
|
||||
|
||||
$prepared = $this->importService->prepare($path, $filename, $calendarId, $this->userId());
|
||||
|
||||
throw new RedirectResponseException($this->url(['key' => 'preview', 'id' => $prepared['logId']]));
|
||||
}
|
||||
|
||||
private function preview(Request $request): string
|
||||
{
|
||||
$id = (int) $request->query->get('id', 0);
|
||||
$log = $this->log->find($id);
|
||||
|
||||
if (null === $log || SyncLog::TYPE_IMPORT !== $log['type']) {
|
||||
throw new ImportException($this->trans('import.not_found'));
|
||||
}
|
||||
|
||||
// Bereits angewendete oder verworfene Importe: gespeicherten Stand zeigen, nichts neu berechnen
|
||||
if (SyncLog::STATUS_PREVIEW !== $log['status']) {
|
||||
$summary = $log['summary']['plan'] ?? [];
|
||||
|
||||
return $this->render('preview', [
|
||||
'log' => $this->decorateLog($log),
|
||||
'calendar' => $this->calendars->find((int) $log['calendar']),
|
||||
'plan' => null,
|
||||
'summary' => [...array_fill_keys([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL, PlannedEvent::UNCHANGED, PlannedEvent::SKIP, 'errors', 'warnings'], 0), ...$summary],
|
||||
'groups' => [],
|
||||
'messages_list' => array_map(ImportMessage::fromArray(...), $log['messages'] ?? []),
|
||||
'is_open' => false,
|
||||
'result' => $log['summary']['result'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$preview = $this->importService->preview($id);
|
||||
$plan = $preview['plan'];
|
||||
|
||||
return $this->render('preview', [
|
||||
'log' => $this->decorateLog($log),
|
||||
'calendar' => $preview['calendar'],
|
||||
'plan' => $plan,
|
||||
'summary' => $plan->summary(),
|
||||
'groups' => $this->groupPlan($plan),
|
||||
'messages_list' => $plan->allMessages(),
|
||||
'is_open' => true,
|
||||
'result' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function apply(Request $request): string
|
||||
{
|
||||
$this->requirePost($request);
|
||||
$id = (int) $request->request->get('id', 0);
|
||||
|
||||
$applied = $this->importService->apply($id, $this->userId());
|
||||
$result = $applied['result'];
|
||||
|
||||
$this->message()->addConfirmation($this->trans('import.applied', [$result['created'], $result['updated'], $result['cancelled'], $result['unchanged']]));
|
||||
|
||||
throw new RedirectResponseException($this->url(['key' => 'preview', 'id' => $id]));
|
||||
}
|
||||
|
||||
private function discard(Request $request): string
|
||||
{
|
||||
$this->requirePost($request);
|
||||
$id = (int) $request->request->get('id', 0);
|
||||
$log = $this->log->find($id);
|
||||
|
||||
if (null !== $log && SyncLog::STATUS_PREVIEW === $log['status']) {
|
||||
$this->log->update($id, ['status' => SyncLog::STATUS_FAILED, 'summary' => ['discarded' => true]]);
|
||||
$this->message()->addInfo($this->trans('import.discarded'));
|
||||
}
|
||||
|
||||
throw new RedirectResponseException($this->url([]));
|
||||
}
|
||||
|
||||
private function inboxView(Request $request): string
|
||||
{
|
||||
$state = InboxState::tryFrom((string) $request->query->get('list', '')) ?? InboxState::New;
|
||||
$items = $this->inbox->findByState($state, $state->isFinal() ? 300 : 0);
|
||||
$referer = (string) $request->attributes->get('_contao_referer_id', '');
|
||||
|
||||
$decorated = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$decorated[] = [
|
||||
'item' => $item,
|
||||
'edit_url' => $this->editUrl($item, $referer),
|
||||
'targets' => $state->moveTargets(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->render('inbox', [
|
||||
'state' => $state,
|
||||
'states' => InboxState::cases(),
|
||||
'counts' => $this->inbox->countByState(),
|
||||
'items' => $decorated,
|
||||
'targets' => $state->moveTargets(),
|
||||
'can_export' => InboxState::DownloadList === $state && [] !== $items,
|
||||
]);
|
||||
}
|
||||
|
||||
private function move(Request $request): string
|
||||
{
|
||||
$this->requirePost($request);
|
||||
|
||||
$target = InboxState::tryFrom((string) $request->request->get('target', '-'));
|
||||
$list = (string) $request->request->get('list', '');
|
||||
|
||||
if (null === $target || InboxState::Exported === $target) {
|
||||
throw new ImportException($this->trans('inbox.invalid_target'));
|
||||
}
|
||||
|
||||
$moved = 0;
|
||||
|
||||
foreach ([InboxItem::TYPE_BOOKING, InboxItem::TYPE_INQUIRY] as $type) {
|
||||
$ids = $request->request->all($type);
|
||||
$moved += $this->inbox->move($type, \is_array($ids) ? $ids : [], $target);
|
||||
}
|
||||
|
||||
// Einzelaktion: type + id
|
||||
$single = (string) $request->request->get('type', '');
|
||||
$singleId = (int) $request->request->get('id', 0);
|
||||
|
||||
if ('' !== $single && $singleId > 0) {
|
||||
$moved += $this->inbox->move($single, [$singleId], $target);
|
||||
}
|
||||
|
||||
$this->message()->addConfirmation($this->trans('inbox.moved', [$moved, $this->trans('state.'.$target->translationKey())]));
|
||||
|
||||
throw new RedirectResponseException($this->url(['key' => 'inbox', 'list' => $list]));
|
||||
}
|
||||
|
||||
private function export(Request $request): string
|
||||
{
|
||||
$this->requirePost($request);
|
||||
|
||||
$result = $this->exporter->export($this->userId());
|
||||
|
||||
if (null === $result) {
|
||||
$this->message()->addInfo($this->trans('export.empty'));
|
||||
|
||||
throw new RedirectResponseException($this->url(['key' => 'inbox', 'list' => InboxState::DownloadList->value]));
|
||||
}
|
||||
|
||||
$this->message()->addConfirmation($this->trans('export.done', [$result['bookings'], $result['inquiries']]));
|
||||
|
||||
throw new RedirectResponseException($this->url(['key' => 'exports', 'highlight' => $result['logId']]));
|
||||
}
|
||||
|
||||
private function exports(Request $request): string
|
||||
{
|
||||
return $this->render('exports', [
|
||||
'exports' => array_map($this->decorateLog(...), $this->log->findLatest(SyncLog::TYPE_EXPORT, 50)),
|
||||
'highlight' => (int) $request->query->get('highlight', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
private function download(Request $request): string
|
||||
{
|
||||
$log = $this->log->find((int) $request->query->get('id', 0));
|
||||
|
||||
if (null === $log || SyncLog::TYPE_EXPORT !== $log['type'] || !$this->storage->exists((string) $log['path'])) {
|
||||
throw new PageNotFoundException('Export file not found.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($this->storage->absolutePath((string) $log['path']));
|
||||
$response->headers->set('Content-Type', 'text/csv; charset=ISO-8859-1');
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, (string) $log['filename']);
|
||||
$response->headers->addCacheControlDirective('no-store');
|
||||
|
||||
throw new ResponseException($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array<string, mixed>>>
|
||||
*/
|
||||
private function groupPlan(ImportPlan $plan): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL, PlannedEvent::SKIP, PlannedEvent::UNCHANGED] as $action) {
|
||||
$groups[$action] = array_map(fn (PlannedEvent $e): array => [
|
||||
'event' => $e,
|
||||
'changes' => $this->formatChanges($e),
|
||||
'date' => isset($e->data['startDate']) ? $this->formatDate((int) $e->data['startDate']) : '',
|
||||
], $plan->byAction($action));
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{field: string, label: string, old: string, new: string}>
|
||||
*/
|
||||
private function formatChanges(PlannedEvent $event): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($event->changes as $field => [$old, $new]) {
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'label' => $this->fieldLabel($field),
|
||||
'old' => $this->formatValue($field, $old),
|
||||
'new' => $this->formatValue($field, $new),
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function formatValue(string $field, mixed $value): string
|
||||
{
|
||||
if (\in_array($field, ['startDate', 'endDate'], true)) {
|
||||
return (int) $value > 0 ? $this->formatDate((int) $value) : '';
|
||||
}
|
||||
|
||||
if (\in_array($field, ['startTime', 'endTime'], true)) {
|
||||
return (int) $value > 0 ? $this->formatDate((int) $value, 'datimFormat') : '';
|
||||
}
|
||||
|
||||
if (\in_array($field, ['addTime', 'published'], true)) {
|
||||
return $this->translator->trans($value ? 'MSC.yes' : 'MSC.no', [], 'contao_default');
|
||||
}
|
||||
|
||||
if ('wkCategories' === $field) {
|
||||
$titles = $this->categories->titles();
|
||||
|
||||
return implode(', ', array_map(static fn ($id): string => $titles[(int) $id] ?? (string) $id, \is_array($value) ? $value : StringUtil::deserialize($value, true)));
|
||||
}
|
||||
|
||||
if ('wkCategoryPrimary' === $field) {
|
||||
return $this->categories->titles()[(int) $value] ?? ((int) $value > 0 ? (string) $value : '');
|
||||
}
|
||||
|
||||
if (str_starts_with($field, 'wkPrice') && !str_starts_with($field, 'wkPriceType')) {
|
||||
return number_format((float) $value, 2, ',', '.');
|
||||
}
|
||||
|
||||
if ('wkState' === $field) {
|
||||
$value = (string) $value;
|
||||
|
||||
return '' === $value ? '' : $this->translator->trans('tl_calendar_events.wkStateRef.'.$value, [], 'contao_tl_calendar_events');
|
||||
}
|
||||
|
||||
$text = trim(strip_tags((string) $value));
|
||||
|
||||
return mb_strlen($text) > 160 ? mb_substr($text, 0, 157).'…' : $text;
|
||||
}
|
||||
|
||||
private function fieldLabel(string $field): string
|
||||
{
|
||||
$label = $this->translator->trans('tl_calendar_events.'.$field.'.0', [], 'contao_tl_calendar_events');
|
||||
|
||||
return $label === 'tl_calendar_events.'.$field.'.0' ? $field : $label;
|
||||
}
|
||||
|
||||
private function formatDate(int $timestamp, string $format = 'dateFormat'): string
|
||||
{
|
||||
$config = $this->framework->getAdapter(Config::class);
|
||||
|
||||
return $this->framework->getAdapter(Date::class)->parse((string) $config->get($format), $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $log
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decorateLog(array $log): array
|
||||
{
|
||||
$log['date'] = $this->formatDate((int) $log['tstamp'], 'datimFormat');
|
||||
$log['calendarTitle'] = $this->calendars->find((int) $log['calendar'])?->title ?? '';
|
||||
$log['preview_url'] = $this->url(['key' => 'preview', 'id' => $log['id']]);
|
||||
$log['download_url'] = $this->url(['key' => 'download', 'id' => $log['id']]);
|
||||
$log['file_exists'] = $this->storage->exists((string) ($log['path'] ?? ''));
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
private function editUrl(InboxItem $item, string $referer): string
|
||||
{
|
||||
$params = $item->isBooking()
|
||||
? ['do' => 'calendar', 'table' => 'tl_wk_booking', 'act' => 'edit', 'id' => $item->id]
|
||||
: ['do' => 'wk_sync_inquiries', 'table' => 'tl_wk_inquiry', 'act' => 'edit', 'id' => $item->id];
|
||||
|
||||
if ('' !== $referer) {
|
||||
$params['ref'] = $referer;
|
||||
}
|
||||
|
||||
return $this->router->generate('contao_backend', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function render(string $template, array $context): string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
return $this->twig->render('@Contao/backend/wk_sync/'.$template.'.html.twig', [
|
||||
...$context,
|
||||
'messages' => $this->message()->generate(),
|
||||
'request_token' => $this->csrfTokenManager->getDefaultTokenValue(),
|
||||
'urls' => [
|
||||
'dashboard' => $this->url([]),
|
||||
'upload' => $this->url(['key' => 'upload']),
|
||||
'apply' => $this->url(['key' => 'apply']),
|
||||
'discard' => $this->url(['key' => 'discard']),
|
||||
'inbox' => $this->url(['key' => 'inbox']),
|
||||
'move' => $this->url(['key' => 'move']),
|
||||
'export' => $this->url(['key' => 'export']),
|
||||
'exports' => $this->url(['key' => 'exports']),
|
||||
],
|
||||
'inbox_urls' => array_combine(
|
||||
array_map(static fn (InboxState $s): string => $s->translationKey(), InboxState::cases()),
|
||||
array_map(fn (InboxState $s): string => $this->url(['key' => 'inbox', 'list' => $s->value]), InboxState::cases()),
|
||||
),
|
||||
'current_key' => (string) $request?->query->get('key', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function url(array $params): string
|
||||
{
|
||||
return $this->router->generate('contao_backend', ['do' => self::MODULE, ...$params]);
|
||||
}
|
||||
|
||||
private function requirePost(Request $request): void
|
||||
{
|
||||
if (!$request->isMethod('POST')) {
|
||||
throw new AccessDeniedException('POST required.');
|
||||
}
|
||||
}
|
||||
|
||||
private function userId(): int
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
return $user instanceof BackendUser ? (int) $user->id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Adapter<Message>
|
||||
*/
|
||||
private function message(): Adapter
|
||||
{
|
||||
return $this->framework->getAdapter(Message::class);
|
||||
}
|
||||
|
||||
private function maxUploadBytes(): int
|
||||
{
|
||||
$limits = [self::MAX_UPLOAD_BYTES];
|
||||
|
||||
foreach (['upload_max_filesize', 'post_max_size'] as $ini) {
|
||||
$value = (string) \ini_get($ini);
|
||||
|
||||
if ('' !== $value && '0' !== $value && '-1' !== $value) {
|
||||
$limits[] = $this->iniBytes($value);
|
||||
}
|
||||
}
|
||||
|
||||
return (int) min($limits);
|
||||
}
|
||||
|
||||
private function iniBytes(string $value): int
|
||||
{
|
||||
$unit = strtolower(substr($value, -1));
|
||||
$number = (int) $value;
|
||||
|
||||
return match ($unit) {
|
||||
'g' => $number * 1024 ** 3,
|
||||
'm' => $number * 1024 ** 2,
|
||||
'k' => $number * 1024,
|
||||
default => (int) $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function trans(string $key, array $params = []): string
|
||||
{
|
||||
return $this->translator->trans('MSC.wk_sync.'.$key, $params, 'contao_default');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Backend;
|
||||
|
||||
use Contao\DataContainer;
|
||||
use Contao\System;
|
||||
|
||||
/**
|
||||
* Klassisches Backend-Modul (BE_MOD callback); die Arbeit erledigt der Dienst WkSyncBackend.
|
||||
*/
|
||||
class WkSyncModule
|
||||
{
|
||||
public function __construct(DataContainer|null $dc = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function generate(): string
|
||||
{
|
||||
return System::getContainer()->get(WkSyncBackend::class)->handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
/**
|
||||
* Warum (nicht) gebucht werden kann.
|
||||
*/
|
||||
final readonly class Availability
|
||||
{
|
||||
public const string OK = 'ok';
|
||||
public const string NOT_ENABLED = 'not_enabled';
|
||||
public const string UNPUBLISHED = 'unpublished';
|
||||
public const string CANCELLED = 'cancelled';
|
||||
public const string CLOSED_AUDIENCE = 'closed_audience';
|
||||
public const string EXTERNAL = 'external';
|
||||
public const string PAST = 'past';
|
||||
public const string DEADLINE = 'deadline';
|
||||
public const string FULL = 'full';
|
||||
public const string NO_FORM = 'no_form';
|
||||
|
||||
public function __construct(
|
||||
public string $reason,
|
||||
public int $placesLeft,
|
||||
public int $openBookings,
|
||||
public int $deadline,
|
||||
public int $displayDeadline,
|
||||
public string $externalContact = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isBookable(): bool
|
||||
{
|
||||
return self::OK === $this->reason;
|
||||
}
|
||||
|
||||
public function isExternal(): bool
|
||||
{
|
||||
return self::EXTERNAL === $this->reason;
|
||||
}
|
||||
|
||||
public function externalContactIsEmail(): bool
|
||||
{
|
||||
return str_contains($this->externalContact, '@') && !str_contains($this->externalContact, '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\Validator;
|
||||
use Mummert\ContaoWkSyncBundle\Import\WkState;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Entscheidet, ob eine Veranstaltung aktuell online gebucht werden kann.
|
||||
*/
|
||||
class BookingAvailability
|
||||
{
|
||||
public function __construct(private readonly BookingRepository $bookings)
|
||||
{
|
||||
}
|
||||
|
||||
public function check(BookingEvent $event, CalendarConfig $calendar, int|null $now = null): Availability
|
||||
{
|
||||
$now ??= time();
|
||||
$open = $this->bookings->countOpenByEvent($event->id);
|
||||
$placesLeft = max(0, $event->placesLeft - $open);
|
||||
$deadline = $event->startTime - $calendar->deadlineDays * 86400;
|
||||
$displayDeadline = $calendar->deadlineDisplayDays > 0 ? $event->startTime - $calendar->deadlineDisplayDays * 86400 : $deadline;
|
||||
|
||||
$result = fn (string $reason, string $contact = ''): Availability => new Availability($reason, $placesLeft, $open, $deadline, $displayDeadline, $contact);
|
||||
|
||||
if (!$calendar->enabled) {
|
||||
return $result(Availability::NOT_ENABLED);
|
||||
}
|
||||
|
||||
if (!$event->published) {
|
||||
return $result(Availability::UNPUBLISHED);
|
||||
}
|
||||
|
||||
if (WkState::isCancelled($event->wkState)) {
|
||||
return $result(Availability::CANCELLED);
|
||||
}
|
||||
|
||||
if ($event->isClosedAudience()) {
|
||||
return $result(Availability::CLOSED_AUDIENCE);
|
||||
}
|
||||
|
||||
$contact = trim($event->organizerEmail);
|
||||
|
||||
if (3 === $event->eventType && '' !== $contact && (Validator::isEmail($contact) || Validator::isUrl($contact))) {
|
||||
return $result(Availability::EXTERNAL, $contact);
|
||||
}
|
||||
|
||||
if ($calendar->bookingForm < 1) {
|
||||
return $result(Availability::NO_FORM);
|
||||
}
|
||||
|
||||
if ($event->startTime <= $now) {
|
||||
return $result(Availability::PAST);
|
||||
}
|
||||
|
||||
if ($deadline <= $now) {
|
||||
return $result(Availability::DEADLINE);
|
||||
}
|
||||
|
||||
if ($placesLeft < 1 || ($event->maxPlaces > 0 && $open >= $event->maxPlaces)) {
|
||||
return $result(Availability::FULL);
|
||||
}
|
||||
|
||||
return $result(Availability::OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Verbindet das Anmeldemodul mit den Formular-Hooks innerhalb einer Anfrage.
|
||||
*/
|
||||
final class BookingContext
|
||||
{
|
||||
public const string ATTRIBUTE = '_wk_booking_context';
|
||||
|
||||
/** @var array<string, mixed>|null die angelegte Anmeldung nach dem Speichern */
|
||||
public array|null $booking = null;
|
||||
|
||||
public function __construct(
|
||||
public readonly BookingEvent $event,
|
||||
public readonly CalendarConfig $calendar,
|
||||
public readonly FieldRules $rules,
|
||||
public readonly Availability $availability,
|
||||
) {
|
||||
}
|
||||
|
||||
public function attachTo(Request $request): void
|
||||
{
|
||||
$request->attributes->set(self::ATTRIBUTE, $this);
|
||||
}
|
||||
|
||||
public static function fromRequest(Request|null $request): self|null
|
||||
{
|
||||
$context = $request?->attributes->get(self::ATTRIBUTE);
|
||||
|
||||
return $context instanceof self ? $context : null;
|
||||
}
|
||||
|
||||
public function isBookingForm(int $formId): bool
|
||||
{
|
||||
return $formId > 0 && $formId === $this->calendar->bookingForm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
|
||||
/**
|
||||
* Baut aus den Formulardaten die Zeile für tl_wk_booking und legt sie an.
|
||||
*/
|
||||
class BookingCreator
|
||||
{
|
||||
public function __construct(private readonly BookingRepository $bookings)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submitted
|
||||
*
|
||||
* @return array<string, mixed> Zeile ohne ID
|
||||
*/
|
||||
public function prepare(BookingEvent $event, array $submitted, FieldRules $rules): array
|
||||
{
|
||||
$now = time();
|
||||
|
||||
$row = [
|
||||
'pid' => $event->id,
|
||||
'tstamp' => $now,
|
||||
'dateCreated' => $now,
|
||||
'token' => bin2hex(random_bytes(20)),
|
||||
'state' => InboxState::New->value,
|
||||
'exportedAt' => 0,
|
||||
'notification' => 0,
|
||||
];
|
||||
|
||||
foreach (BookingFields::COLUMNS as $column) {
|
||||
$visible = $rules->isVisible($column, $submitted);
|
||||
$value = $visible ? ($submitted[$column] ?? null) : null;
|
||||
|
||||
$row[$column] = match (true) {
|
||||
\in_array($column, BookingFields::BOOLEAN_COLUMNS, true) => (int) self::toBool($value),
|
||||
'birthday' === $column => self::normalizeDate(self::toString($value)),
|
||||
default => self::toString($value),
|
||||
};
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submitted
|
||||
*
|
||||
* @return array<string, mixed> die gespeicherte Zeile inklusive ID
|
||||
*/
|
||||
public function create(BookingEvent $event, array $submitted, FieldRules $rules): array
|
||||
{
|
||||
$row = $this->prepare($event, $submitted, $rules);
|
||||
$row['id'] = $this->bookings->insert($row);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* W&K erwartet Geburtstage als TT.MM.JJJJ, unabhängig vom Datumsformat der Website.
|
||||
*/
|
||||
public static function normalizeDate(string $value): string
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value, $m)) {
|
||||
return \sprintf('%02d.%02d.%04d', (int) $m[3], (int) $m[2], (int) $m[1]);
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/', $value, $m)) {
|
||||
return \sprintf('%02d.%02d.%04d', (int) $m[1], (int) $m[2], (int) $m[3]);
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/', $value, $m)) {
|
||||
return \sprintf('%02d.%02d.%04d', (int) $m[2], (int) $m[1], (int) $m[3]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function toBool(mixed $value): bool
|
||||
{
|
||||
if (\is_array($value)) {
|
||||
return [] !== array_filter($value, static fn ($v): bool => '' !== (string) $v);
|
||||
}
|
||||
|
||||
return \in_array(mb_strtolower(trim((string) $value)), ['1', 'on', 'true', 'ja', 'yes'], true);
|
||||
}
|
||||
|
||||
public static function toString(mixed $value): string
|
||||
{
|
||||
if (\is_array($value)) {
|
||||
$value = implode(', ', array_map(static fn ($v): string => trim((string) $v), $value));
|
||||
}
|
||||
|
||||
return mb_substr(trim((string) $value), 0, 65535);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
|
||||
/**
|
||||
* Für die Anmeldung relevante Sicht auf eine Veranstaltung (tl_calendar_events).
|
||||
*/
|
||||
final readonly class BookingEvent
|
||||
{
|
||||
public const string CLOSED_AUDIENCE = 'geschlossener teilnehmerkreis';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row vollständige Zeile aus tl_calendar_events
|
||||
* @param list<int> $categories
|
||||
*/
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public int $calendarId,
|
||||
public string $title,
|
||||
public string $alias,
|
||||
public bool $published,
|
||||
public int $startDate,
|
||||
public int $endDate,
|
||||
public int $startTime,
|
||||
public int $endTime,
|
||||
public string $location,
|
||||
public string $wkId,
|
||||
public string $wkState,
|
||||
public string $internalNumber,
|
||||
public int $eventType,
|
||||
public int $placesLeft,
|
||||
public int $maxPlaces,
|
||||
public string $audience,
|
||||
public string $organizerEmail,
|
||||
public int $confirmationPage,
|
||||
public int $notification,
|
||||
public int $primaryCategory,
|
||||
public array $categories,
|
||||
public CategoryKind $kind,
|
||||
public array $row,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function fromRow(array $row, CategoryKind $kind): self
|
||||
{
|
||||
return new self(
|
||||
(int) $row['id'],
|
||||
(int) $row['pid'],
|
||||
(string) $row['title'],
|
||||
(string) ($row['alias'] ?? ''),
|
||||
(bool) ($row['published'] ?? false),
|
||||
(int) ($row['startDate'] ?? 0),
|
||||
(int) ($row['endDate'] ?? 0),
|
||||
(int) ($row['startTime'] ?? 0),
|
||||
(int) ($row['endTime'] ?? 0),
|
||||
(string) ($row['location'] ?? ''),
|
||||
(string) ($row['wkId'] ?? ''),
|
||||
(string) ($row['wkState'] ?? ''),
|
||||
(string) ($row['wkInternalEventNumber'] ?? ''),
|
||||
(int) ($row['wkEventType'] ?? 0),
|
||||
(int) ($row['wkPlacesLeft'] ?? 0),
|
||||
(int) ($row['wkMaxPlaces'] ?? 0),
|
||||
(string) ($row['wkAudience'] ?? ''),
|
||||
(string) ($row['wkOrganizerEmail'] ?? ''),
|
||||
(int) ($row['wkConfirmationPage'] ?? 0),
|
||||
(int) ($row['wkNotification'] ?? 0),
|
||||
(int) ($row['wkCategoryPrimary'] ?? 0),
|
||||
array_map(intval(...), StringUtil::deserialize($row['wkCategories'] ?? null, true)),
|
||||
$kind,
|
||||
$row,
|
||||
);
|
||||
}
|
||||
|
||||
public function isSingleDay(): bool
|
||||
{
|
||||
return date('Y-m-d', $this->startDate) === date('Y-m-d', $this->endDate ?: $this->startDate);
|
||||
}
|
||||
|
||||
public function isOnline(): bool
|
||||
{
|
||||
return 'online' === mb_strtolower(trim($this->location));
|
||||
}
|
||||
|
||||
public function hasExtpNumber(): bool
|
||||
{
|
||||
return str_starts_with(mb_strtoupper(trim($this->internalNumber)), 'EXTP');
|
||||
}
|
||||
|
||||
public function isClosedAudience(): bool
|
||||
{
|
||||
return self::CLOSED_AUDIENCE === mb_strtolower(trim($this->audience));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
/**
|
||||
* Feldnamen und Optionswerte der Anmeldung. Die Namen sind zugleich die Feldnamen im Formulargenerator.
|
||||
*/
|
||||
final class BookingFields
|
||||
{
|
||||
public const array GENDERS = ['male', 'female', 'divers', 'none'];
|
||||
public const array POSITIONS = ['church_employee', 'teacher', 'pastor', 'volunteer'];
|
||||
public const array TEACHER_POSITIONS = ['state', 'other', 'none'];
|
||||
public const array OVERNIGHT_STAYS = ['none', 'single_room', 'double_room'];
|
||||
|
||||
/**
|
||||
* Spalten, die aus dem Formular übernommen werden.
|
||||
*/
|
||||
public const array COLUMNS = [
|
||||
'gender', 'academicTitle', 'firstname', 'lastname', 'street', 'postal', 'city', 'email', 'phone',
|
||||
'position', 'birthday', 'teacherPosition', 'schoolName', 'schoolCity', 'vegetarian', 'overnightStay',
|
||||
'addDifferentInvoiceAddress', 'invoiceInstitution', 'invoiceGender', 'invoiceAcademicTitle',
|
||||
'invoiceFirstname', 'invoiceLastname', 'invoiceStreet', 'invoicePostal', 'invoiceCity',
|
||||
'addNotes', 'notes', 'privacyAccepted', 'agbAccepted', 'newsletter',
|
||||
];
|
||||
|
||||
public const array BOOLEAN_COLUMNS = [
|
||||
'vegetarian', 'addDifferentInvoiceAddress', 'addNotes', 'privacyAccepted', 'agbAccepted', 'newsletter',
|
||||
];
|
||||
|
||||
/**
|
||||
* Felder, die nur sichtbar sind, wenn ein anderes Feld einen bestimmten Wert hat (wie Subpaletten).
|
||||
*
|
||||
* @var array<string, array{field: string, values: list<string>}>
|
||||
*/
|
||||
public const array DEPENDENCIES = [
|
||||
'schoolName' => ['field' => 'teacherPosition', 'values' => ['state', 'other']],
|
||||
'schoolCity' => ['field' => 'teacherPosition', 'values' => ['state', 'other']],
|
||||
'invoiceInstitution' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceGender' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceAcademicTitle' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceFirstname' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceLastname' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceStreet' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoicePostal' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'invoiceCity' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']],
|
||||
'notes' => ['field' => 'addNotes', 'values' => ['1']],
|
||||
];
|
||||
|
||||
/**
|
||||
* CSS-Klasse, mit der ein Formularfeld (z. B. ein Erklärungstext) an einen Regel-Platzhalter gebunden wird.
|
||||
*/
|
||||
public const string SLOT_CLASS_PREFIX = 'wk-slot-';
|
||||
|
||||
public const string SLOT_NO_OVERNIGHT_STAY = 'no-overnight-stay';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
interface BookingNotifierInterface
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $tokens
|
||||
*
|
||||
* @return bool ob die Benachrichtigung verschickt wurde
|
||||
*/
|
||||
public function notify(int $notificationId, array $tokens): bool;
|
||||
|
||||
/**
|
||||
* @return array<int, string> ID => Titel der verfügbaren Benachrichtigungen
|
||||
*/
|
||||
public function availableNotifications(): array;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
|
||||
class BookingRepository
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldungen, die noch nicht nach W&K übertragen wurden und die freien Plätze belegen.
|
||||
*/
|
||||
public function countOpenByEvent(int $eventId): int
|
||||
{
|
||||
return (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM tl_wk_booking WHERE pid = ? AND state IN (?)',
|
||||
[$eventId, [InboxState::New->value, InboxState::DownloadList->value]],
|
||||
[null, ArrayParameterType::STRING],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function insert(array $data): int
|
||||
{
|
||||
$this->connection->insert('tl_wk_booking', $data);
|
||||
|
||||
return (int) $this->connection->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(int $id): array|null
|
||||
{
|
||||
$row = $this->connection->fetchAssociative('SELECT * FROM tl_wk_booking WHERE id = ?', [$id]);
|
||||
|
||||
return false === $row ? null : $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByToken(string $token): array|null
|
||||
{
|
||||
if ('' === $token || !preg_match('/^[a-f0-9]{32,64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->connection->fetchAssociative('SELECT * FROM tl_wk_booking WHERE token = ?', [$token]);
|
||||
|
||||
return false === $row ? null : $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es zu dieser E-Mail-Adresse bereits eine offene Anmeldung für die Veranstaltung?
|
||||
*/
|
||||
public function hasOpenBookingForEmail(int $eventId, string $email, int $excludeId = 0): bool
|
||||
{
|
||||
return (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM tl_wk_booking WHERE pid = ? AND email = ? AND id != ? AND state != ?',
|
||||
[$eventId, $email, $excludeId, InboxState::Spam->value],
|
||||
) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$this->connection->update('tl_wk_booking', ['tstamp' => time(), ...$data], ['id' => $id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Fachliche Regeln, welche Felder das Anmeldeformular für eine Veranstaltung zeigt.
|
||||
*
|
||||
* - Angaben zur Lehrertätigkeit nur bei Schulkursen.
|
||||
* - Abweichende Rechnungsanschrift bei Schulkursen nur, wenn die Lehrkraft nicht an einer staatlichen Schule unterrichtet.
|
||||
* - Übernachtung nur bei mehrtägigen Veranstaltungen; bei EXTP-Kursen und Elementarkursen im eigenen Haus
|
||||
* stattdessen der Hinweis, dass Übernachtung und Verpflegung direkt im Gästehaus gebucht werden.
|
||||
* - Vegetarisches Essen nicht bei Online-Kursen sowie nicht bei mehrtägigen EXTP-Kursen und Elementarkursen,
|
||||
* es sei denn, es ist ein Lehrerkurs außer Haus.
|
||||
*/
|
||||
class BookingRules
|
||||
{
|
||||
public function rulesFor(BookingEvent $event, CalendarConfig $calendar): FieldRules
|
||||
{
|
||||
$kind = $event->kind;
|
||||
$single = $event->isSingleDay();
|
||||
$ownVenue = $calendar->isOwnVenue($event->location);
|
||||
$teacherCourseAway = $kind->isTeacherCourse() && !$ownVenue;
|
||||
$conference = CategoryKind::Conference === $kind;
|
||||
$elementary = CategoryKind::Elementary === $kind;
|
||||
|
||||
$hidden = [];
|
||||
$slots = [];
|
||||
$dependencies = BookingFields::DEPENDENCIES;
|
||||
|
||||
if ($kind->asksTeacherPosition()) {
|
||||
$dependencies['addDifferentInvoiceAddress'] = ['field' => 'teacherPosition', 'values' => ['other']];
|
||||
} else {
|
||||
$hidden[] = 'teacherPosition';
|
||||
$hidden[] = 'schoolName';
|
||||
$hidden[] = 'schoolCity';
|
||||
}
|
||||
|
||||
if ($single) {
|
||||
$hidden[] = 'overnightStay';
|
||||
} elseif (!$teacherCourseAway && ($event->hasExtpNumber() || $elementary) && !$conference) {
|
||||
$hidden[] = 'overnightStay';
|
||||
$slots[] = BookingFields::SLOT_NO_OVERNIGHT_STAY;
|
||||
}
|
||||
|
||||
$noCatering = ((!$single && $event->hasExtpNumber()) || $elementary) && !$conference;
|
||||
|
||||
if (($noCatering || $event->isOnline()) && !$teacherCourseAway) {
|
||||
$hidden[] = 'vegetarian';
|
||||
}
|
||||
|
||||
return new FieldRules($hidden, $slots, $dependencies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\CalendarEventsModel;
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\Date;
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Import\MemberResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Simple-Tokens für die Benachrichtigung. Die Namen entsprechen den bisherigen Benachrichtigungen
|
||||
* (subscription_*, event_*, event_datetime, subscription_event_wk_id, recipient_email).
|
||||
*/
|
||||
class BookingTokens
|
||||
{
|
||||
private const array SELECT_FIELDS = [
|
||||
'gender' => 'genderRef',
|
||||
'invoiceGender' => 'genderRef',
|
||||
'position' => 'positionRef',
|
||||
'teacherPosition' => 'teacherPositionRef',
|
||||
'overnightStay' => 'overnightStayRef',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly Connection $connection,
|
||||
private readonly ContentUrlGenerator $urlGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly CategoryRepository $categories,
|
||||
private readonly MemberResolver $members,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $booking
|
||||
* @param array{notification: int, conditions: list<int>, source: string} $routing
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(array $booking, BookingEvent $event, CalendarConfig $calendar, string $confirmationUrl, array $routing = []): array
|
||||
{
|
||||
$this->framework->initialize();
|
||||
$config = $this->framework->getAdapter(Config::class);
|
||||
$date = $this->framework->getAdapter(Date::class);
|
||||
|
||||
$dateFormat = (string) $config->get('dateFormat');
|
||||
$timeFormat = (string) $config->get('timeFormat');
|
||||
$datimFormat = (string) $config->get('datimFormat');
|
||||
|
||||
$tokens = [
|
||||
'admin_email' => (string) ($GLOBALS['TL_ADMIN_EMAIL'] ?? $config->get('adminEmail')),
|
||||
];
|
||||
|
||||
// Veranstaltung
|
||||
foreach ($event->row as $key => $value) {
|
||||
$tokens['event_'.$key] = $this->scalar($value);
|
||||
}
|
||||
|
||||
$tokens['event_startDate'] = $date->parse($dateFormat, $event->startDate);
|
||||
$tokens['event_endDate'] = $date->parse($dateFormat, $event->endDate);
|
||||
$tokens['event_startTime'] = $date->parse($timeFormat, $event->startTime);
|
||||
$tokens['event_endTime'] = $date->parse($timeFormat, $event->endTime);
|
||||
$tokens['event_datetime'] = $this->formatDateTime($event, $dateFormat, $timeFormat);
|
||||
$tokens['event_link'] = $this->eventUrl($event);
|
||||
$tokens['event_categories'] = implode(', ', array_map(static fn ($c): string => $c->title, $this->categories->findByIds($event->categories)));
|
||||
$tokens['event_category_primary'] = $this->categories->find($event->primaryCategory)?->title ?? '';
|
||||
$tokens['event_instructor'] = $this->members->namesByIds([(int) ($event->row['wkInstructor'] ?? 0)])[(int) ($event->row['wkInstructor'] ?? 0)] ?? '';
|
||||
$tokens['event_speakers'] = implode(', ', $this->members->namesByIds(array_map(intval(...), StringUtil::deserialize($event->row['wkSpeakers'] ?? null, true))));
|
||||
|
||||
// Bisherige Token-Namen
|
||||
$tokens['event_organizerCity'] = (string) ($event->row['wkOrganizerCity'] ?? '');
|
||||
$tokens['event_internalEventNumber'] = $event->internalNumber;
|
||||
$tokens['subscription_event_wk_id'] = $event->wkId;
|
||||
|
||||
// Kalender
|
||||
$calendarRow = $this->connection->fetchAssociative('SELECT * FROM tl_calendar WHERE id = ?', [$calendar->id]) ?: [];
|
||||
|
||||
foreach ($calendarRow as $key => $value) {
|
||||
$tokens['calendar_'.$key] = $this->scalar($value);
|
||||
}
|
||||
|
||||
// Anmeldung
|
||||
foreach ($booking as $key => $value) {
|
||||
if ('token' === $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokens['subscription_'.$key] = \is_bool($value) ? ($value ? '1' : '') : $this->scalar($value);
|
||||
}
|
||||
|
||||
foreach (self::SELECT_FIELDS as $field => $reference) {
|
||||
$value = (string) ($booking[$field] ?? '');
|
||||
$tokens['subscription_'.$field.'_label'] = '' === $value ? '' : $this->translator->trans('tl_wk_booking.'.$reference.'.'.$value, [], 'contao_tl_wk_booking');
|
||||
}
|
||||
|
||||
foreach (BookingFields::BOOLEAN_COLUMNS as $field) {
|
||||
$tokens['subscription_'.$field.'_label'] = $this->translator->trans(!empty($booking[$field]) ? 'MSC.yes' : 'MSC.no', [], 'contao_default');
|
||||
}
|
||||
|
||||
$tokens['subscription_dateCreated'] = $date->parse($datimFormat, (int) ($booking['dateCreated'] ?? time()));
|
||||
$tokens['subscription_confirmation_page_url'] = $confirmationUrl;
|
||||
$tokens['recipient_email'] = (string) ($booking['email'] ?? '');
|
||||
$tokens['wk_notification_conditions'] = implode(', ', $routing['conditions'] ?? []);
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
private function formatDateTime(BookingEvent $event, string $dateFormat, string $timeFormat): string
|
||||
{
|
||||
$date = $this->framework->getAdapter(Date::class);
|
||||
$text = $date->parse($dateFormat, $event->startDate);
|
||||
|
||||
if (!$event->isSingleDay()) {
|
||||
$text .= ' - '.$date->parse($dateFormat, $event->endDate);
|
||||
}
|
||||
|
||||
if (!empty($event->row['addTime'])) {
|
||||
$text .= ', '.$date->parse($timeFormat, $event->startTime).' - '.$date->parse($timeFormat, $event->endTime);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function eventUrl(BookingEvent $event): string
|
||||
{
|
||||
$model = $this->framework->getAdapter(CalendarEventsModel::class)->findById($event->id);
|
||||
|
||||
if (null === $model) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->urlGenerator->generate($model, [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function scalar(mixed $value): string
|
||||
{
|
||||
if (null === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (\is_string($value) && str_starts_with($value, 'a:')) {
|
||||
$unserialized = StringUtil::deserialize($value);
|
||||
|
||||
if (\is_array($unserialized)) {
|
||||
return implode(', ', array_map(static fn ($v): string => (string) (\is_array($v) ? implode(' ', $v) : $v), $unserialized));
|
||||
}
|
||||
}
|
||||
|
||||
if (\is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\PageModel;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Links zur Anmeldeseite und zum Anfrageformular.
|
||||
*/
|
||||
class BookingUrlBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly ContentUrlGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function bookingUrl(BookingEvent $event, CalendarConfig $calendar): string
|
||||
{
|
||||
$url = $this->pageUrl($calendar->bookingPage);
|
||||
|
||||
if ('' === $url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim($url, '/').'/'.($event->alias ?: $event->id);
|
||||
}
|
||||
|
||||
public function inquiryUrl(BookingEvent $event, CalendarConfig $calendar): string
|
||||
{
|
||||
$url = $this->pageUrl($calendar->inquiryPage);
|
||||
|
||||
if ('' === $url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').'event='.$event->id;
|
||||
}
|
||||
|
||||
public function pageUrl(int $pageId): string
|
||||
{
|
||||
if ($pageId < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->framework->initialize();
|
||||
$page = $this->framework->getAdapter(PageModel::class)->findPublishedById($pageId);
|
||||
|
||||
if (null === $page) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->urlGenerator->generate($page);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\Input;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Ermittelt die Veranstaltung aus der URL (auto_item) oder per ID.
|
||||
*/
|
||||
class EventResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly CategoryRepository $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $calendarIds leere Liste = alle Kalender
|
||||
*/
|
||||
public function resolveFromRequest(Request $request, array $calendarIds = []): BookingEvent|null
|
||||
{
|
||||
$this->framework->initialize();
|
||||
$input = $this->framework->getAdapter(Input::class);
|
||||
|
||||
$identifier = (string) ($input->get('auto_item') ?? $input->get('events') ?? $request->attributes->get('auto_item') ?? '');
|
||||
|
||||
if ('' === $identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findByIdOrAlias($identifier, $calendarIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $calendarIds
|
||||
*/
|
||||
public function findByIdOrAlias(string $identifier, array $calendarIds = []): BookingEvent|null
|
||||
{
|
||||
$sql = 'SELECT * FROM tl_calendar_events WHERE (alias = ? OR id = ?)';
|
||||
$params = [$identifier, (int) $identifier];
|
||||
$types = [null, null];
|
||||
|
||||
if ([] !== $calendarIds) {
|
||||
$sql .= ' AND pid IN (?)';
|
||||
$params[] = array_map(intval(...), $calendarIds);
|
||||
$types[] = ArrayParameterType::INTEGER;
|
||||
}
|
||||
|
||||
$row = $this->connection->fetchAssociative($sql.' LIMIT 1', $params, $types);
|
||||
|
||||
return false === $row ? null : $this->fromRow($row);
|
||||
}
|
||||
|
||||
public function find(int $id): BookingEvent|null
|
||||
{
|
||||
$row = $this->connection->fetchAssociative('SELECT * FROM tl_calendar_events WHERE id = ?', [$id]);
|
||||
|
||||
return false === $row ? null : $this->fromRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public function fromRow(array $row): BookingEvent
|
||||
{
|
||||
return BookingEvent::fromRow($row, $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
|
||||
}
|
||||
|
||||
public function kindFor(int $categoryId): CategoryKind
|
||||
{
|
||||
return $this->categories->find($categoryId)?->kind ?? CategoryKind::Other;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
/**
|
||||
* Ergebnis der Formularregeln für eine Veranstaltung.
|
||||
*/
|
||||
final readonly class FieldRules
|
||||
{
|
||||
/**
|
||||
* @param list<string> $hiddenFields
|
||||
* @param list<string> $activeSlots
|
||||
* @param array<string, array{field: string, values: list<string>}> $dependencies
|
||||
*/
|
||||
public function __construct(
|
||||
public array $hiddenFields,
|
||||
public array $activeSlots,
|
||||
public array $dependencies,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isHidden(string $field): bool
|
||||
{
|
||||
return \in_array($field, $this->hiddenFields, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist ein Feld bei den übermittelten Werten sichtbar? Berücksichtigt Regeln und Abhängigkeiten,
|
||||
* auch über mehrere Stufen (z. B. Rechnungsfelder -> Rechnungsanschrift-Checkbox -> Lehrertätigkeit).
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function isVisible(string $field, array $values, int $depth = 0): bool
|
||||
{
|
||||
if ($this->isHidden($field) || $depth > 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($this->dependencies[$field])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$dependency = $this->dependencies[$field];
|
||||
|
||||
if (!$this->isVisible($dependency['field'], $values, $depth + 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $values[$dependency['field']] ?? '';
|
||||
|
||||
if (\is_array($value)) {
|
||||
$value = (string) reset($value);
|
||||
}
|
||||
|
||||
return \in_array((string) $value, $dependency['values'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function isDependencyMet(string $field, array $values): bool
|
||||
{
|
||||
return !isset($this->dependencies[$field]) || $this->isVisible($field, $values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\FormFieldModel;
|
||||
|
||||
/**
|
||||
* Entfernt Formularfelder, die für die Veranstaltung oder die gewählten Werte nicht gelten.
|
||||
*
|
||||
* Felder ohne Namen (z. B. Erklärungstexte) werden über CSS-Klassen wie „wk-slot-no-overnight-stay“
|
||||
* an einen Regel-Platzhalter gebunden und nur gezeigt, wenn dieser aktiv ist.
|
||||
*/
|
||||
class FormFieldFilter
|
||||
{
|
||||
/**
|
||||
* @param array<int|string, FormFieldModel> $fields
|
||||
* @param array<string, mixed>|null $submitted null = Formular noch nicht abgeschickt
|
||||
*
|
||||
* @return array<int|string, FormFieldModel>
|
||||
*/
|
||||
public function filter(array $fields, FieldRules $rules, array|null $submitted): array
|
||||
{
|
||||
foreach ($fields as $key => $field) {
|
||||
$slots = $this->slotsOf((string) $field->class);
|
||||
|
||||
if ([] !== $slots && [] === array_intersect($slots, $rules->activeSlots)) {
|
||||
unset($fields[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) $field->name;
|
||||
|
||||
if ('' === $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rules->isHidden($name)) {
|
||||
unset($fields[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $submitted && !$rules->isVisible($name, $submitted)) {
|
||||
unset($fields[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function slotsOf(string $classes): array
|
||||
{
|
||||
$slots = [];
|
||||
|
||||
foreach (preg_split('/\s+/', trim($classes)) ?: [] as $class) {
|
||||
if (str_starts_with($class, BookingFields::SLOT_CLASS_PREFIX)) {
|
||||
$slots[] = substr($class, \strlen(BookingFields::SLOT_CLASS_PREFIX));
|
||||
}
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\OptIn\OptIn;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\CoreBundle\String\SimpleTokenParser;
|
||||
use Contao\Idna;
|
||||
use Contao\NewsletterChannelModel;
|
||||
use Contao\NewsletterDenyListModel;
|
||||
use Contao\NewsletterRecipientsModel;
|
||||
use Contao\PageModel;
|
||||
use Contao\System;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Newsletter-Anmeldung mit Double-Opt-in, wie das Abonnieren-Modul des Newsletter-Bundles.
|
||||
*/
|
||||
class NewsletterSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly OptIn $optIn,
|
||||
private readonly ContentUrlGenerator $urlGenerator,
|
||||
private readonly SimpleTokenParser $tokenParser,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly LoggerInterface|null $contaoErrorLogger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return class_exists(NewsletterRecipientsModel::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool ob eine Bestätigungsmail verschickt wurde
|
||||
*/
|
||||
public function subscribe(string $email, CalendarConfig $calendar): bool
|
||||
{
|
||||
if (!$this->isAvailable() || [] === $calendar->newsletterChannels || '' === trim($email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSubscribe(mb_strtolower(trim($email)), $calendar);
|
||||
} catch (\Throwable $e) {
|
||||
$this->contaoErrorLogger?->error('W&K-Anmeldung: Newsletter-Anmeldung fehlgeschlagen: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function doSubscribe(string $email, CalendarConfig $calendar): bool
|
||||
{
|
||||
$this->framework->initialize();
|
||||
|
||||
$recipients = $this->framework->getAdapter(NewsletterRecipientsModel::class);
|
||||
$channels = $this->framework->getAdapter(NewsletterChannelModel::class);
|
||||
$denyList = $this->framework->getAdapter(NewsletterDenyListModel::class);
|
||||
$pages = $this->framework->getAdapter(PageModel::class);
|
||||
|
||||
// Nicht aktivierte Altanmeldungen entfernen
|
||||
if (null !== ($old = $recipients->findOldSubscriptionsByEmailAndPids($email, $calendar->newsletterChannels))) {
|
||||
foreach ($old as $recipient) {
|
||||
$recipient->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$related = [];
|
||||
|
||||
foreach ($calendar->newsletterChannels as $channelId) {
|
||||
// Bereits (aktiv) eingetragen
|
||||
if (null !== $recipients->findOneBy(['pid=?', 'email=?'], [$channelId, $email])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recipient = new NewsletterRecipientsModel();
|
||||
$recipient->pid = $channelId;
|
||||
$recipient->tstamp = $now;
|
||||
$recipient->email = $email;
|
||||
$recipient->active = false;
|
||||
$recipient->addedOn = $now;
|
||||
$recipient->save();
|
||||
|
||||
if (null !== ($denied = $denyList->findByHashAndPid(md5($email), $channelId))) {
|
||||
$denied->delete();
|
||||
}
|
||||
|
||||
$related['tl_newsletter_recipients'][] = $recipient->id;
|
||||
}
|
||||
|
||||
if ([] === $related) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $this->optIn->create('nl', $email, $related);
|
||||
$channelTitles = $channels->findByIds($calendar->newsletterChannels)?->fetchEach('title') ?? [];
|
||||
|
||||
$link = '';
|
||||
|
||||
if ($calendar->newsletterPage > 0 && null !== ($page = $pages->findPublishedById($calendar->newsletterPage))) {
|
||||
$link = $this->urlGenerator->generate($page, [], UrlGeneratorInterface::ABSOLUTE_URL).'?token='.$token->getIdentifier();
|
||||
} elseif (null !== ($request = $this->requestStack->getCurrentRequest())) {
|
||||
$link = $request->getSchemeAndHttpHost().$request->getBaseUrl().'/?token='.$token->getIdentifier();
|
||||
}
|
||||
|
||||
$host = null !== ($request = $this->requestStack->getCurrentRequest()) ? Idna::decode($request->getHost()) : '';
|
||||
|
||||
$data = [
|
||||
'token' => $token->getIdentifier(),
|
||||
'domain' => $host,
|
||||
'link' => $link,
|
||||
'channel' => implode("\n", $channelTitles),
|
||||
'channels' => implode("\n", $channelTitles),
|
||||
];
|
||||
|
||||
$this->framework->getAdapter(System::class)->loadLanguageFile('default');
|
||||
|
||||
$text = '' !== trim($calendar->newsletterText) ? $calendar->newsletterText : $this->translator->trans('MSC.wk_newsletter_optin_text', [], 'contao_default');
|
||||
$subject = \sprintf($GLOBALS['TL_LANG']['MSC']['nl_subject'] ?? 'Ihr Newsletter-Abonnement auf %s', $host);
|
||||
|
||||
$token->send($subject, $this->tokenParser->parse($text, $data));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\EventListener\NotificationCenter\BookingNotificationType;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Terminal42\NotificationCenterBundle\NotificationCenter;
|
||||
|
||||
class NotificationCenterNotifier implements BookingNotifierInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationCenter $notificationCenter,
|
||||
private readonly LoggerInterface|null $contaoErrorLogger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function notify(int $notificationId, array $tokens): bool
|
||||
{
|
||||
try {
|
||||
$receipts = $this->notificationCenter->sendNotification($notificationId, $tokens);
|
||||
} catch (\Throwable $e) {
|
||||
$this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung: Benachrichtigung %d konnte nicht verschickt werden: %s', $notificationId, $e->getMessage()));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$delivered = false;
|
||||
|
||||
foreach ($receipts as $receipt) {
|
||||
if ($receipt->wasDelivered()) {
|
||||
$delivered = true;
|
||||
} elseif (null !== ($exception = $receipt->getException())) {
|
||||
$this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung: Benachrichtigung %d: %s', $notificationId, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return $delivered;
|
||||
}
|
||||
|
||||
public function availableNotifications(): array
|
||||
{
|
||||
return $this->notificationCenter->getNotificationsForNotificationType(BookingNotificationType::NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Wählt die Benachrichtigung für eine Anmeldung: Vorgabe an der Veranstaltung, sonst die erste erfüllte
|
||||
* Bedingung der Kalender-Matrix, sonst die Standard-Benachrichtigung des Kalenders.
|
||||
*
|
||||
* 1 Online-Kurs (alle Kategorien)
|
||||
* 2 Tagesveranstaltung Schulfortbildung mit EXTP-Nummer 5 mehrtägig
|
||||
* 3 Tagesveranstaltung Gemeinde/Tagung/Gottesdienst mit Kindern 7 mehrtägig
|
||||
* 4 Tagesveranstaltung Elementarbereich 6 mehrtägig
|
||||
* 8 Tagesveranstaltung Evangelische Schulen 9 mehrtägig
|
||||
* 10 Tagesveranstaltung Mentoring 11 mehrtägig
|
||||
* 12 Mehrtägige Ausbildung
|
||||
*/
|
||||
class NotificationRouter
|
||||
{
|
||||
public const array CONDITIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
|
||||
/**
|
||||
* @return list<int> erfüllte Bedingungen in Prüfreihenfolge
|
||||
*/
|
||||
public function matchingConditions(BookingEvent $event): array
|
||||
{
|
||||
$conditions = [];
|
||||
|
||||
if ($event->isOnline()) {
|
||||
$conditions[] = 1;
|
||||
}
|
||||
|
||||
$kind = $event->kind;
|
||||
$extp = $event->hasExtpNumber();
|
||||
|
||||
if ($event->isSingleDay()) {
|
||||
if (CategoryKind::School === $kind && $extp) {
|
||||
$conditions[] = 2;
|
||||
}
|
||||
|
||||
if ($kind->isCommunityGroup()) {
|
||||
$conditions[] = 3;
|
||||
}
|
||||
|
||||
if (CategoryKind::Elementary === $kind) {
|
||||
$conditions[] = 4;
|
||||
}
|
||||
|
||||
if (CategoryKind::EvangelicalSchool === $kind) {
|
||||
$conditions[] = 8;
|
||||
}
|
||||
|
||||
if (CategoryKind::Mentoring === $kind) {
|
||||
$conditions[] = 10;
|
||||
}
|
||||
} else {
|
||||
if (CategoryKind::School === $kind && $extp) {
|
||||
$conditions[] = 5;
|
||||
}
|
||||
|
||||
if (CategoryKind::Elementary === $kind) {
|
||||
$conditions[] = 6;
|
||||
}
|
||||
|
||||
if ($kind->isCommunityGroup()) {
|
||||
$conditions[] = 7;
|
||||
}
|
||||
|
||||
if (CategoryKind::EvangelicalSchool === $kind) {
|
||||
$conditions[] = 9;
|
||||
}
|
||||
|
||||
if (CategoryKind::Mentoring === $kind) {
|
||||
$conditions[] = 11;
|
||||
}
|
||||
|
||||
if (CategoryKind::Training === $kind) {
|
||||
$conditions[] = 12;
|
||||
}
|
||||
}
|
||||
|
||||
return $conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{notification: int, conditions: list<int>, source: string}
|
||||
*/
|
||||
public function resolve(BookingEvent $event, CalendarConfig $calendar): array
|
||||
{
|
||||
if ($event->notification > 0) {
|
||||
return ['notification' => $event->notification, 'conditions' => [], 'source' => 'event'];
|
||||
}
|
||||
|
||||
$conditions = $this->matchingConditions($event);
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$id = $calendar->conditionNotifications[$condition] ?? 0;
|
||||
|
||||
if ($id > 0) {
|
||||
return ['notification' => $id, 'conditions' => $conditions, 'source' => 'condition'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['notification' => $calendar->defaultNotification, 'conditions' => $conditions, 'source' => $calendar->defaultNotification > 0 ? 'default' : 'none'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Ohne Notification Center werden keine Mails verschickt; der Vorgang wird nur protokolliert.
|
||||
*/
|
||||
class NullNotifier implements BookingNotifierInterface
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface|null $contaoErrorLogger = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function notify(int $notificationId, array $tokens): bool
|
||||
{
|
||||
$this->contaoErrorLogger?->warning(\sprintf('W&K-Anmeldung: Benachrichtigung %d kann nicht verschickt werden, das Notification Center ist nicht installiert.', $notificationId));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function availableNotifications(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Category;
|
||||
|
||||
final readonly class Category
|
||||
{
|
||||
/**
|
||||
* @param list<string> $wkNames W&K-Bezeichnungen (kbsgebiet), die auf diese Kategorie abgebildet werden
|
||||
*/
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $title,
|
||||
public string $alias,
|
||||
public CategoryKind $kind,
|
||||
public array $wkNames,
|
||||
public int $sorting,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$names = preg_split('/\r?\n/', (string) ($row['wkNames'] ?? '')) ?: [];
|
||||
$names = array_values(array_filter(array_map(trim(...), $names), static fn (string $n): bool => '' !== $n));
|
||||
|
||||
return new self(
|
||||
(int) $row['id'],
|
||||
(string) $row['title'],
|
||||
(string) ($row['alias'] ?? ''),
|
||||
CategoryKind::tryFromValue($row['kind'] ?? null),
|
||||
$names,
|
||||
(int) ($row['sorting'] ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Category;
|
||||
|
||||
/**
|
||||
* Fachliche Art einer Kategorie. Steuert die Formularregeln und die Benachrichtigungs-Matrix.
|
||||
*/
|
||||
enum CategoryKind: string
|
||||
{
|
||||
case School = 'school';
|
||||
case EvangelicalSchool = 'evangelical_school';
|
||||
case Elementary = 'elementary';
|
||||
case Community = 'community';
|
||||
case ChildrenService = 'children_service';
|
||||
case Conference = 'conference';
|
||||
case Training = 'training';
|
||||
case Mentoring = 'mentoring';
|
||||
case Other = 'other';
|
||||
|
||||
/**
|
||||
* Kurse für Lehrkräfte: Angaben zur Lehrertätigkeit werden abgefragt.
|
||||
*/
|
||||
public function asksTeacherPosition(): bool
|
||||
{
|
||||
return \in_array($this, [self::School, self::EvangelicalSchool], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lehrerkurse im Sinne der Übernachtungs-/Verpflegungsregel (auch Ausbildung).
|
||||
*/
|
||||
public function isTeacherCourse(): bool
|
||||
{
|
||||
return \in_array($this, [self::School, self::EvangelicalSchool, self::Training], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeindepädagogik, Gottesdienst mit Kindern und Tagungen teilen sich eine Benachrichtigung.
|
||||
*/
|
||||
public function isCommunityGroup(): bool
|
||||
{
|
||||
return \in_array($this, [self::Community, self::ChildrenService, self::Conference], true);
|
||||
}
|
||||
|
||||
public static function tryFromValue(string|null $value): self
|
||||
{
|
||||
return self::tryFrom((string) $value) ?? self::Other;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Category;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
class CategoryRepository implements ResetInterface
|
||||
{
|
||||
/** @var list<Category>|null */
|
||||
private array|null $cache = null;
|
||||
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Category> nach Sortierung
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
if (null === $this->cache) {
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_wk_category ORDER BY sorting, title');
|
||||
$this->cache = array_map(Category::fromRow(...), $rows);
|
||||
}
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
public function find(int $id): Category|null
|
||||
{
|
||||
return array_find($this->findAll(), static fn (Category $c): bool => $c->id === $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return list<Category> in der Reihenfolge der Sortierung
|
||||
*/
|
||||
public function findByIds(array $ids): array
|
||||
{
|
||||
$ids = array_map(intval(...), $ids);
|
||||
|
||||
return array_values(array_filter($this->findAll(), static fn (Category $c): bool => \in_array($c->id, $ids, true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> ID => Titel
|
||||
*/
|
||||
public function titles(): array
|
||||
{
|
||||
$titles = [];
|
||||
|
||||
foreach ($this->findAll() as $category) {
|
||||
$titles[$category->id] = $category->title;
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return [] === $this->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function insert(array $data): int
|
||||
{
|
||||
$this->connection->insert('tl_wk_category', ['tstamp' => time(), ...$data]);
|
||||
$this->cache = null;
|
||||
|
||||
return (int) $this->connection->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return list<int> IDs, die tatsächlich existieren
|
||||
*/
|
||||
public function existingIds(array $ids): array
|
||||
{
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(intval(...), $this->connection->fetchFirstColumn('SELECT id FROM tl_wk_category WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]));
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->cache = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Category;
|
||||
|
||||
/**
|
||||
* Ordnet die W&K-Bezeichnung (kbsgebiet) den Kategorien zu.
|
||||
*
|
||||
* Zuerst zählen exakte Treffer (alle Kategorien, die den Namen führen, z. B. „Alle Schularten“ bei
|
||||
* Grundschule und weiterführender Schule), sonst der erste Teilstring-Treffer in Sortierreihenfolge.
|
||||
*/
|
||||
class CategoryResolver
|
||||
{
|
||||
public function __construct(private readonly CategoryRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Category>|null $categories
|
||||
*
|
||||
* @return list<Category>
|
||||
*/
|
||||
public function resolve(string $wkText, array|null $categories = null): array
|
||||
{
|
||||
$needle = $this->normalize($wkText);
|
||||
|
||||
if ('' === $needle) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$categories ??= $this->repository->findAll();
|
||||
|
||||
$exact = array_values(array_filter(
|
||||
$categories,
|
||||
fn (Category $c): bool => array_any($c->wkNames, fn (string $n): bool => $this->normalize($n) === $needle),
|
||||
));
|
||||
|
||||
if ([] !== $exact) {
|
||||
return $exact;
|
||||
}
|
||||
|
||||
foreach ($categories as $category) {
|
||||
foreach ($category->wkNames as $name) {
|
||||
$name = $this->normalize($name);
|
||||
|
||||
if ('' !== $name && str_contains($needle, $name)) {
|
||||
return [$category];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalize(string $text): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/u', ' ', mb_strtolower($text)) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingFields;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Legt das Anmeldeformular im Formulargenerator an. Die Feldnamen entsprechen den Spalten von tl_wk_booking,
|
||||
* abhängige Felder werden über die Regeln des Bundles ein- und ausgeblendet.
|
||||
*/
|
||||
#[AsCommand(name: 'wk-sync:form:create', description: 'Legt das W&K-Anmeldeformular mit allen Feldern im Formulargenerator an.')]
|
||||
class CreateBookingFormCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('title', null, InputOption::VALUE_REQUIRED, 'Titel des Formulars', 'W&K-Anmeldung')
|
||||
->addOption('calendar', null, InputOption::VALUE_REQUIRED, 'Formular diesem Kalender (ID) als Anmeldeformular zuweisen')
|
||||
->addOption('privacy-url', null, InputOption::VALUE_REQUIRED, 'Link zur Datenschutzseite', '/datenschutz')
|
||||
->addOption('terms-url', null, InputOption::VALUE_REQUIRED, 'Link zu den AGB', '/agb')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$now = time();
|
||||
$title = (string) $input->getOption('title');
|
||||
|
||||
$formId = $this->insert('tl_form', [
|
||||
'tstamp' => $now,
|
||||
'title' => $title,
|
||||
'alias' => $this->uniqueAlias('wk-anmeldung'),
|
||||
'method' => 'POST',
|
||||
'sendViaEmail' => 0,
|
||||
'storeValues' => 0,
|
||||
'targetTable' => '',
|
||||
'novalidate' => 0,
|
||||
]);
|
||||
|
||||
$sorting = 0;
|
||||
|
||||
foreach ($this->fields((string) $input->getOption('privacy-url'), (string) $input->getOption('terms-url')) as $field) {
|
||||
$sorting += 128;
|
||||
|
||||
if (isset($field['options'])) {
|
||||
$field['options'] = serialize(array_map(static fn (array $o): array => ['value' => $o[0], 'label' => $o[1], 'default' => $o[2] ?? '', 'group' => ''], $field['options']));
|
||||
}
|
||||
|
||||
$this->insert('tl_form_field', ['pid' => $formId, 'sorting' => $sorting, 'tstamp' => $now, ...$field]);
|
||||
}
|
||||
|
||||
$io->success(\sprintf('Formular „%s“ (ID %d) mit %d Feldern angelegt.', $title, $formId, $sorting / 128));
|
||||
|
||||
$calendarId = (int) $input->getOption('calendar');
|
||||
|
||||
if ($calendarId > 0) {
|
||||
$this->connection->update('tl_calendar', ['wkBookingForm' => $formId, 'tstamp' => $now], ['id' => $calendarId]);
|
||||
$io->writeln(\sprintf('Kalender %d verwendet das Formular jetzt als Anmeldeformular.', $calendarId));
|
||||
} else {
|
||||
$io->note('Das Formular jetzt im Kalender unter „W&K-Sync → Anmeldeformular“ auswählen.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fields(string $privacyUrl, string $termsUrl): array
|
||||
{
|
||||
$genders = [['', 'bitte wählen'], ['male', 'Herr'], ['female', 'Frau'], ['divers', 'divers'], ['none', 'keine Angabe']];
|
||||
|
||||
return [
|
||||
['type' => 'select', 'name' => 'gender', 'label' => 'Anrede', 'mandatory' => 1, 'options' => $genders],
|
||||
['type' => 'text', 'name' => 'academicTitle', 'label' => 'Titel', 'maxlength' => 64],
|
||||
['type' => 'text', 'name' => 'firstname', 'label' => 'Vorname', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'lastname', 'label' => 'Nachname', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'street', 'label' => 'Straße & Hausnummer', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'postal', 'label' => 'PLZ', 'mandatory' => 1, 'maxlength' => 32],
|
||||
['type' => 'text', 'name' => 'city', 'label' => 'Ort', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'email', 'label' => 'E-Mail-Adresse', 'mandatory' => 1, 'rgxp' => 'email', 'maxlength' => 255],
|
||||
['type' => 'text', 'name' => 'phone', 'label' => 'Telefonnummer/Handy', 'mandatory' => 1, 'rgxp' => 'phone', 'maxlength' => 64],
|
||||
['type' => 'select', 'name' => 'position', 'label' => 'Ihre Tätigkeit', 'options' => [['', 'bitte wählen'], ['church_employee', 'Kirchlicher Mitarbeiter'], ['teacher', 'Lehrer'], ['pastor', 'Pfarrer'], ['volunteer', 'Ehrenamtlich']]],
|
||||
['type' => 'text', 'name' => 'birthday', 'label' => 'Geburtstag', 'rgxp' => 'date', 'placeholder' => 'TT.MM.JJJJ', 'maxlength' => 10],
|
||||
['type' => 'select', 'name' => 'teacherPosition', 'label' => 'Angaben zur Lehrertätigkeit', 'mandatory' => 1, 'options' => [['', 'bitte wählen'], ['state', 'Ich unterrichte an einer staatlichen Schule'], ['other', 'Ich unterrichte an einer Schule in sonstiger Trägerschaft'], ['none', 'Ich unterrichte nicht']]],
|
||||
['type' => 'text', 'name' => 'schoolName', 'label' => 'Name der Schule', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'schoolCity', 'label' => 'Ort der Schule', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'checkbox', 'name' => 'vegetarian', 'label' => '', 'options' => [['1', 'Ich möchte vegetarisch essen']]],
|
||||
['type' => 'select', 'name' => 'overnightStay', 'label' => 'Übernachtung', 'mandatory' => 1, 'options' => [['', 'bitte wählen'], ['none', 'keine Übernachtung benötigt'], ['single_room', 'Einzelzimmer'], ['double_room', 'Doppelzimmer']]],
|
||||
['type' => 'explanation', 'text' => '<p><strong>Die Buchung von Übernachtung und/oder Verpflegung nehmen Sie direkt im Gästehaus oder in einem ortsansässigen Beherbergungsbetrieb vor. Mit der Anmeldebestätigung erhalten Sie per Mail weitere Informationen.</strong></p>', 'class' => BookingFields::SLOT_CLASS_PREFIX.BookingFields::SLOT_NO_OVERNIGHT_STAY],
|
||||
['type' => 'checkbox', 'name' => 'addDifferentInvoiceAddress', 'label' => '', 'options' => [['1', 'Rechnungen bitte an eine andere Anschrift schicken']]],
|
||||
['type' => 'text', 'name' => 'invoiceInstitution', 'label' => 'Institution (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'select', 'name' => 'invoiceGender', 'label' => 'Anrede (Rechnungsempfänger)', 'mandatory' => 1, 'options' => $genders],
|
||||
['type' => 'text', 'name' => 'invoiceAcademicTitle', 'label' => 'Titel (Rechnungsempfänger)', 'maxlength' => 64],
|
||||
['type' => 'text', 'name' => 'invoiceFirstname', 'label' => 'Vorname (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'invoiceLastname', 'label' => 'Nachname (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'invoiceStreet', 'label' => 'Straße & Hausnummer (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'text', 'name' => 'invoicePostal', 'label' => 'PLZ (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 32],
|
||||
['type' => 'text', 'name' => 'invoiceCity', 'label' => 'Ort (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128],
|
||||
['type' => 'checkbox', 'name' => 'addNotes', 'label' => '', 'options' => [['1', 'Ich habe eine zusätzliche Frage oder Nachricht']]],
|
||||
['type' => 'textarea', 'name' => 'notes', 'label' => 'Ihre Nachricht', 'mandatory' => 1],
|
||||
['type' => 'checkbox', 'name' => 'newsletter', 'label' => '', 'options' => [['1', 'Ich möchte den Newsletter abonnieren']]],
|
||||
['type' => 'checkbox', 'name' => 'privacyAccepted', 'label' => '', 'mandatory' => 1, 'options' => [['1', 'Ich bin mit der Verarbeitung meiner Daten gemäß den <a href="'.$privacyUrl.'" target="_blank">Datenschutzhinweisen</a> einverstanden.']]],
|
||||
['type' => 'checkbox', 'name' => 'agbAccepted', 'label' => '', 'mandatory' => 1, 'options' => [['1', 'Ich habe die <a href="'.$termsUrl.'" target="_blank">AGB</a> gelesen und stimme diesen zu.']]],
|
||||
['type' => 'altcha', 'name' => 'altcha', 'label' => 'Spamschutz', 'mandatory' => 1],
|
||||
['type' => 'submit', 'slabel' => 'Verbindlich anmelden'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function insert(string $table, array $data): int
|
||||
{
|
||||
$columns = array_map(static fn ($c): string => $c->getName(), $this->connection->createSchemaManager()->listTableColumns($table));
|
||||
$this->connection->insert($table, array_intersect_key($data, array_flip($columns)));
|
||||
|
||||
return (int) $this->connection->lastInsertId();
|
||||
}
|
||||
|
||||
private function uniqueAlias(string $alias): string
|
||||
{
|
||||
$base = $alias;
|
||||
|
||||
for ($i = 2; (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_form WHERE alias = ?', [$alias]) > 0; ++$i) {
|
||||
$alias = $base.'-'.$i;
|
||||
}
|
||||
|
||||
return $alias;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Command;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportException;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportMessage;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportPlan;
|
||||
use Mummert\ContaoWkSyncBundle\Import\ImportService;
|
||||
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
|
||||
use Mummert\ContaoWkSyncBundle\Import\SyncFileStorage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'wk-sync:import', description: 'Importiert eine W&K-Seminardatei (CSV) in einen Kalender: Vorschau, mit --apply auch anwenden.')]
|
||||
class ImportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImportService $importService,
|
||||
private readonly SyncFileStorage $storage,
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('file', InputArgument::REQUIRED, 'Pfad zur W&K-Datei (absolut oder relativ zum Projektverzeichnis)')
|
||||
->addArgument('calendar', InputArgument::REQUIRED, 'ID des Kalenders (W&K-Sync muss dort aktiviert sein)')
|
||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Änderungen anwenden (sonst nur Vorschau)')
|
||||
->addOption('user', null, InputOption::VALUE_REQUIRED, 'ID des Backend-Benutzers für das Protokoll', '0')
|
||||
->addOption('details', null, InputOption::VALUE_NONE, 'Alle geplanten Änderungen einzeln auflisten')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$file = (string) $input->getArgument('file');
|
||||
|
||||
if (!str_starts_with($file, '/')) {
|
||||
$file = $this->projectDir.'/'.$file;
|
||||
}
|
||||
|
||||
if (!is_file($file)) {
|
||||
$io->error(\sprintf('Die Datei "%s" wurde nicht gefunden.', $file));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$userId = (int) $input->getOption('user');
|
||||
|
||||
try {
|
||||
$path = $this->storage->store((string) file_get_contents($file), SyncFileStorage::KIND_IMPORT, basename($file));
|
||||
$prepared = $this->importService->prepare($path, basename($file), (int) $input->getArgument('calendar'), $userId);
|
||||
$plan = $prepared['plan'];
|
||||
|
||||
$this->printPlan($io, $plan, (bool) $input->getOption('details'));
|
||||
|
||||
if (!$input->getOption('apply')) {
|
||||
$io->note(\sprintf('Vorschau gespeichert als Import #%d. Zum Anwenden --apply angeben oder den Import im Backend bestätigen.', $prepared['logId']));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$applied = $this->importService->apply($prepared['logId'], $userId);
|
||||
$result = $applied['result'];
|
||||
|
||||
$io->success(\sprintf(
|
||||
'Import #%d angewendet: %d neu, %d geändert, %d storniert, %d unverändert, %d Mitglieder angelegt.',
|
||||
$prepared['logId'], $result['created'], $result['updated'], $result['cancelled'], $result['unchanged'], $result['members'],
|
||||
));
|
||||
} catch (ImportException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function printPlan(SymfonyStyle $io, ImportPlan $plan, bool $details): void
|
||||
{
|
||||
$summary = $plan->summary();
|
||||
|
||||
$io->table(['Neu', 'Geändert', 'Storniert', 'Unverändert', 'Übersprungen', 'Warnungen', 'Fehler'], [[
|
||||
$summary[PlannedEvent::CREATE], $summary[PlannedEvent::UPDATE], $summary[PlannedEvent::CANCEL],
|
||||
$summary[PlannedEvent::UNCHANGED], $summary[PlannedEvent::SKIP], $summary['warnings'], $summary['errors'],
|
||||
]]);
|
||||
|
||||
foreach ($plan->allMessages() as $message) {
|
||||
$line = ('' !== $message->wkId ? '['.$message->wkId.'] ' : '').$message->text;
|
||||
|
||||
match ($message->type) {
|
||||
ImportMessage::ERROR => $io->writeln('<error>Fehler:</error> '.$line),
|
||||
ImportMessage::WARNING => $io->writeln('<comment>Warnung:</comment> '.$line),
|
||||
default => $io->writeln('<info>Hinweis:</info> '.$line),
|
||||
};
|
||||
}
|
||||
|
||||
if (!$details) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL] as $action) {
|
||||
$events = $plan->byAction($action);
|
||||
|
||||
if ([] === $events) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->section(ucfirst($action).' ('.\count($events).')');
|
||||
|
||||
foreach ($events as $event) {
|
||||
$io->writeln(\sprintf(' - %s %s', $event->wkId, $event->title));
|
||||
|
||||
foreach ($event->changes as $field => [$old, $new]) {
|
||||
$io->writeln(\sprintf(' %s: %s -> %s', $field, $this->short($old), $this->short($new)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function short(mixed $value): string
|
||||
{
|
||||
$text = \is_array($value) ? implode(',', array_map(strval(...), $value)) : trim(strip_tags((string) (\is_bool($value) ? (int) $value : $value)));
|
||||
|
||||
return mb_strlen($text) > 60 ? mb_substr($text, 0, 57).'...' : $text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'wk-sync:categories:seed', description: 'Legt die Standard-Kategorien (TPI Moritzburg) an, sofern sie noch nicht existieren.')]
|
||||
class SeedCategoriesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var list<array{title: string, alias: string, kind: CategoryKind, names: list<string>}>
|
||||
*/
|
||||
public const array DEFAULTS = [
|
||||
['title' => 'Elementarbereich', 'alias' => 'elementarbereich', 'kind' => CategoryKind::Elementary, 'names' => ['Elementarbereich']],
|
||||
['title' => 'Grundschule/Förderschule', 'alias' => 'grundschule-foerderschule', 'kind' => CategoryKind::School, 'names' => ['Grundschule', 'Förderschule', 'Grund- und Förderschule', 'Alle Schularten']],
|
||||
['title' => 'Weiterführende Schule', 'alias' => 'weiterfuehrende-schule', 'kind' => CategoryKind::School, 'names' => ['Weiterführende Schule', 'Weiterführende', 'Oberschule', 'Gymnasium', 'Berufsbildende', 'Alle Schularten']],
|
||||
['title' => 'Evang. Schulen', 'alias' => 'evang-schulen', 'kind' => CategoryKind::EvangelicalSchool, 'names' => ['Evang. Schulen', 'Evangelische Schulen', 'evang']],
|
||||
['title' => 'Gemeinde', 'alias' => 'gemeinde', 'kind' => CategoryKind::Community, 'names' => ['Gemeinde', 'Gemeindepädagogische Arbeit', 'Gemeindepädagogik']],
|
||||
['title' => 'Gottesdienst mit Kindern', 'alias' => 'gottesdienst-mit-kindern', 'kind' => CategoryKind::ChildrenService, 'names' => ['Gottesdienst mit Kindern']],
|
||||
['title' => 'Ausbildung', 'alias' => 'ausbildung', 'kind' => CategoryKind::Training, 'names' => ['Ausbildung']],
|
||||
['title' => 'Mentoring', 'alias' => 'mentoring', 'kind' => CategoryKind::Mentoring, 'names' => ['Mentoring', 'Mentoren', 'Mentor']],
|
||||
['title' => 'Tagungen', 'alias' => 'tagungen', 'kind' => CategoryKind::Conference, 'names' => ['Tagungen', 'Tagung']],
|
||||
['title' => 'AusZEIT', 'alias' => 'auszeit', 'kind' => CategoryKind::Other, 'names' => ['AusZEIT']],
|
||||
['title' => 'Angebote anderer Träger', 'alias' => 'angebote-anderer-traeger', 'kind' => CategoryKind::Other, 'names' => ['Angebote anderer Träger']],
|
||||
['title' => 'Schule', 'alias' => 'schule', 'kind' => CategoryKind::School, 'names' => ['Schule']],
|
||||
];
|
||||
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$existing = $this->connection->fetchFirstColumn('SELECT alias FROM tl_wk_category');
|
||||
$sorting = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(sorting), 0) FROM tl_wk_category');
|
||||
$created = 0;
|
||||
|
||||
foreach (self::DEFAULTS as $category) {
|
||||
if (\in_array($category['alias'], $existing, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sorting += 16;
|
||||
|
||||
$this->connection->insert('tl_wk_category', [
|
||||
'tstamp' => time(),
|
||||
'sorting' => $sorting,
|
||||
'title' => $category['title'],
|
||||
'alias' => $category['alias'],
|
||||
'kind' => $category['kind']->value,
|
||||
'wkNames' => implode("\n", $category['names']),
|
||||
]);
|
||||
|
||||
++$created;
|
||||
$io->writeln(\sprintf(' + %s (%s)', $category['title'], $category['kind']->value));
|
||||
}
|
||||
|
||||
$io->success(\sprintf('%d Kategorien angelegt, %d bereits vorhanden.', $created, \count(self::DEFAULTS) - $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\ContaoManager;
|
||||
|
||||
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 Contao\NewsletterBundle\ContaoNewsletterBundle;
|
||||
use Mummert\ContaoWkSyncBundle\ContaoWkSyncBundle;
|
||||
use Terminal42\NotificationCenterBundle\Terminal42NotificationCenterBundle;
|
||||
|
||||
class Plugin implements BundlePluginInterface
|
||||
{
|
||||
public function getBundles(ParserInterface $parser): array
|
||||
{
|
||||
return [
|
||||
BundleConfig::create(ContaoWkSyncBundle::class)
|
||||
->setLoadAfter([
|
||||
ContaoCoreBundle::class,
|
||||
ContaoCalendarBundle::class,
|
||||
ContaoNewsletterBundle::class,
|
||||
Terminal42NotificationCenterBundle::class,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle;
|
||||
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
use Terminal42\NotificationCenterBundle\Terminal42NotificationCenterBundle;
|
||||
|
||||
class ContaoWkSyncBundle extends AbstractBundle
|
||||
{
|
||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$container->import('../config/services.yaml');
|
||||
|
||||
// Benachrichtigungen nur anbinden, wenn das Notification Center 2 installiert ist
|
||||
if (class_exists(Terminal42NotificationCenterBundle::class)) {
|
||||
$container->import('../config/notification_center.yaml');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Controller\FrontendModule;
|
||||
|
||||
use Contao\Controller;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Exception\PageNotFoundException;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\ModuleModel;
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingAvailability;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingRules;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\EventResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anmeldeformular zu einer Veranstaltung (Seite mit auto_item = Alias der Veranstaltung).
|
||||
*/
|
||||
#[AsFrontendModule(self::TYPE, category: 'wk_sync', template: 'frontend_module/wk_booking_form')]
|
||||
class BookingFormController extends AbstractFrontendModuleController
|
||||
{
|
||||
public const string TYPE = 'wk_booking_form';
|
||||
|
||||
public function __construct(
|
||||
private readonly EventResolver $events,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly BookingAvailability $availability,
|
||||
private readonly BookingRules $rules,
|
||||
private readonly CategoryRepository $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
|
||||
{
|
||||
$calendarIds = array_map(intval(...), StringUtil::deserialize($model->cal_calendar, true));
|
||||
$event = $this->events->resolveFromRequest($request, $calendarIds);
|
||||
|
||||
if (null === $event || (!$event->published && !$request->attributes->get('_preview', false))) {
|
||||
throw new PageNotFoundException('Event not found: '.($request->attributes->get('auto_item') ?? ''));
|
||||
}
|
||||
|
||||
$calendar = $this->calendars->find($event->calendarId);
|
||||
|
||||
if (null === $calendar) {
|
||||
throw new PageNotFoundException('Calendar not found for event ID '.$event->id);
|
||||
}
|
||||
|
||||
$availability = $this->availability->check($event, $calendar);
|
||||
$rules = $this->rules->rulesFor($event, $calendar);
|
||||
|
||||
$context = new BookingContext($event, $calendar, $rules, $availability);
|
||||
$context->attachTo($request);
|
||||
|
||||
$formHtml = '';
|
||||
|
||||
if ($availability->isBookable()) {
|
||||
// Die Hooks des Formulargenerators greifen über den BookingContext auf die Veranstaltung zu
|
||||
$formHtml = $this->getContaoAdapter(Controller::class)->getForm($calendar->bookingForm);
|
||||
}
|
||||
|
||||
$template->set('event', $event);
|
||||
$template->set('calendar', $calendar);
|
||||
$template->set('availability', $availability);
|
||||
$template->set('form_html', $formHtml);
|
||||
$template->set('places_left', $availability->placesLeft);
|
||||
$template->set('deadline', $availability->displayDeadline);
|
||||
$template->set('categories', $this->categories->findByIds($event->categories));
|
||||
$template->set('rules_config', json_encode([
|
||||
'hidden' => $rules->hiddenFields,
|
||||
'dependencies' => $rules->dependencies,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$this->tagResponse(['contao.db.tl_calendar_events.'.$event->id, 'contao.db.tl_wk_booking']);
|
||||
|
||||
$response = $template->getResponse();
|
||||
$response->setPrivate();
|
||||
$response->headers->addCacheControlDirective('no-store');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Controller\FrontendModule;
|
||||
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||
use Contao\ModuleModel;
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingAvailability;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingUrlBuilder;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\EventResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anmeldestatus einer Veranstaltung: freie Plätze, Anmeldeschluss und Link zur Anmeldung
|
||||
* (für die Detailseite neben dem Event-Reader).
|
||||
*/
|
||||
#[AsFrontendModule(self::TYPE, category: 'wk_sync', template: 'frontend_module/wk_booking_status')]
|
||||
class BookingStatusController extends AbstractFrontendModuleController
|
||||
{
|
||||
public const string TYPE = 'wk_booking_status';
|
||||
|
||||
public function __construct(
|
||||
private readonly EventResolver $events,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly BookingAvailability $availability,
|
||||
private readonly BookingUrlBuilder $urls,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
|
||||
{
|
||||
$calendarIds = array_map(intval(...), StringUtil::deserialize($model->cal_calendar, true));
|
||||
$event = $this->events->resolveFromRequest($request, $calendarIds);
|
||||
|
||||
if (null === $event || null === ($calendar = $this->calendars->find($event->calendarId))) {
|
||||
return new Response('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
$availability = $this->availability->check($event, $calendar);
|
||||
|
||||
$template->set('event', $event);
|
||||
$template->set('calendar', $calendar);
|
||||
$template->set('availability', $availability);
|
||||
$template->set('places_left', $availability->placesLeft);
|
||||
$template->set('deadline', $availability->displayDeadline);
|
||||
$template->set('booking_url', $this->urls->bookingUrl($event, $calendar));
|
||||
$template->set('inquiry_url', $this->urls->inquiryUrl($event, $calendar));
|
||||
|
||||
$this->tagResponse(['contao.db.tl_calendar_events.'.$event->id, 'contao.db.tl_wk_booking']);
|
||||
|
||||
$response = $template->getResponse();
|
||||
$response->setPrivate();
|
||||
$response->headers->addCacheControlDirective('no-store');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\Config;
|
||||
use Contao\Date;
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Zeile in der Anmeldeliste unterhalb einer Veranstaltung.
|
||||
*/
|
||||
#[AsCallback(table: 'tl_wk_booking', target: 'list.sorting.child_record')]
|
||||
class BookingLabelListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public function __invoke(array $row): string
|
||||
{
|
||||
$config = $this->framework->getAdapter(Config::class);
|
||||
$date = $this->framework->getAdapter(Date::class)->parse((string) $config->get('datimFormat'), (int) $row['dateCreated']);
|
||||
$state = InboxState::tryFrom((string) $row['state']) ?? InboxState::New;
|
||||
$stateLabel = $this->translator->trans('MSC.wk_sync.state.'.$state->translationKey(), [], 'contao_default');
|
||||
|
||||
$name = trim(($row['academicTitle'] ? $row['academicTitle'].' ' : '').$row['firstname'].' '.$row['lastname']);
|
||||
|
||||
return \sprintf(
|
||||
'<div class="tl_content_left">%s <span class="wk-sync-muted" style="color:#999;padding-left:3px">[%s]</span> <span style="color:#999;padding-left:3px">[%s]</span> <span style="color:#999;padding-left:3px">[%s]</span></div>',
|
||||
StringUtil::specialchars($name),
|
||||
StringUtil::specialchars((string) $row['email']),
|
||||
StringUtil::specialchars($date),
|
||||
StringUtil::specialchars($stateLabel),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\CoreBundle\Slug\Slug;
|
||||
use Contao\DataContainer;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
#[AsCallback(table: 'tl_wk_category', target: 'fields.alias.save')]
|
||||
class CategoryAliasListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Slug $slug,
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(mixed $value, DataContainer $dc): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
$exists = fn (string $alias): bool => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_wk_category WHERE alias = ? AND id != ?', [$alias, (int) $dc->id]) > 0;
|
||||
|
||||
if ('' === $value) {
|
||||
return $this->slug->generate((string) $dc->getCurrentRecord()['title'], [], $exists);
|
||||
}
|
||||
|
||||
if ($exists($value)) {
|
||||
throw new \RuntimeException(\sprintf($GLOBALS['TL_LANG']['ERR']['aliasExists'] ?? 'Alias "%s" already exists.', $value));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Newsletter-Verteiler (leer, wenn das Newsletter-Bundle fehlt).
|
||||
*/
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNewsletterChannels.options')]
|
||||
class NewsletterChannelOptionsListener
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function __invoke(): array
|
||||
{
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['tl_newsletter_channel'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach ($this->connection->fetchAllKeyValue('SELECT id, title FROM tl_newsletter_channel ORDER BY title') as $id => $title) {
|
||||
$options[(int) $id] = (string) $title;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface;
|
||||
|
||||
/**
|
||||
* Auswahl der Benachrichtigungen vom Typ „W&K-Anmeldung“ (leer ohne Notification Center).
|
||||
*/
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotificationDefault.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification1.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification2.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification3.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification4.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification5.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification6.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification7.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification8.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification9.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification10.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification11.options')]
|
||||
#[AsCallback(table: 'tl_calendar', target: 'fields.wkNotification12.options')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkNotification.options')]
|
||||
class NotificationOptionsListener
|
||||
{
|
||||
public function __construct(private readonly BookingNotifierInterface $notifier)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function __invoke(): array
|
||||
{
|
||||
return $this->notifier->availableNotifications();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\Form;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\FormFieldFilter;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Blendet im Anmeldeformular die Felder aus, die für die Veranstaltung nicht gelten.
|
||||
*/
|
||||
#[AsHook('compileFormFields')]
|
||||
class CompileFormFieldsListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly FormFieldFilter $filter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(array $fields, string $formId, Form $form): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$context = BookingContext::fromRequest($request);
|
||||
|
||||
if (null === $context || null === $request || !$context->isBookingForm((int) $form->id)) {
|
||||
return $fields;
|
||||
}
|
||||
|
||||
$submitted = $request->request->get('FORM_SUBMIT') === $formId ? $request->request->all() : null;
|
||||
|
||||
return $this->filter->filter($fields, $context->rules, $submitted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\Module;
|
||||
use Contao\StringUtil;
|
||||
|
||||
/**
|
||||
* Kategoriefilter für die Kalender-Module des Contao-Kerns (Feld „W&K-Kategorien“ im Modul).
|
||||
*/
|
||||
#[AsHook('getAllEvents')]
|
||||
class GetAllEventsListener
|
||||
{
|
||||
public function __invoke(array $events, array $calendars, int $start, int $end, Module $module): array
|
||||
{
|
||||
$wanted = array_map(intval(...), StringUtil::deserialize($module->wkCategories ?? null, true));
|
||||
|
||||
if ([] === $wanted) {
|
||||
return $events;
|
||||
}
|
||||
|
||||
foreach ($events as $day => $eventsPerDay) {
|
||||
foreach ($eventsPerDay as $time => $eventsPerTime) {
|
||||
foreach ($eventsPerTime as $key => $event) {
|
||||
$categories = array_map(intval(...), StringUtil::deserialize($event['wkCategories'] ?? null, true));
|
||||
|
||||
if ([] === array_intersect($categories, $wanted)) {
|
||||
unset($events[$day][$time][$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $events[$day][$time]) {
|
||||
unset($events[$day][$time]);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $events[$day]) {
|
||||
unset($events[$day]);
|
||||
}
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\Form;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingAvailability;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\EventResolver;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* 1. Anmeldeformular: Verfügbarkeit beim Absenden erneut prüfen (Plätze, Anmeldeschluss).
|
||||
* 2. Folgeformulare (z. B. Workshop-Auswahl auf der Bestätigungsseite): Daten der Anmeldung aus
|
||||
* dem Parameter ?booking=<token> in die übermittelten Daten übernehmen.
|
||||
*/
|
||||
#[AsHook('prepareFormData')]
|
||||
class PrepareFormDataListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly BookingAvailability $availability,
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly EventResolver $events,
|
||||
private readonly TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submittedData
|
||||
*/
|
||||
public function __invoke(array &$submittedData, array $labels, array $fields, Form $form): void
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (null === $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = BookingContext::fromRequest($request);
|
||||
|
||||
if (null !== $context && $context->isBookingForm((int) $form->id)) {
|
||||
$availability = $this->availability->check($context->event, $context->calendar);
|
||||
|
||||
if (!$availability->isBookable()) {
|
||||
$form->addError($this->translator->trans('MSC.wk_booking.error.'.$availability->reason, [], 'contao_default'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token = (string) ($request->query->get('booking') ?? $submittedData['booking'] ?? '');
|
||||
|
||||
if ('' === $token || null === ($booking = $this->bookings->findByToken($token))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$submittedData['subscription'] = (int) $booking['id'];
|
||||
$submittedData['subscription_email'] = (string) $booking['email'];
|
||||
$submittedData['subscription_firstname'] = (string) $booking['firstname'];
|
||||
$submittedData['subscription_lastname'] = (string) $booking['lastname'];
|
||||
|
||||
if (null !== ($event = $this->events->find((int) $booking['pid']))) {
|
||||
$submittedData['event'] = $event->id;
|
||||
$submittedData['event_title'] = $event->title;
|
||||
$submittedData['event_wk_id'] = $event->wkId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\CoreBundle\Exception\RedirectResponseException;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\Form;
|
||||
use Contao\PageModel;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingCreator;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingTokens;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\NewsletterSubscriber;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\NotificationRouter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Speichert die Anmeldung, verschickt die Benachrichtigung, meldet ggf. zum Newsletter an und leitet
|
||||
* auf die Bestätigungsseite weiter.
|
||||
*/
|
||||
#[AsHook('processFormData', priority: 100)]
|
||||
class ProcessFormDataListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly BookingCreator $creator,
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly NotificationRouter $router,
|
||||
private readonly BookingTokens $tokens,
|
||||
private readonly BookingNotifierInterface $notifier,
|
||||
private readonly NewsletterSubscriber $newsletter,
|
||||
private readonly ContentUrlGenerator $urlGenerator,
|
||||
private readonly LoggerInterface|null $contaoGeneralLogger = null,
|
||||
private readonly LoggerInterface|null $contaoErrorLogger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $submittedData
|
||||
* @param array<string, mixed> $formData
|
||||
*/
|
||||
public function __invoke(array $submittedData, array $formData, array|null $files, array $labels, Form $form): void
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$context = BookingContext::fromRequest($request);
|
||||
|
||||
if (null === $context || !$context->isBookingForm((int) $form->id) || $form->hasErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wurde die Zeile bereits vom Formulargenerator gespeichert („Daten in Tabelle speichern“)?
|
||||
if (null !== $context->booking && !isset($context->booking['id'])) {
|
||||
$stored = $this->bookings->findByToken((string) $context->booking['token']);
|
||||
$booking = $stored ?? $this->creator->create($context->event, $submittedData, $context->rules);
|
||||
} else {
|
||||
$booking = $context->booking ?? $this->creator->create($context->event, $submittedData, $context->rules);
|
||||
}
|
||||
|
||||
$context->booking = $booking;
|
||||
|
||||
$url = $this->confirmationUrl($context, (string) $booking['token'], (int) $form->jumpTo);
|
||||
$routing = $this->router->resolve($context->event, $context->calendar);
|
||||
|
||||
if ($routing['notification'] > 0) {
|
||||
$tokens = $this->tokens->build($booking, $context->event, $context->calendar, $url, $routing);
|
||||
$sent = $this->notifier->notify($routing['notification'], $tokens);
|
||||
$this->bookings->update((int) $booking['id'], ['notification' => $sent ? $routing['notification'] : 0]);
|
||||
|
||||
if (!$sent) {
|
||||
$this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung #%d zu „%s“: Benachrichtigung %d wurde nicht zugestellt.', $booking['id'], $context->event->title, $routing['notification']));
|
||||
}
|
||||
} else {
|
||||
$this->contaoErrorLogger?->warning(\sprintf('W&K-Anmeldung #%d zu „%s“: Es ist keine Benachrichtigung konfiguriert (Bedingungen: %s).', $booking['id'], $context->event->title, implode(', ', $routing['conditions']) ?: 'keine'));
|
||||
}
|
||||
|
||||
if (!empty($booking['newsletter'])) {
|
||||
$this->newsletter->subscribe((string) $booking['email'], $context->calendar);
|
||||
}
|
||||
|
||||
$this->contaoGeneralLogger?->info(\sprintf('Neue W&K-Anmeldung #%d von %s %s zu „%s“ (W&K %s).', $booking['id'], $booking['firstname'], $booking['lastname'], $context->event->title, $context->event->wkId));
|
||||
|
||||
if ('' !== $url) {
|
||||
throw new RedirectResponseException($url);
|
||||
}
|
||||
}
|
||||
|
||||
private function confirmationUrl(BookingContext $context, string $token, int $formJumpTo): string
|
||||
{
|
||||
$this->framework->initialize();
|
||||
$pages = $this->framework->getAdapter(PageModel::class);
|
||||
|
||||
foreach ([$context->event->confirmationPage, $context->calendar->confirmationPage, $formJumpTo] as $pageId) {
|
||||
if ($pageId < 1 || null === ($page = $pages->findPublishedById($pageId))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->urlGenerator->generate($page, [], UrlGeneratorInterface::ABSOLUTE_URL).'?booking='.$token;
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Hook;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\Form;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingContext;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingCreator;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Formulare, die „Daten in Tabelle speichern“ nutzen:
|
||||
* - tl_wk_inquiry (Anfrageformular): Datum, Veranstaltung und Ja/Nein-Felder ergänzen, unbekannte Felder entfernen.
|
||||
* - tl_wk_booking (falls das Anmeldeformular so konfiguriert ist): Zeile vollständig aufbereiten.
|
||||
*/
|
||||
#[AsHook('storeFormData')]
|
||||
class StoreFormDataListener
|
||||
{
|
||||
/** @var array<string, list<string>> */
|
||||
private array $columns = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Connection $connection,
|
||||
private readonly BookingCreator $creator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function __invoke(array $data, Form $form): array
|
||||
{
|
||||
return match ((string) $form->targetTable) {
|
||||
'tl_wk_inquiry' => $this->prepareInquiry($data),
|
||||
'tl_wk_booking' => $this->prepareBooking($data, $form),
|
||||
default => $data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareInquiry(array $data): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$now = time();
|
||||
|
||||
$eventId = (int) ($data['event'] ?? 0);
|
||||
|
||||
if ($eventId < 1 && null !== $request) {
|
||||
$eventId = (int) $request->query->get('event', 0);
|
||||
}
|
||||
|
||||
$data['dateCreated'] = $now;
|
||||
$data['tstamp'] = $now;
|
||||
$data['state'] = InboxState::New->value;
|
||||
$data['event'] = $eventId;
|
||||
|
||||
foreach (['newsletter', 'privacy'] as $bool) {
|
||||
$data[$bool] = (int) BookingCreator::toBool($data[$bool] ?? null);
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (\is_array($value)) {
|
||||
$data[$key] = BookingCreator::toString($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->onlyKnownColumns('tl_wk_inquiry', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareBooking(array $data, Form $form): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$context = BookingContext::fromRequest($request);
|
||||
|
||||
if (null === $context || !$context->isBookingForm((int) $form->id)) {
|
||||
return $this->onlyKnownColumns('tl_wk_booking', $data);
|
||||
}
|
||||
|
||||
$row = $this->creator->prepare($context->event, $data, $context->rules);
|
||||
$context->booking = $row;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function onlyKnownColumns(string $table, array $data): array
|
||||
{
|
||||
if (!isset($this->columns[$table])) {
|
||||
$this->columns[$table] = array_map(
|
||||
static fn ($column): string => $column->getName(),
|
||||
$this->connection->createSchemaManager()->listTableColumns($table),
|
||||
);
|
||||
}
|
||||
|
||||
return array_intersect_key($data, array_flip($this->columns[$table]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\NotificationCenter;
|
||||
|
||||
use Terminal42\NotificationCenterBundle\NotificationType\NotificationTypeInterface;
|
||||
use Terminal42\NotificationCenterBundle\Token\Definition\AnythingTokenDefinition;
|
||||
use Terminal42\NotificationCenterBundle\Token\Definition\EmailTokenDefinition;
|
||||
use Terminal42\NotificationCenterBundle\Token\Definition\Factory\TokenDefinitionFactoryInterface;
|
||||
use Terminal42\NotificationCenterBundle\Token\Definition\TextTokenDefinition;
|
||||
|
||||
/**
|
||||
* Benachrichtigungstyp „W&K-Anmeldung“ (nur mit Notification Center 2).
|
||||
*/
|
||||
class BookingNotificationType implements NotificationTypeInterface
|
||||
{
|
||||
public const string NAME = 'wk_booking';
|
||||
|
||||
public function __construct(private readonly TokenDefinitionFactoryInterface $factory)
|
||||
{
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return self::NAME;
|
||||
}
|
||||
|
||||
public function getTokenDefinitions(): array
|
||||
{
|
||||
return [
|
||||
$this->factory->create(EmailTokenDefinition::class, 'recipient_email', 'wk_booking.recipient_email'),
|
||||
$this->factory->create(EmailTokenDefinition::class, 'admin_email', 'wk_booking.admin_email'),
|
||||
$this->factory->create(EmailTokenDefinition::class, 'subscription_email', 'wk_booking.subscription_email'),
|
||||
$this->factory->create(EmailTokenDefinition::class, 'event_wkOrganizerEmail', 'wk_booking.event_wkOrganizerEmail'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'subscription_confirmation_page_url', 'wk_booking.subscription_confirmation_page_url'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'subscription_event_wk_id', 'wk_booking.subscription_event_wk_id'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'event_link', 'wk_booking.event_link'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'event_datetime', 'wk_booking.event_datetime'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'event_categories', 'wk_booking.event_categories'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'event_speakers', 'wk_booking.event_speakers'),
|
||||
$this->factory->create(TextTokenDefinition::class, 'wk_notification_conditions', 'wk_booking.wk_notification_conditions'),
|
||||
$this->factory->create(AnythingTokenDefinition::class, 'subscription_*', 'wk_booking.subscription_*'),
|
||||
$this->factory->create(AnythingTokenDefinition::class, 'event_*', 'wk_booking.event_*'),
|
||||
$this->factory->create(AnythingTokenDefinition::class, 'calendar_*', 'wk_booking.calendar_*'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Export;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Import\SyncFileStorage;
|
||||
use Mummert\ContaoWkSyncBundle\Inbox\InboxState;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\SyncLog;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Exportiert die Download-Liste (Anmeldungen und Kontaktanfragen) als CSV für W&K, markiert die Datensätze
|
||||
* als exportiert und reduziert die freien Plätze der Veranstaltungen bis zum nächsten W&K-Import.
|
||||
*/
|
||||
class BookingExporter
|
||||
{
|
||||
public const array HEADER = [
|
||||
'KBNR', 'KNKDAN', 'KNKDTITEL', 'KNKDN2', 'KNKDN1', 'KNKDSTR', 'KNKDPLZ', 'KNKDORT', 'KNGEBDAT',
|
||||
'KDEMAIL', 'KDTEL1', 'KDFREI01', 'KDFREI02', 'KDFREI03', 'KDFREI04', 'KNUKAT', 'KNVEG', 'schoolName', 'schoolCity',
|
||||
];
|
||||
|
||||
private const array OVERNIGHT_CODES = ['none' => 2, 'single_room' => 3, 'double_room' => 4];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly WkCsvWriter $writer,
|
||||
private readonly NameFormatter $names,
|
||||
private readonly SyncFileStorage $storage,
|
||||
private readonly SyncLog $log,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly LoggerInterface|null $contaoGeneralLogger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die CSV-Zeilen für die aktuelle Download-Liste (ohne etwas zu verändern).
|
||||
*
|
||||
* @return array{rows: list<list<mixed>>, bookingIds: list<int>, inquiryIds: list<int>, places: array<int, int>}
|
||||
*/
|
||||
public function collect(): array
|
||||
{
|
||||
$rows = [];
|
||||
$bookingIds = [];
|
||||
$inquiryIds = [];
|
||||
$places = [];
|
||||
|
||||
$bookings = $this->connection->fetchAllAssociative(
|
||||
'SELECT b.*, e.wkId AS eventWkId FROM tl_wk_booking b INNER JOIN tl_calendar_events e ON e.id = b.pid WHERE b.state = ? ORDER BY b.dateCreated, b.id',
|
||||
[InboxState::DownloadList->value],
|
||||
);
|
||||
|
||||
foreach ($bookings as $row) {
|
||||
$rows[] = $this->row($row, (string) $row['eventWkId']);
|
||||
$bookingIds[] = (int) $row['id'];
|
||||
$places[(int) $row['pid']] = ($places[(int) $row['pid']] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$inquiries = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM tl_wk_inquiry WHERE state = ? ORDER BY dateCreated, id',
|
||||
[InboxState::DownloadList->value],
|
||||
);
|
||||
|
||||
foreach ($inquiries as $row) {
|
||||
$rows[] = $this->row($row, '');
|
||||
$inquiryIds[] = (int) $row['id'];
|
||||
}
|
||||
|
||||
return ['rows' => $rows, 'bookingIds' => $bookingIds, 'inquiryIds' => $inquiryIds, 'places' => $places];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{logId: int, filename: string, bookings: int, inquiries: int}|null null, wenn die Download-Liste leer ist
|
||||
*/
|
||||
public function export(int $userId): array|null
|
||||
{
|
||||
$data = $this->collect();
|
||||
|
||||
if ([] === $data['rows']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$csv = $this->writer->write(self::HEADER, $data['rows']);
|
||||
$filename = date('Y-m-d').'_website_kontaktanfragen_wk.csv';
|
||||
$now = time();
|
||||
|
||||
$path = $this->storage->store($csv, SyncFileStorage::KIND_EXPORT, $filename);
|
||||
|
||||
$logId = $this->connection->transactional(function () use ($data, $now, $userId, $filename, $path): int {
|
||||
if ([] !== $data['bookingIds']) {
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE tl_wk_booking SET state = ?, exportedAt = ?, tstamp = ? WHERE id IN (?)',
|
||||
[InboxState::Exported->value, $now, $now, $data['bookingIds']],
|
||||
[null, null, null, ArrayParameterType::INTEGER],
|
||||
);
|
||||
}
|
||||
|
||||
if ([] !== $data['inquiryIds']) {
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE tl_wk_inquiry SET state = ?, exportedAt = ?, tstamp = ? WHERE id IN (?)',
|
||||
[InboxState::Exported->value, $now, $now, $data['inquiryIds']],
|
||||
[null, null, null, ArrayParameterType::INTEGER],
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($data['places'] as $eventId => $amount) {
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE tl_calendar_events SET wkPlacesLeft = GREATEST(0, wkPlacesLeft - ?), tstamp = ? WHERE id = ?',
|
||||
[$amount, $now, $eventId],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->log->create(SyncLog::TYPE_EXPORT, SyncLog::STATUS_DONE, $userId, 0, $filename, $path, [
|
||||
'bookings' => \count($data['bookingIds']),
|
||||
'inquiries' => \count($data['inquiryIds']),
|
||||
'events' => \count($data['places']),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->contaoGeneralLogger?->info(\sprintf('W&K-Export #%d: %d Anmeldungen und %d Anfragen als %s exportiert.', $logId, \count($data['bookingIds']), \count($data['inquiryIds']), $filename));
|
||||
|
||||
return ['logId' => $logId, 'filename' => $filename, 'bookings' => \count($data['bookingIds']), 'inquiries' => \count($data['inquiryIds'])];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function row(array $row, string $wkId): array
|
||||
{
|
||||
$gender = (string) ($row['gender'] ?? '');
|
||||
$position = (string) ($row['position'] ?? '');
|
||||
|
||||
return [
|
||||
$wkId,
|
||||
'' === $gender ? '' : $this->translator->trans('tl_wk_booking.genderRef.'.$gender, [], 'contao_tl_wk_booking'),
|
||||
(string) ($row['academicTitle'] ?? ''),
|
||||
$this->names->fix((string) ($row['firstname'] ?? '')),
|
||||
$this->names->fix((string) ($row['lastname'] ?? '')),
|
||||
(string) ($row['street'] ?? ''),
|
||||
(string) ($row['postal'] ?? ''),
|
||||
(string) ($row['city'] ?? ''),
|
||||
(string) ($row['birthday'] ?? ''),
|
||||
(string) ($row['email'] ?? ''),
|
||||
(string) ($row['phone'] ?? ''),
|
||||
'pastor' === $position ? 1 : 0,
|
||||
'church_employee' === $position ? 1 : 0,
|
||||
'volunteer' === $position ? 1 : 0,
|
||||
'teacher' === $position ? 1 : 0,
|
||||
self::OVERNIGHT_CODES[(string) ($row['overnightStay'] ?? '')] ?? 2,
|
||||
!empty($row['vegetarian']) ? 1 : 0,
|
||||
(string) ($row['schoolName'] ?? ''),
|
||||
(string) ($row['schoolCity'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Export;
|
||||
|
||||
/**
|
||||
* Bereinigt Namen für W&K: Leerzeichen zusammenfassen, Bindestriche ohne Leerzeichen, Groß-/Kleinschreibung
|
||||
* vereinheitlichen („MÜLLER“ → „Müller“, „anna-lena“ → „Anna-Lena“).
|
||||
*/
|
||||
class NameFormatter
|
||||
{
|
||||
public function fix(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? $name);
|
||||
$name = preg_replace('/\s*-\s*/u', '-', $name) ?? $name;
|
||||
|
||||
if ('' === $name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Export;
|
||||
|
||||
/**
|
||||
* Schreibt die Datei für den W&K-Import: Semikolon, alle Werte in Anführungszeichen, CRLF, ISO-8859-1.
|
||||
*/
|
||||
class WkCsvWriter
|
||||
{
|
||||
public const string DELIMITER = ';';
|
||||
public const string LINE_END = "\r\n";
|
||||
|
||||
/**
|
||||
* @param list<string> $header
|
||||
* @param list<list<mixed>> $rows
|
||||
*/
|
||||
public function write(array $header, array $rows): string
|
||||
{
|
||||
$lines = [$this->line($header)];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lines[] = $this->line($row);
|
||||
}
|
||||
|
||||
return $this->toLatin1(implode(self::LINE_END, $lines).self::LINE_END);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $values
|
||||
*/
|
||||
private function line(array $values): string
|
||||
{
|
||||
return implode(self::DELIMITER, array_map(
|
||||
static fn (mixed $value): string => '"'.str_replace('"', '""', str_replace(["\r\n", "\r", "\n"], ' ', (string) $value)).'"',
|
||||
$values,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichen außerhalb von ISO-8859-1 werden auf ihr Grundzeichen reduziert (ă → a), sonst durch „?“ ersetzt.
|
||||
*/
|
||||
public function toLatin1(string $text): string
|
||||
{
|
||||
$text = str_replace(['€', '„', '“', '”', '‚', '‘', '’', '–', '—', '…'], ['EUR', '"', '"', '"', "'", "'", "'", '-', '-', '...'], $text);
|
||||
|
||||
$result = '';
|
||||
|
||||
foreach (mb_str_split($text, 1, 'UTF-8') as $char) {
|
||||
$converted = mb_convert_encoding($char, 'ISO-8859-1', 'UTF-8');
|
||||
|
||||
if ('?' !== $converted || '?' === $char) {
|
||||
$result .= $converted;
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = class_exists(\Normalizer::class) ? preg_replace('/\p{Mn}+/u', '', \Normalizer::normalize($char, \Normalizer::FORM_D) ?: $char) : $char;
|
||||
$converted = mb_convert_encoding((string) $base, 'ISO-8859-1', 'UTF-8');
|
||||
$result .= '' === $converted ? '?' : $converted;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
/**
|
||||
* Liest die W&K-Exportdatei (Semikolon, Anführungszeichen, Windows-1252 oder UTF-8, CRLF, mehrzeilige Felder).
|
||||
*/
|
||||
class CsvReader
|
||||
{
|
||||
public const string DELIMITER = ';';
|
||||
public const string ENCLOSURE = '"';
|
||||
|
||||
public function read(string $path): CsvTable
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new ImportException(\sprintf('Die Datei "%s" kann nicht gelesen werden.', basename($path)));
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
|
||||
if ('' === trim($content)) {
|
||||
throw new ImportException('Die Datei ist leer.');
|
||||
}
|
||||
|
||||
return $this->readString($content);
|
||||
}
|
||||
|
||||
public function readString(string $content): CsvTable
|
||||
{
|
||||
[$content, $encoding] = $this->toUtf8($content);
|
||||
|
||||
if ('Windows-1252' === $encoding) {
|
||||
$content = $this->fixEuroSign($content);
|
||||
}
|
||||
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, $content);
|
||||
rewind($stream);
|
||||
|
||||
$header = null;
|
||||
$rows = [];
|
||||
$warnings = [];
|
||||
$record = 0;
|
||||
|
||||
try {
|
||||
while (false !== ($fields = fgetcsv($stream, null, self::DELIMITER, self::ENCLOSURE, ''))) {
|
||||
++$record;
|
||||
|
||||
// Leere Zeile
|
||||
if ([null] === $fields || [] === $fields || (1 === \count($fields) && '' === trim((string) $fields[0]))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields = array_map(static fn (string|null $v): string => trim(str_replace("\r\n", "\n", (string) $v)), $fields);
|
||||
|
||||
if (null === $header) {
|
||||
$header = array_map(static fn (string $h): string => mb_strtolower(trim($h, " \t\"\xEF\xBB\xBF")), $fields);
|
||||
|
||||
if (\count(array_unique($header)) !== \count($header)) {
|
||||
$warnings[] = ImportMessage::warning('Die Kopfzeile enthält doppelte Spaltennamen; es gilt jeweils die letzte Spalte.');
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected = \count($header);
|
||||
$actual = \count($fields);
|
||||
|
||||
if ($actual < $expected) {
|
||||
$fields = array_pad($fields, $expected, '');
|
||||
$warnings[] = ImportMessage::warning(\sprintf('Datensatz %d hat weniger Spalten (%d) als die Kopfzeile (%d); fehlende Werte wurden als leer übernommen.', $record, $actual, $expected), '', $record);
|
||||
} elseif ($actual > $expected) {
|
||||
$fields = \array_slice($fields, 0, $expected);
|
||||
$warnings[] = ImportMessage::warning(\sprintf('Datensatz %d hat mehr Spalten (%d) als die Kopfzeile (%d); überzählige Werte wurden ignoriert.', $record, $actual, $expected), '', $record);
|
||||
}
|
||||
|
||||
$row = array_combine($header, $fields);
|
||||
$row['__record'] = (string) $record;
|
||||
$rows[] = $row;
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
if (null === $header) {
|
||||
throw new ImportException('Die Datei enthält keine Kopfzeile.');
|
||||
}
|
||||
|
||||
return new CsvTable($header, $rows, $encoding, $warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} Inhalt in UTF-8 und erkannte Quellkodierung
|
||||
*/
|
||||
private function toUtf8(string $content): array
|
||||
{
|
||||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||||
return [substr($content, 3), 'UTF-8'];
|
||||
}
|
||||
|
||||
if (mb_check_encoding($content, 'UTF-8')) {
|
||||
return [$content, 'UTF-8'];
|
||||
}
|
||||
|
||||
return [mb_convert_encoding($content, 'UTF-8', 'Windows-1252'), 'Windows-1252'];
|
||||
}
|
||||
|
||||
/**
|
||||
* W&K exportiert das Euro-Zeichen als „Ç“ (DOS-Zeichensatz). Nur im Umfeld von Zahlen ersetzen,
|
||||
* damit Namen wie „Çelik“ erhalten bleiben.
|
||||
*/
|
||||
private function fixEuroSign(string $content): string
|
||||
{
|
||||
$content = preg_replace('/(?<=\d)(\s*)Ç/u', '$1€', $content) ?? $content;
|
||||
|
||||
return preg_replace('/Ç(?=\s*\d)/u', '€', $content) ?? $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
/**
|
||||
* Eingelesene CSV-Datei: Kopfzeile plus Datensätze als assoziative Arrays.
|
||||
*/
|
||||
final readonly class CsvTable
|
||||
{
|
||||
/**
|
||||
* @param list<string> $header
|
||||
* @param list<array<string, string>> $rows Datensätze, Schlüssel = Spaltenname in Kleinbuchstaben
|
||||
* @param list<ImportMessage> $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
public array $header,
|
||||
public array $rows,
|
||||
public string $encoding,
|
||||
public array $warnings = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasColumn(string $name): bool
|
||||
{
|
||||
return \in_array(mb_strtolower($name), $this->header, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $names
|
||||
*
|
||||
* @return list<string> fehlende Spalten
|
||||
*/
|
||||
public function missingColumns(array $names): array
|
||||
{
|
||||
return array_values(array_filter($names, fn (string $n): bool => !$this->hasColumn($n)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
class ImportException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Contao\CoreBundle\Cache\CacheTagManager;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Slug\Slug;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Wendet einen Importplan in einer Transaktion auf tl_calendar_events an.
|
||||
*/
|
||||
class ImportExecutor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly Slug $slug,
|
||||
private readonly MemberResolver $memberResolver,
|
||||
private readonly CacheTagManager $cacheTagManager,
|
||||
private readonly ContaoFramework $framework,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, updated: int, cancelled: int, unchanged: int, members: int}
|
||||
*/
|
||||
public function execute(ImportPlan $plan, CalendarConfig $calendar, int $userId): array
|
||||
{
|
||||
// Slug-Generierung und Modelle brauchen das initialisierte Contao-Framework (auch auf der Konsole)
|
||||
$this->framework->initialize();
|
||||
|
||||
$result = ['created' => 0, 'updated' => 0, 'cancelled' => 0, 'unchanged' => 0, 'members' => 0];
|
||||
$now = time();
|
||||
$touchedIds = [];
|
||||
|
||||
$this->connection->transactional(function () use ($plan, $calendar, $userId, $now, &$result, &$touchedIds): void {
|
||||
foreach ($plan->events as $event) {
|
||||
switch ($event->action) {
|
||||
case PlannedEvent::CREATE:
|
||||
$set = $this->buildSet($event, $calendar, $now, $result);
|
||||
$set['pid'] = $calendar->id;
|
||||
$set['author'] = $calendar->author > 0 ? $calendar->author : $userId;
|
||||
$set['alias'] = $this->generateAlias($event->title, $calendar);
|
||||
$set['source'] = 'default';
|
||||
|
||||
$this->connection->insert('tl_calendar_events', $set);
|
||||
$touchedIds[] = (int) $this->connection->lastInsertId();
|
||||
++$result['created'];
|
||||
break;
|
||||
|
||||
case PlannedEvent::UPDATE:
|
||||
$set = $this->buildSet($event, $calendar, $now, $result);
|
||||
$this->connection->update('tl_calendar_events', $set, ['id' => $event->existingId]);
|
||||
$touchedIds[] = $event->existingId;
|
||||
++$result['updated'];
|
||||
break;
|
||||
|
||||
case PlannedEvent::CANCEL:
|
||||
$this->connection->update('tl_calendar_events', [
|
||||
'wkState' => WkState::CANCELLED,
|
||||
'published' => 0,
|
||||
'tstamp' => $now,
|
||||
'wkImported' => $now,
|
||||
], ['id' => $event->existingId]);
|
||||
$touchedIds[] = $event->existingId;
|
||||
++$result['cancelled'];
|
||||
break;
|
||||
|
||||
case PlannedEvent::UNCHANGED:
|
||||
$this->connection->update('tl_calendar_events', ['wkImported' => $now], ['id' => $event->existingId]);
|
||||
++$result['unchanged'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$tags = ['contao.db.tl_calendar_events', 'contao.db.tl_calendar.'.$calendar->id];
|
||||
|
||||
foreach (array_unique($touchedIds) as $id) {
|
||||
$tags[] = 'contao.db.tl_calendar_events.'.$id;
|
||||
}
|
||||
|
||||
$this->cacheTagManager->invalidateTags($tags);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{created: int, updated: int, cancelled: int, unchanged: int, members: int} $result
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSet(PlannedEvent $event, CalendarConfig $calendar, int $now, array &$result): array
|
||||
{
|
||||
$set = array_map(static fn (mixed $v): mixed => \is_bool($v) ? (int) $v : $v, $event->data);
|
||||
$set['tstamp'] = $now;
|
||||
$set['wkImported'] = $now;
|
||||
$set['wkCategories'] = serialize(array_map(strval(...), $event->categories));
|
||||
$set['wkCategoryPrimary'] = $event->primaryCategory;
|
||||
|
||||
$instructorId = 0;
|
||||
|
||||
if ('' !== $event->instructorName) {
|
||||
[$instructorId, $created] = $calendar->instructorGroup > 0
|
||||
? $this->memberResolver->resolve($event->instructorName, $calendar->instructorGroup)
|
||||
: [$this->memberResolver->findId($event->instructorName), false];
|
||||
|
||||
if ($created) {
|
||||
++$result['members'];
|
||||
}
|
||||
}
|
||||
|
||||
$set['wkInstructor'] = (int) $instructorId;
|
||||
|
||||
$speakerIds = [];
|
||||
|
||||
foreach ($event->speakerNames as $name) {
|
||||
[$id, $created] = $calendar->speakerGroup > 0
|
||||
? $this->memberResolver->resolve($name, $calendar->speakerGroup)
|
||||
: [$this->memberResolver->findId($name), false];
|
||||
|
||||
if ($created) {
|
||||
++$result['members'];
|
||||
}
|
||||
|
||||
if (null !== $id && $id > 0) {
|
||||
$speakerIds[] = (string) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$set['wkSpeakers'] = serialize($speakerIds);
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
private function generateAlias(string $title, CalendarConfig $calendar): string
|
||||
{
|
||||
return $this->slug->generate(
|
||||
$title,
|
||||
$calendar->jumpTo,
|
||||
fn (string $alias): bool => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE alias = ?', [$alias]) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function eventsExist(array $ids): bool
|
||||
{
|
||||
if ([] === $ids) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]) === \count(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
final readonly class ImportMessage
|
||||
{
|
||||
public const string ERROR = 'error';
|
||||
public const string WARNING = 'warning';
|
||||
public const string INFO = 'info';
|
||||
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public string $text,
|
||||
public string $wkId = '',
|
||||
public int $line = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function error(string $text, string $wkId = '', int $line = 0): self
|
||||
{
|
||||
return new self(self::ERROR, $text, $wkId, $line);
|
||||
}
|
||||
|
||||
public static function warning(string $text, string $wkId = '', int $line = 0): self
|
||||
{
|
||||
return new self(self::WARNING, $text, $wkId, $line);
|
||||
}
|
||||
|
||||
public static function info(string $text, string $wkId = '', int $line = 0): self
|
||||
{
|
||||
return new self(self::INFO, $text, $wkId, $line);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, text: string, wkId: string, line: int}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return ['type' => $this->type, 'text' => $this->text, 'wkId' => $this->wkId, 'line' => $this->line];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type?: string, text?: string, wkId?: string, line?: int} $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self((string) ($data['type'] ?? self::INFO), (string) ($data['text'] ?? ''), (string) ($data['wkId'] ?? ''), (int) ($data['line'] ?? 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
/**
|
||||
* Ergebnis der Vorschau: alle geplanten Änderungen plus Meldungen.
|
||||
*/
|
||||
final class ImportPlan
|
||||
{
|
||||
/**
|
||||
* @param list<PlannedEvent> $events
|
||||
* @param list<ImportMessage> $messages Meldungen, die keiner Zeile zugeordnet sind
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $calendarId,
|
||||
public readonly array $events,
|
||||
public readonly array $messages = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<PlannedEvent>
|
||||
*/
|
||||
public function byAction(string $action): array
|
||||
{
|
||||
return array_values(array_filter($this->events, static fn (PlannedEvent $e): bool => $e->action === $action));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function summary(): array
|
||||
{
|
||||
$summary = [
|
||||
PlannedEvent::CREATE => 0,
|
||||
PlannedEvent::UPDATE => 0,
|
||||
PlannedEvent::UNCHANGED => 0,
|
||||
PlannedEvent::SKIP => 0,
|
||||
PlannedEvent::CANCEL => 0,
|
||||
'errors' => 0,
|
||||
'warnings' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->events as $event) {
|
||||
++$summary[$event->action];
|
||||
|
||||
foreach ($event->messages as $message) {
|
||||
if (ImportMessage::ERROR === $message->type) {
|
||||
++$summary['errors'];
|
||||
} elseif (ImportMessage::WARNING === $message->type) {
|
||||
++$summary['warnings'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->messages as $message) {
|
||||
if (ImportMessage::ERROR === $message->type) {
|
||||
++$summary['errors'];
|
||||
} elseif (ImportMessage::WARNING === $message->type) {
|
||||
++$summary['warnings'];
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportMessage>
|
||||
*/
|
||||
public function allMessages(): array
|
||||
{
|
||||
$messages = $this->messages;
|
||||
|
||||
foreach ($this->events as $event) {
|
||||
foreach ($event->messages as $message) {
|
||||
$messages[] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function hasWrites(): bool
|
||||
{
|
||||
return array_any($this->events, static fn (PlannedEvent $e): bool => $e->isWrite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\Category;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
|
||||
/**
|
||||
* Vergleicht die W&K-Datei mit dem Bestand und erstellt die Vorschau (nichts wird geschrieben).
|
||||
*/
|
||||
class ImportPlanner
|
||||
{
|
||||
/**
|
||||
* Felder, die verglichen werden (alle übrigen Spalten bleiben beim Update unangetastet).
|
||||
*/
|
||||
public const array COMPARED_FIELDS = [
|
||||
'title', 'startDate', 'endDate', 'addTime', 'startTime', 'endTime', 'location', 'teaser',
|
||||
'wkState', 'wkSubTitle', 'wkSubTitleAdditional', 'wkTopics', 'wkAdditionalDates', 'wkTags',
|
||||
'wkAudience', 'wkCategoryText', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft',
|
||||
'wkOrganizer', 'wkOrganizerAdditional', 'wkOrganizerSalutation', 'wkOrganizerAcademicTitle',
|
||||
'wkOrganizerLastname', 'wkOrganizerFirstname', 'wkOrganizerStreet', 'wkOrganizerCountryCode',
|
||||
'wkOrganizerPostal', 'wkOrganizerCity', 'wkOrganizerEmail',
|
||||
'wkPrice1', 'wkPriceType1', 'wkPrice2', 'wkPriceType2', 'wkPrice3', 'wkPriceType3',
|
||||
'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly RowMapper $rowMapper,
|
||||
private readonly CategoryRepository $categoryRepository,
|
||||
private readonly CategoryResolver $categoryResolver,
|
||||
private readonly MemberResolver $memberResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
public function plan(CsvTable $table, CalendarConfig $calendar): ImportPlan
|
||||
{
|
||||
$missing = $table->missingColumns(RowMapper::REQUIRED_COLUMNS);
|
||||
|
||||
if ([] !== $missing) {
|
||||
throw new ImportException(\sprintf('Die Datei ist kein W&K-Seminarexport: Die Spalten %s fehlen.', implode(', ', $missing)));
|
||||
}
|
||||
|
||||
$categories = $this->categoryRepository->findAll();
|
||||
$existing = $this->loadExistingEvents($calendar->id);
|
||||
$messages = $table->warnings;
|
||||
$events = [];
|
||||
$seen = [];
|
||||
|
||||
if ([] === $categories) {
|
||||
$messages[] = ImportMessage::warning('Es sind noch keine W&K-Kategorien angelegt. Alle Seminare würden mangels Kategorie storniert; bitte zuerst die Kategorien pflegen.');
|
||||
}
|
||||
|
||||
foreach ($table->rows as $row) {
|
||||
$record = (int) ($row['__record'] ?? 0);
|
||||
unset($row['__record']);
|
||||
|
||||
$wkRow = $this->rowMapper->map($row, $record);
|
||||
|
||||
if ($wkRow->skipped) {
|
||||
$events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, (string) ($row['kbtitel'] ?? ''), 0, [], [], [], 0, '', [], $wkRow->messages, $record);
|
||||
$seen[$wkRow->wkId] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seen[$wkRow->wkId])) {
|
||||
$events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, $wkRow->data['title'], 0, [], [], [], 0, '', [], [
|
||||
ImportMessage::error(\sprintf('Seminar %s „%s“: Die W&K-Nummer kommt in der Datei mehrfach vor; nur der erste Datensatz wird verwendet.', $wkRow->wkId, $wkRow->data['title']), $wkRow->wkId, $record),
|
||||
], $record);
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$wkRow->wkId] = true;
|
||||
$events[] = $this->planRow($wkRow, $existing[$wkRow->wkId] ?? null, $categories, $calendar);
|
||||
}
|
||||
|
||||
// Seminare, die nicht mehr in der Datei stehen, werden storniert
|
||||
foreach ($existing as $wkId => $row) {
|
||||
if (isset($seen[$wkId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (WkState::isCancelled($row['wkState']) && !$row['published']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = new PlannedEvent(PlannedEvent::CANCEL, (string) $wkId, (string) $row['title'], (int) $row['id'], [], [
|
||||
'wkState' => [(string) $row['wkState'], WkState::CANCELLED],
|
||||
'published' => [(bool) $row['published'], false],
|
||||
], [], 0, '', [], [
|
||||
ImportMessage::info(\sprintf('Seminar %s „%s“ steht nicht mehr in der W&K-Datei und wird storniert und ausgeblendet.', $wkId, $row['title']), (string) $wkId),
|
||||
]);
|
||||
}
|
||||
|
||||
return new ImportPlan($calendar->id, $events, $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $existing
|
||||
* @param list<Category> $categories
|
||||
*/
|
||||
private function planRow(WkRow $row, array|null $existing, array $categories, CalendarConfig $calendar): PlannedEvent
|
||||
{
|
||||
$data = $row->data;
|
||||
$messages = $row->messages;
|
||||
$wkId = $row->wkId;
|
||||
$title = (string) $data['title'];
|
||||
|
||||
// Kategorien
|
||||
$resolved = $this->categoryResolver->resolve($row->categoryText, $categories);
|
||||
$categoryIds = array_map(static fn (Category $c): int => $c->id, $resolved);
|
||||
$primary = $categoryIds[0] ?? 0;
|
||||
|
||||
if ([] === $resolved) {
|
||||
$messages[] = ImportMessage::error('' === trim($row->categoryText)
|
||||
? \sprintf('Seminar %s „%s“: Das Feld kbsgebiet enthält keinen Kategorienamen. Das Seminar wird storniert und ausgeblendet.', $wkId, $title)
|
||||
: \sprintf('Seminar %s „%s“: Für „%s“ (kbsgebiet) gibt es keine W&K-Kategorie. Das Seminar wird storniert und ausgeblendet.', $wkId, $title, $row->categoryText), $wkId, $row->line);
|
||||
$data['wkState'] = WkState::CANCELLED;
|
||||
}
|
||||
|
||||
$cancelled = WkState::isCancelled($data['wkState']);
|
||||
|
||||
// Personen (nur nachschlagen; angelegt wird beim Anwenden)
|
||||
$instructorId = '' !== $row->instructorName ? $this->memberResolver->findId($row->instructorName) : null;
|
||||
$speakerIds = [];
|
||||
|
||||
foreach ($row->speakerNames as $name) {
|
||||
$speakerIds[] = $this->memberResolver->findId($name);
|
||||
}
|
||||
|
||||
if (null === $existing) {
|
||||
$data['published'] = !$cancelled;
|
||||
|
||||
if ('' !== $row->instructorName && null === $instructorId && $calendar->instructorGroup < 1) {
|
||||
$messages[] = ImportMessage::info(\sprintf('Seminar %s „%s“: Kursleitung „%s“ ist kein Mitglied; ohne Mitgliedergruppe im Kalender wird kein Mitglied angelegt.', $wkId, $title, $row->instructorName), $wkId, $row->line);
|
||||
}
|
||||
|
||||
return new PlannedEvent(PlannedEvent::CREATE, $wkId, $title, 0, $data, [], $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line);
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
|
||||
foreach (self::COMPARED_FIELDS as $field) {
|
||||
if (!\array_key_exists($field, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$old = $this->normalize($field, $existing[$field] ?? null);
|
||||
$new = $this->normalize($field, $data[$field]);
|
||||
|
||||
if ($old !== $new) {
|
||||
$changes[$field] = [$existing[$field] ?? null, $data[$field]];
|
||||
}
|
||||
}
|
||||
|
||||
// Veröffentlichung folgt nur einem Statuswechsel, damit manuelles Ausblenden erhalten bleibt
|
||||
$wasCancelled = WkState::isCancelled($existing['wkState']);
|
||||
$published = (bool) $existing['published'];
|
||||
|
||||
if ($cancelled && $published) {
|
||||
$data['published'] = false;
|
||||
$changes['published'] = [true, false];
|
||||
} elseif (!$cancelled && $wasCancelled && !$published) {
|
||||
$data['published'] = true;
|
||||
$changes['published'] = [false, true];
|
||||
}
|
||||
|
||||
$existingCategories = array_map(intval(...), StringUtil::deserialize($existing['wkCategories'] ?? null, true));
|
||||
sort($existingCategories);
|
||||
$sortedNew = $categoryIds;
|
||||
sort($sortedNew);
|
||||
|
||||
if ($existingCategories !== $sortedNew) {
|
||||
$changes['wkCategories'] = [$existingCategories, $categoryIds];
|
||||
}
|
||||
|
||||
if ((int) $existing['wkCategoryPrimary'] !== $primary) {
|
||||
$changes['wkCategoryPrimary'] = [(int) $existing['wkCategoryPrimary'], $primary];
|
||||
}
|
||||
|
||||
$existingInstructor = (int) $existing['wkInstructor'];
|
||||
|
||||
if (('' !== $row->instructorName || $existingInstructor > 0) && $existingInstructor !== (int) $instructorId) {
|
||||
$changes['wkInstructor'] = [$this->memberName($existingInstructor), $row->instructorName];
|
||||
}
|
||||
|
||||
$existingSpeakers = array_map(intval(...), StringUtil::deserialize($existing['wkSpeakers'] ?? null, true));
|
||||
|
||||
if ($existingSpeakers !== array_map(static fn (int|null $id): int => (int) $id, $speakerIds) && ([] !== $existingSpeakers || [] !== $row->speakerNames)) {
|
||||
$changes['wkSpeakers'] = [implode(', ', array_values($this->memberResolver->namesByIds($existingSpeakers))), implode(', ', $row->speakerNames)];
|
||||
}
|
||||
|
||||
$action = [] === $changes ? PlannedEvent::UNCHANGED : PlannedEvent::UPDATE;
|
||||
|
||||
return new PlannedEvent($action, $wkId, $title, (int) $existing['id'], $data, $changes, $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> W&K-Nummer => Zeile
|
||||
*/
|
||||
private function loadExistingEvents(int $calendarId): array
|
||||
{
|
||||
$columns = array_unique(['id', 'wkId', 'published', 'wkCategories', 'wkCategoryPrimary', 'wkInstructor', 'wkSpeakers', ...self::COMPARED_FIELDS]);
|
||||
$sql = \sprintf('SELECT %s FROM tl_calendar_events WHERE pid = ? AND wkId != "" ORDER BY id', implode(', ', array_map(static fn (string $c): string => "`$c`", $columns)));
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($this->connection->fetchAllAssociative($sql, [$calendarId]) as $row) {
|
||||
// Bei Dubletten im Bestand gewinnt der älteste Datensatz
|
||||
$events[(string) $row['wkId']] ??= $row;
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function normalize(string $field, mixed $value): string
|
||||
{
|
||||
if (str_starts_with($field, 'wkPrice') && !str_starts_with($field, 'wkPriceType')) {
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
if (\in_array($field, ['startDate', 'endDate', 'startTime', 'endTime', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft'], true)) {
|
||||
return (string) (int) $value;
|
||||
}
|
||||
|
||||
if ('addTime' === $field) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\r\n", "\n", (string) $value));
|
||||
}
|
||||
|
||||
private function memberName(int $id): string
|
||||
{
|
||||
if ($id < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->memberResolver->namesByIds([$id])[$id] ?? (string) $id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\SyncLog;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Ablauf: Datei ablegen und Vorschau berechnen, dann nach Bestätigung anwenden. Die Vorschau wird beim
|
||||
* Anwenden aus derselben Datei neu berechnet, damit zwischenzeitliche Änderungen im Bestand berücksichtigt werden.
|
||||
*/
|
||||
class ImportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CsvReader $csvReader,
|
||||
private readonly ImportPlanner $planner,
|
||||
private readonly ImportExecutor $executor,
|
||||
private readonly SyncFileStorage $storage,
|
||||
private readonly SyncLog $log,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly LoggerInterface|null $contaoGeneralLogger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Vorschau für eine abgelegte Datei und legt den Protokolleintrag an.
|
||||
*
|
||||
* @return array{logId: int, plan: ImportPlan}
|
||||
*/
|
||||
public function prepare(string $relativePath, string $filename, int $calendarId, int $userId): array
|
||||
{
|
||||
$calendar = $this->requireCalendar($calendarId);
|
||||
$plan = $this->planFile($relativePath, $calendar);
|
||||
|
||||
$logId = $this->log->create(
|
||||
SyncLog::TYPE_IMPORT,
|
||||
SyncLog::STATUS_PREVIEW,
|
||||
$userId,
|
||||
$calendarId,
|
||||
$filename,
|
||||
$relativePath,
|
||||
['plan' => $plan->summary(), 'rows' => \count($plan->events)],
|
||||
array_map(static fn (ImportMessage $m): array => $m->toArray(), $plan->allMessages()),
|
||||
);
|
||||
|
||||
return ['logId' => $logId, 'plan' => $plan];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau eines Protokolleintrags neu berechnen.
|
||||
*
|
||||
* @return array{log: array<string, mixed>, plan: ImportPlan, calendar: CalendarConfig}
|
||||
*/
|
||||
public function preview(int $logId): array
|
||||
{
|
||||
$log = $this->requireLog($logId);
|
||||
$calendar = $this->requireCalendar((int) $log['calendar']);
|
||||
|
||||
return ['log' => $log, 'plan' => $this->planFile((string) $log['path'], $calendar), 'calendar' => $calendar];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{plan: ImportPlan, result: array{created: int, updated: int, cancelled: int, unchanged: int, members: int}}
|
||||
*/
|
||||
public function apply(int $logId, int $userId): array
|
||||
{
|
||||
$log = $this->requireLog($logId);
|
||||
|
||||
if (SyncLog::STATUS_PREVIEW !== $log['status']) {
|
||||
throw new ImportException('Dieser Import wurde bereits angewendet oder ist fehlgeschlagen.');
|
||||
}
|
||||
|
||||
$calendar = $this->requireCalendar((int) $log['calendar']);
|
||||
$plan = $this->planFile((string) $log['path'], $calendar);
|
||||
|
||||
try {
|
||||
$result = $this->executor->execute($plan, $calendar, $userId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log->update($logId, [
|
||||
'status' => SyncLog::STATUS_FAILED,
|
||||
'user' => $userId,
|
||||
'messages' => [ImportMessage::error('Der Import ist fehlgeschlagen: '.$e->getMessage())->toArray()],
|
||||
]);
|
||||
|
||||
$this->contaoGeneralLogger?->error(\sprintf('W&K-Import #%d fehlgeschlagen: %s', $logId, $e->getMessage()));
|
||||
|
||||
throw new ImportException('Der Import ist fehlgeschlagen. Es wurden keine Änderungen gespeichert. ('.$e->getMessage().')', 0, $e);
|
||||
}
|
||||
|
||||
$this->log->update($logId, [
|
||||
'status' => SyncLog::STATUS_APPLIED,
|
||||
'user' => $userId,
|
||||
'summary' => ['plan' => $plan->summary(), 'result' => $result, 'rows' => \count($plan->events)],
|
||||
'messages' => array_map(static fn (ImportMessage $m): array => $m->toArray(), $plan->allMessages()),
|
||||
]);
|
||||
|
||||
$this->contaoGeneralLogger?->info(\sprintf(
|
||||
'W&K-Import #%d in Kalender "%s": %d neu, %d geändert, %d storniert, %d unverändert.',
|
||||
$logId, $calendar->title, $result['created'], $result['updated'], $result['cancelled'], $result['unchanged'],
|
||||
));
|
||||
|
||||
return ['plan' => $plan, 'result' => $result];
|
||||
}
|
||||
|
||||
public function planFile(string $relativePath, CalendarConfig $calendar): ImportPlan
|
||||
{
|
||||
if (!$this->storage->exists($relativePath)) {
|
||||
throw new ImportException('Die Importdatei ist nicht mehr vorhanden. Bitte die Datei erneut hochladen.');
|
||||
}
|
||||
|
||||
$table = $this->csvReader->read($this->storage->absolutePath($relativePath));
|
||||
|
||||
return $this->planner->plan($table, $calendar);
|
||||
}
|
||||
|
||||
private function requireCalendar(int $calendarId): CalendarConfig
|
||||
{
|
||||
$calendar = $this->calendars->find($calendarId);
|
||||
|
||||
if (null === $calendar || !$calendar->enabled) {
|
||||
throw new ImportException('Der Kalender ist nicht für den W&K-Sync freigegeben.');
|
||||
}
|
||||
|
||||
return $calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requireLog(int $logId): array
|
||||
{
|
||||
$log = $this->log->find($logId);
|
||||
|
||||
if (null === $log || SyncLog::TYPE_IMPORT !== $log['type']) {
|
||||
throw new ImportException('Der Importvorgang wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Kursleiter und Referenten aus W&K als Mitglieder (tl_member) führen. Die Zuordnung erfolgt wie bisher
|
||||
* über den Nachnamen; W&K liefert meist „Vorname Nachname“ in einem Feld.
|
||||
*/
|
||||
class MemberResolver
|
||||
{
|
||||
/** @var array<string, int|null> */
|
||||
private array $lookup = [];
|
||||
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* ID eines vorhandenen Mitglieds mit diesem Namen, ohne es anzulegen.
|
||||
*/
|
||||
public function findId(string $name): int|null
|
||||
{
|
||||
$name = trim($name);
|
||||
|
||||
if ('' === $name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = mb_strtolower($name);
|
||||
|
||||
if (!\array_key_exists($key, $this->lookup)) {
|
||||
$id = $this->connection->fetchOne('SELECT id FROM tl_member WHERE lastname = ? ORDER BY id LIMIT 1', [$name]);
|
||||
$this->lookup[$key] = false === $id ? null : (int) $id;
|
||||
}
|
||||
|
||||
return $this->lookup[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* ID des Mitglieds; legt es bei Bedarf an und ergänzt die Mitgliedergruppe.
|
||||
*
|
||||
* @return array{0: int|null, 1: bool} ID und ob neu angelegt
|
||||
*/
|
||||
public function resolve(string $name, int $groupId): array
|
||||
{
|
||||
$name = trim($name);
|
||||
|
||||
if ('' === $name) {
|
||||
return [null, false];
|
||||
}
|
||||
|
||||
$id = $this->findId($name);
|
||||
$created = false;
|
||||
|
||||
if (null === $id) {
|
||||
$this->connection->insert('tl_member', [
|
||||
'tstamp' => time(),
|
||||
'dateAdded' => time(),
|
||||
'firstname' => '',
|
||||
'lastname' => $name,
|
||||
'groups' => serialize($groupId > 0 ? [(string) $groupId] : []),
|
||||
]);
|
||||
|
||||
$id = (int) $this->connection->lastInsertId();
|
||||
$this->lookup[mb_strtolower($name)] = $id;
|
||||
$created = true;
|
||||
} elseif ($groupId > 0) {
|
||||
$this->ensureGroup($id, $groupId);
|
||||
}
|
||||
|
||||
return [$id, $created];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return array<int, string> ID => Name
|
||||
*/
|
||||
public function namesByIds(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map(intval(...), $ids)));
|
||||
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllKeyValue('SELECT id, TRIM(CONCAT(lastname, " ", firstname)) FROM tl_member WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]);
|
||||
|
||||
return array_map(strval(...), array_combine(array_map(intval(...), array_keys($rows)), array_values($rows)));
|
||||
}
|
||||
|
||||
private function ensureGroup(int $memberId, int $groupId): void
|
||||
{
|
||||
$groups = StringUtil::deserialize($this->connection->fetchOne('SELECT `groups` FROM tl_member WHERE id = ?', [$memberId]), true);
|
||||
$groups = array_map(strval(...), $groups);
|
||||
|
||||
if (\in_array((string) $groupId, $groups, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$groups[] = (string) $groupId;
|
||||
$this->connection->update('tl_member', ['groups' => serialize(array_values($groups)), 'tstamp' => time()], ['id' => $memberId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
/**
|
||||
* Geplante Änderung an einer Veranstaltung.
|
||||
*/
|
||||
final class PlannedEvent
|
||||
{
|
||||
public const string CREATE = 'create';
|
||||
public const string UPDATE = 'update';
|
||||
public const string UNCHANGED = 'unchanged';
|
||||
public const string SKIP = 'skip';
|
||||
public const string CANCEL = 'cancel';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Zielwerte (Spalten von tl_calendar_events)
|
||||
* @param array<string, array{0: mixed, 1: mixed}> $changes Geänderte Felder: Name => [alt, neu]
|
||||
* @param list<int> $categories IDs der zugeordneten Kategorien
|
||||
* @param list<string> $speakerNames
|
||||
* @param list<ImportMessage> $messages
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $action,
|
||||
public readonly string $wkId,
|
||||
public readonly string $title,
|
||||
public readonly int $existingId = 0,
|
||||
public readonly array $data = [],
|
||||
public readonly array $changes = [],
|
||||
public readonly array $categories = [],
|
||||
public readonly int $primaryCategory = 0,
|
||||
public readonly string $instructorName = '',
|
||||
public readonly array $speakerNames = [],
|
||||
public array $messages = [],
|
||||
public readonly int $line = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isWrite(): bool
|
||||
{
|
||||
return \in_array($this->action, [self::CREATE, self::UPDATE, self::CANCEL], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Contao\Validator;
|
||||
|
||||
/**
|
||||
* Ordnet die Spalten der W&K-Datei den Feldern von tl_calendar_events zu und normalisiert die Werte.
|
||||
*/
|
||||
class RowMapper
|
||||
{
|
||||
/**
|
||||
* W&K-Spalte => Feld in tl_calendar_events (einfache Textfelder).
|
||||
*/
|
||||
public const array TEXT_COLUMNS = [
|
||||
'kbkdab' => 'wkOrganizer',
|
||||
'kbkdab2' => 'wkOrganizerAdditional',
|
||||
'kbkdan' => 'wkOrganizerSalutation',
|
||||
'kbkdtitel' => 'wkOrganizerAcademicTitle',
|
||||
'kbkdn1' => 'wkOrganizerLastname',
|
||||
'kbkdn2' => 'wkOrganizerFirstname',
|
||||
'kbkdstr' => 'wkOrganizerStreet',
|
||||
'kbkdlkz' => 'wkOrganizerCountryCode',
|
||||
'kbkdplz' => 'wkOrganizerPostal',
|
||||
'kbkdort' => 'wkOrganizerCity',
|
||||
'kbtitel' => 'title',
|
||||
'kbutitel' => 'wkSubTitle',
|
||||
'kbrtitel' => 'wkSubTitleAdditional',
|
||||
'kbztermin' => 'wkAdditionalDates',
|
||||
'kbktxp1' => 'wkPriceType1',
|
||||
'kbktxp2' => 'wkPriceType2',
|
||||
'kbktxp3' => 'wkPriceType3',
|
||||
'kbktxp4' => 'wkPriceType4',
|
||||
'kbktxp5' => 'wkPriceType5',
|
||||
'kbktxp6' => 'wkPriceType6',
|
||||
'kbvort' => 'location',
|
||||
'kbgruppe' => 'wkAudience',
|
||||
'kbsgebiet' => 'wkCategoryText',
|
||||
];
|
||||
|
||||
public const array PRICE_COLUMNS = [
|
||||
'kbkp1' => 'wkPrice1',
|
||||
'kbkp2' => 'wkPrice2',
|
||||
'kbkp3' => 'wkPrice3',
|
||||
'kbkp4' => 'wkPrice4',
|
||||
'kbkp5' => 'wkPrice5',
|
||||
'kbkp6' => 'wkPrice6',
|
||||
];
|
||||
|
||||
/**
|
||||
* Spalten, ohne die die Datei nicht als W&K-Export erkannt wird.
|
||||
*/
|
||||
public const array REQUIRED_COLUMNS = ['kbnr', 'kbtitel', 'kbanreise', 'kbabreise', 'kbstatus1', 'kbsgebiet', 'kbvsnr'];
|
||||
|
||||
public const string COLUMN_ID = 'kbnr';
|
||||
public const string COLUMN_START_DATE = 'kbanreise';
|
||||
public const string COLUMN_END_DATE = 'kbabreise';
|
||||
public const string COLUMN_START_TIME = 'kbuanreise';
|
||||
public const string COLUMN_END_TIME = 'kbuabreise';
|
||||
public const string COLUMN_STATE = 'kbstatus1';
|
||||
public const string COLUMN_TAGS = 'kbwmemo';
|
||||
public const string COLUMN_TEASER = 'kbpmemo';
|
||||
public const string COLUMN_TOPICS = 'kbzusatz';
|
||||
public const string COLUMN_INSTRUCTOR = 'kbkln1';
|
||||
public const string COLUMN_MAX_PLACES = 'kbanzmax';
|
||||
public const string COLUMN_RESERVED = 'kbanztat';
|
||||
public const string COLUMN_INTERNAL_NUMBER = 'kbvsnr';
|
||||
public const string COLUMN_SPEAKERS = 'kbmref';
|
||||
public const string COLUMN_EMAIL = 'kbkdemail';
|
||||
public const string COLUMN_EVENT_TYPE = 'kbvsart';
|
||||
|
||||
public function __construct(private readonly TextFormatter $textFormatter = new TextFormatter())
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $row
|
||||
*/
|
||||
public function map(array $row, int $record): WkRow
|
||||
{
|
||||
$get = static fn (string $column): string => trim((string) ($row[$column] ?? ''));
|
||||
|
||||
$wkId = $get(self::COLUMN_ID);
|
||||
$title = $get('kbtitel');
|
||||
$messages = [];
|
||||
|
||||
$skip = function (string $text, string $type = ImportMessage::ERROR) use ($wkId, $record, &$messages): WkRow {
|
||||
$messages[] = new ImportMessage($type, $text, $wkId, $record);
|
||||
|
||||
return new WkRow($record, $wkId, [], '', '', [], $messages, true);
|
||||
};
|
||||
|
||||
if ('' === $wkId || !preg_match('/^0\d+$/', $wkId)) {
|
||||
return $skip(\sprintf('Datensatz %d: Die W&K-Nummer „%s“ fehlt oder beginnt nicht mit einer führenden Null. Das Seminar wird nicht importiert.', $record, $wkId));
|
||||
}
|
||||
|
||||
if ('' === $get(self::COLUMN_INTERNAL_NUMBER)) {
|
||||
return $skip(\sprintf('Seminar %s „%s“: Das Feld kbvsnr ist leer, das Seminar wird nicht veröffentlicht (übersprungen).', $wkId, $title), ImportMessage::INFO);
|
||||
}
|
||||
|
||||
if ('' === $title) {
|
||||
return $skip(\sprintf('Seminar %s: Der Titel (kbtitel) ist leer. Das Seminar wird nicht importiert.', $wkId));
|
||||
}
|
||||
|
||||
$startDate = $this->parseDate($get(self::COLUMN_START_DATE));
|
||||
|
||||
if (null === $startDate) {
|
||||
return $skip(\sprintf('Seminar %s „%s“: Das Beginndatum „%s“ ist ungültig. Das Seminar wird nicht importiert.', $wkId, $title, $get(self::COLUMN_START_DATE)));
|
||||
}
|
||||
|
||||
$endDate = $this->parseDate($get(self::COLUMN_END_DATE));
|
||||
|
||||
if (null === $endDate) {
|
||||
if ('' !== $this->digitsOnly($get(self::COLUMN_END_DATE))) {
|
||||
$messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Das Enddatum „%s“ ist ungültig; das Beginndatum wird verwendet.', $wkId, $title, $get(self::COLUMN_END_DATE)), $wkId, $record);
|
||||
}
|
||||
|
||||
$endDate = $startDate;
|
||||
} elseif ($endDate < $startDate) {
|
||||
$messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Das Enddatum liegt vor dem Beginndatum; das Beginndatum wird verwendet.', $wkId, $title), $wkId, $record);
|
||||
$endDate = $startDate;
|
||||
}
|
||||
|
||||
$startTime = $this->parseTime($get(self::COLUMN_START_TIME));
|
||||
$endTime = $this->parseTime($get(self::COLUMN_END_TIME));
|
||||
|
||||
$data = [
|
||||
'wkId' => $wkId,
|
||||
'title' => $title,
|
||||
];
|
||||
|
||||
foreach (self::TEXT_COLUMNS as $column => $field) {
|
||||
$data[$field] = $get($column);
|
||||
}
|
||||
|
||||
// Datum und Uhrzeit wie Contao selbst (tl_calendar_events::adjustTime)
|
||||
$data['startDate'] = $startDate->getTimestamp();
|
||||
$data['endDate'] = $endDate->getTimestamp();
|
||||
$data['addTime'] = null !== $startTime;
|
||||
|
||||
if (null !== $startTime) {
|
||||
$data['startTime'] = $startDate->setTime($startTime[0], $startTime[1])->getTimestamp();
|
||||
$data['endTime'] = null !== $endTime
|
||||
? $endDate->setTime($endTime[0], $endTime[1])->getTimestamp()
|
||||
: $endDate->setTime($startTime[0], $startTime[1])->getTimestamp();
|
||||
|
||||
if ($data['endTime'] < $data['startTime']) {
|
||||
$data['endTime'] = $data['startTime'];
|
||||
}
|
||||
} else {
|
||||
$data['startTime'] = $startDate->getTimestamp();
|
||||
$data['endTime'] = $endDate->modify('+1 day')->getTimestamp() - 1;
|
||||
}
|
||||
|
||||
// Status
|
||||
$state = $get(self::COLUMN_STATE);
|
||||
|
||||
if (!\in_array($state, WkState::values(), true)) {
|
||||
$messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Unbekannter Status „%s“.', $wkId, $title, $state), $wkId, $record);
|
||||
$state = '';
|
||||
}
|
||||
|
||||
$data['wkState'] = $state;
|
||||
|
||||
// Interne Veranstaltungsnummer: „keine“ bedeutet keine Nummer
|
||||
$internal = $get(self::COLUMN_INTERNAL_NUMBER);
|
||||
$data['wkInternalEventNumber'] = 'keine' === mb_strtolower($internal) ? '' : $internal;
|
||||
|
||||
// Veranstaltungsart und Plätze
|
||||
$data['wkEventType'] = (int) $this->digitsOnly($get(self::COLUMN_EVENT_TYPE));
|
||||
$maxPlaces = (int) $this->digitsOnly($get(self::COLUMN_MAX_PLACES));
|
||||
$reserved = (int) $this->digitsOnly($get(self::COLUMN_RESERVED));
|
||||
$data['wkMaxPlaces'] = $maxPlaces;
|
||||
$data['wkPlacesLeft'] = max(0, $maxPlaces - $reserved);
|
||||
|
||||
// E-Mail des Hauptverantwortlichen
|
||||
$email = $get(self::COLUMN_EMAIL);
|
||||
|
||||
if ('' !== $email && !Validator::isEmail($email) && !self::isAbsoluteUrl($email)) {
|
||||
$messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: „%s“ ist weder eine gültige E-Mail-Adresse noch eine URL; das Feld bleibt leer.', $wkId, $title, $email), $wkId, $record);
|
||||
$email = '';
|
||||
}
|
||||
|
||||
$data['wkOrganizerEmail'] = $email;
|
||||
|
||||
// Preise
|
||||
foreach (self::PRICE_COLUMNS as $column => $field) {
|
||||
$data[$field] = $this->parsePrice($get($column));
|
||||
}
|
||||
|
||||
// Texte
|
||||
$data['teaser'] = $this->textFormatter->paragraphs($get(self::COLUMN_TEASER));
|
||||
$data['wkTopics'] = $this->textFormatter->topics($get(self::COLUMN_TOPICS));
|
||||
$data['wkTags'] = $this->normalizeList($get(self::COLUMN_TAGS));
|
||||
|
||||
// Personen
|
||||
$instructor = $this->cleanName($get(self::COLUMN_INSTRUCTOR));
|
||||
$speakers = [];
|
||||
|
||||
foreach (preg_split('/\r?\n/', $get(self::COLUMN_SPEAKERS)) ?: [] as $speaker) {
|
||||
$speaker = $this->cleanName($speaker);
|
||||
|
||||
if ('' !== $speaker && !\in_array($speaker, $speakers, true)) {
|
||||
$speakers[] = $speaker;
|
||||
}
|
||||
}
|
||||
|
||||
return new WkRow($record, $wkId, $data, $get('kbsgebiet'), $instructor, $speakers, $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Veranstaltungsart 3 verweist über dieses Feld auch auf externe Anmeldeseiten.
|
||||
*/
|
||||
public static function isAbsoluteUrl(string $value): bool
|
||||
{
|
||||
return (bool) preg_match('#^https?://#i', $value) && Validator::isUrl($value);
|
||||
}
|
||||
|
||||
private function parseDate(string $value): \DateTimeImmutable|null
|
||||
{
|
||||
if (!preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/', $value, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!checkdate((int) $m[2], (int) $m[1], (int) $m[3])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new \DateTimeImmutable())->setDate((int) $m[3], (int) $m[2], (int) $m[1])->setTime(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: int}|null Stunde und Minute
|
||||
*/
|
||||
private function parseTime(string $value): array|null
|
||||
{
|
||||
if (!preg_match('/^(\d{1,2})[.:](\d{2})$/', $value, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hour = (int) $m[1];
|
||||
$minute = (int) $m[2];
|
||||
|
||||
if ($hour > 23 || $minute > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$hour, $minute];
|
||||
}
|
||||
|
||||
private function parsePrice(string $value): float
|
||||
{
|
||||
$value = str_replace(['€', ' '], '', $value);
|
||||
|
||||
if ('' === $value) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// „1.250,00“ oder „12,50“ oder „12.50“
|
||||
if (str_contains($value, ',')) {
|
||||
$value = str_replace('.', '', $value);
|
||||
$value = str_replace(',', '.', $value);
|
||||
}
|
||||
|
||||
return is_numeric($value) ? round((float) $value, 2) : 0.0;
|
||||
}
|
||||
|
||||
private function normalizeList(string $value): string
|
||||
{
|
||||
$items = array_filter(array_map(trim(...), preg_split('/[,\n]/', $value) ?: []), static fn (string $i): bool => '' !== $i);
|
||||
|
||||
return implode(', ', array_values(array_unique($items)));
|
||||
}
|
||||
|
||||
private function cleanName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? $name);
|
||||
|
||||
if ('' === $name || preg_match('/^n\.\s*n\.?$/iu', $name)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function digitsOnly(string $value): string
|
||||
{
|
||||
return preg_replace('/\D+/', '', $value) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Import;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Filesystem\Path;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Ablage der Import- und Exportdateien außerhalb des Web-Roots (var/wk-sync).
|
||||
*/
|
||||
class SyncFileStorage
|
||||
{
|
||||
public const string KIND_IMPORT = 'imports';
|
||||
public const string KIND_EXPORT = 'exports';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly Filesystem $filesystem = new Filesystem(),
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeUpload(UploadedFile $file, string $kind = self::KIND_IMPORT): string
|
||||
{
|
||||
$name = $this->buildName($file->getClientOriginalName(), 'csv');
|
||||
$dir = $this->directory($kind);
|
||||
$file->move($dir, $name);
|
||||
|
||||
return $kind.'/'.$name;
|
||||
}
|
||||
|
||||
public function store(string $content, string $kind, string $originalName): string
|
||||
{
|
||||
$name = $this->buildName($originalName, 'csv');
|
||||
$this->filesystem->dumpFile($this->directory($kind).'/'.$name, $content);
|
||||
|
||||
return $kind.'/'.$name;
|
||||
}
|
||||
|
||||
public function absolutePath(string $relativePath): string
|
||||
{
|
||||
$path = Path::canonicalize($this->baseDir().'/'.$relativePath);
|
||||
|
||||
if (!Path::isBasePath($this->baseDir(), $path)) {
|
||||
throw new \InvalidArgumentException('Ungültiger Dateipfad.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function exists(string $relativePath): bool
|
||||
{
|
||||
return '' !== $relativePath && is_file($this->absolutePath($relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Dateien, die älter als die angegebene Anzahl Tage sind.
|
||||
*/
|
||||
public function purge(int $days = 90): int
|
||||
{
|
||||
$deleted = 0;
|
||||
$limit = time() - $days * 86400;
|
||||
|
||||
foreach ([self::KIND_IMPORT, self::KIND_EXPORT] as $kind) {
|
||||
$dir = $this->baseDir().'/'.$kind;
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (new \DirectoryIterator($dir) as $file) {
|
||||
if ($file->isFile() && $file->getMTime() < $limit) {
|
||||
$this->filesystem->remove($file->getPathname());
|
||||
++$deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
private function directory(string $kind): string
|
||||
{
|
||||
if (!\in_array($kind, [self::KIND_IMPORT, self::KIND_EXPORT], true)) {
|
||||
throw new \InvalidArgumentException(\sprintf('Unbekannte Ablage "%s".', $kind));
|
||||
}
|
||||
|
||||
$dir = $this->baseDir().'/'.$kind;
|
||||
$this->filesystem->mkdir($dir, 0o775);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function baseDir(): string
|
||||
{
|
||||
return Path::canonicalize($this->projectDir.'/var/wk-sync');
|
||||
}
|
||||
|
||||
private function buildName(string $originalName, string $extension): string
|
||||
{
|
||||
$base = pathinfo($originalName, PATHINFO_FILENAME);
|
||||
$base = preg_replace('/[^A-Za-z0-9_-]+/', '_', $base) ?? 'datei';
|
||||
$base = trim(substr($base, 0, 60), '_') ?: 'datei';
|
||||
|
||||
return date('Ymd-His').'_'.$base.'.'.$extension;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user