-> the record's first product photo, * falling back to its hero image; a record without any image gets the * generic grey placeholder (reported) * - every other card -> the numbered placeholder * fileadmin/placeholders/pages/-.png, NN counted from 10 in * card order per page (slots 01-04 and 08 are taken by the page * conventions; the number doubles as the image wish list to Joe) - * a missing numbered file falls back to the generic grey placeholder * and is reported for generation * * Market pages (0010-FE): an "Explore " card whose primary_cta * points to a /markets/... page gets that TARGET page's first media file * (mirrors the marketlist cards); these cards keep their text_only * variant (the FE styles them as market cards). Flipcards are skipped * entirely - they carry icons, never photos. * * A card that already has a vitec_image reference is NEVER touched, but it * still advances the numbering, so re-runs stay stable. Editors swap the * placeholder for the real asset on the element. * * vendor/bin/typo3 vitec:attach-card-images * vendor/bin/typo3 vitec:attach-card-images --apply */ #[AsCommand( name: 'vitec:attach-card-images', description: 'Attach product photos / numbered placeholders to the workbook cards (dry-run by default)' )] final class AttachCardImagesCommand extends Command { private const ROOTS = ['/products', '/insights', '/support', '/contact', '/markets']; private const FIRST_SLOT = 10; protected function configure(): void { $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $apply = (bool)$input->getOption('apply'); $pool = GeneralUtility::makeInstance(ConnectionPool::class); $pages = $pool->getConnectionForTable('pages'); $content = $pool->getConnectionForTable('tt_content'); $refs = $pool->getConnectionForTable('sys_file_reference'); $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $pageUids = []; foreach (self::ROOTS as $rootSlug) { foreach ($pages->fetchAllAssociative( "SELECT uid, slug FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND (slug = ? OR slug LIKE ?) AND sys_language_uid IN (0,-1) ORDER BY slug", [$rootSlug, $rootSlug . '/%'] ) as $row) { $pageUids[(int)$row['uid']] = (string)$row['slug']; } } $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($pageUids) . ' page(s) in ' . implode(' ', self::ROOTS)); $datamap = []; $newIndex = 0; $stats = ['photo' => 0, 'pagemedia' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'upgraded' => 0, 'missing' => []]; foreach ($pageUids as $pageUid => $slug) { $cards = $content->fetchAllAssociative( "SELECT uid, header, vitec_primary_cta, vitec_layout_variant FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND CType = 'vitec_card' ORDER BY colPos, sorting", [$pageUid] ); if ($cards === []) { continue; } $output->writeln($slug . ' (#' . $pageUid . '): ' . count($cards) . ' card(s)'); $slot = self::FIRST_SLOT - 1; foreach ($cards as $card) { $cardUid = (int)$card['uid']; if ((string)$card['vitec_layout_variant'] === 'flipcard') { continue; // flipcards carry icons (vitec_icon), never photos } $cta = (string)$card['vitec_primary_cta']; $isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches); if (!$isProduct) { $slot++; } $existingRef = $refs->fetchAssociative( "SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_image' AND r.uid_foreign = ? ORDER BY r.sorting_foreign LIMIT 1", [$cardUid] ); if ($existingRef) { // upgrade pass (2026-10-28): a product card that got the // generic grey because its record had no image swaps to // the real photo once the delivery arrived if ($isProduct && str_ends_with((string)$existingRef['identifier'], 'placeholder-grey.png')) { $photo = $this->productImageIdentifier($pool, $matches[1]); if ($photo !== null && $storage->hasFile($photo)) { $output->writeln(sprintf(' ^ #%d "%s": grau -> %s', $cardUid, (string)$card['header'], basename($photo))); $stats['upgraded']++; if ($apply) { $datamap['sys_file_reference'][(string)(int)$existingRef['uid']] = [ 'uid_local' => $storage->getFile($photo)->getUid(), ]; } if ((string)$card['vitec_layout_variant'] === 'text_only') { $datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical'; $stats['variant']++; } continue; } } $stats['kept']++; // a card that carries an image must not stay on the // explicit no-image variant (found 2026-10-27: the FE // correctly hides images on text_only) if ((string)$card['vitec_layout_variant'] === 'text_only') { $datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical'; $stats['variant']++; } continue; } $identifier = null; $note = ''; $isMarketCard = false; if ($isProduct) { $identifier = $this->productImageIdentifier($pool, $matches[1]); if ($identifier !== null) { $stats['photo']++; $note = 'product photo'; } else { $identifier = 'placeholders/placeholder-grey.png'; $stats['grey']++; $note = 'record ' . $matches[1] . ' has NO image - generic grey'; } } elseif (($pageMedia = $this->marketPageMediaIdentifier($pool, $cta)) !== null) { // 0010-FE: mirror the marketlist cards, which show the // target sub-market page's media $identifier = $pageMedia; $isMarketCard = true; $stats['pagemedia']++; $note = 'target page media'; } else { $identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png'; if ($storage->hasFile($identifier)) { $stats['placeholder']++; $note = 'numbered placeholder'; } else { $stats['missing'][] = $pageUid . '-' . $slot; $identifier = 'placeholders/placeholder-grey.png'; $stats['grey']++; $note = 'numbered file MISSING (generate ' . $pageUid . '-' . $slot . '.png) - generic grey'; } } if (!$storage->hasFile($identifier)) { $output->writeln(' ! #' . $cardUid . ' "' . $card['header'] . '": ' . $identifier . ' missing - skipped'); continue; } $output->writeln(sprintf(' + #%d "%s" <- %s (%s)', $cardUid, (string)$card['header'], basename($identifier), $note)); if (!$apply) { continue; } // market cards keep text_only - the FE keys its market-card // styling on that variant (0010-FE) and renders the image anyway if (!$isMarketCard && (string)$card['vitec_layout_variant'] === 'text_only') { $datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical'; $stats['variant']++; } $file = $storage->getFile($identifier); $datamap['sys_file_reference']['NEWCI' . (++$newIndex)] = [ 'pid' => $pageUid, 'table_local' => 'sys_file', 'uid_local' => $file->getUid(), 'tablenames' => 'tt_content', 'uid_foreign' => $cardUid, 'fieldname' => 'vitec_image', ]; } } $output->writeln(sprintf("\n%d product photo(s), %d target page media, %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d grey->photo upgrade(s), %d variant flip(s) text_only->vertical%s", $stats['photo'], $stats['pagemedia'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['upgraded'], $stats['variant'], $stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing']))); if (!$apply) { $output->writeln('DRY RUN - nothing written. Add --apply to write.'); return Command::SUCCESS; } if ($datamap === []) { $output->writeln('Nothing to attach.'); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } $output->writeln('Done. Flush the frontend cache.'); return Command::SUCCESS; } /** * First media file of the /markets/... page the card links to (plain * slug or t3://page?uid=N); null when the target is no market page or * has no media. */ private function marketPageMediaIdentifier(ConnectionPool $pool, string $cta): ?string { $pages = $pool->getConnectionForTable('pages'); $page = null; if (preg_match('#^t3://page\?uid=(\d+)#', $cta, $m)) { $page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$m[1]]); } elseif (preg_match('#^/markets/[a-z0-9+/-]+$#', $cta)) { $page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$cta]); } if (!$page || !str_starts_with((string)$page['slug'], '/markets/')) { return null; } $identifier = $pool->getConnectionForTable('sys_file_reference')->fetchOne( "SELECT f.identifier 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 = 'pages' AND r.fieldname = 'media' AND r.uid_foreign = ? ORDER BY r.sorting_foreign LIMIT 1", [(int)$page['uid']] ); return $identifier ? ltrim((string)$identifier, '/') : null; } /** First product photo, falling back to the hero image; null when the record has neither. */ private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string { $product = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAssociative( 'SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug = ?', [$slug] ); if (!$product) { return null; } foreach (['productimage', 'heroimage'] as $fieldName) { $identifier = $pool->getConnectionForTable('sys_file_reference')->fetchOne( "SELECT f.identifier 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 = ? AND r.uid_foreign = ? ORDER BY r.sorting_foreign LIMIT 1", [$fieldName, (int)$product['uid']] ); if ($identifier) { return ltrim((string)$identifier, '/'); } } return null; } }