, 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//. * 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('' . $slug . ': page not found - skipped'); 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('' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped'); 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%s (#%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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); 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%d band(s) %s. 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 $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[] = ' no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')'; } if ($lines === []) { $output->writeln($slug . ': band complete - untouched'); return 0; } $output->writeln(sprintf("\n%s (#%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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); } } 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); } }