Files
VITEC-website/packages/vitec/Classes/Import/MarketHeroPageImporter.php
T
orascheandClaude Fable 5 303defe24d Markets branch: 11-market tree, sub-market import, hero pipeline
Structure per the agency agreement (Joe/Christina, confirmed 2026-10-23):
/markets -> eleven market hero pages -> their sub-market pages; the
use-case level stays sections on the sub-market pages.

- new vitec:align-market-pages: renames 8 main market pages onto the
  agency names (slug change -> EXT:redirects), creates the 3 new markets,
  hides the dropped "Security & Surveillance" (records/stories
  untouched), reports unknown children. A child-slug pass realigns every
  child to <parent-slug>/<slugified-title> (DataHandler does not cascade
  slug changes; this also repaired the third embedded-slash slug,
  rtcc-/-rtic). --publish unhides the tree (dev convenience; element
  shells stay hidden)
- sub-market import: all 24 agency workbooks applied (4 existing pages,
  20 created) - their Meta sheets were clones of Corporate Environments
  (record_uid 32) and were repaired beforehand (market/sub-market set,
  record_uid 0; the record taxonomy alignment is a separate step)
- SubmarketWorkbookImporter: page-properties parity (og/twitter titles
  and descriptions, canonical, abstract from the intro copy) and a
  placeholder upgrade pass - pages created in the same run only get the
  generic grey (their uid is still NEW), the re-run swaps it for the
  numbered per-page placeholder
- new market hero pipeline: frontend layout 23 "Market Hero Page"
  (zones hero=colPos1, main=colPos0), Import\MarketHeroPageImporter +
  vitec:import-market-hero-workbooks (inbox migrations/markethero_xlsx_in,
  gitignored). Hero H1 travels on the introparagraph header (markethero
  has no headline field), the hero CTA anchors #submarkets, sub-market
  cards link their child page via t3://page, customerlogos and
  usecaselist ship hidden until filled editorially, prefooter ctabanner;
  full page-properties set with 11 curated keyword sets
- applied end to end and live-verified (layout 23 heroes with linked
  cards, layout 21 sub pages with all six colPos groups, clean slugs)
- spec v1.25

Open: market RECORD taxonomy (story badges / Solr facets still use the
old 9 mains), featured product "ISR Video Mission Commander" has no
record, the /markets main page workbook (template 2) is a separate flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 13:52:43 +02:00

482 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Import;
use PhpOffice\PhpSpreadsheet\IOFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Imports ONE agency "Markets hero sub page" workbook (Joe's Dropbox
* delivery, structure decision Olli 2026-10-23) into its market hero page -
* the eleven pages directly below /markets after vitec:align-market-pages.
*
* DELIBERATE SIBLING of the other workbook importers - pipelines evolve
* independently.
*
* Workbook format: sheets Read me / Page layout / Content / Sources / Meta;
* the Content sheet is a label table (column A component, column B copy,
* column C/D guidance). The market is identified by the FILE name (minus a
* " market page" suffix); the hero page must already exist below /markets.
*
* Target template is the NEW layout 23 "Market Hero Page" (zones hero=1,
* main=0). Element plan, in page order:
*
* colPos 1: vitec_markethero (CTA -> #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<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var array<string,array<int,array<string,int>>> */
private array $cmdmap = [];
/** @var array<int,string[]> */
private array $sequence = [];
/** @var array<string,int[]> */
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<string,mixed> 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 === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
}
/** @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<string,int> $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<string,mixed> $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<int,array{uid:int,uid_local:int}> 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<string,string> $settings
* @return array<string,mixed>
*/
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)));
}
}