Products: categories and sub-categories become clickable pages

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>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-05 12:05:04 +01:00
1 parent 73e4298b75
commit bd7a145afa
6 files changed
+627 -79

No files matched your search

@@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* below /products:
*
* /products
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
* [folder] sub-category (B, menu label) e.g. "End-Points"
* category page (A, /products/<slug>) e.g. "Platforms and End-Points"
* sub-category page (B, /products/<slug>) e.g. "End-Points"
* overview page (C, /products/<slug>) 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, placeholder
* All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when
* new or still empty, get generated content: hero (page title, placeholder
* background - 0026-FE), one intro paragraph per grouping title (or
* "Product range"), a product card per sub-product (record teaser, link
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
* no image get the placeholder backfilled; any existing reference wins.
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
* Afterwards run vitec:wrap-cards-grid per page.
*
* A and B were sysfolders (menu labels) until 2026-11-05; Olli: every
* category and sub-category must be a clickable page. Decisions 2026-11-05:
* - ADOPT: six sub-categories ARE a 2026-10 category page (Joe's category
* workbooks; their "Why VITEC" cards match the sub-category icon sets).
* The page moves into the folder's place, keeps content, slug and SEO,
* gets the sitemap name as nav_title; the folder's children move below
* it and the emptied folder is deleted.
* - MERGE: "Capture & Graphics" gets the main column of BOTH matching
* pages (Graphics & Multi-Display, Video Capture) copied below each other.
* - the rest (3 categories, End-Points, RF Gateway) are converted folders
* with generated content: hero, "Product range", one card per child page
* (title, child list, the child's page media, link), contact pre-footer.
* - the 2026-10 pages without a place (Graphics & Multi-Display, Video
* Capture, Infrastructure & Accessories) leave the menus (nav_hide) but
* stay reachable.
* Folders become pages and pages move by direct UPDATE - not through the
* DataHandler, whose slug hook (EXT:redirects) would rewrite the flat slugs.
*
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
@@ -152,6 +169,43 @@ final class BuildProductOverviewsCommand extends Command
]],
];
/** category (A) and sub-category (B) pages built here: flat slugs like the overview pages */
private const SECTION_SLUGS = [
'Platforms and End-Points' => 'platforms-and-end-points',
'End-Points' => 'end-points',
'IP Video Streaming, Distribution & KVM' => 'ip-video-streaming-distribution-and-kvm',
'RF Gateway' => 'rf-gateway',
'Video Wall Controllers & Management' => 'video-wall-controllers-and-management',
'Capture & Graphics' => 'capture-and-graphics',
];
/**
* Sub-categories that ARE a 2026-10 category page (Joe's category
* workbooks, imported 2026-10-27 - their "Why VITEC" cards match the
* agency's sub-category icon sets 1:1). Decision Olli 2026-11-05: the
* page moves into the tree in place of the folder and keeps content,
* slug and SEO; the sitemap name becomes its nav_title (menu label).
*/
private const ADOPT = [
'IPTV Distribution & Signage Platform' => '/products/iptv-and-digital-signage',
'Control Room Platform' => '/products/control-room-platforms',
'Encoders & Decoders' => '/products/video-encoding-and-streaming',
'IP Gateway & Transcoder' => '/products/gateways-and-transcoding',
'AVoIP & KVM' => '/products/av-over-ip-and-kvm',
'Video Walls' => '/products/video-wall-and-display-processing',
];
/** sub-category built from the content of two category pages (main column of both, in order) */
private const MERGE = [
'Capture & Graphics' => ['/products/graphics-and-multi-display', '/products/video-capture'],
];
/** 2026-10 category pages without a place in the tree: stay reachable, leave the menus */
private const LEGACY_CATEGORY_SLUGS = [
'/products/graphics-and-multi-display', '/products/video-capture',
'/products/infrastructure-and-accessories',
];
/**
* Sitemap names that do not exist as records - mapped onto the existing
* record by slug. The Vision "+1" names are almost certainly an Excel
@@ -179,6 +233,24 @@ final class BuildProductOverviewsCommand extends Command
private array $report = [];
private int $newCounter = 0;
private bool $apply = false;
/** @var array<string,string> section page id => title */
private array $sectionTitles = [];
/** @var array<string,array<int,array<string,mixed>>> section page id => child specs (card order) */
private array $sectionChildren = [];
/** @var string[] section page ids that get generated content (new or empty) */
private array $sectionsToFill = [];
/** @var array<int,array<string,mixed>> folder uid => page fields, written by direct UPDATE */
private array $conversions = [];
/** @var int[] legacy category pages that leave the menus */
private array $legacyToHide = [];
/** @var array<int,array{page:int,folder:int,parent:int,navTitle:string}> adopted 2026-10 pages, applied by direct UPDATE */
private array $adoptions = [];
/** @var array<string,string> sub-category id => id of the node its children currently hang on (the old folder) */
private array $lookupParent = [];
/** @var array<string,int[]> merge target id => source page uids */
private array $merges = [];
/** @var array<string,string> overview slug => product names (card text on the sub-category page) */
private array $overviewNames = [];
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
private ?array $products = null;
@@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command
$shortcuts = []; // [id, spec]
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
$categoryId = $this->section($rootId, $categoryTitle, 'category');
$categoryChildren = [];
foreach ($groups as [$groupTitle, $entries]) {
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
$groupId = $this->section($categoryId, $groupTitle, 'sub-category');
$groupFolderIds[$groupTitle] = $groupId;
$groupChildren = [];
foreach ($entries as $entry) {
if (isset($entry['shortcut'])) {
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
$groupChildren[] = ['shortcut' => $entry];
continue;
}
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
$groupChildren[] = [
'id' => $overviewBySlug[$entry['slug']],
'title' => (string)$entry['page'],
'text' => $this->overviewNames[$entry['slug']] ?? '',
];
}
$this->sectionChildren[$groupId] = $groupChildren;
$categoryChildren[] = [
'id' => $groupId,
'title' => $groupTitle,
'text' => implode(', ', array_column(array_filter($groupChildren, static fn(array $c): bool => isset($c['id'])), 'title')),
];
}
$this->sectionChildren[$categoryId] = $categoryChildren;
}
// shortcut targets (a group folder or an overview page)
// shortcut targets (a sub-category page or an overview page); on the
// sub-category page the shortcut becomes a card linking its target
$targetOf = function (array $spec) use ($groupFolderIds, $overviewBySlug): ?string {
return isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
};
foreach ($shortcuts as [$shortcutId, $spec]) {
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
$target = $targetOf($spec);
if ($target === null) {
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
continue;
@@ -229,6 +320,25 @@ final class BuildProductOverviewsCommand extends Command
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
}
foreach ($this->sectionChildren as $sectionId => $children) {
foreach ($children as $i => $child) {
if (isset($child['shortcut'])) {
$target = $targetOf($child['shortcut']);
$this->sectionChildren[$sectionId][$i] = $target === null ? null : [
'id' => $target,
'title' => (string)$child['shortcut']['shortcut'],
'text' => (string)($child['shortcut']['toGroup'] ?? ''),
];
}
}
$this->sectionChildren[$sectionId] = array_values(array_filter($this->sectionChildren[$sectionId]));
}
$this->sectionMeta();
foreach ($this->sectionsToFill as $sectionId) {
$this->sectionContent($sectionId);
}
$this->legacyCategoryPages();
foreach ($this->report as $line) {
$output->writeln($line);
@@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command
return Command::SUCCESS;
}
// folders -> pages and legacy nav_hide by direct UPDATE (see class
// comment: the DataHandler slug hook would rewrite child slugs)
$pageConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach ($this->conversions as $uid => $fields) {
$pageConnection->update('pages', $fields, ['uid' => $uid]);
}
foreach ($this->adoptions as $adoption) {
$pageConnection->update('pages', ['pid' => $adoption['parent'], 'nav_hide' => 0, 'hidden' => 0, 'nav_title' => $adoption['navTitle']], ['uid' => $adoption['page']]);
$pageConnection->update('pages', ['pid' => $adoption['parent']], ['l10n_parent' => $adoption['page']]);
if ($adoption['folder'] > 0) {
$pageConnection->update('pages', ['pid' => $adoption['page']], ['pid' => $adoption['folder'], 'deleted' => 0]);
$pageConnection->update('pages', ['deleted' => 1], ['uid' => $adoption['folder']]);
}
}
foreach ($this->legacyToHide as $uid) {
$pageConnection->update('pages', ['nav_hide' => 1], ['uid' => $uid]);
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
@@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command
return Command::FAILURE;
}
$this->applyOrder($dataHandler->substNEWwithIDs);
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
$output->writeln("\n<info>Written.</info> Next, per overview page:");
foreach ($overviewBySlug as $slug => $id) {
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
$output->writeln("\n<info>Written.</info> Next, per page with generated cards:");
foreach ($this->sectionsToFill as $id) {
$slug = $this->conversions[(int)$id]['slug'] ?? ('/products/' . self::SECTION_SLUGS[$this->sectionTitles[$id]]);
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=' . $slug . ' --apply');
}
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
$output->writeln('(overview pages as needed: --page=/products/<slug>), then vitec:build-why-vitec-bands and cache:flush');
return Command::SUCCESS;
}
// ---- structure -------------------------------------------------------------------
private function folder(string $parentId, string $title, string $kind): string
/**
* A category (A) or sub-category (B) page, found by title below its
* parent. A sysfolder from the 2026-11-02 build is queued for conversion
* into a page; an empty page is queued for generated content.
*/
private function section(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;
$existing = ctype_digit($parentId)
? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null;
if (isset(self::ADOPT[$title])) {
return $this->adopt($parentId, $title, $existing);
}
$id = $this->newId();
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
$slug = '/products/' . self::SECTION_SLUGS[$title];
if ($existing === null) {
$id = $this->newId();
$this->datamap['pages'][$id] = [
'pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
];
$this->pageOrder[$parentId][] = $id;
$this->sectionTitles[$id] = $title;
$this->sectionsToFill[] = $id;
$this->report[] = sprintf('%s page "%s" %s CREATE', $kind, $title, $slug);
return $id;
}
$uid = $existing['uid'];
$id = (string)$uid;
$this->pageOrder[$parentId][] = $id;
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
$this->sectionTitles[$id] = $title;
if ($existing['doktype'] === self::DOKTYPE_FOLDER) {
$taken = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND uid <> ? AND sys_language_uid IN (0,-1)',
[$slug, $uid]
);
if ($taken !== false) {
$this->report[] = sprintf('%s folder "%s" #%d: <error>slug %s taken by #%d - NOT converted</error>', $kind, $title, $uid, $slug, (int)$taken);
return $id;
}
$this->conversions[$uid] = [
'doktype' => self::DOKTYPE_PAGE, 'slug' => $slug, 'layout' => self::LAYOUT,
'nav_hide' => 0, 'hidden' => 0,
];
$this->report[] = sprintf('%s folder "%s" #%d -> page %s', $kind, $title, $uid, $slug);
// a redirect on the slug wins over the page (2026-11-05: the 2026-10
// stub redirect /products/platforms-and-end-points hid the new page)
$redirect = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect')->fetchAssociative(
'SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND disabled = 0 AND source_path IN (?, ?)',
[$slug, $slug . '/']
);
if ($redirect) {
$this->report[] = sprintf(' <error>! redirect #%d %s -> %s overrides this page - disable it (Redirects module)</error>', (int)$redirect['uid'], $slug, $redirect['target']);
}
} else {
$this->report[] = sprintf('%s page "%s" #%d (exists)', $kind, $title, $uid);
}
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$uid]);
if ($contentCount === 0) {
$this->sectionsToFill[] = $id;
} else {
$this->report[] = sprintf(' (%d elements - content left untouched)', $contentCount);
}
if (isset(self::MERGE[$title])) {
$this->merges[$id] = [];
foreach (self::MERGE[$title] as $sourceSlug) {
$source = $this->pageBySlug($sourceSlug);
if ($source === null) {
$this->report[] = ' ! merge source ' . $sourceSlug . ' not found';
continue;
}
$this->merges[$id][] = $source;
}
}
return $id;
}
/**
* A 2026-10 category page takes the sub-category's place (ADOPT): it
* moves below the category at the folder's position, the folder's
* children move below it, the emptied folder is deleted. Content, slug
* and SEO of the page stay; nav_title = sitemap name.
*
* @param array{uid:int,doktype:int}|null $folder
*/
private function adopt(string $parentId, string $title, ?array $folder): string
{
$page = $this->pageBySlug(self::ADOPT[$title]);
if ($page === null) {
$this->report[] = sprintf('sub-category "%s": <error>page %s not found - NOT adopted</error>', $title, self::ADOPT[$title]);
return $folder !== null ? (string)$folder['uid'] : $this->newId();
}
$id = (string)$page;
$this->pageOrder[$parentId][] = $id;
$this->lookupParent[$id] = $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? (string)$folder['uid'] : $id;
if (!ctype_digit($parentId)) {
$this->report[] = sprintf('sub-category "%s": <error>category not built yet - run again</error>', $title);
return $id;
}
$this->adoptions[] = [
'page' => $page,
'folder' => $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? $folder['uid'] : 0,
'parent' => (int)$parentId,
'navTitle' => $title,
];
$this->report[] = sprintf(
'sub-category "%s" = %s #%d (content, slug, SEO stay; menu title "%s")%s',
$title, self::ADOPT[$title], $page, $title,
$folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? '; folder #' . $folder['uid'] . ' -> children move, folder deleted' : ''
);
return $id;
}
private function pageBySlug(string $slug): ?int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$slug]
);
return $uid !== false ? (int)$uid : null;
}
/** SEO/OG title, description and abstract of the section pages, from their children. */
private function sectionMeta(): void
{
foreach ($this->sectionTitles as $id => $title) {
$names = array_column($this->sectionChildren[$id] ?? [], 'title');
$description = 'VITEC ' . $title . ($names !== [] ? ': ' . implode(', ', $names) : '') . '.';
$meta = [
'seo_title' => $title . ' | VITEC', 'description' => $description,
'og_title' => $title . ' | VITEC', 'og_description' => $description,
'abstract' => implode(', ', $names),
];
if (isset($this->conversions[(int)$id])) {
$this->conversions[(int)$id] += $meta;
} elseif (isset($this->datamap['pages'][$id])) {
$this->datamap['pages'][$id] += $meta;
}
}
}
/**
* Generated content of a new or empty category / sub-category page:
* hero, "Product range", one card per child page (title, child list as
* text, the child's first page media as image, link), contact pre-footer.
*/
private function sectionContent(string $pageId): void
{
$title = $this->sectionTitles[$pageId];
$this->report[] = sprintf('content for "%s" (%s):', $title, ctype_digit($pageId) ? '#' . $pageId : 'new page');
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', ['header' => $title, 'header_layout' => 1, 'bodytext' => '']);
$placeholder = $this->heroPlaceholder($pageId);
if ($placeholder !== null) {
$this->report[] = ' hero: ' . ($this->apply ? 'attach ' : 'would attach ') . $placeholder;
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $this->pid($pageId),
'table_local' => 'sys_file',
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
->getDefaultStorage()->getFile($placeholder)->getUid(),
];
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
}
}
if (isset($this->merges[$pageId])) {
// MERGE target: the main column is copied from the source pages after the run
$this->report[] = ' main column: copied from ' . count($this->merges[$pageId]) . ' page(s) #' . implode(', #', $this->merges[$pageId]) . ' (after the write)';
} else {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
}
foreach (isset($this->merges[$pageId]) ? [] : ($this->sectionChildren[$pageId] ?? []) as $child) {
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => (string)$child['title'],
'bodytext' => $this->p((string)$child['text']),
'vitec_primary_cta_label' => 'Learn more',
'vitec_primary_cta' => ctype_digit((string)$child['id']) ? 't3://page?uid=' . $child['id'] : '',
];
$fileUid = ctype_digit((string)$child['id']) ? $this->pageMediaFile((int)$child['id']) : 0;
$cardId = $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
if ($fileUid > 0) {
$this->datamap['tt_content'][$cardId]['vitec_layout_variant'] = 'vertical';
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = ['pid' => $this->pid($pageId), 'table_local' => 'sys_file', 'uid_local' => $fileUid];
$this->datamap['tt_content'][$cardId]['vitec_image'] = $refId;
}
}
$this->report[] = sprintf(' card "%s"%s%s', $child['title'], $fields['vitec_primary_cta'] !== '' ? ' -> ' . $fields['vitec_primary_cta'] : ' (link after the first run)', $fileUid > 0 ? ' + page image' : '');
}
$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',
]);
}
/** The 2026-10 category pages below /products leave the menus (nav_hide), stay reachable. */
private function legacyCategoryPages(): void
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach (self::LEGACY_CATEGORY_SLUGS as $slug) {
$row = $pages->fetchAssociative('SELECT uid, nav_hide FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
if (!$row) {
$this->report[] = 'legacy ' . $slug . ': not found';
} elseif ((int)$row['nav_hide'] === 0) {
$this->legacyToHide[] = (int)$row['uid'];
$this->report[] = sprintf('legacy %s #%d: hide in menus (stays reachable)', $slug, (int)$row['uid']);
}
}
}
/**
* MERGE: copies the visible top-level main-column elements of the source
* pages, in order, below each other onto the (new, previously empty)
* target page. One DataHandler copy per element, each placed after the
* previous copy; EXT:container copies a grid's children along. Re-runs
* do nothing - the target is only filled while it is empty.
*
* @param array<string,int> $subst
*/
private function copyMergedContent(array $subst, OutputInterface $output): void
{
$content = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
foreach ($this->merges as $targetId => $sources) {
$targetId = (string)$targetId;
if (!in_array($targetId, $this->sectionsToFill, true)) {
continue;
}
$targetUid = ctype_digit($targetId) ? (int)$targetId : (int)($subst[$targetId] ?? 0);
if ($targetUid === 0) {
continue;
}
$after = 0;
$copied = 0;
foreach ($sources as $sourceUid) {
$elements = $content->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0) ORDER BY sorting, uid',
[$sourceUid, self::COLPOS_MAIN]
);
foreach ($elements as $elementUid) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], ['tt_content' => [(int)$elementUid => ['copy' => $after > 0 ? -$after : $targetUid]]]);
$dataHandler->process_cmdmap();
$newUid = (int)($dataHandler->copyMappingArray_merged['tt_content'][(int)$elementUid] ?? 0);
if ($newUid > 0) {
$after = $newUid;
$copied++;
}
}
}
$output->writeln(sprintf('merged %d element(s) onto #%d', $copied, $targetUid));
}
}
/** sys_file uid of a page's first media reference, 0 when none */
private function pageMediaFile(int $pageUid): int
{
return (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'pages' AND fieldname = 'media' AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1",
[$pageUid]
);
}
private function shortcutPage(string $groupId, string $title): string
{
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
$lookup = $this->lookupParent[$groupId] ?? $groupId; // an adopted page's shortcuts still hang on the old folder
$existing = ctype_digit($lookup) ? ($this->childByTitle((int)$lookup, $title, [self::DOKTYPE_SHORTCUT])['uid'] ?? null) : null;
if ($existing !== null) {
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
$this->pageOrder[$groupId][] = (string)$existing;
@@ -296,13 +680,14 @@ final class BuildProductOverviewsCommand extends Command
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
);
[$cards, $names] = $this->resolveItems($entry['items']);
$this->overviewNames[$entry['slug']] = implode(', ', $names);
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 (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) {
$updates['pid'] = $this->pid($groupId); // moved into its sub-category
}
if ($updates !== []) {
$this->datamap['pages'][$pageId] = $updates;
@@ -509,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command
// ---- lookups -----------------------------------------------------------------------
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
/**
* @param int[] $doktypes
* @return array{uid:int,doktype:int}|null
*/
private function childByTitle(int $parentUid, string $title, array $doktypes): ?array
{
$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]
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ')
AND sys_language_uid IN (0,-1) ORDER BY uid',
[$parentUid, $title]
);
return $uid !== false ? (int)$uid : null;
return $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null;
}
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */