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
+896 -46

No files matched your search

@@ -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