'hero', 'label' => 'Hero', 'kind' => 'images'], ['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'], ['key' => 'og', 'label' => 'OG image', 'kind' => 'images'], ['key' => 'video', 'label' => 'Video', 'kind' => 'video'], ['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'], ['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'], ['key' => 'description', 'label' => 'Description', 'kind' => 'text'], ['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'], ['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'], ['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'], ['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'], ['key' => 'features', 'label' => 'Features', 'kind' => 'features'], ['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'], ['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'], ['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'], ]; private const FLAGS = [ 'hidden' => 'hidden', 'legacy' => 'legacy', 'supportproduct' => 'support', 'subproduct' => 'sub', 'hideonwebsite' => 'hideWeb', 'hideonproducts' => 'hideList', 'showdatapath' => 'datapath', ]; private const TEXT_FIELDS = [ 'teaser' => 'Teaser', 'subtitle' => 'Subtitle', 'description' => 'Description', 'description2' => 'Description 2', 'highlights' => 'Highlights', 'capabilities' => 'Capabilities', 'applications' => 'Applications', ]; public function __construct( private readonly ConnectionPool $connectionPool, ) {} /** * @return array{generatedAt: string, fields: array, summary: array{products: int, filled: array}, products: array>} */ public function overview(): array { $products = $this->products(); $uids = array_map(static fn(array $p): int => (int)$p['uid'], $products); $files = $this->fileCounts($uids); $features = $this->featureCounts($uids); $resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0'); $datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download'); $solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution'); $filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0); $rows = []; foreach ($products as $p) { $uid = (int)$p['uid']; $f = $files[$uid] ?? []; $checks = [ 'hero' => $this->check($f['heroimage'] ?? 0), 'photos' => $this->check($f['productimage'] ?? 0), 'og' => $this->check($f['ogimage'] ?? 0), 'video' => $this->check($f['videofile'] ?? 0), 'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0), 'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0), 'description' => $this->check($this->filled($p['description']) ? 1 : 0), 'description2' => $this->check($this->filled($p['description2']) ? 1 : 0), 'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0), 'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0), 'applications' => $this->check($this->filled($p['applications']) ? 1 : 0), 'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0], 'resources' => $this->check($resources[$uid] ?? 0), 'datasheets' => $this->check($datasheets[$uid] ?? 0), 'solutions' => $this->check($solutions[$uid] ?? 0), ]; foreach ($checks as $key => $c) { if ($c['ok']) { $filled[$key]++; } } $rows[] = $this->base($p) + ['checks' => $checks]; } return [ 'generatedAt' => date(\DATE_ATOM), 'fields' => self::FIELDS, 'summary' => ['products' => count($rows), 'filled' => $filled], 'products' => $rows, ]; } /** * @return array|null null when the product does not exist */ public function detail(int $uid): ?array { $p = $this->connection()->executeQuery( 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, capabilities, applications, resources_layout, hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?', [$uid] )->fetchAssociative(); if ($p === false) { return null; } $files = $this->files($uid); $texts = []; foreach (self::TEXT_FIELDS as $field => $label) { $texts[$field] = [ 'label' => $label, 'ok' => $this->filled($p[$field]), 'html' => (string)$p[$field], ]; } return $this->base($p) + [ 'images' => [ 'hero' => $files['heroimage'] ?? [], 'photos' => $files['productimage'] ?? [], 'og' => $files['ogimage'] ?? [], ], 'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []), 'texts' => $texts, 'features' => $this->features($uid), 'resources' => $this->resources($uid), 'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid), 'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid), ]; } // ---- building blocks ---------------------------------------------------- /** @param array $p */ private function base(array $p): array { $slug = trim((string)$p['slug'], '/'); $flags = []; foreach (self::FLAGS as $column => $label) { if ((int)($p[$column] ?? 0) === 1) { $flags[] = $label; } } return [ 'uid' => (int)$p['uid'], 'title' => (string)$p['title'], 'slug' => $slug, 'url' => '/product/' . $slug, 'flags' => $flags, 'resourcesLayout' => (string)($p['resources_layout'] ?? ''), ]; } /** @return array{ok: bool, count: int} */ private function check(int $count): array { return ['ok' => $count > 0, 'count' => $count]; } private function filled(mixed $value): bool { return trim(strip_tags((string)$value)) !== ''; } /** @return array> */ private function products(): array { return $this->connection()->executeQuery( 'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights, capabilities, applications, resources_layout, hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid' )->fetchAllAssociative(); } /** * Visible FAL references per product and field. * * @param int[] $uids * @return array> [productUid][fieldname] = count */ private function fileCounts(array $uids): array { if ($uids === []) { return []; } $rows = $this->connection()->executeQuery( "SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product' AND fieldname IN ('heroimage','productimage','ogimage','videofile') AND uid_foreign IN (" . implode(',', array_map('intval', $uids)) . ') GROUP BY uid_foreign, fieldname' )->fetchAllAssociative(); $out = []; foreach ($rows as $r) { $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; } return $out; } /** * Files of one product with original URL and a ~480 px thumbnail * (SVG and non-images keep the original as thumbnail). * * @return array> */ private function files(int $uid): array { $rows = $this->connection()->executeQuery( "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ? ORDER BY r.fieldname, r.sorting_foreign", [$uid] )->fetchAllAssociative(); $out = []; foreach ($rows as $r) { $url = '/fileadmin' . $r['identifier']; $out[(string)$r['fieldname']][] = [ 'url' => $url, 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), 'name' => (string)$r['name'], 'mime' => (string)$r['mime_type'], ]; } return $out; } private function thumbnail(int $fileUid, string $mime, string $fallback): string { if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') { return $fallback; } try { $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid); $imageService = GeneralUtility::makeInstance(ImageService::class); $processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]); return $imageService->getImageUri($processed); } catch (\Throwable) { return $fallback; } } /** * @param int[] $uids * @return array */ private function featureCounts(array $uids): array { if ($uids === []) { return []; } $in = implode(',', array_map('intval', $uids)); $out = []; foreach ($this->connection()->executeQuery( "SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product" )->fetchAllAssociative() as $r) { $out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0]; } foreach ($this->connection()->executeQuery( "SELECT fe.product, COUNT(DISTINCT fe.uid) AS n FROM tx_vitec_product_feature fe JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0 WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product" )->fetchAllAssociative() as $r) { $out[(int)$r['product']]['icons'] = (int)$r['n']; } return $out; } /** @return array */ private function features(int $uid): array { $cards = $this->connection()->executeQuery( 'SELECT uid, title, cardtext FROM tx_vitec_product_feature WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', [$uid] )->fetchAllAssociative(); if ($cards === []) { return []; } $icons = []; foreach ($this->connection()->executeQuery( "SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ') ORDER BY r.uid_foreign, r.sorting_foreign' )->fetchAllAssociative() as $r) { $icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']]; } return array_map(static fn(array $c): array => [ 'title' => (string)$c['title'], 'text' => (string)$c['cardtext'], 'icon' => $icons[(int)$c['uid']] ?? null, ], $cards); } /** @return array */ private function resources(int $uid): array { return array_map(static fn(array $r): array => [ 'title' => (string)$r['title'], 'type' => (string)$r['restype'], 'link' => (string)$r['link'], 'text' => (string)$r['cardtext'], ], $this->connection()->executeQuery( 'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid', [$uid] )->fetchAllAssociative()); } /** @return array [productUid] = count */ private function countBy(string $table, string $column, string $where): array { $out = []; foreach ($this->connection()->executeQuery( "SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column" )->fetchAllAssociative() as $r) { $out[(int)$r['k']] = (int)$r['n']; } return $out; } /** @return array [productUid] = count of non-deleted foreign records */ private function mmCounts(string $mmTable, string $foreignTable): array { $out = []; foreach ($this->connection()->executeQuery( "SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 GROUP BY mm.uid_local" )->fetchAllAssociative() as $r) { $out[(int)$r['k']] = (int)$r['n']; } return $out; } /** @return array */ private function mmTitles(string $mmTable, string $foreignTable, int $uid): array { return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery( "SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0 WHERE mm.uid_local = ? ORDER BY mm.sorting", [$uid] )->fetchAllAssociative()); } private function connection(): \TYPO3\CMS\Core\Database\Connection { return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE); } }