- vitec:import-product-images: attach staged photos (fileadmin/user_upload/ products/<slug>/, 116 files harvested from vitec.com and datapath.co.uk carousels) to image-less products via FAL/DataHandler; fill-up only. fix_image_reference_fieldname.php repairs the first run's references (TCA field is productimage, not the JSON key images) - vitec:link-product-downloads: match the download records imported on 2026-08-06 to products by title (doc-type/brand/role suffix stripping, alias table for live-site naming deviations, series rules for g45xx/e38xx/ chassis docs); 55 unique-match links added, add-only, idempotent - SEO meta: seo_meta_vitec.json + apply_seo_meta.php fill empty seotitle/ seometa from the live vitec.com product pages (11 products, fill-up only)"
143 lines
5.7 KiB
PHP
143 lines
5.7 KiB
PHP
<?php
|
|
|
|
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;
|
|
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\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.
|
|
*
|
|
* 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).
|
|
*
|
|
* Idempotent: products that got their images on a previous run are skipped
|
|
* on the next one.
|
|
*
|
|
* vendor/bin/typo3 vitec:import-product-images --dry-run
|
|
* vendor/bin/typo3 vitec:import-product-images
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:import-product-images',
|
|
description: 'Attach staged product photos (fileadmin/user_upload/products/<slug>/) to image-less products'
|
|
)]
|
|
final class ImportProductImagesCommand extends Command
|
|
{
|
|
private const TABLE = 'tx_vitec_domain_model_product';
|
|
private const STAGE = 'user_upload/products';
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
|
}
|
|
|
|
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>');
|
|
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]
|
|
);
|
|
if ($product === false) {
|
|
$output->writeln(sprintf('<comment>skip %-45s - no product with this slug</comment>', $slug));
|
|
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++;
|
|
continue;
|
|
}
|
|
|
|
$files = $folder->getFiles();
|
|
usort($files, static fn($a, $b): int => strcasecmp($a->getName(), $b->getName()));
|
|
if ($files === []) {
|
|
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'],
|
|
];
|
|
}
|
|
$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)));
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
|
$dataHandler->start($datamap, []);
|
|
$dataHandler->process_datamap();
|
|
if ($dataHandler->errorLog !== []) {
|
|
foreach ($dataHandler->errorLog as $error) {
|
|
$output->writeln('<error>' . $error . '</error>');
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
$output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush');
|
|
return Command::SUCCESS;
|
|
}
|
|
}
|