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>
1006 lines
51 KiB
PHP
1006 lines
51 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Command;
|
|
|
|
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\Resource\StorageRepository;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Builds the product section structure of the agency's "VITEC Product
|
|
* Sitemap" / "Product Menu Prototype" (delivery 2026-11-02) in the page tree
|
|
* below /products:
|
|
*
|
|
* /products
|
|
* 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.
|
|
* 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.
|
|
*
|
|
* 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
|
|
* 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']],
|
|
]],
|
|
]],
|
|
];
|
|
|
|
/** 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
|
|
* 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<string,array<string|int,array<string,mixed>>> */
|
|
private array $datamap = [];
|
|
/** @var array<string,string[]> parent id => ordered child page ids */
|
|
private array $pageOrder = [];
|
|
/** @var array<string,array<int,string[]>> page id => colPos => ordered content ids */
|
|
private array $contentOrder = [];
|
|
/** @var string[] */
|
|
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;
|
|
|
|
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 = $this->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('<error>/products not found.</error>');
|
|
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->section($rootId, $categoryTitle, 'category');
|
|
$categoryChildren = [];
|
|
foreach ($groups as [$groupTitle, $entries]) {
|
|
$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 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 = $targetOf($spec);
|
|
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->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);
|
|
}
|
|
if (!$apply) {
|
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
|
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();
|
|
if ($dataHandler->errorLog !== []) {
|
|
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
$this->applyOrder($dataHandler->substNEWwithIDs);
|
|
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
|
|
|
|
$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('(overview pages as needed: --page=/products/<slug>), then vitec:build-why-vitec-bands and cache:flush');
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
// ---- structure -------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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, self::DOKTYPE_PAGE]) : null;
|
|
if (isset(self::ADOPT[$title])) {
|
|
return $this->adopt($parentId, $title, $existing);
|
|
}
|
|
$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->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
|
|
{
|
|
$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;
|
|
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<string,mixed> $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']);
|
|
$this->overviewNames[$entry['slug']] = implode(', ', $names);
|
|
|
|
if ($existing) {
|
|
$pageId = (string)(int)$existing['uid'];
|
|
$this->pageOrder[$groupId][] = $pageId;
|
|
$updates = [];
|
|
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;
|
|
}
|
|
$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 {
|
|
$this->backfillHeroImage((int)$pageId);
|
|
}
|
|
} 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<int,mixed> $items
|
|
* @return array{0:array<int,array<string,mixed>>,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<string,mixed> $entry
|
|
* @param array<int,array<string,mixed>> $cards
|
|
*/
|
|
private function content(string $pageId, array $entry, array $cards): void
|
|
{
|
|
$heroId = $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'])) : '',
|
|
]);
|
|
// no overview page goes live with an empty fullscreen hero (0026-FE):
|
|
// the numbered placeholder when the page already has a uid, else the
|
|
// generic wide grey one (swap the real asset in later)
|
|
$placeholder = $this->heroPlaceholder($pageId);
|
|
if ($placeholder === null) {
|
|
$this->report[] = ' ! hero: no placeholder file in fileadmin - left empty';
|
|
} elseif ($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(),
|
|
];
|
|
// content block vitec/herosection has prefixFields - the real
|
|
// column/fieldname is vitec_hero_bgimage (JSON strips the prefix)
|
|
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
|
|
$this->report[] = ' hero: attach ' . $placeholder;
|
|
} else {
|
|
$this->report[] = ' hero: would attach ' . $placeholder;
|
|
}
|
|
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<string,mixed> $fields */
|
|
private function element(string $pageId, int $colPos, string $ctype, array $fields): string
|
|
{
|
|
$id = $this->newId();
|
|
$this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields;
|
|
$this->contentOrder[$pageId][$colPos][] = $id;
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* Attaches the placeholder hero background to an EXISTING overview page
|
|
* whose hero has no image yet (0026-FE: all 20 pages rendered an empty
|
|
* fullscreen hero). A hero that carries ANY reference is never touched -
|
|
* editors swap images in the element.
|
|
*/
|
|
private function backfillHeroImage(int $pageUid): void
|
|
{
|
|
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
|
$heroUid = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
|
"SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_herosection' ORDER BY colPos, sorting LIMIT 1",
|
|
[$pageUid]
|
|
);
|
|
if ($heroUid === 0) {
|
|
return;
|
|
}
|
|
$existing = (int)$pool->getConnectionForTable('sys_file_reference')->fetchOne(
|
|
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage' AND uid_foreign = ?",
|
|
[$heroUid]
|
|
);
|
|
if ($existing > 0) {
|
|
return;
|
|
}
|
|
$placeholder = $this->heroPlaceholder((string)$pageUid);
|
|
if ($placeholder === null) {
|
|
$this->report[] = ' ! hero #' . $heroUid . ': empty and no placeholder file - left empty';
|
|
return;
|
|
}
|
|
if (!$this->apply) {
|
|
$this->report[] = ' hero #' . $heroUid . ': empty, would attach ' . $placeholder;
|
|
return;
|
|
}
|
|
$refId = $this->newId();
|
|
$this->datamap['sys_file_reference'][$refId] = [
|
|
'pid' => $pageUid,
|
|
'table_local' => 'sys_file',
|
|
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
|
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
|
'tablenames' => 'tt_content',
|
|
'uid_foreign' => $heroUid,
|
|
'fieldname' => 'vitec_hero_bgimage',
|
|
];
|
|
$this->datamap['tt_content'][$heroUid]['vitec_hero_bgimage'] = 1;
|
|
$this->report[] = ' hero #' . $heroUid . ': attach ' . $placeholder;
|
|
}
|
|
|
|
/** The hero placeholder identifier for a page: numbered when available, generic wide grey otherwise, null when neither file exists. */
|
|
private function heroPlaceholder(string $pageId): ?string
|
|
{
|
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
|
$candidates = ctype_digit($pageId) ? ['placeholders/pages/' . $pageId . '-01.png'] : [];
|
|
$candidates[] = 'placeholders/placeholder-grey-wide.png';
|
|
foreach ($candidates as $identifier) {
|
|
if ($storage->hasFile($identifier)) {
|
|
return $identifier;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- lookups -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param int[] $doktypes
|
|
* @return array{uid:int,doktype:int}|null
|
|
*/
|
|
private function childByTitle(int $parentUid, string $title, array $doktypes): ?array
|
|
{
|
|
$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 $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null;
|
|
}
|
|
|
|
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
|
|
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<string,mixed>|null */
|
|
private function productBySlug(string $slug): ?array
|
|
{
|
|
foreach ($this->products() as $row) {
|
|
if ((string)$row['slug'] === $slug) {
|
|
return $row;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string,mixed>|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<string,int> $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 === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|