From c8d653b39b61248079511d38fe1e960a0468319a Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 27 Oct 2026 14:26:15 +0100 Subject: [PATCH] Card images: product photos plus numbered placeholders on the new cards - new vitec:attach-card-images (dry-run by default) for the ~145 vitec_card elements the 2026-10-27 workbook imports created across /products, /insights, /support and /contact: a card linking /product/ gets the record's first product photo (hero banner as fallback), every other card gets the numbered placeholder placeholders/pages/-NN.png counted from slot 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 towards the agency). 110 placeholder PNGs (640x480) were generated from the live payloads so the numbering is deterministic; cards that already carry an image are never touched but keep advancing the counter, so re-runs stay stable - container children are included: the editor had arranged the /support cards into a grid container (colPos 221-223/251), which the first top-level-only query skipped - applied and verified live: 42 product photos, 110 numbered placeholders, 18 generic grey cards - the grey ones map the records without any image at all (ArtioSign, ArtioView, Arqa, the Aetria family, Wall Control 10, X-Series; all Datapath software without an image delivery, queued for Joe) Co-Authored-By: Claude Fable 5 --- .../Command/AttachCardImagesCommand.php | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/vitec/Classes/Command/AttachCardImagesCommand.php diff --git a/packages/vitec/Classes/Command/AttachCardImagesCommand.php b/packages/vitec/Classes/Command/AttachCardImagesCommand.php new file mode 100644 index 0000000..0b6a2c8 --- /dev/null +++ b/packages/vitec/Classes/Command/AttachCardImagesCommand.php @@ -0,0 +1,199 @@ + -> 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 + * + * 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']; + 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, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'missing' => []]; + foreach ($pageUids as $pageUid => $slug) { + $cards = $content->fetchAllAssociative( + "SELECT uid, header, vitec_primary_cta 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']; + $cta = (string)$card['vitec_primary_cta']; + $isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches); + if (!$isProduct) { + $slot++; + } + $existing = (int)$refs->fetchOne( + "SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' + AND fieldname = 'vitec_image' AND uid_foreign = ?", + [$cardUid] + ); + if ($existing > 0) { + $stats['kept']++; + continue; + } + $identifier = null; + $note = ''; + 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'; + } + } 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; + } + $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 numbered placeholder(s), %d generic grey, %d card(s) already had an image%s", + $stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'], + $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 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; + } +}