Die SEO-Artikel trugen (a) eine FAQ-Sektion als nackte <p>-Paare im
Bodytext und (b) ~150 Links auf die alte datapath.com-Struktur
(/datapath-products/..., alte /markets/...-Namen, /video-wall-solutions)
- alles 404 auf der neuen Seite.
- NewsJsonRenderer (Detail-Modus): extractFaqBand() schneidet die
'Frequently asked questions'-Sektion aus dem Bodytext und liefert sie
strukturiert als faq {heading, items[{question, answer(HTML)}]} fuers
Frontend-Accordion (fail-safe: unter 2 sauberen Paaren bleibt alles
unveraendert); collectMentionedProducts() liefert bis zu 6 Karten
(title, text, image, link) fuer im Text verlinkte Produkt- und
Kategorieseiten. Beide Felder additiv; Listen unveraendert.
- Migration fix_datapath_links_20261110: 148 News-Records auf die neuen
Ziele umgeschrieben (Mapping alte Datapath-IA -> /product/<slug> bzw.
/products/<slug>/Markets/Solutions, Ziele gegen DB verifiziert) und
113 sys_redirects 301 angelegt (alte Pfade mit und ohne Slash - greift
fuer externe Backlinks, sobald datapath.com hierher zeigt).
Live verifiziert: Beispielartikel liefert faq (8 Fragen, Antworten mit
funktionierenden Links), 4 Karten, Bodytext ohne FAQ-Absaetze und ohne
Datapath-Links; normale News unveraendert (faq null); Redirects 301 ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1224 lines
47 KiB
PHP
1224 lines
47 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\LinkResolver;
|
|
use Evomedien\Vitec\Service\RteResolver;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* 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 ?ContentObjectRenderer $cObj = null;
|
|
|
|
/**
|
|
* TYPO3 v14 hands the ContentObjectRenderer over through this setter only -
|
|
* ContentObjectRenderer::callUserFunction() duck-types it with
|
|
* is_callable([$classObj, 'setContentObjectRenderer']). Without the method
|
|
* $this->cObj stays null, the cObj branch of render() never fires and the
|
|
* call falls through to page discovery, which picks the FIRST element of
|
|
* this CType on the page rather than the one actually being rendered.
|
|
*/
|
|
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
|
{
|
|
$this->cObj = $cObj;
|
|
}
|
|
|
|
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) ?: '';
|
|
}
|
|
|
|
// Server-side pagination for the big overview list (contract
|
|
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
|
|
// WITHOUT a hand-picked selection paginates. That is exactly the
|
|
// /insights/news element (#130); every teaser list carries a
|
|
// limit (1-3) and keeps today's full behaviour, as do selected/
|
|
// search lists - the frontend falls back to client-side paging
|
|
// wherever the `pagination` key is absent.
|
|
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
|
|
&& (int)($settings['limit'] ?? 0) === 0
|
|
&& trim((string)($settings['selectedList'] ?? '')) === '';
|
|
|
|
if ($paginated
|
|
&& $this->cObj !== null
|
|
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
|
|
) {
|
|
// `page`/`category` sit in cacheHash.excludedParameters, so the
|
|
// TYPO3 page cache does NOT distinguish the query variants -
|
|
// a cacheable render would bake page 1 in for every URL. As
|
|
// USER_INT the element renders per request; EXT:headless wraps
|
|
// the INT marker itself (JsonContentObject detects
|
|
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
|
|
// variant per path+query, so per-request cost stays off the
|
|
// visitors' hot path.
|
|
$this->cObj->convertToUserIntObject();
|
|
return '';
|
|
}
|
|
|
|
$newsRows = $this->fetchNewsList($contentElement, $settings);
|
|
|
|
if ($paginated) {
|
|
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
|
|
}
|
|
|
|
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $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 = [], string $mode = 'detail'): array
|
|
{
|
|
$uid = (int)($news['uid'] ?? 0);
|
|
$pathSegment = (string)($news['path_segment'] ?? '');
|
|
$urls = $this->buildNewsUrls($news, $settings);
|
|
|
|
// Detail view only (contract 0083-BE): the SEO articles carry a
|
|
// "Frequently asked questions" section as bare <p> pairs plus inline
|
|
// product links. The Q&A pairs move into a structured `faq` band
|
|
// (accordion in the frontend), linked products/categories become
|
|
// `mentionedProducts` cards; the body keeps everything else.
|
|
$detailBody = null;
|
|
$faqBand = null;
|
|
$mentionedProducts = [];
|
|
if ($mode !== 'list') {
|
|
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
|
|
$faqSplit = $this->extractFaqBand($detailBody);
|
|
$detailBody = $faqSplit['html'];
|
|
$faqBand = $faqSplit['faq'];
|
|
$mentionedProducts = $this->collectMentionedProducts($detailBody);
|
|
}
|
|
|
|
return [
|
|
'uid' => $uid,
|
|
'title' => (string)($news['title'] ?? ''),
|
|
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
|
|
'pathSegment' => $pathSegment,
|
|
'type' => (string)($news['type'] ?? '0'),
|
|
'detailUrl' => $urls['detailUrl'],
|
|
'canonicalUrl' => $urls['canonicalUrl'],
|
|
'teaser' => (string)($news['teaser'] ?? ''),
|
|
// List payloads do not carry the full article body: with ~600
|
|
// records that inflated /_json/insights/news to 2.6 MB (and the
|
|
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
|
|
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
|
|
// card goes blank. The detail mode is unchanged.
|
|
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
|
|
// paragraph) are split out of the body - they reach Google via
|
|
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
|
|
'bodytext' => $mode === 'list'
|
|
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
|
|
: (string)$detailBody,
|
|
'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),
|
|
'faq' => $faqBand,
|
|
'mentionedProducts' => $mentionedProducts,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
|
|
* page's cards plus pagination meta, filter chips with full-list counts
|
|
* and the active category. Request: ?page=<n>&category=<slug> on the page
|
|
* URL; page is clamped into [1..pages] (page > pages serves the last
|
|
* page), an unknown category slug falls back to "all".
|
|
*
|
|
* @param array<string,mixed> $contentElement
|
|
* @param array<string,mixed> $settings
|
|
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
|
|
*/
|
|
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
|
|
{
|
|
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
|
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
|
|
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
|
|
|
|
// All categories of all entries in ONE query - feeds both the filter
|
|
// chips (counts over the FULL list, independent of the active filter)
|
|
// and the per-card category labels.
|
|
$categoriesByNews = $this->categoriesForNewsUids(
|
|
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
|
|
);
|
|
|
|
$categoryIndex = [];
|
|
foreach ($rows as $row) {
|
|
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
|
$slug = $this->slugify((string)$category['title']);
|
|
if ($slug === '') {
|
|
continue;
|
|
}
|
|
if (!isset($categoryIndex[$slug])) {
|
|
$categoryIndex[$slug] = [
|
|
'uid' => (int)$category['uid'],
|
|
'title' => (string)$category['title'],
|
|
'slug' => $slug,
|
|
'count' => 0,
|
|
];
|
|
}
|
|
$categoryIndex[$slug]['count']++;
|
|
}
|
|
}
|
|
ksort($categoryIndex);
|
|
|
|
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
|
|
|
|
if ($activeCategory !== '') {
|
|
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
|
|
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
|
if ($this->slugify((string)$category['title']) === $activeCategory) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}));
|
|
}
|
|
|
|
// Newest first, server-side (the flexform historically says
|
|
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
|
|
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
|
|
|
|
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
|
|
if ($itemsPerPage <= 0) {
|
|
$itemsPerPage = 12;
|
|
}
|
|
|
|
$total = count($rows);
|
|
$pages = max(1, (int)ceil($total / $itemsPerPage));
|
|
$page = min($requestedPage, $pages);
|
|
|
|
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
|
|
|
|
$items = array_map(
|
|
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
|
|
$pageRows
|
|
);
|
|
|
|
$response = [
|
|
'mode' => 'list',
|
|
'items' => $items,
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'pages' => $pages,
|
|
'itemsPerPage' => $itemsPerPage,
|
|
'total' => $total,
|
|
],
|
|
'categories' => array_values($categoryIndex),
|
|
'activeCategory' => $activeCategory,
|
|
'settings' => $this->buildSettingsPayload($contentElement, $settings),
|
|
];
|
|
|
|
return json_encode($response) ?: '';
|
|
}
|
|
|
|
/**
|
|
* Lean card shape for paginated pages - only what the cards render
|
|
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
|
|
* deliberately absent here.
|
|
*
|
|
* @param array<string,mixed> $news
|
|
* @param array<string,mixed> $settings
|
|
* @param array<int,array<string,mixed>> $categories
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function serializeListCard(array $news, array $settings, array $categories): array
|
|
{
|
|
$uid = (int)($news['uid'] ?? 0);
|
|
$urls = $this->buildNewsUrls($news, $settings);
|
|
$teaser = trim((string)($news['teaser'] ?? ''));
|
|
|
|
return [
|
|
'uid' => $uid,
|
|
'title' => (string)($news['title'] ?? ''),
|
|
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
|
|
'type' => (string)($news['type'] ?? '0'),
|
|
'detailUrl' => $urls['detailUrl'],
|
|
'datetime' => (int)($news['datetime'] ?? 0),
|
|
'istopnews' => (bool)($news['istopnews'] ?? false),
|
|
'teaser' => $teaser,
|
|
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
|
|
'categories' => array_map(
|
|
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
|
|
$categories
|
|
),
|
|
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* uid => categories map for a set of news records, resolved in one query
|
|
* (the per-record resolveCategories() would fire ~600 queries here).
|
|
*
|
|
* @param list<int> $newsUids
|
|
* @return array<int,array<int,array{uid:int,title:string}>>
|
|
*/
|
|
private function categoriesForNewsUids(array $newsUids): array
|
|
{
|
|
$newsUids = array_values(array_filter($newsUids));
|
|
if ($newsUids === []) {
|
|
return [];
|
|
}
|
|
|
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
|
|
$rows = $queryBuilder
|
|
->select('c.uid', 'c.title', 'mm.uid_foreign')
|
|
->from('sys_category', 'c')
|
|
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
|
|
->where(
|
|
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
|
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('c.deleted', 0),
|
|
$queryBuilder->expr()->eq('c.hidden', 0)
|
|
)
|
|
->orderBy('c.title', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
$map = [];
|
|
foreach ($rows as $row) {
|
|
$map[(int)$row['uid_foreign']][] = [
|
|
'uid' => (int)$row['uid'],
|
|
'title' => (string)$row['title'],
|
|
];
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* Category title -> filter slug ("Press Release" -> "press-release"),
|
|
* mirroring the slugs the page URLs already carry.
|
|
*/
|
|
private function slugify(string $value): string
|
|
{
|
|
$value = mb_strtolower(trim($value));
|
|
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
|
|
return trim($value, '-');
|
|
}
|
|
|
|
/**
|
|
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
|
|
*/
|
|
private function listExcerpt(string $html): string
|
|
{
|
|
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
|
|
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
|
|
if (mb_strlen($text) <= 300) {
|
|
return $text;
|
|
}
|
|
$cut = mb_substr($text, 0, 300);
|
|
$lastSpace = mb_strrpos($cut, ' ');
|
|
if ($lastSpace !== false && $lastSpace > 180) {
|
|
$cut = mb_substr($cut, 0, $lastSpace);
|
|
}
|
|
return rtrim($cut, " .,;:") . '...';
|
|
}
|
|
|
|
/**
|
|
* Splits the trailing FAQ section out of an article body.
|
|
*
|
|
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
|
|
* with "Frequently asked" or "FAQ", followed by alternating question
|
|
* paragraphs (plain text ending in "?") and answer paragraphs (may
|
|
* contain links). Fail-safe: anything that does not yield at least two
|
|
* clean Q&A pairs leaves the HTML untouched.
|
|
*
|
|
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
|
|
*/
|
|
private function extractFaqBand(string $html): array
|
|
{
|
|
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
|
|
return ['html' => $html, 'faq' => null];
|
|
}
|
|
|
|
$heading = trim((string)$headingMatch[2][0]);
|
|
$sectionStart = (int)$headingMatch[0][1];
|
|
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
|
|
$sectionEnd = $cursor;
|
|
|
|
$items = [];
|
|
$question = null;
|
|
$answerParts = [];
|
|
|
|
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
|
|
$text = trim(strip_tags($paragraph[1]));
|
|
if ($text === '') {
|
|
$cursor += strlen($paragraph[0]);
|
|
$sectionEnd = $cursor;
|
|
continue;
|
|
}
|
|
if (str_ends_with($text, '?')) {
|
|
if ($question !== null && $answerParts !== []) {
|
|
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
|
|
}
|
|
$question = $text;
|
|
$answerParts = [];
|
|
} elseif ($question !== null) {
|
|
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
|
|
} else {
|
|
break;
|
|
}
|
|
$cursor += strlen($paragraph[0]);
|
|
$sectionEnd = $cursor;
|
|
}
|
|
|
|
if ($question !== null && $answerParts !== []) {
|
|
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
|
|
}
|
|
|
|
if (count($items) < 2) {
|
|
return ['html' => $html, 'faq' => null];
|
|
}
|
|
|
|
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
|
|
|
|
return [
|
|
'html' => trim($clean),
|
|
'faq' => ['heading' => $heading, 'items' => $items],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Cards for every product detail page (/product/<slug>) and product
|
|
* category page (/products/<slug>) linked inside an article body -
|
|
* order of first appearance, deduplicated, at most six.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function collectMentionedProducts(string $html): array
|
|
{
|
|
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
|
|
return [];
|
|
}
|
|
|
|
$cards = [];
|
|
foreach ($matches as $match) {
|
|
$href = (string)$match[1];
|
|
if (isset($cards[$href]) || count($cards) >= 6) {
|
|
continue;
|
|
}
|
|
try {
|
|
if (!empty($match[2])) {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
|
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
|
|
->from('tx_vitec_domain_model_product')
|
|
->where(
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0),
|
|
$qb->expr()->eq('hideonwebsite', 0),
|
|
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
|
|
)
|
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$cards[$href] = [
|
|
'type' => 'product',
|
|
'title' => (string)($row['title'] ?? ''),
|
|
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
|
|
'link' => $href,
|
|
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
|
|
];
|
|
} else {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('pages');
|
|
$row = $qb->select('uid', 'title', 'description', 'abstract')
|
|
->from('pages')
|
|
->where(
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0),
|
|
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
|
|
)
|
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$cards[$href] = [
|
|
'type' => 'category',
|
|
'title' => (string)($row['title'] ?? ''),
|
|
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
|
|
'link' => $href,
|
|
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
|
|
];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return array_values($cards);
|
|
}
|
|
|
|
/**
|
|
* Where a news item actually points.
|
|
*
|
|
* EXT:news knows three record types, and only the first one lives on the
|
|
* news detail route:
|
|
*
|
|
* 0 article -> detail page + path_segment (the default below)
|
|
* 1 internal page -> the page picked in the backend; the record itself
|
|
* carries no body, so linking it to the detail route
|
|
* would show a teaser and hide the actual content
|
|
* 2 external url -> the given address, verbatim
|
|
*
|
|
* `detailUrl` is rewritten rather than accompanied by a second field, so a
|
|
* consumer can keep following one key for every item and never has to branch
|
|
* on the type. `type` is emitted alongside anyway — a front end may well want
|
|
* to open an external target in a new tab.
|
|
*
|
|
* Falls back to the article URL whenever a type 1/2 record does not yield a
|
|
* usable target: a link to the teaser beats a dead one.
|
|
*
|
|
* @param array<string,mixed> $news
|
|
* @param array<string,mixed> $settings
|
|
* @return array{detailUrl:string, canonicalUrl:string}
|
|
*/
|
|
private function buildNewsUrls(array $news, array $settings): array
|
|
{
|
|
$type = (string)($news['type'] ?? '0');
|
|
|
|
if ($type === '2') {
|
|
$external = trim((string)($news['externalurl'] ?? ''));
|
|
if ($external !== '') {
|
|
return ['detailUrl' => $external, 'canonicalUrl' => $external];
|
|
}
|
|
}
|
|
|
|
if ($type === '1') {
|
|
$pageUid = $this->extractPageUid((string)($news['internalurl'] ?? ''));
|
|
$pageUrl = $pageUid > 0 ? LinkResolver::pageUrl($pageUid) : null;
|
|
if ($pageUrl !== null && $pageUrl !== '') {
|
|
return [
|
|
'detailUrl' => $pageUrl,
|
|
'canonicalUrl' => $this->absoluteUrl($pageUrl),
|
|
];
|
|
}
|
|
}
|
|
|
|
$pathSegment = trim((string)($news['path_segment'] ?? ''), '/');
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* `internalurl` holds either `t3://page?uid=N` or a bare uid, depending on
|
|
* how old the record is - the legacy import produced both forms.
|
|
*/
|
|
private function extractPageUid(string $internalUrl): int
|
|
{
|
|
$internalUrl = trim($internalUrl);
|
|
if (preg_match('#t3://page\?uid=(\d+)#i', $internalUrl, $m)) {
|
|
return (int)$m[1];
|
|
}
|
|
return ctype_digit($internalUrl) ? (int)$internalUrl : 0;
|
|
}
|
|
|
|
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),
|
|
// Editor-maintained alt text; the frontend falls back to
|
|
// the article title when this is empty (0070-FE, B5).
|
|
'alternative' => (string)($fileReference->getAlternative() ?? ''),
|
|
];
|
|
} 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);
|
|
}
|
|
} |