diff --git a/composer.json b/composer.json index 020d2a8..83ee01f 100644 --- a/composer.json +++ b/composer.json @@ -29,5 +29,8 @@ "contao-components/installer": true, "contao/manager-plugin": true } + }, + "suggest": { + "numero2/contao-opengraph3": "Open-Graph-Standardwerte (Titel mit Datum, Teaser, Eventbild) für Event-Detailseiten" } } diff --git a/config/services.yaml b/config/services.yaml index 1a1325c..e10725a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,8 @@ services: - ../src/DependencyInjection/ - ../src/Contao/Manager/ - ../src/EventManagerBundle.php + - ../src/OpenGraph/ # nur mit numero2/contao-opengraph3, siehe EventManagerExtension + - ../src/Seo/ # statische Helfer, kein Service Mummert\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback: tags: diff --git a/contao/dca/tl_organization.php b/contao/dca/tl_organization.php index f9f0fa0..e5b5a46 100644 --- a/contao/dca/tl_organization.php +++ b/contao/dca/tl_organization.php @@ -305,3 +305,11 @@ $GLOBALS['TL_DCA']['tl_organization']['fields']['alias']['save_callback'][] = st return $value; }; + +// Organisationsliste und Karte sind mit dem allgemeinen Tabellen-Tag getaggt +// (FrontendCacheInvalidator): beim Speichern im Backend mit invalidieren. +$GLOBALS['TL_DCA']['tl_organization']['config']['oninvalidate_cache_tags_callback'][] = static function ($dc, array $tags): array { + $tags[] = \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator::ORGANIZATION_TAG; + + return $tags; +}; diff --git a/src/Controller/Frontend/EventMapController.php b/src/Controller/Frontend/EventMapController.php index dfd87be..38f7a7f 100644 --- a/src/Controller/Frontend/EventMapController.php +++ b/src/Controller/Frontend/EventMapController.php @@ -29,11 +29,15 @@ class EventMapController extends AbstractFrontendModuleController public function __construct( private readonly MapModuleDataProvider $mapModuleDataProvider, + private readonly \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator $cacheInvalidator, ) { } protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response { + // Karte zeigt Events aller Kalender und Organisationen: bei Änderungen neu erzeugen + $this->tagResponse($this->cacheInvalidator->getMapTags()); + $containerId = sprintf('eventmanager-map-%d', (int) ($model->id ?? 0)); $dataElementId = sprintf('%s-data', $containerId); $filterWrapperId = sprintf('%s-filter', $containerId); diff --git a/src/DependencyInjection/EventManagerExtension.php b/src/DependencyInjection/EventManagerExtension.php index fd02f94..660511b 100644 --- a/src/DependencyInjection/EventManagerExtension.php +++ b/src/DependencyInjection/EventManagerExtension.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Mummert\EventManagerBundle\DependencyInjection; +use Mummert\EventManagerBundle\OpenGraph\EventOpenGraphProvider; +use numero2\Opengraph3Bundle\OpenGraph\Provider\ProviderInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -15,5 +17,14 @@ class EventManagerExtension extends Extension { $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); $loader->load('services.yaml'); + + // Open-Graph-Standardwerte für Events, nur wenn numero2/contao-opengraph3 installiert ist. + // Höhere Priorität als dessen eigener CalendarEventsProvider (der erste passende Provider gewinnt). + if (interface_exists(ProviderInterface::class)) { + $container->register(EventOpenGraphProvider::class, EventOpenGraphProvider::class) + ->setAutowired(true) + ->setAutoconfigured(false) + ->addTag('numero2_opengraph3.provider', ['priority' => 10]); + } } } diff --git a/src/EventListener/EventFullTemplateDataListener.php b/src/EventListener/EventFullTemplateDataListener.php index a3410bc..2184bd0 100644 --- a/src/EventListener/EventFullTemplateDataListener.php +++ b/src/EventListener/EventFullTemplateDataListener.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace Mummert\EventManagerBundle\EventListener; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; +use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag; +use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor; use Contao\StringUtil; use Contao\Template; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; +use Mummert\EventManagerBundle\Seo\EventMetaTitle; #[AsHook('parseTemplate', method: 'onParseTemplate')] class EventFullTemplateDataListener @@ -17,6 +20,7 @@ class EventFullTemplateDataListener public function __construct( private readonly Connection $connection, + private readonly ResponseContextAccessor $responseContextAccessor, ) { } @@ -41,6 +45,9 @@ class EventFullTemplateDataListener $organizations = $this->fetchOrganizations($eventId); $tags = $this->fetchTags($eventId); + $this->setMetaTitle($data); + $this->setMetaDescriptionFallback($data, $locationRow); + $template->location_row = $locationRow; $template->organizations = $organizations; $template->tags = $tags; @@ -62,6 +69,75 @@ class EventFullTemplateDataListener $template->tag_fields = $this->toFlatTagFields($tags); } + /** + * Meta-Titel mit Datum ("Titel – 03.12.2026"), damit wiederkehrende Events + * gleichen Namens in den Suchergebnissen unterscheidbar sind. H1 und Listen + * zeigen weiterhin nur den Redaktions-Titel. + * + * @param array $data + */ + private function setMetaTitle(array $data): void + { + $headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class); + + if (!$headBag instanceof HtmlHeadBag) { + return; + } + + $title = EventMetaTitle::build($data); + + if ('' !== $title) { + $headBag->setTitle($title); + } + } + + /** + * Ersatz-Description für Events ohne Teaser/Beschreibung (Redakteure pflegen + * keine Meta-Description): "Titel am 03.12.2026 um 16:30 Uhr in Ort, Stadt." + * + * @param array $data + * @param array $locationRow + */ + private function setMetaDescriptionFallback(array $data, array $locationRow): void + { + $headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class); + + if (!$headBag instanceof HtmlHeadBag) { + return; + } + + // Vorhandene Description behalten, außer sie ist leer oder stammt aus einem Teaser, + // der nur aus einem Insert-Tag besteht (z. B. {{insert_content::ID}} als gemeinsamer + // Serien-Teaser: sonst hätten alle Events der Serie dieselbe Description). + $hasOwnDescription = '' !== trim(strip_tags((string) ($data['description'] ?? ''))); + $teaserIsInsertTagOnly = (bool) preg_match('/^\{\{[^{}]+\}\}$/', trim((string) ($data['teaser'] ?? ''))); + + if ($hasOwnDescription || ('' !== trim($headBag->getMetaDescription()) && !$teaserIsInsertTagOnly)) { + return; + } + + $title = html_entity_decode(strip_tags((string) ($data['title'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $date = EventMetaTitle::formatDate((int) ($data['startDate'] ?? 0), (int) ($data['endDate'] ?? 0)); + + if ('' === $date) { + return; + } + + $description = trim($title).(str_contains($date, '–') ? ' vom '.str_replace('–', ' bis ', $date) : ' am '.$date); + + if (!empty($data['addTime']) && !empty($data['startTime'])) { + $description .= ' um '.date('H:i', (int) $data['startTime']).' Uhr'; + } + + $place = implode(', ', array_filter([trim((string) ($locationRow['title'] ?? '')), trim((string) ($locationRow['city'] ?? ''))])); + + if ('' !== $place) { + $description .= ' in '.$place; + } + + $headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.'); + } + /** @return array */ private function fetchLocationRow(int $locationId): array { diff --git a/src/EventListener/OrganizationListingTemplateDataListener.php b/src/EventListener/OrganizationListingTemplateDataListener.php index c505234..284feef 100644 --- a/src/EventListener/OrganizationListingTemplateDataListener.php +++ b/src/EventListener/OrganizationListingTemplateDataListener.php @@ -18,6 +18,7 @@ class OrganizationListingTemplateDataListener public function __construct( private readonly Connection $connection, private readonly ?LoggerInterface $logger = null, + private readonly \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator $cacheInvalidator, ) { } @@ -27,6 +28,9 @@ class OrganizationListingTemplateDataListener return; } + // Seite bei Änderungen an Organisationen automatisch aus dem Cache werfen + $this->cacheInvalidator->tagOrganizationListing(); + $tbody = $template->tbody; if (!\is_array($tbody) || [] === $tbody) { diff --git a/src/OpenGraph/EventOpenGraphProvider.php b/src/OpenGraph/EventOpenGraphProvider.php new file mode 100644 index 0000000..ca6d52b --- /dev/null +++ b/src/OpenGraph/EventOpenGraphProvider.php @@ -0,0 +1,125 @@ +type; + } + + public function getReference(ModuleModel $model): ?OpenGraphReference + { + $event = CalendarEventsModel::findPublishedByParentAndIdOrAlias( + (string) Input::get('auto_item'), + StringUtil::deserialize($model->cal_calendar, true), + ); + + if (null === $event) { + return null; + } + + $row = $event->row(); + $defaults = [ + 'og_type' => 'website', + 'og_title' => EventMetaTitle::build($row), + 'og_description' => $this->buildDescription($row), + ]; + + if ($event->addImage && $event->singleSRC) { + $defaults['og_image'] = $event->singleSRC; + } + + return new OpenGraphReference($event, $defaults); + } + + /** + * @param array $row + */ + private function buildDescription(array $row): string + { + foreach (['teaser', 'description'] as $field) { + $text = $this->toPlainText((string) ($row[$field] ?? '')); + + if ('' !== $text) { + return $this->truncate($text); + } + } + + // Kein Text gepflegt: Datum, Uhrzeit und Ort als Beschreibung + $parts = [EventMetaTitle::formatDate((int) ($row['startDate'] ?? 0), (int) ($row['endDate'] ?? 0))]; + + if (!empty($row['addTime']) && !empty($row['startTime'])) { + $parts[0] .= ', '.date('H:i', (int) $row['startTime']).' Uhr'; + } + + $locationId = (int) ($row['location_id'] ?? 0); + + if ($locationId > 0) { + $location = $this->connection->fetchAssociative('SELECT title, city FROM tl_location WHERE id = ?', [$locationId]); + + if ($location) { + $parts[] = trim(implode(', ', array_filter([(string) $location['title'], (string) $location['city']]))); + } + } + + return implode(' – ', array_filter($parts)); + } + + private function toPlainText(string $value): string + { + // Leer oder nur ein Insert-Tag (gemeinsamer Serien-Teaser): lieber Datum und Ort + if ('' === trim($value) || preg_match('/^\{\{[^{}]+\}\}$/', trim($value))) { + return ''; + } + + $value = $this->insertTagParser->replaceInline($value); + $value = preg_replace('/<(br|\/p|\/li|\/h[1-6])\b[^>]*>/i', ' ', $value) ?? $value; + $value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/u', ' ', $value)); + } + + private function truncate(string $text): string + { + if (mb_strlen($text) <= self::DESCRIPTION_LENGTH) { + return $text; + } + + $cut = mb_substr($text, 0, self::DESCRIPTION_LENGTH); + $space = mb_strrpos($cut, ' '); + + return rtrim(false !== $space && $space > 120 ? mb_substr($cut, 0, $space) : $cut, " ,.;:–-").' …'; + } +} diff --git a/src/Seo/EventMetaTitle.php b/src/Seo/EventMetaTitle.php new file mode 100644 index 0000000..3ac5a18 --- /dev/null +++ b/src/Seo/EventMetaTitle.php @@ -0,0 +1,60 @@ + und in og:title, nicht in der H1 und nicht + * in den Listen. So bleiben wiederkehrende Veranstaltungen gleichen Namens + * für Suchmaschinen unterscheidbar. + */ +final class EventMetaTitle +{ + /** + * @param array $event Event-Datensatz (title, pageTitle, startDate, endDate) + */ + public static function build(array $event): string + { + $base = self::clean((string) ($event['pageTitle'] ?? '')); + + if ('' === $base) { + $base = self::clean((string) ($event['title'] ?? '')); + } + + $date = self::formatDate((int) ($event['startDate'] ?? 0), (int) ($event['endDate'] ?? 0)); + + if ('' === $date) { + return $base; + } + + return '' === $base ? $date : $base.' – '.$date; + } + + public static function formatDate(int $start, int $end = 0): string + { + if ($start <= 0) { + return ''; + } + + if ($end <= 0 || date('Ymd', $end) === date('Ymd', $start)) { + return date('d.m.Y', $start); + } + + if (date('Y', $end) === date('Y', $start)) { + return date('d.m.', $start).'–'.date('d.m.Y', $end); + } + + return date('d.m.Y', $start).'–'.date('d.m.Y', $end); + } + + private static function clean(string $value): string + { + $value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/u', ' ', $value)); + } +} diff --git a/src/Service/EventRepository.php b/src/Service/EventRepository.php index 90f8abf..6e28952 100644 --- a/src/Service/EventRepository.php +++ b/src/Service/EventRepository.php @@ -11,6 +11,7 @@ class EventRepository { public function __construct( private readonly Connection $connection, + private readonly FrontendCacheInvalidator $cacheInvalidator, ) { } @@ -71,6 +72,8 @@ class EventRepository ['id' => $eventId], ['id' => ParameterType::INTEGER], ); + + $this->cacheInvalidator->invalidateEvent($eventId); } public function duplicate(int $eventId): ?int @@ -138,6 +141,8 @@ class EventRepository $this->assignTagsToEvent($newEventId, $tagIds); } + $this->cacheInvalidator->invalidateEvent($newEventId); + return $newEventId; } @@ -264,6 +269,8 @@ class EventRepository public function delete(int $eventId): void { + $calendarId = (int) $this->connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]); + $this->connection->delete( 'tl_tags_rel', ['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId], @@ -281,6 +288,8 @@ class EventRepository ['id' => $eventId], ['id' => ParameterType::INTEGER], ); + + $this->cacheInvalidator->invalidateEvent($eventId, $calendarId); } /** @return array|null */ @@ -508,6 +517,8 @@ class EventRepository ], ); } + + $this->cacheInvalidator->invalidateEvent($eventId); } /** @@ -525,6 +536,8 @@ class EventRepository 'id' => ParameterType::INTEGER, ], ); + + $this->cacheInvalidator->invalidateEvent($eventId); } /** @@ -573,6 +586,8 @@ class EventRepository $this->assignEventToOrganizations($eventId, $allowedOrganizationIds); + $this->cacheInvalidator->invalidateEvent($eventId); + return $eventId; } @@ -730,6 +745,8 @@ class EventRepository ['id' => $eventId], $types, ); + + $this->cacheInvalidator->invalidateEvent($eventId); } /** @return array */ diff --git a/src/Service/FrontendCacheInvalidator.php b/src/Service/FrontendCacheInvalidator.php new file mode 100644 index 0000000..4061b52 --- /dev/null +++ b/src/Service/FrontendCacheInvalidator.php @@ -0,0 +1,67 @@ +connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]); + + if ($calendarId > 0) { + $tags[] = 'contao.db.tl_calendar.'.$calendarId; + } + + $this->cacheTagManager->invalidateTags($tags); + } + + public function invalidateOrganization(int $organizationId): void + { + $this->cacheTagManager->invalidateTags([self::ORGANIZATION_TAG, self::ORGANIZATION_TAG.'.'.$organizationId]); + } + + /** + * Tags für Seiten, die Events aller Kalender und Organisationen zeigen (Karte). + * + * @return list + */ + public function getMapTags(): array + { + $tags = [self::ORGANIZATION_TAG]; + + foreach ($this->connection->fetchFirstColumn('SELECT id FROM tl_calendar') as $calendarId) { + $tags[] = 'contao.db.tl_calendar.'.$calendarId; + } + + return $tags; + } + + public function tagOrganizationListing(): void + { + $this->cacheTagManager->tagWith([self::ORGANIZATION_TAG]); + } +} diff --git a/src/Service/OrganizationRepository.php b/src/Service/OrganizationRepository.php index b6172d2..75390eb 100644 --- a/src/Service/OrganizationRepository.php +++ b/src/Service/OrganizationRepository.php @@ -11,6 +11,7 @@ class OrganizationRepository { public function __construct( private readonly Connection $connection, + private readonly FrontendCacheInvalidator $cacheInvalidator, ) { } @@ -85,6 +86,8 @@ class OrganizationRepository 'id' => ParameterType::INTEGER, ], ); + + $this->cacheInvalidator->invalidateOrganization($organizationId); } public function updateLogo(int $organizationId, ?string $logoUuid): void @@ -106,6 +109,8 @@ class OrganizationRepository ['id' => $organizationId], $types, ); + + $this->cacheInvalidator->invalidateOrganization($organizationId); } /** @@ -208,6 +213,8 @@ class OrganizationRepository ], ); } + + $this->cacheInvalidator->invalidateOrganization($organizationId); } /** @return array */