diff --git a/.gitignore b/.gitignore
index 4983fd8..0c85f39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,3 +136,7 @@ migrations/subsolutions_xlsx_in/*
migrations/solutiongroups_xlsx_in/*
migrations/product_images_in/
migrations/markethero_xlsx_in/
+migrations/product_categories_xlsx_in/
+
+# generated internal report (php migrations/build_product_audit.php)
+public/product-audit/
diff --git a/migrations/build_product_audit.php b/migrations/build_product_audit.php
new file mode 100644
index 0000000..8a7f759
--- /dev/null
+++ b/migrations/build_product_audit.php
@@ -0,0 +1,215 @@
+ https://dev.vitec.com/product-audit/
+ *
+ * Plain PDO against config/system/settings.php (pattern
+ * migrations/check_bg.php), re-run any time the data changes:
+ *
+ * php migrations/build_product_audit.php
+ */
+
+error_reporting(E_ALL);
+ini_set('display_errors', '1');
+
+$root = null;
+$dir = __DIR__;
+for ($i = 0; $i < 5; $i++) {
+ if (is_file($dir . '/config/system/settings.php')) {
+ $root = $dir;
+ break;
+ }
+ $dir = dirname($dir);
+}
+if ($root === null) {
+ exit("Could not locate project root above " . __DIR__ . "\n");
+}
+$settings = require $root . '/config/system/settings.php';
+$db = $settings['DB']['Connections']['Default'];
+$pdo = new PDO(
+ sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
+ $db['user'] ?? '',
+ $db['password'] ?? '',
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+);
+
+// ---- data -----------------------------------------------------------------
+$products = $pdo->query(
+ "SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
+ capabilities, applications, resources, resources_layout, datasheets, solutions,
+ hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
+ FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title"
+)->fetchAll();
+
+$refs = $pdo->query(
+ "SELECT r.uid_foreign, r.fieldname, COUNT(*) AS cnt, MIN(r.sorting_foreign) AS minsort
+ FROM sys_file_reference r
+ WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
+ AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
+ GROUP BY r.uid_foreign, r.fieldname"
+)->fetchAll();
+$refCount = [];
+foreach ($refs as $r) {
+ $refCount[(int)$r['uid_foreign']][$r['fieldname']] = (int)$r['cnt'];
+}
+
+// first thumbnail per product: prefer a product photo, fall back to the hero
+$thumbRows = $pdo->query(
+ "SELECT r.uid_foreign, r.fieldname, f.identifier
+ FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
+ WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
+ AND r.fieldname IN ('heroimage','productimage')
+ ORDER BY r.uid_foreign, FIELD(r.fieldname,'productimage','heroimage'), r.sorting_foreign"
+)->fetchAll();
+$thumb = [];
+foreach ($thumbRows as $r) {
+ $uid = (int)$r['uid_foreign'];
+ if (!isset($thumb[$uid])) {
+ $thumb[$uid] = '/fileadmin' . $r['identifier'];
+ }
+}
+
+// ---- helpers ----------------------------------------------------------------
+function h(string $v): string
+{
+ return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5);
+}
+function badge(string $label, bool $ok, string $detail = ''): string
+{
+ $cls = $ok ? 'ok' : 'miss';
+ $txt = $label . ($detail !== '' ? ' ' . $detail : '');
+ return '' . h($txt) . '';
+}
+
+// ---- stats ------------------------------------------------------------------
+$total = count($products);
+$stat = ['hero' => 0, 'photo' => 0, 'teaser' => 0, 'highlights' => 0, 'description' => 0, 'resources' => 0, 'datasheets' => 0, 'solutions' => 0];
+foreach ($products as $p) {
+ $uid = (int)$p['uid'];
+ if (($refCount[$uid]['heroimage'] ?? 0) > 0) { $stat['hero']++; }
+ if (($refCount[$uid]['productimage'] ?? 0) > 0) { $stat['photo']++; }
+ if (trim((string)$p['teaser']) !== '') { $stat['teaser']++; }
+ if (trim((string)$p['highlights']) !== '') { $stat['highlights']++; }
+ if (trim((string)$p['description']) !== '') { $stat['description']++; }
+ if ((int)$p['resources'] > 0) { $stat['resources']++; }
+ if ((int)$p['datasheets'] > 0) { $stat['datasheets']++; }
+ if ((int)$p['solutions'] > 0) { $stat['solutions']++; }
+}
+
+// ---- rows -------------------------------------------------------------------
+$rowsHtml = '';
+foreach ($products as $p) {
+ $uid = (int)$p['uid'];
+ $hero = $refCount[$uid]['heroimage'] ?? 0;
+ $photos = $refCount[$uid]['productimage'] ?? 0;
+ $og = $refCount[$uid]['ogimage'] ?? 0;
+ $video = $refCount[$uid]['videofile'] ?? 0;
+ $hasTeaser = trim((string)$p['teaser']) !== '';
+ $hasHighlights = trim((string)$p['highlights']) !== '';
+ $hasDescription = trim((string)$p['description']) !== '';
+
+ $classes = ['row'];
+ if ($hero === 0) { $classes[] = 'f-nohero'; }
+ if ($photos === 0) { $classes[] = 'f-nophoto'; }
+ if (!$hasTeaser) { $classes[] = 'f-noteaser'; }
+ if (!$hasHighlights) { $classes[] = 'f-nohighlights'; }
+ if ((int)$p['legacy'] === 1) { $classes[] = 'f-legacy'; }
+ if ((int)$p['hidden'] === 1 || (int)$p['hideonwebsite'] === 1) { $classes[] = 'f-hidden'; }
+
+ $flags = [];
+ foreach (['hidden' => 'hidden', 'legacy' => 'legacy', 'supportproduct' => 'support', 'subproduct' => 'sub', 'hideonwebsite' => 'hideWeb', 'hideonproducts' => 'hideList', 'showdatapath' => 'datapath'] as $col => $label) {
+ if ((int)$p[$col] === 1) {
+ $flags[] = '' . $label . '';
+ }
+ }
+
+ $img = isset($thumb[$uid])
+ ? '
'
+ : '
no image
';
+ $slug = trim((string)$p['slug'], '/');
+
+ $rowsHtml .= ''
+ . '
' . $img . '
'
+ . '
'
+ . '
'
+ . '
'
+ . badge('Hero', $hero > 0, $hero > 1 ? '(' . $hero . '!)' : '')
+ . badge('Photos', $photos > 0, (string)($photos ?: ''))
+ . badge('Teaser', $hasTeaser)
+ . badge('Description', $hasDescription)
+ . badge('Highlights', $hasHighlights)
+ . badge('Resources', (int)$p['resources'] > 0, (string)((int)$p['resources'] ?: ''))
+ . badge('Datasheets', (int)$p['datasheets'] > 0, (string)((int)$p['datasheets'] ?: ''))
+ . badge('Solutions', (int)$p['solutions'] > 0, (string)((int)$p['solutions'] ?: ''))
+ . badge('OG', $og > 0)
+ . badge('Video', $video > 0)
+ . '' . h((string)$p['resources_layout']) . ''
+ . implode('', $flags)
+ . '
' . "\n";
+}
+
+// ---- page -------------------------------------------------------------------
+$generated = date('Y-m-d H:i');
+$statLine = sprintf(
+ 'Hero %d/%d · Photos %d/%d · Teaser %d/%d · Description %d/%d · Highlights %d/%d · Resources %d/%d · Datasheets %d/%d · Solutions %d/%d',
+ $stat['hero'], $total, $stat['photo'], $total, $stat['teaser'], $total, $stat['description'], $total,
+ $stat['highlights'], $total, $stat['resources'], $total, $stat['datasheets'], $total, $stat['solutions'], $total
+);
+
+$html = ''
+ . ''
+ . ''
+ . 'VITEC Product Audit'
+ . '' . "\n" . $rowsHtml . ''
+ . '';
+
+$outDir = $root . '/public/product-audit';
+if (!is_dir($outDir)) {
+ mkdir($outDir, 0775, true);
+}
+file_put_contents($outDir . '/index.html', $html);
+echo "geschrieben: public/product-audit/index.html ($total Produkte)\n";
+echo "-> https://dev.vitec.com/product-audit/\n";
diff --git a/packages/vitec/Classes/Command/CreateMissingProductsCommand.php b/packages/vitec/Classes/Command/CreateMissingProductsCommand.php
new file mode 100644
index 0000000..b39538b
--- /dev/null
+++ b/packages/vitec/Classes/Command/CreateMissingProductsCommand.php
@@ -0,0 +1,125 @@
+addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ Bootstrap::initializeBackendAuthentication();
+ $apply = (bool)$input->getOption('apply');
+ $pool = GeneralUtility::makeInstance(ConnectionPool::class);
+ $conn = $pool->getConnectionForTable(self::TABLE);
+ $output->writeln(($apply ? 'APPLY' : 'DRY-RUN'));
+
+ $datamap = [];
+ $index = 0;
+ foreach (self::PRODUCTS as [$title, $slug, $siblingSlug, $teaser]) {
+ $existing = $conn->fetchAssociative(
+ 'SELECT uid FROM ' . self::TABLE . ' WHERE deleted = 0 AND (slug = ? OR title = ?)', [$slug, $title]
+ );
+ if ($existing) {
+ $output->writeln(sprintf(' = "%s" exists (#%d) - skipped', $title, (int)$existing['uid']));
+ continue;
+ }
+ $sibling = $conn->fetchAssociative(
+ 'SELECT uid, pid FROM ' . self::TABLE . ' WHERE deleted = 0 AND slug = ?', [$siblingSlug]
+ );
+ if (!$sibling) {
+ $output->writeln(sprintf(' ! sibling %s not found - "%s" skipped', $siblingSlug, $title));
+ continue;
+ }
+ $categories = $pool->getConnectionForTable('sys_category_record_mm')->fetchFirstColumn(
+ "SELECT uid_local FROM sys_category_record_mm
+ WHERE tablenames = ? AND fieldname = 'categories' AND uid_foreign = ? ORDER BY sorting_foreign",
+ [self::TABLE, (int)$sibling['uid']]
+ );
+ $datamap[self::TABLE]['NEWPROD' . (++$index)] = [
+ 'pid' => (int)$sibling['pid'],
+ 'title' => $title,
+ 'slug' => $slug,
+ 'teaser' => $teaser,
+ 'categories' => implode(',', array_map('intval', $categories)),
+ ];
+ $output->writeln(sprintf(' + "%s" (slug %s, teaser draft) - pid %d + %d categorie(s) from sibling #%d',
+ $title, $slug, (int)$sibling['pid'], count($categories), (int)$sibling['uid']));
+ }
+
+ if (!$apply) {
+ $output->writeln("\nDRY RUN - nothing written. Add --apply, then re-run vitec:import-product-images.");
+ return Command::SUCCESS;
+ }
+ if ($datamap === []) {
+ $output->writeln('Nothing to create.');
+ return Command::SUCCESS;
+ }
+ $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+ $dataHandler->start($datamap, []);
+ $dataHandler->process_datamap();
+ if ($dataHandler->errorLog !== []) {
+ $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
+ return Command::FAILURE;
+ }
+ foreach ($dataHandler->substNEWwithIDs as $newUid) {
+ $output->writeln(' created #' . (int)$newUid);
+ }
+ $output->writeln('Done. Now run vitec:import-product-images (dry-run, then --apply) to attach the assets; replace the teaser drafts once the agency texts arrive.');
+ return Command::SUCCESS;
+ }
+}
diff --git a/packages/vitec/Classes/Command/ImportProductCategoryPagesCommand.php b/packages/vitec/Classes/Command/ImportProductCategoryPagesCommand.php
new file mode 100644
index 0000000..c728cc3
--- /dev/null
+++ b/packages/vitec/Classes/Command/ImportProductCategoryPagesCommand.php
@@ -0,0 +1,521 @@
+ vitec_herosection (CTA label, anchor is FE)
+ * Category Introduction,
+ * Use Case, Why VITEC,
+ * Product Finder intro,
+ * Product Range intro,
+ * Related Solutions intro -> vitec_introparagraph
+ * Why VITEC Card -> vitec_card (text_only)
+ * Comparison Table rows -> ONE core `text` element with a plain
+ * (product / type / description) -
+ * no comparison CType exists; FE styles it
+ * Product Card -> vitec_card, link resolved record-first
+ * (exact title, stripped parenthesis, family
+ * alias, unique slug prefix) -> /product/
+ * Solution Card -> vitec_card, link to the solution group page
+ * (title norm, visualization/visualisation
+ * tolerated)
+ * Pre-footer CTA -> vitec_ctabanner (centered/orange, primary
+ * -> /contact)
+ *
+ * Pages are created VISIBLE (dev decision) with slug from the workbook URL
+ * row; /products/video-capture (#457, empty shell) is reused. Page
+ * properties: seo/og/twitter title + description (hero copy), canonical,
+ * abstract (category intro), keywords (primary keyword). Content-idempotent
+ * via the slot/sequence pattern of the sibling importers.
+ *
+ * vendor/bin/typo3 vitec:import-product-category-pages
+ * vendor/bin/typo3 vitec:import-product-category-pages --only=capture --apply
+ */
+#[AsCommand(
+ name: 'vitec:import-product-category-pages',
+ description: 'Build the /products category pages from the agency workbooks (dry-run by default)'
+)]
+final class ImportProductCategoryPagesCommand extends Command
+{
+ private const LAYOUT = 9;
+ private const COLPOS_HERO = 1;
+ private const COLPOS_MAIN = 0;
+ private const COLPOS_PREFOOTER = 3;
+
+ /** sitemap order 3.1-3.9 -> page sorting under /products */
+ private const CATEGORY_ORDER = [
+ 'iptv-and-digital-signage', 'video-encoding-and-streaming', 'gateways-and-transcoding',
+ 'control-room-platforms', 'av-over-ip-and-kvm', 'video-wall-and-display-processing',
+ 'graphics-and-multi-display', 'video-capture', 'infrastructure-and-accessories',
+ ];
+
+ /** @var array>> */
+ private array $datamap = [];
+ /** @var array colPos => ordered ids (per page run) */
+ private array $sequence = [];
+ /** @var array */
+ private array $slotCursor = [];
+ private array $report = [];
+ private int $newCounter = 0;
+
+ protected function configure(): void
+ {
+ $this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_categories_xlsx_in');
+ $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
+ $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ Bootstrap::initializeBackendAuthentication();
+ $apply = (bool)$input->getOption('apply');
+ $only = mb_strtolower((string)$input->getOption('only'));
+ $dir = rtrim((string)$input->getOption('dir'), '/');
+ $pool = GeneralUtility::makeInstance(ConnectionPool::class);
+ $pages = $pool->getConnectionForTable('pages');
+
+ $productsRoot = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products'");
+ if (!$productsRoot) {
+ $output->writeln('/products not found.');
+ return Command::FAILURE;
+ }
+ $rootUid = (int)$productsRoot['uid'];
+ $files = glob($dir . '/*.xlsx') ?: [];
+ $files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
+ && ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
+ $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $rootUid . ' · ' . count($files) . ' workbook(s)');
+
+ $pageUidBySlugTail = [];
+ foreach ($files as $file) {
+ try {
+ $result = $this->importOne($file, $rootUid, $apply, $output);
+ if ($result !== null) {
+ $pageUidBySlugTail[$result['slugTail']] = $result['pageUid'];
+ }
+ } catch (\Throwable $e) {
+ $output->writeln(' ' . basename($file) . ': ' . $e->getMessage() . '');
+ }
+ }
+
+ if ($apply && $pageUidBySlugTail !== []) {
+ $sorting = 0;
+ foreach (self::CATEGORY_ORDER as $tail) {
+ $sorting += 256;
+ if (isset($pageUidBySlugTail[$tail])) {
+ $pages->update('pages', ['sorting' => $sorting], ['uid' => $pageUidBySlugTail[$tail]]);
+ }
+ }
+ $output->writeln('Done. Flush the frontend cache.');
+ } elseif (!$apply) {
+ $output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
+ }
+ return Command::SUCCESS;
+ }
+
+ /** @return array{slugTail:string,pageUid:int}|null */
+ private function importOne(string $file, int $rootUid, bool $apply, OutputInterface $output): ?array
+ {
+ $this->datamap = [];
+ $this->sequence = [];
+ $this->slotCursor = [];
+ $this->report = [];
+ $pool = GeneralUtility::makeInstance(ConnectionPool::class);
+ $pages = $pool->getConnectionForTable('pages');
+
+ [$meta, $components] = $this->readSheet($file);
+ $url = rtrim((string)($meta['url'] ?? ''), '/ ');
+ if ($url === '' || !str_starts_with($url, '/products/')) {
+ $output->writeln(' ' . basename($file) . ': no /products/... URL row - skipped');
+ return null;
+ }
+ $slugTail = substr($url, strlen('/products/'));
+ $hero = $this->firstComponent($components, 'hero');
+ $title = trim((string)($hero['heading'] ?? '')) !== '' ? trim((string)$hero['heading']) : $slugTail;
+
+ // --- page -----------------------------------------------------------------
+ $existing = $pages->fetchAssociative('SELECT uid, title, layout FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$url]);
+ if ($existing) {
+ $pageId = (string)(int)$existing['uid'];
+ if ((int)$existing['layout'] !== self::LAYOUT) {
+ $this->datamap['pages'][$pageId]['layout'] = self::LAYOUT;
+ $this->report[] = 'layout -> ' . self::LAYOUT . ' (was ' . (int)$existing['layout'] . ')';
+ }
+ } else {
+ $pageId = $this->newId();
+ $this->datamap['pages'][$pageId] = [
+ 'pid' => $rootUid, 'doktype' => 1, 'hidden' => 0,
+ 'title' => $title, 'slug' => $url, 'layout' => self::LAYOUT,
+ ];
+ $this->report[] = 'page CREATED (visible)';
+ }
+ $pageUid = ctype_digit($pageId) ? (int)$pageId : 0;
+
+ // --- hero -------------------------------------------------------------------
+ if ($hero !== null) {
+ $id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId);
+ $this->setFields($id, [
+ 'header' => (string)$hero['heading'], 'header_layout' => 1,
+ 'bodytext' => $this->p((string)$hero['body']),
+ 'vitec_cta_label' => (string)$hero['cta'],
+ ]);
+ }
+
+ // --- main column, in workbook order ------------------------------------------
+ $comparisonRows = [];
+ foreach ($components as $component) {
+ $kind = mb_strtolower($component['component']);
+ if (str_starts_with($kind, 'hero')) {
+ continue;
+ }
+ if (str_contains($kind, 'comparison table')) {
+ $comparisonRows[] = $component;
+ continue;
+ }
+ if (str_contains($kind, 'why vitec card')) {
+ $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
+ $this->setFields($id, [
+ 'vitec_layout_variant' => 'text_only',
+ 'header' => (string)$component['heading'],
+ 'bodytext' => $this->p((string)$component['cta']),
+ 'vitec_primary_cta_label' => '',
+ ]);
+ continue;
+ }
+ if (str_starts_with($kind, 'product card')) {
+ $this->flushComparison($comparisonRows, $pageUid, $pageId);
+ $cardTitle = (string)$component['heading'];
+ $fields = [
+ 'vitec_layout_variant' => 'text_only',
+ 'header' => $cardTitle,
+ 'bodytext' => $this->p($this->stripCtaSuffix((string)$component['cta'])),
+ 'vitec_primary_cta_label' => 'Learn more',
+ ];
+ $product = $this->resolveProduct($cardTitle);
+ if ($product !== null) {
+ $fields['vitec_primary_cta'] = '/product/' . $product['slug'];
+ $this->report[] = 'product card "' . $cardTitle . '" -> /product/' . $product['slug'];
+ } else {
+ $this->report[] = 'product card "' . $cardTitle . '": NO record resolved - link left empty';
+ }
+ $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
+ $this->setFields($id, $fields);
+ continue;
+ }
+ if (str_starts_with($kind, 'solution card')) {
+ $cardTitle = (string)$component['heading'];
+ $fields = [
+ 'vitec_layout_variant' => 'text_only',
+ 'header' => $cardTitle,
+ 'bodytext' => $this->p($this->stripCtaSuffix((string)$component['cta'])),
+ 'vitec_primary_cta_label' => 'Learn more',
+ ];
+ $solutionUid = $this->resolveSolutionPage($cardTitle);
+ if ($solutionUid > 0) {
+ $fields['vitec_primary_cta'] = 't3://page?uid=' . $solutionUid;
+ $this->report[] = 'solution card "' . $cardTitle . '" -> page #' . $solutionUid;
+ } else {
+ $this->report[] = 'solution card "' . $cardTitle . '": NO solution page resolved - link left empty';
+ }
+ $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
+ $this->setFields($id, $fields);
+ continue;
+ }
+ if (str_starts_with($kind, 'pre-footer')) {
+ $this->flushComparison($comparisonRows, $pageUid, $pageId);
+ $id = $this->slotNext($pageUid, self::COLPOS_PREFOOTER, 'vitec_ctabanner', $pageId);
+ $this->setFields($id, [
+ 'header' => (string)$component['heading'],
+ 'bodytext' => $this->p((string)$component['body']),
+ 'vitec_primary_cta_label' => (string)$component['cta'] !== '' ? (string)$component['cta'] : 'Contact us',
+ 'vitec_primary_cta' => $this->contactLink(),
+ 'vitec_layout_variant' => 'centered',
+ 'vitec_background_variant' => 'orange',
+ ]);
+ continue;
+ }
+ // every other named section is an intro paragraph in main
+ $this->flushComparison($comparisonRows, $pageUid, $pageId);
+ $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph', $pageId);
+ $this->setFields($id, [
+ 'header' => (string)$component['heading'],
+ 'bodytext' => $this->p((string)$component['body']),
+ ]);
+ }
+ $this->flushComparison($comparisonRows, $pageUid, $pageId);
+
+ // --- page properties ----------------------------------------------------------
+ $heroBody = trim((string)($hero['body'] ?? ''));
+ $intro = $this->firstComponent($components, 'category introduction');
+ $seoTitle = $title . ' | VITEC';
+ $props = [
+ 'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
+ 'description' => $heroBody, 'og_description' => $heroBody, 'twitter_description' => $heroBody,
+ 'abstract' => mb_substr(trim((string)($intro['body'] ?? $heroBody)), 0, 300),
+ 'keywords' => (string)($meta['keyword'] ?? ''),
+ ];
+ if ($pageUid > 0) {
+ $props['canonical_link'] = 't3://page?uid=' . $pageUid;
+ }
+ foreach ($props as $field => $value) {
+ $this->datamap['pages'][$pageId][$field] = $value;
+ }
+ $this->report[] = 'page meta: seo/og/twitter + abstract + keywords ("' . ($meta['keyword'] ?? '') . '")';
+
+ $output->writeln(sprintf('%s -> %s (%s)', basename($file), $url, $existing ? '#' . (int)$existing['uid'] : 'NEW'));
+ foreach ($this->report as $line) {
+ $output->writeln(' ' . $line);
+ }
+ if (!$apply) {
+ return null;
+ }
+
+ $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+ $dataHandler->start($this->datamap, []);
+ $dataHandler->process_datamap();
+ if ($dataHandler->errorLog !== []) {
+ throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
+ }
+ $resolvedPageUid = $pageUid > 0 ? $pageUid : (int)($dataHandler->substNEWwithIDs[$pageId] ?? 0);
+ // canonical for NEW pages needs the real uid
+ if ($pageUid === 0 && $resolvedPageUid > 0) {
+ GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')
+ ->update('pages', ['canonical_link' => 't3://page?uid=' . $resolvedPageUid], ['uid' => $resolvedPageUid]);
+ }
+ $this->applySequence($dataHandler->substNEWwithIDs, $resolvedPageUid);
+ return ['slugTail' => $slugTail, 'pageUid' => $resolvedPageUid];
+ }
+
+ /** One `text` element holding the comparison rows as a plain table. */
+ private function flushComparison(array &$rows, int $pageUid, string $pageId): void
+ {
+ if ($rows === []) {
+ return;
+ }
+ $body = '| Product | Type | Description |
';
+ foreach ($rows as $row) {
+ $body .= '| ' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . ' | '
+ . '' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . ' | '
+ . '' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . ' |
';
+ }
+ $body .= '
';
+ $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
+ $this->setFields($id, ['header' => '', 'bodytext' => $body]);
+ $this->report[] = 'comparison table: ' . count($rows) . ' row(s) as one text element (plain , FE styles it)';
+ $rows = [];
+ }
+
+ // ---- resolution -----------------------------------------------------------------
+
+ /** @return array|null */
+ private function resolveProduct(string $cardTitle): ?array
+ {
+ $conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_product');
+ $rows = $conn->fetchAllAssociative('SELECT uid, title, slug FROM tx_vitec_domain_model_product WHERE deleted = 0');
+ $full = $this->norm($cardTitle);
+ $stripped = $this->norm((string)preg_replace('/\s*\(.*$/', '', $cardTitle));
+ foreach ([$full, $stripped] as $needle) {
+ foreach ($rows as $row) {
+ if ($this->norm((string)$row['title']) === $needle) {
+ return $row;
+ }
+ }
+ }
+ $alias = ProductFamilyAliases::resolve($stripped);
+ if ($alias !== null) {
+ return $alias;
+ }
+ // unique slug prefix (activesqx -> activesqx2)
+ $matches = array_values(array_filter($rows, fn(array $row): bool => str_starts_with(
+ $this->norm((string)$row['slug']), $stripped
+ )));
+ return count($matches) === 1 ? $matches[0] : null;
+ }
+
+ /** Workbook solution names that differ from the page titles beyond spelling. */
+ private const SOLUTION_ALIASES = [
+ // key is post-norm (visualization->visualisation already applied)
+ 'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
+ ];
+
+ private function resolveSolutionPage(string $cardTitle): int
+ {
+ $needle = $this->norm($cardTitle);
+ $needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
+ $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
+ $solutions = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
+ if (!$solutions) {
+ return 0;
+ }
+ foreach ($pages->fetchAllAssociative(
+ 'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [(int)$solutions['uid']]
+ ) as $row) {
+ if ($this->norm((string)$row['title']) === $needle) {
+ return (int)$row['uid'];
+ }
+ }
+ return 0;
+ }
+
+ private function contactLink(): string
+ {
+ $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
+ $row = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/contact'");
+ return $row ? 't3://page?uid=' . (int)$row['uid'] : '';
+ }
+
+ // ---- workbook reading --------------------------------------------------------------
+
+ /** @return array{0:array,1:array} */
+ private function readSheet(string $file): array
+ {
+ $spreadsheet = IOFactory::load($file);
+ $sheet = $spreadsheet->getSheet(0);
+ $rows = $sheet->toArray(null, false, false, false);
+ $spreadsheet->disconnectWorksheets();
+ $meta = [];
+ $components = [];
+ $inTable = false;
+ foreach ($rows as $row) {
+ $a = trim((string)($row[0] ?? ''));
+ $b = trim((string)($row[1] ?? ''));
+ if (!$inTable) {
+ if (mb_strtolower($a) === 'url') {
+ $meta['url'] = $b;
+ }
+ if (mb_strtolower($a) === 'primary keyword') {
+ $meta['keyword'] = $b;
+ }
+ if (mb_strtolower($a) === 'order') {
+ $inTable = true;
+ }
+ continue;
+ }
+ if ($b === '') {
+ continue;
+ }
+ $components[] = [
+ 'component' => $b,
+ 'heading' => trim((string)($row[2] ?? '')),
+ 'body' => trim((string)($row[3] ?? '')),
+ 'cta' => trim((string)($row[4] ?? '')),
+ ];
+ }
+ return [$meta, $components];
+ }
+
+ /** @param array> $components */
+ private function firstComponent(array $components, string $needle): ?array
+ {
+ foreach ($components as $component) {
+ if (str_contains(mb_strtolower($component['component']), $needle)) {
+ return $component;
+ }
+ }
+ return null;
+ }
+
+ private function stripCtaSuffix(string $copy): string
+ {
+ return trim((string)preg_replace('/\s*\|\s*CTA:.*$/i', '', $copy));
+ }
+
+ // ---- element plumbing (pattern of the sibling importers) ------------------------------
+
+ private function slotNext(int $pageUid, int $colPos, string $ctype, string $pageId): string
+ {
+ $key = $colPos . '/' . $ctype;
+ if (!isset($this->slotCursor[$key])) {
+ $this->slotCursor[$key] = $pageUid > 0 ? $this->slotElements($pageUid, $colPos, $ctype) : [];
+ }
+ $uid = array_shift($this->slotCursor[$key]);
+ if ($uid !== null) {
+ $this->sequence[$colPos][] = (string)$uid;
+ return (string)$uid;
+ }
+ $id = $this->newId();
+ $this->datamap['tt_content'][$id] = [
+ 'pid' => ctype_digit($pageId) ? (int)$pageId : $pageId,
+ 'colPos' => $colPos,
+ 'CType' => $ctype,
+ ];
+ $this->sequence[$colPos][] = $id;
+ return $id;
+ }
+
+ /** @return int[] */
+ private function slotElements(int $pageUid, int $colPos, string $ctype): array
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
+ return array_map('intval', $connection->fetchFirstColumn(
+ 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
+ [$pageUid, $colPos, $ctype]
+ ));
+ }
+
+ /** @param array $fields */
+ private function setFields(string $id, array $fields): void
+ {
+ foreach ($fields as $field => $value) {
+ $this->datamap['tt_content'][$id][$field] = $value;
+ }
+ }
+
+ /** @param array $substNEWwithIDs */
+ private function applySequence(array $substNEWwithIDs, int $pageUid): void
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
+ foreach ($this->sequence as $ids) {
+ $sorting = 0;
+ foreach ($ids as $id) {
+ $uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
+ if ($uid > 0) {
+ $sorting += 256;
+ $connection->update('tt_content', ['sorting' => $sorting, 'pid' => $pageUid], ['uid' => $uid]);
+ }
+ }
+ }
+ }
+
+ private function p(string $text): string
+ {
+ $text = trim($text);
+ return $text === '' ? '' : '' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '
';
+ }
+
+ private function norm(string $value): string
+ {
+ $value = mb_strtolower($value);
+ // US/UK tolerance - the workbooks write Center/Visualization, the
+ // page titles Centre/Visualisation
+ $value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
+ return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
+ }
+
+ private function newId(): string
+ {
+ return 'NEWCAT' . (++$this->newCounter);
+ }
+}
diff --git a/packages/vitec/Classes/Import/ProductFamilyAliases.php b/packages/vitec/Classes/Import/ProductFamilyAliases.php
index ff365e3..9b91e92 100644
--- a/packages/vitec/Classes/Import/ProductFamilyAliases.php
+++ b/packages/vitec/Classes/Import/ProductFamilyAliases.php
@@ -34,6 +34,18 @@ final class ProductFamilyAliases
'diamondip' => 'diamond-ip-og-ip-to-ip-encoder',
'ogplatform' => 'mgw-diamond-og-sdi-composite-blade-encoder',
'capturecards' => 'visionav-hd',
+ // category-page workbooks (2026-10-27): family cards on
+ // /products/ resolve to their flagship record
+ 'rfgateways' => '45-series-rf-gateways',
+ 'ipgateways' => 'channellink',
+ 'prismtranscoder' => 'prism-1ru-rack-mount-transcoder',
+ 'mgwace' => 'mgw-ace-encoder',
+ 'imagegraphicscards' => 'image-4k',
+ 'avedia' => 'avedia-platform-iptv-signage-cmsserver',
+ 'vsn' => 'vsn-v3',
+ 'visionlc' => 'visionlc-hd',
+ 'videowallmanagementsoftware' => 'wall-control-10',
+ 'vitecog' => 'mgw-diamond-og-sdi-composite-blade-encoder',
];
/**