data or page discovery). * `renderForRecord()` = one specific tt_content row; reused by * ContainerChildrenProcessor / ContentElementResolver. Exception-safe. */ class EventlistJsonRenderer { /** * Parent of the region categories. Hard-coded like the other taxonomy roots * in this extension (Annex B-5 tracks them); making it a FlexForm setting * would put a raw uid in front of editors for a value that belongs to the * content model, not to a single content element. */ private const REGION_PARENT_CATEGORY = 104; #[AsAllowedCallable] public function render(string $content, array $conf): string { // 1) cObj data path $row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null; if ($row && (string)($row['CType'] ?? '') === 'vitec_eventlist') { return $this->renderForRecord($row); } // 2) Page discovery via v14 request attribute $pageId = 0; $request = $GLOBALS['TYPO3_REQUEST'] ?? null; if ($request !== null) { $pageInfo = $request->getAttribute('frontend.page.information'); if ($pageInfo !== null) { $pageId = (int)$pageInfo->getId(); } } if ($pageId <= 0) { $pageId = (int)($GLOBALS['TSFE']->id ?? 0); } if ($pageId <= 0) { return ''; } $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $contentElements = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)), $qb->expr()->eq('CType', $qb->createNamedParameter('vitec_eventlist', ParameterType::STRING)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAllAssociative(); if (empty($contentElements)) { return ''; } return $this->renderForRecord($contentElements[0]); } /** * @param array $contentElement */ public function renderForRecord(array $contentElement): string { try { $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); $settings = $flexFormData['settings'] ?? []; $showPast = (bool)($settings['showpast'] ?? false); $daysInAdvance = (int)($settings['daysinadvance'] ?? 0); $limit = (int)($settings['limit'] ?? 0); $debugMode = (bool)($settings['debug'] ?? false); $layout = (string)($settings['layout'] ?? 'list'); $upcoming = $this->fetchEvents(true, $limit, $daysInAdvance); $response = [ 'events' => array_map(fn($e) => $this->serializeEvent($e), $upcoming), 'settings' => [ 'layout' => $layout, 'showpast' => $showPast, 'daysinadvance' => $daysInAdvance, 'limit' => $limit, ], ]; if ($layout === 'regions') { $response['regions'] = $this->fetchRegions($daysInAdvance); } if ($showPast) { $past = $this->fetchEvents(false, $limit); $response['pastEvents'] = array_map(fn($e) => $this->serializeEvent($e), $past); } if ($debugMode) { $response['debug'] = [ 'upcomingCount' => count($upcoming), 'settings' => $settings, ]; if (isset($response['regions'])) { $response['debug']['regionCount'] = count($response['regions']); } } return json_encode($response); } catch (\Throwable $e) { return ''; } } /** * @return array> */ private function fetchEvents(bool $upcoming, int $limit, int $daysInAdvance = 0): array { $todayMidnight = strtotime('today'); $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_event'); $expr = $qb->expr(); $qb ->select('*') ->from('tx_vitec_domain_model_event') ->where( $expr->eq('deleted', 0), $expr->eq('hidden', 0), $expr->eq('hideonwebsite', 0) ); if ($upcoming) { // running or future: end >= today, or (no end and start >= today) $qb->andWhere( $expr->or( $expr->gte('eventend', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)), $expr->and( $expr->eq('eventend', 0), $expr->gte('eventstart', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)) ) ) ); $qb->orderBy('eventstart', 'ASC'); if ($daysInAdvance > 0) { $qb->andWhere( $expr->lt('eventstart', $qb->createNamedParameter($todayMidnight + ($daysInAdvance * 86400), ParameterType::INTEGER)) ); } } else { // past: (end > 0 and end < today) or (no end and 0 < start < today) $qb->andWhere( $expr->or( $expr->and( $expr->gt('eventend', 0), $expr->lt('eventend', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)) ), $expr->and( $expr->eq('eventend', 0), $expr->gt('eventstart', 0), $expr->lt('eventstart', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)) ) ) ); $qb->orderBy('eventstart', 'DESC'); } if ($limit > 0) { $qb->setMaxResults($limit); } return $qb->executeQuery()->fetchAllAssociative(); } /** * Regions for the `regions` layout: the categories below * REGION_PARENT_CATEGORY that have at least one upcoming event, each * carrying the image of its next one as the card logo. * * A region without an upcoming event is left out entirely -- that is the * whole point of the layout, so `showpast` does not resurrect it. `limit` * stays out too: it caps events, and applying it before the grouping would * drop whole regions from the carousel without the editor seeing why. * * Order is the backend sorting of the categories, so the editor arranges the * carousel by dragging them; `nextEvent.eventstart` is in the payload for a * frontend that would rather sort chronologically. * * @return array> */ private function fetchRegions(int $daysInAdvance): array { $regions = $this->fetchRegionCategories(); if ($regions === []) { return []; } // Every category below a region -- the region itself included -- points // at that region, so an event filed under a sub-category still counts. $regionByCategory = []; foreach ($regions as $regionUid => $region) { foreach ($region['categoryUids'] as $categoryUid) { $regionByCategory[$categoryUid] = $regionUid; } } $rows = $this->fetchUpcomingEventsByCategory(array_keys($regionByCategory), $daysInAdvance); // One row per event/category pair, sorted by eventstart -- so the first // row seen for a region is its next event, and the ??= keeps it. $eventsPerRegion = []; foreach ($rows as $row) { $regionUid = $regionByCategory[(int)$row['uid_local']] ?? null; if ($regionUid === null) { continue; } $eventsPerRegion[$regionUid][(int)$row['uid']] ??= $row; } $result = []; foreach ($regions as $regionUid => $region) { $regionEvents = $eventsPerRegion[$regionUid] ?? []; if ($regionEvents === []) { continue; } $next = reset($regionEvents); $start = (int)($next['eventstart'] ?? 0); $end = (int)($next['eventend'] ?? 0); $result[] = [ 'uid' => $regionUid, 'title' => $region['title'], // Plain text, not RTE: sys_category descriptions are named as // out of scope for the richtext conversion in Clause 9.11. 'description' => $region['description'], 'eventCount' => count($regionEvents), 'image' => $this->getEventImage((int)$next['uid']), 'nextEvent' => [ 'uid' => (int)$next['uid'], 'title' => (string)($next['title'] ?? ''), 'slug' => (string)($next['slug'] ?? ''), 'eventstart' => $start > 0 ? date('Y-m-d', $start) : null, 'eventend' => $end > 0 ? date('Y-m-d', $end) : null, 'eventurl' => (string)($next['eventurl'] ?? ''), ], ]; } return $result; } /** * The region categories, keyed by uid and in backend sorting order, each * with its own uid plus every descendant. * * sys_category is read in one go rather than per level: the table is small, * and CustomerlogosJsonRenderer and MarketListJsonRenderer make the same * trade-off. * * @return array */ private function fetchRegionCategories(): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); $rows = $qb ->select('uid', 'title', 'description', 'parent') ->from('sys_category') ->where( $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->orderBy('sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); $byUid = []; $childrenByParent = []; foreach ($rows as $row) { $uid = (int)$row['uid']; $byUid[$uid] = $row; $childrenByParent[(int)($row['parent'] ?? 0)][] = $uid; } $regions = []; foreach ($childrenByParent[self::REGION_PARENT_CATEGORY] ?? [] as $regionUid) { $regions[$regionUid] = [ 'title' => (string)($byUid[$regionUid]['title'] ?? ''), 'description' => (string)($byUid[$regionUid]['description'] ?? ''), 'categoryUids' => $this->collectCategoryBranch($regionUid, $childrenByParent), ]; } return $regions; } /** * A category plus every descendant, breadth first. Iterative and * seen-guarded: a parent pointing back up the tree would otherwise loop. * * @param array $childrenByParent * @return int[] */ private function collectCategoryBranch(int $uid, array $childrenByParent): array { $collected = [$uid]; $seen = [$uid => true]; $queue = [$uid]; while ($queue !== []) { $current = array_shift($queue); foreach ($childrenByParent[$current] ?? [] as $childUid) { if (isset($seen[$childUid])) { continue; } $seen[$childUid] = true; $collected[] = $childUid; $queue[] = $childUid; } } return $collected; } /** * Upcoming events assigned to any of the given categories, soonest first. * One row per event/category pair, so an event in two regions appears in * both. The "upcoming" test is the one fetchEvents() uses. * * @param int[] $categoryUids * @return array> */ private function fetchUpcomingEventsByCategory(array $categoryUids, int $daysInAdvance): array { if ($categoryUids === []) { return []; } $todayMidnight = strtotime('today'); $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_event'); $expr = $qb->expr(); $qb ->select('e.uid', 'e.title', 'e.slug', 'e.eventstart', 'e.eventend', 'e.eventurl') ->addSelect('mm.uid_local') ->from('tx_vitec_domain_model_event', 'e') ->join( 'e', 'sys_category_record_mm', 'mm', 'mm.uid_foreign = e.uid AND mm.tablenames = ' . $qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING) . ' AND mm.fieldname = ' . $qb->createNamedParameter('categories', ParameterType::STRING) ) ->where( $expr->in('mm.uid_local', $qb->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)), $expr->eq('e.deleted', 0), $expr->eq('e.hidden', 0), $expr->eq('e.hideonwebsite', 0), $expr->or( $expr->gte('e.eventend', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)), $expr->and( $expr->eq('e.eventend', 0), $expr->gte('e.eventstart', $qb->createNamedParameter($todayMidnight, ParameterType::INTEGER)) ) ) ) ->orderBy('e.eventstart', 'ASC'); if ($daysInAdvance > 0) { $qb->andWhere( $expr->lt('e.eventstart', $qb->createNamedParameter($todayMidnight + ($daysInAdvance * 86400), ParameterType::INTEGER)) ); } return $qb->executeQuery()->fetchAllAssociative(); } /** * @param array $event * @return array */ protected function serializeEvent(array $event): array { $uid = (int)$event['uid']; $start = (int)($event['eventstart'] ?? 0); $end = (int)($event['eventend'] ?? 0); return [ 'uid' => $uid, 'title' => (string)($event['title'] ?? ''), 'slug' => (string)($event['slug'] ?? ''), 'teaser' => (string)($event['teaser'] ?? ''), 'description' => RteResolver::html($event['description'] ?? ''), 'eventstart' => $start > 0 ? date('Y-m-d', $start) : null, 'eventend' => $end > 0 ? date('Y-m-d', $end) : null, 'venue' => (string)($event['venue'] ?? ''), 'booth' => (string)($event['booth'] ?? ''), 'city' => (string)($event['city'] ?? ''), 'country' => (string)($event['country'] ?? ''), 'attendancemode' => (string)($event['attendancemode'] ?? 'offline'), 'eventstatus' => (string)($event['eventstatus'] ?? 'scheduled'), 'eventurl' => (string)($event['eventurl'] ?? ''), 'meetinglink' => (string)($event['meetinglink'] ?? ''), 'image' => $this->getEventImage($uid), 'categories' => $this->getEventCategories($uid), ]; } /** * @return array|null */ protected function getEventImage(int $eventUid): ?array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $fileRefData = $qb ->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop') ->from('sys_file_reference', 'sfr') ->where( $qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING)), $qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter('image', ParameterType::STRING)), $qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)), $qb->expr()->eq('sfr.deleted', 0), $qb->expr()->eq('sfr.hidden', 0) ) ->orderBy('sfr.sorting_foreign') ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!$fileRefData) { return null; } try { $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $imageService = GeneralUtility::makeInstance(ImageService::class); $fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']); $srcset = []; foreach ([400, 800] as $width) { $variant = $imageService->applyProcessingInstructions( $fileReference, ['width' => $width, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp'] ); $srcset[] = [ 'url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w', ]; } $default = $imageService->applyProcessingInstructions( $fileReference, ['width' => 400, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp'] ); return [ 'uid' => (int)$fileRefData['uid'], 'url' => $imageService->getImageUri($default), 'title' => $fileRefData['title'] ?? '', 'alternative' => $fileRefData['alternative'] ?? '', 'description' => $fileRefData['description'] ?? '', 'srcset' => $srcset, 'properties' => [ 'width' => $fileReference->getProperty('width'), 'height' => $fileReference->getProperty('height'), 'mimeType' => $fileReference->getProperty('mime_type'), ], ]; } catch (\Exception $e) { return null; } } protected function getEventCategories(int $eventUid): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); $categories = $qb ->select('c.uid', 'c.title', 'c.description') ->from('sys_category', 'c') ->join( 'c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid AND mm.tablenames = ' . $qb->createNamedParameter('tx_vitec_domain_model_event', ParameterType::STRING) . ' AND mm.fieldname = ' . $qb->createNamedParameter('categories', ParameterType::STRING) ) ->where( $qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($eventUid, ParameterType::INTEGER)), $qb->expr()->eq('c.deleted', 0), $qb->expr()->eq('c.hidden', 0) ) ->orderBy('mm.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); return array_map(static function ($cat) { return [ 'uid' => (int)$cat['uid'], 'title' => (string)($cat['title'] ?? ''), 'description' => (string)($cat['description'] ?? ''), ]; }, $categories); } }