Products run: missing records, asset re-run, audit page, category pages

- new vitec:create-missing-products: MGW Diamond TOUGH, MGW Pico TOUGH,
  MGW Pico+ TOUGH and X-View Integrated Displays as visible records
  (teaser drafts, pid and sys_category assignments copied from the
  sibling product; titles equal the Dropbox folder names so the image
  importer matches immediately). IQS4 stays out - its delivery folder
  is empty, question to Joe
- asset re-run via vitec:import-product-images after re-staging the
  Dropbox delivery (the misfiled Diamond-OG banner corrected into the
  OG inbox folder again): new photos for 95-Series, Avedia chassis,
  SFF PRISM and the new TOUGH/X-View records
- new migrations/build_product_audit.php: generates the internal
  product-asset audit at /product-audit/ (same basic auth, same-origin
  thumbnails) - one row per record with thumbnail, detail link, badges
  for hero/photos/texts/resources/datasheets/solutions, flags, stats
  and client-side filters; re-run any time
- new vitec:import-product-category-pages: builds the nine
  /products/<category> pages (frontend layout 9, hero/main/preFooter)
  from the agency "Product Category Pages" workbooks - intro
  paragraphs, Why-VITEC / product / solution cards, the comparison
  rows as ONE text element with a plain table (no comparison CType
  exists; FE styles it), prefooter banner to /contact, full page
  properties, page sorting per sitemap 3.1-3.9; the empty shells #455
  and #457 are reused. Product card links resolve record-first (exact
  title, stripped parenthesis, family alias, unique slug prefix);
  ProductFamilyAliases gains ten category-level family labels. Solution
  cards tolerate US/UK spelling (center/centre, visualization/
  visualisation). Applied and verified live (nine routes, links,
  tables, meta)
- known gaps reported, queued for Joe: IQS4, Media Players (93/94),
  Chassis Systems, Express Backplanes, Accessories & Cables, PCIe
  Encoders, VMS Plugins & Integrations; the Vision family card awaits
  a flagship decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-27 13:31:03 +01:00
1 parent 8497dad06a
commit e2d4367d7a
5 files changed
+877

No files matched your search

@@ -0,0 +1,125 @@
<?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\Utility\GeneralUtility;
/**
* Creates the four product records the agency delivery references but the
* database never had (decision Olli 2026-10-27; they were a sitemap-tracker
* question before, their asset folders arrived with the Dropbox delivery):
*
* MGW Diamond TOUGH, MGW Pico TOUGH, MGW Pico+ TOUGH,
* X-View Integrated Displays
*
* Each new record copies pid and sys_category assignments from a named
* SIBLING product (so catalog panel and product finder place it correctly)
* and is created VISIBLE (dev decision Olli 2026-10-27) with a short
* placeholder teaser (Claude draft, English) - replace it once the agency
* texts arrive. Titles equal
* the Dropbox folder names, so a vitec:import-product-images re-run attaches
* the delivered banner/carousel assets immediately.
*
* IQS4 stays out on purpose: its delivery folder is empty - question to Joe.
*
* vendor/bin/typo3 vitec:create-missing-products
* vendor/bin/typo3 vitec:create-missing-products --apply
*/
#[AsCommand(
name: 'vitec:create-missing-products',
description: 'Create the missing TOUGH / X-View product records (dry-run by default)'
)]
final class CreateMissingProductsCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_product';
/** title (= Dropbox folder name), slug, sibling slug (pid + categories donor), teaser draft */
private const PRODUCTS = [
['MGW Diamond TOUGH', 'mgw-diamond-tough', 'mgw-diamond-sdi-encoder',
'Rugged four-channel HEVC/H.264 encoder built for harsh and mobile operational environments.'],
['MGW Pico TOUGH', 'mgw-pico-tough', 'mgw-pico-portable-encoder',
'Rugged portable H.264 encoder for low-latency video contribution in the field.'],
['MGW Pico+ TOUGH', 'mgw-pico-plus-tough', 'mgw-pico-portable-encoder',
'Rugged portable HEVC/H.264 encoder for demanding field deployments.'],
['X-View Integrated Displays', 'x-view-integrated-displays', 'x-player-end-points-xp23-xp25-xp26',
'Commercial displays with an integrated IPTV end-point for TV and signage delivery.'],
];
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 = (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(' <error>! sibling %s not found - "%s" skipped</error>', $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('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
foreach ($dataHandler->substNEWwithIDs as $newUid) {
$output->writeln(' created #' . (int)$newUid);
}
$output->writeln('<info>Done. Now run vitec:import-product-images (dry-run, then --apply) to attach the assets; replace the teaser drafts once the agency texts arrive.</info>');
return Command::SUCCESS;
}
}
@@ -0,0 +1,521 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Import\ProductFamilyAliases;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Builds the nine product CATEGORY pages under /products from the agency
* workbooks "Product Category Pages/*.xlsx" (Nextcloud Material/Products,
* delivery 2026-10; staged in migrations/product_categories_xlsx_in/).
*
* Workbook shape (sheet 1): a meta block (URL, Primary keyword, ...), then
* component rows "Order | Component | Heading | Body | CTA/Card copy | Note".
* Mapping onto existing CTypes, zones per frontend layout 9 "Product Main
* Category" (hero = colPos 1, main = 0, preFooter = 3):
*
* Hero - H1 -> 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
* <table> (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/<slug>
* 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<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var array<int,string[]> colPos => ordered ids (per page run) */
private array $sequence = [];
/** @var array<string,int[]> */
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('<error>/products not found.</error>');
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('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
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('<info>Done. Flush the frontend cache.</info>');
} 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('<comment> ' . basename($file) . ': no /products/... URL row - skipped</comment>');
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 = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
foreach ($rows as $row) {
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>';
}
$body .= '</tbody></table>';
$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 <table>, FE styles it)';
$rows = [];
}
// ---- resolution -----------------------------------------------------------------
/** @return array<string,mixed>|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<string,string>,1:array<int,array{component:string,heading:string,body:string,cta:string}>} */
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<int,array<string,string>> $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<string,mixed> $fields */
private function setFields(string $id, array $fields): void
{
foreach ($fields as $field => $value) {
$this->datamap['tt_content'][$id][$field] = $value;
}
}
/** @param array<string,int> $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 === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
}
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);
}
}
@@ -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/<category> 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',
];
/**