product.features: Why-choose cards with icons (contract 0036-FE)
- new inline table tx_vitec_product_feature (title, cardtext, FAL icon)
+ `features` field on the product record; ProductShowJsonRenderer
emits product.features {heading, items[{title, text, icon}]} and null
while a product has no cards, so the front end keeps its
applications-HTML fallback
- new vitec:seed-product-features: seeds the cards from the
applications <p><strong> pairs and matches the staged agency icons by
normalised title (camelCase split, stop words, Levenshtein <= 3 typo
tolerance); products with existing cards are never touched
- new vitec:build-why-vitec-bands: "Why VITEC?" intro + icon_card band
on the 7 overview pages with a staged icon set (the contract's
"centered" variant does not exist - documented in ticket 0039-BE);
vision/visionav sub-group sets stay unassigned
- product-check page: sortable title column and an Open Product button
- new migration fix_colombia_quote.php: stray quote element #12037
removed, quote_role set (ticket 0038-FE)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ccb9b149e
commit
46b12d4a58
8 files changed
+799
-4
No files matched your search
@@ -0,0 +1,206 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Builds the "Why VITEC?" icon band on the product OVERVIEW pages
|
||||
* (/products/<slug>, contract 0036-FE variant 3b): one vitec_introparagraph
|
||||
* headline plus one vitec_card (icon_card variant - the design's "centered"
|
||||
* card; 0036 named a variant that does not exist) per staged agency icon of
|
||||
* that page's context under fileadmin/icons/delivered/products/<context>/.
|
||||
* Card titles are derived from the icon filename theme ("ScalableArchitecture"
|
||||
* -> "Scalable architecture"); bodytext stays empty until the agency
|
||||
* delivers copy (0036: a card without text is fine).
|
||||
*
|
||||
* The band lands directly AFTER the first element of the main column (the
|
||||
* intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph
|
||||
* is skipped - editors own the band from then on. The Vision sub-group icon
|
||||
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
|
||||
* decision, reported only.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:build-why-vitec-bands
|
||||
* vendor/bin/typo3 vitec:build-why-vitec-bands --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:build-why-vitec-bands',
|
||||
description: 'Create the Why-VITEC icon bands on the product overview pages from the staged icons (dry-run by default)'
|
||||
)]
|
||||
final class BuildWhyVitecBandsCommand extends Command
|
||||
{
|
||||
private const ICON_BASE = 'icons/delivered/products/';
|
||||
private const HEADER = 'Why VITEC?';
|
||||
private const COLPOS = 0;
|
||||
|
||||
/** staged icon context => overview page slug */
|
||||
private const BANDS = [
|
||||
'activesqx' => '/products/activesqx',
|
||||
'aetria' => '/products/aetria',
|
||||
'aligo' => '/products/aligo',
|
||||
'avedia-modular-system' => '/products/avedia-modular-system',
|
||||
'image-graphics-cards' => '/products/image-graphics-cards',
|
||||
'operator-workstations' => '/products/operator-workstations',
|
||||
'prism-transcoder' => '/products/prism-transcoder',
|
||||
];
|
||||
|
||||
/** acronyms kept upper-case when the filename theme becomes a card title */
|
||||
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
|
||||
|
||||
private int $newCounter = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$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');
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count(self::BANDS) . ' band(s)');
|
||||
|
||||
$built = 0;
|
||||
foreach (self::BANDS as $context => $slug) {
|
||||
$page = $pool->getConnectionForTable('pages')->fetchAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)',
|
||||
[$slug]
|
||||
);
|
||||
if (!$page) {
|
||||
$output->writeln('<error>' . $slug . ': page not found - skipped</error>');
|
||||
continue;
|
||||
}
|
||||
$pageUid = (int)$page['uid'];
|
||||
|
||||
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
|
||||
[$pageUid, self::HEADER]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
$output->writeln($slug . ': band exists - untouched');
|
||||
continue;
|
||||
}
|
||||
|
||||
$folder = self::ICON_BASE . $context;
|
||||
$icons = [];
|
||||
if ($storage->hasFolder($folder)) {
|
||||
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
|
||||
if (mb_strtolower($file->getExtension()) === 'svg' && preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
|
||||
$icons[] = ['identifier' => $file->getIdentifier(), 'title' => $this->themeTitle($m[1])];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($icons === []) {
|
||||
$output->writeln('<comment>' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped</comment>');
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
|
||||
foreach ($icons as $icon) {
|
||||
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
|
||||
}
|
||||
$built++;
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// current main-column order (top-level elements only: the band
|
||||
// must not land inside a b13 container)
|
||||
$elements = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ?
|
||||
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
|
||||
ORDER BY sorting, uid',
|
||||
[$pageUid, self::COLPOS]
|
||||
);
|
||||
$existingUids = array_map(static fn(array $r): string => (string)(int)$r['uid'], $elements);
|
||||
|
||||
$datamap = [];
|
||||
$newIds = [];
|
||||
$introId = $this->newId();
|
||||
$datamap['tt_content'][$introId] = [
|
||||
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_introparagraph',
|
||||
'header' => self::HEADER, 'bodytext' => '',
|
||||
];
|
||||
$newIds[] = $introId;
|
||||
foreach ($icons as $icon) {
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pageUid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
|
||||
];
|
||||
$cardId = $this->newId();
|
||||
$datamap['tt_content'][$cardId] = [
|
||||
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_card',
|
||||
'header' => $icon['title'], 'bodytext' => '',
|
||||
'vitec_layout_variant' => 'icon_card',
|
||||
'vitec_icon' => $refId,
|
||||
];
|
||||
$newIds[] = $cardId;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
// final order: first element (intro), then the band, then the rest
|
||||
$order = array_merge(
|
||||
array_slice($existingUids, 0, 1),
|
||||
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
|
||||
array_slice($existingUids, 1)
|
||||
);
|
||||
$sorting = 0;
|
||||
foreach ($order as $uid) {
|
||||
if ((int)$uid > 0) {
|
||||
$sorting += 256;
|
||||
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => (int)$uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
"\n<info>%d band(s) %s.</info> Vision sub-group icon sets (vision, visionav) stay unassigned - one page, two sets (editorial).",
|
||||
$built, $apply ? 'written - flush the cache' : 'planned (dry run - nothing written)'
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
|
||||
private function themeTitle(string $theme): string
|
||||
{
|
||||
$words = preg_split('/(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)/', $theme, -1, PREG_SPLIT_NO_EMPTY) ?: [$theme];
|
||||
$out = [];
|
||||
foreach ($words as $i => $word) {
|
||||
if (in_array(strtoupper($word), self::ACRONYMS, true)) {
|
||||
$out[] = strtoupper($word);
|
||||
} elseif (preg_match('/\d/', $word)) {
|
||||
$out[] = $word;
|
||||
} else {
|
||||
$out[] = $i === 0 ? ucfirst(mb_strtolower($word)) : mb_strtolower($word);
|
||||
}
|
||||
}
|
||||
return implode(' ', $out);
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWWV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?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.
|
||||
*
|
||||
* Unmatched icons and unmatched cards are reported so the editors can map
|
||||
* the leftovers by hand (0036-FE asks for exactly that list).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:seed-product-features
|
||||
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:seed-product-features',
|
||||
description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)'
|
||||
)]
|
||||
final class SeedProductFeaturesCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_product_feature';
|
||||
private const ICON_BASE = 'icons/delivered/products/';
|
||||
|
||||
/** product slug => staging context where the names differ */
|
||||
private const ICON_DIRS = [
|
||||
'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform',
|
||||
'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform',
|
||||
'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product',
|
||||
];
|
||||
|
||||
private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is'];
|
||||
|
||||
private int $newCounter = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
|
||||
$products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
|
||||
'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)'
|
||||
);
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)');
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$seeded = 0;
|
||||
$cards = 0;
|
||||
$iconsMatched = 0;
|
||||
$skippedExisting = 0;
|
||||
$skippedEmpty = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
$uid = (int)$product['uid'];
|
||||
$slug = (string)$product['slug'];
|
||||
if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne(
|
||||
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
|
||||
[$uid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
$skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairs = $this->parseApplications((string)$product['applications']);
|
||||
if ($pairs === []) {
|
||||
$skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// staged icons of this product, filename theme => identifier
|
||||
$context = self::ICON_DIRS[$slug] ?? $slug;
|
||||
$iconByTheme = [];
|
||||
$folder = self::ICON_BASE . $context;
|
||||
if ($storage->hasFolder($folder)) {
|
||||
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
|
||||
if (mb_strtolower($file->getExtension()) !== 'svg') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
|
||||
$iconByTheme[$m[1]] = $file->getIdentifier();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n<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;
|
||||
}
|
||||
}
|
||||
|
||||
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), %d with existing cards untouched, %d without applications pairs%s</info>",
|
||||
$seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty,
|
||||
$apply ? '' : ' (dry run - nothing written)'
|
||||
));
|
||||
if ($apply && $seeded > 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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);
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,11 @@ class ProductShowJsonRenderer
|
||||
'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')),
|
||||
];
|
||||
|
||||
// "Why choose <product>?" cards (contract 0036-FE): null while the
|
||||
// product has no feature records - the front end then keeps parsing
|
||||
// the `applications` HTML above.
|
||||
$data['features'] = $this->featuresBand($uid, (string)$data['applications'], (string)$data['title']);
|
||||
|
||||
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
|
||||
// @graph string. The frontend injects it as <script type="application/ld+json">.
|
||||
// Supersedes the manual `structureddata` field above (kept for backwards
|
||||
@@ -267,6 +272,67 @@ class ProductShowJsonRenderer
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Why choose" card band: editor-maintained tx_vitec_product_feature
|
||||
* records in sorting order, icons resolved to urls. The heading is the
|
||||
* first <h3> of the applications HTML, falling back to the generic
|
||||
* "Why choose <title>?".
|
||||
*
|
||||
* @return array{heading:string,items:array<int,array<string,mixed>>}|null
|
||||
*/
|
||||
protected function featuresBand(int $productUid, string $applicationsHtml, string $productTitle): ?array
|
||||
{
|
||||
try {
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$rows = $pool->getConnectionForTable('tx_vitec_product_feature')->fetchAllAssociative(
|
||||
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
|
||||
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
|
||||
[$productUid]
|
||||
);
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
$icons = [];
|
||||
foreach ($pool->getConnectionForTable('sys_file_reference')->fetchAllAssociative(
|
||||
"SELECT r.uid_foreign, r.alternative, f.identifier FROM sys_file_reference r
|
||||
JOIN sys_file f ON f.uid = r.uid_local
|
||||
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature'
|
||||
AND r.fieldname = 'icon'
|
||||
AND r.uid_foreign IN (" . implode(',', array_map('intval', array_column($rows, 'uid'))) . ')
|
||||
ORDER BY r.uid_foreign, r.sorting_foreign'
|
||||
) as $iconRow) {
|
||||
$icons[(int)$iconRow['uid_foreign']] ??= [
|
||||
'url' => '/fileadmin' . (string)$iconRow['identifier'],
|
||||
'alternative' => trim((string)($iconRow['alternative'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$heading = '';
|
||||
if (preg_match('#<h3[^>]*>(.*?)</h3>#is', $applicationsHtml, $m)) {
|
||||
$heading = trim(strip_tags($m[1]));
|
||||
}
|
||||
if ($heading === '') {
|
||||
$heading = 'Why choose ' . $productTitle . '?';
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$icon = $icons[(int)$row['uid']] ?? null;
|
||||
if ($icon !== null && $icon['alternative'] === '') {
|
||||
$icon['alternative'] = (string)$row['title'];
|
||||
}
|
||||
$items[] = [
|
||||
'title' => (string)$row['title'],
|
||||
'text' => trim((string)($row['cardtext'] ?? '')),
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
return ['heading' => $heading, 'items' => $items];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the product's structured-data @graph from the already-serialized
|
||||
* product array. Exception-safe: returns '' on any failure.
|
||||
|
||||
Reference in new issue
Block a user