Compare commits
5
Commits
contao-5.7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93baeac8a4 | ||
|
|
11f1ab3d2b | ||
|
|
3aad6aa559 | ||
|
|
07f7b4828a | ||
|
|
4d4f21b7c8 |
No files matched your search
@@ -1,13 +1,13 @@
|
||||
# W&K-Sync Bundle
|
||||
|
||||
Verbindet **Contao 5.7 und 6** mit der Seminarverwaltung **W&K** (Windows-Software ohne Schnittstelle) über CSV-Dateien:
|
||||
Verbindet **Contao 6** 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** oder **6.0**, PHP **8.5**, Doctrine DBAL **4**, MySQL 8 oder MariaDB
|
||||
- Contao **6**, PHP **8.5**, Doctrine DBAL **4**, MySQL 8 oder MariaDB
|
||||
- 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; ohne Notification Center werden Anmeldungen gespeichert und protokolliert, aber keine Mails verschickt (Stand September 2026 unterstützt das Notification Center Contao 6 noch nicht)
|
||||
- Newsletter-Anmeldung (Double-Opt-in) mit dem Newsletter-Bundle, optional
|
||||
@@ -96,12 +96,12 @@ Regeln beim Import:
|
||||
| `kbwmemo` | `wkTags` | Kommagetrennt, dedupliziert |
|
||||
| `kbkln1`, `kbmref` | `wkInstructor`, `wkSpeakers` | Mitglieder nach Nachname; werden nur angelegt, wenn in den Einstellungen Mitgliedergruppen gesetzt sind (die Vorschau kündigt das an). „N.N.“ wird ignoriert |
|
||||
| `kbanzmax`, `kbanztat` | `wkMaxPlaces`, `wkPlacesLeft` | Freie Plätze = max − belegt |
|
||||
| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen |
|
||||
| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen, gespeichert mit zwei Nachkommastellen |
|
||||
| `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. Ein erneuter Import derselben Datei meldet alle Seminare als unverändert. Jeder Import wird protokolliert (W&K-Tools → Protokoll); die Datei liegt unter `var/wk-sync/imports/`. Alte Import- und Exportdateien räumt `vendor/bin/contao-console wk-sync:purge --days=90` auf (z. B. per Cronjob); die Protokolleinträge bleiben erhalten.
|
||||
Das Euro-Zeichen, das W&K als „Ç“ exportiert, wird im Umfeld von Zahlen korrigiert. Aliase werden nur beim Anlegen erzeugt und bleiben danach stabil. Ein erneuter Import derselben Datei meldet alle Seminare als unverändert, auch nachdem eine Veranstaltung im Backend gespeichert wurde: Die Schreibweise von HTML-Entities in Teaser und Themen (`"` statt `"`) und die Reihenfolge der Referenten gelten nicht als Änderung. Jeder Import wird protokolliert (W&K-Tools → Protokoll); die Datei liegt unter `var/wk-sync/imports/`. Alte Import- und Exportdateien räumt `vendor/bin/contao-console wk-sync:purge --days=90` auf (z. B. per Cronjob); die Protokolleinträge bleiben erhalten.
|
||||
|
||||
Auf der Konsole:
|
||||
|
||||
@@ -168,11 +168,19 @@ Alle Unterpunkte von „W&K-Tools“ sind normale Contao-Backend-Module und werd
|
||||
- Verfügbarkeit wird beim Absenden erneut geprüft (Wettlauf um den letzten Platz).
|
||||
- Anmeldungen und Anfragen werden nur mit bekannten Spalten gespeichert.
|
||||
|
||||
## Contao 5.7 und 6
|
||||
## Contao 6
|
||||
|
||||
Ein Stand des Bundles läuft in beiden Versionen. Contao 6 speichert Eingaben unkodiert und kodiert erst bei der Ausgabe, Contao 5 speichert sie kodiert. Die Backend-Oberfläche des Bundles ist komplett in Twig (automatisches Escaping); die wenigen Stellen, die HTML außerhalb von Twig erzeugen (Kategorienliste, Prüfung der Einrichtung, Tooltips der Navigation), kodieren selbst ohne doppeltes Kodieren und geben in Contao 6 ein `RecordLabel` zurück. Der CSV-Export dekodiert gespeicherte Werte, damit unter Contao 5 keine HTML-Entities (z. B. `(` für eine Klammer) in der W&K-Datei landen.
|
||||
Das Bundle setzt Contao 6 voraus und nutzt dessen Konventionen:
|
||||
|
||||
Das Backend-Modul (Import, Posteingang, Exporte) ist ein klassisches `BE_MOD`-Callback-Modul und das Anmeldeformular wird über `Controller::getForm()` gerendert; beides ist in Contao 6 weiterhin unterstützt und nicht als veraltet markiert.
|
||||
- **Output-Encoding:** Contao speichert Eingaben unkodiert. Import, Anmeldungen und Export arbeiten durchgehend mit Klartext, die W&K-Datei enthält also nie HTML-Entities. Die Backend-Oberfläche ist Twig (automatisches Escaping); die Kategorienliste liefert ein kodiertes `RecordLabel`.
|
||||
- **Anzeigefelder:** Die Prüfung der Einrichtung, die Erklärung der Benachrichtigungslogik und der Hinweis bei fehlenden Benachrichtigungen sind `input_field_callback`-Felder ohne Spalte. Sie legen kein virtuelles Feld (`jsonData`) an.
|
||||
- **Backend-Stylesheet:** `public/wk-sync-backend.css` wird über `contao.backend.custom_css` eingebunden (Icon der Navigationsgruppe, Posteingang, Einstellungen).
|
||||
- **Veranstaltungsfelder:** Von den 43 `wk…`-Feldern in `tl_calendar_events` sind 29 reine Anzeigefelder **virtuelle Felder** (Untertitel, Bereich, Zielgruppe, Schlagworte, Zusatztermine, Preise und Preisarten, Veranstalter). Contao speichert sie gemeinsam im JSON-Feld `jsonData` statt in je einer Spalte; das spart rund 13 KB der auf 65 KB begrenzten Zeilengröße. Echte Spalten bleiben alle Felder, mit denen gerechnet, gefiltert oder verknüpft wird (`wkId`, `wkState`, `wkInternalEventNumber`, `wkEventType`, `wkMaxPlaces`, `wkPlacesLeft`, `wkImported`, `wkCategories`, `wkCategoryPrimary`, `wkTopics`, `wkInstructor`, `wkSpeakers`, `wkNotification`, `wkConfirmationPage`). Die Liste steht in `Sync\EventFields`.
|
||||
- Backend, Modelle und Templates (`event.wkSubTitle`, `event.wkPrice1` …) arbeiten unverändert, auch die Backend-Suche findet die Felder.
|
||||
- Eigene SQL-Abfragen lesen ein virtuelles Feld mit `JSON_UNQUOTE(JSON_EXTRACT(jsonData, '$.wkSubTitle'))`. In PHP liefert `Sync\EventRowCodec::expand($row)` die Zeile mit allen Feldern, `combine($set, $bisherigesJson)` bereitet Werte zum Speichern auf.
|
||||
- Bestehende Installationen stellt die Migration `EventFieldsToVirtualMigration` bei `contao:migrate` um: Werte in das JSON-Feld kopieren, Spalten entfernen.
|
||||
- Preise werden als Text mit zwei Nachkommastellen gespeichert (`240.00`). Im Backend sind `240`, `240,50` und `1.240,50` als Eingabe erlaubt.
|
||||
- **Ohne Ersatz im Core:** Das Backend-Modul (Import, Posteingang, Exporte) ist ein `BE_MOD`-Callback-Modul, das Anmeldeformular wird über `Controller::getForm()` gerendert und die Veranstaltung wird über `Input::get('auto_item')` ermittelt. Alle drei sind in Contao 6 nicht als veraltet markiert.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -185,5 +193,5 @@ Die Tests decken CSV-Parsing, Zeilen-Zuordnung, Kategorie-Auflösung, Formularre
|
||||
|
||||
Backend-Bausteine, die sich auch anderswo nutzen lassen:
|
||||
|
||||
- Widget `wk_explanation` (`inputType`) für Erklärtexte in DCA-Formularen; Text aus `eval.text` oder der Sprachdatei des Feldes, Box über `eval.boxClass` (`tl_info`, `tl_confirm`, `tl_error`). Contaos eigener Typ `explanation` existiert nur im Frontend-Formulargenerator.
|
||||
- `BackendMenuListener` hängt das Icon der Navigationsgruppe ein und nimmt die doppelte Escapierung zurück, mit der der Contao-Kern Modulbeschreibungen mit `&` in Tooltips ausgibt.
|
||||
- Anzeigefelder in DCA-Formularen (Prüfung der Einrichtung, Erklärtexte) sind `input_field_callback`-Felder: Feld ohne `inputType` und `sql` mit `eval.doNotShow`, gerendert von einem Listener mit `#[AsCallback(target: 'fields.<feld>.input_field')]`.
|
||||
- Das Backend-Stylesheet samt Icon der Navigationsgruppe wird in `ContaoWkSyncBundle::prependExtension()` über `contao.backend.custom_css` registriert.
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mummert/wk-sync-bundle",
|
||||
"description": "W&K-Sync für Contao 5.7 und 6: Kursimport aus der Seminarverwaltung W&K (CSV), Online-Anmeldungen mit Posteingang und CSV-Export zurück nach W&K",
|
||||
"description": "W&K-Sync für Contao 6: 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": [
|
||||
@@ -13,8 +13,8 @@
|
||||
"php": "^8.5",
|
||||
"ext-iconv": "*",
|
||||
"ext-mbstring": "*",
|
||||
"contao/core-bundle": "^5.7 || ^6.0",
|
||||
"contao/calendar-bundle": "^5.7 || ^6.0",
|
||||
"contao/core-bundle": "^6.0",
|
||||
"contao/calendar-bundle": "^6.0",
|
||||
"doctrine/dbal": "^4.0",
|
||||
"symfony/config": "^7.4 || ^8.0",
|
||||
"symfony/console": "^7.4 || ^8.0",
|
||||
|
||||
@@ -16,8 +16,7 @@ services:
|
||||
- '../src/Import/{ImportPlan,PlannedEvent,ImportMessage,WkRow}.php'
|
||||
- '../src/Booking/{Availability,BookingFields}.php'
|
||||
- '../src/Inbox/{InboxItem,InboxState}.php'
|
||||
- '../src/Sync/CalendarConfig.php'
|
||||
- '../src/Widget/'
|
||||
- '../src/Sync/{CalendarConfig,EventFields}.php'
|
||||
|
||||
# Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt
|
||||
Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NullNotifier'
|
||||
|
||||
@@ -4,18 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
use Contao\ArrayUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Backend\WkSyncModule;
|
||||
use Mummert\ContaoWkSyncBundle\Widget\Backend\ExplanationWidget;
|
||||
|
||||
// Backend-Bereich „W&K-Tools“ direkt nach „Inhalte“
|
||||
ArrayUtil::arrayInsert($GLOBALS['BE_MOD'], 1, [
|
||||
'wk_tools' => [
|
||||
'wk_import' => [
|
||||
'callback' => WkSyncModule::class,
|
||||
'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css',
|
||||
],
|
||||
'wk_inbox' => [
|
||||
'callback' => WkSyncModule::class,
|
||||
'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css',
|
||||
],
|
||||
'wk_bookings' => [
|
||||
'tables' => ['tl_wk_booking'],
|
||||
@@ -25,20 +22,15 @@ ArrayUtil::arrayInsert($GLOBALS['BE_MOD'], 1, [
|
||||
],
|
||||
'wk_exports' => [
|
||||
'callback' => WkSyncModule::class,
|
||||
'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css',
|
||||
],
|
||||
'wk_categories' => [
|
||||
'tables' => ['tl_wk_category'],
|
||||
],
|
||||
'wk_config' => [
|
||||
'tables' => ['tl_wk_config'],
|
||||
'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css',
|
||||
],
|
||||
'wk_log' => [
|
||||
'tables' => ['tl_wk_sync_log'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Erklärungstexte in Backend-Formularen (z. B. Benachrichtigungslogik in den W&K-Einstellungen)
|
||||
$GLOBALS['BE_FFL']['wk_explanation'] = ExplanationWidget::class;
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Mummert\ContaoWkSyncBundle\Import\WkState;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
|
||||
|
||||
@@ -41,11 +42,21 @@ $text = static fn (int $length, string $class = 'w50', array $eval = []): array
|
||||
'sql' => ['type' => 'string', 'length' => $length, 'default' => ''],
|
||||
];
|
||||
|
||||
$price = static fn (): array => [
|
||||
// Reine Anzeigefelder sind virtuelle Felder: keine eigene Spalte, gespeichert im JSON-Feld (siehe EventFields)
|
||||
$virtualText = static fn (string $field, string $class = 'w50'): array => [
|
||||
'exclude' => true,
|
||||
'search' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => EventFields::TEXTS[$field], 'tl_class' => $class],
|
||||
'targetColumn' => EventFields::STORAGE,
|
||||
];
|
||||
|
||||
// Eingabe und Format prüft der PriceFieldListener (Dezimalkomma oder -punkt, zwei Nachkommastellen)
|
||||
$virtualPrice = static fn (): array => [
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'prcnt', 'maxlength' => 10, 'tl_class' => 'w50'],
|
||||
'sql' => "decimal(10,2) NOT NULL default '0.00'",
|
||||
'eval' => ['maxlength' => 13, 'tl_class' => 'w50'],
|
||||
'targetColumn' => EventFields::STORAGE,
|
||||
];
|
||||
|
||||
$fields = [
|
||||
@@ -92,8 +103,8 @@ $fields = [
|
||||
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
],
|
||||
'wkSubTitle' => $text(255, 'clr long'),
|
||||
'wkSubTitleAdditional' => $text(255, 'long'),
|
||||
'wkSubTitle' => $virtualText('wkSubTitle', 'clr long'),
|
||||
'wkSubTitleAdditional' => $virtualText('wkSubTitleAdditional', 'long'),
|
||||
'wkCategories' => [
|
||||
'filter' => true,
|
||||
'exclude' => true,
|
||||
@@ -111,9 +122,9 @@ $fields = [
|
||||
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||
],
|
||||
'wkCategoryText' => $text(255),
|
||||
'wkAudience' => $text(255),
|
||||
'wkTags' => $text(255),
|
||||
'wkCategoryText' => $virtualText('wkCategoryText'),
|
||||
'wkAudience' => $virtualText('wkAudience'),
|
||||
'wkTags' => $virtualText('wkTags'),
|
||||
'wkTopics' => [
|
||||
'search' => true,
|
||||
'exclude' => true,
|
||||
@@ -121,7 +132,7 @@ $fields = [
|
||||
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'wkAdditionalDates' => $text(512, 'clr long'),
|
||||
'wkAdditionalDates' => $virtualText('wkAdditionalDates', 'clr long'),
|
||||
'wkInstructor' => [
|
||||
'filter' => true,
|
||||
'exclude' => true,
|
||||
@@ -138,29 +149,29 @@ $fields = [
|
||||
'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]),
|
||||
'wkPrice1' => $virtualPrice(),
|
||||
'wkPriceType1' => $virtualText('wkPriceType1'),
|
||||
'wkPrice2' => $virtualPrice(),
|
||||
'wkPriceType2' => $virtualText('wkPriceType2'),
|
||||
'wkPrice3' => $virtualPrice(),
|
||||
'wkPriceType3' => $virtualText('wkPriceType3'),
|
||||
'wkPrice4' => $virtualPrice(),
|
||||
'wkPriceType4' => $virtualText('wkPriceType4'),
|
||||
'wkPrice5' => $virtualPrice(),
|
||||
'wkPriceType5' => $virtualText('wkPriceType5'),
|
||||
'wkPrice6' => $virtualPrice(),
|
||||
'wkPriceType6' => $virtualText('wkPriceType6'),
|
||||
'wkOrganizer' => $virtualText('wkOrganizer'),
|
||||
'wkOrganizerAdditional' => $virtualText('wkOrganizerAdditional'),
|
||||
'wkOrganizerSalutation' => $virtualText('wkOrganizerSalutation'),
|
||||
'wkOrganizerAcademicTitle' => $virtualText('wkOrganizerAcademicTitle'),
|
||||
'wkOrganizerFirstname' => $virtualText('wkOrganizerFirstname'),
|
||||
'wkOrganizerLastname' => $virtualText('wkOrganizerLastname'),
|
||||
'wkOrganizerStreet' => $virtualText('wkOrganizerStreet'),
|
||||
'wkOrganizerPostal' => $virtualText('wkOrganizerPostal'),
|
||||
'wkOrganizerCity' => $virtualText('wkOrganizerCity'),
|
||||
'wkOrganizerCountryCode' => $virtualText('wkOrganizerCountryCode'),
|
||||
'wkOrganizerEmail' => $virtualText('wkOrganizerEmail', 'w50'),
|
||||
'wkNotification' => [
|
||||
'exclude' => true,
|
||||
'inputType' => 'select',
|
||||
|
||||
@@ -166,14 +166,14 @@ $GLOBALS['TL_DCA']['tl_wk_booking'] = [
|
||||
'sorting' => true,
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['mandatory' => true, 'rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'eval' => ['mandatory' => true, 'rgxp' => 'email', 'maxlength' => 255, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'phone' => [
|
||||
'search' => true,
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'phone', 'maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'eval' => ['rgxp' => 'phone', 'maxlength' => 64, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'position' => [
|
||||
|
||||
@@ -80,7 +80,7 @@ $GLOBALS['TL_DCA']['tl_wk_category'] = [
|
||||
'search' => true,
|
||||
'exclude' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:80px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'eval' => ['style' => 'height:80px', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -121,7 +121,7 @@ $GLOBALS['TL_DCA']['tl_wk_config'] = [
|
||||
'default' => 'TPI Moritzburg',
|
||||
'exclude' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:60px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'eval' => ['style' => 'height:60px', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'author' => [
|
||||
@@ -165,7 +165,7 @@ $GLOBALS['TL_DCA']['tl_wk_config'] = [
|
||||
'newsletterText' => [
|
||||
'exclude' => true,
|
||||
'inputType' => 'textarea',
|
||||
'eval' => ['style' => 'height:120px', 'decodeEntities' => true, 'tl_class' => 'clr'],
|
||||
'eval' => ['style' => 'height:120px', 'tl_class' => 'clr'],
|
||||
'sql' => ['type' => 'text', 'notnull' => false],
|
||||
],
|
||||
'notificationDefault' => [
|
||||
@@ -177,25 +177,11 @@ $GLOBALS['TL_DCA']['tl_wk_config'] = [
|
||||
],
|
||||
];
|
||||
|
||||
// Text und Box setzt der SetupCheckListener beim Bearbeiten
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields']['setupCheck'] = [
|
||||
'label' => ['', ''],
|
||||
'inputType' => 'wk_explanation',
|
||||
'eval' => ['tl_class' => 'long clr'],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields']['notificationOverview'] = [
|
||||
'label' => ['', ''],
|
||||
'inputType' => 'wk_explanation',
|
||||
'eval' => ['tl_class' => 'long clr'],
|
||||
];
|
||||
|
||||
// Text und Sichtbarkeit setzt der NotificationNoticeListener (nur ohne Benachrichtigungen vom Typ „W&K-Anmeldung“)
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields']['notificationNotice'] = [
|
||||
'label' => ['', ''],
|
||||
'inputType' => 'wk_explanation',
|
||||
'eval' => ['tl_class' => 'long clr', 'boxClass' => 'tl_error'],
|
||||
];
|
||||
// Reine Anzeigefelder ohne Spalte: Die Listener rendern sie per input_field_callback (Prüfung der Einrichtung,
|
||||
// Erklärung der Benachrichtigungslogik, Hinweis bei fehlenden Benachrichtigungen)
|
||||
foreach (['setupCheck', 'notificationOverview', 'notificationNotice'] as $field) {
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields'][$field] = ['eval' => ['doNotShow' => true]];
|
||||
}
|
||||
|
||||
// Bedingung 1 (Online) und 12 (Ausbildung) stehen allein, alle anderen paarweise eintägig/mehrtägig
|
||||
$leftColumn = [1, 2, 3, 4, 8, 10, 12];
|
||||
|
||||
@@ -133,13 +133,13 @@ $GLOBALS['TL_DCA']['tl_wk_inquiry'] = [
|
||||
'search' => true,
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
|
||||
],
|
||||
'phone' => [
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'],
|
||||
'eval' => ['maxlength' => 64, 'tl_class' => 'w50'],
|
||||
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
|
||||
],
|
||||
'subject' => [
|
||||
|
||||
@@ -120,3 +120,5 @@ $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';
|
||||
|
||||
$GLOBALS['TL_LANG']['ERR']['wk_price'] = 'Bitte geben Sie einen gültigen Preis ein, z. B. 240 oder 240,50.';
|
||||
@@ -56,6 +56,8 @@ $lang['notificationOverview'] = '<p><strong>So wird die Benachrichtigung für ei
|
||||
</tbody>
|
||||
</table>';
|
||||
|
||||
$lang['notificationUnavailable'] = 'Das Notification Center ist nicht installiert. Anmeldungen werden gespeichert und im Systemprotokoll vermerkt, es wird aber keine E-Mail verschickt. Sobald das Notification Center installiert ist, lassen sich hier Benachrichtigungen vom Typ „W&K-Anmeldung“ zuordnen.';
|
||||
|
||||
$lang['notificationNotice'] = 'Es ist noch keine Benachrichtigung vom Typ „W&K-Anmeldung“ angelegt, deshalb sind die Auswahlfelder leer. Bitte zuerst unter <a href="%s">Notification Center → Benachrichtigungen</a> eine Benachrichtigung mit diesem Typ anlegen; danach lässt sie sich hier zuordnen.';
|
||||
|
||||
$lang['notification1'] = ['Bedingung 1: Online-Kurs', 'Veranstaltungsort „Online“, alle Kategorie-Arten, ein- oder mehrtägig. Leer = die Bedingung der Kategorie-Art zählt.'];
|
||||
|
||||
@@ -116,3 +116,5 @@ $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';
|
||||
|
||||
$GLOBALS['TL_LANG']['ERR']['wk_price'] = 'Please enter a valid price, e.g. 240 or 240.50.';
|
||||
@@ -56,6 +56,8 @@ $lang['notificationOverview'] = '<p><strong>How the notification for a booking i
|
||||
</tbody>
|
||||
</table>';
|
||||
|
||||
$lang['notificationUnavailable'] = 'The Notification Center is not installed. Bookings are stored and logged, but no e-mail is sent. Once the Notification Center is installed, notifications of the type "W&K booking" can be assigned here.';
|
||||
|
||||
$lang['notificationNotice'] = 'There is no notification of the type "W&K booking" yet, so the selects are empty. Please create one under <a href="%s">Notification Center → Notifications</a> first; it can then be assigned here.';
|
||||
|
||||
$lang['notification1'] = ['Condition 1: online course', 'Location "Online", all category kinds, single or multi-day. Empty = the condition of the category kind applies.'];
|
||||
|
||||
@@ -60,7 +60,9 @@
|
||||
{% 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>
|
||||
{% if row.edit_url %}
|
||||
<a href="{{ row.edit_url }}" class="wk-sync-action" title="{{ 'MSC.wk_sync.inbox.edit'|trans }}">{{ 'MSC.wk_sync.inbox.edit'|trans }}</a>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* Icon der Navigationsgruppe „W&K-Tools“ (Lucide „graduation-cap“, wie die Gruppen-Icons des Contao-Cores) */
|
||||
#tl_navigation .group-wk_tools { background: url(graduation-cap.svg) 3px 2px no-repeat; }
|
||||
[data-color-scheme=dark] #tl_navigation .group-wk_tools { background-image: url(graduation-cap--dark.svg); }
|
||||
|
||||
.wk-sync { padding: 18px 15px; }
|
||||
.wk-sync h2 { margin: 1.5rem 0 .75rem; font-size: 1.1rem; }
|
||||
.wk-sync > h2:first-child, .wk-sync > .tl_message + h2 { margin-top: 0; }
|
||||
@@ -52,7 +56,7 @@
|
||||
.wk-sync-highlight td { background: #fff8e1; }
|
||||
.wk-sync-meta { margin: 0 0 .75rem; }
|
||||
|
||||
/* Erklärungen in Backend-Formularen (Widget wk_explanation) */
|
||||
/* Erklärungen in Backend-Formularen (Anzeigefelder der W&K-Einstellungen) */
|
||||
.wk-sync-explanation p, .wk-sync-explanation ol { margin: 0 0 .6em; }
|
||||
.wk-sync-explanation ol { padding-left: 1.4em; }
|
||||
.wk-sync-explanation li { margin: .2em 0; }
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/* Icon der Navigationsgruppe „W&K-Tools“ (Lucide „graduation-cap“, wie die Gruppen-Icons des Contao-Cores) */
|
||||
#tl_navigation .group-wk_tools { background: url(graduation-cap.svg) 3px 2px no-repeat; }
|
||||
[data-color-scheme=dark] #tl_navigation .group-wk_tools { background-image: url(graduation-cap--dark.svg); }
|
||||
@@ -13,6 +13,7 @@ use Contao\CoreBundle\Exception\RedirectResponseException;
|
||||
use Contao\CoreBundle\Exception\ResponseException;
|
||||
use Contao\CoreBundle\Framework\Adapter;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Security\ContaoCorePermissions;
|
||||
use Contao\Date;
|
||||
use Contao\Message;
|
||||
use Contao\StringUtil;
|
||||
@@ -223,12 +224,18 @@ class WkSyncBackend
|
||||
$items = $this->inbox->findByState($state, $state->isFinal() ? 300 : 0);
|
||||
$referer = (string) $request->attributes->get('_contao_referer_id', '');
|
||||
|
||||
// „Bearbeiten“ führt in die Module Anmeldungen/Kontaktanfragen; ohne Zugriff darauf gäbe es einen 403, also kein Button
|
||||
$canEdit = [
|
||||
true => $this->security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_MODULE, self::MODULE_BOOKINGS),
|
||||
false => $this->security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_MODULE, self::MODULE_INQUIRIES),
|
||||
];
|
||||
|
||||
$decorated = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$decorated[] = [
|
||||
'item' => $item,
|
||||
'edit_url' => $this->editUrl($item, $referer),
|
||||
'edit_url' => $canEdit[$item->isBooking()] ? $this->editUrl($item, $referer) : '',
|
||||
'targets' => $state->moveTargets(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,4 +17,9 @@ interface BookingNotifierInterface
|
||||
* @return array<int, string> ID => Titel der verfügbaren Benachrichtigungen
|
||||
*/
|
||||
public function availableNotifications(): array;
|
||||
|
||||
/**
|
||||
* Ob überhaupt Benachrichtigungen verschickt werden können (Notification Center installiert).
|
||||
*/
|
||||
public function isAvailable(): bool;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ class EventResolver
|
||||
private readonly Connection $connection,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly CategoryRepository $categories,
|
||||
private readonly EventRowCodec $codec = new EventRowCodec(),
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,7 +34,8 @@ class EventResolver
|
||||
$this->framework->initialize();
|
||||
$input = $this->framework->getAdapter(Input::class);
|
||||
|
||||
$identifier = (string) ($input->get('auto_item') ?? $input->get('events') ?? $request->attributes->get('auto_item') ?? '');
|
||||
// Für den dynamischen URL-Parameter gibt es noch keinen Ersatz im Request-Objekt (Input::get('auto_item') ist nicht veraltet)
|
||||
$identifier = (string) ($input->get('auto_item') ?? $request->attributes->get('auto_item') ?? '');
|
||||
|
||||
if ('' === $identifier) {
|
||||
return null;
|
||||
@@ -69,11 +72,11 @@ class EventResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<string, mixed> $row Zeile aus der Datenbank (mit JSON-Feld) oder bereits aufbereitete Daten
|
||||
*/
|
||||
public function fromRow(array $row): BookingEvent
|
||||
{
|
||||
return BookingEvent::fromRow($row, $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
|
||||
return BookingEvent::fromRow($this->codec->expand($row), $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
|
||||
}
|
||||
|
||||
public function kindFor(int $categoryId): CategoryKind
|
||||
|
||||
@@ -51,7 +51,7 @@ final readonly class FieldRules
|
||||
$value = $values[$dependency['field']] ?? '';
|
||||
|
||||
if (\is_array($value)) {
|
||||
$value = (string) reset($value);
|
||||
$value = (string) array_first($value);
|
||||
}
|
||||
|
||||
return \in_array((string) $value, $dependency['values'], true);
|
||||
|
||||
@@ -51,4 +51,9 @@ class NotificationCenterNotifier implements BookingNotifierInterface
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,9 @@ class NullNotifier implements BookingNotifierInterface
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,17 @@ use Terminal42\NotificationCenterBundle\Terminal42NotificationCenterBundle;
|
||||
|
||||
class ContaoWkSyncBundle extends AbstractBundle
|
||||
{
|
||||
/**
|
||||
* Das Backend-Stylesheet (Icon der Navigationsgruppe, Posteingang, Einstellungen) wird über die
|
||||
* Core-Konfiguration contao.backend.custom_css auf allen Backend-Seiten eingebunden.
|
||||
*/
|
||||
public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$builder->prependExtensionConfig('contao', [
|
||||
'backend' => ['custom_css' => ['bundles/contaowksync/wk-sync-backend.css']],
|
||||
]);
|
||||
}
|
||||
|
||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$container->import('../config/services.yaml');
|
||||
|
||||
@@ -8,6 +8,8 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\CoreBundle\Slug\Slug;
|
||||
use Contao\DataContainer;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
#[AsCallback(table: 'tl_wk_category', target: 'fields.alias.save')]
|
||||
class CategoryAliasListener
|
||||
@@ -15,6 +17,8 @@ class CategoryAliasListener
|
||||
public function __construct(
|
||||
private readonly Slug $slug,
|
||||
private readonly Connection $connection,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -24,7 +28,7 @@ class CategoryAliasListener
|
||||
$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);
|
||||
return $this->slug->generate((string) ($dc->getCurrentRecord()['title'] ?? ''), $this->slugOptions(), $exists);
|
||||
}
|
||||
|
||||
if ($exists($value)) {
|
||||
@@ -33,4 +37,21 @@ class CategoryAliasListener
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Weiterleitungsseite des W&K-Kalenders bestimmt Sprache und erlaubte Zeichen des Alias (deutsche
|
||||
* Seiten: „ö“ wird „oe“). Ohne Kalender zählt die Sprache des Backend-Benutzers.
|
||||
*
|
||||
* @return int|array<string, string>
|
||||
*/
|
||||
private function slugOptions(): int|array
|
||||
{
|
||||
foreach ($this->calendars->findEnabled() as $calendar) {
|
||||
if ($calendar->jumpTo > 0) {
|
||||
return $calendar->jumpTo;
|
||||
}
|
||||
}
|
||||
|
||||
return ['locale' => $this->requestStack->getCurrentRequest()?->getLocale() ?? 'de'];
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,20 @@ use Contao\StringUtil;
|
||||
/**
|
||||
* Kategorienliste: die W&K-Bezeichnungen (eine pro Zeile) lesbar getrennt anzeigen.
|
||||
*
|
||||
* Contao 6 behandelt Spaltenwerte aus dem label_callback als Text; HTML muss dort als RecordLabel
|
||||
* zurückgegeben werden. Contao 5.7 kennt die Klasse nicht und gibt die Spalten unverändert aus.
|
||||
* Contao behandelt Spaltenwerte aus dem label_callback als Text; HTML wird als RecordLabel zurückgegeben.
|
||||
*/
|
||||
#[AsCallback(table: 'tl_wk_category', target: 'list.label.label')]
|
||||
class CategoryLabelListener
|
||||
{
|
||||
/**
|
||||
* @param list<string> $args Spaltenwerte in der Reihenfolge von list.label.fields
|
||||
*
|
||||
* @return list<string>|RecordLabel
|
||||
* @param list<string> $args kodierte Spaltenwerte in der Reihenfolge von list.label.fields
|
||||
*/
|
||||
public function __invoke(array $row, string $label, DataContainer $dc, array $args): array|RecordLabel
|
||||
public function __invoke(array $row, string $label, DataContainer $dc, array $args): RecordLabel
|
||||
{
|
||||
$names = array_filter(array_map(trim(...), explode("\n", str_replace("\r", '', (string) ($row['wkNames'] ?? '')))));
|
||||
|
||||
$args[2] = implode('<span class="tl_gray"> · </span>', array_map(static fn (string $name): string => StringUtil::specialchars($name, false, false), $names));
|
||||
$args[2] = implode('<span class="tl_gray"> · </span>', array_map(StringUtil::specialchars(...), $names));
|
||||
|
||||
return class_exists(RecordLabel::class) ? RecordLabel::fromHtml($args) : $args;
|
||||
return RecordLabel::fromHtml($args);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\StringUtil;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Zeigt in den W&K-Einstellungen einen Hinweis, solange es keine Benachrichtigung vom Typ „W&K-Anmeldung“
|
||||
* gibt; sonst wären die Auswahlfelder leer, ohne dass man erfährt, warum.
|
||||
* Anzeigefelder der Legende „Benachrichtigungen“ in den W&K-Einstellungen (input_field_callback, keine
|
||||
* Spalten): die Erklärung der fest hinterlegten Logik und ein Hinweis, solange keine Benachrichtigung
|
||||
* zugeordnet werden kann – sonst wären die Auswahlfelder leer, ohne dass man erfährt, warum.
|
||||
*/
|
||||
#[AsCallback(table: 'tl_wk_config', target: 'config.onload')]
|
||||
class NotificationNoticeListener
|
||||
{
|
||||
public function __construct(
|
||||
@@ -24,23 +24,34 @@ class NotificationNoticeListener
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(): void
|
||||
#[AsCallback(table: 'tl_wk_config', target: 'fields.notificationOverview.input_field')]
|
||||
public function renderOverview(): string
|
||||
{
|
||||
if ([] !== $this->notifier->availableNotifications()) {
|
||||
PaletteManipulator::create()
|
||||
->removeField('notificationNotice')
|
||||
->applyToPalette('default', 'tl_wk_config');
|
||||
return $this->box('tl_info', $this->translator->trans('tl_wk_config.notificationOverview', [], 'contao_tl_wk_config'));
|
||||
}
|
||||
|
||||
return;
|
||||
#[AsCallback(table: 'tl_wk_config', target: 'fields.notificationNotice.input_field')]
|
||||
public function renderNotice(): string
|
||||
{
|
||||
if (!$this->notifier->isAvailable()) {
|
||||
return $this->box('tl_error', $this->translator->trans('tl_wk_config.notificationUnavailable', [], 'contao_tl_wk_config'));
|
||||
}
|
||||
|
||||
if ([] !== $this->notifier->availableNotifications()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = $this->router->generate('contao_backend', ['do' => 'nc_notifications']);
|
||||
|
||||
// Contao-Domänen werden per sprintf ersetzt, daher %s statt benannter Platzhalter
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields']['notificationNotice']['eval']['text'] = $this->translator->trans(
|
||||
'tl_wk_config.notificationNotice',
|
||||
[htmlspecialchars($url, ENT_QUOTES)],
|
||||
'contao_tl_wk_config',
|
||||
);
|
||||
return $this->box('tl_error', $this->translator->trans('tl_wk_config.notificationNotice', [StringUtil::specialchars($url)], 'contao_tl_wk_config'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Texte aus der Sprachdatei sind HTML.
|
||||
*/
|
||||
private function box(string $class, string $html): string
|
||||
{
|
||||
return \sprintf('<div class="widget clr"><div class="%s wk-sync-explanation">%s</div></div>', $class, $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Preisfelder der Veranstaltungen: akzeptiert „240“, „240.5“, „240,50“ und „1.240,50“ und speichert
|
||||
* immer mit zwei Nachkommastellen („240.50“), wie der Import. Leer bedeutet 0.
|
||||
*/
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice1.save')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice2.save')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice3.save')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice4.save')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice5.save')]
|
||||
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice6.save')]
|
||||
class PriceFieldListener
|
||||
{
|
||||
public function __construct(private readonly TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(mixed $value): string
|
||||
{
|
||||
$value = preg_replace('/\s+|\x{00A0}|€|EUR/iu', '', (string) $value) ?? '';
|
||||
|
||||
if ('' === $value) {
|
||||
return '0.00';
|
||||
}
|
||||
|
||||
// „240.50“ (Dezimalpunkt) oder „1.240,50“ / „240,50“ (deutsches Format)
|
||||
if (!preg_match('/^\d+(\.\d{1,2})?$/', $value)) {
|
||||
if (!preg_match('/^(\d{1,3}(\.\d{3})+|\d+)(,\d{1,2})?$/', $value)) {
|
||||
throw new \RuntimeException($this->translator->trans('ERR.wk_price', [], 'contao_default'));
|
||||
}
|
||||
|
||||
$value = str_replace(['.', ','], ['', '.'], $value);
|
||||
}
|
||||
|
||||
if ((float) $value >= 100000000) {
|
||||
throw new \RuntimeException($this->translator->trans('ERR.wk_price', [], 'contao_default'));
|
||||
}
|
||||
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\DataContainer;
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\BookingFields;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\NotificationRouter;
|
||||
use Mummert\ContaoWkSyncBundle\Controller\FrontendModule\BookingFormController;
|
||||
use Mummert\ContaoWkSyncBundle\Controller\FrontendModule\BookingStatusController;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Prüft in den W&K-Einstellungen, ob die Verkettung stimmt: Felder des Anmeldeformulars, Module auf den
|
||||
* Seiten, Anfrageformular mit Zieltabelle, zugeordnete Benachrichtigungen.
|
||||
*
|
||||
* Das Feld „setupCheck“ ist reine Anzeige (input_field_callback): keine Spalte, kein Widget, kein
|
||||
* virtuelles Feld.
|
||||
*/
|
||||
#[AsCallback(table: 'tl_wk_config', target: 'config.onload')]
|
||||
#[AsCallback(table: 'tl_wk_config', target: 'fields.setupCheck.input_field')]
|
||||
class SetupCheckListener
|
||||
{
|
||||
/**
|
||||
@@ -29,22 +31,13 @@ class SetupCheckListener
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(): void
|
||||
public function __invoke(DataContainer $dc): string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (null === $request || 'edit' !== $request->query->get('act') || 'wk_config' !== $request->query->get('do')) {
|
||||
PaletteManipulator::create()->removeField('setupCheck')->applyToPalette('default', 'tl_wk_config');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->connection->fetchAssociative('SELECT * FROM tl_wk_config WHERE id = ?', [(int) $request->query->get('id')]) ?: [];
|
||||
$config = $this->connection->fetchAssociative('SELECT * FROM tl_wk_config WHERE id = ?', [(int) $dc->id]) ?: [];
|
||||
$lines = [];
|
||||
$error = false;
|
||||
$add = function (string $state, string $key, array $params = []) use (&$lines, &$error): void {
|
||||
@@ -130,12 +123,16 @@ class SetupCheckListener
|
||||
$add('ok', 'all_ok');
|
||||
}
|
||||
|
||||
$field = &$GLOBALS['TL_DCA']['tl_wk_config']['fields']['setupCheck'];
|
||||
$field['eval']['text'] = '<ul class="wk-sync-check">'.implode('', $lines).'</ul>';
|
||||
$field['eval']['boxClass'] = $error ? 'tl_error' : 'tl_confirm';
|
||||
return \sprintf(
|
||||
'<div class="widget clr"><div class="%s wk-sync-explanation"><ul class="wk-sync-check">%s</ul></div></div>',
|
||||
$error ? 'tl_error' : 'tl_confirm',
|
||||
implode('', $lines),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Texte aus der Sprachdatei sind HTML, die eingesetzten Titel aus der Datenbank werden kodiert.
|
||||
*
|
||||
* @param list<string> $params
|
||||
*/
|
||||
private function line(string $state, string $key, array $params = []): string
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\EventListener\Menu;
|
||||
|
||||
use Contao\BackendUser;
|
||||
use Contao\CoreBundle\Event\MenuEvent;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
|
||||
/**
|
||||
* Backend-Navigation der Gruppe „W&K-Tools“: Gruppen-Icon einbinden und die Tooltips korrigieren.
|
||||
*
|
||||
* Contao 5 escaped die Modul-Beschreibungen für den Tooltip bereits in BackendUser::navigation()
|
||||
* (StringUtil::specialchars) und das Menü-Template escaped das title-Attribut ein zweites Mal, sodass
|
||||
* aus „W&K“ im Tooltip „W&K“ wird. Der Core-Listener läuft mit Priorität 10; dieser Listener nimmt
|
||||
* die erste Escapierung für die W&K-Module zurück. Contao 6 baut die Navigation ohne
|
||||
* BackendUser::navigation() und escaped nur noch einmal, dort bleibt der Tooltip unverändert.
|
||||
*/
|
||||
#[AsEventListener]
|
||||
class BackendMenuListener
|
||||
{
|
||||
public function __invoke(MenuEvent $event): void
|
||||
{
|
||||
$tree = $event->getTree();
|
||||
|
||||
if ('mainMenu' !== $tree->getName() || null === ($group = $tree->getChild('wk_tools'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$GLOBALS['TL_CSS'][] = 'bundles/contaowksync/wk-sync-menu.css|static';
|
||||
|
||||
if (!method_exists(BackendUser::class, 'navigation')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($group->getChildren() as $item) {
|
||||
$title = $item->getLinkAttribute('title');
|
||||
|
||||
if (\is_string($title) && str_contains($title, '&')) {
|
||||
$item->setLinkAttribute('title', html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Export;
|
||||
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Import\SyncFileStorage;
|
||||
@@ -135,9 +134,6 @@ class BookingExporter
|
||||
*/
|
||||
private function row(array $row, string $wkId): array
|
||||
{
|
||||
// Contao 5 speichert Formulareingaben kodiert (z. B. ( für eine Klammer), die CSV braucht Klartext
|
||||
$row = array_map(static fn (mixed $value): mixed => \is_string($value) ? StringUtil::decodeEntities($value) : $value, $row);
|
||||
|
||||
$gender = (string) ($row['gender'] ?? '');
|
||||
$position = (string) ($row['position'] ?? '');
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Slug\Slug;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
|
||||
|
||||
/**
|
||||
* Wendet einen Importplan in einer Transaktion auf tl_calendar_events an.
|
||||
@@ -21,6 +23,7 @@ class ImportExecutor
|
||||
private readonly MemberResolver $memberResolver,
|
||||
private readonly CacheTagManager $cacheTagManager,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly EventRowCodec $codec = new EventRowCodec(),
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,14 +49,18 @@ class ImportExecutor
|
||||
$set['alias'] = $this->generateAlias($event->title, $calendar);
|
||||
$set['source'] = 'default';
|
||||
|
||||
$this->connection->insert('tl_calendar_events', $set);
|
||||
$this->connection->insert('tl_calendar_events', $this->codec->combine($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]);
|
||||
|
||||
// Virtuelle Felder anderer Erweiterungen im JSON-Feld bleiben erhalten
|
||||
$stored = $this->connection->fetchOne(\sprintf('SELECT `%s` FROM tl_calendar_events WHERE id = ? FOR UPDATE', EventFields::STORAGE), [$event->existingId]);
|
||||
|
||||
$this->connection->update('tl_calendar_events', $this->codec->combine($set, \is_string($stored) ? $stored : null), ['id' => $event->existingId]);
|
||||
$touchedIds[] = $event->existingId;
|
||||
++$result['updated'];
|
||||
break;
|
||||
|
||||
@@ -10,6 +10,7 @@ use Mummert\ContaoWkSyncBundle\Category\Category;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
|
||||
|
||||
/**
|
||||
* Vergleicht die W&K-Datei mit dem Bestand und erstellt die Vorschau (nichts wird geschrieben).
|
||||
@@ -30,12 +31,18 @@ class ImportPlanner
|
||||
'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6',
|
||||
];
|
||||
|
||||
/**
|
||||
* Felder mit HTML: Unterschiede nur in der Schreibweise von Entities gelten nicht als Änderung.
|
||||
*/
|
||||
private const array HTML_FIELDS = ['teaser', 'wkTopics'];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly RowMapper $rowMapper,
|
||||
private readonly CategoryRepository $categoryRepository,
|
||||
private readonly CategoryResolver $categoryResolver,
|
||||
private readonly MemberResolver $memberResolver,
|
||||
private readonly EventRowCodec $codec = new EventRowCodec(),
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -199,10 +206,14 @@ class ImportPlanner
|
||||
$changes['wkInstructor'] = [$this->memberName($existingInstructor), $row->instructorName];
|
||||
}
|
||||
|
||||
// Die Reihenfolge zählt nicht: Das Backend speichert die Auswahl in der Reihenfolge der Optionen
|
||||
$existingSpeakers = array_map(intval(...), StringUtil::deserialize($existing['wkSpeakers'] ?? null, true));
|
||||
sort($existingSpeakers);
|
||||
$sortedSpeakers = $speakerIds;
|
||||
sort($sortedSpeakers);
|
||||
$speakersWillBeCreated = $calendar->speakerGroup > 0 && [] !== $unresolvedSpeakers;
|
||||
|
||||
if ($existingSpeakers !== $speakerIds || $speakersWillBeCreated) {
|
||||
if ($existingSpeakers !== $sortedSpeakers || $speakersWillBeCreated) {
|
||||
$changes['wkSpeakers'] = [implode(', ', array_values($this->memberResolver->namesByIds($existingSpeakers))), implode(', ', $row->speakerNames)];
|
||||
}
|
||||
|
||||
@@ -216,14 +227,15 @@ class ImportPlanner
|
||||
*/
|
||||
private function loadExistingEvents(int $calendarId): array
|
||||
{
|
||||
$columns = array_unique(['id', 'wkId', 'published', 'wkCategories', 'wkCategoryPrimary', 'wkInstructor', 'wkSpeakers', ...self::COMPARED_FIELDS]);
|
||||
// Die reinen Anzeigefelder liegen als virtuelle Felder im JSON-Feld der Veranstaltung
|
||||
$columns = $this->codec->columns(['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;
|
||||
$events[(string) $row['wkId']] ??= $this->codec->expand($row);
|
||||
}
|
||||
|
||||
return $events;
|
||||
@@ -243,6 +255,11 @@ class ImportPlanner
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
// Nach dem Speichern im Backend stehen in HTML-Feldern numerische Entities (" statt ")
|
||||
if (\in_array($field, self::HTML_FIELDS, true)) {
|
||||
$value = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
return trim(str_replace("\r\n", "\n", (string) $value));
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class InboxRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<InboxItem> älteste zuerst
|
||||
* @return list<InboxItem> älteste zuerst; im Ordner „Exportiert“ neueste zuerst (siehe InboxState::newestFirst())
|
||||
*/
|
||||
public function findByState(InboxState $state, int $limit = 0): array
|
||||
{
|
||||
@@ -55,6 +55,10 @@ class InboxRepository
|
||||
$items = \array_slice($items, -$limit);
|
||||
}
|
||||
|
||||
if ($state->newestFirst()) {
|
||||
$items = array_reverse($items);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ enum InboxState: string
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportierte Datensätze sind ein Archiv und werden neueste zuerst gezeigt; die Arbeitsordner bleiben älteste zuerst.
|
||||
*/
|
||||
public function newestFirst(): bool
|
||||
{
|
||||
return self::Exported === $this;
|
||||
}
|
||||
|
||||
public function translationKey(): string
|
||||
{
|
||||
return '' === $this->value ? 'new' : $this->value;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Migration;
|
||||
|
||||
use Contao\CoreBundle\Migration\Version600\AbstractColumnToVirtualMigration;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
|
||||
|
||||
/**
|
||||
* Überträgt die bisherigen Spalten der Anzeigefelder in das JSON-Feld der Veranstaltungen und entfernt
|
||||
* die Spalten. Läuft mit „contao:migrate“, solange noch eine der Spalten vorhanden ist.
|
||||
*/
|
||||
class EventFieldsToVirtualMigration extends AbstractColumnToVirtualMigration
|
||||
{
|
||||
protected function getMapping(): array
|
||||
{
|
||||
return [
|
||||
EventFields::TABLE => array_fill_keys(EventFields::virtual(), EventFields::STORAGE),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Sync;
|
||||
|
||||
/**
|
||||
* Die reinen Anzeigefelder der Veranstaltungen sind virtuelle Felder: Contao speichert sie gemeinsam im
|
||||
* JSON-Feld „jsonData“ statt in je einer Spalte. Das hält die Zeilengröße von tl_calendar_events klein.
|
||||
*
|
||||
* Echte Spalten bleiben alle Felder, mit denen gerechnet, gefiltert oder verknüpft wird (wkId, wkState,
|
||||
* wkInternalEventNumber, wkEventType, wkMaxPlaces, wkPlacesLeft, wkImported, wkCategories,
|
||||
* wkCategoryPrimary, wkTopics, wkInstructor, wkSpeakers, wkNotification, wkConfirmationPage).
|
||||
*/
|
||||
final class EventFields
|
||||
{
|
||||
public const string TABLE = 'tl_calendar_events';
|
||||
|
||||
public const string STORAGE = 'jsonData';
|
||||
|
||||
/**
|
||||
* Virtuelle Textfelder mit ihrer maximalen Länge.
|
||||
*/
|
||||
public const array TEXTS = [
|
||||
'wkSubTitle' => 255,
|
||||
'wkSubTitleAdditional' => 255,
|
||||
'wkCategoryText' => 255,
|
||||
'wkAudience' => 255,
|
||||
'wkTags' => 255,
|
||||
'wkAdditionalDates' => 512,
|
||||
'wkPriceType1' => 64,
|
||||
'wkPriceType2' => 64,
|
||||
'wkPriceType3' => 64,
|
||||
'wkPriceType4' => 64,
|
||||
'wkPriceType5' => 64,
|
||||
'wkPriceType6' => 64,
|
||||
'wkOrganizer' => 128,
|
||||
'wkOrganizerAdditional' => 128,
|
||||
'wkOrganizerSalutation' => 64,
|
||||
'wkOrganizerAcademicTitle' => 64,
|
||||
'wkOrganizerFirstname' => 64,
|
||||
'wkOrganizerLastname' => 128,
|
||||
'wkOrganizerStreet' => 128,
|
||||
'wkOrganizerPostal' => 16,
|
||||
'wkOrganizerCity' => 128,
|
||||
'wkOrganizerCountryCode' => 16,
|
||||
'wkOrganizerEmail' => 255,
|
||||
];
|
||||
|
||||
/**
|
||||
* Virtuelle Preisfelder; gespeichert als Text mit zwei Nachkommastellen („240.00“), wie ihn die
|
||||
* früheren DECIMAL-Spalten geliefert haben.
|
||||
*/
|
||||
public const array PRICES = ['wkPrice1', 'wkPrice2', 'wkPrice3', 'wkPrice4', 'wkPrice5', 'wkPrice6'];
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function virtual(): array
|
||||
{
|
||||
return [...array_keys(self::TEXTS), ...self::PRICES];
|
||||
}
|
||||
|
||||
public static function isVirtual(string $field): bool
|
||||
{
|
||||
return isset(self::TEXTS[$field]) || \in_array($field, self::PRICES, true);
|
||||
}
|
||||
|
||||
public static function isPrice(string $field): bool
|
||||
{
|
||||
return \in_array($field, self::PRICES, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Sync;
|
||||
|
||||
/**
|
||||
* Übersetzt zwischen der Datenbankzeile einer Veranstaltung und der Sicht mit allen W&K-Feldern.
|
||||
*
|
||||
* Contao selbst (Backend, Modelle, Templates) liest und schreibt virtuelle Felder automatisch. Import,
|
||||
* Vorschau und Anmeldung arbeiten direkt auf der Datenbank und gehen deshalb über diese Klasse.
|
||||
*/
|
||||
class EventRowCodec
|
||||
{
|
||||
/**
|
||||
* Zeile aus der Datenbank: Die virtuellen Felder werden aus dem JSON-Feld gelesen und mit ihren
|
||||
* Vorgaben ergänzt, das JSON-Feld selbst entfällt. Spalten der Zeile haben Vorrang, Felder anderer
|
||||
* Erweiterungen aus dem JSON-Feld bleiben erhalten.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function expand(array $row): array
|
||||
{
|
||||
$stored = $this->decode($row[EventFields::STORAGE] ?? null);
|
||||
unset($row[EventFields::STORAGE]);
|
||||
|
||||
$virtual = [];
|
||||
|
||||
foreach (EventFields::virtual() as $field) {
|
||||
$virtual[$field] = $this->normalize($field, $stored[$field] ?? null);
|
||||
}
|
||||
|
||||
return [...$stored, ...$virtual, ...$row];
|
||||
}
|
||||
|
||||
/**
|
||||
* Werte zum Speichern: Die virtuellen Felder wandern in das JSON-Feld. Beim Aktualisieren wird der
|
||||
* bisherige Inhalt des JSON-Felds übergeben, damit nicht übergebene Felder erhalten bleiben.
|
||||
*
|
||||
* @param array<string, mixed> $set
|
||||
* @param array<string, mixed>|string|null $storage bisheriger Inhalt des JSON-Felds
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function combine(array $set, array|string|null $storage = null): array
|
||||
{
|
||||
$stored = $this->decode($storage);
|
||||
|
||||
foreach ($set as $field => $value) {
|
||||
if (EventFields::isVirtual($field)) {
|
||||
$stored[$field] = $this->normalize($field, $value);
|
||||
unset($set[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$set[EventFields::STORAGE] = [] === $stored ? null : json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spalten einer Feldliste, die es in der Tabelle wirklich gibt, ergänzt um das JSON-Feld.
|
||||
*
|
||||
* @param list<string> $fields
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function columns(array $fields): array
|
||||
{
|
||||
$columns = array_values(array_filter(array_unique($fields), static fn (string $field): bool => !EventFields::isVirtual($field)));
|
||||
$columns[] = EventFields::STORAGE;
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decode(array|string|null $storage): array
|
||||
{
|
||||
if (\is_array($storage)) {
|
||||
return $storage;
|
||||
}
|
||||
|
||||
if (null === $storage || '' === $storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($storage, true, 16, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function normalize(string $field, mixed $value): string
|
||||
{
|
||||
if (EventFields::isPrice($field)) {
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
return \is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Widget\Backend;
|
||||
|
||||
use Contao\Widget;
|
||||
|
||||
/**
|
||||
* Erklärungstext im Backend-Formular (Contao kennt „explanation“ nur im Frontend-Formulargenerator).
|
||||
*
|
||||
* Der Text kommt aus eval.text oder, wenn leer, aus $GLOBALS['TL_LANG'][Tabelle][Feld]; eval.boxClass
|
||||
* bestimmt die Box (Standard tl_info, alternativ z. B. tl_error). Das Feld wird nicht gespeichert.
|
||||
*/
|
||||
class ExplanationWidget extends Widget
|
||||
{
|
||||
protected $blnSubmitInput = false;
|
||||
|
||||
protected $strTemplate = 'be_widget';
|
||||
|
||||
public function generate(): string
|
||||
{
|
||||
$text = (string) ($this->arrConfiguration['text'] ?? '');
|
||||
|
||||
if ('' === $text) {
|
||||
$lang = $GLOBALS['TL_LANG'][$this->strTable][$this->strField] ?? '';
|
||||
$text = (string) (\is_array($lang) ? ($lang[0] ?? '') : $lang);
|
||||
}
|
||||
|
||||
return \sprintf('<div class="%s wk-sync-explanation">%s</div>', (string) ($this->arrConfiguration['boxClass'] ?? 'tl_info'), $text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Tests\EventListener;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\EventListener\DataContainer\PriceFieldListener;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class PriceFieldListenerTest extends TestCase
|
||||
{
|
||||
#[DataProvider('prices')]
|
||||
public function testNormalizesPrices(string $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, $this->listener()($input));
|
||||
}
|
||||
|
||||
public static function prices(): iterable
|
||||
{
|
||||
yield 'empty' => ['', '0.00'];
|
||||
yield 'above one hundred' => ['216.00', '216.00'];
|
||||
yield 'integer' => ['240', '240.00'];
|
||||
yield 'decimal comma' => ['240,5', '240.50'];
|
||||
yield 'thousands' => ['1.250,50', '1250.50'];
|
||||
yield 'currency sign' => ['12,90 €', '12.90'];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidPrices')]
|
||||
public function testRejectsInvalidPrices(string $input): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->listener()($input);
|
||||
}
|
||||
|
||||
public static function invalidPrices(): iterable
|
||||
{
|
||||
yield 'text' => ['auf Anfrage'];
|
||||
yield 'negative' => ['-5'];
|
||||
yield 'three decimals' => ['1,234'];
|
||||
yield 'too large' => ['100000000'];
|
||||
}
|
||||
|
||||
private function listener(): PriceFieldListener
|
||||
{
|
||||
$translator = $this->createStub(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturn('Ungültiger Preis');
|
||||
|
||||
return new PriceFieldListener($translator);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,13 @@ use Mummert\ContaoWkSyncBundle\Import\MemberResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
|
||||
use Mummert\ContaoWkSyncBundle\Import\RowMapper;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ImportPlannerTest extends TestCase
|
||||
{
|
||||
private string $sql = '';
|
||||
|
||||
public function testReimportWithoutMemberGroupsIsUnchanged(): void
|
||||
{
|
||||
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
|
||||
@@ -51,12 +54,63 @@ class ImportPlannerTest extends TestCase
|
||||
self::assertSame(['Alter Titel', 'Testseminar'], $plan->events[0]->changes['title']);
|
||||
}
|
||||
|
||||
public function testVirtualFieldsAreComparedFromTheJsonStorage(): void
|
||||
{
|
||||
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0, existingOverrides: ['wkSubTitle' => 'Alter Untertitel', 'wkPrice1' => '99.00']);
|
||||
|
||||
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
|
||||
self::assertSame(['wkSubTitle', 'wkPrice1'], array_keys($plan->events[0]->changes));
|
||||
self::assertSame(['Alter Untertitel', 'Modul 1'], $plan->events[0]->changes['wkSubTitle']);
|
||||
self::assertSame(['99.00', 216.0], $plan->events[0]->changes['wkPrice1']);
|
||||
}
|
||||
|
||||
public function testEntitySpellingAfterABackendSaveIsNoChange(): void
|
||||
{
|
||||
$plan = $this->plan(
|
||||
existingInstructor: 0,
|
||||
existingSpeakers: [],
|
||||
memberIds: [],
|
||||
instructorGroup: 0,
|
||||
speakerGroup: 0,
|
||||
existingOverrides: ['teaser' => '<p>Er sagte "Hallo" & ging</p>'],
|
||||
rowOverrides: ['kbpmemo' => 'Er sagte "Hallo" & ging'],
|
||||
);
|
||||
|
||||
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
|
||||
}
|
||||
|
||||
public function testSpeakerOrderDoesNotMatter(): void
|
||||
{
|
||||
$plan = $this->plan(
|
||||
existingInstructor: 11,
|
||||
existingSpeakers: [13, 12],
|
||||
memberIds: ['Anna Muster' => 11, 'Bernd Beispiel' => 12, 'Clara Chor' => 13],
|
||||
instructorGroup: 0,
|
||||
speakerGroup: 0,
|
||||
rowOverrides: ['kbmref' => "Bernd Beispiel\nClara Chor"],
|
||||
);
|
||||
|
||||
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
|
||||
}
|
||||
|
||||
public function testExistingRowsAreReadFromRealColumnsAndTheJsonStorage(): void
|
||||
{
|
||||
$this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
|
||||
|
||||
self::assertStringContainsString('`jsonData`', $this->sql);
|
||||
self::assertStringContainsString('`wkState`', $this->sql);
|
||||
self::assertStringNotContainsString('wkSubTitle', $this->sql);
|
||||
self::assertStringNotContainsString('wkPrice1', $this->sql);
|
||||
self::assertStringNotContainsString('wkOrganizer', $this->sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $memberIds Name => Mitglieds-ID (nur diese Namen sind Mitglieder)
|
||||
* @param list<int> $existingSpeakers
|
||||
* @param array<string, mixed> $existingOverrides
|
||||
* @param array<string, string> $rowOverrides abweichende Werte der W&K-Zeile
|
||||
*/
|
||||
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
|
||||
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = [], array $rowOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
|
||||
{
|
||||
$row = [
|
||||
'kbnr' => '0001234',
|
||||
@@ -68,10 +122,16 @@ class ImportPlannerTest extends TestCase
|
||||
'kbsgebiet' => 'Grundschule',
|
||||
'kbkln1' => 'Anna Muster',
|
||||
'kbmref' => "\nBernd Beispiel",
|
||||
'kbutitel' => 'Modul 1',
|
||||
'kbkp1' => '216,00',
|
||||
'kbktxp1' => 'Kursgebühr',
|
||||
...$rowOverrides,
|
||||
];
|
||||
|
||||
$mapped = (new RowMapper())->map($row, 2);
|
||||
$existing = [
|
||||
|
||||
// Bestand wie in der Datenbank: echte Spalten plus JSON-Feld mit den virtuellen Feldern
|
||||
$existing = (new EventRowCodec())->combine([
|
||||
...$mapped->data,
|
||||
...$existingOverrides,
|
||||
'id' => 7,
|
||||
@@ -80,10 +140,14 @@ class ImportPlannerTest extends TestCase
|
||||
'wkCategoryPrimary' => 3,
|
||||
'wkInstructor' => $existingInstructor,
|
||||
'wkSpeakers' => serialize(array_map(strval(...), $existingSpeakers)),
|
||||
];
|
||||
]);
|
||||
|
||||
$connection = $this->createStub(Connection::class);
|
||||
$connection->method('fetchAllAssociative')->willReturn([$existing]);
|
||||
$connection->method('fetchAllAssociative')->willReturnCallback(function (string $sql) use ($existing): array {
|
||||
$this->sql = $sql;
|
||||
|
||||
return [$existing];
|
||||
});
|
||||
|
||||
$categories = $this->createStub(CategoryRepository::class);
|
||||
$categories->method('findAll')->willReturn([new Category(3, 'Grundschule', 'grundschule', CategoryKind::School, ['Grundschule'], 16)]);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Tests\Sync;
|
||||
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EventRowCodecTest extends TestCase
|
||||
{
|
||||
public function testExpandReadsVirtualFieldsFromTheStorage(): void
|
||||
{
|
||||
$row = (new EventRowCodec())->expand([
|
||||
'id' => 7,
|
||||
'title' => 'Basis-Kurs',
|
||||
'wkId' => '0001234',
|
||||
'jsonData' => '{"wkSubTitle":"Modul 1","wkPrice1":"216.00","wkOrganizerCity":"Moritzburg","playerSize":"16:9"}',
|
||||
]);
|
||||
|
||||
self::assertSame('Modul 1', $row['wkSubTitle']);
|
||||
self::assertSame('216.00', $row['wkPrice1']);
|
||||
self::assertSame('Moritzburg', $row['wkOrganizerCity']);
|
||||
self::assertSame('0001234', $row['wkId']);
|
||||
self::assertSame('16:9', $row['playerSize'], 'Felder anderer Erweiterungen bleiben lesbar');
|
||||
self::assertArrayNotHasKey('jsonData', $row);
|
||||
}
|
||||
|
||||
public function testExpandFillsDefaultsForMissingFields(): void
|
||||
{
|
||||
$codec = new EventRowCodec();
|
||||
|
||||
foreach ([null, '', '{}', 'kein JSON', '"text"'] as $storage) {
|
||||
$row = $codec->expand(['id' => 1, 'jsonData' => $storage]);
|
||||
|
||||
self::assertSame('', $row['wkSubTitle']);
|
||||
self::assertSame('', $row['wkOrganizerEmail']);
|
||||
self::assertSame('0.00', $row['wkPrice3']);
|
||||
}
|
||||
|
||||
self::assertSame([], array_diff(EventFields::virtual(), array_keys($codec->expand(['id' => 1]))));
|
||||
}
|
||||
|
||||
public function testExpandKeepsAlreadyExpandedValues(): void
|
||||
{
|
||||
// Daten aus einem Contao-Modell sind bereits aufbereitet und haben kein JSON-Feld mehr
|
||||
$row = (new EventRowCodec())->expand(['id' => 1, 'wkSubTitle' => 'Modul 2', 'wkPrice1' => '12.50']);
|
||||
|
||||
self::assertSame('Modul 2', $row['wkSubTitle']);
|
||||
self::assertSame('12.50', $row['wkPrice1']);
|
||||
}
|
||||
|
||||
public function testCombineMovesVirtualFieldsIntoTheStorage(): void
|
||||
{
|
||||
$set = (new EventRowCodec())->combine([
|
||||
'title' => 'Basis-Kurs',
|
||||
'wkId' => '0001234',
|
||||
'wkPlacesLeft' => 12,
|
||||
'wkSubTitle' => 'Modul 1 „Einführung“',
|
||||
'wkPrice1' => 216.0,
|
||||
'wkPrice2' => 1250.5,
|
||||
'wkPriceType1' => 'Kursgebühr',
|
||||
]);
|
||||
|
||||
self::assertSame(['title', 'wkId', 'wkPlacesLeft', 'jsonData'], array_keys($set));
|
||||
self::assertSame(
|
||||
['wkSubTitle' => 'Modul 1 „Einführung“', 'wkPrice1' => '216.00', 'wkPrice2' => '1250.50', 'wkPriceType1' => 'Kursgebühr'],
|
||||
json_decode($set['jsonData'], true, flags: JSON_THROW_ON_ERROR),
|
||||
);
|
||||
self::assertStringContainsString('„Einführung“', $set['jsonData'], 'Umlaute und Anführungszeichen stehen lesbar im JSON');
|
||||
}
|
||||
|
||||
public function testCombineKeepsStoredFieldsThatAreNotPassed(): void
|
||||
{
|
||||
$set = (new EventRowCodec())->combine(
|
||||
['wkSubTitle' => 'Neu', 'tstamp' => 5],
|
||||
'{"wkSubTitle":"Alt","wkAudience":"Lehrkräfte","playerSize":"16:9"}',
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
['wkSubTitle' => 'Neu', 'wkAudience' => 'Lehrkräfte', 'playerSize' => '16:9'],
|
||||
json_decode($set['jsonData'], true, flags: JSON_THROW_ON_ERROR),
|
||||
);
|
||||
self::assertSame(5, $set['tstamp']);
|
||||
}
|
||||
|
||||
public function testCombineWithoutVirtualFieldsLeavesTheStorageEmpty(): void
|
||||
{
|
||||
self::assertNull((new EventRowCodec())->combine(['title' => 'Ohne'])['jsonData']);
|
||||
}
|
||||
|
||||
public function testRoundTrip(): void
|
||||
{
|
||||
$codec = new EventRowCodec();
|
||||
$data = ['id' => 3, 'wkId' => '0009', 'wkTags' => 'a, b', 'wkPrice6' => '9.90', 'wkOrganizerEmail' => 'info@example.org'];
|
||||
|
||||
$row = $codec->expand($codec->combine($data));
|
||||
|
||||
foreach ($data as $field => $value) {
|
||||
self::assertSame($value, $row[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testColumnsSkipVirtualFields(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['id', 'wkId', 'wkState', 'jsonData'],
|
||||
(new EventRowCodec())->columns(['id', 'wkId', 'wkSubTitle', 'wkState', 'wkPrice1', 'id']),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRealColumnsAreNotVirtual(): void
|
||||
{
|
||||
foreach (['wkId', 'wkState', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft', 'wkImported', 'wkCategories', 'wkCategoryPrimary', 'wkTopics', 'wkInstructor', 'wkSpeakers', 'wkNotification', 'wkConfirmationPage'] as $field) {
|
||||
self::assertFalse(EventFields::isVirtual($field), $field);
|
||||
}
|
||||
|
||||
self::assertCount(29, EventFields::virtual());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user