lean card data for the list plugin * serializeDetail() -> full detail (hero, content elements, related, seo) * * Inline content elements are resolved flat; the VITEC Columns content block * (vitec_columns) is resolved specially into { header…, layout, columns:{…} }. * * All relation/FAL resolution lives here so the List/Show UserFunc renderers * stay thin. Every resolver is exception-safe and returns null/[] on failure. */ final class UsecaseSerializer { private const TABLE = 'tx_vitec_domain_model_usecase'; private const IMAGE_WIDTHS = [400, 800, 1200, 1600]; private const COLUMNS_CTYPE = 'vitec_columns'; /** * @param array $u * @return array */ public function serializeListItem(array $u): array { $uid = (int)$u['uid']; return [ 'uid' => $uid, 'title' => (string)($u['title'] ?? ''), 'slug' => (string)($u['slug'] ?? ''), 'subtitle' => (string)($u['subtitle'] ?? ''), 'teaser' => (string)($u['teaser'] ?? ''), 'cardImage' => $this->image($uid, 'card_image'), 'customerLogo' => $this->image($uid, 'customer_logo'), 'markets' => $this->relationMulti('tx_vitec_usecase_market_mm', 'tx_vitec_domain_model_market', $uid), 'categories' => $this->categories($uid), 'featured' => (bool)($u['featured'] ?? false), 'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'), 'backgroundVariant' => (string)($u['background_variant'] ?? 'none'), ]; } /** * @param array $u * @return array */ public function serializeDetail(array $u): array { $uid = (int)$u['uid']; $base = $this->serializeListItem($u); return array_merge($base, [ 'hero' => [ 'bgImage' => $this->image($uid, 'hero_bgimage'), 'smallImage' => $this->image($uid, 'hero_small_image'), 'video' => $this->video($uid, 'hero_video'), 'overlayColor' => (string)($u['hero_overlay_color'] ?? ''), 'overlayOpacity' => (float)($u['hero_overlay_opacity'] ?? 0), 'layout' => (string)($u['hero_layout'] ?? 'fullscreen'), 'textTheme' => (string)($u['text_theme'] ?? 'light'), ], 'contentElements' => $this->contentElements($uid), 'related' => [ 'show' => (bool)($u['show_related'] ?? true), 'markets' => $base['markets'], 'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid), 'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid), 'categories' => $base['categories'], ], 'seo' => $this->seo($u), 'appearance' => [ 'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'), 'backgroundVariant' => (string)($u['background_variant'] ?? 'none'), 'accentColor' => (string)($u['accent_color'] ?? ''), 'featured' => (bool)($u['featured'] ?? false), 'heroLayout' => (string)($u['hero_layout'] ?? 'fullscreen'), 'textTheme' => (string)($u['text_theme'] ?? 'light'), ], ]); } // ---------------------------------------------------------------- images /** * FAL image (with srcset). $table defaults to the usecase table but can be * a content-block collection table (for Columns items). * * @return array|null */ public function image(int $recordUid, string $fieldName, ?string $table = null): ?array { try { $table = $table ?? self::TABLE; $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $ref = $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($table, ParameterType::STRING)), $qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)), $qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)), $qb->expr()->eq('sfr.deleted', 0), $qb->expr()->eq('sfr.hidden', 0) ) ->orderBy('sfr.sorting_foreign') ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!$ref) { return null; } $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $imageService = GeneralUtility::makeInstance(ImageService::class); $fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']); if (str_contains((string)$fileReference->getMimeType(), 'svg')) { // SVG: no processing/srcset — deliver the file as-is. return [ 'uid' => (int)$ref['uid'], 'url' => (string)$fileReference->getPublicUrl(), 'title' => (string)($ref['title'] ?? ''), 'alternative' => (string)($ref['alternative'] ?? ''), 'description' => (string)($ref['description'] ?? ''), 'srcset' => [], 'properties' => ['mimeType' => $fileReference->getMimeType()], ]; } $srcset = []; foreach (self::IMAGE_WIDTHS as $width) { $variant = $imageService->applyProcessingInstructions( $fileReference, ['width' => $width, 'crop' => $ref['crop'] ?? null] ); $srcset[] = [ 'url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w', ]; } $default = $imageService->applyProcessingInstructions( $fileReference, ['width' => 800, 'crop' => $ref['crop'] ?? null] ); return [ 'uid' => (int)$ref['uid'], 'url' => $imageService->getImageUri($default), 'title' => (string)($ref['title'] ?? ''), 'alternative' => (string)($ref['alternative'] ?? ''), 'description' => (string)($ref['description'] ?? ''), 'srcset' => $srcset, 'properties' => [ 'width' => $fileReference->getProperty('width'), 'height' => $fileReference->getProperty('height'), 'mimeType' => $fileReference->getProperty('mime_type'), ], ]; } catch (\Throwable $e) { return null; } } /** * All FAL images of one field (multi-reference), same shape as image(). * * @return array> */ public function images(int $recordUid, string $fieldName, ?string $table = null): array { try { $table = $table ?? self::TABLE; $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $refs = $qb ->select('sfr.uid') ->from('sys_file_reference', 'sfr') ->where( $qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)), $qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)), $qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)), $qb->expr()->eq('sfr.deleted', 0), $qb->expr()->eq('sfr.hidden', 0) ) ->orderBy('sfr.sorting_foreign') ->executeQuery() ->fetchAllAssociative(); $out = []; foreach ($refs as $i => $r) { // reuse the single-image resolver per reference position $img = $this->imageByReferenceUid((int)$r['uid']); if ($img !== null) { $out[] = $img; } } return $out; } catch (\Throwable $e) { return []; } } /** * Resolve one sys_file_reference uid to the standard image shape. * * @return array|null */ private function imageByReferenceUid(int $refUid): ?array { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $ref = $qb ->select('uid', 'title', 'description', 'alternative', 'crop') ->from('sys_file_reference') ->where($qb->expr()->eq('uid', $qb->createNamedParameter($refUid, ParameterType::INTEGER))) ->executeQuery() ->fetchAssociative(); if (!$ref) { return null; } $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $imageService = GeneralUtility::makeInstance(ImageService::class); $fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']); if (str_contains((string)$fileReference->getMimeType(), 'svg')) { // SVG: no processing/srcset — deliver the file as-is. return [ 'uid' => (int)$ref['uid'], 'url' => (string)$fileReference->getPublicUrl(), 'title' => (string)($ref['title'] ?? ''), 'alternative' => (string)($ref['alternative'] ?? ''), 'description' => (string)($ref['description'] ?? ''), 'srcset' => [], 'properties' => ['mimeType' => $fileReference->getMimeType()], ]; } $srcset = []; foreach (self::IMAGE_WIDTHS as $width) { $variant = $imageService->applyProcessingInstructions( $fileReference, ['width' => $width, 'crop' => $ref['crop'] ?? null] ); $srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w']; } $default = $imageService->applyProcessingInstructions( $fileReference, ['width' => 800, 'crop' => $ref['crop'] ?? null] ); return [ 'uid' => (int)$ref['uid'], 'url' => $imageService->getImageUri($default), 'title' => (string)($ref['title'] ?? ''), 'alternative' => (string)($ref['alternative'] ?? ''), 'description' => (string)($ref['description'] ?? ''), 'srcset' => $srcset, 'properties' => [ 'width' => $fileReference->getProperty('width'), 'height' => $fileReference->getProperty('height'), 'mimeType' => $fileReference->getProperty('mime_type'), ], ]; } catch (\Throwable $e) { return null; } } /** * FAL video (public URL only). $table as in image(). * * @return array|null */ public function video(int $recordUid, string $fieldName, ?string $table = null): ?array { try { $table = $table ?? self::TABLE; $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_file_reference'); $ref = $qb ->select('uid', 'title', 'description') ->from('sys_file_reference') ->where( $qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)), $qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)), $qb->expr()->eq('uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->orderBy('sorting_foreign') ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!$ref) { return null; } $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']); return [ 'uid' => (int)$ref['uid'], 'url' => $fileReference->getPublicUrl(), 'title' => (string)($ref['title'] ?? ''), 'mimeType' => $fileReference->getProperty('mime_type'), ]; } catch (\Throwable $e) { return null; } } // ------------------------------------------------------------- relations /** * Single market (foreign_table, no MM) -> title/slug. * * @return array|null */ public function market(int $marketUid): ?array { if ($marketUid <= 0) { return null; } try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tx_vitec_domain_model_market'); $row = $qb ->select('uid', 'title', 'subtitle', 'teaser') ->from('tx_vitec_domain_model_market') ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($marketUid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); if (!$row) { return null; } return [ 'uid' => (int)$row['uid'], 'title' => (string)($row['title'] ?? ''), 'subtitle' => (string)($row['subtitle'] ?? ''), 'teaser' => (string)($row['teaser'] ?? ''), ]; } catch (\Throwable $e) { return null; } } /** * n:n relation resolved through an MM table -> [{uid,title}, …]. * * @return array> */ public function relationMulti(string $mmTable, string $foreignTable, int $usecaseUid): array { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable($foreignTable); $rows = $qb ->select('f.uid', 'f.title') ->from($foreignTable, 'f') ->join('f', $mmTable, 'mm', 'mm.uid_foreign = f.uid') ->where( $qb->expr()->eq('mm.uid_local', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)), $qb->expr()->eq('f.deleted', 0), $qb->expr()->eq('f.hidden', 0) ) ->orderBy('mm.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); return array_map(static fn ($r) => [ 'uid' => (int)$r['uid'], 'title' => (string)($r['title'] ?? ''), ], $rows); } catch (\Throwable $e) { return []; } } /** * System categories (sys_category MM) -> [{uid,title,description}, …]. * * @return array> */ public function categories(int $usecaseUid): array { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('sys_category'); $rows = $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(self::TABLE, ParameterType::STRING) . ' AND mm.fieldname = ' . $qb->createNamedParameter('categories', ParameterType::STRING) ) ->where( $qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)), $qb->expr()->eq('c.deleted', 0), $qb->expr()->eq('c.hidden', 0) ) ->orderBy('mm.sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); return array_map(static fn ($c) => [ 'uid' => (int)$c['uid'], 'title' => (string)($c['title'] ?? ''), 'description' => (string)($c['description'] ?? ''), ], $rows); } catch (\Throwable $e) { return []; } } // ------------------------------------------------------- content elements /** * Inline content elements (tt_content owned via tx_vitec_usecase_content). * Flat CEs are normalised; a VITEC Columns element is resolved specially. * * @return array> */ public function contentElements(int $usecaseUid): array { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $rows = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('tx_vitec_usecase_content', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->orderBy('sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); $out = []; foreach ($rows as $row) { $resolved = $this->resolveInlineElement($row); if ($resolved !== null) { $out[] = $resolved; } } return $out; } catch (\Throwable $e) { return []; } } /** * @param array $row * @return array|null */ private function resolveInlineElement(array $row): ?array { $ctype = (string)($row['CType'] ?? ''); if ($ctype === self::COLUMNS_CTYPE) { return $this->resolveColumnsElement($row); } if ($ctype === 'vitec_quotation') { $uid = (int)$row['uid']; $logoField = array_key_exists('vitec_quote_logo', $row) ? 'vitec_quote_logo' : 'quote_logo'; return [ 'id' => $uid, 'type' => 'vitec_quotation', 'colPos' => (int)($row['colPos'] ?? 0), 'sorting' => (int)($row['sorting'] ?? 0), 'header' => (string)($row['header'] ?? ''), 'quote' => (string)($row['vitec_quote'] ?? $row['quote'] ?? ''), 'name' => (string)($row['vitec_quote_name'] ?? $row['quote_name'] ?? ''), 'position' => (string)($row['vitec_quote_position'] ?? $row['quote_position'] ?? ''), 'logo' => $this->image($uid, $logoField, 'tt_content'), ]; } $el = ContentElementResolver::normaliseRecord($row); if (is_array($el)) { $uid = (int)$row['uid']; $media = []; foreach (['image', 'assets', 'media'] as $f) { foreach ($this->images($uid, $f, 'tt_content') as $img) { $media[] = $img; } } if ($media !== []) { $el['images'] = $media; } } return $el; } /** * VITEC Columns content block -> { header…, layout, columns:{left,right} }. * Items live in a content-blocks collection table (discovered from TCA) and * carry a `column` (left/right) plus a typed payload. * * @param array $row * @return array */ private function resolveColumnsElement(array $row): array { $uid = (int)$row['uid']; $left = []; $right = []; $table = $this->collectionTable('vitec_items') ?? $this->collectionTable('items'); if ($table !== null) { try { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table); $items = $qb ->select('*') ->from($table) ->where( $qb->expr()->eq('foreign_table_parent_uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->orderBy('sorting', 'ASC') ->executeQuery() ->fetchAllAssociative(); foreach ($items as $it) { $column = (string)($it['vitec_column'] ?? $it['column'] ?? 'left'); $resolved = $this->resolveColumnItem($it, $table); if ($column === 'right') { $right[] = $resolved; } else { $left[] = $resolved; } } } catch (\Throwable $e) { // collection table not ready / query failed -> empty columns } } return [ 'id' => $uid, 'type' => self::COLUMNS_CTYPE, 'colPos' => (int)($row['colPos'] ?? 0), 'sorting' => (int)($row['sorting'] ?? 0), 'header' => (string)($row['header'] ?? ''), 'subheader' => (string)($row['subheader'] ?? ''), 'headerLayout' => (int)($row['header_layout'] ?? 0), 'headerPosition' => (string)($row['header_position'] ?? ''), 'headerLink' => (string)($row['header_link'] ?? ''), 'layout' => (string)($row['vitec_layout'] ?? $row['layout'] ?? 'cols_50_50'), 'columns' => [ 'left' => $left, 'right' => $right, ], ]; } /** * @param array $it * @return array */ private function resolveColumnItem(array $it, string $table): array { $uid = (int)$it['uid']; $get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? ''); return [ 'type' => $get('item_type') !== '' ? $get('item_type') : 'text', 'headline' => $get('headline'), 'text' => $get('text'), 'image' => $this->image($uid, 'vitec_image', $table) ?? $this->image($uid, 'image', $table), 'video' => $this->video($uid, 'vitec_video', $table) ?? $this->video($uid, 'video', $table), 'link' => $get('link'), 'linkLabel' => $get('link_label'), ]; } /** * Resolve a content-blocks collection field's storage table from TCA. */ private function collectionTable(string $field): ?string { $t = $GLOBALS['TCA']['tt_content']['columns'][$field]['config']['foreign_table'] ?? null; return (is_string($t) && $t !== '') ? $t : null; } // ------------------------------------------------------------------- seo /** * SEO bundle with fallback resolution (title/teaser/card/hero images). * * @param array $u * @return array */ public function seo(array $u): array { $uid = (int)$u['uid']; $title = $this->firstNonEmpty([(string)($u['seo_title'] ?? ''), (string)($u['title'] ?? '')]); $desc = $this->firstNonEmpty([(string)($u['seo_description'] ?? ''), (string)($u['teaser'] ?? '')]); $ogTitle = $this->firstNonEmpty([(string)($u['og_title'] ?? ''), $title]); $ogDesc = $this->firstNonEmpty([(string)($u['og_description'] ?? ''), $desc]); $ogImage = $this->image($uid, 'og_image') ?? $this->image($uid, 'card_image') ?? $this->image($uid, 'hero_bgimage'); $twTitle = $this->firstNonEmpty([(string)($u['twitter_title'] ?? ''), $ogTitle]); $twDesc = $this->firstNonEmpty([(string)($u['twitter_description'] ?? ''), $ogDesc]); $twImage = $this->image($uid, 'twitter_image') ?? $ogImage; return [ 'title' => $title, 'description' => $desc, 'canonical' => (string)($u['canonical_link'] ?? ''), 'robots' => [ 'noIndex' => (bool)($u['no_index'] ?? false), 'noFollow' => (bool)($u['no_follow'] ?? false), ], 'openGraph' => [ 'title' => $ogTitle, 'description' => $ogDesc, 'image' => $ogImage, ], 'twitter' => [ 'title' => $twTitle, 'description' => $twDesc, 'image' => $twImage, ], ]; } /** * @param array $candidates */ private function firstNonEmpty(array $candidates): string { foreach ($candidates as $c) { $c = trim($c); if ($c !== '') { return $c; } } return ''; } }