From f4f18e913afd4a7e9a7b83cd18ed1915352bb060 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 10 Nov 2026 19:37:15 +0100 Subject: [PATCH] Produkt-Uebersichtsseiten: Agentur-Texte aus den "Enriched"-Workbooks importiert - neues CLI vitec:import-product-overview-workbooks (Dropbox-Lieferung 09.11., 15 Workbooks): Hero-Subline/CTA, Intro + Use Case, Why-VITEC-Band (Text- karten bzw. Text auf passende Icon-Karten), Range-Intro, Solution-Karten mit Link auf die Gruppenseite, CTA-Banner, SEO-Meta; Produktkarten bleiben (Record-Teaser), Vergleichstabelle nur mit --comparison - vitec:seed-product-features --attach-icons: gestagte Icons an vorhandene Karten ohne Icon haengen (PRISM 5/5, SFF PRISM 4/5 angehaengt) - Diagnose-Skripte check_overview_pages_20261110 / check_banners_20261110 - .gitignore: migrations/product_overviews_xlsx_in, product_images_pending Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + migrations/check_banners_20261110.php | 21 + migrations/check_overview_pages_20261110.php | 70 ++ .../ImportProductOverviewWorkbooksCommand.php | 681 ++++++++++++++++++ .../Command/SeedProductFeaturesCommand.php | 167 +++-- 5 files changed, 896 insertions(+), 46 deletions(-) create mode 100644 migrations/check_banners_20261110.php create mode 100644 migrations/check_overview_pages_20261110.php create mode 100644 packages/vitec/Classes/Command/ImportProductOverviewWorkbooksCommand.php diff --git a/.gitignore b/.gitignore index 0d7133a..e6e8f81 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ migrations/product_categories_xlsx_in/ # generated internal report (php migrations/build_product_audit.php) public/product-audit/ migrations/datapath_stories_in/ + +migrations/product_overviews_xlsx_in/ +migrations/product_images_pending/ diff --git a/migrations/check_banners_20261110.php b/migrations/check_banners_20261110.php new file mode 100644 index 0000000..c9a27ec --- /dev/null +++ b/migrations/check_banners_20261110.php @@ -0,0 +1,21 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); +foreach ([25, 24, 22] as $uid) { + $p = $pdo->query("SELECT uid, slug FROM tx_vitec_domain_model_product WHERE uid = $uid")->fetch(); + echo "\n=== #{$p['uid']} {$p['slug']}\n"; + foreach ($pdo->query("SELECT r.uid ref, r.uid_local, r.hidden, f.identifier, f.tstamp FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'heroimage' AND r.uid_foreign = $uid ORDER BY r.sorting_foreign") as $r) { + echo " heroimage ref#{$r['ref']} file#{$r['uid_local']} {$r['identifier']} (file tstamp " . date('Y-m-d H:i', (int)$r['tstamp']) . ")" . ($r['hidden'] ? ' HIDDEN' : '') . "\n"; + } + foreach ($pdo->query("SELECT uid, identifier, tstamp FROM sys_file WHERE identifier LIKE '/product_import/{$p['slug']}/%'") as $f) { + echo " sys_file#{$f['uid']} {$f['identifier']} (" . date('Y-m-d H:i', (int)$f['tstamp']) . ")\n"; + } +} +echo "\n=== feature icons prism/sff ===\n"; +foreach ($pdo->query("SELECT f.product, f.title, (SELECT COUNT(*) FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.uid_foreign = f.uid) icons FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product IN (29, 30) ORDER BY f.product, f.sorting") as $r) { + echo " #{$r['product']} \"{$r['title']}\" icons={$r['icons']}\n"; +} +echo "DONE\n"; diff --git a/migrations/check_overview_pages_20261110.php b/migrations/check_overview_pages_20261110.php new file mode 100644 index 0000000..2978c9c --- /dev/null +++ b/migrations/check_overview_pages_20261110.php @@ -0,0 +1,70 @@ + migrations/check_overview_out.txt + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); +$settings = require __DIR__ . '/../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] +); + +$slugs = ['avedia', 'ez-tv', 'x-player-end-points', 'x-view-integrated-displays', 'aetria', 'operator-workstations', + 'appliances', 'modular-systems', 'rugged-appliances', 'avedia-modular-system', 'channellink-ip-gateway', + 'prism-transcoder', 'aligo', 'vsn-series-video-wall-controllers', 'video-wall-management-software', + 'vms-plugins-and-integrations', 'x-series-multi-display-processors', 'image-graphics-cards', + 'vision-video-capture-cards', 'activesqx']; + +$ce = $pdo->prepare('SELECT uid, colPos, CType, header, header_layout, vitec_layout_variant, vitec_primary_cta, vitec_primary_cta_label, tx_container_parent, sorting, hidden, LENGTH(bodytext) blen, LEFT(bodytext, 90) bshort FROM tt_content WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY colPos, sorting'); +$kids = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_container_parent = ? AND CType = 'vitec_card'"); +foreach ($slugs as $slug) { + $p = $pdo->prepare('SELECT uid, title, nav_title, layout, seo_title, description, keywords, abstract FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0'); + $p->execute(['/products/' . $slug]); + $page = $p->fetch(); + echo "\n=== /products/$slug "; + if (!$page) { echo "NOT FOUND\n"; continue; } + printf("#%d \"%s\" nav=\"%s\" layout=%s\n seo_title=%s | desc=%s | kw=%s\n", $page['uid'], $page['title'], $page['nav_title'], $page['layout'], $page['seo_title'], mb_substr((string)$page['description'], 0, 80), $page['keywords']); + $ce->execute([$page['uid']]); + foreach ($ce->fetchAll() as $r) { + $n = ''; + if (str_starts_with($r['CType'], 'vitec_cols')) { $kids->execute([$r['uid']]); $n = ' cards=' . $kids->fetchColumn(); } + if ((int)$r['tx_container_parent'] > 0 && $r['CType'] === 'vitec_card') { continue; } + printf(" col%-3s s%-6d #%-6d %-22s %-12s %-10s \"%s\" len=%d%s %s%s\n", $r['colPos'], $r['sorting'], $r['uid'], $r['CType'], $r['vitec_layout_variant'], $r['vitec_primary_cta_label'], $r['header'], $r['blen'], $n, (int)$r['tx_container_parent'] > 0 ? ' IN#' . $r['tx_container_parent'] : '', $r['hidden'] ? ' HIDDEN' : ''); + if ($r['blen'] > 0 && !str_starts_with($r['CType'], 'vitec_cols')) { echo " > " . str_replace("\n", ' ', (string)$r['bshort']) . "\n"; } + } +} + +echo "\n=== products (slug | title) matching banner/icon deliveries ===\n"; +foreach ($pdo->query("SELECT uid, slug, title, hidden FROM tx_vitec_domain_model_product WHERE deleted = 0 AND (slug LIKE '%mges%' OR slug LIKE '%ace%' OR slug LIKE '%chassis%' OR slug LIKE '%prism%' OR slug LIKE '%diamond%' OR slug LIKE '%avedia%') ORDER BY slug") as $r) { + printf(" #%-4d %-45s %s%s\n", $r['uid'], $r['slug'], $r['title'], $r['hidden'] ? ' (hidden)' : ''); +} + +echo "\n=== pages under /solutions (2 levels) ===\n"; +$root = $pdo->query("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'")->fetchColumn(); +$q = $pdo->prepare('SELECT uid, title, slug, doktype, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting'); +$q->execute([$root]); +foreach ($q->fetchAll() as $g) { + printf(" #%-4d %-55s %s%s\n", $g['uid'], $g['title'], $g['slug'], $g['hidden'] ? ' (hidden)' : ''); + $q2 = $pdo->prepare('SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting'); + $q2->execute([$g['uid']]); + foreach ($q2->fetchAll() as $s) { + printf(" #%-4d %-55s %s%s\n", $s['uid'], $s['title'], $s['slug'], $s['hidden'] ? ' (hidden)' : ''); + } +} + +echo "\n=== products with feature cards (count) for prism/sff/avedia chassis ===\n"; +foreach ($pdo->query("SELECT p.uid, p.slug, (SELECT COUNT(*) FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product = p.uid) n FROM tx_vitec_domain_model_product p WHERE p.deleted = 0 AND (p.slug LIKE '%prism%' OR p.slug LIKE '%chassis%')") as $r) { + printf(" #%-4d %-45s features=%d\n", $r['uid'], $r['slug'], $r['n']); +} +echo "\nDONE\n"; diff --git a/packages/vitec/Classes/Command/ImportProductOverviewWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportProductOverviewWorkbooksCommand.php new file mode 100644 index 0000000..cbe7fe0 --- /dev/null +++ b/packages/vitec/Classes/Command/ImportProductOverviewWorkbooksCommand.php @@ -0,0 +1,681 @@ +, layout 9, built by + * vitec:build-product-overviews on 2026-11-02) with the agency's editorial + * copy from the " - Enriched.xlsx" workbooks (Dropbox delivery + * 2026-11-09, staged in migrations/product_overviews_xlsx_in/). + * + * Workbook shape: sheet "Content" with rows "Section | Content field | + * Website copy / value | Production notes", sections 01 HERO, 02 INTRO, + * 03 WHY VITEC, 04 COMPARISON, 05 PRODUCT RANGE, 06 SOLUTIONS, 07 CTA, + * 08 SEO. The page is found by its title (= the workbook's H1). + * + * The pages already carry generated content (hero, optional "Why VITEC?" + * icon band, "Product range" intro, product card grids, contact banner). + * This importer UPDATES in place and only ADDS what is missing: + * + * 01 HERO -> hero bodytext (subline) + CTA label; title stays + * 02 INTRO -> NEW introparagraph (H2 + introduction) at the top of + * the main column, plus the use-case paragraph + * 03 WHY VITEC -> page with icon band: benefit copy goes onto the card + * whose title matches the benefit heading (theme match + * like vitec:build-why-vitec-bands), unmatched benefits + * are REPORTED only (the band is the agency's icon set); + * page without band: NEW band of text_only cards + * 04 COMPARISON -> skipped unless --comparison: the workbook holds no + * specifications ("populate from approved datasheets"), + * only the card descriptions again + * 05 PRODUCT RANGE -> the "Product range" intro gets the H2 + intro copy; + * product CARDS stay untouched (they show the record + * teasers from the product workbooks - the overview + * workbook texts are mostly boilerplate) + * 06 SOLUTIONS -> NEW intro + one text_only card per solution, linked + * to the solution group page under /solutions + * 07 CTA -> contact banner header/body/button (link stays) + * 08 SEO -> seo/og/twitter title + description, keywords, abstract + * + * Main-column order after apply: intro, use case, Why VITEC band, + * [comparison], range intro, product cards (unchanged), solutions. + * Idempotent: existing elements are found by header; nothing is duplicated. + * + * vendor/bin/typo3 vitec:import-product-overview-workbooks + * vendor/bin/typo3 vitec:import-product-overview-workbooks --only=aetria --apply + */ +#[AsCommand( + name: 'vitec:import-product-overview-workbooks', + description: 'Fill the product overview pages from the agency "Enriched" workbooks (dry-run by default)' +)] +final class ImportProductOverviewWorkbooksCommand extends Command +{ + private const COLPOS_HERO = 1; + private const COLPOS_MAIN = 0; + private const COLPOS_PREFOOTER = 3; + private const WHY_HEADER = 'Why VITEC?'; + private const RANGE_PLACEHOLDER = 'Product range'; + private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with']; + + /** workbook H1 (post-norm) => page slug where the title match fails */ + private const TITLE_TO_SLUG = [ + 'xplayerendpoints' => '/products/x-player-end-points', + ]; + + /** workbook solution names that differ from the group page titles beyond spelling (post-norm) */ + private const SOLUTION_ALIASES = [ + 'videowallandvisualisationsolutions' => 'videowallanddatavisualisation', + ]; + + /** @var array>> */ + private array $datamap = []; + /** @var string[] */ + private array $report = []; + private int $newCounter = 0; + private bool $apply = false; + private bool $withComparison = false; + private int $touched = 0; + + protected function configure(): void + { + $this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_overviews_xlsx_in'); + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', ''); + $this->addOption('comparison', null, InputOption::VALUE_NONE, 'Also create the comparison table (text element)'); + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $this->apply = (bool)$input->getOption('apply'); + $this->withComparison = (bool)$input->getOption('comparison'); + $only = mb_strtolower((string)$input->getOption('only')); + $dir = rtrim((string)$input->getOption('dir'), '/'); + + $files = glob($dir . '/*.xlsx') ?: []; + $files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$') + && ($only === '' || str_contains(mb_strtolower(basename($f)), $only)))); + sort($files); + $output->writeln(($this->apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($files) . ' workbook(s) in ' . $dir + . ($this->withComparison ? ' · with comparison table' : ' · comparison table skipped')); + + $ok = 0; + foreach ($files as $file) { + try { + if ($this->importOne($file, $output)) { + $ok++; + } + } catch (\Throwable $e) { + $output->writeln(' ' . basename($file) . ': ' . $e->getMessage() . ''); + } + } + $output->writeln(sprintf( + "\n%d of %d workbook(s) %s, %d element/page write(s).%s", + $ok, count($files), $this->apply ? 'applied' : 'planned', $this->touched, + $this->apply ? ' Flush the cache, then vitec:ssr-purge --all + vitec:ssr-warm.' : ' DRY RUN - nothing written. Add --apply to write.' + )); + return Command::SUCCESS; + } + + private function importOne(string $file, OutputInterface $output): bool + { + $this->datamap = []; + $this->report = []; + $wb = $this->readWorkbook($file); + $h1 = $wb['hero']['h1'] ?? ''; + if ($h1 === '') { + $output->writeln(' ' . basename($file) . ': no "01 · HERO / H1" row - skipped'); + return false; + } + $page = $this->pageByTitle($h1); + if ($page === null) { + $output->writeln(' ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped' + . ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . ''); + return false; + } + $pageUid = (int)$page['uid']; + $output->writeln(sprintf("\n%s -> %s (#%d)", basename($file), $page['slug'], $pageUid)); + + $pool = GeneralUtility::makeInstance(ConnectionPool::class); + $conn = $pool->getConnectionForTable('tt_content'); + $top = $conn->fetchAllAssociative( + 'SELECT uid, colPos, CType, header, bodytext, vitec_layout_variant, vitec_primary_cta_label, vitec_cta_label FROM tt_content + WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) + AND (tx_container_parent IS NULL OR tx_container_parent = 0) + ORDER BY colPos, sorting, uid', + [$pageUid] + ); + $main = array_values(array_filter($top, static fn(array $r): bool => (int)$r['colPos'] === self::COLPOS_MAIN)); + $byUid = []; + foreach ($main as $row) { + $byUid[(string)$row['uid']] = $row; + } + + // --- 01 hero ------------------------------------------------------------------ + $hero = $this->first($top, self::COLPOS_HERO, 'vitec_herosection'); + if ($hero === null) { + $this->report[] = 'hero: NO vitec_herosection in colPos 1 - subline/CTA not placed'; + } else { + $this->update((string)$hero['uid'], $hero, [ + 'bodytext' => $this->p($wb['hero']['subline'] ?? ''), + 'vitec_cta_label' => $wb['hero']['cta'] ?? '', + ], 'hero #' . $hero['uid']); + } + + // --- 02 intro + use case ---------------------------------------------------------- + $introH2 = ($wb['intro']['h2'] ?? '') !== '' ? $wb['intro']['h2'] : $h1; + $introId = $this->ensureIntro($main, $byUid, $pageUid, $introH2, $wb['intro']['body'] ?? '', 'intro'); + $useId = null; + if (($wb['intro']['usecase_body'] ?? '') !== '') { + $useHeading = ($wb['intro']['usecase_heading'] ?? '') !== '' ? $wb['intro']['usecase_heading'] : 'Applications and workflows'; + $useId = $this->ensureIntro($main, $byUid, $pageUid, $useHeading, $wb['intro']['usecase_body'], 'use case'); + } + + // --- 03 why vitec ----------------------------------------------------------------- + $whyIds = []; + $whyIndex = $this->indexOf($main, 'vitec_introparagraph', self::WHY_HEADER); + if ($whyIndex !== null) { + $whyIds[] = (string)$main[$whyIndex]['uid']; + $cards = []; + for ($i = $whyIndex + 1; $i < count($main) && $main[$i]['CType'] === 'vitec_card'; $i++) { + $cards[] = $main[$i]; + $whyIds[] = (string)$main[$i]['uid']; + } + $used = []; + foreach ($wb['why']['benefits'] as $benefit) { + $match = null; + foreach ($cards as $card) { + if (isset($used[(string)$card['uid']])) { + continue; + } + if ($this->norm((string)$card['header']) === $this->norm($benefit['heading']) || $this->sameTheme($benefit['heading'], (string)$card['header'])) { + $match = $card; + break; + } + } + if ($match === null) { + $this->report[] = 'why: benefit "' . $benefit['heading'] . '" has no matching icon card - NOT placed (band = agency icon set; editorial decision)'; + continue; + } + $used[(string)$match['uid']] = true; + if (trim(strip_tags((string)$match['bodytext'])) !== '' && $this->norm(strip_tags((string)$match['bodytext'])) !== $this->norm($benefit['body'])) { + $this->report[] = 'why: card "' . $match['header'] . '" already has own text - left as is'; + continue; + } + $this->update((string)$match['uid'], $match, ['bodytext' => $this->p($benefit['body'])], 'why card "' . $match['header'] . '" <- "' . $benefit['heading'] . '"'); + } + } elseif ($wb['why']['benefits'] !== []) { + $id = $this->create($pageUid, 'vitec_introparagraph', ['header' => self::WHY_HEADER, 'bodytext' => ''], 'why: NEW band intro'); + $whyIds[] = $id; + foreach ($wb['why']['benefits'] as $benefit) { + $whyIds[] = $this->create($pageUid, 'vitec_card', [ + 'vitec_layout_variant' => 'text_only', + 'header' => $benefit['heading'], + 'bodytext' => $this->p($benefit['body']), + 'vitec_primary_cta_label' => '', + ], 'why: NEW card "' . $benefit['heading'] . '"'); + } + } + + // --- 04 comparison (opt-in) ------------------------------------------------------------ + $comparisonId = null; + if ($this->withComparison && ($wb['comparison']['rows'] ?? []) !== []) { + $h2 = $this->fixCase($wb['comparison']['h2'] ?? 'Compare ' . $h1, $h1); + $body = $this->p($wb['comparison']['intro'] ?? '') + . ''; + foreach ($wb['comparison']['rows'] as $row) { + $body .= ''; + } + $body .= '
ProductDescription
' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '' + . htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '
'; + $idx = $this->indexOf($main, 'text', $h2); + if ($idx !== null) { + $comparisonId = (string)$main[$idx]['uid']; + $this->update($comparisonId, $main[$idx], ['bodytext' => $body], 'comparison #' . $comparisonId); + } else { + $comparisonId = $this->create($pageUid, 'text', ['header' => $h2, 'bodytext' => $body], 'comparison: NEW text element (' . count($wb['comparison']['rows']) . ' rows)'); + } + } elseif (($wb['comparison']['rows'] ?? []) !== []) { + $this->report[] = 'comparison: ' . count($wb['comparison']['rows']) . ' row(s) skipped (no specifications in the workbook; --comparison to create)'; + } + + // --- 05 product range intro ---------------------------------------------------------- + $rangeH2 = $this->fixCase(($wb['range']['h2'] ?? '') !== '' ? $wb['range']['h2'] : ('Explore the ' . $h1 . ' range'), $h1); + $rangeIdx = $this->indexOf($main, 'vitec_introparagraph', $rangeH2) ?? $this->indexOf($main, 'vitec_introparagraph', self::RANGE_PLACEHOLDER); + if ($rangeIdx !== null) { + $rangeId = (string)$main[$rangeIdx]['uid']; + $this->update($rangeId, $main[$rangeIdx], ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range intro #' . $rangeId); + } else { + $rangeId = $this->create($pageUid, 'vitec_introparagraph', ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range: NEW intro (grouped page)'); + } + $names = array_map(static fn(array $p): string => $p['name'], $wb['range']['products'] ?? []); + $cardsOnPage = (int)$conn->fetchOne( + "SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_card' AND colPos >= 200", + [$pageUid] + ); + $this->report[] = 'range: ' . count($names) . ' workbook product(s), ' . $cardsOnPage . ' card(s) in grids on the page - cards untouched (record teasers win)'; + + // --- 06 solutions ------------------------------------------------------------------------ + $solIds = []; + if (($wb['solutions']['items'] ?? []) !== []) { + $h2 = $this->fixCase(($wb['solutions']['h2'] ?? '') !== '' ? $wb['solutions']['h2'] : ('Solutions using ' . $h1), $h1); + $idx = $this->indexOf($main, 'vitec_introparagraph', $h2); + if ($idx !== null) { + $this->report[] = 'solutions: intro "' . $h2 . '" exists - block left as is'; + } else { + $solIds[] = $this->create($pageUid, 'vitec_introparagraph', ['header' => $h2, 'bodytext' => ''], 'solutions: NEW intro "' . $h2 . '"'); + foreach ($wb['solutions']['items'] as $item) { + $fields = [ + 'vitec_layout_variant' => 'text_only', + 'header' => $item['title'], + 'bodytext' => $this->p($item['text']), + 'vitec_primary_cta_label' => 'Learn more', + ]; + $target = $this->resolveSolutionPage($item['title']); + if ($target > 0) { + $fields['vitec_primary_cta'] = 't3://page?uid=' . $target; + } + $solIds[] = $this->create($pageUid, 'vitec_card', $fields, 'solutions: NEW card "' . $item['title'] . '" -> ' . ($target > 0 ? 'page #' . $target : 'NO solution page resolved (link empty)')); + } + } + } + + // --- 07 cta banner ------------------------------------------------------------------------- + $cta = $this->first($top, self::COLPOS_PREFOOTER, 'vitec_ctabanner'); + if ($cta === null) { + $this->report[] = 'cta: NO vitec_ctabanner in colPos 3 - not placed'; + } elseif (($wb['cta']['h2'] ?? '') !== '') { + $this->update((string)$cta['uid'], $cta, [ + 'header' => $this->fixCase($wb['cta']['h2'], $h1), + 'bodytext' => $this->p($wb['cta']['body'] ?? ''), + 'vitec_primary_cta_label' => ($wb['cta']['button'] ?? '') !== '' ? $wb['cta']['button'] : 'Contact us', + ], 'cta #' . $cta['uid']); + } + + // --- 08 seo / page properties ------------------------------------------------------------- + $seoTitle = ($wb['seo']['title'] ?? '') !== '' ? $wb['seo']['title'] : $h1 . ' | VITEC'; + $desc = $wb['seo']['description'] ?? ''; + $keywords = implode(', ', array_filter([$wb['seo']['primary'] ?? '', $wb['seo']['secondary'] ?? ''])); + $props = [ + 'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle, + 'description' => $desc, 'og_description' => $desc, 'twitter_description' => $desc, + 'keywords' => $keywords, + 'abstract' => mb_substr(trim(($wb['intro']['body'] ?? '') !== '' ? $wb['intro']['body'] : $desc), 0, 300), + ]; + $changed = []; + foreach ($props as $field => $value) { + if (trim((string)($page[$field] ?? '')) !== trim($value)) { + $changed[] = $field; + $this->datamap['pages'][(string)$pageUid][$field] = $value; + } + } + if ($changed !== []) { + $this->touched++; + $this->report[] = 'page meta: ' . implode(', ', $changed); + } else { + $this->report[] = 'page meta: unchanged'; + } + + foreach ($this->report as $line) { + $output->writeln(' ' . $line); + } + if (!$this->apply) { + return true; + } + + // --- write ----------------------------------------------------------------------------------- + $subst = []; + if ($this->datamap !== []) { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($this->datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); + } + $subst = $dataHandler->substNEWwithIDs; + } + + // final main-column order: intro, use case, why band, [comparison], range intro, the rest (cards/grids/group intros), solutions + $order = array_merge([$introId], $useId !== null ? [$useId] : [], $whyIds, $comparisonId !== null ? [$comparisonId] : [], [$rangeId]); + $placed = array_flip($order); + foreach ($main as $row) { + if (!isset($placed[(string)$row['uid']])) { + $order[] = (string)$row['uid']; + } + } + $order = array_merge($order, $solIds); + $sorting = 0; + foreach ($order as $id) { + $uid = str_starts_with($id, 'NEW') ? (int)($subst[$id] ?? 0) : (int)$id; + if ($uid > 0) { + $sorting += 256; + $conn->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]); + } + } + return true; + } + + // ---- element helpers --------------------------------------------------------------------------- + + /** @param array> $rows */ + private function first(array $rows, int $colPos, string $ctype): ?array + { + foreach ($rows as $row) { + if ((int)$row['colPos'] === $colPos && $row['CType'] === $ctype) { + return $row; + } + } + return null; + } + + /** @param array> $main */ + private function indexOf(array $main, string $ctype, string $header): ?int + { + $needle = $this->norm($header); + foreach ($main as $i => $row) { + if ($row['CType'] === $ctype && $this->norm((string)$row['header']) === $needle) { + return $i; + } + } + return null; + } + + /** + * Existing introparagraph with this header (updated) or a new one. + * + * @param array> $main + * @param array> $byUid + */ + private function ensureIntro(array $main, array $byUid, int $pageUid, string $header, string $body, string $label): string + { + $idx = $this->indexOf($main, 'vitec_introparagraph', $header); + if ($idx !== null) { + $id = (string)$main[$idx]['uid']; + $this->update($id, $byUid[$id], ['header' => $header, 'bodytext' => $this->p($body)], $label . ' #' . $id); + return $id; + } + return $this->create($pageUid, 'vitec_introparagraph', ['header' => $header, 'bodytext' => $this->p($body)], $label . ': NEW "' . $header . '"'); + } + + /** @param array $fields */ + private function create(int $pageUid, string $ctype, array $fields, string $label): string + { + $id = $this->newId(); + $this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => self::COLPOS_MAIN, 'CType' => $ctype] + $fields; + $this->report[] = $label; + $this->touched++; + return $id; + } + + /** + * Writes only fields whose current value differs (RTE-tolerant on bodytext). + * + * @param array $current + * @param array $fields + */ + private function update(string $uid, array $current, array $fields, string $label): void + { + $diff = []; + foreach ($fields as $field => $value) { + $old = (string)($current[$field] ?? ''); + $same = $field === 'bodytext' + ? $this->norm(strip_tags($old)) === $this->norm(strip_tags($value)) + : trim($old) === trim($value); + if (!$same) { + $diff[] = $field; + $this->datamap['tt_content'][$uid][$field] = $value; + } + } + if ($diff === []) { + $this->report[] = $label . ': unchanged'; + return; + } + $this->touched++; + $this->report[] = $label . ': ' . implode(', ', $diff); + } + + // ---- resolution -------------------------------------------------------------------------------- + + /** @return array|null */ + private function pageByTitle(string $title): ?array + { + $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $needle = $this->norm($title); + $rows = $pages->fetchAllAssociative( + "SELECT uid, slug, title, nav_title, seo_title, og_title, twitter_title, description, og_description, twitter_description, keywords, abstract + FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND slug LIKE '/products/%'" + ); + if (isset(self::TITLE_TO_SLUG[$needle])) { + foreach ($rows as $row) { + if ($row['slug'] === self::TITLE_TO_SLUG[$needle]) { + return $row; + } + } + } + $hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['title']) === $needle)); + if (count($hits) === 1) { + return $hits[0]; + } + if (count($hits) > 1) { + // prefer the flat overview slug (one segment below /products) + $flat = array_values(array_filter($hits, static fn(array $r): bool => substr_count((string)$r['slug'], '/') === 2)); + if (count($flat) === 1) { + return $flat[0]; + } + $this->report[] = 'ambiguous title "' . $title . '": ' . implode(', ', array_map(static fn(array $r): string => $r['slug'], $hits)); + return null; + } + $hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['nav_title']) === $needle)); + return count($hits) === 1 ? $hits[0] : null; + } + + private function resolveSolutionPage(string $title): int + { + $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $root = $pages->fetchOne("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'"); + if (!$root) { + return 0; + } + $needle = $this->norm($title); + $needle = self::SOLUTION_ALIASES[$needle] ?? $needle; + $strip = static fn(string $n): string => (string)preg_replace('/solutions$/', '', $n); + $rows = $pages->fetchAllAssociative( + 'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) AND doktype = 1', + [(int)$root] + ); + foreach ($rows as $row) { + if ($this->norm((string)$row['title']) === $needle) { + return (int)$row['uid']; + } + } + foreach ($rows as $row) { + if ($strip($this->norm((string)$row['title'])) === $strip($needle)) { + return (int)$row['uid']; + } + } + return 0; + } + + // ---- workbook reading -------------------------------------------------------------------------- + + /** @return array */ + private function readWorkbook(string $file): array + { + $spreadsheet = IOFactory::load($file); + $sheet = $spreadsheet->getSheetByName('Content') ?? $spreadsheet->getSheet($spreadsheet->getSheetCount() - 1); + $rows = $sheet->toArray(null, false, false, false); + $spreadsheet->disconnectWorksheets(); + + $wb = ['hero' => [], 'intro' => [], 'why' => ['benefits' => []], 'comparison' => ['rows' => []], + 'range' => ['products' => []], 'solutions' => ['items' => []], 'cta' => [], 'seo' => []]; + $benefits = []; + $products = []; + $solutions = []; + foreach ($rows as $row) { + $section = trim((string)($row[0] ?? '')); + $field = trim((string)($row[1] ?? '')); + $value = trim((string)($row[2] ?? '')); + if ($section === '' || $field === '' || !preg_match('/^(\d\d)/', $section, $m)) { + continue; + } + $f = mb_strtolower($field); + switch ($m[1]) { + case '01': + if ($f === 'h1') { + $wb['hero']['h1'] = $value; + } elseif (str_contains($f, 'subline')) { + $wb['hero']['subline'] = $value; + } elseif (str_contains($f, 'cta')) { + $wb['hero']['cta'] = $value; + } + break; + case '02': + if ($f === 'h2') { + $wb['intro']['h2'] = $value; + } elseif ($f === 'introduction') { + $wb['intro']['body'] = $value; + } elseif (str_contains($f, 'use-case card heading')) { + $wb['intro']['usecase_heading'] = $value; + } elseif (str_contains($f, 'use-case description')) { + $wb['intro']['usecase_body'] = $value; + } + break; + case '03': + if (preg_match('/^benefit\s*(\d+)\s*[—–-]+\s*(heading|body)$/u', $f, $b)) { + $benefits[(int)$b[1]][$b[2]] = $value; + } elseif (str_contains($f, 'h2')) { + $wb['why']['h2'] = $value; + } + break; + case '04': + if ($f === 'h2') { + $wb['comparison']['h2'] = $value; + } elseif ($f === 'intro') { + $wb['comparison']['intro'] = $value; + } elseif (preg_match('/^product\s*\d+\s*:\s*(.+)$/iu', $field, $c)) { + $wb['comparison']['rows'][] = ['name' => trim($c[1]), 'text' => $value]; + } + break; + case '05': + if ($f === 'h2') { + $wb['range']['h2'] = $value; + } elseif ($f === 'intro') { + $wb['range']['intro'] = $value; + } elseif (preg_match('/^product\s*(\d+)\s*[—–-]+\s*(group|name|description|cta|target url)$/u', $f, $p)) { + $products[(int)$p[1]][$p[2]] = $value; + } + break; + case '06': + if ($f === 'h2') { + $wb['solutions']['h2'] = $value; + } elseif (preg_match('/^solution\s*(\d+)\s*[—–-]+\s*(title|description|link)$/u', $f, $s)) { + $solutions[(int)$s[1]][$s[2]] = $value; + } + break; + case '07': + if ($f === 'h2') { + $wb['cta']['h2'] = $value; + } elseif ($f === 'body') { + $wb['cta']['body'] = $value; + } elseif ($f === 'button') { + $wb['cta']['button'] = $value; + } elseif ($f === 'url') { + $wb['cta']['url'] = $value; + } + break; + case '08': + if (str_contains($f, 'seo title')) { + $wb['seo']['title'] = $value; + } elseif (str_contains($f, 'meta description')) { + $wb['seo']['description'] = $value; + } elseif (str_contains($f, 'primary keyword')) { + $wb['seo']['primary'] = $value; + } elseif (str_contains($f, 'secondary keyword')) { + $wb['seo']['secondary'] = $value; + } + break; + } + } + ksort($benefits); + foreach ($benefits as $b) { + if (($b['heading'] ?? '') !== '') { + $wb['why']['benefits'][] = ['heading' => $b['heading'], 'body' => $b['body'] ?? '']; + } + } + ksort($products); + foreach ($products as $p) { + if (($p['name'] ?? '') !== '') { + $wb['range']['products'][] = ['group' => $p['group'] ?? '', 'name' => $p['name'], 'text' => $p['description'] ?? '']; + } + } + ksort($solutions); + foreach ($solutions as $s) { + if (($s['title'] ?? '') !== '') { + $wb['solutions']['items'][] = ['title' => $s['title'], 'text' => $s['description'] ?? '', 'link' => $s['link'] ?? '']; + } + } + return $wb; + } + + // ---- text helpers -------------------------------------------------------------------------------- + + /** "Compare aetria" -> "Compare Aetria": the template lower-cased the product name inside the H2s */ + private function fixCase(string $text, string $h1): string + { + $lower = mb_strtolower($h1); + if ($lower === '' || $lower === $h1) { + return $text; + } + return (string)preg_replace('/' . preg_quote($lower, '/') . '/u', $h1, $text); + } + + private function sameTheme(string $a, string $b): bool + { + $words = fn(string $text): array => array_values(array_diff( + preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [], + self::STOPWORDS + )); + $x = $words($a); + $y = $words($b); + if ($x === [] || count($x) !== count($y)) { + return false; + } + foreach ($x as $i => $word) { + if (!str_starts_with($y[$i], $word) && !str_starts_with($word, $y[$i])) { + return false; + } + } + return true; + } + + private function p(string $text): string + { + $text = trim($text); + return $text === '' ? '' : '

' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '

'; + } + + private function norm(string $value): string + { + $value = mb_strtolower($value); + $value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value); + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value)); + } + + private function newId(): string + { + return 'NEWOV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT); + } +} diff --git a/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php b/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php index a2bdc0f..507fb35 100644 --- a/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php +++ b/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php @@ -29,13 +29,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; * 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. + * the seed the editors own the cards. EXCEPT with --attach-icons + * (2026-11-10, later icon deliveries such as PRISM/SFF PRISM/Avedia + * chassis): then its cards WITHOUT an icon get the matching staged icon + * (same matcher); titles, texts and cards with an icon stay untouched. * * 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 + * vendor/bin/typo3 vitec:seed-product-features --only=prism --attach-icons --apply */ #[AsCommand( name: 'vitec:seed-product-features', @@ -60,6 +64,7 @@ final class SeedProductFeaturesCommand extends Command protected function configure(): void { $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring'); + $this->addOption('attach-icons', null, InputOption::VALUE_NONE, 'Products with existing cards: attach staged icons to cards without one'); $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); } @@ -67,6 +72,7 @@ final class SeedProductFeaturesCommand extends Command { Bootstrap::initializeBackendAuthentication(); $apply = (bool)$input->getOption('apply'); + $attachIcons = (bool)$input->getOption('attach-icons'); $only = mb_strtolower((string)($input->getOption('only') ?? '')); $pool = GeneralUtility::makeInstance(ConnectionPool::class); @@ -81,6 +87,7 @@ final class SeedProductFeaturesCommand extends Command $iconsMatched = 0; $skippedExisting = 0; $skippedEmpty = 0; + $attached = 0; foreach ($products as $product) { $uid = (int)$product['uid']; @@ -93,17 +100,11 @@ final class SeedProductFeaturesCommand extends Command 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?', [$uid] ); - if ($existing > 0) { + if ($existing > 0 && !$attachIcons) { $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 = []; @@ -119,48 +120,70 @@ final class SeedProductFeaturesCommand extends Command } } - $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; - } - } - - // second pass: token containment, e.g. theme "ManagementFoundation" - // inside the card title "Aetria management foundation" - foreach ($pairs as $i => $pair) { - if (isset($pair['icon'])) { + if ($existing > 0) { + // --attach-icons: existing cards without an icon get a staged one + if ($iconByTheme === []) { + $skippedExisting++; continue; } - foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) { - $titleTokens = $this->tokens($pair['title']); - $themeTokens = $this->tokens($theme); - if ($titleTokens !== [] && $themeTokens !== [] - && (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === []) - ) { - $pairs[$i]['icon'] = $iconByTheme[$theme]; - $usedThemes[$theme] = true; - break; - } + $bare = $pool->getConnectionForTable(self::TABLE)->fetchAllAssociative( + 'SELECT f.uid, f.title FROM ' . self::TABLE . ' f WHERE f.deleted = 0 AND f.product = ? + AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = ? + AND r.fieldname = ? AND r.uid_foreign = f.uid) + ORDER BY f.sorting', + [$uid, self::TABLE, 'icon'] + ); + if ($bare === []) { + $skippedExisting++; + continue; } + $pairs = array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title'], 'text' => '-'], $bare); + $output->writeln(sprintf("\n%s (uid %d) - ATTACH: %d existing card(s) without icon, %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme))); + $usedThemes = $this->matchIcons($pairs, $iconByTheme); + foreach ($pairs as $pair) { + $output->writeln(' "' . $pair['title'] . '"' . (isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' - no icon')); + } + foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) { + $output->writeln(' UNMATCHED icon: ' . basename($identifier) . ''); + } + $iconsMatched += count($usedThemes); + $attached += count($usedThemes); + if (!$apply || $usedThemes === []) { + continue; + } + $datamap = []; + foreach ($pairs as $pair) { + if (!isset($pair['icon'])) { + continue; + } + $refId = $this->newId(); + $datamap['sys_file_reference'][$refId] = [ + 'pid' => (int)$product['pid'], + 'table_local' => 'sys_file', + 'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(), + ]; + $datamap[self::TABLE][(string)$pair['uid']] = ['icon' => $refId]; + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln(' DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + continue; } - // last pair standing: a complete set (#icons == #cards) with exactly - // one icon and one card left over - they belong to each other - if (count($iconByTheme) === count($pairs)) { - $freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes)); - $freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon']))); - if (count($freeThemes) === 1 && count($freePairs) === 1) { - $pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]]; - $usedThemes[$freeThemes[0]] = true; - } + $pairs = $this->parseApplications((string)$product['applications']); + if ($pairs === []) { + $skippedEmpty++; + continue; } + $output->writeln(sprintf("\n%s (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme))); + + $usedThemes = $this->matchIcons($pairs, $iconByTheme); + foreach ($pairs as $pair) { $output->writeln(sprintf( ' "%s"%s%s', @@ -224,11 +247,11 @@ final class SeedProductFeaturesCommand extends Command } $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, + "\n%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s", + $seeded, $cards, $iconsMatched, $attachIcons ? ', ' . $attached . ' attached to existing cards' : '', $skippedExisting, $skippedEmpty, $apply ? '' : ' (dry run - nothing written)' )); - if ($apply && $seeded > 0) { + if ($apply && ($seeded > 0 || $attached > 0)) { $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); } return Command::SUCCESS; @@ -276,6 +299,58 @@ final class SeedProductFeaturesCommand extends Command return $pairs; } + /** + * Matches the staged icons onto the cards (three passes: normalised title + * / Levenshtein, token containment, last pair standing). Sets 'icon' on + * the matched pairs and returns the used themes. + * + * @param array $pairs + * @param array $iconByTheme theme => identifier + * @return array + */ + private function matchIcons(array &$pairs, array $iconByTheme): array + { + $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; + } + } + + // second pass: token containment, e.g. theme "ManagementFoundation" + // inside the card title "Aetria management foundation" + foreach ($pairs as $i => $pair) { + if (isset($pair['icon'])) { + continue; + } + foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) { + $titleTokens = $this->tokens($pair['title']); + $themeTokens = $this->tokens($theme); + if ($titleTokens !== [] && $themeTokens !== [] + && (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === []) + ) { + $pairs[$i]['icon'] = $iconByTheme[$theme]; + $usedThemes[$theme] = true; + break; + } + } + } + + // last pair standing: a complete set (#icons == #cards) with exactly + // one icon and one card left over - they belong to each other + if (count($iconByTheme) === count($pairs)) { + $freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes)); + $freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon']))); + if (count($freeThemes) === 1 && count($freePairs) === 1) { + $pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]]; + $usedThemes[$freeThemes[0]] = true; + } + } + return $usedThemes; + } + /** * The icon theme that matches a card title: exact on the normalised * strings (stop words removed on both sides), else the closest theme