diff --git a/.gitignore b/.gitignore index fb67471..4983fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ migrations/submarkets_xlsx_in/* migrations/subsolutions_xlsx_in/* migrations/solutiongroups_xlsx_in/* migrations/product_images_in/ +migrations/markethero_xlsx_in/ diff --git a/packages/vitec/Classes/Command/AlignMarketPagesCommand.php b/packages/vitec/Classes/Command/AlignMarketPagesCommand.php new file mode 100644 index 0000000..a2aa349 --- /dev/null +++ b/packages/vitec/Classes/Command/AlignMarketPagesCommand.php @@ -0,0 +1,230 @@ + exactly 11 market hero pages -> their sub-market pages + * (the use-case level stays SECTIONS on the sub-market pages, 14.10.) + * + * What it does (dry-run by default): + * - renames 8 of our 9 main market pages onto Joe's market names, slug + * included (the slug change makes EXT:redirects create the redirect) + * - creates the 3 new markets HIDDEN (Critical National Infrastructure, + * Government, Public Safety) + * - hides "Security & Surveillance" (market dropped by the agency - its + * themes live in the solutions branch; records and stories untouched) + * - reports every other child it does not recognise (e.g. the stray + * "Hospitality & Leisure" page) for an editorial decision + * + * DELIBERATELY NOT here: the market RECORD taxonomy (9 mains drive the + * success-story badges, Solr facets and the story mapping + * INDUSTRY_TO_MARKET) - aligning that is a separate, editorially decided + * step. Pages first, so the workbook imports have their tree. + * + * vendor/bin/typo3 vitec:align-market-pages + * vendor/bin/typo3 vitec:align-market-pages --apply + */ +#[AsCommand( + name: 'vitec:align-market-pages', + description: "Align the /markets page tree with Joe's 11-market structure (dry-run by default)" +)] +final class AlignMarketPagesCommand extends Command +{ + /** Joe's market name => our current page title (null = create new). */ + private const MARKETS = [ + 'Hospitality' => 'Retail Hospitality & Leisure', + 'Transport' => 'Transport & Infrastructure', + 'Sports & Entertainment' => 'Sports Venues & Entertainment', + 'Broadcast and Telecoms' => 'Media Broadcast & Telecom', + 'Education' => 'Healthcare & Education', + 'Corporation' => 'Corporate & Enterprise', + 'Industrial' => 'Energy Industrial & Resources', + 'Critical National Infrastructure' => null, + 'Government' => null, + 'Public Safety' => null, + 'Defense and Security' => 'Defense Government & Public Sector', + ]; + + /** Dropped by the agency - page gets hidden, nothing is deleted. */ + private const HIDE = ['Security & Surveillance']; + + protected function configure(): void + { + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + $this->addOption('publish', null, InputOption::VALUE_NONE, 'Also unhide the market pages and their children (dev convenience; the dropped market and element shells stay hidden)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + + $root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'"); + if (!$root) { + $output->writeln('Page /markets not found.'); + return Command::FAILURE; + } + $rootUid = (int)$root['uid']; + $children = $connection->fetchAllAssociative( + 'SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting', + [$rootUid] + ); + $byNorm = []; + foreach ($children as $child) { + $byNorm[$this->norm((string)$child['title'])] = $child; + } + + $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /markets uid ' . $rootUid . ' · ' . count($children) . ' direct children'); + $datamap = []; + $claimed = []; + $targets = []; + $newIndex = 0; + + foreach (self::MARKETS as $joeName => $oldName) { + $joeSlug = '/markets/' . $this->slugify($joeName); + $existingJoe = $byNorm[$this->norm($joeName)] ?? null; + if ($existingJoe !== null) { + $claimed[(int)$existingJoe['uid']] = true; + $targets[] = ['uid' => (int)$existingJoe['uid'], 'slug' => (string)$existingJoe['slug']]; + $line = sprintf('= %-36s exists (uid %d%s)', $joeName, (int)$existingJoe['uid'], ((int)$existingJoe['hidden'] === 1) ? ', hidden' : ''); + $output->writeln(' ' . $line); + continue; + } + $old = $oldName !== null ? ($byNorm[$this->norm($oldName)] ?? null) : null; + if ($old !== null) { + $claimed[(int)$old['uid']] = true; + $datamap['pages'][(int)$old['uid']] = [ + 'title' => $joeName, + 'slug' => $joeSlug, + ]; + $targets[] = ['uid' => (int)$old['uid'], 'slug' => $joeSlug]; + $output->writeln(sprintf(' ~ %-36s RENAME from "%s" (uid %d), slug %s -> %s (redirect follows)', + $joeName, $oldName, (int)$old['uid'], $old['slug'], $joeSlug)); + continue; + } + if ($oldName !== null) { + $output->writeln(sprintf(' ? %-36s neither Joe name nor "%s" found - review', $joeName, $oldName)); + continue; + } + $newId = 'NEWMKT' . (++$newIndex); + $datamap['pages'][$newId] = [ + 'pid' => $rootUid, + 'title' => $joeName, + 'slug' => $joeSlug, + 'doktype' => 1, + 'hidden' => 1, + ]; + $output->writeln(sprintf(' + %-36s CREATE (hidden)', $joeName)); + } + + foreach (self::HIDE as $hideName) { + $page = $byNorm[$this->norm($hideName)] ?? null; + if ($page === null) { + $output->writeln(' hide: "' . $hideName . '" not found - nothing to do'); + continue; + } + $claimed[(int)$page['uid']] = true; + if ((int)$page['hidden'] === 1) { + $output->writeln(' - ' . $hideName . ' already hidden (uid ' . (int)$page['uid'] . ')'); + continue; + } + $datamap['pages'][(int)$page['uid']] = ['hidden' => 1]; + $output->writeln(' - ' . $hideName . ' HIDE (uid ' . (int)$page['uid'] . ') - dropped by the agency, records/stories untouched'); + } + + foreach ($children as $child) { + if (!isset($claimed[(int)$child['uid']])) { + $output->writeln(sprintf(' ! unexpected child "%s" (uid %d%s) - review editorially', + (string)$child['title'], (int)$child['uid'], ((int)$child['hidden'] === 1) ? ', hidden' : '')); + } + } + + // --- optional: publish the tree (dev convenience, Olli 2026-10-23) ------- + // Unhides the 11 market pages and their children. The dropped market + // ("Security & Surveillance") is no target and stays hidden; hidden + // ELEMENT shells (trusted-by, case studies) also stay hidden - an + // unhidden empty story list would render ALL stories. + if ((bool)$input->getOption('publish')) { + foreach ($targets as $target) { + $self = $connection->fetchAssociative('SELECT uid, title, hidden FROM pages WHERE uid = ?', [$target['uid']]); + $rows = $connection->fetchAllAssociative( + 'SELECT uid, title, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', + [$target['uid']] + ); + if ($self) { + array_unshift($rows, $self); + } + foreach ($rows as $row) { + if ((int)$row['hidden'] === 1) { + $datamap['pages'][(int)$row['uid']]['hidden'] = 0; + $output->writeln(' * publish: "' . (string)$row['title'] . '" (uid ' . (int)$row['uid'] . ')'); + } + } + } + } + + // --- child slugs follow their (renamed) parent --------------------------- + // DataHandler does not cascade slug changes to subpages (only the + // backend UI offers that), so realign every child slug to + // /; EXT:redirects covers the old URLs. + // This also normalises slug accidents like embedded slashes from + // titles ("RTCC / RTIC"). + foreach ($targets as $target) { + foreach ($connection->fetchAllAssociative( + 'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', + [$target['uid']] + ) as $child) { + $expected = rtrim((string)$target['slug'], '/') . '/' . $this->slugify((string)$child['title']); + if ((string)$child['slug'] !== $expected) { + $datamap['pages'][(int)$child['uid']]['slug'] = $expected; + $output->writeln(sprintf(' ~ child slug: "%s" (uid %d) %s -> %s (redirect follows)', + (string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected)); + } + } + } + + if (!$apply) { + $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); + return Command::SUCCESS; + } + if ($datamap === []) { + $output->writeln('Nothing to change.'); + 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; + } + $output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } + + private function norm(string $value): string + { + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value))); + } + + private function slugify(string $value): string + { + return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-'); + } +} diff --git a/packages/vitec/Classes/Command/ImportMarketHeroWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportMarketHeroWorkbooksCommand.php new file mode 100644 index 0000000..d8cd232 --- /dev/null +++ b/packages/vitec/Classes/Command/ImportMarketHeroWorkbooksCommand.php @@ -0,0 +1,90 @@ +addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/markethero_xlsx_in'); + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring'); + $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'), '/'); + + if (!is_dir($dir)) { + $output->writeln('Directory not found: ' . $dir . ''); + return Command::FAILURE; + } + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)); + foreach ($iterator as $fileInfo) { + $name = $fileInfo->getFilename(); + if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) { + if ($only === '' || str_contains(mb_strtolower($name), $only)) { + $files[] = $fileInfo->getPathname(); + } + } + } + sort($files); + if ($files === []) { + $output->writeln('No workbooks found in ' . $dir); + return Command::SUCCESS; + } + $output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN')); + + $errors = 0; + foreach ($files as $file) { + try { + $result = $this->importer->importFile($file, $apply); + $output->writeln(sprintf("\n%s (market: %s)", $result['file'], $result['market'])); + foreach ($result['report'] as $line) { + $output->writeln(' ' . $line . ''); + } + $output->writeln(sprintf(' page #%d · %d element(s) touched · %s', + $result['pageUid'], $result['elements'], $apply ? 'written.' : 'dry run')); + } catch (\Throwable $e) { + $errors++; + $output->writeln('' . basename($file) . ': ' . $e->getMessage() . ''); + } + } + if (!$apply) { + $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); + } + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Import/MarketHeroPageImporter.php b/packages/vitec/Classes/Import/MarketHeroPageImporter.php new file mode 100644 index 0000000..529422d --- /dev/null +++ b/packages/vitec/Classes/Import/MarketHeroPageImporter.php @@ -0,0 +1,481 @@ + #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))); + } +} diff --git a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php index 0be920a..162bd95 100644 --- a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php +++ b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php @@ -122,14 +122,29 @@ final class SubmarketWorkbookImporter } // --- SEO on the page -------------------------------------------------- + // Full page-properties set (parity with the sub-solution importer, + // Olli 2026-10-19/23): OG/Twitter mirror the SEO fields, canonical + // points at the page itself, the abstract derives from the intro copy. $seoTitle = trim((string)($values['seo_title'] ?? '')); $seoDescription = trim((string)($values['seo_description'] ?? '')); if ($seoTitle !== '') { $this->datamap['pages'][$pageId]['seo_title'] = $seoTitle; + $this->datamap['pages'][$pageId]['og_title'] = $seoTitle; + $this->datamap['pages'][$pageId]['twitter_title'] = $seoTitle; } if ($seoDescription !== '') { $this->datamap['pages'][$pageId]['description'] = $seoDescription; + $this->datamap['pages'][$pageId]['og_description'] = $seoDescription; + $this->datamap['pages'][$pageId]['twitter_description'] = $seoDescription; } + if (!str_starts_with($pageId, 'NEW')) { + $this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId; + } + $abstract = $this->abstractFrom((string)($values['intro_text'] ?? ''), $seoDescription); + if ($abstract !== '') { + $this->datamap['pages'][$pageId]['abstract'] = $abstract; + } + $this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars); keywords stay editorial for now'; // --- slot 110: hero --------------------------------------------------- $heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero'); @@ -657,10 +672,33 @@ final class SubmarketWorkbookImporter */ private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int|string $pid, int $slot = 0): void { - if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) { - return; - } $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + // A page CREATED by the previous run got the generic grey (its uid was + // still NEW); when the numbered file exists by now, UPGRADE the + // reference on re-run. Any other existing reference stays untouched. + if (!str_starts_with($elementId, 'NEW')) { + $existing = $this->liveReferences((int)$elementId, $fieldName); + if ($existing !== []) { + $numbered = ($slot > 0 && !str_starts_with((string)$pid, 'NEW')) + ? 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png' + : null; + $currentIdentifier = (string)GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('sys_file') + ->fetchOne('SELECT identifier FROM sys_file WHERE uid = ?', [(int)$existing[0]['uid_local']]); + $isGeneric = str_contains($currentIdentifier, '/placeholders/placeholder-grey'); + if ($numbered === null || !$isGeneric || !$storage->hasFile($numbered)) { + return; + } + if (!$apply) { + $this->report[] = $fieldName . ': would upgrade the generic placeholder to ' . $numbered; + return; + } + if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($numbered), $pid) === 'swapped') { + $this->report[] = $fieldName . ': generic placeholder upgraded to ' . basename($numbered); + } + return; + } + } // Numbered per page and slot - "#501-02" is rendered INTO the image - // so a review can name the exact asset to deliver. The generic grey // file covers pages without a pre-rendered set (e.g. brand-new ones). @@ -720,6 +758,24 @@ final class SubmarketWorkbookImporter return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); } + /** + * Page abstract from the intro copy: tags stripped, whitespace collapsed, + * word-boundary cut near 300 characters; SEO description as fallback. + */ + 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}"; + } + private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); diff --git a/packages/vitec/Classes/View/BackendLayoutDataProvider.php b/packages/vitec/Classes/View/BackendLayoutDataProvider.php index 40a375a..6d24ca4 100755 --- a/packages/vitec/Classes/View/BackendLayoutDataProvider.php +++ b/packages/vitec/Classes/View/BackendLayoutDataProvider.php @@ -65,6 +65,8 @@ final class BackendLayoutDataProvider implements DataProviderInterface // Solution group hero pages (Joe's Template 06, import 2026-10-23) - // same hero/main zones as layout 5, so the front end reuses parts. 22 => ['title' => 'Solution Group Page', 'zones' => ['hero', 'main']], + // Market hero pages (Joe's "Markets hero sub page", import 2026-10-23). + 23 => ['title' => 'Market Hero Page', 'zones' => ['hero', 'main']], ]; /** Display name for each zone. Shown as column header in the BE page module. */ diff --git a/packages/vitec/Configuration/TCA/Overrides/pages.php b/packages/vitec/Configuration/TCA/Overrides/pages.php index 1574ae4..3849d14 100755 --- a/packages/vitec/Configuration/TCA/Overrides/pages.php +++ b/packages/vitec/Configuration/TCA/Overrides/pages.php @@ -33,6 +33,7 @@ defined('TYPO3') or die(); ['label' => $l . 'layout.20', 'value' => '20'], ['label' => $l . 'layout.21', 'value' => '21'], ['label' => $l . 'layout.22', 'value' => '22'], + ['label' => $l . 'layout.23', 'value' => '23'], ]; $GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0'; diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index 9e0bd2a..3c34a7c 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -3,7 +3,7 @@ | | | |---|---| | **Document identifier** | EVO‑VITEC‑HL‑001 | -| **Version** | 1.24 | +| **Version** | 1.25 | | **Status** | Released | | **Date** | 2026‑10‑23 | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | @@ -37,6 +37,7 @@ | 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). | | 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). | | 1.24 | 2026‑10‑23 | **Interface change (additive).** New frontend layout **22 Solution Group Page** for the eight solution group hero pages (zones hero = `colPos1`, main = `colPos0`). Element order: `vitec_markethero` + `vitec_introparagraph` in the hero zone; `vitec_marketintro` 2, `vitec_introparagraph` + 4 `text_only` cards (challenges), `vitec_introparagraph` + 4 alternating `horizontal`/`horizontal_reverse` cards (capabilities, bullets as `
    `), `vitec_usecaselist` (testimonials, shipped hidden until stories are picked), `vitec_marketinsights`, `vitec_ctabanner` in main — imported from the agency Template‑06 workbooks by `vitec:import-solution-group-workbooks`; page properties as on the sub‑solution pages. | +| 1.25 | 2026‑10‑23 | **Interface change (additive).** The markets branch is aligned with the agreed agency structure: `/markets` eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout **23 Market Hero Page** (zones hero = `colPos1`, main = `colPos0`): `vitec_markethero` (CTA anchors `#submarkets`) + `vitec_introparagraph` (header carries the hero H1) in the hero zone; `vitec_marketintro`, `vitec_introparagraph`, sub‑market `vitec_card`s whose `primary_cta` links the child page, `vitec_ctabanner` in main (`vitec_customerlogos` and `vitec_usecaselist` ship hidden until filled editorially). Imported by `vitec:import-market-hero-workbooks`; the tree itself by `vitec:align-market-pages` (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set. | This document is drafted in the style of, and adopts the terminology conventions of, ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for @@ -1613,4 +1614,4 @@ remediation. - Header convention — the uniform header section across CEs, plugins and containers. - `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point. -*End of document EVO‑VITEC‑HL‑001 v1.24.* +*End of document EVO‑VITEC‑HL‑001 v1.25.* diff --git a/packages/vitec/Resources/Private/Language/locallang_pages.xlf b/packages/vitec/Resources/Private/Language/locallang_pages.xlf index 81379a6..9af865f 100755 --- a/packages/vitec/Resources/Private/Language/locallang_pages.xlf +++ b/packages/vitec/Resources/Private/Language/locallang_pages.xlf @@ -72,6 +72,9 @@ Solution Group Page + + Market Hero Page +