Files
VITEC-website/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php
T
orascheandClaude Fable 5 46b12d4a58 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>
2026-11-04 19:55:01 +01:00

207 lines
9.0 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;
/**
* 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);
}
}