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:
1 parent
3e2ebf3823
commit
115952a132
2 files changed
+266
-77
No files matched your search
@@ -134,3 +134,4 @@ migrations/products_xlsx/*
|
||||
migrations/submarkets_xlsx_in/*
|
||||
migrations/subsolutions_xlsx_in/*
|
||||
migrations/solutiongroups_xlsx_in/*
|
||||
migrations/product_images_in/
|
||||
@@ -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);
|
||||
|
||||
$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]
|
||||
$this->products = $connection->fetchAllAssociative(
|
||||
'SELECT uid, pid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
if ($product === false) {
|
||||
$output->writeln(sprintf('<comment>skip %-45s - no product with this slug</comment>', $slug));
|
||||
|
||||
$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;
|
||||
}
|
||||
$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++;
|
||||
}
|
||||
$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)));
|
||||
} 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;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n%d product(s) to fill, %d already had images.", $planned, $skippedExisting));
|
||||
if ($planned === 0) {
|
||||
return Command::SUCCESS;
|
||||
if (in_array($file->getUid(), $photoFileUids, true)) {
|
||||
$output->writeln(' productimage: ' . $fileName . ' already attached - unchanged');
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nothing written.');
|
||||
return Command::SUCCESS;
|
||||
$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 !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$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 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))), '-');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user