From 00ef6aba912eab0519cbaaa436a8859211f1c190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Wed, 18 Feb 2026 21:30:04 +0100 Subject: [PATCH] Initial bundle implementation --- .gitignore | 2 + README.md | 98 +++++ ..._06_26_Gymnasium_Nossen_Siegel_schwarz.svg | 274 ++++++++++++++ .../frontend/module_event_list.html.twig | 249 +++++++++++++ Resources/views/pdf/events.html.twig | 70 ++++ composer.json | 22 ++ contao/config/modules.php | 5 + contao/dca/tl_module.php | 5 + contao/languages/de/modules.php | 5 + contao/languages/en/modules.php | 5 + .../frontend/module_event_list.html.twig | 37 ++ src/Contao/Manager/Plugin.php | 35 ++ src/Controller/EventListModuleController.php | 342 ++++++++++++++++++ src/Controller/EventPdfController.php | 291 +++++++++++++++ .../GymnasiumNossenExtension.php | 19 + src/GymnasiumNossenBundle.php | 15 + src/Resources/config/routes.yaml | 3 + src/Resources/contao/config/services.yaml | 11 + 18 files changed, 1488 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg create mode 100644 Resources/views/frontend/module_event_list.html.twig create mode 100644 Resources/views/pdf/events.html.twig create mode 100644 composer.json create mode 100644 contao/config/modules.php create mode 100644 contao/dca/tl_module.php create mode 100644 contao/languages/de/modules.php create mode 100644 contao/languages/en/modules.php create mode 100644 contao/templates/frontend/module_event_list.html.twig create mode 100644 src/Contao/Manager/Plugin.php create mode 100644 src/Controller/EventListModuleController.php create mode 100644 src/Controller/EventPdfController.php create mode 100644 src/DependencyInjection/GymnasiumNossenExtension.php create mode 100644 src/GymnasiumNossenBundle.php create mode 100644 src/Resources/config/routes.yaml create mode 100644 src/Resources/contao/config/services.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafff2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..8536409 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# gymnasium-nossen-bundle + +Contao 5.7 Bundle für eine gefilterte Eventliste mit PDF-Export. + +## Funktionen + +- Frontend-Modul `gymnasium_eventlist` (Kategorie `events`) +- Zwei-Spalten-Layout im Frontend (links Filter/Export, rechts Eventliste) +- Filter über: + - Event-Archive (Checkboxen, Multi-Select) + - Date-Range `from` und `to` (`YYYY-MM-DD`) +- Auto-Filter ohne separaten Submit-Button (bei Datums- oder Archivänderung) +- Flatpickr via CDN (Dark Theme) mit Clear-Button pro Datumsfeld +- Ausgabe nur erlaubter Archive/Events gemäß Archivschutz +- CSS-Klassen pro Event im Markup: + - `pid-` + - `is-featured` (wenn Event im Backend hervorgehoben) +- PDF-Export ausgewählter Events über `/events/pdf` +- Optionale PDF-Felder: + - Überschrift + - Einleitungstext + +## Anforderungen + +- PHP `^8.4` +- `contao/core-bundle ^5.7` +- `contao/calendar-bundle ^5.7` +- `dompdf/dompdf ^3.1` + +## Installation im Hauptprojekt + +Das Bundle wird als `path`-Repository eingebunden und im Hauptprojekt per Composer installiert. + +## Modul-Konfiguration + +Backend-Modultyp: + +- `gefilterte Eventliste` (`gymnasium_eventlist`) + +Einstellungen: + +- `cal_calendar` (Mehrfachauswahl) + +Hinweis zur Archivfilter-Vorauswahl: + +- Erstaufruf: alle erlaubten Archive sind vorausgewählt +- Bewusst alle Archive abgewählt: es werden keine Events angezeigt (`Keine Termine gefunden.`) + +## PDF-Export + +Route: + +- `POST /events/pdf` + +POST-Felder: + +- `event_ids[]` (Pflicht) +- `from` (optional) +- `to` (optional) +- `pdf_heading` (optional) +- `pdf_intro` (optional) + +PDF-Inhalt: + +- Logo oben rechts (nur erste Seite) +- Überschrift (optional überschreibbar) +- Einleitungstext (optional) +- Datumsbereich (falls gesetzt) +- Eventzeilen mit Contao-Zeitlogik (`addTime`, `startDate/endDate`, `startTime/endTime`) +- Datum/Uhrzeit in Dunkelgrau und kursiv + +## Berechtigungslogik (Archive) + +- Nicht eingeloggte Nutzer sehen nur ungeschützte Event-Archive. +- Eingeloggte `tl_member` sehen zusätzlich geschützte Archive, wenn ihre Mitgliedsgruppen in den Archivgruppen enthalten sind. +- Diese Logik gilt sowohl für den Archiv-Filter als auch für die Eventliste. + +## Aktive Struktur (final) + +- `contao/config/modules.php` +- `contao/dca/tl_module.php` +- `contao/languages/de/modules.php` +- `contao/languages/en/modules.php` +- `contao/templates/frontend/module_event_list.html.twig` +- `src/Contao/Manager/Plugin.php` +- `src/Controller/EventListModuleController.php` +- `src/Controller/EventPdfController.php` +- `src/DependencyInjection/GymnasiumNossenExtension.php` +- `src/Resources/config/routes.yaml` +- `src/Resources/contao/config/services.yaml` +- `Resources/views/frontend/module_event_list.html.twig` +- `Resources/views/pdf/events.html.twig` +- `Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg` + +## Hinweise + +- Die primären Twig-Dateien für das Runtime-Rendering liegen unter `Resources/views/...`. +- Nach Änderungen: `cache:clear` ausführen. diff --git a/Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg b/Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg new file mode 100644 index 0000000..7702927 --- /dev/null +++ b/Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/views/frontend/module_event_list.html.twig b/Resources/views/frontend/module_event_list.html.twig new file mode 100644 index 0000000..f020561 --- /dev/null +++ b/Resources/views/frontend/module_event_list.html.twig @@ -0,0 +1,249 @@ + + + +
+
+
+

Filtern

+ + + {% if availableArchives is not empty %} +
+ Event-Archive + + {% for archive in availableArchives %} + + {% endfor %} +
+ {% endif %} + + +
+ + × +
+ + +
+ + × +
+
+ +
+ + + + {% if pdfError == 'no_selection' %} +

Bitte wählen Sie mindestens einen Termin für den PDF-Export aus.

+ {% elseif pdfError == 'not_found' %} +

Die ausgewählten Termine konnten nicht geladen werden.

+ {% endif %} + + + +

+ + +

+ +

+ + +

+ +

+ +

+
+
+ +
+ {% if events is not empty %} +

+ +

+ +
    + {% for event in events %} +
  • + + +

    + {{ event.startDate }}{% if event.endDate %} - {{ event.endDate }}{% endif %} +

    + + {% if event.url %} + weiterlesen… + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

Keine Termine gefunden.

+ {% endif %} +
+
+ + + + diff --git a/Resources/views/pdf/events.html.twig b/Resources/views/pdf/events.html.twig new file mode 100644 index 0000000..b72e4bb --- /dev/null +++ b/Resources/views/pdf/events.html.twig @@ -0,0 +1,70 @@ + + +{% if logoDataUri %} + +{% endif %} + +

{{ heading }}

+ +{% if introText %} +

{{ introText }}

+{% endif %} + +{% if dateRange %} +

{{ dateRange }}

+{% endif %} + +
+{% for event in events %} +

+ {{ event.title }} – + + {{ event.startText }} + {% if event.endText %} + - {{ event.endText }} + {% endif %} + +

+{% endfor %} +
diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..180fc81 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "gymnasium-nossen/gymnasium-nossen-bundle", + "description": "Contao frontend module with date-range filtered upcoming events for members.", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.4", + "contao/core-bundle": "^5.7", + "contao/calendar-bundle": "^5.7", + "dompdf/dompdf": "^3.1", + "contao/manager-plugin": "^2.0" + }, + "autoload": { + "psr-4": { + "GymnasiumNossenBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "GymnasiumNossenBundle\\Contao\\Manager\\Plugin" + }, + "prefer-stable": true +} \ No newline at end of file diff --git a/contao/config/modules.php b/contao/config/modules.php new file mode 100644 index 0000000..e4139d0 --- /dev/null +++ b/contao/config/modules.php @@ -0,0 +1,5 @@ + + + + + + + + {% if isFiltered %} + ALLE TERMINE ANZEIGEN + {% endif %} + + +{% if events is not empty %} + +{% else %} +

Keine Termine gefunden.

+{% endif %} diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..7ee05f9 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,35 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } + + public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): RouteCollection|null + { + return $resolver + ->resolve(__DIR__.'/../../Resources/config/routes.yaml') + ->load(__DIR__.'/../../Resources/config/routes.yaml') + ; + } +} diff --git a/src/Controller/EventListModuleController.php b/src/Controller/EventListModuleController.php new file mode 100644 index 0000000..62c0231 --- /dev/null +++ b/src/Controller/EventListModuleController.php @@ -0,0 +1,342 @@ +requestStack->getMainRequest() ?? $request; + + $configuredArchiveIds = $this->getConfiguredArchiveIds($model); + $availableArchives = $this->fetchAllowedArchives($configuredArchiveIds); + $allowedArchiveIds = array_map(static fn (array $archive): int => (int) $archive['id'], $availableArchives); + $archiveFilterApplied = '1' === (string) $activeRequest->query->get('archive_filter_applied', '0'); + $selectedArchiveIds = $this->resolveSelectedArchiveIds($activeRequest->query->all('archive_ids'), $allowedArchiveIds, $archiveFilterApplied); + + $fromRaw = $activeRequest->query->get('from'); + $toRaw = $activeRequest->query->get('to'); + + $from = $this->parseDateParameter(is_string($fromRaw) ? $fromRaw : null); + $to = $this->parseDateParameter(is_string($toRaw) ? $toRaw : null); + + $isDateRangeFilterActive = null !== $from && null !== $to && $from <= $to; + + $events = $this->fetchEvents($selectedArchiveIds, $from, $to, $isDateRangeFilterActive); + + $template->set('events', $events); + $template->set('availableArchives', $availableArchives); + $template->set('selectedArchiveIds', $selectedArchiveIds); + $template->set('from', is_string($fromRaw) ? $fromRaw : ''); + $template->set('to', is_string($toRaw) ? $toRaw : ''); + $template->set('isFiltered', $isDateRangeFilterActive); + $template->set('resetUrl', ($activeRequest->getBaseUrl() ?: '').$activeRequest->getPathInfo()); + $template->set('pdfError', $this->normalizePdfError($activeRequest->query->get('pdf_error'))); + + $templatePath = __DIR__.'/../../Resources/views/frontend/module_event_list.html.twig'; + $templateContent = file_get_contents($templatePath); + + if (false === $templateContent) { + throw new \RuntimeException('The module template file could not be read: '.$templatePath); + } + + return new Response( + $this->twig->createTemplate($templateContent)->render($this->contextFactory->fromData($template->getData())), + ); + } + + private function parseDateParameter(?string $rawValue): ?DateTimeImmutable + { + if (null === $rawValue || '' === $rawValue) { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $rawValue); + $errors = DateTimeImmutable::getLastErrors(); + + if ( + false === $date + || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) + || $date->format('Y-m-d') !== $rawValue + ) { + return null; + } + + return $date; + } + + /** + * @return list + */ + private function fetchEvents(array $archiveIds, ?DateTimeImmutable $from, ?DateTimeImmutable $to, bool $isDateRangeFilterActive): array + { + if ([] === $archiveIds) { + return []; + } + + $effectiveStartExpression = 'COALESCE(NULLIF(e.startTime, 0), NULLIF(e.startDate, 0), 0)'; + $effectiveEndExpression = 'COALESCE(NULLIF(e.endTime, 0), NULLIF(e.endDate, 0), '.$effectiveStartExpression.')'; + + $queryBuilder = $this->connection->createQueryBuilder(); + + $queryBuilder + ->select('e.id', 'e.pid', 'e.featured', 'e.alias', 'e.source', 'e.url', 'e.jumpTo', 'e.articleId', 'e.title', 'e.addTime', 'e.startDate', 'e.endDate', 'e.startTime', 'e.endTime') + ->from('tl_calendar_events', 'e') + ->where('e.pid IN (:archiveIds)') + ->andWhere('e.published = :published') + ->setParameter('archiveIds', $archiveIds, ArrayParameterType::INTEGER) + ->setParameter('published', 1) + ->orderBy($effectiveStartExpression, 'ASC') + ; + + if ($isDateRangeFilterActive && null !== $from && null !== $to) { + $fromTimestamp = $from->setTime(0, 0)->getTimestamp(); + $toTimestamp = $to->setTime(23, 59, 59)->getTimestamp(); + + $queryBuilder + ->andWhere($effectiveStartExpression.' <= :toTimestamp') + ->andWhere($effectiveEndExpression.' >= :fromTimestamp') + ->setParameter('toTimestamp', $toTimestamp) + ->setParameter('fromTimestamp', $fromTimestamp) + ; + } else { + $todayStart = (new DateTimeImmutable('today'))->setTime(0, 0)->getTimestamp(); + + $queryBuilder + ->andWhere($effectiveStartExpression.' >= :todayStart') + ->setParameter('todayStart', $todayStart) + ; + } + + $rows = $queryBuilder->executeQuery()->fetchAllAssociative(); + + $events = []; + + foreach ($rows as $row) { + ['startDateText' => $startDateText, 'endDateText' => $endDateText] = $this->formatEventDateTimeTexts($row); + + $eventModel = new CalendarEventsModel($row); + + try { + $eventUrl = $this->contentUrlGenerator->generate($eventModel); + } catch (ExceptionInterface) { + $eventUrl = null; + } + + $events[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'pid' => (int) ($row['pid'] ?? 0), + 'isFeatured' => '1' === (string) ($row['featured'] ?? ''), + 'title' => $this->normalizeTitle((string) ($row['title'] ?? '')), + 'startDate' => $startDateText, + 'endDate' => $endDateText, + 'url' => $eventUrl, + ]; + } + + return $events; + } + + private function normalizeTitle(string $title): string + { + $decoded = $title; + + for ($index = 0; $index < 2; ++$index) { + $next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if ($next === $decoded) { + break; + } + + $decoded = $next; + } + + return $decoded; + } + + /** + * @param array $row + * + * @return array{startDateText: string, endDateText: string|null} + */ + private function formatEventDateTimeTexts(array $row): array + { + $addTime = (int) ($row['addTime'] ?? 0) === 1; + $startDateTimestamp = (int) ($row['startDate'] ?? 0); + $endDateTimestamp = (int) ($row['endDate'] ?? 0); + $startTimeTimestamp = (int) ($row['startTime'] ?? 0); + $endTimeTimestamp = (int) ($row['endTime'] ?? 0); + + if ($addTime) { + $startTimestamp = $startTimeTimestamp > 0 ? $startTimeTimestamp : $startDateTimestamp; + $start = (new DateTimeImmutable())->setTimestamp($startTimestamp); + $startText = $start->format('d.m.Y H:i'); + + if ($endDateTimestamp <= 0) { + return ['startDateText' => $startText, 'endDateText' => null]; + } + + $endTimestamp = $endTimeTimestamp > 0 ? $endTimeTimestamp : $endDateTimestamp; + $end = (new DateTimeImmutable())->setTimestamp($endTimestamp); + $sameDay = $start->format('Y-m-d') === $end->format('Y-m-d'); + $endText = $sameDay ? $end->format('H:i') : $end->format('d.m.Y H:i'); + + return ['startDateText' => $startText, 'endDateText' => $endText]; + } + + $startTimestamp = $startDateTimestamp > 0 ? $startDateTimestamp : $startTimeTimestamp; + $start = (new DateTimeImmutable())->setTimestamp($startTimestamp); + $startText = $start->format('d.m.Y'); + + if ($endDateTimestamp <= 0) { + return ['startDateText' => $startText, 'endDateText' => null]; + } + + $end = (new DateTimeImmutable())->setTimestamp($endDateTimestamp); + $endText = $end->format('d.m.Y'); + + return ['startDateText' => $startText, 'endDateText' => $endText !== $startText ? $endText : null]; + } + + private function normalizePdfError(mixed $rawValue): ?string + { + if (!is_string($rawValue)) { + return null; + } + + return match ($rawValue) { + 'no_selection', 'not_found' => $rawValue, + default => null, + }; + } + + /** + * @return list + */ + private function getConfiguredArchiveIds(ModuleModel $model): array + { + return array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + StringUtil::deserialize($model->cal_calendar, true), + )))); + } + + /** + * @param list $configuredArchiveIds + * + * @return list + */ + private function fetchAllowedArchives(array $configuredArchiveIds): array + { + if ([] === $configuredArchiveIds) { + return []; + } + + $user = $this->security->getUser(); + $memberGroupIds = []; + + if ($user instanceof FrontendUser) { + $memberGroupIds = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + StringUtil::deserialize($user->groups, true), + )))); + } + + $rows = $this->connection->createQueryBuilder() + ->select('c.id', 'c.title', 'c.protected', 'c.groups') + ->from('tl_calendar', 'c') + ->where('c.id IN (:ids)') + ->setParameter('ids', $configuredArchiveIds, ArrayParameterType::INTEGER) + ->orderBy('c.title', 'ASC') + ->executeQuery() + ->fetchAllAssociative() + ; + + $archives = []; + + foreach ($rows as $row) { + $isProtected = (int) ($row['protected'] ?? 0) === 1; + + if ($isProtected) { + if (!$user instanceof FrontendUser) { + continue; + } + + $calendarGroups = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + StringUtil::deserialize($row['groups'] ?? null, true), + )))); + + if ([] === $calendarGroups || [] === array_intersect($memberGroupIds, $calendarGroups)) { + continue; + } + } + + $archives[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $this->normalizeTitle((string) ($row['title'] ?? '')), + ]; + } + + return $archives; + } + + /** + * @param mixed $rawValues + * @param list $configuredArchiveIds + * @param bool $archiveFilterApplied + * + * @return list + */ + private function resolveSelectedArchiveIds(mixed $rawValues, array $configuredArchiveIds, bool $archiveFilterApplied): array + { + if ([] === $configuredArchiveIds) { + return []; + } + + if (!$archiveFilterApplied) { + return $configuredArchiveIds; + } + + if (!is_array($rawValues) || [] === $rawValues) { + return []; + } + + $selected = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + $rawValues, + )))); + + return array_values(array_intersect($configuredArchiveIds, $selected)); + } +} diff --git a/src/Controller/EventPdfController.php b/src/Controller/EventPdfController.php new file mode 100644 index 0000000..d988e85 --- /dev/null +++ b/src/Controller/EventPdfController.php @@ -0,0 +1,291 @@ +normalizeEventIds($request->request->all('event_ids')); + + if ([] === $eventIds) { + return $this->redirectToListWithError($request, 'no_selection'); + } + + $events = $this->fetchEvents($eventIds); + + if ([] === $events) { + return $this->redirectToListWithError($request, 'not_found'); + } + + $from = $this->parseDateParameter($request->request->get('from')); + $to = $this->parseDateParameter($request->request->get('to')); + $heading = $this->normalizeOptionalText($request->request->get('pdf_heading')) ?? 'Termine'; + $introText = $this->normalizeOptionalText($request->request->get('pdf_intro')); + + $html = $this->renderTemplate($events, $from, $to, $heading, $introText); + + $options = new Options(); + $options->set('defaultFont', 'DejaVu Sans'); + $options->set('isRemoteEnabled', false); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + return new Response( + $dompdf->output(), + Response::HTTP_OK, + [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="termine.pdf"', + ], + ); + } + + /** + * @param array $rawIds + * + * @return list + */ + private function normalizeEventIds(array $rawIds): array + { + $ids = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + $rawIds, + )))); + + return array_values(array_filter($ids, static fn (int $id): bool => $id > 0)); + } + + /** + * @param list $eventIds + * + * @return list + */ + private function fetchEvents(array $eventIds): array + { + $queryBuilder = $this->connection->createQueryBuilder(); + + $rows = $queryBuilder + ->select('e.title', 'e.addTime', 'e.startDate', 'e.endDate', 'e.startTime', 'e.endTime') + ->from('tl_calendar_events', 'e') + ->where('e.id IN (:ids)') + ->andWhere('e.published = :published') + ->setParameter('ids', $eventIds, ArrayParameterType::INTEGER) + ->setParameter('published', 1) + ->orderBy('e.startTime', 'ASC') + ->executeQuery() + ->fetchAllAssociative() + ; + + $events = []; + + foreach ($rows as $row) { + ['startText' => $startText, 'endText' => $endText] = $this->formatEventDateTimeTexts($row); + + $events[] = [ + 'title' => $this->normalizeTitle((string) ($row['title'] ?? '')), + 'startText' => $startText, + 'endText' => $endText, + ]; + } + + return $events; + } + + private function parseDateParameter(mixed $rawValue): ?DateTimeImmutable + { + if (!is_string($rawValue) || '' === $rawValue) { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $rawValue); + $errors = DateTimeImmutable::getLastErrors(); + + if ( + false === $date + || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) + || $date->format('Y-m-d') !== $rawValue + ) { + return null; + } + + return $date; + } + + private function normalizeTitle(string $title): string + { + $decoded = $title; + + for ($index = 0; $index < 2; ++$index) { + $next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if ($next === $decoded) { + break; + } + + $decoded = $next; + } + + return $decoded; + } + + /** + * @param list $events + */ + private function renderTemplate(array $events, ?DateTimeImmutable $from, ?DateTimeImmutable $to, string $heading, ?string $introText): string + { + $templatePath = __DIR__.'/../../Resources/views/pdf/events.html.twig'; + $templateContent = file_get_contents($templatePath); + + if (false === $templateContent) { + throw new \RuntimeException('The PDF template file could not be read: '.$templatePath); + } + + $dateRange = null; + + if (null !== $from && null !== $to && $from <= $to) { + $dateRange = sprintf('%s - %s', $this->formatGermanLongDate($from), $this->formatGermanLongDate($to)); + } + + $logoDataUri = $this->getLogoDataUri(); + + return $this->twig->createTemplate($templateContent)->render([ + 'events' => $events, + 'heading' => $heading, + 'introText' => $introText, + 'dateRange' => $dateRange, + 'logoDataUri' => $logoDataUri, + ]); + } + + private function normalizeOptionalText(mixed $rawValue): ?string + { + if (!is_string($rawValue)) { + return null; + } + + $value = trim($rawValue); + + return '' !== $value ? $value : null; + } + + private function redirectToListWithError(Request $request, string $error): RedirectResponse + { + $target = (string) $request->headers->get('referer', '/'); + + $parts = parse_url($target); + $query = []; + + if (isset($parts['query']) && '' !== $parts['query']) { + parse_str($parts['query'], $query); + } + + $query['pdf_error'] = $error; + + $path = ($parts['path'] ?? '/').'?'.http_build_query($query); + + return new RedirectResponse($path, Response::HTTP_SEE_OTHER); + } + + private function formatGermanLongDate(DateTimeImmutable $date): string + { + $months = [ + 1 => 'Januar', + 2 => 'Februar', + 3 => 'März', + 4 => 'April', + 5 => 'Mai', + 6 => 'Juni', + 7 => 'Juli', + 8 => 'August', + 9 => 'September', + 10 => 'Oktober', + 11 => 'November', + 12 => 'Dezember', + ]; + + $month = $months[(int) $date->format('n')] ?? $date->format('m'); + + return sprintf('%d. %s %d', (int) $date->format('j'), $month, (int) $date->format('Y')); + } + + /** + * @param array $row + * + * @return array{startText: string, endText: string|null} + */ + private function formatEventDateTimeTexts(array $row): array + { + $addTime = (int) ($row['addTime'] ?? 0) === 1; + $startDateTimestamp = (int) ($row['startDate'] ?? 0); + $endDateTimestamp = (int) ($row['endDate'] ?? 0); + $startTimeTimestamp = (int) ($row['startTime'] ?? 0); + $endTimeTimestamp = (int) ($row['endTime'] ?? 0); + + if ($addTime) { + $startTimestamp = $startTimeTimestamp > 0 ? $startTimeTimestamp : $startDateTimestamp; + $start = (new DateTimeImmutable())->setTimestamp($startTimestamp); + $startText = $start->format('d.m.Y H:i').' Uhr'; + + if ($endDateTimestamp <= 0) { + return ['startText' => $startText, 'endText' => null]; + } + + $endTimestamp = $endTimeTimestamp > 0 ? $endTimeTimestamp : $endDateTimestamp; + $end = (new DateTimeImmutable())->setTimestamp($endTimestamp); + + $sameDay = $start->format('Y-m-d') === $end->format('Y-m-d'); + $endText = $sameDay ? $end->format('H:i').' Uhr' : $end->format('d.m.Y H:i').' Uhr'; + + return ['startText' => $startText, 'endText' => $endText]; + } + + $startTimestamp = $startDateTimestamp > 0 ? $startDateTimestamp : $startTimeTimestamp; + $start = (new DateTimeImmutable())->setTimestamp($startTimestamp); + $startText = $start->format('d.m.Y'); + + if ($endDateTimestamp <= 0) { + return ['startText' => $startText, 'endText' => null]; + } + + $end = (new DateTimeImmutable())->setTimestamp($endDateTimestamp); + $endText = $end->format('d.m.Y'); + + return ['startText' => $startText, 'endText' => $endText !== $startText ? $endText : null]; + } + + private function getLogoDataUri(): ?string + { + $logoPath = __DIR__.'/../../Resources/img/2022_06_26_Gymnasium_Nossen_Siegel_schwarz.svg'; + $logoContent = file_get_contents($logoPath); + + if (false === $logoContent) { + return null; + } + + return 'data:image/svg+xml;base64,'.base64_encode($logoContent); + } +} diff --git a/src/DependencyInjection/GymnasiumNossenExtension.php b/src/DependencyInjection/GymnasiumNossenExtension.php new file mode 100644 index 0000000..146febb --- /dev/null +++ b/src/DependencyInjection/GymnasiumNossenExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/GymnasiumNossenBundle.php b/src/GymnasiumNossenBundle.php new file mode 100644 index 0000000..12926d4 --- /dev/null +++ b/src/GymnasiumNossenBundle.php @@ -0,0 +1,15 @@ +