- 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>
417 lines
18 KiB
PHP
417 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Command;
|
|
|
|
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\Resource\StorageRepository;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Seeds the "Why choose <product>?" cards (tx_vitec_product_feature,
|
|
* contract 0036-FE) from the `applications` RTE HTML plus the staged agency
|
|
* icons:
|
|
*
|
|
* - `applications` pairs `<p><strong>Title</strong></p><p>Text</p>` become
|
|
* one feature record each (several text paragraphs are joined); the
|
|
* leading <h3> stays where it is - the JSON heading is derived from it.
|
|
* - icons come from fileadmin/icons/delivered/products/<context>/ (the
|
|
* 2026-11-03 staging); <context> is the product slug unless ICON_DIRS
|
|
* maps it. Matching: normalised title vs. the filename theme (the part
|
|
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
|
|
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
|
|
* - a product that already HAS feature records is never touched - after
|
|
* the seed the editors own the cards. 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',
|
|
description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)'
|
|
)]
|
|
final class SeedProductFeaturesCommand extends Command
|
|
{
|
|
private const TABLE = 'tx_vitec_product_feature';
|
|
private const ICON_BASE = 'icons/delivered/products/';
|
|
|
|
/** product slug => staging context where the names differ */
|
|
private const ICON_DIRS = [
|
|
'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform',
|
|
'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform',
|
|
'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product',
|
|
];
|
|
|
|
private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is'];
|
|
|
|
private int $newCounter = 0;
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
|
|
$this->addOption('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)');
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
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);
|
|
|
|
$products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
|
|
'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)'
|
|
);
|
|
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)');
|
|
|
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
|
$seeded = 0;
|
|
$cards = 0;
|
|
$iconsMatched = 0;
|
|
$skippedExisting = 0;
|
|
$skippedEmpty = 0;
|
|
$attached = 0;
|
|
|
|
foreach ($products as $product) {
|
|
$uid = (int)$product['uid'];
|
|
$slug = (string)$product['slug'];
|
|
if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) {
|
|
continue;
|
|
}
|
|
|
|
$existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne(
|
|
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
|
|
[$uid]
|
|
);
|
|
if ($existing > 0 && !$attachIcons) {
|
|
$skippedExisting++;
|
|
continue;
|
|
}
|
|
|
|
// staged icons of this product, filename theme => identifier
|
|
$context = self::ICON_DIRS[$slug] ?? $slug;
|
|
$iconByTheme = [];
|
|
$folder = self::ICON_BASE . $context;
|
|
if ($storage->hasFolder($folder)) {
|
|
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
|
|
if (mb_strtolower($file->getExtension()) !== 'svg') {
|
|
continue;
|
|
}
|
|
if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
|
|
$iconByTheme[$m[1]] = $file->getIdentifier();
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($existing > 0) {
|
|
// --attach-icons: existing cards without an icon get a staged one
|
|
if ($iconByTheme === []) {
|
|
$skippedExisting++;
|
|
continue;
|
|
}
|
|
$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;
|
|
}
|
|
|
|
$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',
|
|
$pair['title'],
|
|
$pair['text'] !== '' ? '' : ' <comment>(no text)</comment>',
|
|
isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'
|
|
));
|
|
}
|
|
foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) {
|
|
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
|
|
}
|
|
|
|
$cards += count($pairs);
|
|
$iconsMatched += count($usedThemes);
|
|
$seeded++;
|
|
|
|
if (!$apply) {
|
|
continue;
|
|
}
|
|
|
|
$datamap = [];
|
|
$featureIds = [];
|
|
foreach ($pairs as $pair) {
|
|
$featureId = $this->newId();
|
|
$record = [
|
|
'pid' => (int)$product['pid'],
|
|
'product' => $uid,
|
|
'title' => $pair['title'],
|
|
'cardtext' => $pair['text'],
|
|
];
|
|
if (isset($pair['icon'])) {
|
|
$refId = $this->newId();
|
|
$datamap['sys_file_reference'][$refId] = [
|
|
'pid' => (int)$product['pid'],
|
|
'table_local' => 'sys_file',
|
|
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
|
|
];
|
|
$record['icon'] = $refId;
|
|
}
|
|
$datamap[self::TABLE][$featureId] = $record;
|
|
$featureIds[] = $featureId;
|
|
}
|
|
$datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)];
|
|
|
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
|
$dataHandler->start($datamap, []);
|
|
$dataHandler->process_datamap();
|
|
if ($dataHandler->errorLog !== []) {
|
|
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
// DataHandler puts NEW records at the TOP - write explicit sorting
|
|
$sorting = 0;
|
|
foreach ($featureIds as $featureId) {
|
|
$realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0);
|
|
if ($realUid > 0) {
|
|
$sorting += 256;
|
|
$pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$output->writeln(sprintf(
|
|
"\n<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 || $attached > 0)) {
|
|
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
|
}
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* The `<p><strong>Title</strong></p><p>Text…</p>` pairs of the
|
|
* applications HTML, in order. Several text paragraphs after one title
|
|
* are joined; the <h3> heading and empty paragraphs are skipped.
|
|
*
|
|
* @return array<int,array{title:string,text:string,icon?:string}>
|
|
*/
|
|
private function parseApplications(string $html): array
|
|
{
|
|
if (trim($html) === '') {
|
|
return [];
|
|
}
|
|
$pairs = [];
|
|
$current = null;
|
|
if (!preg_match_all('#<p[^>]*>(.*?)</p>#is', $html, $matches)) {
|
|
return [];
|
|
}
|
|
foreach ($matches[1] as $inner) {
|
|
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
|
if ($plain === '') {
|
|
continue;
|
|
}
|
|
$strongOnly = preg_match('#^\s*<strong[^>]*>(.*?)</strong>\s*$#is', trim($inner), $m)
|
|
? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')))
|
|
: null;
|
|
if ($strongOnly !== null && $strongOnly !== '') {
|
|
if ($current !== null) {
|
|
$pairs[] = $current;
|
|
}
|
|
$current = ['title' => $strongOnly, 'text' => ''];
|
|
continue;
|
|
}
|
|
if ($current !== null) {
|
|
$current['text'] = trim($current['text'] . ' ' . $plain);
|
|
}
|
|
}
|
|
if ($current !== null) {
|
|
$pairs[] = $current;
|
|
}
|
|
return $pairs;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* within Levenshtein distance 3 (typo tolerance).
|
|
*
|
|
* @param array<string,string> $iconByTheme theme => identifier
|
|
*/
|
|
private function matchTheme(string $title, array $iconByTheme): ?string
|
|
{
|
|
if ($iconByTheme === []) {
|
|
return null;
|
|
}
|
|
$titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]);
|
|
$best = null;
|
|
$bestDistance = PHP_INT_MAX;
|
|
foreach (array_keys($iconByTheme) as $theme) {
|
|
$themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]);
|
|
foreach ($titleNorms as $a) {
|
|
foreach ($themeNorms as $b) {
|
|
if ($a === $b) {
|
|
return $theme;
|
|
}
|
|
if (abs(strlen($a) - strlen($b)) <= 3) {
|
|
$distance = levenshtein($a, $b);
|
|
if ($distance < $bestDistance) {
|
|
$bestDistance = $distance;
|
|
$best = $theme;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $bestDistance <= 3 ? $best : null;
|
|
}
|
|
|
|
/**
|
|
* Lowercase word list (camelCase split, stop words removed) for the
|
|
* token-containment pass.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private function tokens(string $value): array
|
|
{
|
|
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
$words = array_map(static fn(string $w): string => mb_strtolower($w), $words);
|
|
return array_values(array_filter($words, static fn(string $w): bool => !in_array($w, self::STOPWORDS, true)));
|
|
}
|
|
|
|
/** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */
|
|
private function norm(string $value, bool $dropStopwords): string
|
|
{
|
|
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
if ($dropStopwords) {
|
|
$words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true));
|
|
}
|
|
return mb_strtolower(implode('', $words));
|
|
}
|
|
|
|
private function newId(): string
|
|
{
|
|
return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
|
}
|
|
}
|