/ - one folder * per product, downloaded straight from the live sites. For every folder * whose slug matches a product record WITHOUT any image (fill-up only, * existing images are never touched), the files are indexed in FAL and * appended to the `images` field through DataHandler (sys_file_reference), * in alphabetical file order (the live carousels' _01.._NN order). * * Idempotent: products that got their images on a previous run are skipped * on the next one. * * vendor/bin/typo3 vitec:import-product-images --dry-run * vendor/bin/typo3 vitec:import-product-images */ #[AsCommand( name: 'vitec:import-product-images', description: 'Attach staged product photos (fileadmin/user_upload/products//) to image-less products' )] final class ImportProductImagesCommand extends Command { private const TABLE = 'tx_vitec_domain_model_product'; private const STAGE = 'user_upload/products'; protected function configure(): void { $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dryRun = (bool)$input->getOption('dry-run'); $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); if ($storage === null || !$storage->hasFolder(self::STAGE)) { $output->writeln('Staging folder ' . self::STAGE . ' not found in the default storage.'); return Command::FAILURE; } $stageFolder = $storage->getFolder(self::STAGE); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $datamap = []; $newIndex = 0; $planned = 0; $skippedExisting = 0; foreach ($stageFolder->getSubfolders() as $folder) { $slug = $folder->getName(); $product = $connection->fetchAssociative( 'SELECT uid, pid, title FROM ' . self::TABLE . ' WHERE deleted = 0 AND slug = ?', [$slug] ); if ($product === false) { $output->writeln(sprintf('skip %-45s - no product with this slug', $slug)); continue; } $uid = (int)$product['uid']; // NB: the TCA field is `productimage` - `images` is only the // JSON key the serializers emit (lesson from the first run). $existing = (int)$connection->fetchOne( "SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = ? AND fieldname = 'productimage' AND uid_foreign = ?", [self::TABLE, $uid] ); if ($existing > 0) { $output->writeln(sprintf('keep %-45s - already has %d image(s)', $slug, $existing)); $skippedExisting++; continue; } $files = $folder->getFiles(); usort($files, static fn($a, $b): int => strcasecmp($a->getName(), $b->getName())); if ($files === []) { continue; } $referenceIds = []; foreach ($files as $file) { // getFiles() delivers indexed File objects (sys_file created on // the fly for new files), so uid is available right away. $newId = 'NEW' . ++$newIndex; $referenceIds[] = $newId; $datamap['sys_file_reference'][$newId] = [ 'table_local' => 'sys_file', 'uid_local' => $file->getUid(), 'uid_foreign' => $uid, 'tablenames' => self::TABLE, 'fieldname' => 'productimage', 'pid' => (int)$product['pid'], ]; } $datamap[self::TABLE][$uid]['productimage'] = implode(',', $referenceIds); $planned++; $output->writeln(sprintf('%s %-45s -> uid %d "%s": %d image(s)', $dryRun ? 'plan ' : 'WRITE', $slug, $uid, $product['title'], count($referenceIds))); } $output->writeln(sprintf("\n%d product(s) to fill, %d already had images.", $planned, $skippedExisting)); if ($planned === 0) { return Command::SUCCESS; } if ($dryRun) { $output->writeln('DRY RUN - nothing written.'); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { foreach ($dataHandler->errorLog as $error) { $output->writeln('' . $error . ''); } return Command::FAILURE; } $output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } }