The vitec/herosection content block has prefixFields - the real column and sys_file_reference fieldname is vitec_hero_bgimage; the JSON merely strips the prefix. The first apply wrote the 20 references with the bare name, so they existed but never rendered (no herosection anywhere had an image, so there was no working example to catch it against). - BuildProductOverviews: attach/backfill/skip-check now use vitec_hero_bgimage - new migration fix_overview_hero_refs.php: renames the existing 20 references in place (no re-attach) and sets the counter column Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
616 lines
30 KiB
PHP
616 lines
30 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
|
|
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
|
|
* [folder] sub-category (B, menu label) 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
|
|
* 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.
|
|
*
|
|
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
|
|
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
|
|
* existing records (UHD2, SD14, HD4, ...) - very likely an Excel
|
|
* auto-fill artefact; cards show the RECORD title
|
|
* - products outside the sitemap (APEX, Arqa family, Avedia/EZ TV
|
|
* End-Points, VisionSC-SDI4, ActiveSQX) stay as records, just not in
|
|
* the new structure; the nine old category pages stay as well
|
|
* - "Avedia Modular Chassis" exists twice in the sitemap and as two
|
|
* records - each occurrence is pinned to one record by slug
|
|
*
|
|
* Idempotent: existing folders/pages/shortcuts are found by title (folders,
|
|
* shortcuts) or slug (overview pages); content is only generated on pages
|
|
* without any content element, so editorial work is never overwritten.
|
|
*
|
|
* vendor/bin/typo3 vitec:build-product-overviews
|
|
* vendor/bin/typo3 vitec:build-product-overviews --apply
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:build-product-overviews',
|
|
description: 'Build the product menu structure (folders, overview pages, shortcuts) from the 2026-11 sitemap (dry-run by default)'
|
|
)]
|
|
final class BuildProductOverviewsCommand extends Command
|
|
{
|
|
private const LAYOUT = 9;
|
|
private const DOKTYPE_PAGE = 1;
|
|
private const DOKTYPE_SHORTCUT = 4;
|
|
private const DOKTYPE_FOLDER = 254;
|
|
private const COLPOS_HERO = 1;
|
|
private const COLPOS_MAIN = 0;
|
|
private const COLPOS_PREFOOTER = 3;
|
|
|
|
/**
|
|
* The agency sitemap (VITEC Product Sitemap.html, 2026-11-02). Items are
|
|
* product titles; ['slug' => ...] pins a record where the title is
|
|
* ambiguous or differs; ['group' => ...] is a grouping title (not a page).
|
|
* Shortcuts point to a group (B) or an overview page by slug.
|
|
*/
|
|
private const STRUCTURE = [
|
|
['Platforms and End-Points', [
|
|
['IPTV Distribution & Signage Platform', [
|
|
['page' => 'Avedia', 'slug' => 'avedia', 'items' => ['Avedia Platform']],
|
|
['page' => 'EZ TV', 'slug' => 'ez-tv', 'items' => ['EZ TV Platform']],
|
|
]],
|
|
['End-Points', [
|
|
['page' => 'X-Player End-Points', 'slug' => 'x-player-end-points', 'items' => ['X-Player End-Points', '95-Series End-Points']],
|
|
['page' => 'X-View Integrated Displays', 'slug' => 'x-view-integrated-displays', 'items' => ['X-View Integrated Displays']],
|
|
['shortcut' => 'Video Wall Controllers', 'toGroup' => 'Video Walls'],
|
|
]],
|
|
['Control Room Platform', [
|
|
['page' => 'Aetria', 'slug' => 'aetria', 'items' => ['Aetria WallControl', 'Aetria Command Center', 'Aetria Network Manager', 'Aetria Touch']],
|
|
['page' => 'Operator Workstations', 'slug' => 'operator-workstations', 'items' => ['Aetria Workstation', 'Aligo Workstation']],
|
|
['shortcut' => 'Aligo KVM', 'toPage' => 'aligo'],
|
|
]],
|
|
]],
|
|
['IP Video Streaming, Distribution & KVM', [
|
|
['Encoders & Decoders', [
|
|
['page' => 'Appliances', 'slug' => 'appliances', 'items' => [
|
|
['MGW Diamond-H', 'HDMI encoder'], ['MGW Diamond', 'SDI encoder'], ['MGW Ace encoder', 'ultra-low latency encoder'],
|
|
['MGW Ace decoder', 'ultra-low latency decoder'], ['MGW Pico', 'portable encoder'],
|
|
]],
|
|
['page' => 'Modular Systems', 'slug' => 'modular-systems', 'items' => [
|
|
['group' => 'VITEC OG', 'items' => [
|
|
['MGW Diamond OG', 'SDI & composite blade encoder'], ['MGW Diamond+ OG', 'SDI blade encoder'],
|
|
['MGW Diamond-Hx OG', 'HDMI/DVI blade encoder'], ['MGW Ace Decoder OG', 'ultra-low latency blade decoder'],
|
|
['Diamond IP-OG', 'IP to IP encoder'],
|
|
]],
|
|
['group' => 'Avedia', 'items' => [
|
|
['38-Series Encoders', 'modular blade systems'], ['39-Series Encoders', 'modular blade systems'],
|
|
['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210'],
|
|
]],
|
|
['group' => 'Blade System', 'items' => [
|
|
['MGES7000', 'High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade'],
|
|
['MGES6000', 'Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder'],
|
|
]],
|
|
]],
|
|
['page' => 'Rugged Appliances', 'slug' => 'rugged-appliances', 'items' => ['MGW Diamond TOUGH', 'MGW Pico+ TOUGH', 'MGW Pico TOUGH']],
|
|
]],
|
|
['RF Gateway', [
|
|
['page' => 'Avedia Modular System', 'slug' => 'avedia-modular-system', 'items' => [
|
|
'45-series RF gateways', ['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1'],
|
|
]],
|
|
]],
|
|
['IP Gateway & Transcoder', [
|
|
['page' => 'ChannelLink IP Gateway', 'slug' => 'channellink-ip-gateway', 'items' => ['ChannelLink']],
|
|
['page' => 'PRISM Transcoder', 'slug' => 'prism-transcoder', 'items' => [
|
|
['PRISM', '1RU rack-mount transcoder'], ['SFF PRISM', 'Small format appliance transcoder'],
|
|
]],
|
|
]],
|
|
['AVoIP & KVM', [
|
|
['page' => 'Aligo', 'slug' => 'aligo', 'items' => ['QTX100', 'TX100', 'RX100', 'TX100 SQX']],
|
|
]],
|
|
]],
|
|
['Video Wall Controllers & Management', [
|
|
['Video Walls', [
|
|
['page' => 'VSN-Series Video Wall Controllers', 'slug' => 'vsn-series-video-wall-controllers', 'items' => ['VSN V3', 'VSNMicro 600', 'VSNMini 300']],
|
|
['page' => 'Video Wall Management Software', 'slug' => 'video-wall-management-software', 'items' => [
|
|
['Aetria', null, 'aetria'], 'Wall Control 10',
|
|
]],
|
|
['page' => 'VMS Plugins & Integrations', 'slug' => 'vms-plugins-and-integrations', 'items' => ['Genetec', 'Milestone']],
|
|
['page' => 'X-Series Multi-Display Processors', 'slug' => 'x-series-multi-display-processors', 'items' => ['Fx4', 'Fx4-HDR', 'Fx4-SDI', 'Hx4']],
|
|
]],
|
|
['Capture & Graphics', [
|
|
['page' => 'Image Graphics Cards', 'slug' => 'image-graphics-cards', 'items' => ['Image 2K', 'Image 4K', 'IQS4']],
|
|
['page' => 'Vision Video Capture Cards', 'slug' => 'vision-video-capture-cards', 'items' => [
|
|
['group' => 'VisionSC', 'items' => ['VisionSC-UHD3', 'VisionSC-DP3', 'VisionSC-SD15', 'VisionSC-HD4+', 'VisionSC-S3', 'VisionSC-A3']],
|
|
['group' => 'VisionIO', 'desc' => 'real-time capture + overlay', 'items' => ['VisionIO-SD15', 'VisionIO-XD3']],
|
|
['group' => 'VisionAV', 'desc' => 'video + audio capture', 'items' => ['VisionAV-SDI', 'VisionAV-HD', 'VisionAV', 'VisionHD5']],
|
|
['group' => 'Vision', 'desc' => 'DVI & SDI capture cards', 'items' => ['VisionRGB-E1S', 'VisionRGB-E2S', 'VisionSD4+1S', 'VisionSD12', 'VisionSD8']],
|
|
['group' => 'VisionLC', 'desc' => 'low-profile capture cards', 'items' => ['VisionLC-HD', 'VisionLC-HD3', 'VisionLC-SDI']],
|
|
]],
|
|
['page' => 'ActiveSQX', 'slug' => 'activesqx', 'desc' => 'IP encode/decode', 'items' => ['ActiveSQX2']],
|
|
]],
|
|
]],
|
|
];
|
|
|
|
/**
|
|
* Sitemap names that do not exist as records - mapped onto the existing
|
|
* record by slug. The Vision "+1" names are almost certainly an Excel
|
|
* auto-fill artefact (same pattern in the team tracker).
|
|
*/
|
|
private const NAME_TO_SLUG = [
|
|
'VisionSC-UHD3' => 'visionsc-uhd2',
|
|
'VisionSC-DP3' => 'visionsc-dp2',
|
|
'VisionSC-SD15' => 'visionsc-sd14',
|
|
'VisionSC-S3' => 'visionsc-s2',
|
|
'VisionSC-A3' => 'visionsc-a2',
|
|
'VisionIO-SD15' => 'visionio-sd14',
|
|
'VisionIO-XD3' => 'visionio-xd2',
|
|
'VisionHD5' => 'visionhd4',
|
|
'VisionLC-HD3' => 'visionlc-hd2',
|
|
];
|
|
|
|
/** @var array<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<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->folder($rootId, $categoryTitle, 'category');
|
|
foreach ($groups as [$groupTitle, $entries]) {
|
|
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
|
|
$groupFolderIds[$groupTitle] = $groupId;
|
|
foreach ($entries as $entry) {
|
|
if (isset($entry['shortcut'])) {
|
|
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
|
|
continue;
|
|
}
|
|
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// shortcut targets (a group folder or an overview page)
|
|
foreach ($shortcuts as [$shortcutId, $spec]) {
|
|
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
|
|
if ($target === null) {
|
|
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
|
|
continue;
|
|
}
|
|
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
|
|
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
|
|
}
|
|
|
|
foreach ($this->report as $line) {
|
|
$output->writeln($line);
|
|
}
|
|
if (!$apply) {
|
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
|
$dataHandler->start($this->datamap, []);
|
|
$dataHandler->process_datamap();
|
|
if ($dataHandler->errorLog !== []) {
|
|
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
$this->applyOrder($dataHandler->substNEWwithIDs);
|
|
|
|
$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('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
// ---- structure -------------------------------------------------------------------
|
|
|
|
private function folder(string $parentId, string $title, string $kind): string
|
|
{
|
|
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null;
|
|
if ($existing !== null) {
|
|
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
|
|
$this->pageOrder[$parentId][] = (string)$existing;
|
|
return (string)$existing;
|
|
}
|
|
$id = $this->newId();
|
|
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
|
|
$this->pageOrder[$parentId][] = $id;
|
|
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
|
|
return $id;
|
|
}
|
|
|
|
private function shortcutPage(string $groupId, string $title): string
|
|
{
|
|
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
|
|
if ($existing !== null) {
|
|
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
|
|
$this->pageOrder[$groupId][] = (string)$existing;
|
|
return (string)$existing;
|
|
}
|
|
$id = $this->newId();
|
|
$this->datamap['pages'][$id] = ['pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_SHORTCUT, 'title' => $title, 'hidden' => 0];
|
|
$this->pageOrder[$groupId][] = $id;
|
|
$this->report[] = sprintf(' shortcut "%s" CREATE', $title);
|
|
return $id;
|
|
}
|
|
|
|
/** @param array<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']);
|
|
|
|
if ($existing) {
|
|
$pageId = (string)(int)$existing['uid'];
|
|
$this->pageOrder[$groupId][] = $pageId;
|
|
$updates = [];
|
|
if ((string)(int)$existing['pid'] !== $groupId) {
|
|
$updates['pid'] = $this->pid($groupId); // moved into its folder
|
|
}
|
|
if ($updates !== []) {
|
|
$this->datamap['pages'][$pageId] = $updates;
|
|
}
|
|
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
|
|
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [(int)$pageId]);
|
|
$this->report[] = sprintf(' page "%s" %s #%s (exists, %d elements%s)', $entry['page'], $slug, $pageId, $contentCount,
|
|
$contentCount > 0 ? ' - content left untouched' : ' - content generated');
|
|
if ($contentCount === 0) {
|
|
$this->content($pageId, $entry, $cards);
|
|
} else {
|
|
$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 -----------------------------------------------------------------------
|
|
|
|
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
|
|
{
|
|
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
|
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
|
|
[$parentUid, $title, $doktype]
|
|
);
|
|
return $uid !== false ? (int)$uid : null;
|
|
}
|
|
|
|
/** @return array<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);
|
|
}
|
|
}
|