From e2d4367d7a0499b3ea9456e16b8cb1403a6c393f Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 27 Oct 2026 13:31:03 +0100 Subject: [PATCH] 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/ 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 --- .gitignore | 4 + migrations/build_product_audit.php | 215 ++++++++ .../Command/CreateMissingProductsCommand.php | 125 +++++ .../ImportProductCategoryPagesCommand.php | 521 ++++++++++++++++++ .../Classes/Import/ProductFamilyAliases.php | 12 + 5 files changed, 877 insertions(+) create mode 100644 migrations/build_product_audit.php create mode 100644 packages/vitec/Classes/Command/CreateMissingProductsCommand.php create mode 100644 packages/vitec/Classes/Command/ImportProductCategoryPagesCommand.php 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 . '
' + . '
' + . '
' . h((string)$p['title']) . '' + . ' #' . $uid . '
' + . '
' + . 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' + . '

VITEC Product Audit (' . $total . ' records, generated ' . $generated . ')

' + . '
' . h($statLine) . '
' + . '
' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
' . "\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 = '
'; + foreach ($rows as $row) { + $body .= '' + . '' + . ''; + } + $body .= '
ProductTypeDescription
' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '
'; + $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', ]; /**