/contact, Explore Solutions -> /solutions). * * The page is hand-built today (textpic/productlist/containers): every * pre-existing visible top-level element that this run does not reuse is * HIDDEN, not deleted - re-runs reuse the elements this command created * (slot pattern) and leave the hidden legacy untouched. * * vendor/bin/typo3 vitec:build-product-portfolio * vendor/bin/typo3 vitec:build-product-portfolio --apply */ #[AsCommand( name: 'vitec:build-product-portfolio', description: 'Fill the /products portfolio page from the agency workbook (dry-run by default)' )] final class BuildProductPortfolioCommand extends Command { private const COLPOS_HERO = 1; private const COLPOS_MAIN = 0; private const COLPOS_PREFOOTER = 3; private const DEFAULT_FILE = 'migrations/product_categories_xlsx_in/portfolio/Product_Portfolio_Page_Content_US_English.xlsx'; /** @var array>> */ private array $datamap = []; /** @var array */ private array $sequence = []; /** @var array */ private array $slotCursor = []; /** @var array pre-existing uids this run reuses */ private array $consumed = []; private int $newCounter = 0; protected function configure(): void { $this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Workbook path', self::DEFAULT_FILE); $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'); $file = (string)$input->getOption('file'); $pool = GeneralUtility::makeInstance(ConnectionPool::class); $pages = $pool->getConnectionForTable('pages'); $content = $pool->getConnectionForTable('tt_content'); $root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products'"); if (!$root || !is_file($file)) { $output->writeln('' . (!$root ? '/products not found.' : $file . ' missing.') . ''); return Command::FAILURE; } $pageUid = (int)$root['uid']; // Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid // stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo // der Slot-Cursor sie nicht sieht - ein Re-Run hat deshalb die 15 // Container versteckt und 38 Duplikat-Karten erzeugt (Repair: // migrations/fix_products_grid.php). Auf einer gewickelten Seite // darf dieser Builder nicht mehr laufen. $containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class) ->getConnectionForTable('tt_content')->fetchOne( "SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'", [$pageUid] ); if ($containerCount > 0) { $output->writeln('/products ist grid-gewickelt (' . $containerCount . ' vitec_cols-Container). Ein Re-Run wuerde Karten duplizieren und die Grids verstecken - Abbruch.' . ' Fuer Einzelaenderungen die Karten im BE pflegen oder erst die Grids aufloesen.'); return Command::FAILURE; } // the page sits on layout 0 (Default, main only) - the portfolio // needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27: // the page's own content was all deleted, the rendered payload came // from the Collection Product Infos compose mechanics) $this->datamap['pages'][$pageUid]['layout'] = 1; [$meta, $components] = $this->readSheet($file); $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $pageUid . ' · ' . count($components) . ' component(s)'); $categoryPages = []; foreach ($pages->fetchAllAssociative( 'SELECT uid, slug FROM pages WHERE deleted = 0 AND pid = ? AND doktype = 1 AND sys_language_uid IN (0,-1)', [$pageUid] ) as $row) { $categoryPages[$this->norm((string)basename((string)$row['slug']))] = (int)$row['uid']; } // --- hero --------------------------------------------------------------------- $hero = $this->firstComponent($components, 'hero'); if ($hero !== null) { $id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection'); $this->setFields($id, [ 'header' => (string)$hero['heading'], 'header_layout' => 1, 'bodytext' => $this->p((string)$hero['body']), 'vitec_cta_label' => (string)$hero['cta'], ]); $output->writeln(' hero: "' . $hero['heading'] . '"'); } // --- main: categories, cards, case studies, faq -------------------------------- $faqItems = []; foreach ($components as $component) { $kind = mb_strtolower($component['component']); if (str_starts_with($kind, 'hero')) { continue; } if (str_starts_with($kind, 'product category')) { $fields = [ 'header' => (string)$component['heading'], 'bodytext' => $this->p((string)$component['body']), ]; $categoryUid = $categoryPages[$this->norm((string)$component['heading'])] ?? 0; if ($categoryUid > 0) { $fields['vitec_button_text'] = (string)$component['cta'] !== '' ? (string)$component['cta'] : 'Learn more'; $fields['vitec_button_link'] = 't3://page?uid=' . $categoryUid; $output->writeln(' category "' . $component['heading'] . '" -> page #' . $categoryUid); } else { $output->writeln(' category "' . $component['heading'] . '": NO category page matched - button left empty'); } $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph'); $this->setFields($id, $fields); continue; } if (str_starts_with($kind, 'product card')) { $cardTitle = (string)$component['heading']; $fields = [ 'vitec_layout_variant' => 'text_only', 'header' => $cardTitle, 'bodytext' => $this->p((string)$component['cta']), 'vitec_primary_cta_label' => 'Learn more', ]; $product = $this->resolveProduct($cardTitle); if ($product !== null) { $fields['vitec_primary_cta'] = '/product/' . $product['slug']; } else { $output->writeln(' product card "' . $cardTitle . '": NO record resolved - link left empty'); } $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card'); $this->setFields($id, $fields); continue; } if (str_starts_with($kind, 'case studies')) { $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_ctabanner'); $this->setFields($id, [ 'header' => (string)$component['heading'], 'bodytext' => $this->p((string)$component['body']), 'vitec_primary_cta_label' => 'View Success Stories', 'vitec_primary_cta' => $this->pageLink('/success-stories'), 'vitec_secondary_cta_label' => 'View Events', 'vitec_secondary_cta' => $this->pageLink('/insights/events'), 'vitec_layout_variant' => 'centered', 'vitec_background_variant' => 'midnight', ]); $output->writeln(' case studies & events banner -> /success-stories + /insights/events'); continue; } if (str_starts_with($kind, 'faq')) { $faqItems[] = ['question' => (string)$component['heading'], 'answer' => (string)$component['body']]; continue; } if (str_starts_with($kind, 'pre-footer')) { $id = $this->slotNext($pageUid, self::COLPOS_PREFOOTER, 'vitec_ctabanner'); $this->setFields($id, [ 'header' => (string)$component['heading'], 'bodytext' => $this->p((string)$component['body']), 'vitec_primary_cta_label' => 'Talk to an Expert', 'vitec_primary_cta' => $this->pageLink('/contact'), 'vitec_secondary_cta_label' => 'Explore Solutions', 'vitec_secondary_cta' => $this->pageLink('/solutions'), 'vitec_layout_variant' => 'centered', 'vitec_background_variant' => 'orange', ]); $output->writeln(' prefooter -> /contact + /solutions'); continue; } } // faq accordion with its collection children if ($faqItems !== []) { $faqId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_faq'); $questionField = $this->faqColumn($pool, 'question'); $answerField = $this->faqColumn($pool, 'answer'); $childIds = []; $existingChildren = []; if (!str_starts_with($faqId, 'NEW')) { $existingChildren = array_map('intval', $pool->getConnectionForTable('vitec_faq_items')->fetchFirstColumn( 'SELECT uid FROM vitec_faq_items WHERE deleted = 0 AND foreign_table_parent_uid = ? ORDER BY sorting', [(int)$faqId] )); } foreach ($faqItems as $item) { $childId = array_shift($existingChildren); $childKey = $childId !== null ? (string)$childId : $this->newId(); $this->datamap['vitec_faq_items'][$childKey] = array_filter([ 'pid' => $childId !== null ? null : $pageUid, $questionField => $item['question'], $answerField => $this->p($item['answer']), ], static fn($v): bool => $v !== null); $childIds[] = $childKey; } $this->setFields($faqId, [ 'header' => 'Frequently asked questions', 'vitec_faq_items' => implode(',', $childIds), 'vitec_first_item_open' => 1, ]); $output->writeln(' faq accordion: ' . count($faqItems) . ' item(s) (fields ' . $questionField . '/' . $answerField . ')'); } // --- hide the unconsumed legacy elements ---------------------------------------- $legacy = $content->fetchAllAssociative( 'SELECT uid, CType, header FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND tx_container_parent = 0 AND colPos >= 0 ORDER BY sorting', [$pageUid] ); $hidden = 0; foreach ($legacy as $row) { if (isset($this->consumed[(int)$row['uid']])) { continue; } $this->datamap['tt_content'][(string)(int)$row['uid']]['hidden'] = 1; $hidden++; } $output->writeln(' legacy: ' . $hidden . ' visible element(s) HIDDEN (kept for reference), ' . count($this->consumed) . ' reused'); // --- page properties -------------------------------------------------------------- $heroBody = trim((string)($hero['body'] ?? '')); $seoTitle = 'Products | VITEC'; foreach ([ 'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle, 'description' => $heroBody, 'og_description' => $heroBody, 'twitter_description' => $heroBody, 'abstract' => $heroBody, 'keywords' => (string)($meta['keyword'] ?? ''), 'canonical_link' => 't3://page?uid=' . $pageUid, ] as $field => $value) { $this->datamap['pages'][$pageUid][$field] = $value; } $output->writeln(' page meta: seo/og/twitter + abstract + keywords ("' . ($meta['keyword'] ?? '') . '")'); if (!$apply) { $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($this->datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } $this->applySequence($content, $dataHandler->substNEWwithIDs); $output->writeln('Done. Flush the frontend cache.'); return Command::SUCCESS; } // ---- helpers (pattern of vitec:import-product-category-pages) --------------------- private function faqColumn(ConnectionPool $pool, string $base): string { $conn = $pool->getConnectionForTable('vitec_faq_items'); $columns = array_column($conn->fetchAllAssociative('SHOW COLUMNS FROM vitec_faq_items'), 'Field'); return in_array($base, $columns, true) ? $base : 'vitec_' . $base; } /** @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; } $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; } private function pageLink(string $slug): string { $row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages') ->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$slug]); return $row ? 't3://page?uid=' . (int)$row['uid'] : ''; } /** @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) === '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 slotNext(int $pageUid, int $colPos, string $ctype): string { $key = $colPos . '/' . $ctype; if (!isset($this->slotCursor[$key])) { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); $this->slotCursor[$key] = array_map('intval', $connection->fetchFirstColumn( 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? AND tx_container_parent = 0 ORDER BY sorting', [$pageUid, $colPos, $ctype] )); } $uid = array_shift($this->slotCursor[$key]); if ($uid !== null) { $this->consumed[$uid] = true; $this->sequence[$colPos][] = (string)$uid; $this->datamap['tt_content'][(string)$uid]['hidden'] = 0; return (string)$uid; } $id = $this->newId(); $this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => $colPos, 'CType' => $ctype]; $this->sequence[$colPos][] = $id; return $id; } /** @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($connection, array $substNEWwithIDs): void { 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], ['uid' => $uid]); } } } } private function p(string $text): string { $text = trim($text); // ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu ', // und die RTE-Persistenz escapte das & gleich nochmal (' im JSON) return $text === '' ? '' : '

' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '

'; } private function norm(string $value): string { $value = mb_strtolower($value); $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 'NEWPF' . (++$this->newCounter); } }