Add evo_megamenu_json extension and seed the megamenu

- new project-neutral extension evo_megamenu_json (Evomedien): a Megamenu
  plugin holding entries, columns, items, teaser cards and a featured slot
  as nested records; presentation settings travel with the payload so the
  front end reads behaviour instead of hard-coding it; published as
  page.10.fields.megaMenu, driven by the site settings megamenu.contentUid
  and megamenu.storagePid
- vitec:seed-megamenu fills one element from the live structure: 5 entries,
  26 columns, 95 items, 4 story cards; items without a page yet are flagged
  pending so the front end falls back to the column link
- vitec:debug-sets prints the resolved site set order
- read-only diagnostics: check_megamenu_schema.php, check_typoscript_templates.php
- docs: schema updates run via extension:setup (database:updateschema no
  longer exists in v14), and a JSON Accept header activates headless-mixed,
  which strips every set-added page field
This commit is contained in:
2026-09-11 11:44:20 +02:00
parent e9eaa62d89
commit 5bb4e374b4
31 changed files with 2251 additions and 4 deletions

View File

@@ -0,0 +1,523 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Doctrine\DBAL\ParameterType;
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\Utility\GeneralUtility;
/**
* Fills one "Megamenu" element (EXT:evo_megamenu_json) with the site's real
* structure, so the editors start from a complete menu instead of a blank
* element - roughly 5 entries, 25 columns and 90 items that would otherwise
* be clicked together by hand.
*
* This is a PROJECT-side seed, deliberately not part of the neutral
* extension: it knows where VITEC keeps its structure.
*
* Products columns = level-1 product categories, items = level-2
* (the family pages do not exist yet, so every item
* is marked `pending` and the front end falls back to
* the column link)
* Solutions columns = the 8 group categories, items = the 39 solution
* records, linked through their `detail_page`
* Markets columns = level-1 market categories, items = level-2; linked
* through the market record's `detail_page` where one
* exists, `pending` otherwise
* Insights one column of section links (pages still to be built)
* Stories teaser layout, four usecase records with their card image
*
* Column links are resolved against the page tree by title, so a renamed
* page is reported rather than silently linked to nothing. Run it once;
* everything after that is editorial work in the backend.
*
* vendor/bin/typo3 vitec:seed-megamenu --pid=42 --dry-run
* vendor/bin/typo3 vitec:seed-megamenu --pid=42
*/
#[AsCommand(
name: 'vitec:seed-megamenu',
description: 'Create a Megamenu element filled with the live product, solution and market structure'
)]
final class SeedMegamenuCommand extends Command
{
private const CTYPE = 'evomegamenujson_megamenu';
private const T_ENTRY = 'tx_evomegamenujson_entry';
private const T_COLUMN = 'tx_evomegamenujson_column';
private const T_ITEM = 'tx_evomegamenujson_item';
private const T_TEASER = 'tx_evomegamenujson_teaser';
private int $newId = 0;
/** @var array<string,array<string,mixed>> */
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(
'<comment>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.</comment>',
$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('<error>No storage page given - pass --pid=<uid of a sysfolder or page>.</error>');
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('<error>Nothing could be built - is the structure in place?</error>');
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('<comment>' . $warning . '</comment>');
}
$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>' . $error . '</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/<slug>.
// 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<int,array{uid:int,title:string,children:array<int,array{uid:int,title:string}>}> */
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<int,array<int,array<string,mixed>>>
*/
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<string,int> 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<string,mixed>|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<string,array<string,mixed>> 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));
}
}