'Compact List', '2' => 'Featured Story', '3' => 'Editorial Grid', '4' => 'Detail Spotlight', ]; private const TEMPLATE_LAYOUT_DEFAULTS = [ 'news_pi1' => '1', 'news_newsliststicky' => '1', 'news_newsselectedlist' => '1', 'news_newsdetail' => '2', 'news_newsdatemenu' => '3', 'news_categorylist' => '3', 'news_newssearchform' => '4', 'news_newssearchresult' => '4', 'news_taglist' => '3', ]; #[AsAllowedCallable] public function render(string $content, array $conf): string { try { $row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null; if ($row !== null && in_array((string)($row['CType'] ?? ''), self::NEWS_CTYPES, true)) { return $this->renderForRecord($row); } $pageId = $this->resolveCurrentPageId(); if ($pageId <= 0) { return ''; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $ctypeConstraints = []; foreach (self::NEWS_CTYPES as $ctype) { $ctypeConstraints[] = $queryBuilder->expr()->eq( 'CType', $queryBuilder->createNamedParameter($ctype, ParameterType::STRING) ); } $contentElements = $queryBuilder ->select('*') ->from('tt_content') ->where( $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)), $queryBuilder->expr()->or(...$ctypeConstraints), $queryBuilder->expr()->eq('deleted', 0), $queryBuilder->expr()->eq('hidden', 0) ) ->orderBy('sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); if ($contentElements === []) { return ''; } return $this->renderForRecord($contentElements[0]); } catch (\Throwable $e) { return ''; } } /** * Render exactly the given news tt_content row. * * @param array $contentElement */ public function renderForRecord(array $contentElement): string { try { $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); $settings = $flexFormData['settings'] ?? []; $type = (string)($contentElement['CType'] ?? ''); $mode = $type === 'news_newsdetail' ? 'detail' : 'list'; $debug = (bool)($settings['debug'] ?? false); if ($mode === 'detail') { $news = $this->resolveDetailNews($contentElement, $settings); $response = [ 'mode' => 'detail', 'news' => $news !== null ? $this->serializeNews($news, $settings) : null, 'settings' => $this->buildSettingsPayload($contentElement, $settings), ]; if ($debug) { $response['debug'] = [ 'contentElementUid' => (int)($contentElement['uid'] ?? 0), 'settings' => $settings, ]; } return json_encode($response) ?: ''; } $newsRows = $this->fetchNewsList($contentElement, $settings); $items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows); $response = [ 'mode' => 'list', 'items' => $items, 'settings' => $this->buildSettingsPayload($contentElement, $settings), ]; if ($debug) { $response['debug'] = [ 'contentElementUid' => (int)($contentElement['uid'] ?? 0), 'count' => count($items), 'settings' => $settings, ]; } return json_encode($response) ?: ''; } catch (\Throwable $e) { return json_encode([ 'mode' => 'error', 'message' => $e->getMessage(), ]) ?: ''; } } /** * @param array $contentElement * @param array $settings * @return array|null */ private function resolveDetailNews(array $contentElement, array $settings): ?array { $newsUid = $this->resolveNewsUid((string)($settings['singleNews'] ?? '')); if ($newsUid <= 0) { $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? []; $newsParam = $queryParams['tx_news_pi1']['news'] ?? null; $newsUid = $this->resolveNewsUid((string)($newsParam ?? '')); } if ($newsUid > 0) { $news = $this->fetchNewsByUid($newsUid, (int)($contentElement['sys_language_uid'] ?? 0)); if ($news !== null) { return $news; } } $list = $this->fetchNewsList($contentElement, $settings, 1); return $list[0] ?? null; } /** * @param array $contentElement * @param array $settings * @param int|null $limitOverride * @return array> */ private function fetchNewsList(array $contentElement, array $settings, ?int $limitOverride = null): array { $selectedNews = $this->parseUidList((string)($settings['selectedList'] ?? '')); $storagePids = $this->parseUidList((string)($settings['startingpoint'] ?? '')); if ($storagePids === []) { $storagePids = [(int)($contentElement['pid'] ?? 0)]; } $categoryUids = $this->parseUidList((string)($settings['categories'] ?? '')); $includeSubCategories = (bool)($settings['includeSubCategories'] ?? false); $tagUids = $this->parseUidList((string)($settings['tags'] ?? '')); $limit = $limitOverride ?? (int)($settings['limit'] ?? 0); $orderDirection = strtoupper((string)($settings['orderDirection'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; $topNewsFirst = (bool)($settings['topNewsFirst'] ?? false); $searchTerm = $this->resolveSearchTerm((string)($contentElement['CType'] ?? '')); $languageUid = (int)($contentElement['sys_language_uid'] ?? 0); $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_news'); $rows = $queryBuilder ->select('n.*') ->from('tx_news_domain_model_news', 'n') ->where( $queryBuilder->expr()->eq('n.deleted', 0), $queryBuilder->expr()->eq('n.hidden', 0), $queryBuilder->expr()->in('n.pid', $queryBuilder->createNamedParameter($storagePids, ArrayParameterType::INTEGER)) ) ->orderBy('n.istopnews', $topNewsFirst ? 'DESC' : 'ASC') ->addOrderBy('n.datetime', 'DESC') ->executeQuery() ->fetchAllAssociative(); if ($languageUid > 0) { $rows = array_values(array_filter($rows, static fn (array $row): bool => (int)($row['sys_language_uid'] ?? 0) === $languageUid)); } else { $rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)($row['sys_language_uid'] ?? 0), [0, -1], true))); } if ($selectedNews !== []) { $selectedLookup = array_fill_keys($selectedNews, true); $rows = array_values(array_filter($rows, static fn (array $row): bool => isset($selectedLookup[(int)($row['uid'] ?? 0)]))); } if ($categoryUids !== []) { $categoryLookup = $this->newsUidsForCategories($categoryUids, $includeSubCategories); if ($categoryLookup === [] && !$includeSubCategories) { $categoryLookup = $this->newsUidsForCategories($this->expandCategoryUids($categoryUids), true); } if ($categoryLookup !== []) { $rows = array_values(array_filter($rows, static fn (array $row): bool => isset($categoryLookup[(int)($row['uid'] ?? 0)]))); } } if ($tagUids !== []) { $tagLookup = $this->newsUidsForTags($tagUids); if ($tagLookup !== []) { $rows = array_values(array_filter($rows, static fn (array $row): bool => isset($tagLookup[(int)($row['uid'] ?? 0)]))); } } if ($searchTerm !== '') { $needle = mb_strtolower($searchTerm); $rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool { $haystack = mb_strtolower((string)($row['title'] ?? '') . ' ' . (string)($row['teaser'] ?? '') . ' ' . (string)($row['path_segment'] ?? '')); return str_contains($haystack, $needle); })); } if ($limit > 0) { $rows = array_slice($rows, 0, $limit); } return $rows; } /** * @param int $newsUid * @param int $languageUid * @return array|null */ private function fetchNewsByUid(int $newsUid, int $languageUid): ?array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_news'); $query = $queryBuilder ->select('n.*') ->from('tx_news_domain_model_news', 'n') ->where( $queryBuilder->expr()->eq('n.uid', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('n.deleted', 0), $queryBuilder->expr()->eq('n.hidden', 0) ); if ($languageUid > 0) { $query->andWhere( $queryBuilder->expr()->eq('n.sys_language_uid', $queryBuilder->createNamedParameter($languageUid, ParameterType::INTEGER)) ); } $news = $query->executeQuery()->fetchAssociative(); return is_array($news) ? $news : null; } /** * @param array $news * @param array $settings * @return array */ private function serializeNews(array $news, array $settings = []): array { $uid = (int)($news['uid'] ?? 0); $pathSegment = (string)($news['path_segment'] ?? ''); $urls = $this->buildNewsUrls($pathSegment, $settings); return [ 'uid' => $uid, 'title' => (string)($news['title'] ?? ''), 'alternativeTitle' => (string)($news['alternative_title'] ?? ''), 'pathSegment' => $pathSegment, 'detailUrl' => $urls['detailUrl'], 'canonicalUrl' => $urls['canonicalUrl'], 'teaser' => (string)($news['teaser'] ?? ''), 'bodytext' => (string)($news['bodytext'] ?? ''), 'datetime' => (int)($news['datetime'] ?? 0), 'archive' => (int)($news['archive'] ?? 0), 'istopnews' => (bool)($news['istopnews'] ?? false), 'author' => (string)($news['author'] ?? ''), 'authorEmail' => (string)($news['author_email'] ?? ''), 'categories' => $this->resolveCategories($uid), 'tags' => $this->resolveTags($uid), 'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid), 'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid), 'related' => $this->resolveRelatedNews($uid), 'relatedLinks' => $this->resolveRelatedLinks($uid), ]; } /** * @param array $settings * @return array{detailUrl:string, canonicalUrl:string} */ private function buildNewsUrls(string $pathSegment, array $settings): array { $pathSegment = trim($pathSegment, '/'); if ($pathSegment === '') { return ['detailUrl' => '', 'canonicalUrl' => '']; } $detailPid = (int)($settings['detailPid'] ?? 0); if ($detailPid <= 0) { $detailPid = $this->resolveCurrentPageId(); } $detailPageSlug = $detailPid > 0 ? $this->resolvePageSlug($detailPid) : ''; if ($detailPageSlug === '') { return ['detailUrl' => '', 'canonicalUrl' => '']; } $detailUrl = rtrim($detailPageSlug, '/') . '/' . $pathSegment; $canonicalUrl = $this->absoluteUrl($detailUrl); return [ 'detailUrl' => $detailUrl, 'canonicalUrl' => $canonicalUrl, ]; } private function resolvePageSlug(int $pageId): string { if ($pageId <= 0) { return ''; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('pages'); $row = $queryBuilder ->select('slug') ->from('pages') ->where( $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)), $queryBuilder->expr()->eq('deleted', 0), $queryBuilder->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); return (string)($row['slug'] ?? ''); } private function absoluteUrl(string $path): string { $path = '/' . ltrim($path, '/'); $request = $GLOBALS['TYPO3_REQUEST'] ?? null; if ($request !== null) { $normalizedParams = $request->getAttribute('normalizedParams'); if ($normalizedParams !== null) { return rtrim($normalizedParams->getSiteUrl(), '/') . $path; } } $site = $request?->getAttribute('site'); if ($site !== null) { return rtrim((string)$site->getBase(), '/') . $path; } return $path; } /** * @return array> */ private function resolveCategories(int $newsUid): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); return $queryBuilder ->select('c.uid', 'c.title') ->from('sys_category', 'c') ->join( 'c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid' ) ->where( $queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)), $queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)) ) ->orderBy('c.title', 'ASC') ->executeQuery() ->fetchAllAssociative(); } /** * @return array> */ private function resolveTags(int $newsUid): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_tag'); return $queryBuilder ->select('t.uid', 't.title') ->from('tx_news_domain_model_tag', 't') ->join( 't', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = t.uid' ) ->where( $queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)) ) ->orderBy('t.title', 'ASC') ->executeQuery() ->fetchAllAssociative(); } /** * @param list $categoryUids * @return array */ private function newsUidsForCategories(array $categoryUids, bool $includeSubCategories): array { if ($categoryUids === []) { return []; } $categories = $includeSubCategories ? $this->expandCategoryUids($categoryUids) : $categoryUids; if ($categories === []) { return []; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category_record_mm'); $uids = $queryBuilder ->select('uid_foreign') ->from('sys_category_record_mm') ->where( $queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)), $queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)), $queryBuilder->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER)) ) ->executeQuery() ->fetchFirstColumn(); return array_fill_keys(array_map('intval', $uids), true); } /** * @param list $tagUids * @return array */ private function newsUidsForTags(array $tagUids): array { if ($tagUids === []) { return []; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_news_tag_mm'); $uids = $queryBuilder ->select('uid_local') ->from('tx_news_domain_model_news_tag_mm') ->where( $queryBuilder->expr()->in('uid_foreign', $queryBuilder->createNamedParameter($tagUids, ArrayParameterType::INTEGER)) ) ->executeQuery() ->fetchFirstColumn(); return array_fill_keys(array_map('intval', $uids), true); } /** * @return array> */ private function resolveRelatedNews(int $newsUid): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_news'); return $queryBuilder ->select('n.uid', 'n.title', 'n.path_segment') ->from('tx_news_domain_model_news', 'n') ->join( 'n', 'tx_news_domain_model_news_related_mm', 'mm', 'mm.uid_foreign = n.uid' ) ->where( $queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('n.deleted', 0), $queryBuilder->expr()->eq('n.hidden', 0) ) ->orderBy('n.datetime', 'DESC') ->executeQuery() ->fetchAllAssociative(); } /** * @return array> */ private function resolveRelatedLinks(int $newsUid): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_link'); return $queryBuilder ->select('l.uid', 'l.title', 'l.description', 'l.uri') ->from('tx_news_domain_model_link', 'l') ->where( $queryBuilder->expr()->eq('l.parent', $queryBuilder->createNamedParameter($newsUid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('l.deleted', 0), $queryBuilder->expr()->eq('l.hidden', 0) ) ->orderBy('l.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); } /** * @return array> */ private function resolveFiles(string $tableName, string $fieldName, int $uid): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $rows = $queryBuilder ->select('uid') ->from('sys_file_reference') ->where( $queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter($tableName, ParameterType::STRING)), $queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)), $queryBuilder->expr()->eq('deleted', 0), $queryBuilder->expr()->eq('hidden', 0) ) ->orderBy('sorting_foreign', 'ASC') ->executeQuery() ->fetchAllAssociative(); if ($rows === []) { return []; } $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $imageService = GeneralUtility::makeInstance(ImageService::class); $files = []; foreach ($rows as $row) { try { $fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']); $processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1400]); $files[] = [ 'uid' => (int)$row['uid'], 'url' => $imageService->getImageUri($processed), ]; } catch (\Throwable $e) { continue; } } return $files; } private function resolveNewsUid(string $rawValue): int { $rawValue = trim($rawValue); if ($rawValue === '') { return 0; } if (ctype_digit($rawValue)) { return (int)$rawValue; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_news_domain_model_news'); $row = $queryBuilder ->select('uid') ->from('tx_news_domain_model_news') ->where( $queryBuilder->expr()->eq('path_segment', $queryBuilder->createNamedParameter($rawValue, ParameterType::STRING)), $queryBuilder->expr()->eq('deleted', 0), $queryBuilder->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); return (int)($row['uid'] ?? 0); } /** * @return list */ private function parseUidList(string $rawValue): array { $values = array_filter(array_map('trim', explode(',', $rawValue))); return array_values(array_filter(array_map(static fn (string $value): int => (int)$value, $values))); } /** * @param list $categoryUids * @return list */ private function expandCategoryUids(array $categoryUids): array { if ($categoryUids === []) { return []; } $allUids = array_fill_keys($categoryUids, true); $queue = $categoryUids; $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); while ($queue !== []) { $currentUid = array_shift($queue); if ($currentUid === null) { continue; } $children = $queryBuilder ->select('uid') ->from('sys_category') ->where( $queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($currentUid, ParameterType::INTEGER)), $queryBuilder->expr()->eq('deleted', 0) ) ->executeQuery() ->fetchFirstColumn(); foreach ($children as $childUid) { $childUid = (int)$childUid; if ($childUid <= 0 || isset($allUids[$childUid])) { continue; } $allUids[$childUid] = true; $queue[] = $childUid; } } return array_values(array_map('intval', array_keys($allUids))); } private function resolveCurrentPageId(): int { $request = $GLOBALS['TYPO3_REQUEST'] ?? null; if ($request !== null) { $pageInfo = $request->getAttribute('frontend.page.information'); if ($pageInfo !== null) { return (int)$pageInfo->getId(); } } return (int)($GLOBALS['TSFE']->id ?? 0); } /** * @param array $contentElement * @param array $settings * @return array */ private function buildSettingsPayload(array $contentElement, array $settings): array { $payload = $settings; $payload['type'] = (string)($contentElement['CType'] ?? ''); $payload['detailPid'] = (int)($settings['detailPid'] ?? 0); $payload['listPid'] = (int)($settings['listPid'] ?? 0); $payload['templateLayout'] = $this->resolveTemplateLayoutValue((string)($contentElement['CType'] ?? ''), (string)($settings['templateLayout'] ?? '')); $payload['templateLayoutLabel'] = self::TEMPLATE_LAYOUTS[$payload['templateLayout']] ?? ''; $payload['templateLayouts'] = array_map( static fn (string $label, string $key): array => ['key' => $key, 'label' => $label], self::TEMPLATE_LAYOUTS, array_keys(self::TEMPLATE_LAYOUTS) ); return $payload; } private function resolveTemplateLayoutValue(string $type, string $value): string { $value = trim($value); if ($value !== '' && isset(self::TEMPLATE_LAYOUTS[$value])) { return $value; } return self::TEMPLATE_LAYOUT_DEFAULTS[$type] ?? ''; } private function resolveSearchTerm(string $type): string { if ($type !== 'news_newssearchresult') { return ''; } $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? []; $search = $queryParams['tx_news_pi1']['search']['sword'] ?? ''; return trim((string)$search); } }