> */ private array $datamap = []; /** @var string[] */ private array $warnings = []; protected function configure(): void { $this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage page/folder for the element and its records'); $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); $this->addOption('force', null, InputOption::VALUE_NONE, 'Seed even though a megamenu element already exists'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dryRun = (bool)$input->getOption('dry-run'); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); $existing = $connection->fetchAssociative( "SELECT uid, pid FROM tt_content WHERE deleted = 0 AND CType = ? ORDER BY uid LIMIT 1", [self::CTYPE] ); if ($existing !== false && !$input->getOption('force')) { $output->writeln(sprintf( 'A megamenu element already exists (uid %d on pid %d). Edit it in the backend, or re-run with --force to add a second one.', $existing['uid'], $existing['pid'] )); return Command::SUCCESS; } $pid = (int)($input->getOption('pid') ?? 0); if ($pid <= 0) { $pid = (int)($existing['pid'] ?? 0); } if ($pid <= 0) { $output->writeln('No storage page given - pass --pid=.'); return Command::FAILURE; } $contentId = $this->id(); $entryIds = []; foreach ([ $this->buildProducts($pid, $output), $this->buildSolutions($pid, $output), $this->buildMarkets($pid, $output), $this->buildInsights($pid, $output), $this->buildStories($pid, $output), ] as $entryId) { if ($entryId !== null) { $entryIds[] = $entryId; } } if ($entryIds === []) { $output->writeln('Nothing could be built - is the structure in place?'); return Command::FAILURE; } $this->datamap['tt_content'][$contentId] = [ 'pid' => $pid, 'CType' => self::CTYPE, 'header' => 'Main navigation', 'tx_evomegamenujson_entries' => implode(',', $entryIds), ]; foreach ($this->warnings as $warning) { $output->writeln('' . $warning . ''); } $output->writeln(sprintf("\n%d entries, %d columns, %d items, %d teaser cards on pid %d.", count($entryIds), count($this->datamap[self::T_COLUMN] ?? []), count($this->datamap[self::T_ITEM] ?? []), count($this->datamap[self::T_TEASER] ?? []), $pid )); if ($dryRun) { $output->writeln('DRY RUN - nothing written.'); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($this->datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { foreach ($dataHandler->errorLog as $error) { $output->writeln('' . $error . ''); } return Command::FAILURE; } $uid = (int)($dataHandler->substNEWwithIDs[$contentId] ?? 0); $output->writeln(sprintf('Megamenu element created: uid %d.', $uid)); $output->writeln(sprintf('Point the site setting megamenu.contentUid at %d, then: vendor/bin/typo3 cache:flush', $uid)); return Command::SUCCESS; } // ------------------------------------------------------------- entries private function buildProducts(int $pid, OutputInterface $output): ?string { $root = $this->pageBySlug('/products'); $categories = $this->categoryTree('Product'); if ($categories === []) { $this->warnings[] = 'Products skipped: no category tree below "Product".'; return null; } $pages = $root ? $this->childPagesByTitle((int)$root['uid']) : []; $columnIds = []; foreach ($categories as $group) { $page = $pages[$this->norm($group['title'])] ?? null; if ($page === null) { $this->warnings[] = 'Products: no page found for category "' . $group['title'] . '".'; } $itemIds = []; foreach ($group['children'] as $family) { // The family pages are still to be built (decision 2026-09-04: // product families are content pages) - mark them pending so // the front end falls back to the category link. $itemIds[] = $this->item($pid, $family['title'], '', true); } $columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds); } $output->writeln(sprintf('Products: %d columns, %d items (all pending - family pages missing)', count($columnIds), count($this->datamap[self::T_ITEM] ?? []))); return $this->entry($pid, 'Products', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds); } private function buildSolutions(int $pid, OutputInterface $output): ?string { $root = $this->pageBySlug('/solutions'); $categories = $this->categoryTree('Solution'); if ($categories === []) { $this->warnings[] = 'Solutions skipped: no category tree below "Solution".'; return null; } $pages = $root ? $this->childPagesByTitle((int)$root['uid']) : []; $byCategory = $this->recordsByCategory('tx_vitec_domain_model_solution'); $columnIds = []; $itemCount = 0; foreach ($categories as $group) { $records = $byCategory[(int)$group['uid']] ?? []; if ($records === []) { continue; } $page = $pages[$this->norm($group['title'])] ?? null; $itemIds = []; foreach ($records as $record) { $detail = (int)$record['detail_page']; $itemIds[] = $this->item($pid, (string)$record['title'], $detail > 0 ? $this->pageLink($detail) : '', $detail === 0); $itemCount++; } $columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds); } $output->writeln(sprintf('Solutions: %d columns, %d items', count($columnIds), $itemCount)); return $this->entry($pid, 'Solutions', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds); } private function buildMarkets(int $pid, OutputInterface $output): ?string { $root = $this->pageBySlug('/markets'); $categories = $this->categoryTree('Market'); if ($categories === []) { $this->warnings[] = 'Markets skipped: no category tree below "Market".'; return null; } $pages = $root ? $this->childPagesByTitle((int)$root['uid']) : []; $detailByTitle = $this->detailPageByTitle('tx_vitec_domain_model_market'); $columnIds = []; $pending = 0; $itemCount = 0; foreach ($categories as $group) { $page = $pages[$this->norm($group['title'])] ?? null; $itemIds = []; foreach ($group['children'] as $market) { $detail = (int)($detailByTitle[$this->norm($market['title'])] ?? 0); $itemIds[] = $this->item($pid, $market['title'], $detail > 0 ? $this->pageLink($detail) : '', $detail === 0); $pending += $detail === 0 ? 1 : 0; $itemCount++; } $columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds); } $output->writeln(sprintf('Markets: %d columns, %d items (%d pending - no detail page)', count($columnIds), $itemCount, $pending)); return $this->entry($pid, 'Markets', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds); } private function buildInsights(int $pid, OutputInterface $output): ?string { $root = $this->pageBySlug('/insights'); if ($root === null) { $this->warnings[] = 'Insights skipped: no page /insights.'; return null; } $pages = $this->childPagesByTitle((int)$root['uid']); $sections = ['News & Articles', 'Events & Webinars', 'Blog']; $itemIds = []; foreach ($sections as $section) { $page = $pages[$this->norm($section)] ?? null; $itemIds[] = $this->item($pid, $section, $page ? $this->pageLink((int)$page['uid']) : '', $page === null); } $columnId = $this->column($pid, 'Sections', $this->pageLink((int)$root['uid']), $itemIds); $output->writeln('Insights: 1 column, ' . count($itemIds) . ' section links (teaser cards stay editorial)'); return $this->entry($pid, 'Insights', $this->pageLink((int)$root['uid']), 'columns', [$columnId]); } private function buildStories(int $pid, OutputInterface $output): ?string { $root = $this->pageBySlug('/success-stories'); if ($root === null) { $this->warnings[] = 'Success Stories skipped: no page /success-stories.'; return null; } $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_usecase'); $stories = $connection->fetchAllAssociative( "SELECT uid, title, teaser, slug, detail_page FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND hidden = 0 AND slug <> '' ORDER BY tstamp DESC LIMIT 4" ); // Stories have no `detail_page` of their own - one detail page serves // them all and SuccessStoryPathRewrite builds /success-stories/. // The seed writes that path, the same URL the list plugin emits. $basePath = '/' . trim((string)$root['slug'], '/'); $teaserIds = []; $withoutLink = 0; foreach ($stories as $story) { $detail = (int)$story['detail_page']; $slug = trim((string)$story['slug'], '/'); $link = $detail > 0 ? $this->pageLink($detail) : ($slug !== '' ? $basePath . '/' . $slug : ''); $withoutLink += $link === '' ? 1 : 0; $teaserIds[] = $this->teaser( $pid, 'Success Story', (string)$story['title'], $this->shorten((string)($story['teaser'] ?? '')), $link, $this->fileOf('tx_vitec_domain_model_usecase', 'card_image', (int)$story['uid']) ); } if ($withoutLink > 0) { $this->warnings[] = 'Success Stories: ' . $withoutLink . ' card(s) without link - story has neither detail page nor slug.'; } $output->writeln('Success Stories: teaser layout, ' . count($teaserIds) . ' cards'); return $this->entry($pid, 'Success Stories', $this->pageLink((int)$root['uid']), 'teasers', [], $teaserIds); } // -------------------------------------------------------- record makers /** @param string[] $columnIds @param string[] $teaserIds */ private function entry(int $pid, string $title, string $link, string $layout, array $columnIds, array $teaserIds = []): string { $id = $this->id(); $record = [ 'pid' => $pid, 'title' => $title, 'link' => $link, 'layout' => $layout, ]; if ($columnIds !== []) { $record['menu_columns'] = implode(',', $columnIds); } if ($teaserIds !== []) { $record['menu_teasers'] = implode(',', $teaserIds); } $this->datamap[self::T_ENTRY][$id] = $record; return $id; } /** @param string[] $itemIds */ private function column(int $pid, string $title, string $link, array $itemIds): string { $id = $this->id(); $this->datamap[self::T_COLUMN][$id] = [ 'pid' => $pid, 'title' => $title, 'link' => $link, 'menu_items' => implode(',', $itemIds), ]; return $id; } private function item(int $pid, string $title, string $link, bool $pending): string { $id = $this->id(); $this->datamap[self::T_ITEM][$id] = [ 'pid' => $pid, 'title' => $title, 'link' => $link, 'pending' => $pending ? 1 : 0, ]; return $id; } private function teaser(int $pid, string $kicker, string $title, string $text, string $link, int $fileUid): string { $id = $this->id(); $record = [ 'pid' => $pid, 'kicker' => $kicker, 'title' => $title, 'teasertext' => $text, 'link' => $link, ]; if ($fileUid > 0) { $referenceId = $this->id(); $this->datamap['sys_file_reference'][$referenceId] = [ 'pid' => $pid, 'table_local' => 'sys_file', 'uid_local' => $fileUid, 'tablenames' => self::T_TEASER, 'fieldname' => 'image', 'uid_foreign' => $id, ]; $record['image'] = $referenceId; } $this->datamap[self::T_TEASER][$id] = $record; return $id; } private function id(): string { return 'NEW' . str_pad((string)++$this->newId, 4, '0', STR_PAD_LEFT); } // ------------------------------------------------------------ lookups /** @return array}> */ private function categoryTree(string $rootTitle): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category'); $rootUid = (int)$connection->fetchOne( 'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = ?', [$rootTitle] ); if ($rootUid <= 0) { return []; } $tree = []; foreach ($connection->fetchAllAssociative( 'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [$rootUid] ) as $group) { $children = []; foreach ($connection->fetchAllAssociative( 'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [(int)$group['uid']] ) as $child) { $children[] = ['uid' => (int)$child['uid'], 'title' => (string)$child['title']]; } $tree[] = ['uid' => (int)$group['uid'], 'title' => (string)$group['title'], 'children' => $children]; } return $tree; } /** * Records of a table grouped by the category they hang on, sorted by * their position in the category (uid order = creation order). * * @return array>> */ private function recordsByCategory(string $table): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); $rows = $connection->fetchAllAssociative( "SELECT r.uid, r.title, r.detail_page, mm.uid_local AS category FROM $table r JOIN sys_category_record_mm mm ON mm.uid_foreign = r.uid AND mm.tablenames = ? AND mm.fieldname = 'categories' WHERE r.deleted = 0 AND r.hidden = 0 ORDER BY r.uid", [$table] ); $grouped = []; foreach ($rows as $row) { $grouped[(int)$row['category']][] = $row; } return $grouped; } /** @return array normalized record title => detail_page uid */ private function detailPageByTitle(string $table): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); $map = []; foreach ($connection->fetchAllAssociative( "SELECT title, detail_page FROM $table WHERE deleted = 0 AND hidden = 0" ) as $row) { $detail = (int)$row['detail_page']; if ($detail > 0) { $map[$this->norm((string)$row['title'])] = $detail; } } return $map; } /** @return array|null */ private function pageBySlug(string $slug): ?array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $row = $connection->fetchAssociative( 'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid LIMIT 1', [$slug] ); if ($row === false) { $this->warnings[] = 'No page with slug ' . $slug . '.'; return null; } return $row; } /** @return array> normalized title => page row */ private function childPagesByTitle(int $parentUid): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $map = []; foreach ($connection->fetchAllAssociative( 'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND doktype = 1 AND sys_language_uid IN (0,-1) ORDER BY sorting', [$parentUid] ) as $row) { $map[$this->norm((string)$row['title'])] = $row; } return $map; } /** uid of the first file behind a FAL field, 0 when none. */ private function fileOf(string $table, string $field, int $uid): int { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); $fileUid = $connection->fetchOne( "SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND tablenames = ? AND fieldname = ? AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1", [$table, $field, $uid] ); return is_numeric($fileUid) ? (int)$fileUid : 0; } private function pageLink(int $pageUid): string { return 't3://page?uid=' . $pageUid; } private function shorten(string $text, int $max = 120): string { $flat = trim((string)preg_replace('/\s+/u', ' ', strip_tags($text))); return mb_strlen($flat) > $max ? mb_substr($flat, 0, $max - 1) . '…' : $flat; } private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', mb_strtolower($value)); } }