diff --git a/.gitignore b/.gitignore index 2fc03f6..fb67471 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ migrations/products_xlsx/* migrations/submarkets_xlsx_in/* migrations/subsolutions_xlsx_in/* migrations/solutiongroups_xlsx_in/* +migrations/product_images_in/ diff --git a/packages/vitec/Classes/Command/ImportProductImagesCommand.php b/packages/vitec/Classes/Command/ImportProductImagesCommand.php index fbdd25c..7e327cc 100644 --- a/packages/vitec/Classes/Command/ImportProductImagesCommand.php +++ b/packages/vitec/Classes/Command/ImportProductImagesCommand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Evomedien\Vitec\Command; -use Doctrine\DBAL\ParameterType; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -13,130 +12,319 @@ use Symfony\Component\Console\Output\OutputInterface; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; use TYPO3\CMS\Core\Resource\StorageRepository; use TYPO3\CMS\Core\Utility\GeneralUtility; /** - * Attach the product photos harvested from vitec.com / datapath.co.uk - * (2026-09-04) to the product records. + * Attaches the agency product assets (Joe's Dropbox delivery 2026-10-23, + * one folder per product) to the product records: * - * Files live in fileadmin/user_upload/products// - 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). + * - files whose name contains "banner" -> heroimage (the 1920 hero) + * - every other jpg/png (carousel/...) -> productimage (appended) * - * Idempotent: products that got their images on a previous run are skipped - * on the next one. + * 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 --dry-run * 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 staged product photos (fileadmin/user_upload/products//) to image-less products' + 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'; - private const STAGE = 'user_upload/products'; + + /** + * 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('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); + $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(); - $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.'); + $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; } - $stageFolder = $storage->getFolder(self::STAGE); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); + $this->products = $connection->fetchAllAssociative( + 'SELECT uid, pid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0' + ); - $datamap = []; - $newIndex = 0; - $planned = 0; - $skippedExisting = 0; + $folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: []); + sort($folders); + $output->writeln(count($folders) . ' product folder(s) ยท ' . ($apply ? 'APPLY' : 'DRY-RUN')); - 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)); + $notFound = []; + $banners = 0; + $photos = 0; + $errors = 0; + foreach ($folders as $folder) { + $name = basename($folder); + if ($only !== '' && !str_contains(mb_strtolower($name), $only)) { 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++; + $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 = $folder->getFiles(); - usort($files, static fn($a, $b): int => strcasecmp($a->getName(), $b->getName())); + $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; } - $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'], - ]; + $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; } - $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))); + + 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++; } - $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; + 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)); } - $dataHandler = GeneralUtility::makeInstance(DataHandler::class); - $dataHandler->start($datamap, []); - $dataHandler->process_datamap(); - if ($dataHandler->errorLog !== []) { - foreach ($dataHandler->errorLog as $error) { - $output->writeln('' . $error . ''); + 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 Command::FAILURE; } - $output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); - return Command::SUCCESS; + 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))), '-'); } }