Product banners and photos: vitec:import-product-images

- new command attaching the agency asset delivery (one Dropbox folder
  per product) to the product records: files containing "banner" go to
  heroimage (first reference reused/swapped), every other jpg/png -
  carousel subfolders included - is APPENDED to productimage with the
  full reference list, so the live-site images from 2026-09-04 stay
- folder -> record matching: normalised title, title without the
  parenthetical, slug, then IMAGE_ALIASES (Joe's "+1" Vision folder
  names map onto the existing records - their banner files still carry
  the old product names; 95-Series -> avedia-end-points)
- uploads land in fileadmin/product_import/<slug>/ with REPLACE, so
  re-runs are idempotent; inbox migrations/product_images_in/ gitignored
- applied: 39 banners + 14 photos across 42 products, 0 errors,
  live-verified (srcset/webp processing included)
- the "MGW Diamond (SDI encoder)" Dropbox folder held the OG blade's
  banner (image inspected): moved to the OG product in the inbox, the
  appliance deliberately keeps no hero; the Dropbox source still has it
  in the wrong folder
- still without records: MGW Diamond/Pico/Pico+ TOUGH and X-View - their
  assets wait in the inbox, a re-run attaches them once records exist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-23 13:05:32 +02:00
1 parent 3e2ebf3823
commit 115952a132
2 files changed
+270 -81

No files matched your search

@@ -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/<product-slug>/ - 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/<slug>/ 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/<slug>/) 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<int,array<string,mixed>> */
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('<error>Staging folder ' . self::STAGE . ' not found in the default storage.</error>');
$apply = (bool)$input->getOption('apply');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$dir = rtrim((string)$input->getOption('dir'), '/');
if (!is_dir($dir)) {
$output->writeln('<error>Directory not found: ' . $dir . '</error>');
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('<comment>skip %-45s - no product with this slug</comment>', $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<info>%s</info> -> %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(' <comment>no jpg/png files - skipped</comment>');
continue;
}
try {
[$b, $p] = $this->attachProduct($product, $files, $apply, $output);
$banners += $b;
$photos += $p;
} catch (\Throwable $e) {
$errors++;
$output->writeln(' <error>' . $e->getMessage() . '</error>');
}
}
$output->writeln('');
foreach ($notFound as $name) {
$output->writeln('<comment>NOT FOUND (no product record): ' . $name . '</comment>');
}
$output->writeln(sprintf(
'<info>%d banner action(s), %d photo action(s), %d folder(s) without record, %d error(s)%s</info>',
$banners, $photos, count($notFound), $errors,
$apply ? '' : ' (dry run - nothing written)'
));
if ($apply && ($banners + $photos) > 0) {
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
}
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<string,mixed> $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>' . $error . '</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<string,mixed> */
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<int,array{uid:int,uid_local:int}> 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<string,mixed>|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))), '-');
}
}