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 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5.1 committed 2026-11-10 19:37:15 +01:00
1 parent 2aa8865b23
commit f4f18e913a
5 files changed
+895 -45

No files matched your search

+3
View File
@@ -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/
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
// READ-ONLY: hero references + sys_file rows of the three products that got a banner on 2026-11-10
$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]);
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";
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Diagnose (2026-11-10): state of the 20 product overview pages before the
* "Enriched" workbook import, plus product slugs / solution pages needed for
* banner, icon and solution-card mapping. READ-ONLY, plain PDO.
*
* php migrations/check_overview_pages_20261110.php > 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";
@@ -0,0 +1,681 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use PhpOffice\PhpSpreadsheet\IOFactory;
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;
/**
* Fills the product OVERVIEW pages (/products/<slug>, layout 9, built by
* vitec:build-product-overviews on 2026-11-02) with the agency's editorial
* copy from the "<Overview> - 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<string,array<string|int,array<string,mixed>>> */
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('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
$output->writeln(sprintf(
"\n<info>%d of %d workbook(s) %s, %d element/page write(s).</info>%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('<comment> ' . basename($file) . ': no "01 · HERO / H1" row - skipped</comment>');
return false;
}
$page = $this->pageByTitle($h1);
if ($page === null) {
$output->writeln('<error> ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped'
. ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . '</error>');
return false;
}
$pageUid = (int)$page['uid'];
$output->writeln(sprintf("\n<info>%s</info> -> %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'] ?? '')
. '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Description</th></tr></thead><tbody>';
foreach ($wb['comparison']['rows'] as $row) {
$body .= '<tr><td>' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '</td><td>'
. htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
}
$body .= '</tbody></table>';
$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<int,array<string,mixed>> $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<int,array<string,mixed>> $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<int,array<string,mixed>> $main
* @param array<string,array<string,mixed>> $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<string,mixed> $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<string,mixed> $current
* @param array<string,string> $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<string,mixed>|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<string,mixed> */
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 === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
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);
}
}
@@ -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<info>%s</info> (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<info>%s</info> (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']) : ' <comment>- no icon</comment>'));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$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(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
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<info>%s</info> (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<info>%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s</info>",
$seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty,
"\n<info>%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s</info>",
$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('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
}
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<int,array{title:string,text:string,icon?:string,uid?:int}> $pairs
* @param array<string,string> $iconByTheme theme => identifier
* @return array<string,bool>
*/
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