sys_category gains a story selection and a layout (Options tab, alongside the images field EXT:news puts there), so one editorial decision serves every product list that renders the category. ProductListJsonRenderer emits them beside the products in the 7.3 list envelope; each card carries forCategories - the product categories it came from - so the front end hides it with the same ?cat=/?subcat= filter it applies to the products. Stories are collected over the category and its descendants and deduplicated. Story links follow the existing rule (detail page's parent path plus slug, which SuccessStoryPathRewrite maps back), taking the page from the element's FlexForm or the new site setting vitec.storyDetailPid. Spec raised to 1.17 with clause 7.15.2.1; the header had been left at 1.12 although entries up to 1.15 already existed, so today's two entries were renumbered to 1.16 and 1.17.
1007 lines
43 KiB
PHP
Executable File
1007 lines
43 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\UserFunc;
|
|
|
|
|
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
|
use Doctrine\DBAL\ParameterType;
|
|
use Evomedien\Vitec\Domain\Repository\ProductRepository;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use TYPO3\CMS\Core\Core\Environment;
|
|
use TYPO3\CMS\Core\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
|
|
use TYPO3\CMS\Core\Resource\FileReference;
|
|
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;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* UserFunc to render product list as JSON for headless.
|
|
*
|
|
* `render()` performs page discovery (used for top-level list plugins via
|
|
* TypoScript). `renderForRecord()` processes one specific tt_content row and
|
|
* is used both internally and by ContainerChildrenProcessor to resolve a
|
|
* product-list plugin nested inside a b13 container.
|
|
*/
|
|
class ProductListJsonRenderer
|
|
{
|
|
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;
|
|
}
|
|
|
|
#[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_productlist') {
|
|
return $this->renderForRecord($row);
|
|
}
|
|
|
|
// 2) Page-id via v14 request attribute (TSFE->id is often null in JSON cObj context)
|
|
$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 '';
|
|
}
|
|
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('tt_content');
|
|
|
|
$contentElements = $queryBuilder
|
|
->select('*')
|
|
->from('tt_content')
|
|
->where(
|
|
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_productlist', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('deleted', 0),
|
|
$queryBuilder->expr()->eq('hidden', 0)
|
|
)
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
if (empty($contentElements)) {
|
|
return '';
|
|
}
|
|
|
|
return $this->renderForRecord($contentElements[0]);
|
|
}
|
|
|
|
|
|
/**
|
|
* Render exactly the given tt_content row (the product-list plugin element).
|
|
* Exception-safe: returns '' on any failure.
|
|
*
|
|
* @param array<string,mixed> $contentElement tt_content row of the plugin
|
|
*/
|
|
public function renderForRecord(array $contentElement): string
|
|
{
|
|
try {
|
|
// Parse FlexForm of THIS element
|
|
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
|
|
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
|
|
$settings = $flexFormData['settings'] ?? [];
|
|
|
|
$categoryUids = array_filter(
|
|
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
|
|
);
|
|
// A selected category matches its WHOLE subtree (decision
|
|
// 2026-09-04): editors pick e.g. the "Platforms and End-Points"
|
|
// parent, the products hang on its child categories.
|
|
if ($categoryUids !== []) {
|
|
$categoryUids = $this->expandWithDescendants($categoryUids);
|
|
}
|
|
$debugMode = (bool)($settings['debug'] ?? false);
|
|
$allProducts = (bool)($settings['allproducts'] ?? false);
|
|
|
|
// Extbase repositories don't work in UserFunc context — direct query.
|
|
$productQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
|
|
|
$productQuery = $productQueryBuilder
|
|
->select('p.*')
|
|
->from('tx_vitec_domain_model_product', 'p')
|
|
->where(
|
|
$productQueryBuilder->expr()->eq('p.deleted', 0),
|
|
$productQueryBuilder->expr()->eq('p.hidden', 0),
|
|
$productQueryBuilder->expr()->eq('p.legacy', 0),
|
|
$productQueryBuilder->expr()->eq('p.supportproduct', 0),
|
|
$productQueryBuilder->expr()->eq('p.hideonwebsite', 0),
|
|
$productQueryBuilder->expr()->eq('p.hideonproducts', 0),
|
|
$productQueryBuilder->expr()->eq('p.subproduct', 0)
|
|
);
|
|
|
|
if (!empty($categoryUids) && !$allProducts) {
|
|
$productQuery
|
|
->join(
|
|
'p',
|
|
'sys_category_record_mm',
|
|
'mm',
|
|
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $productQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . ' AND mm.fieldname = ' . $productQueryBuilder->createNamedParameter('categories', ParameterType::STRING)
|
|
)
|
|
->andWhere(
|
|
$productQueryBuilder->expr()->in('mm.uid_local', $productQueryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY))
|
|
)
|
|
->groupBy('p.uid');
|
|
}
|
|
|
|
$products = $productQuery->executeQuery()->fetchAllAssociative();
|
|
|
|
// Order by category in TREE order (decision 2026-09-04): products
|
|
// of the first selected/child category first, then the next, so a
|
|
// list over a parent category groups its families like the
|
|
// sitemap. $categoryUids comes from expandWithDescendants in
|
|
// depth-first tree order; within one category the uid order is
|
|
// kept - the records were created in sitemap-V2 row order.
|
|
if (!empty($categoryUids) && !$allProducts && $products !== []) {
|
|
$rankByCategory = array_flip($categoryUids);
|
|
$mmQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category_record_mm');
|
|
$assignments = $mmQueryBuilder->select('uid_local', 'uid_foreign')
|
|
->from('sys_category_record_mm')
|
|
->where(
|
|
$mmQueryBuilder->expr()->eq('tablenames', $mmQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
|
$mmQueryBuilder->expr()->eq('fieldname', $mmQueryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
|
$mmQueryBuilder->expr()->in('uid_foreign', $mmQueryBuilder->createNamedParameter(
|
|
array_map(static fn(array $p): int => (int)$p['uid'], $products),
|
|
Connection::PARAM_INT_ARRAY
|
|
))
|
|
)->executeQuery()->fetchAllAssociative();
|
|
$rankByProduct = [];
|
|
foreach ($assignments as $assignment) {
|
|
$productUid = (int)$assignment['uid_foreign'];
|
|
$rank = $rankByCategory[(int)$assignment['uid_local']] ?? null;
|
|
if ($rank !== null && $rank < ($rankByProduct[$productUid] ?? PHP_INT_MAX)) {
|
|
$rankByProduct[$productUid] = $rank;
|
|
}
|
|
}
|
|
usort($products, static function (array $a, array $b) use ($rankByProduct): int {
|
|
$rankA = $rankByProduct[(int)$a['uid']] ?? PHP_INT_MAX;
|
|
$rankB = $rankByProduct[(int)$b['uid']] ?? PHP_INT_MAX;
|
|
return $rankA <=> $rankB ?: (int)$a['uid'] <=> (int)$b['uid'];
|
|
});
|
|
}
|
|
|
|
$productsData = [];
|
|
foreach ($products as $product) {
|
|
$productsData[] = $this->serializeProduct($product);
|
|
}
|
|
|
|
// Envelope with layout and toolbar flag, matching the other list
|
|
// plugins. This turned the payload from a bare array into an object:
|
|
// the front end has to read `products.products` instead of
|
|
// iterating `products` directly. `layout` was configurable in the
|
|
// FlexForm all along but had never been emitted - note that product
|
|
// layouts are numbered 0..3 here, not the grid/list/carousel
|
|
// vocabulary the other lists use.
|
|
$response = [
|
|
'layout' => (string)($settings['layout'] ?? '0'),
|
|
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
|
|
'products' => $productsData,
|
|
// Success stories the editor attached to these categories
|
|
// (sys_category, Options tab). They ride along with the list
|
|
// so the front end can hide them with the same category
|
|
// filter it applies to the products.
|
|
'successStories' => $this->successStoriesFor(
|
|
$allProducts ? [] : $categoryUids,
|
|
(int)($settings['storysinglepid'] ?? 0)
|
|
),
|
|
];
|
|
|
|
if ($debugMode) {
|
|
$response['debug'] = [
|
|
'pageId' => (int)($GLOBALS['TSFE']->id ?? 0),
|
|
'categoryUids' => $categoryUids,
|
|
'productCount' => count($productsData),
|
|
'settings' => $settings,
|
|
'fallbackFiles' => $this->collectFallbackFilesFromProducts($productsData),
|
|
];
|
|
}
|
|
|
|
return json_encode($response);
|
|
} catch (\Throwable $e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Success stories attached to the given categories, in the order the
|
|
* categories were resolved and, inside one category, in the order the
|
|
* editor arranged them. A story linked from several of the categories
|
|
* appears once and carries all of them in `forCategories`, which is what
|
|
* the front end filters on: the product finder writes ?cat= / ?subcat=,
|
|
* and a card stays visible while one of its categories is selected.
|
|
*
|
|
* Cards use the shared story shape (UsecaseSerializer), so the front end
|
|
* can render them with the same component as every other story list.
|
|
* `detailUrl` follows the same rule as the story list: the story's own
|
|
* detail page wins, otherwise the Single PID configured on this element
|
|
* plus the slug - and null when neither is available.
|
|
*
|
|
* Returned in the list envelope of 7.3 - `layout` plus the array - so the
|
|
* front end treats these cards like every other story list. The layout is
|
|
* an editorial setting on the category, next to the selection itself.
|
|
*
|
|
* @param int[] $categoryUids
|
|
* @return array{layout:string,stories:array<int,array<string,mixed>>}
|
|
*/
|
|
private function successStoriesFor(array $categoryUids, int $singlePid): array
|
|
{
|
|
if ($categoryUids === []) {
|
|
return ['layout' => 'grid', 'stories' => []];
|
|
}
|
|
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
$rows = $queryBuilder->select('uid', 'tx_vitec_success_stories', 'tx_vitec_success_stories_layout')->from('sys_category')
|
|
->where(
|
|
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY)),
|
|
$queryBuilder->expr()->eq('deleted', 0),
|
|
$queryBuilder->expr()->eq('hidden', 0)
|
|
)->executeQuery()->fetchAllAssociative();
|
|
|
|
$listByCategory = [];
|
|
$layoutByCategory = [];
|
|
foreach ($rows as $row) {
|
|
$listByCategory[(int)$row['uid']] = (string)($row['tx_vitec_success_stories'] ?? '');
|
|
$layoutByCategory[(int)$row['uid']] = (string)($row['tx_vitec_success_stories_layout'] ?? '') ?: 'grid';
|
|
}
|
|
|
|
$order = [];
|
|
$forCategories = [];
|
|
$layout = 'grid';
|
|
foreach ($categoryUids as $categoryUid) {
|
|
foreach (explode(',', $listByCategory[(int)$categoryUid] ?? '') as $raw) {
|
|
$storyUid = (int)trim($raw);
|
|
if ($storyUid <= 0) {
|
|
continue;
|
|
}
|
|
if ($order === []) {
|
|
// The first category that actually contributes stories sets
|
|
// the layout - that is the top-most one the element is
|
|
// configured with, which is where an editor expects the
|
|
// setting to live.
|
|
$layout = $layoutByCategory[(int)$categoryUid] ?? 'grid';
|
|
}
|
|
if (!in_array($storyUid, $order, true)) {
|
|
$order[] = $storyUid;
|
|
}
|
|
$forCategories[$storyUid][] = (int)$categoryUid;
|
|
}
|
|
}
|
|
if ($order === []) {
|
|
return ['layout' => 'grid', 'stories' => []];
|
|
}
|
|
|
|
$storyQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
|
|
$storyRows = $storyQueryBuilder->select('*')->from('tx_vitec_domain_model_usecase')
|
|
->where(
|
|
$storyQueryBuilder->expr()->in('uid', $storyQueryBuilder->createNamedParameter($order, Connection::PARAM_INT_ARRAY)),
|
|
$storyQueryBuilder->expr()->eq('deleted', 0),
|
|
$storyQueryBuilder->expr()->eq('hidden', 0)
|
|
)->executeQuery()->fetchAllAssociative();
|
|
|
|
$byUid = [];
|
|
foreach ($storyRows as $storyRow) {
|
|
$byUid[(int)$storyRow['uid']] = $storyRow;
|
|
}
|
|
|
|
// Same base as the story list: the detail page's PARENT path, because
|
|
// SuccessStoryPathRewrite maps /success-stories/<slug> onto the detail
|
|
// subpage at request time. Falls back to the site setting
|
|
// `vitec.storyDetailPid`, so the editor does not have to configure the
|
|
// page on every single product list.
|
|
if ($singlePid <= 0) {
|
|
$singlePid = $this->storyDetailPidFromSite();
|
|
}
|
|
$detailBase = '';
|
|
if ($singlePid > 0) {
|
|
$detailPath = rtrim(\Evomedien\Vitec\Service\LinkResolver::pageUrl($singlePid) ?? '', '/');
|
|
$parent = str_contains($detailPath, '/') ? substr($detailPath, 0, (int)strrpos($detailPath, '/')) : '';
|
|
$detailBase = $parent !== '' ? $parent : $detailPath;
|
|
}
|
|
|
|
$serializer = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\UsecaseSerializer::class);
|
|
$stories = [];
|
|
foreach ($order as $storyUid) {
|
|
if (!isset($byUid[$storyUid])) {
|
|
continue; // hidden or deleted meanwhile - simply drops out
|
|
}
|
|
$item = $serializer->serializeListItem($byUid[$storyUid]);
|
|
if (($item['detailUrl'] ?? null) === null && $detailBase !== '' && ($item['slug'] ?? '') !== '') {
|
|
$item['detailUrl'] = $detailBase . '/' . ltrim((string)$item['slug'], '/');
|
|
}
|
|
// NOT the story's own topic categories (those stay in `categories`)
|
|
// but the product categories it was attached to.
|
|
$item['forCategories'] = array_values(array_unique($forCategories[$storyUid]));
|
|
$stories[] = $item;
|
|
}
|
|
|
|
return ['layout' => $layout, 'stories' => $stories];
|
|
}
|
|
|
|
/**
|
|
* Site-wide fallback for the success story detail page, 0 when unset.
|
|
* Read from the site settings rather than hard-coded, so the page can be
|
|
* moved without touching code.
|
|
*/
|
|
private function storyDetailPidFromSite(): int
|
|
{
|
|
try {
|
|
$site = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('site');
|
|
if ($site === null || !method_exists($site, 'getSettings')) {
|
|
return 0;
|
|
}
|
|
return (int)$site->getSettings()->get('vitec.storyDetailPid', 0);
|
|
} catch (\Throwable) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The given category uids plus every descendant category uid, DEPTH first
|
|
* over sys_category.parent with siblings in sys_category.sorting order -
|
|
* the result is in backend-tree order and doubles as the sort rank for
|
|
* the list. One query for the whole table - the tree is small (< 100
|
|
* rows).
|
|
*
|
|
* @param int[] $categoryUids
|
|
* @return int[]
|
|
*/
|
|
private function expandWithDescendants(array $categoryUids): array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
$rows = $queryBuilder->select('uid', 'parent')->from('sys_category')
|
|
->where($queryBuilder->expr()->eq('deleted', 0))
|
|
->orderBy('parent')->addOrderBy('sorting')
|
|
->executeQuery()->fetchAllAssociative();
|
|
$childrenByParent = [];
|
|
foreach ($rows as $row) {
|
|
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
|
|
}
|
|
$result = [];
|
|
$visit = function (int $categoryUid) use (&$visit, &$result, $childrenByParent): void {
|
|
$result[] = $categoryUid;
|
|
foreach ($childrenByParent[$categoryUid] ?? [] as $childUid) {
|
|
if (!in_array($childUid, $result, true)) {
|
|
$visit($childUid);
|
|
}
|
|
}
|
|
};
|
|
foreach (array_map('intval', $categoryUids) as $categoryUid) {
|
|
if (!in_array($categoryUid, $result, true)) {
|
|
$visit($categoryUid);
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Serialize a single product DB row to the full headless JSON structure.
|
|
*
|
|
* @param array<string,mixed> $product
|
|
* @return array<string,mixed>
|
|
*/
|
|
protected function serializeProduct(array $product): array
|
|
{
|
|
$uid = (int)$product['uid'];
|
|
|
|
return [
|
|
'uid' => $uid,
|
|
|
|
'title' => (string)($product['title'] ?? ''),
|
|
'slug' => (string)($product['slug'] ?? ''),
|
|
'urltitle' => (string)($product['urltitle'] ?? ''),
|
|
'seotitle' => (string)($product['seotitle'] ?? ''),
|
|
'seometa' => (string)($product['seometa'] ?? ''),
|
|
'keywords' => (string)($product['keywords'] ?? ''),
|
|
'structureddata' => (string)($product['structureddata'] ?? ''),
|
|
'teaser' => (string)($product['teaser'] ?? ''),
|
|
'subtitle' => (string)($product['subtitle'] ?? ''),
|
|
'video' => (string)($product['video'] ?? ''),
|
|
'applications' => RteResolver::html($product['applications'] ?? ''),
|
|
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
|
|
'description' => RteResolver::html($product['description'] ?? ''),
|
|
'description2' => RteResolver::html($product['description2'] ?? ''),
|
|
'highlights' => RteResolver::html($product['highlights'] ?? ''),
|
|
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
|
|
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
|
|
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
|
|
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
|
|
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
|
|
|
|
'hideonapp' => (bool)($product['hideonapp'] ?? false),
|
|
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
|
|
'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false),
|
|
'hideonproducts' => (bool)($product['hideonproducts'] ?? false),
|
|
'shortcut' => (bool)($product['shortcut'] ?? false),
|
|
'legacy' => (bool)($product['legacy'] ?? false),
|
|
'supportproduct' => (bool)($product['supportproduct'] ?? false),
|
|
'subproduct' => (bool)($product['subproduct'] ?? false),
|
|
'showdatapath' => (bool)($product['showdatapath'] ?? false),
|
|
|
|
'link' => '/product/' . (string)($product['slug'] ?? ''),
|
|
|
|
'categories' => $this->getProductCategories($uid),
|
|
'images' => $this->getProductImages($uid),
|
|
'downloads' => $this->getProductDownloads($uid),
|
|
'ogimage' => $this->getProductOgImage($uid),
|
|
'videofile' => $this->getProductVideoFile($uid),
|
|
'relatedprodukt' => $this->getRelatedProducts($uid),
|
|
];
|
|
}
|
|
|
|
protected function getProductImages(int $productUid): array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
|
|
$fileReferences = $queryBuilder
|
|
->select('sfr.uid', 'sfr.uid_local', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
|
|
->from('sys_file_reference', 'sfr')
|
|
->where(
|
|
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('sfr.deleted', 0),
|
|
$queryBuilder->expr()->eq('sfr.hidden', 0)
|
|
)
|
|
->orderBy('sfr.sorting_foreign')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
|
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
|
|
|
$images = [];
|
|
foreach ($fileReferences as $fileRefData) {
|
|
try {
|
|
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
|
|
|
|
$sizes = [
|
|
'small' => ['width' => 400, 'height' => null],
|
|
'medium' => ['width' => 800, 'height' => null],
|
|
'large' => ['width' => 1200, 'height' => null],
|
|
'xlarge' => ['width' => 1600, 'height' => null],
|
|
];
|
|
|
|
$srcset = [];
|
|
foreach ($sizes as $sizeName => $dimensions) {
|
|
$processedImage = $imageService->applyProcessingInstructions(
|
|
$fileReference,
|
|
[
|
|
'width' => $dimensions['width'],
|
|
'height' => $dimensions['height'],
|
|
'crop' => $fileRefData['crop'] ?? null,
|
|
'fileExtension' => 'webp'
|
|
]
|
|
);
|
|
|
|
$imageUri = $imageService->getImageUri($processedImage);
|
|
$srcset[] = [
|
|
'url' => $imageUri,
|
|
'width' => $dimensions['width'],
|
|
'descriptor' => $dimensions['width'] . 'w'
|
|
];
|
|
}
|
|
|
|
$defaultProcessed = $imageService->applyProcessingInstructions(
|
|
$fileReference,
|
|
['width' => 800, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
|
|
);
|
|
|
|
$images[] = [
|
|
'uid' => (int)$fileRefData['uid'],
|
|
'url' => $imageService->getImageUri($defaultProcessed),
|
|
'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) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $images;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
protected function getProductOgImage(int $productUid): ?array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
|
|
$fileRefData = $queryBuilder
|
|
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
|
|
->from('sys_file_reference', 'sfr')
|
|
->where(
|
|
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter('ogimage', ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('sfr.deleted', 0),
|
|
$queryBuilder->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']);
|
|
|
|
$processed = $imageService->applyProcessingInstructions(
|
|
$fileReference,
|
|
['width' => 1200, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
|
|
);
|
|
|
|
return [
|
|
'uid' => (int)$fileRefData['uid'],
|
|
'url' => $imageService->getImageUri($processed),
|
|
'title' => $fileRefData['title'] ?? '',
|
|
'alternative' => $fileRefData['alternative'] ?? '',
|
|
'description' => $fileRefData['description'] ?? '',
|
|
'properties' => [
|
|
'width' => $fileReference->getProperty('width'),
|
|
'height' => $fileReference->getProperty('height'),
|
|
'mimeType' => $fileReference->getProperty('mime_type'),
|
|
],
|
|
];
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve the uploaded video file (single FAL reference, fieldname=videofile).
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
protected function getProductVideoFile(int $productUid): ?array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
|
|
$row = $queryBuilder
|
|
->select('fr.uid', 'fr.title', 'fr.description', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
|
|
->from('sys_file_reference', 'fr')
|
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
|
->where(
|
|
$queryBuilder->expr()->eq('fr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', \Doctrine\DBAL\ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('fr.fieldname', $queryBuilder->createNamedParameter('videofile', \Doctrine\DBAL\ParameterType::STRING)),
|
|
$queryBuilder->expr()->eq('fr.uid_foreign', $queryBuilder->createNamedParameter($productUid, \Doctrine\DBAL\ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('fr.deleted', 0),
|
|
$queryBuilder->expr()->eq('fr.hidden', 0),
|
|
$queryBuilder->expr()->eq('f.missing', 0)
|
|
)
|
|
->orderBy('fr.sorting_foreign', 'ASC')
|
|
->setMaxResults(1)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'uid' => (int)$row['file_uid'],
|
|
'name' => (string)($row['name'] ?? ''),
|
|
'url' => '/fileadmin' . ($row['identifier'] ?? ''),
|
|
'size' => (int)($row['size'] ?? 0),
|
|
'extension' => (string)($row['extension'] ?? ''),
|
|
'mimeType' => (string)($row['mime_type'] ?? ''),
|
|
'title' => (string)($row['title'] ?? ''),
|
|
'description' => (string)($row['description'] ?? ''),
|
|
];
|
|
}
|
|
protected function getProductCategories(int $productUid): array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
|
|
$categories = $queryBuilder
|
|
->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 = ' .
|
|
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
|
|
' AND mm.fieldname = ' .
|
|
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
|
|
)
|
|
->where(
|
|
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('c.deleted', 0),
|
|
$queryBuilder->expr()->eq('c.hidden', 0)
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
return array_map(function ($cat) {
|
|
return [
|
|
'uid' => (int)$cat['uid'],
|
|
'title' => $cat['title'] ?? '',
|
|
'description' => $cat['description'] ?? '',
|
|
'parents' => $this->getCategoryParents((int)$cat['uid']),
|
|
];
|
|
}, $categories);
|
|
}
|
|
|
|
/** @var array<int,array{parent:int,title:string}>|null Lazy uid => [parent,title] map of all categories. */
|
|
private ?array $categoryTreeMap = null;
|
|
|
|
/**
|
|
* All ancestor categories of one category, ROOT FIRST (excluding itself).
|
|
* Backed by a once-per-request map of sys_category — cheap for any number
|
|
* of products. Cycle-safe.
|
|
*
|
|
* @return array<int,array{uid:int,title:string}>
|
|
*/
|
|
private function getCategoryParents(int $categoryUid): array
|
|
{
|
|
if ($this->categoryTreeMap === null) {
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
$rows = $qb
|
|
->select('uid', 'parent', 'title')
|
|
->from('sys_category')
|
|
->where(
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
$this->categoryTreeMap = [];
|
|
foreach ($rows as $row) {
|
|
$this->categoryTreeMap[(int)$row['uid']] = [
|
|
'parent' => (int)$row['parent'],
|
|
'title' => (string)$row['title'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$parents = [];
|
|
$seen = [$categoryUid => true];
|
|
$current = $this->categoryTreeMap[$categoryUid]['parent'] ?? 0;
|
|
while ($current > 0 && isset($this->categoryTreeMap[$current]) && !isset($seen[$current])) {
|
|
$seen[$current] = true;
|
|
array_unshift($parents, ['uid' => $current, 'title' => $this->categoryTreeMap[$current]['title']]);
|
|
$current = $this->categoryTreeMap[$current]['parent'];
|
|
}
|
|
|
|
return $parents;
|
|
}
|
|
|
|
private function getDownloadFileType(int $downloadUid): string
|
|
{
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
|
|
$categories = $qb
|
|
->select('c.filetype')
|
|
->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_download', ParameterType::STRING) .
|
|
' AND mm.fieldname = ' .
|
|
$qb->createNamedParameter('categories', ParameterType::STRING)
|
|
)
|
|
->where(
|
|
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('c.deleted', 0),
|
|
$qb->expr()->eq('c.hidden', 0),
|
|
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
foreach ($categories as $category) {
|
|
$filetype = trim((string)($category['filetype'] ?? ''));
|
|
if ($filetype !== '') {
|
|
return $filetype;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function resolveFileByConvention(string $fileprefix, string $filetype): ?array
|
|
{
|
|
$fileprefix = trim($fileprefix);
|
|
$filetype = trim($filetype);
|
|
if ($fileprefix === '' || $filetype === '') {
|
|
return null;
|
|
}
|
|
|
|
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
|
|
if (!is_dir($baseDir) || !is_readable($baseDir)) {
|
|
return null;
|
|
}
|
|
|
|
$escapedPrefix = preg_quote($fileprefix, '/');
|
|
$escapedType = preg_quote($filetype, '/');
|
|
$pattern = '/^' . $escapedPrefix . '__' . $escapedType . '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
|
|
|
|
$bestFile = null;
|
|
$bestNumber = -1;
|
|
$bestLetterRank = -1;
|
|
|
|
$entries = scandir($baseDir);
|
|
if ($entries === false) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
if (!is_string($entry) || $entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
if (!preg_match($pattern, $entry, $matches)) {
|
|
continue;
|
|
}
|
|
|
|
$number = (int)$matches[1];
|
|
$letterRank = $this->letterSequenceToRank((string)$matches[2]);
|
|
|
|
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
|
|
$bestNumber = $number;
|
|
$bestLetterRank = $letterRank;
|
|
$bestFile = $entry;
|
|
}
|
|
}
|
|
|
|
if ($bestFile === null) {
|
|
return null;
|
|
}
|
|
|
|
$fullPath = $baseDir . '/' . $bestFile;
|
|
if (!is_file($fullPath) || !is_readable($fullPath)) {
|
|
return null;
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($bestFile, PATHINFO_EXTENSION));
|
|
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : '';
|
|
|
|
return [
|
|
'uid' => 0,
|
|
'name' => $bestFile,
|
|
'url' => '/fileadmin/downloads/Collateral/' . rawurlencode($bestFile),
|
|
'size' => (int)(filesize($fullPath) ?: 0),
|
|
'extension' => $extension,
|
|
'mimeType' => $mimeType,
|
|
];
|
|
}
|
|
|
|
private function letterSequenceToRank(string $letters): int
|
|
{
|
|
$rank = 0;
|
|
$letters = strtoupper($letters);
|
|
$length = strlen($letters);
|
|
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$char = ord($letters[$i]);
|
|
if ($char < 65 || $char > 90) {
|
|
continue;
|
|
}
|
|
|
|
$rank = ($rank * 26) + ($char - 64);
|
|
}
|
|
|
|
return $rank;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $products
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function collectFallbackFilesFromProducts(array $products): array
|
|
{
|
|
$fallbackFiles = [];
|
|
|
|
foreach ($products as $product) {
|
|
$downloads = $product['downloads'] ?? null;
|
|
if (!is_array($downloads)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($downloads as $download) {
|
|
if (!is_array($download)) {
|
|
continue;
|
|
}
|
|
|
|
$file = $download['file'] ?? null;
|
|
if (!is_array($file) || (int)($file['uid'] ?? 1) !== 0) {
|
|
continue;
|
|
}
|
|
|
|
$fallbackFiles[] = [
|
|
'productUid' => (int)($product['uid'] ?? 0),
|
|
'downloadUid' => (int)($download['uid'] ?? 0),
|
|
'name' => (string)($file['name'] ?? ''),
|
|
'url' => (string)($file['url'] ?? ''),
|
|
'source' => 'naming-convention-fallback',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $fallbackFiles;
|
|
}
|
|
|
|
protected function getProductDownloads(int $productUid): array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_download');
|
|
|
|
$downloads = $queryBuilder
|
|
->select('d.*')
|
|
->from('tx_vitec_domain_model_download', 'd')
|
|
->join(
|
|
'd',
|
|
'tx_vitec_product_download_mm',
|
|
'mm',
|
|
'mm.uid_foreign = d.uid'
|
|
)
|
|
->where(
|
|
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('d.deleted', 0),
|
|
$queryBuilder->expr()->eq('d.hidden', 0),
|
|
$queryBuilder->expr()->eq('d.hideonwebsite', 0)
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
$result = [];
|
|
foreach ($downloads as $download) {
|
|
$fileInfo = null;
|
|
$downloadUid = (int)$download['uid'];
|
|
$fileprefix = (string)($download['fileprefix'] ?? '');
|
|
$filetype = $this->getDownloadFileType($downloadUid);
|
|
|
|
if (!empty($download['file'])) {
|
|
$fileQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
|
|
$fileRef = $fileQueryBuilder
|
|
->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
|
|
->from('sys_file_reference', 'fr')
|
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
|
->where(
|
|
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
|
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
|
|
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
|
|
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
|
|
$fileQueryBuilder->expr()->eq('f.missing', 0)
|
|
)
|
|
->orderBy('fr.sorting_foreign', 'ASC')
|
|
->setMaxResults(1)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
if ($fileRef) {
|
|
$fileInfo = [
|
|
'uid' => (int)$fileRef['file_uid'],
|
|
'name' => $fileRef['name'],
|
|
'url' => '/fileadmin' . $fileRef['identifier'],
|
|
'size' => (int)$fileRef['size'],
|
|
'extension' => $fileRef['extension'],
|
|
'mimeType' => $fileRef['mime_type'] ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($fileInfo === null) {
|
|
$fileInfo = $this->resolveFileByConvention($fileprefix, $filetype);
|
|
}
|
|
|
|
$result[] = [
|
|
'uid' => $downloadUid,
|
|
'title' => $download['title'] ?? '',
|
|
'slug' => $download['slug'] ?? '',
|
|
'teaser' => $download['teaser'] ?? '',
|
|
'description' => RteResolver::html($download['description'] ?? ''),
|
|
'keywords' => $download['keywords'] ?? '',
|
|
'icon' => $download['icon'] ?? '',
|
|
'fileprefix' => $fileprefix,
|
|
'filetype' => $filetype,
|
|
'file' => $fileInfo,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected function getRelatedProducts(int $productUid): array
|
|
{
|
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
|
|
|
$related = $queryBuilder
|
|
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
|
|
->from('tx_vitec_domain_model_product', 'p')
|
|
->join(
|
|
'p',
|
|
'tx_vitec_product_related_mm',
|
|
'mm',
|
|
'mm.uid_foreign = p.uid'
|
|
)
|
|
->where(
|
|
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->eq('p.deleted', 0),
|
|
$queryBuilder->expr()->eq('p.hidden', 0)
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
$result = [];
|
|
foreach ($related as $rel) {
|
|
$relUid = (int)$rel['uid'];
|
|
$result[] = [
|
|
'uid' => $relUid,
|
|
'title' => (string)($rel['title'] ?? ''),
|
|
'slug' => (string)($rel['slug'] ?? ''),
|
|
'subtitle' => (string)($rel['subtitle'] ?? ''),
|
|
'teaser' => (string)($rel['teaser'] ?? ''),
|
|
'description' => RteResolver::html($rel['description'] ?? ''),
|
|
'link' => '/product/' . (string)($rel['slug'] ?? ''),
|
|
'images' => $this->getProductImages($relUid),
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|