?" cards (tx_vitec_product_feature, * contract 0036-FE) from the `applications` RTE HTML plus the staged agency * icons: * * - `applications` pairs `

Title

Text

` become * one feature record each (several text paragraphs are joined); the * leading

stays where it is - the JSON heading is derived from it. * - icons come from fileadmin/icons/delivered/products// (the * 2026-11-03 staging); is the product slug unless ICON_DIRS * maps it. Matching: normalised title vs. the filename theme (the part * after the last "-"), stop words removed, Levenshtein <= 3 as typo * tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard"). * - a product that already HAS feature records is never touched - after * the seed the editors own the cards. * * Unmatched icons and unmatched cards are reported so the editors can map * the leftovers by hand (0036-FE asks for exactly that list). * * vendor/bin/typo3 vitec:seed-product-features * vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply */ #[AsCommand( name: 'vitec:seed-product-features', description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)' )] final class SeedProductFeaturesCommand extends Command { private const TABLE = 'tx_vitec_product_feature'; private const ICON_BASE = 'icons/delivered/products/'; /** product slug => staging context where the names differ */ private const ICON_DIRS = [ 'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform', 'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform', 'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product', ]; private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is']; private int $newCounter = 0; protected function configure(): void { $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing 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') ?? '')); $pool = GeneralUtility::makeInstance(ConnectionPool::class); $products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative( 'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)' ); $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)'); $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $seeded = 0; $cards = 0; $iconsMatched = 0; $skippedExisting = 0; $skippedEmpty = 0; foreach ($products as $product) { $uid = (int)$product['uid']; $slug = (string)$product['slug']; if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) { continue; } $existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne( 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?', [$uid] ); if ($existing > 0) { $skippedExisting++; continue; } $pairs = $this->parseApplications((string)$product['applications']); if ($pairs === []) { $skippedEmpty++; continue; } // staged icons of this product, filename theme => identifier $context = self::ICON_DIRS[$slug] ?? $slug; $iconByTheme = []; $folder = self::ICON_BASE . $context; if ($storage->hasFolder($folder)) { foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) { if (mb_strtolower($file->getExtension()) !== 'svg') { continue; } if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) { $iconByTheme[$m[1]] = $file->getIdentifier(); } } } $output->writeln(sprintf("\n%s (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme))); // match icons onto the pairs $usedThemes = []; foreach ($pairs as $i => $pair) { $theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes)); if ($theme !== null) { $pairs[$i]['icon'] = $iconByTheme[$theme]; $usedThemes[$theme] = true; } } foreach ($pairs as $pair) { $output->writeln(sprintf( ' "%s"%s%s', $pair['title'], $pair['text'] !== '' ? '' : ' (no text)', isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' - no icon' )); } foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) { $output->writeln(' UNMATCHED icon: ' . basename($identifier) . ''); } $cards += count($pairs); $iconsMatched += count($usedThemes); $seeded++; if (!$apply) { continue; } $datamap = []; $featureIds = []; foreach ($pairs as $pair) { $featureId = $this->newId(); $record = [ 'pid' => (int)$product['pid'], 'product' => $uid, 'title' => $pair['title'], 'cardtext' => $pair['text'], ]; if (isset($pair['icon'])) { $refId = $this->newId(); $datamap['sys_file_reference'][$refId] = [ 'pid' => (int)$product['pid'], 'table_local' => 'sys_file', 'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(), ]; $record['icon'] = $refId; } $datamap[self::TABLE][$featureId] = $record; $featureIds[] = $featureId; } $datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)]; $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { $output->writeln(' DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } // DataHandler puts NEW records at the TOP - write explicit sorting $sorting = 0; foreach ($featureIds as $featureId) { $realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0); if ($realUid > 0) { $sorting += 256; $pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]); } } } $output->writeln(sprintf( "\n%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s", $seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty, $apply ? '' : ' (dry run - nothing written)' )); if ($apply && $seeded > 0) { $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); } return Command::SUCCESS; } /** * The `

Title

Text…

` pairs of the * applications HTML, in order. Several text paragraphs after one title * are joined; the

heading and empty paragraphs are skipped. * * @return array */ private function parseApplications(string $html): array { if (trim($html) === '') { return []; } $pairs = []; $current = null; if (!preg_match_all('#]*>(.*?)

#is', $html, $matches)) { return []; } foreach ($matches[1] as $inner) { $plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if ($plain === '') { continue; } $strongOnly = preg_match('#^\s*]*>(.*?)\s*$#is', trim($inner), $m) ? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'))) : null; if ($strongOnly !== null && $strongOnly !== '') { if ($current !== null) { $pairs[] = $current; } $current = ['title' => $strongOnly, 'text' => '']; continue; } if ($current !== null) { $current['text'] = trim($current['text'] . ' ' . $plain); } } if ($current !== null) { $pairs[] = $current; } return $pairs; } /** * The icon theme that matches a card title: exact on the normalised * strings (stop words removed on both sides), else the closest theme * within Levenshtein distance 3 (typo tolerance). * * @param array $iconByTheme theme => identifier */ private function matchTheme(string $title, array $iconByTheme): ?string { if ($iconByTheme === []) { return null; } $titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]); $best = null; $bestDistance = PHP_INT_MAX; foreach (array_keys($iconByTheme) as $theme) { $themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]); foreach ($titleNorms as $a) { foreach ($themeNorms as $b) { if ($a === $b) { return $theme; } if (abs(strlen($a) - strlen($b)) <= 3) { $distance = levenshtein($a, $b); if ($distance < $bestDistance) { $bestDistance = $distance; $best = $theme; } } } } } return $bestDistance <= 3 ? $best : null; } /** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */ private function norm(string $value, bool $dropStopwords): string { $words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; if ($dropStopwords) { $words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true)); } return mb_strtolower(implode('', $words)); } private function newId(): string { return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); } }