Initial bundle implementation

This commit is contained in:
Jürgen Mummert
2026-02-18 21:30:04 +01:00
commit 00ef6aba91
18 changed files with 1488 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace GymnasiumNossenBundle\Contao\Manager;
use Contao\CalendarBundle\ContaoCalendarBundle;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use GymnasiumNossenBundle\GymnasiumNossenBundle;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\RouteCollection;
class Plugin implements BundlePluginInterface, RoutingPluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(GymnasiumNossenBundle::class)
->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')
;
}
}
@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace GymnasiumNossenBundle\Controller;
use Contao\CalendarEventsModel;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\CoreBundle\Twig\Interop\ContextFactory;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\StringUtil;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Exception\ExceptionInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Environment;
class EventListModuleController extends AbstractFrontendModuleController
{
public function __construct(
private readonly Connection $connection,
private readonly RequestStack $requestStack,
private readonly ContentUrlGenerator $contentUrlGenerator,
private readonly ContextFactory $contextFactory,
private readonly Security $security,
private readonly Environment $twig,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$activeRequest = $this->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<array{id: int, pid: int, isFeatured: bool, title: string, startDate: string, endDate: string|null, url: string|null}>
*/
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<string, mixed> $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<int>
*/
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<int> $configuredArchiveIds
*
* @return list<array{id: int, title: string}>
*/
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<int> $configuredArchiveIds
* @param bool $archiveFilterApplied
*
* @return list<int>
*/
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));
}
}
+291
View File
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace GymnasiumNossenBundle\Controller;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
#[Route('/events/pdf', name: 'gymnasium_event_pdf', methods: ['POST'])]
class EventPdfController
{
public function __construct(
private readonly Connection $connection,
private readonly Environment $twig,
) {
}
public function __invoke(Request $request): Response
{
$eventIds = $this->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<mixed> $rawIds
*
* @return list<int>
*/
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<int> $eventIds
*
* @return list<array{title: string, startText: string, endText: string|null}>
*/
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<array{title: string, startText: string, endText: string|null}> $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<string, mixed> $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);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace GymnasiumNossenBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class GymnasiumNossenExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/contao/config'));
$loader->load('services.yaml');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace GymnasiumNossenBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class GymnasiumNossenBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}
+3
View File
@@ -0,0 +1,3 @@
gymnasium_nossen_bundle_controllers:
resource: ../../Controller/
type: attribute
+11
View File
@@ -0,0 +1,11 @@
services:
_defaults:
autowire: true
autoconfigure: true
GymnasiumNossenBundle\Controller\:
resource: ../../../Controller/
GymnasiumNossenBundle\Controller\EventListModuleController:
tags:
- { name: contao.frontend_module, type: gymnasium_eventlist, category: events, template: frontend/module_event_list }