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

+1
View File
@@ -134,3 +134,4 @@ migrations/products_xlsx/*
migrations/submarkets_xlsx_in/* migrations/submarkets_xlsx_in/*
migrations/subsolutions_xlsx_in/* migrations/subsolutions_xlsx_in/*
migrations/solutiongroups_xlsx_in/* migrations/solutiongroups_xlsx_in/*
migrations/product_images_in/
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Command; namespace Evomedien\Vitec\Command;
use Doctrine\DBAL\ParameterType;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; 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\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\StorageRepository; use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Attach the product photos harvested from vitec.com / datapath.co.uk * Attaches the agency product assets (Joe's Dropbox delivery 2026-10-23,
* (2026-09-04) to the product records. * one folder per product) to the product records:
* *
* Files live in fileadmin/user_upload/products/<product-slug>/ - one folder * - files whose name contains "banner" -> heroimage (the 1920 hero)
* per product, downloaded straight from the live sites. For every folder * - every other jpg/png (carousel/...) -> productimage (appended)
* 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 * Folder -> record matching: normalised title, title without the
* on the next one. * 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
* vendor/bin/typo3 vitec:import-product-images --only=pico --apply
*/ */
#[AsCommand( #[AsCommand(
name: 'vitec:import-product-images', 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 final class ImportProductImagesCommand extends Command
{ {
private const TABLE = 'tx_vitec_domain_model_product'; 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 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 protected function execute(InputInterface $input, OutputInterface $output): int
{ {
Bootstrap::initializeBackendAuthentication(); Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run'); $apply = (bool)$input->getOption('apply');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $dir = rtrim((string)$input->getOption('dir'), '/');
if ($storage === null || !$storage->hasFolder(self::STAGE)) { if (!is_dir($dir)) {
$output->writeln('<error>Staging folder ' . self::STAGE . ' not found in the default storage.</error>'); $output->writeln('<error>Directory not found: ' . $dir . '</error>');
return Command::FAILURE; return Command::FAILURE;
} }
$stageFolder = $storage->getFolder(self::STAGE);
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$this->products = $connection->fetchAllAssociative(
'SELECT uid, pid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0'
);
$datamap = []; $folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: []);
$newIndex = 0; sort($folders);
$planned = 0; $output->writeln(count($folders) . ' product folder(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN'));
$skippedExisting = 0;
foreach ($stageFolder->getSubfolders() as $folder) { $notFound = [];
$slug = $folder->getName(); $banners = 0;
$product = $connection->fetchAssociative( $photos = 0;
'SELECT uid, pid, title FROM ' . self::TABLE . ' WHERE deleted = 0 AND slug = ?', $errors = 0;
[$slug] foreach ($folders as $folder) {
); $name = basename($folder);
if ($product === false) { if ($only !== '' && !str_contains(mb_strtolower($name), $only)) {
$output->writeln(sprintf('<comment>skip %-45s - no product with this slug</comment>', $slug));
continue; continue;
} }
$uid = (int)$product['uid']; $product = $this->resolveProduct($name);
if ($product === null) {
// NB: the TCA field is `productimage` - `images` is only the $notFound[] = $name;
// 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; continue;
} }
$output->writeln(sprintf("\n<info>%s</info> -> %s (uid %d)", $name, $product['slug'], (int)$product['uid']));
$files = $folder->getFiles(); $files = $this->imageFiles($folder);
usort($files, static fn($a, $b): int => strcasecmp($a->getName(), $b->getName()));
if ($files === []) { 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; continue;
} }
$referenceIds = []; $file = $storage->addFile($path, $folderObj, $fileName, DuplicationBehavior::REPLACE);
foreach ($files as $file) {
// getFiles() delivers indexed File objects (sys_file created on if ($isBanner) {
// the fly for new files), so uid is available right away. if ($heroRefs !== []) {
$newId = 'NEW' . ++$newIndex; $first = $heroRefs[0];
$referenceIds[] = $newId; if ((int)$first['uid_local'] === $file->getUid()) {
$datamap['sys_file_reference'][$newId] = [ $output->writeln(' heroimage: ' . $fileName . ' already attached - unchanged');
'table_local' => 'sys_file', } else {
'uid_local' => $file->getUid(), // swap the first hero reference onto the new banner;
'uid_foreign' => $uid, // further hero references stay untouched (editorial)
'tablenames' => self::TABLE, $datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
'fieldname' => 'productimage', $output->writeln(' heroimage: ' . $fileName . ' swapped onto the existing reference');
'pid' => (int)$product['pid'], $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++; if (in_array($file->getUid(), $photoFileUids, true)) {
$output->writeln(sprintf('%s %-45s -> uid %d "%s": %d image(s)', $output->writeln(' productimage: ' . $fileName . ' already attached - unchanged');
$dryRun ? 'plan ' : 'WRITE', $slug, $uid, $product['title'], count($referenceIds))); 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 ($newPhotoIds !== []) {
if ($planned === 0) { // inline fields take the FULL reference list - existing uids first,
return Command::SUCCESS; // the new NEW ids appended, so nothing editorial gets detached
} $datamap[self::TABLE][$uid]['productimage'] = implode(',', array_merge($photoRefIds, $newPhotoIds));
if ($dryRun) {
$output->writeln('DRY RUN - nothing written.');
return Command::SUCCESS;
} }
$dataHandler = GeneralUtility::makeInstance(DataHandler::class); if ($apply && $datamap !== []) {
$dataHandler->start($datamap, []); $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->process_datamap(); $dataHandler->start($datamap, []);
if ($dataHandler->errorLog !== []) { $dataHandler->process_datamap();
foreach ($dataHandler->errorLog as $error) { if ($dataHandler->errorLog !== []) {
$output->writeln('<error>' . $error . '</error>'); throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
} }
return Command::FAILURE;
} }
$output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); return [$bannerActions, $photoActions];
return Command::SUCCESS; }
/** @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))), '-');
} }
} }