) e.g. "X-Player End-Points" * [shortcut] menu shortcut e.g. "Video Wall Controllers" * * The page tree is the single source of the products menu (ProvideProduct * Catalog reads it): editors rename, reorder or move overview pages there. * A and B are sysfolders - never routed, never in navigation menus. The 20 * overview pages get flat slugs (decision Olli 2026-11-02) and, when they * are new or still empty, generated content: hero (page title), one intro * paragraph per grouping title (or "Product range"), a product card per * sub-product (record teaser, link /product/), contact pre-footer. * Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images. * * Decisions without customer confirmation (Olli: proceed, nothing deleted): * - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the * existing records (UHD2, SD14, HD4, ...) - very likely an Excel * auto-fill artefact; cards show the RECORD title * - products outside the sitemap (APEX, Arqa family, Avedia/EZ TV * End-Points, VisionSC-SDI4, ActiveSQX) stay as records, just not in * the new structure; the nine old category pages stay as well * - "Avedia Modular Chassis" exists twice in the sitemap and as two * records - each occurrence is pinned to one record by slug * * Idempotent: existing folders/pages/shortcuts are found by title (folders, * shortcuts) or slug (overview pages); content is only generated on pages * without any content element, so editorial work is never overwritten. * * vendor/bin/typo3 vitec:build-product-overviews * vendor/bin/typo3 vitec:build-product-overviews --apply */ #[AsCommand( name: 'vitec:build-product-overviews', description: 'Build the product menu structure (folders, overview pages, shortcuts) from the 2026-11 sitemap (dry-run by default)' )] final class BuildProductOverviewsCommand extends Command { private const LAYOUT = 9; private const DOKTYPE_PAGE = 1; private const DOKTYPE_SHORTCUT = 4; private const DOKTYPE_FOLDER = 254; private const COLPOS_HERO = 1; private const COLPOS_MAIN = 0; private const COLPOS_PREFOOTER = 3; /** * The agency sitemap (VITEC Product Sitemap.html, 2026-11-02). Items are * product titles; ['slug' => ...] pins a record where the title is * ambiguous or differs; ['group' => ...] is a grouping title (not a page). * Shortcuts point to a group (B) or an overview page by slug. */ private const STRUCTURE = [ ['Platforms and End-Points', [ ['IPTV Distribution & Signage Platform', [ ['page' => 'Avedia', 'slug' => 'avedia', 'items' => ['Avedia Platform']], ['page' => 'EZ TV', 'slug' => 'ez-tv', 'items' => ['EZ TV Platform']], ]], ['End-Points', [ ['page' => 'X-Player End-Points', 'slug' => 'x-player-end-points', 'items' => ['X-Player End-Points', '95-Series End-Points']], ['page' => 'X-View Integrated Displays', 'slug' => 'x-view-integrated-displays', 'items' => ['X-View Integrated Displays']], ['shortcut' => 'Video Wall Controllers', 'toGroup' => 'Video Walls'], ]], ['Control Room Platform', [ ['page' => 'Aetria', 'slug' => 'aetria', 'items' => ['Aetria WallControl', 'Aetria Command Center', 'Aetria Network Manager', 'Aetria Touch']], ['page' => 'Operator Workstations', 'slug' => 'operator-workstations', 'items' => ['Aetria Workstation', 'Aligo Workstation']], ['shortcut' => 'Aligo KVM', 'toPage' => 'aligo'], ]], ]], ['IP Video Streaming, Distribution & KVM', [ ['Encoders & Decoders', [ ['page' => 'Appliances', 'slug' => 'appliances', 'items' => [ ['MGW Diamond-H', 'HDMI encoder'], ['MGW Diamond', 'SDI encoder'], ['MGW Ace encoder', 'ultra-low latency encoder'], ['MGW Ace decoder', 'ultra-low latency decoder'], ['MGW Pico', 'portable encoder'], ]], ['page' => 'Modular Systems', 'slug' => 'modular-systems', 'items' => [ ['group' => 'VITEC OG', 'items' => [ ['MGW Diamond OG', 'SDI & composite blade encoder'], ['MGW Diamond+ OG', 'SDI blade encoder'], ['MGW Diamond-Hx OG', 'HDMI/DVI blade encoder'], ['MGW Ace Decoder OG', 'ultra-low latency blade decoder'], ['Diamond IP-OG', 'IP to IP encoder'], ]], ['group' => 'Avedia', 'items' => [ ['38-Series Encoders', 'modular blade systems'], ['39-Series Encoders', 'modular blade systems'], ['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210'], ]], ['group' => 'Blade System', 'items' => [ ['MGES7000', 'High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade'], ['MGES6000', 'Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder'], ]], ]], ['page' => 'Rugged Appliances', 'slug' => 'rugged-appliances', 'items' => ['MGW Diamond TOUGH', 'MGW Pico+ TOUGH', 'MGW Pico TOUGH']], ]], ['RF Gateway', [ ['page' => 'Avedia Modular System', 'slug' => 'avedia-modular-system', 'items' => [ '45-series RF gateways', ['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1'], ]], ]], ['IP Gateway & Transcoder', [ ['page' => 'ChannelLink IP Gateway', 'slug' => 'channellink-ip-gateway', 'items' => ['ChannelLink']], ['page' => 'PRISM Transcoder', 'slug' => 'prism-transcoder', 'items' => [ ['PRISM', '1RU rack-mount transcoder'], ['SFF PRISM', 'Small format appliance transcoder'], ]], ]], ['AVoIP & KVM', [ ['page' => 'Aligo', 'slug' => 'aligo', 'items' => ['QTX100', 'TX100', 'RX100', 'TX100 SQX']], ]], ]], ['Video Wall Controllers & Management', [ ['Video Walls', [ ['page' => 'VSN-Series Video Wall Controllers', 'slug' => 'vsn-series-video-wall-controllers', 'items' => ['VSN V3', 'VSNMicro 600', 'VSNMini 300']], ['page' => 'Video Wall Management Software', 'slug' => 'video-wall-management-software', 'items' => [ ['Aetria', null, 'aetria'], 'Wall Control 10', ]], ['page' => 'VMS Plugins & Integrations', 'slug' => 'vms-plugins-and-integrations', 'items' => ['Genetec', 'Milestone']], ['page' => 'X-Series Multi-Display Processors', 'slug' => 'x-series-multi-display-processors', 'items' => ['Fx4', 'Fx4-HDR', 'Fx4-SDI', 'Hx4']], ]], ['Capture & Graphics', [ ['page' => 'Image Graphics Cards', 'slug' => 'image-graphics-cards', 'items' => ['Image 2K', 'Image 4K', 'IQS4']], ['page' => 'Vision Video Capture Cards', 'slug' => 'vision-video-capture-cards', 'items' => [ ['group' => 'VisionSC', 'items' => ['VisionSC-UHD3', 'VisionSC-DP3', 'VisionSC-SD15', 'VisionSC-HD4+', 'VisionSC-S3', 'VisionSC-A3']], ['group' => 'VisionIO', 'desc' => 'real-time capture + overlay', 'items' => ['VisionIO-SD15', 'VisionIO-XD3']], ['group' => 'VisionAV', 'desc' => 'video + audio capture', 'items' => ['VisionAV-SDI', 'VisionAV-HD', 'VisionAV', 'VisionHD5']], ['group' => 'Vision', 'desc' => 'DVI & SDI capture cards', 'items' => ['VisionRGB-E1S', 'VisionRGB-E2S', 'VisionSD4+1S', 'VisionSD12', 'VisionSD8']], ['group' => 'VisionLC', 'desc' => 'low-profile capture cards', 'items' => ['VisionLC-HD', 'VisionLC-HD3', 'VisionLC-SDI']], ]], ['page' => 'ActiveSQX', 'slug' => 'activesqx', 'desc' => 'IP encode/decode', 'items' => ['ActiveSQX2']], ]], ]], ]; /** * Sitemap names that do not exist as records - mapped onto the existing * record by slug. The Vision "+1" names are almost certainly an Excel * auto-fill artefact (same pattern in the team tracker). */ private const NAME_TO_SLUG = [ 'VisionSC-UHD3' => 'visionsc-uhd2', 'VisionSC-DP3' => 'visionsc-dp2', 'VisionSC-SD15' => 'visionsc-sd14', 'VisionSC-S3' => 'visionsc-s2', 'VisionSC-A3' => 'visionsc-a2', 'VisionIO-SD15' => 'visionio-sd14', 'VisionIO-XD3' => 'visionio-xd2', 'VisionHD5' => 'visionhd4', 'VisionLC-HD3' => 'visionlc-hd2', ]; /** @var array>> */ private array $datamap = []; /** @var array parent id => ordered child page ids */ private array $pageOrder = []; /** @var array> page id => colPos => ordered content ids */ private array $contentOrder = []; /** @var string[] */ private array $report = []; private int $newCounter = 0; /** @var array|null */ private ?array $products = null; 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'); $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)"); if (!$root) { $output->writeln('/products not found.'); return Command::FAILURE; } $rootId = (string)(int)$root['uid']; $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' ยท /products #' . $rootId); $groupFolderIds = []; // B title => id $overviewBySlug = []; // slug => id $shortcuts = []; // [id, spec] foreach (self::STRUCTURE as [$categoryTitle, $groups]) { $categoryId = $this->folder($rootId, $categoryTitle, 'category'); foreach ($groups as [$groupTitle, $entries]) { $groupId = $this->folder($categoryId, $groupTitle, 'sub-category'); $groupFolderIds[$groupTitle] = $groupId; foreach ($entries as $entry) { if (isset($entry['shortcut'])) { $shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry]; continue; } $overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry); } } } // shortcut targets (a group folder or an overview page) foreach ($shortcuts as [$shortcutId, $spec]) { $target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null); if ($target === null) { $this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found'; continue; } $this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target; $this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0; } foreach ($this->report as $line) { $output->writeln($line); } if (!$apply) { $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($this->datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } $this->applyOrder($dataHandler->substNEWwithIDs); $output->writeln("\nWritten. Next, per overview page:"); foreach ($overviewBySlug as $slug => $id) { $output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply'); } $output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } // ---- structure ------------------------------------------------------------------- private function folder(string $parentId, string $title, string $kind): string { $existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null; if ($existing !== null) { $this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing); $this->pageOrder[$parentId][] = (string)$existing; return (string)$existing; } $id = $this->newId(); $this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0]; $this->pageOrder[$parentId][] = $id; $this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title); return $id; } private function shortcutPage(string $groupId, string $title): string { $existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null; if ($existing !== null) { $this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing); $this->pageOrder[$groupId][] = (string)$existing; return (string)$existing; } $id = $this->newId(); $this->datamap['pages'][$id] = ['pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_SHORTCUT, 'title' => $title, 'hidden' => 0]; $this->pageOrder[$groupId][] = $id; $this->report[] = sprintf(' shortcut "%s" CREATE', $title); return $id; } /** @param array $entry */ private function overviewPage(string $groupId, array $entry): string { $slug = '/products/' . $entry['slug']; $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $existing = $pages->fetchAssociative( 'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug] ); [$cards, $names] = $this->resolveItems($entry['items']); if ($existing) { $pageId = (string)(int)$existing['uid']; $this->pageOrder[$groupId][] = $pageId; $updates = []; if ((string)(int)$existing['pid'] !== $groupId) { $updates['pid'] = $this->pid($groupId); // moved into its folder } if ($updates !== []) { $this->datamap['pages'][$pageId] = $updates; } $contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content') ->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [(int)$pageId]); $this->report[] = sprintf(' page "%s" %s #%s (exists, %d elements%s)', $entry['page'], $slug, $pageId, $contentCount, $contentCount > 0 ? ' - content left untouched' : ' - content generated'); if ($contentCount === 0) { $this->content($pageId, $entry, $cards); } } else { $pageId = $this->newId(); $title = (string)$entry['page']; $description = 'VITEC ' . $title . ': ' . implode(', ', $names) . '.'; $this->datamap['pages'][$pageId] = [ 'pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0, 'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT, 'seo_title' => $title . ' | VITEC', 'description' => $description, 'og_title' => $title . ' | VITEC', 'og_description' => $description, 'abstract' => implode(', ', $names), ]; $this->pageOrder[$groupId][] = $pageId; $this->report[] = sprintf(' page "%s" %s CREATE (%d product cards)', $entry['page'], $slug, count(array_filter($cards, fn($c) => !isset($c['group'])))); $this->content($pageId, $entry, $cards); } foreach ($cards as $card) { if (isset($card['group'])) { $this->report[] = ' [' . $card['group'] . ']'; } elseif ($card['record'] === null) { $this->report[] = ' ! "' . $card['name'] . '" - NO record, card without link'; } elseif ($card['mapped']) { $this->report[] = ' "' . $card['name'] . '" -> ' . $card['record']['title'] . ' (/product/' . $card['record']['slug'] . ', name mapped)'; } } return $pageId; } /** * Flattens the sitemap items into card specs, grouping titles kept as * markers. * * @param array $items * @return array{0:array>,1:string[]} */ private function resolveItems(array $items): array { $cards = []; $names = []; foreach ($items as $item) { if (is_array($item) && isset($item['group'])) { $cards[] = ['group' => $item['group'], 'desc' => $item['desc'] ?? '']; [$sub, $subNames] = $this->resolveItems($item['items']); $cards = array_merge($cards, $sub); $names = array_merge($names, $subNames); continue; } [$name, $desc, $slug] = is_array($item) ? [$item[0], $item[1] ?? '', $item[2] ?? null] : [$item, '', null]; $slug ??= self::NAME_TO_SLUG[$name] ?? null; $record = $slug !== null ? $this->productBySlug($slug) : $this->productByTitle($name); $cards[] = ['name' => $name, 'desc' => (string)$desc, 'record' => $record, 'mapped' => isset(self::NAME_TO_SLUG[$name])]; $names[] = $record['title'] ?? $name; } return [$cards, $names]; } /** * Generated content of a new or empty overview page (layout 9 zones). * * @param array $entry * @param array> $cards */ private function content(string $pageId, array $entry, array $cards): void { $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [ 'header' => (string)$entry['page'], 'header_layout' => 1, 'bodytext' => isset($entry['desc']) ? $this->p(ucfirst((string)$entry['desc'])) : '', ]); if (!isset($cards[0]['group'])) { $this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']); } foreach ($cards as $card) { if (isset($card['group'])) { $this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', [ 'header' => (string)$card['group'], 'bodytext' => $card['desc'] !== '' ? $this->p(ucfirst((string)$card['desc'])) : '', ]); continue; } $record = $card['record']; $teaser = trim(strip_tags((string)($record['teaser'] ?? ''))); $fields = [ 'vitec_layout_variant' => 'text_only', 'header' => $record['title'] ?? $card['name'], 'bodytext' => $this->p($teaser !== '' ? $teaser : ucfirst((string)$card['desc'])), 'vitec_primary_cta_label' => 'Learn more', ]; if ($record !== null) { $fields['vitec_primary_cta'] = '/product/' . $record['slug']; } $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields); } $this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [ 'header' => 'Talk to a VITEC expert', 'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'), 'vitec_primary_cta_label' => 'Contact us', 'vitec_primary_cta' => $this->contactLink(), 'vitec_layout_variant' => 'centered', 'vitec_background_variant' => 'orange', ]); } /** @param array $fields */ private function element(string $pageId, int $colPos, string $ctype, array $fields): void { $id = $this->newId(); $this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields; $this->contentOrder[$pageId][$colPos][] = $id; } // ---- lookups ----------------------------------------------------------------------- private function childByTitle(int $parentUid, string $title, int $doktype): ?int { $uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne( 'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$parentUid, $title, $doktype] ); return $uid !== false ? (int)$uid : null; } /** @return array */ private function products(): array { return $this->products ??= GeneralUtility::makeInstance(ConnectionPool::class) ->getConnectionForTable('tx_vitec_domain_model_product') ->fetchAllAssociative('SELECT uid, title, slug, teaser FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY uid'); } /** @return array|null */ private function productBySlug(string $slug): ?array { foreach ($this->products() as $row) { if ((string)$row['slug'] === $slug) { return $row; } } return null; } /** @return array|null exact (normalized) title, first visible match by uid */ private function productByTitle(string $title): ?array { foreach ($this->products() as $row) { if ($this->norm((string)$row['title']) === $this->norm($title)) { return $row; } } return null; } private function contactLink(): string { $row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages') ->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/contact'"); return $row ? 't3://page?uid=' . (int)$row['uid'] : ''; } // ---- plumbing ---------------------------------------------------------------------- /** * DataHandler puts every NEW record with a positive pid at the TOP of * its parent/column (lesson 2026-10-07) - sorting is written explicitly * in sitemap order for pages and content alike. * * @param array $subst */ private function applyOrder(array $subst): void { $pool = GeneralUtility::makeInstance(ConnectionPool::class); $resolve = static fn(string $id): int => ctype_digit($id) ? (int)$id : (int)($subst[$id] ?? 0); foreach ($this->pageOrder as $parentId => $children) { $sorting = 0; foreach ($children as $childId) { $uid = $childId !== '' ? $resolve($childId) : 0; if ($uid > 0) { $sorting += 256; $pool->getConnectionForTable('pages')->update('pages', ['sorting' => $sorting], ['uid' => $uid]); } } } foreach ($this->contentOrder as $pageId => $columns) { foreach ($columns as $ids) { $sorting = 0; foreach ($ids as $id) { $uid = $resolve($id); if ($uid > 0) { $sorting += 256; $pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]); } } } } } private function pid(string $id): int|string { return ctype_digit($id) ? (int)$id : $id; } private function p(string $text): string { $text = trim($text); return $text === '' ? '' : '

' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '

'; } private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value))); } private function newId(): string { return 'NEWPO' . (++$this->newCounter); } }