The 2026-11-02 menu build created the 3 categories and 9 sub-categories as sysfolders (labels); every level must be a clickable page. - build-product-overviews: six sub-categories adopt the 2026-10 category pages (Joe's workbooks; content, slug and SEO kept, sitemap name as nav_title), the remaining folders become pages with generated content, Capture & Graphics gets the main column of Graphics & Multi-Display and Video Capture copied in. Pages move/convert by direct UPDATE (the redirects slug hook would rewrite the flat child slugs); three 2026-10 pages leave the menus but stay reachable. Warns about a sys_redirect on a target slug. - ProvideProductCatalog: categories[].link and groups[].link (additive), menu title = nav_title when set, only filled groups/categories count. The menu search reads both levels as pages too. - build-why-vitec-bands: band at the top of the main column; on pages with an existing "Why VITEC?" section the context's icons go onto the cards whose title matches the icon theme (16 icons on 4 pages). - No browser cache headers on the page JSON: headless enables sendCacheHeaders and TYPO3 14 caches pages for a year by default, so every /_json/ response carried max-age=~31536000. - Spec v1.32. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
14 KiB
PHP
314 lines
14 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 at the TOP of the main column, before the "Product range"
|
|
* intro and its cards (0042-FE: placed after the intro it split heading and
|
|
* cards; fix_why_band_position.php corrected the first seven pages).
|
|
* Idempotent: a page already carrying a "Why VITEC?" introparagraph gets
|
|
* no second band; unused icons of its context go onto existing cards whose
|
|
* title matches the icon theme (2026-11-05: the adopted category pages'
|
|
* workbook cards) - nothing else is touched. 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 (or, since 2026-11-05, sub-category) 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',
|
|
// sub-category pages = the adopted 2026-10 category pages (2026-11-05);
|
|
// they already carry a "Why VITEC?" section from Joe's workbooks whose
|
|
// cards ARE these icon themes -> icons go onto the existing cards
|
|
'avoip-and-kvm' => '/products/av-over-ip-and-kvm',
|
|
'control-room-platform' => '/products/control-room-platforms',
|
|
'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding',
|
|
'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage',
|
|
];
|
|
|
|
/** words ignored when an icon theme is matched against a card title */
|
|
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
|
|
|
|
/** 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'];
|
|
|
|
$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;
|
|
}
|
|
|
|
$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) {
|
|
// the page has its "Why VITEC?" section already (own band or
|
|
// a workbook import): put the icons onto matching cards
|
|
// without one; everything else stays untouched
|
|
$built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output);
|
|
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: the band first, then the existing main column
|
|
$order = array_merge(
|
|
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
|
|
$existingUids
|
|
);
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Icons onto the existing cards of a page whose title matches an icon
|
|
* theme ("Centralized management" ~ CentralManagement: every word of
|
|
* the theme is a prefix of a card-title word or vice versa, same word
|
|
* count). Only cards without an icon; they become icon_card like the
|
|
* generated bands. Returns 1 when anything was (or would be) attached.
|
|
*
|
|
* @param array<int,array{identifier:string,title:string}> $icons
|
|
*/
|
|
private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int
|
|
{
|
|
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
|
$cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
|
"SELECT c.uid, c.header FROM tt_content c
|
|
WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card'
|
|
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
|
|
AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid)
|
|
ORDER BY c.uid",
|
|
[$pageUid]
|
|
);
|
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
|
// icons already on a card of this page (e.g. the generated bands)
|
|
$used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
|
|
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
|
|
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?",
|
|
[$pageUid]
|
|
));
|
|
$datamap = [];
|
|
$lines = [];
|
|
foreach ($icons as $icon) {
|
|
if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) {
|
|
continue;
|
|
}
|
|
foreach ($cards as $k => $card) {
|
|
if (!$this->sameTheme($icon['title'], (string)$card['header'])) {
|
|
continue;
|
|
}
|
|
$lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier']));
|
|
$refId = $this->newId();
|
|
$datamap['sys_file_reference'][$refId] = [
|
|
'pid' => $pageUid,
|
|
'table_local' => 'sys_file',
|
|
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
|
|
];
|
|
$datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card'];
|
|
unset($cards[$k]);
|
|
continue 2;
|
|
}
|
|
$lines[] = ' <comment>no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')</comment>';
|
|
}
|
|
if ($lines === []) {
|
|
$output->writeln($slug . ': band complete - untouched');
|
|
return 0;
|
|
}
|
|
$output->writeln(sprintf("\n<info>%s</info> (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid));
|
|
foreach ($lines as $line) {
|
|
$output->writeln($line);
|
|
}
|
|
if ($datamap === []) {
|
|
return 0;
|
|
}
|
|
if ($apply) {
|
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
|
$dataHandler->start($datamap, []);
|
|
$dataHandler->process_datamap();
|
|
if ($dataHandler->errorLog !== []) {
|
|
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
private function sameTheme(string $iconTitle, string $cardTitle): 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
|
|
));
|
|
$a = $words($iconTitle);
|
|
$b = $words($cardTitle);
|
|
if ($a === [] || count($a) !== count($b)) {
|
|
return false;
|
|
}
|
|
foreach ($a as $i => $word) {
|
|
if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** "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);
|
|
}
|
|
}
|