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

+4
View File
@@ -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/
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
/**
* Builds the internal product-asset audit page: one row per product record
* with thumbnail and badges for everything we track (hero, photos, texts,
* resources, datasheets, solutions, flags). Output is a static page under
* the dev host's basic auth:
*
* public/product-audit/index.html -> 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 '<span class="b ' . $cls . '">' . h($txt) . '</span>';
}
// ---- 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[] = '<span class="b flag">' . $label . '</span>';
}
}
$img = isset($thumb[$uid])
? '<img loading="lazy" src="' . h($thumb[$uid]) . '" alt="">'
: '<div class="noimg">no image</div>';
$slug = trim((string)$p['slug'], '/');
$rowsHtml .= '<div class="' . implode(' ', $classes) . '">'
. '<div class="thumb">' . $img . '</div>'
. '<div class="info">'
. '<div class="t"><a href="/product/' . h($slug) . '" target="_blank">' . h((string)$p['title']) . '</a>'
. ' <span class="uid">#' . $uid . '</span></div>'
. '<div class="badges">'
. 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)
. '<span class="b lay">' . h((string)$p['resources_layout']) . '</span>'
. implode('', $flags)
. '</div></div></div>' . "\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 = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
. '<meta name="robots" content="noindex,nofollow">'
. '<title>VITEC Product Audit</title><style>'
. ':root{--ok:#1a7f37;--miss:#c0392b;--line:#e3e5e8;color-scheme:light}'
. 'body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c1e21}'
. 'header{position:sticky;top:0;background:#fff;border-bottom:1px solid var(--line);padding:12px 20px;z-index:2}'
. 'h1{font-size:17px;margin:0 0 4px}.stats{color:#555;font-size:12.5px}'
. '.filters{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}'
. '.filters button{border:1px solid var(--line);background:#fff;border-radius:14px;padding:3px 11px;font-size:12.5px;cursor:pointer}'
. '.filters button.on{background:#26358c;color:#fff;border-color:#26358c}'
. 'main{max-width:1200px;margin:14px auto;padding:0 20px}'
. '.row{display:flex;gap:14px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;padding:10px;align-items:center}'
. '.row.hide{display:none}'
. '.thumb{flex:0 0 150px;height:84px;display:flex;align-items:center;justify-content:center;background:#fafbfc;border-radius:6px;overflow:hidden}'
. '.thumb img{max-width:100%;max-height:100%;object-fit:contain}'
. '.noimg{color:#b23;font-size:12px}'
. '.t{font-weight:600;margin-bottom:5px}.t a{color:#1c1e21;text-decoration:none}.t a:hover{text-decoration:underline}'
. '.uid{color:#999;font-weight:400;font-size:12px}'
. '.badges{display:flex;flex-wrap:wrap;gap:4px}'
. '.b{font-size:11.5px;border-radius:10px;padding:1px 8px;border:1px solid var(--line)}'
. '.b.ok{background:#e8f5ec;color:var(--ok);border-color:#bfe3c8}'
. '.b.miss{background:#fdecea;color:var(--miss);border-color:#f3c3bd}'
. '.b.flag{background:#fff7e0;color:#8a6d00;border-color:#eadfa8}'
. '.b.lay{background:#eef1fb;color:#26358c;border-color:#ccd4f0}'
. '</style></head><body>'
. '<header><h1>VITEC Product Audit <span class="uid">(' . $total . ' records, generated ' . $generated . ')</span></h1>'
. '<div class="stats">' . h($statLine) . '</div>'
. '<div class="filters">'
. '<button class="on" data-f="">all</button>'
. '<button data-f="f-nohero">no hero</button>'
. '<button data-f="f-nophoto">no photos</button>'
. '<button data-f="f-noteaser">no teaser</button>'
. '<button data-f="f-nohighlights">no highlights</button>'
. '<button data-f="f-legacy">legacy</button>'
. '<button data-f="f-hidden">hidden</button>'
. '</div></header><main>' . "\n" . $rowsHtml . '</main>'
. '<script>document.querySelectorAll(".filters button").forEach(b=>b.addEventListener("click",()=>{'
. 'document.querySelectorAll(".filters button").forEach(x=>x.classList.remove("on"));b.classList.add("on");'
. 'const f=b.dataset.f;document.querySelectorAll(".row").forEach(r=>{r.classList.toggle("hide",f!==""&&!r.classList.contains(f));});'
. '}));</script></body></html>';
$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";
@@ -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',
];
/**