- 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>
331 lines
14 KiB
PHP
331 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Command;
|
|
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
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;
|
|
|
|
/**
|
|
* Attaches the agency product assets (Joe's Dropbox delivery 2026-10-23,
|
|
* one folder per product) to the product records:
|
|
*
|
|
* - files whose name contains "banner" -> heroimage (the 1920 hero)
|
|
* - every other jpg/png (carousel/...) -> productimage (appended)
|
|
*
|
|
* 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
|
|
* vendor/bin/typo3 vitec:import-product-images --only=pico --apply
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:import-product-images',
|
|
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';
|
|
|
|
/**
|
|
* 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('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();
|
|
$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;
|
|
}
|
|
|
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
|
$this->products = $connection->fetchAllAssociative(
|
|
'SELECT uid, pid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0'
|
|
);
|
|
|
|
$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;
|
|
}
|
|
$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 = $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;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
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++;
|
|
}
|
|
|
|
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 !== []) {
|
|
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
|
}
|
|
}
|
|
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))), '-');
|
|
}
|
|
}
|