Import product photos from live sites; link downloads and SEO meta to products
- 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)"
This commit is contained in:
75
migrations/apply_seo_meta.php
Normal file
75
migrations/apply_seo_meta.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill empty seotitle/seometa product fields from a scraped mapping file
|
||||||
|
* (migrations/seo_meta_vitec.json - source: the live vitec.com product
|
||||||
|
* pages, 2026-09-04). Fill-up ONLY: fields that already hold a value are
|
||||||
|
* never overwritten.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/apply_seo_meta.php
|
||||||
|
* Write: php migrations/apply_seo_meta.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) {
|
||||||
|
$root = $dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) {
|
||||||
|
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = json_decode((string)file_get_contents(__DIR__ . '/seo_meta_vitec.json'), true);
|
||||||
|
if (!is_array($map)) {
|
||||||
|
exit("seo_meta_vitec.json missing or invalid.\n");
|
||||||
|
}
|
||||||
|
unset($map['_quelle']);
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '',
|
||||||
|
$db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
$select = $pdo->prepare("SELECT uid, title, seotitle, seometa FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug = ?");
|
||||||
|
$update = $pdo->prepare("UPDATE tx_vitec_domain_model_product SET seotitle = ?, seometa = ?, tstamp = ? WHERE uid = ?");
|
||||||
|
|
||||||
|
$written = 0;
|
||||||
|
foreach ($map as $slug => $meta) {
|
||||||
|
$select->execute([$slug]);
|
||||||
|
$product = $select->fetch();
|
||||||
|
if (!$product) {
|
||||||
|
echo "FEHLT $slug - kein Produkt\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newTitle = trim((string)$product['seotitle']) === '' ? trim((string)($meta['seotitle'] ?? '')) : (string)$product['seotitle'];
|
||||||
|
$newMeta = trim((string)$product['seometa']) === '' ? trim((string)($meta['seometa'] ?? '')) : (string)$product['seometa'];
|
||||||
|
$changes = [];
|
||||||
|
if ($newTitle !== (string)$product['seotitle']) {
|
||||||
|
$changes[] = 'seotitle';
|
||||||
|
}
|
||||||
|
if ($newMeta !== (string)$product['seometa']) {
|
||||||
|
$changes[] = 'seometa';
|
||||||
|
}
|
||||||
|
if ($changes === []) {
|
||||||
|
echo "ok {$product['title']} - beide Felder bereits gefuellt oder keine Daten\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printf("%s %s (uid %d): %s\n", $apply ? 'WRITE ' : 'plan ', $product['title'], $product['uid'], implode(' + ', $changes));
|
||||||
|
if ($apply) {
|
||||||
|
$update->execute([$newTitle, $newMeta, time(), (int)$product['uid']]);
|
||||||
|
}
|
||||||
|
$written++;
|
||||||
|
}
|
||||||
|
printf("\n%d Produkt(e) %s.%s\n", $written, $apply ? 'geschrieben' : 'geplant', $apply ? ' Cache leeren: vendor/bin/typo3 cache:flush' : ' Mit --apply schreiben.');
|
||||||
59
migrations/fix_image_reference_fieldname.php
Normal file
59
migrations/fix_image_reference_fieldname.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot repair (2026-09-04): the first vitec:import-product-images run
|
||||||
|
* wrote its sys_file_reference rows with fieldname 'images' - the JSON key -
|
||||||
|
* but the TCA field (and everything that queries it) is 'productimage'.
|
||||||
|
* Re-files those references and refreshes the products' inline counter.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/fix_image_reference_fieldname.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) {
|
||||||
|
$root = $dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) {
|
||||||
|
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '',
|
||||||
|
$db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
|
||||||
|
$wrong = (int)$pdo->query(
|
||||||
|
"SELECT COUNT(*) FROM sys_file_reference
|
||||||
|
WHERE deleted = 0 AND tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'images'"
|
||||||
|
)->fetchColumn();
|
||||||
|
echo "Misfiled references (fieldname 'images'): $wrong\n";
|
||||||
|
|
||||||
|
if ($wrong > 0) {
|
||||||
|
$pdo->exec(
|
||||||
|
"UPDATE sys_file_reference SET fieldname = 'productimage'
|
||||||
|
WHERE deleted = 0 AND tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'images'"
|
||||||
|
);
|
||||||
|
echo "Re-filed to 'productimage'.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $pdo->exec(
|
||||||
|
"UPDATE tx_vitec_domain_model_product p
|
||||||
|
SET p.productimage = (
|
||||||
|
SELECT COUNT(*) FROM sys_file_reference r
|
||||||
|
WHERE r.deleted = 0 AND r.uid_foreign = p.uid
|
||||||
|
AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'productimage'
|
||||||
|
)
|
||||||
|
WHERE p.deleted = 0"
|
||||||
|
);
|
||||||
|
echo "Inline counters refreshed on $updated product(s). Flush the cache: vendor/bin/typo3 cache:flush\n";
|
||||||
51
migrations/seo_meta_vitec.json
Normal file
51
migrations/seo_meta_vitec.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"_quelle": "vitec.com Produktseiten, Scrape 2026-09-04. Nur Produkt-Detailseiten; Kategorieseiten-Metas (ChannelLink, 45-series, PRISM, 38-Series) bewusst ausgelassen (zu generisch). 'VITEC - '-Praefix entfernt.",
|
||||||
|
"mgw-diamond-sdi-encoder": {
|
||||||
|
"seotitle": "MGW Diamond - 4K and Multi-Channel SD/HD HEVC Encoder",
|
||||||
|
"seometa": "MGW Diamond is a small, power-efficient quad channel HD or one channel 4K HEVC video encoder ideal for field-based applications. It features a powerful encoding engine with the ability to output up to eight streams simultaneously."
|
||||||
|
},
|
||||||
|
"mgw-ace-encoder": {
|
||||||
|
"seotitle": "MGW Ace Encoder Compact HEVC (H.265) Hardware Encoder",
|
||||||
|
"seometa": "MGW Ace Encoder is the world's first HEVC / H.265 hardware encoder in a professional grade portable streaming appliance. Powered by VITEC HEVC GEN2+ encoder, it delivers industry's best video quality with up to 50% bandwidth savings compared to H.264 and Ultra Low Latency streaming down to 16ms glass-to-glass."
|
||||||
|
},
|
||||||
|
"mgw-ace-decoder": {
|
||||||
|
"seotitle": "MGW Ace Decoder - Professional Portable HEVC & H.264 Decoder",
|
||||||
|
"seometa": "MGW Ace Decoder is a professional grade, high performance IP decoder supporting the bandwidth efficient HEVC/H.265 and H.264/AVC compression standards, with 4:2:2 10-bit decoding from IP or DVB-ASI, genlock support and 4K-ready 12G-SDI and HDMI outputs."
|
||||||
|
},
|
||||||
|
"mgw-pico-portable-encoder": {
|
||||||
|
"seotitle": "MGW Pico Encoder - Ultra-Compact, Low Latency H.264 Encoding & Streaming Appliance",
|
||||||
|
"seometa": "The MGW Pico Encoder is the world's smallest H.264 HD/SD portable encoding appliance. With 3G/HD/SD-SDI and Composite inputs, low power consumption and robust industrial design, the appliance is ideal for any video field-based streaming application."
|
||||||
|
},
|
||||||
|
"mgw-diamond-og-sdi-composite-blade-encoder": {
|
||||||
|
"seotitle": "MGW Diamond OG - 4K and Multi-Channel SD/HD HEVC VITEC OG Encoder Card",
|
||||||
|
"seometa": "MGW Diamond OG is a small, power-efficient quad channel HD or one channel 4K HEVC video encoder card for the openGear ecosystem, with a powerful encoding engine able to output up to eight streams simultaneously."
|
||||||
|
},
|
||||||
|
"mgw-diamond-og-sdi-blade-encoder": {
|
||||||
|
"seotitle": "MGW Diamond+ OG Encoder - Multi-codec, Broadcast-grade 4K/Multichannel HD VITEC OG Encoder Card",
|
||||||
|
"seometa": "MGW Diamond+ OG is a broadcast grade HEVC, H.264 and MPEG-2 IP encoder that is ideal for contribution or point-to-point streaming applications and compatible with the openGear ecosystem for seamless integration."
|
||||||
|
},
|
||||||
|
"mgw-diamond-hx-og": {
|
||||||
|
"seotitle": "MGW Diamond-Hx OG Encoder 4K & Multi-Channel SD/HD HDMI VITEC OG Encoder Card",
|
||||||
|
"seometa": "openGear 4K HEVC video encoder card that is ideal for IPTV distribution or Direct-to-Web applications, featuring a powerful encoding engine with the ability to deliver up to four streams simultaneously."
|
||||||
|
},
|
||||||
|
"mgw-ace-decoder-og-ultra-low-latency-blade-encoder": {
|
||||||
|
"seotitle": "MGW Ace Decoder OG - Professional HEVC & H.264 openGear Decoder Card",
|
||||||
|
"seometa": "MGW Ace Decoder OG is a professional grade, high performance IP decoder card supporting HEVC/H.265 and H.264/AVC, with 4:2:2 10-bit decoding from IP or DVB-ASI, genlock support and 4K-ready outputs for the openGear ecosystem."
|
||||||
|
},
|
||||||
|
"diamond-ip-og-ip-to-ip-encoder": {
|
||||||
|
"seotitle": "Diamond-IP OG Encoder - 4K HEVC encoder with SMPTE ST2110 capture",
|
||||||
|
"seometa": ""
|
||||||
|
},
|
||||||
|
"mges7000-high-density-4kuhdhd-hevc-h264-iptv-encoding-blade": {
|
||||||
|
"seotitle": "MGES 7000 - Market Leading High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade",
|
||||||
|
"seometa": "Featuring the highest density available on the market, VITEC's 4K/UHD/HD HEVC & H.264 IPTV 8-input blade offers real-time hardware encoding with secondary channel, integrated resolution and frame-rate scaling, AES 256/128-bit encryption and low latency mode."
|
||||||
|
},
|
||||||
|
"mges6000-broadcast-quality-hdsd-h264-quad-input-iptv-encoder": {
|
||||||
|
"seotitle": "MGES 6000 - Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder",
|
||||||
|
"seometa": "The MGES-6000 Blade provides real-time hardware encoding of HD and SD video for all IPTV applications, with best-in-class picture quality, a secondary low-res channel, streaming to up to seven IP destinations per port and optional AES-256/128-bit encryption."
|
||||||
|
},
|
||||||
|
"x-player-end-points-xp23-xp25-xp26": {
|
||||||
|
"seotitle": "VITEC X-Player End-Points",
|
||||||
|
"seometa": "High-performance IP video & digital signage playback devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/vitec/Classes/Command/ImportProductImagesCommand.php
Normal file
142
packages/vitec/Classes/Command/ImportProductImagesCommand.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
packages/vitec/Classes/Command/LinkProductDownloadsCommand.php
Normal file
274
packages/vitec/Classes/Command/LinkProductDownloadsCommand.php
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?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\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link the imported download records (vitec:import-downloads, 2026-08-06) to
|
||||||
|
* their products via `downloads` (tx_vitec_product_download_mm) by matching
|
||||||
|
* the download title against the product titles.
|
||||||
|
*
|
||||||
|
* Deliberately conservative: a download is linked ONLY when the match is
|
||||||
|
* unique. Matching per download title, in this order:
|
||||||
|
*
|
||||||
|
* 1. title minus a trailing document-type phrase ("... Datasheet",
|
||||||
|
* "... Brochure", "... User Guide", ...) == product title
|
||||||
|
* 2. additionally with a leading brand word (VITEC / Datapath) removed
|
||||||
|
* 3. additionally with a trailing role word (Transmitter / Receiver /
|
||||||
|
* Encoder / Decoder / Card) removed - "Aligo TX100 Transmitter
|
||||||
|
* Datasheet" -> "TX100"
|
||||||
|
*
|
||||||
|
* All comparisons on a normalized form (lowercase, alphanumerics only).
|
||||||
|
* Existing links are never touched, nothing is removed; a download that
|
||||||
|
* matches nothing (or more than one product) is reported for manual review.
|
||||||
|
* Idempotent: already-linked pairs are skipped.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:link-product-downloads --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:link-product-downloads
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:link-product-downloads',
|
||||||
|
description: 'Link download records to products by title match (unique matches only, add-only)'
|
||||||
|
)]
|
||||||
|
final class LinkProductDownloadsCommand extends Command
|
||||||
|
{
|
||||||
|
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
|
||||||
|
private const MM_TABLE = 'tx_vitec_product_download_mm';
|
||||||
|
|
||||||
|
/** Trailing document-type phrases, longest first. */
|
||||||
|
private const DOC_SUFFIXES = [
|
||||||
|
'quick start guide', 'quick reference guide', 'application note',
|
||||||
|
'success story', 'user guide', 'white paper', 'case study',
|
||||||
|
'whats new', "what's new", 'datasheet', 'data sheet', 'brochure',
|
||||||
|
'firmware', 'software', 'manual', 'flyer', 'guide',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Trailing role words a download title may carry beyond the product name. */
|
||||||
|
private const ROLE_SUFFIXES = [
|
||||||
|
'capture card', 'graphics card', 'encoder card', 'decoder card',
|
||||||
|
'transmitter', 'receiver', 'transcoder', 'end-point', 'end point',
|
||||||
|
'encoder', 'decoder', 'blade', 'card',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known live-site names that differ from OUR (authoritative) dev names -
|
||||||
|
* normalized document name => dev product title. Dev names never change
|
||||||
|
* (decision 2026-09-04), so the bridge lives here.
|
||||||
|
*/
|
||||||
|
private const ALIASES = [
|
||||||
|
'visionscsdi4' => 'VisionSC-SD14',
|
||||||
|
'visioniosdi4' => 'VisionIO-SD14',
|
||||||
|
'visionsdi2' => 'VisionSD12',
|
||||||
|
'prismsff' => 'SFF PRISM',
|
||||||
|
'sffprism' => 'SFF PRISM',
|
||||||
|
'avediaep6' => 'Avedia End-Points',
|
||||||
|
'eztvep6' => 'EZ TV End-Points',
|
||||||
|
'xp2526' => 'X-Player End-Points',
|
||||||
|
'xp2650' => 'X-Player End-Points',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series documents: regex on the lowercased document name (doc-type
|
||||||
|
* suffix already stripped) => dev product title. May match SEVERAL
|
||||||
|
* records of that title (the Avedia Modular Chassis exists twice by
|
||||||
|
* design - encoder branch and RF branch - and gets its datasheets on
|
||||||
|
* both).
|
||||||
|
*/
|
||||||
|
private const SERIES_RULES = [
|
||||||
|
'/^rf gateway g45\d+/' => '45-series RF gateways',
|
||||||
|
'/^encoder e38\d+/' => '38-Series Encoders',
|
||||||
|
'/^chassis c1(101|103|210)$/' => 'Avedia Modular Chassis',
|
||||||
|
];
|
||||||
|
|
||||||
|
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');
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::PRODUCT_TABLE);
|
||||||
|
|
||||||
|
$products = $connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0'
|
||||||
|
);
|
||||||
|
$productByNorm = [];
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$productByNorm[$this->normalize((string)$product['title'])][] = (int)$product['uid'];
|
||||||
|
}
|
||||||
|
$titleByUid = array_column($products, 'title', 'uid');
|
||||||
|
|
||||||
|
$downloads = $connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
|
||||||
|
);
|
||||||
|
|
||||||
|
$existing = [];
|
||||||
|
foreach ($connection->fetchAllAssociative('SELECT uid_local, uid_foreign FROM ' . self::MM_TABLE) as $row) {
|
||||||
|
$existing[(int)$row['uid_local']][] = (int)$row['uid_foreign'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$additions = []; // product uid => download uids to add
|
||||||
|
$unmatched = [];
|
||||||
|
$ambiguous = [];
|
||||||
|
|
||||||
|
foreach ($downloads as $download) {
|
||||||
|
$downloadUid = (int)$download['uid'];
|
||||||
|
$name = $this->stripDocSuffix((string)$download['title']);
|
||||||
|
if ($name === null) {
|
||||||
|
// No document-type suffix - not a per-product document
|
||||||
|
// (company brochures etc.); leave those to manual linking.
|
||||||
|
$unmatched[] = $download['title'] . ' (kein Dokumenttyp-Suffix)';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aliases and series rules may map to several records on purpose;
|
||||||
|
// generic title matches must stay unique.
|
||||||
|
$multiAllowed = false;
|
||||||
|
$candidates = [];
|
||||||
|
$aliasTitle = self::ALIASES[$this->normalize($name)] ?? null;
|
||||||
|
if ($aliasTitle === null) {
|
||||||
|
foreach (self::SERIES_RULES as $pattern => $title) {
|
||||||
|
if (preg_match($pattern, mb_strtolower($name))) {
|
||||||
|
$aliasTitle = $title;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($aliasTitle !== null) {
|
||||||
|
$candidates = $productByNorm[$this->normalize($aliasTitle)] ?? [];
|
||||||
|
$multiAllowed = true;
|
||||||
|
}
|
||||||
|
if ($candidates === []) {
|
||||||
|
$candidates = $this->matchProduct($name, $productByNorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidates === []) {
|
||||||
|
$unmatched[] = $download['title'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count($candidates) > 1 && !$multiAllowed) {
|
||||||
|
$ambiguous[] = $download['title'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($candidates as $productUid) {
|
||||||
|
if (in_array($downloadUid, $existing[$productUid] ?? [], true)) {
|
||||||
|
continue; // already linked
|
||||||
|
}
|
||||||
|
$additions[$productUid][] = $downloadUid;
|
||||||
|
$output->writeln(sprintf('%s "%s" (dl %d) -> %d %s',
|
||||||
|
$dryRun ? 'plan ' : 'link ', $download['title'], $downloadUid, $productUid, $titleByUid[$productUid]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($unmatched as $title) {
|
||||||
|
$output->writeln('<comment>kein Treffer: ' . $title . '</comment>');
|
||||||
|
}
|
||||||
|
foreach ($ambiguous as $title) {
|
||||||
|
$output->writeln('<comment>MEHRDEUTIG: ' . $title . '</comment>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pairCount = array_sum(array_map('count', $additions));
|
||||||
|
$output->writeln(sprintf("\n%d neue Verknuepfung(en) auf %d Produkt(e); %d ohne Treffer, %d mehrdeutig.",
|
||||||
|
$pairCount, count($additions), count($unmatched), count($ambiguous)));
|
||||||
|
|
||||||
|
if ($additions === []) {
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln('DRY RUN - nichts geschrieben.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datamap = [];
|
||||||
|
foreach ($additions as $productUid => $downloadUids) {
|
||||||
|
$merged = array_merge($existing[$productUid] ?? [], $downloadUids);
|
||||||
|
$datamap[self::PRODUCT_TABLE][$productUid]['downloads'] = implode(',', array_unique($merged));
|
||||||
|
}
|
||||||
|
$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('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title without its trailing document-type phrase; null when none found. */
|
||||||
|
private function stripDocSuffix(string $title): ?string
|
||||||
|
{
|
||||||
|
$norm = mb_strtolower(trim($title));
|
||||||
|
foreach (self::DOC_SUFFIXES as $suffix) {
|
||||||
|
if (str_ends_with($norm, $suffix)) {
|
||||||
|
return trim(mb_substr(trim($title), 0, mb_strlen(trim($title)) - mb_strlen($suffix)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique product uids matching the document name, applying the passes
|
||||||
|
* described in the class comment.
|
||||||
|
*
|
||||||
|
* @param array<string,int[]> $productByNorm
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function matchProduct(string $name, array $productByNorm): array
|
||||||
|
{
|
||||||
|
$variants = [$name];
|
||||||
|
// leading brand word
|
||||||
|
$stripped = preg_replace('/^(vitec|datapath)\s+/i', '', $name);
|
||||||
|
if ($stripped !== $name) {
|
||||||
|
$variants[] = $stripped;
|
||||||
|
}
|
||||||
|
// trailing role word (on both variants)
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
foreach (self::ROLE_SUFFIXES as $role) {
|
||||||
|
if (preg_match('/\s+' . preg_quote($role, '/') . '$/i', $variant)) {
|
||||||
|
$variants[] = trim(preg_replace('/\s+' . preg_quote($role, '/') . '$/i', '', $variant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$norm = $this->normalize($variant);
|
||||||
|
// aliases also apply to the stripped variants ("VisionSC SDI4
|
||||||
|
// Capture Card" -> "VisionSC SDI4" -> alias -> VisionSC-SD14)
|
||||||
|
$aliasTitle = self::ALIASES[$norm] ?? null;
|
||||||
|
if ($aliasTitle !== null) {
|
||||||
|
$norm = $this->normalize($aliasTitle);
|
||||||
|
}
|
||||||
|
$hit = $productByNorm[$norm] ?? [];
|
||||||
|
if ($hit !== []) {
|
||||||
|
return $hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lowercase, alphanumerics only - "TX1/F" and "tx1 f" compare equal.
|
||||||
|
* "+" survives as "plus", otherwise "MGW Diamond+ OG" and
|
||||||
|
* "MGW Diamond OG" would collide.
|
||||||
|
*/
|
||||||
|
private function normalize(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user