Files
VITEC-website/packages/vitec/Classes/UserFunc/NewsJsonRenderer.php

754 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Headless JSON renderer for EXT:news content elements.
*
* The renderer supports the news list/detail plugin variants used in this
* project and emits a stable JSON structure for the frontend.
*
* Exception-safe: returns '' on any failure.
*/
final class NewsJsonRenderer
{
private const NEWS_CTYPES = [
'news_pi1',
'news_newsliststicky',
'news_newsselectedlist',
'news_newsdetail',
'news_newsdatemenu',
'news_categorylist',
'news_newssearchform',
'news_newssearchresult',
'news_taglist',
];
private const TEMPLATE_LAYOUTS = [
'1' => '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<string,mixed> $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<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>|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<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param int|null $limitOverride
* @return array<int,array<string,mixed>>
*/
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<string,mixed>|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<string,mixed> $news
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
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' => RteResolver::html($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<string,mixed> $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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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<int> $categoryUids
* @return array<int,true>
*/
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<int> $tagUids
* @return array<int,true>
*/
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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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<int,array<string,mixed>>
*/
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, 'fileExtension' => 'webp']);
$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<int>
*/
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<int> $categoryUids
* @return list<int>
*/
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<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
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);
}
}