getAttribute("frontend.page.information"); if ($pi !== null) { $pageId = (int)$pi->getId(); } } if ($pageId <= 0) { $pageId = (int)($GLOBALS["TSFE"]->id ?? 0); } $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $rows = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)), $qb->expr()->eq('CType', $qb->createNamedParameter('vitec_datasheets', ParameterType::STRING)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAllAssociative(); if (empty($rows)) { return ''; } return $this->renderForRecord($rows[0]); } /** * @param array $contentElement */ public function renderForRecord(array $contentElement): string { try { $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); $settings = $flexFormData['settings'] ?? []; $debugMode = (bool)($settings['debug'] ?? false); $settingsOut = [ 'showFilter' => (bool)($settings['showFilter'] ?? true), 'showSearch' => (bool)($settings['showSearch'] ?? true), 'itemsPerPage' => (int)($settings['itemsPerPage'] ?? 20), ]; // 1) Pull all (product_uid, latest_datasheet_uid) pairs in one query. // GROUP BY product, take MAX(tstamp) → join back to get that exact download. $items = $this->fetchProductLatestDatasheetPairs(); // 2) Hydrate each pair: full product + full datasheet objects. $itemsOut = []; foreach ($items as $pair) { $productUid = (int)$pair['product_uid']; $datasheetUid = (int)$pair['datasheet_uid']; $product = $this->fetchProduct($productUid); $datasheet = $this->fetchDownload($datasheetUid); if (!$product || !$datasheet) { continue; } $itemsOut[] = [ 'product' => $this->serializeProduct($product), 'datasheet' => $this->serializeDatasheet($datasheet), ]; } $response = [ 'items' => $itemsOut, 'settings' => $settingsOut, ]; if ($debugMode) { $response['debug'] = [ 'productCount' => count($itemsOut), 'settings' => $settings, 'fallbackFiles' => $this->collectFallbackFilesFromItems($itemsOut), ]; } return json_encode($response); } catch (\Throwable $e) { return ''; } } /** * For each visible product that has at least one visible datasheet, * return [product_uid => latest_datasheet_uid]. Sorted by product title. * * @return array */ private function fetchProductLatestDatasheetPairs(): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_product_download_mm'); $expr = $qb->expr(); // Inner aggregation: for each product, the latest download tstamp. // We use a single query with JOIN on the same (mm + download) to resolve // the actual download_uid matching MAX(tstamp). Approach: do it in two // simple steps to stay SQL-portable. // Step A: gather all candidate (product_uid, download_uid, tstamp) tuples. $rows = $qb ->select( 'mm.uid_local AS product_uid', 'mm.uid_foreign AS datasheet_uid', 'd.tstamp AS d_tstamp', 'p.title AS p_title' ) ->from('tx_vitec_product_download_mm', 'mm') ->join('mm', 'tx_vitec_domain_model_download', 'd', 'd.uid = mm.uid_foreign') ->join('mm', 'tx_vitec_domain_model_product', 'p', 'p.uid = mm.uid_local') ->where( $expr->eq('d.deleted', 0), $expr->eq('d.hidden', 0), $expr->eq('d.hideonwebsite', 0), $expr->eq('d.hideondatasheets', 0), $expr->eq('p.deleted', 0), $expr->eq('p.hidden', 0), $expr->eq('p.hideonwebsite', 0) ) ->executeQuery() ->fetchAllAssociative(); // Step B: reduce to {product_uid => latest datasheet} in PHP. $latestPerProduct = []; // product_uid => [datasheet_uid, tstamp, title] foreach ($rows as $r) { $pUid = (int)$r['product_uid']; $dUid = (int)$r['datasheet_uid']; $ts = (int)$r['d_tstamp']; $title = (string)$r['p_title']; if (!isset($latestPerProduct[$pUid]) || $ts > $latestPerProduct[$pUid]['tstamp']) { $latestPerProduct[$pUid] = [ 'product_uid' => $pUid, 'datasheet_uid' => $dUid, 'tstamp' => $ts, 'title' => $title, ]; } } // Sort by product title ASC. usort($latestPerProduct, static fn($a, $b) => strcasecmp($a['title'], $b['title'])); // Strip helper fields. return array_map( static fn($r) => ['product_uid' => $r['product_uid'], 'datasheet_uid' => $r['datasheet_uid']], $latestPerProduct ); } /** * @return array|false */ private function fetchProduct(int $uid) { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_product'); return $qb ->select('uid', 'title', 'slug', 'subtitle', 'teaser') ->from('tx_vitec_domain_model_product') ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); } /** * @return array|false */ private function fetchDownload(int $uid) { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_download'); return $qb ->select('*') ->from('tx_vitec_domain_model_download') ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); } /** * @param array $product * @return array */ private function serializeProduct(array $product): array { $uid = (int)$product['uid']; return [ 'uid' => $uid, 'title' => (string)($product['title'] ?? ''), 'slug' => (string)($product['slug'] ?? ''), 'subtitle' => (string)($product['subtitle'] ?? ''), 'teaser' => (string)($product['teaser'] ?? ''), 'link' => '/product/' . (string)($product['slug'] ?? ''), 'image' => $this->getProductFirstImage($uid), 'categories' => $this->getProductCategories($uid), ]; } /** * @param array $download * @return array */ private function serializeDatasheet(array $download): array { $uid = (int)$download['uid']; $filetype = $this->getDownloadFileType($uid); return [ 'uid' => $uid, 'title' => (string)($download['title'] ?? ''), 'slug' => (string)($download['slug'] ?? ''), 'teaser' => (string)($download['teaser'] ?? ''), 'description' => RteResolver::html($download['description'] ?? ''), 'tstamp' => (int)($download['tstamp'] ?? 0), 'filetype' => $filetype, 'type' => $this->getDownloadTypeLabel($uid), 'file' => $this->getDownloadFile($uid, (string)($download['fileprefix'] ?? ''), $filetype), ]; } /** * Display taxonomy from the `type` category field (sys_category.type, * fallback title) - analogous to getDownloadFileType()/`categories`, * distinguished in the MM table by fieldname = 'type'. */ private function getDownloadTypeLabel(int $downloadUid): string { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); $categories = $qb ->select('c.type', 'c.title') ->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('type', 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) ) ->orderBy('mm.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); foreach ($categories as $category) { $label = trim((string)($category['type'] ?? '')); if ($label === '') { $label = trim((string)($category['title'] ?? '')); } if ($label !== '') { return $label; } } return ''; } 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|null */ private function getProductFirstImage(int $productUid): ?array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $fileRefData = $qb ->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop') ->from('sys_file_reference', 'sfr') ->where( $qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)), $qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter('productimage', ParameterType::STRING)), $qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($productUid, ParameterType::INTEGER)), $qb->expr()->eq('sfr.deleted', 0), $qb->expr()->eq('sfr.hidden', 0) ) ->orderBy('sfr.sorting_foreign') ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!$fileRefData) { return null; } try { $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $imageService = GeneralUtility::makeInstance(ImageService::class); $fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']); $default = $imageService->applyProcessingInstructions( $fileReference, ['width' => 400, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp'] ); return [ 'uid' => (int)$fileRefData['uid'], 'url' => $imageService->getImageUri($default), 'title' => $fileRefData['title'] ?? '', 'alternative' => $fileRefData['alternative'] ?? '', 'description' => $fileRefData['description'] ?? '', ]; } catch (\Exception $e) { return null; } } private function getProductCategories(int $productUid): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); $categories = $qb ->select('c.uid', 'c.title', 'c.description') ->from('sys_category', 'c') ->join( 'c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid AND mm.tablenames = ' . $qb->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . ' AND mm.fieldname = ' . $qb->createNamedParameter('categories', ParameterType::STRING) ) ->where( $qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($productUid, ParameterType::INTEGER)), $qb->expr()->eq('c.deleted', 0), $qb->expr()->eq('c.hidden', 0) ) ->orderBy('mm.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); return array_map(static function ($cat) { return [ 'uid' => (int)$cat['uid'], 'title' => (string)($cat['title'] ?? ''), 'description' => (string)($cat['description'] ?? ''), ]; }, $categories); } /** * @return array|null */ private function getDownloadFile(int $downloadUid, string $fileprefix = '', string $filetype = ''): ?array { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $row = $qb ->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( $qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)), $qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)), $qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)), $qb->expr()->eq('fr.deleted', 0), $qb->expr()->eq('f.missing', 0) ) ->orderBy('fr.sorting_foreign', 'ASC') ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!$row) { return $this->resolveFileByConvention($fileprefix, $filetype) ?? $this->resolveFileByFilepath($downloadUid); } 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'] ?? ''), ]; } /** * Resolve file from naming convention: * [fileprefix]__[filetype]__[number]-[letter].ext * and pick highest number + highest letter. * * @return array|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]; $letter = strtoupper($matches[2]); $letterRank = $this->letterSequenceToRank($letter); 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, 'title' => '', 'description' => '', ]; } /** * Last-resort file resolution via the record's `filepath` column - for * filenames outside the ____- convention * (the 2026-08 import left a handful of such legacy names in place). * Same payload shape as resolveFileByConvention(). Exception-safe. */ private function resolveFileByFilepath(int $downloadUid): ?array { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_download'); $row = $qb ->select('filepath') ->from('tx_vitec_domain_model_download') ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)) ) ->executeQuery() ->fetchAssociative(); $filepath = trim((string)($row['filepath'] ?? '')); if ($filepath === '') { return null; } $relative = '/' . ltrim($filepath, '/'); $fullPath = Environment::getPublicPath() . $relative; if (!is_file($fullPath) || !is_readable($fullPath)) { return null; } $name = basename($fullPath); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($fullPath) ?: '') : ''; return [ 'uid' => 0, 'name' => $name, 'url' => rtrim(dirname($relative), '/') . '/' . rawurlencode($name), 'thumbnail' => null, 'size' => (int)(filesize($fullPath) ?: 0), 'extension' => $extension, 'mimeType' => $mimeType, 'title' => '', 'description' => '', ]; } catch (\Throwable $e) { return null; } } 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> $items * @return array> */ private function collectFallbackFilesFromItems(array $items): array { $fallbackFiles = []; foreach ($items as $item) { $datasheet = $item['datasheet'] ?? null; if (!is_array($datasheet)) { continue; } $file = $datasheet['file'] ?? null; if (!is_array($file) || (int)($file['uid'] ?? 1) !== 0) { continue; } $fallbackFiles[] = [ 'datasheetUid' => (int)($datasheet['uid'] ?? 0), 'name' => (string)($file['name'] ?? ''), 'url' => (string)($file['url'] ?? ''), 'source' => 'naming-convention-fallback', ]; } return $fallbackFiles; } }