#submarkets, bg slot 01) * vitec_introparagraph (header = Hero H1, body = hero intro) * colPos 0: vitec_marketintro (Market intro H2/copy + CTA label) * vitec_introparagraph (Sub-markets H2) * vitec_card xN (sub-market cards, text_only, CTA links * to the child page when it exists - * re-run after the sub-market import * resolves the rest) * vitec_customerlogos (Trusted by - HIDDEN shell, logos are * editorial) * vitec_usecaselist (Case studies - HIDDEN shell, selection * is editorial) * vitec_ctabanner (prefooter, two CTA labels) * * Page properties as on the solution pages (og/twitter/canonical/abstract * from the hero intro copy, keywords from MARKET_KEYWORDS). * * NOTE norm(): '&' maps to 'and' here (unlike the sub-market/sub-solution * importers) so the file "Broadcast & Telecoms" matches the page * "Broadcast and Telecoms". Both spellings occur in Joe's delivery. */ final class MarketHeroPageImporter { private const LAYOUT = 23; private const COLPOS_HERO = 1; private const COLPOS_MAIN = 0; private const CARD_ROWS = 6; /** Curated page keywords per market (Claude, 2026-10-23). Keys per norm(). */ private const MARKET_KEYWORDS = [ 'hospitality' => 'hospitality iptv, hotel tv systems, guest entertainment, digital signage hotels, cruise video systems', 'transport' => 'transport video solutions, airport iptv, passenger information displays, transport signage, station video systems', 'sportsandentertainment' => 'stadium iptv, venue video distribution, sports video solutions, fan engagement displays, casino video systems', 'broadcastandtelecoms' => 'broadcast video solutions, telecom video distribution, contribution encoding, media networks, video over ip broadcast', 'education' => 'campus iptv, education video distribution, lecture capture, student digital signage, university video systems', 'corporation' => 'corporate iptv, enterprise video distribution, workplace digital signage, internal communications video', 'industrial' => 'industrial video monitoring, mining control rooms, manufacturing video systems, logistics monitoring', 'criticalnationalinfrastructure' => 'critical infrastructure video, utility control rooms, energy grid monitoring, data centre video walls', 'government' => 'government video solutions, public sector iptv, traffic control rooms, agency video distribution', 'publicsafety' => 'public safety video, emergency operations centre, rtcc video wall, real time crime center technology', 'defenseandsecurity' => 'defense video solutions, military video distribution, isr video, command and control video, secure video systems', ]; /** @var string[] */ private array $report = []; /** @var array>> */ private array $datamap = []; /** @var array>> */ private array $cmdmap = []; /** @var array */ private array $sequence = []; /** @var array */ private array $slotCursor = []; private int $newCounter = 0; /** * @return array{file:string,market:string,pageUid:int,elements:int,applied:bool,report:string[]} */ public function importFile(string $file, bool $apply): array { $this->report = []; $this->datamap = []; $this->cmdmap = []; $this->sequence = []; $this->slotCursor = []; $market = trim((string)preg_replace('/\s+market page$/i', '', (string)preg_replace('/\.xlsx$/i', '', basename($file)))); if ($this->norm($market) === 'marketsheropage') { throw new \RuntimeException('"Markets Hero Page" is the /markets main page (template 2) - own flow, skipped here.'); } $values = $this->readContentSheet($file); // --- resolve the hero page below /markets -------------------------------- $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'"); if (!$root) { throw new \RuntimeException('Page /markets not found.'); } $page = $this->childByTitle((int)$root['uid'], $market); if ($page === null) { throw new \RuntimeException('Market hero page "' . $market . '" not found below /markets - run vitec:align-market-pages first.'); } $pageUid = (int)$page['uid']; $pageId = (string)$pageUid; if ((int)$page['layout'] !== self::LAYOUT) { $this->datamap['pages'][$pageId]['layout'] = self::LAYOUT; $this->datamap['pages'][$pageId]['backend_layout'] = 'vitec__' . self::LAYOUT; $this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)$page['layout'] . ')'; } // --- SEO / page properties ------------------------------------------------ $seoTitle = trim((string)($values['seo title'] ?? '')); $seoDescription = trim((string)($values['seo description'] ?? '')); if ($seoTitle !== '') { $this->setFields('pages', $pageId, [ 'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle, ]); } if ($seoDescription !== '') { $this->setFields('pages', $pageId, [ 'description' => $seoDescription, 'og_description' => $seoDescription, 'twitter_description' => $seoDescription, ]); } $this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId; $abstract = $this->abstractFrom((string)($values['hero intro copy'] ?? ''), $seoDescription); if ($abstract !== '') { $this->datamap['pages'][$pageId]['abstract'] = $abstract; } $keywords = self::MARKET_KEYWORDS[$this->norm($market)] ?? ''; if ($keywords !== '') { $this->datamap['pages'][$pageId]['keywords'] = $keywords; } $this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars) + keywords' . ($keywords === '' ? ' MISSING (no curated set for "' . $market . '")' : ''); // --- hero zone ------------------------------------------------------------- $heroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_markethero'); $this->setFields('tt_content', $heroId, [ 'vitec_cta_label' => (string)($values['hero primary cta'] ?? ''), 'vitec_cta_link' => '#submarkets', ]); $this->attachPlaceholder($heroId, 'vitec_bgimage', $apply, $pageUid, 1); $heroIntroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_introparagraph'); $this->setFields('tt_content', $heroIntroId, [ 'header' => (string)($values['hero h1'] ?? ''), 'bodytext' => $this->p((string)($values['hero intro copy'] ?? '')), ]); // --- main: market intro ----------------------------------------------------- $introId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_marketintro'); $this->setFields('tt_content', $introId, [ 'vitec_intro_headline' => (string)($values['market intro h2'] ?? ''), 'vitec_intro_text' => $this->p((string)($values['market intro copy'] ?? '')), 'vitec_learnmore_label' => (string)($values['market intro secondary cta'] ?? ''), 'vitec_show_portfolio' => 0, ]); if (trim((string)($values['market intro secondary cta'] ?? '')) !== '') { $this->report[] = 'intro CTA "' . trim((string)$values['market intro secondary cta']) . '": set the link target editorially (contact page)'; } // --- main: sub-market cards -------------------------------------------------- $subHeaderId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph'); $this->setFields('tt_content', $subHeaderId, [ 'header' => (string)($values['sub-markets h2'] ?? 'Explore ' . $market), ]); foreach (range(1, self::CARD_ROWS) as $i) { $title = trim((string)($values['sub-market card ' . $i . ' title'] ?? '')); if ($title === '') { continue; } $cardId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card'); $fields = [ 'vitec_layout_variant' => 'text_only', 'header' => $title, 'bodytext' => $this->p((string)($values['sub-market card ' . $i . ' body'] ?? '')), 'vitec_primary_cta_label' => (string)($values['sub-market card ' . $i . ' cta'] ?? 'Learn more'), ]; $child = $this->childByTitle($pageUid, $title); if ($child !== null) { $fields['vitec_primary_cta'] = 't3://page?uid=' . (int)$child['uid']; $this->report[] = 'sub-market card "' . $title . '" -> page #' . (int)$child['uid']; } else { $this->report[] = 'sub-market card "' . $title . '": child page missing - re-run after the sub-market import to set the link'; } $this->setFields('tt_content', $cardId, $fields); } // --- main: trusted by (hidden shell) + case studies (hidden shell) ----------- $logosExisting = $this->slotElements($pageUid, self::COLPOS_MAIN, 'vitec_customerlogos') !== []; $logosId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_customerlogos'); $fields = ['header' => (string)($values['trusted by h2'] ?? 'Trusted by')]; if (!$logosExisting) { $fields['hidden'] = 1; $this->report[] = 'trusted-by: created HIDDEN - pick the customer logos editorially, then unhide'; } $this->setFields('tt_content', $logosId, $fields); $casesExisting = $this->slotElements($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist') !== []; $casesId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist'); $fields = ['header' => (string)($values['case studies h2'] ?? 'Case studies')]; if (!$casesExisting) { $fields['hidden'] = 1; $fields['pi_flexform'] = $this->flex([ 'settings.usecases' => '', 'settings.showtoolbar' => '0', ]); $this->report[] = 'case studies: created HIDDEN - pick the stories editorially, then unhide'; } $this->setFields('tt_content', $casesId, $fields); // --- main: prefooter ----------------------------------------------------------- $preId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_ctabanner'); $this->setFields('tt_content', $preId, [ 'header' => (string)($values['prefooter h2'] ?? ''), 'bodytext' => $this->p((string)($values['prefooter supporting copy'] ?? '')), 'vitec_primary_cta_label' => (string)($values['prefooter primary cta'] ?? ''), 'vitec_secondary_cta_label' => (string)($values['prefooter secondary cta'] ?? ''), 'vitec_layout_variant' => 'centered', 'vitec_background_variant' => 'orange', ]); $this->report[] = 'prefooter CTAs: set the link targets editorially (contact / solutions)'; $result = [ 'file' => basename($file), 'market' => $market, 'pageUid' => $pageUid, 'elements' => count($this->datamap['tt_content'] ?? []), 'applied' => $apply, 'report' => $this->report, ]; if (!$apply) { return $result; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($this->datamap, $this->cmdmap); $dataHandler->process_datamap(); if ($this->cmdmap !== []) { $dataHandler->process_cmdmap(); } if ($dataHandler->errorLog !== []) { throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); } $this->applySequence($dataHandler->substNEWwithIDs); $result['report'] = $this->report; return $result; } // ---- workbook reading -------------------------------------------------------- /** @return array lower-cased column-A label => column-B value, first hit wins */ private function readContentSheet(string $file): array { $spreadsheet = IOFactory::load($file); $content = $spreadsheet->getSheetByName('Content'); if ($content === null) { $spreadsheet->disconnectWorksheets(); throw new \RuntimeException('No "Content" sheet - not a market hero workbook?'); } $values = []; $highestRow = min(200, (int)$content->getHighestRow()); for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) { $label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue())); $label = rtrim($label, ' *'); if ($label === '' || str_starts_with($label, 'section ')) { continue; } if (!array_key_exists($label, $values)) { $values[$label] = $content->getCell('B' . $rowNum)->getValue(); } } $spreadsheet->disconnectWorksheets(); return $values; } private function p(string $text): string { $text = trim($text); return $text === '' ? '' : '

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

'; } /** @return array{uid:int,layout:int}|null */ private function childByTitle(int $parentUid, string $title): ?array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); foreach ($connection->fetchAllAssociative( 'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid] ) as $row) { if ($this->norm((string)$row['title']) === $this->norm($title)) { return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']]; } } return null; } // ---- element plumbing (pattern of the sibling importers) ----------------------- private function slotNext(int $pageUid, int $colPos, string $ctype): string { $key = $colPos . '/' . $ctype; if (!isset($this->slotCursor[$key])) { $this->slotCursor[$key] = $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' => $pageUid, 'colPos' => $colPos, 'CType' => $ctype, ]; $this->sequence[$colPos][] = $id; return $id; } /** @return int[] uids in sorting order */ 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] )); } /** * Writes tt_content.sorting in page order (DataHandler puts NEW records * with a positive pid at the TOP of their column - lesson 2026-10-07). * * @param array $substNEWwithIDs */ private function applySequence(array $substNEWwithIDs): 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], ['uid' => $uid]); } } } } /** @param array $fields */ private function setFields(string $table, string $id, array $fields): void { foreach ($fields as $field => $value) { $this->datamap[$table][$id][$field] = $value; } } /** Grey numbered stand-in, same rules as the sibling importers. */ private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int $pid, int $slot): void { if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) { return; } $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $identifier = null; if ($slot > 0) { $candidate = 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png'; if ($storage->hasFile($candidate)) { $identifier = $candidate; } } $identifier ??= 'placeholders/' . ($fieldName === 'vitec_bgimage' ? 'placeholder-grey-wide.png' : 'placeholder-grey.png'); if (!$storage->hasFile($identifier)) { $this->report[] = $fieldName . ': empty in the workbook and fileadmin/' . $identifier . ' is missing - left empty'; return; } if (!$apply) { $this->report[] = $fieldName . ': empty in the workbook - would attach ' . $identifier; return; } if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($identifier), $pid) !== 'unchanged') { $this->report[] = $fieldName . ': placeholder ' . basename($identifier) . ' attached - swap in the real asset later'; } } /** Content-idempotent FAL attach (lesson 2026-10-02), as in the siblings. */ private function attachFalFile(string $elementId, string $fieldName, \TYPO3\CMS\Core\Resource\File $file, int|string $pid): string { if (!str_starts_with($elementId, 'NEW')) { $existingRefs = $this->liveReferences((int)$elementId, $fieldName); if ($existingRefs !== []) { $first = array_shift($existingRefs); $state = 'unchanged'; if ((int)$first['uid_local'] !== $file->getUid()) { $this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()]; $state = 'swapped'; } foreach ($existingRefs as $surplus) { $this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1; $this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid']; } return $state; } } $refId = $this->newId(); // pid, tablenames and uid_foreign are REQUIRED - without them // DataHandler silently rejects the reference (lesson 2026-10-02). $this->datamap['sys_file_reference'][$refId] = [ 'pid' => $pid, 'table_local' => 'sys_file', 'uid_local' => $file->getUid(), 'tablenames' => 'tt_content', 'fieldname' => $fieldName, 'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId, ]; $this->datamap['tt_content'][$elementId][$fieldName] = $refId; return 'attached'; } /** @return array live refs on one field, oldest first */ private function liveReferences(int $elementUid, string $fieldName): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); return array_map( static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']], $connection->fetchAllAssociative( "SELECT uid, uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content' AND fieldname = ? AND uid_foreign = ? ORDER BY uid", [$fieldName, $elementUid] ) ); } /** Abstract from the hero intro copy, word-boundary cut near 300 chars. */ private function abstractFrom(string $introHtml, string $fallback): string { $plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5))); if ($plain === '') { return $fallback; } if (mb_strlen($plain) <= 300) { return $plain; } $cut = mb_substr($plain, 0, 300); $pos = mb_strrpos($cut, ' '); return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}"; } /** * @param array $settings * @return array */ private function flex(array $settings): array { $fields = []; foreach ($settings as $key => $value) { $fields[$key] = ['vDEF' => $value]; } return ['data' => ['sDEF' => ['lDEF' => $fields]]]; } private function newId(): string { return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); } /** '&' -> 'and' so "Broadcast & Telecoms" matches "Broadcast and Telecoms". */ private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value))); } }