heroimage (the 1920 hero) * - every other jpg/png (carousel/...) -> productimage (appended) * * Folder -> record matching: normalised title, title without the * parenthetical suffix, slugified folder vs. record slug, then the * IMAGE_ALIASES below. The "+1" folder names (VisionSC-UHD3, ...) are Joe's * renames of EXISTING products - their banners even carry the old names - * so they map onto our current records. * * Idempotent: files upload to fileadmin/product_import// with * DuplicationBehavior::REPLACE (same identifier -> same sys_file uid); * heroimage reuses/swaps its first live reference, productimage only * APPENDS missing files and never touches existing references (the * live-site images from the 2026-09-04 import stay). * * vendor/bin/typo3 vitec:import-product-images * vendor/bin/typo3 vitec:import-product-images --only=pico --apply */ #[AsCommand( name: 'vitec:import-product-images', description: 'Attach the agency banner/photo delivery to the product records (dry-run by default)' )] final class ImportProductImagesCommand extends Command { private const TABLE = 'tx_vitec_domain_model_product'; /** * Folder names that no title/slug match resolves (keys per norm()). * The Vision "+1" names are renames of existing products (their banner * files still carry the old names); 95-Series = the Avedia end points. */ private const IMAGE_ALIASES = [ 'visionscuhd3' => 'visionsc-uhd2', 'visionscdp3' => 'visionsc-dp2', 'visionioxd3' => 'visionio-xd2', 'visioniosd15' => 'visionio-sd14', 'visionhd5' => 'visionhd4', 'visionschd4plus' => 'visionsc-hd4', 'visionsd4plus1s' => 'visionsd4-1s', '95seriesendpoints' => 'avedia-end-points', // empty in this delivery, mapped for future drops: 'visionlchd3' => 'visionlc-hd2', 'visionsca3' => 'visionsc-a2', 'visionscs3' => 'visionsc-s2', 'visionscsd15' => 'visionsc-sd14', ]; /** @var array> */ private array $products = []; private int $newCounter = 0; protected function configure(): void { $this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Asset directory (one folder per product)', 'migrations/product_images_in'); $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product folders whose name contains this substring'); $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'); $only = mb_strtolower((string)($input->getOption('only') ?? '')); $dir = rtrim((string)$input->getOption('dir'), '/'); if (!is_dir($dir)) { $output->writeln('Directory not found: ' . $dir . ''); return Command::FAILURE; } $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $this->products = $connection->fetchAllAssociative( 'SELECT uid, pid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0' ); $folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: []); sort($folders); $output->writeln(count($folders) . ' product folder(s) ยท ' . ($apply ? 'APPLY' : 'DRY-RUN')); $notFound = []; $banners = 0; $photos = 0; $errors = 0; foreach ($folders as $folder) { $name = basename($folder); if ($only !== '' && !str_contains(mb_strtolower($name), $only)) { continue; } $product = $this->resolveProduct($name); if ($product === null) { $notFound[] = $name; continue; } $output->writeln(sprintf("\n%s -> %s (uid %d)", $name, $product['slug'], (int)$product['uid'])); $files = $this->imageFiles($folder); if ($files === []) { $output->writeln(' no jpg/png files - skipped'); continue; } try { [$b, $p] = $this->attachProduct($product, $files, $apply, $output); $banners += $b; $photos += $p; } catch (\Throwable $e) { $errors++; $output->writeln(' ' . $e->getMessage() . ''); } } $output->writeln(''); foreach ($notFound as $name) { $output->writeln('NOT FOUND (no product record): ' . $name . ''); } $output->writeln(sprintf( '%d banner action(s), %d photo action(s), %d folder(s) without record, %d error(s)%s', $banners, $photos, count($notFound), $errors, $apply ? '' : ' (dry run - nothing written)' )); if ($apply && ($banners + $photos) > 0) { $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); } return $errors === 0 ? Command::SUCCESS : Command::FAILURE; } /** @return string[] all jpg/png below the folder (carousel subfolders included), banners first */ private function imageFiles(string $folder): array { $files = []; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folder, \FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $fileInfo) { if ($fileInfo->isFile() && preg_match('/\.(jpe?g|png)$/i', $fileInfo->getFilename())) { $files[] = $fileInfo->getPathname(); } } usort($files, static function (string $a, string $b): int { $ab = (int)!str_contains(mb_strtolower(basename($a)), 'banner'); $bb = (int)!str_contains(mb_strtolower(basename($b)), 'banner'); return $ab <=> $bb ?: strcasecmp(basename($a), basename($b)); }); return $files; } /** * @param array $product * @param string[] $files * @return array{0:int,1:int} banner actions, photo actions */ private function attachProduct(array $product, array $files, bool $apply, OutputInterface $output): array { $uid = (int)$product['uid']; $pid = (int)$product['pid']; $slug = (string)$product['slug']; $datamap = []; $bannerActions = 0; $photoActions = 0; $storage = null; $folderObj = null; if ($apply) { $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $folderPath = 'product_import/' . $slug; $folderObj = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath); } $heroRefs = $this->liveReferences($uid, 'heroimage'); $photoRefs = $this->liveReferences($uid, 'productimage'); $photoFileUids = array_column($photoRefs, 'uid_local'); $photoRefIds = array_map(static fn(array $r): string => (string)$r['uid'], $photoRefs); $newPhotoIds = []; foreach ($files as $path) { $fileName = basename($path); $isBanner = str_contains(mb_strtolower($fileName), 'banner'); $fieldName = $isBanner ? 'heroimage' : 'productimage'; if (!$apply) { $output->writeln(' ' . $fieldName . ': would attach ' . $fileName); $isBanner ? $bannerActions++ : $photoActions++; continue; } $file = $storage->addFile($path, $folderObj, $fileName, DuplicationBehavior::REPLACE); if ($isBanner) { if ($heroRefs !== []) { $first = $heroRefs[0]; if ((int)$first['uid_local'] === $file->getUid()) { $output->writeln(' heroimage: ' . $fileName . ' already attached - unchanged'); } else { // swap the first hero reference onto the new banner; // further hero references stay untouched (editorial) $datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()]; $output->writeln(' heroimage: ' . $fileName . ' swapped onto the existing reference'); $bannerActions++; } } else { $refId = $this->newId(); $datamap['sys_file_reference'][$refId] = $this->refFields($file->getUid(), $uid, $pid, 'heroimage'); $datamap[self::TABLE][$uid]['heroimage'] = $refId; $output->writeln(' heroimage: ' . $fileName . ' attached'); $bannerActions++; } continue; } if (in_array($file->getUid(), $photoFileUids, true)) { $output->writeln(' productimage: ' . $fileName . ' already attached - unchanged'); continue; } $refId = $this->newId(); $datamap['sys_file_reference'][$refId] = $this->refFields($file->getUid(), $uid, $pid, 'productimage'); $newPhotoIds[] = $refId; $photoFileUids[] = $file->getUid(); $output->writeln(' productimage: ' . $fileName . ' appended'); $photoActions++; } if ($newPhotoIds !== []) { // inline fields take the FULL reference list - existing uids first, // the new NEW ids appended, so nothing editorial gets detached $datamap[self::TABLE][$uid]['productimage'] = implode(',', array_merge($photoRefIds, $newPhotoIds)); } if ($apply && $datamap !== []) { $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); } } return [$bannerActions, $photoActions]; } /** @return array */ private function refFields(int $fileUid, int $productUid, int $pid, string $fieldName): array { // pid, tablenames and uid_foreign are REQUIRED - without them // DataHandler silently rejects the reference (lesson 2026-10-02). return [ 'pid' => $pid, 'table_local' => 'sys_file', 'uid_local' => $fileUid, 'tablenames' => self::TABLE, 'fieldname' => $fieldName, 'uid_foreign' => $productUid, ]; } /** @return array live refs, oldest first */ private function liveReferences(int $productUid, string $fieldName): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); return array_map( static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']], $connection->fetchAllAssociative( 'SELECT uid, uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND fieldname = ? AND uid_foreign = ? ORDER BY sorting_foreign, uid', [self::TABLE, $fieldName, $productUid] ) ); } /** @return array|null */ private function resolveProduct(string $folderName): ?array { $noParens = trim((string)preg_replace('/\s*\(.*?\)/', '', $folderName)); foreach (array_unique([$folderName, $noParens]) as $candidate) { $normed = $this->norm($candidate); foreach ($this->products as $product) { if ($this->norm((string)$product['title']) === $normed) { return $product; } } $slug = $this->slugify($candidate); foreach ($this->products as $product) { if ((string)$product['slug'] === $slug) { return $product; } } } $alias = self::IMAGE_ALIASES[$this->norm($folderName)] ?? null; if ($alias !== null) { foreach ($this->products as $product) { if ((string)$product['slug'] === $alias) { return $product; } } } return null; } private function newId(): string { return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); } private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); } private function slugify(string $value): string { return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-'); } }