diff --git a/migrations/apply_seo_meta.php b/migrations/apply_seo_meta.php new file mode 100644 index 0000000..9486a78 --- /dev/null +++ b/migrations/apply_seo_meta.php @@ -0,0 +1,75 @@ + 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.'); diff --git a/migrations/fix_image_reference_fieldname.php b/migrations/fix_image_reference_fieldname.php new file mode 100644 index 0000000..6b75d6f --- /dev/null +++ b/migrations/fix_image_reference_fieldname.php @@ -0,0 +1,59 @@ + 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"; diff --git a/migrations/seo_meta_vitec.json b/migrations/seo_meta_vitec.json new file mode 100644 index 0000000..60015ea --- /dev/null +++ b/migrations/seo_meta_vitec.json @@ -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" + } +} diff --git a/packages/vitec/Classes/Command/ImportProductImagesCommand.php b/packages/vitec/Classes/Command/ImportProductImagesCommand.php new file mode 100644 index 0000000..fbdd25c --- /dev/null +++ b/packages/vitec/Classes/Command/ImportProductImagesCommand.php @@ -0,0 +1,142 @@ +/ - 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//) 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('Staging folder ' . self::STAGE . ' not found in the default storage.'); + 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('skip %-45s - no product with this slug', $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 . ''); + } + return Command::FAILURE; + } + $output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Command/LinkProductDownloadsCommand.php b/packages/vitec/Classes/Command/LinkProductDownloadsCommand.php new file mode 100644 index 0000000..163a9a9 --- /dev/null +++ b/packages/vitec/Classes/Command/LinkProductDownloadsCommand.php @@ -0,0 +1,274 @@ + "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('kein Treffer: ' . $title . ''); + } + foreach ($ambiguous as $title) { + $output->writeln('MEHRDEUTIG: ' . $title . ''); + } + + $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 . ''); + } + 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 $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))); + } +}