"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))); } }