/ - they are switched to layout 5; missing pages are * created HIDDEN as with markets. * - intro carries a headline (vitec_intro_headline) and no learn-more / * portfolio (solution design); hero has no video teaser. * - detail_page is set on tx_vitec_domain_model_solution. * * Shared lessons (same as the market importer): named ranges first, then * label-scan over the locked column A; content-idempotent file attach; * sys_file_reference needs pid + tablenames + uid_foreign. */ final class SubsolutionWorkbookImporter { private const LAYOUT = 5; private const COLPOS_HERO = 1; private const COLPOS_MAIN = 0; private const CARD_ROWS = 8; private const STORY_ROWS = 6; private const PRODUCT_ROWS = 9; /** Story detail page; mirrors the site setting vitec.storyDetailPid. */ private const STORY_SINGLE_PID = 26; /** * Curated page keywords per sub-solution (Claude, 2026-10-19 - Olli asked * for generated keywords in the page properties). Keys normalised like * norm(); versioned here so a re-import stays idempotent and the set is * reviewable in one place. */ private const PAGE_KEYWORDS = [ 'controlroomplatformssocnoccommandcentres' => 'control room platform, soc video wall, noc visualisation, command centre technology, control room video management', 'commandcontrolvisualisation' => 'command and control visualisation, c2 video wall, mission control displays, situational command centre', 'operationsmanagementenvironments' => 'operations management, operations control room, operational video platform, operations visualisation', 'crisisincidentmanagementcentres' => 'crisis management centre, incident management, emergency operations centre, eoc video wall', 'multisourcevideomonitoring' => 'multi-source video monitoring, video wall monitoring, multiviewer, simultaneous video sources', 'realtimedatamonitoringcctviotsensors' => 'real-time data monitoring, cctv monitoring, iot sensor visualisation, live data feeds', 'situationalawarenessplatforms' => 'situational awareness, common operating picture, real-time awareness platform, decision support displays', 'surveillanceoperationalvisibility' => 'surveillance visibility, operational monitoring, video surveillance integration, live operational oversight', 'incidentdetectionresponse' => 'incident detection, incident response, alarm visualisation, rapid response video', 'iptvdistribution' => 'iptv distribution, enterprise iptv, iptv system, video over ip, iptv headend, multicast video distribution', 'avoveripdistribution' => 'av over ip, av-over-ip distribution, networked av, video distribution network, av signal distribution', 'securevideostreaminglowlatency' => 'secure video streaming, low latency streaming, encrypted video transport, srt streaming, mission-critical streaming', 'multisitevideodelivery' => 'multi-site video delivery, video distribution multiple locations, enterprise video network, wan video delivery', 'internalexternalbroadcastdistribution' => 'broadcast distribution, internal broadcast, corporate broadcast delivery, live broadcast distribution', 'videoencodingdecoding' => 'video encoder, video decoder, h.264 encoding, hevc encoding, hardware video encoding, video decoding appliance', 'livevideocapture' => 'live video capture, video capture cards, sdi capture, hdmi capture, real-time video ingest', 'signalprocessingconversion' => 'video signal processing, signal conversion, video format conversion, sdi hdmi conversion', 'transcodingoptimisation' => 'video transcoding, stream optimisation, format transcoding, bitrate optimisation, iptv transcoder', 'contributionremoteproduction' => 'contribution encoding, remote production, remi production, live contribution links, broadcast contribution', 'videowallplatforms' => 'video wall platform, video wall controller, video wall processor, large-scale display wall', 'multidisplayvisualisation' => 'multi-display visualisation, multi-screen output, display wall graphics, multi-monitor controller', 'realtimedashboards' => 'real-time dashboards, kpi displays, live data dashboards, operational dashboards', 'dataaggregationdisplay' => 'data aggregation, data visualisation wall, multi-source data display, information aggregation', 'operationalintelligencedisplays' => 'operational intelligence, intelligence displays, business operations visualisation, insight walls', 'digitalsignagenetworks' => 'digital signage network, enterprise signage, signage cms, networked digital displays', 'passengerpublicinformationdisplays' => 'passenger information displays, public information displays, pids, transport signage, flight information displays', 'wayfindingmessaging' => 'digital wayfinding, wayfinding signage, visitor messaging, directional displays', 'advertisingsponsorshipdisplays' => 'advertising displays, sponsorship signage, dooh advertising, venue advertising screens', 'targetedcontentdelivery' => 'targeted content delivery, personalised signage, audience targeting displays, contextual content', 'internalcommunicationsiptv' => 'internal communications iptv, corporate iptv, employee communications video, workplace tv', 'staffmessagingsystems' => 'staff messaging, employee messaging displays, internal messaging system, workforce communication', 'corporatebroadcasting' => 'corporate broadcasting, enterprise video broadcast, town hall streaming, executive broadcasts', 'guestvisitorengagement' => 'guest engagement, visitor engagement displays, hospitality iptv, welcome screens', 'multilocationcommunicationnetworks' => 'multi-location communications, distributed video network, multi-site messaging, global video delivery', 'surveillancecctvintegration' => 'cctv integration, surveillance system integration, vms video wall, camera feed integration', 'securityoperationscentressoc' => 'security operations centre, soc displays, security monitoring wall, soc video integration', 'perimetermonitoring' => 'perimeter monitoring, perimeter security video, fence line surveillance, site protection monitoring', 'incidentresponsesystems' => 'incident response system, security incident video, response coordination displays, alert management', 'securemonitoringenvironments' => 'secure monitoring, classified video environment, secure control room, accredited video systems', ]; /** @var string[] */ private array $report = []; /** @var array>> */ private array $datamap = []; /** @var array>> */ private array $cmdmap = []; /** @var array colPos => element ids (uid or NEW id) in page order */ private array $sequence = []; private int $newCounter = 0; /** * @return array{file:string,group:string,subsolution:string,newPage:bool,pageUid:int,elements:int,applied:bool,report:string[]} * @throws \RuntimeException on fatal problems */ public function importFile(string $file, bool $apply): array { $this->report = []; $this->datamap = []; $this->cmdmap = []; $this->sequence = []; $spreadsheet = IOFactory::load($file); $values = $this->readNamedValues($spreadsheet); $subsolution = trim((string)($values['meta_subsolution'] ?? '')); $group = trim((string)($values['meta_group'] ?? '')); $recordUid = (int)($values['meta_record_uid'] ?? 0); $spreadsheet->disconnectWorksheets(); if ($subsolution === '' || $group === '') { throw new \RuntimeException('Meta sheet missing - not a generated sub-solution workbook?'); } // --- resolve pages --------------------------------------------------- $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $solutionsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'"); if (!$solutionsRoot) { throw new \RuntimeException('Page /solutions not found.'); } $mainPage = $this->childByTitle((int)$solutionsRoot['uid'], $group); if ($mainPage === null) { throw new \RuntimeException('Solution area page "' . $group . '" not found below /solutions.'); } $subPage = $this->childByTitle((int)$mainPage['uid'], $subsolution); if ($subPage === null) { $pageId = $this->newId(); $this->datamap['pages'][$pageId] = [ 'pid' => (int)$mainPage['uid'], 'title' => $subsolution, 'doktype' => 1, 'layout' => self::LAYOUT, 'hidden' => 1, 'backend_layout' => 'vitec__' . self::LAYOUT, ]; $this->report[] = 'page: CREATE (hidden, review & publish editorially)'; } else { $pageId = (string)(int)$subPage['uid']; $updates = []; if ((int)$subPage['layout'] !== self::LAYOUT) { $updates['layout'] = self::LAYOUT; $updates['backend_layout'] = 'vitec__' . self::LAYOUT; $this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)$subPage['layout'] . ')'; } if ($updates !== []) { $this->datamap['pages'][$pageId] = $updates; } } // --- SEO on the page -------------------------------------------------- // Full page-properties set (Olli, 2026-10-19): OG/Twitter mirror the // SEO fields, canonical points at the page itself, the abstract is // derived from the intro copy, keywords come from PAGE_KEYWORDS. $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; } $keywords = self::PAGE_KEYWORDS[$this->norm($subsolution)] ?? ''; 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 "' . $subsolution . '")' : ''); // --- hero zone (colPos 1) --------------------------------------------- // The blocks below are registered in PAGE ORDER ($this->sequence) and // their sorting is written explicitly after DataHandler ran - see // applySequence() for why the creation order cannot be relied on. $heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero'); $this->setFields('tt_content', $heroId, [ 'vitec_subline' => (string)($values['hero_subline'] ?? ''), 'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''), 'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''), ]); $this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 1); // --- main column: intro ------------------------------------------------- $introId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketintro'); $this->setFields('tt_content', $introId, [ 'vitec_intro_headline' => (string)($values['intro_headline'] ?? ''), 'vitec_intro_text' => (string)($values['intro_text'] ?? ''), 'vitec_show_portfolio' => 0, ]); $this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 2); // --- main column: advantages (intro-paragraph + flip cards) -------------- $flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_introparagraph'); $this->setFields('tt_content', $flowHeaderId, [ 'header' => (string)($values['flow_headline'] ?? ''), 'bodytext' => (string)($values['flow_subtext'] ?? ''), ]); $existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_MAIN, 'vitec_card') : []; $cardIndex = 0; for ($i = 1; $i <= self::CARD_ROWS; $i++) { $title = trim((string)($values['flow_card' . $i . '_title'] ?? '')); $front = trim((string)($values['flow_card' . $i . '_front'] ?? '')); $back = trim((string)($values['flow_card' . $i . '_back'] ?? '')); $icon = trim((string)($values['flow_card' . $i . '_icon'] ?? '')); if ($title === '' && $front === '') { continue; } $cardId = $existingCards[$cardIndex] ?? $this->newId(); if (!isset($existingCards[$cardIndex])) { $this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId); $this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_MAIN; $this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card'; } $this->sequence[self::COLPOS_MAIN][] = (string)$cardId; $this->setFields('tt_content', (string)$cardId, [ 'vitec_layout_variant' => 'flipcard', 'header' => $title, 'bodytext' => $front, 'vitec_back_headline' => $title, 'vitec_back_text' => $back, ]); if ($icon !== '') { $this->attachIconFile((string)$cardId, $i, $icon, $apply, $this->pidValue($pageId)); } $cardIndex++; } if (count($existingCards) > $cardIndex) { $this->report[] = 'advantages: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially'; } // --- main column: testimonials ------------------------------------------- $storyUids = []; foreach (range(1, self::STORY_ROWS) as $i) { $storyTitle = trim((string)($values['testi_story_' . $i] ?? '')); if ($storyTitle === '') { continue; } $story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle); if ($story === null) { $this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped'; continue; } if (trim((string)$story['quote']) === '') { $this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record'; } $storyUids[] = (int)$story['uid']; } $testiId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_usecaselist'); $this->setFields('tt_content', $testiId, [ 'header' => (string)($values['testi_headline'] ?? ''), 'pi_flexform' => $this->flex([ 'settings.layout' => 'testimonials', 'settings.usecases' => implode(',', $storyUids), 'settings.singlePid' => (string)self::STORY_SINGLE_PID, 'settings.showtoolbar' => '0', ]), ]); // --- main column: products ------------------------------------------------- $featuredTitle = trim((string)($values['prod_featured'] ?? '')); $featuredUid = 0; if ($featuredTitle !== '') { $featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle); // Family labels (Joe's vocabulary) resolve to their representative product. if ($featured === null && ($featured = ProductFamilyAliases::resolve($this->norm($featuredTitle))) !== null) { $this->report[] = 'featured product "' . $featuredTitle . '" -> family alias "' . $featured['title'] . '"'; } $featured === null ? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND' : $featuredUid = (int)$featured['uid']; } $featureId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productfeature'); $this->setFields('tt_content', $featureId, [ 'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]), ]); $productUids = []; foreach (range(1, self::PRODUCT_ROWS) as $i) { $productTitle = trim((string)($values['prod_card_' . $i] ?? '')); if ($productTitle === '') { continue; } $product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle); if ($product === null && ($product = ProductFamilyAliases::resolve($this->norm($productTitle))) !== null) { $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" -> family alias "' . $product['title'] . '"'; } $product === null ? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped' : $productUids[] = (int)$product['uid']; } $listId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productlist'); $this->setFields('tt_content', $listId, [ 'pi_flexform' => $this->flex([ 'settings.selectedproducts' => implode(',', $productUids), 'settings.layout' => '0', 'settings.showtoolbar' => '0', ]), ]); // --- main column: insights --------------------------------------------------- $insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketinsights'); $this->setFields('tt_content', $insightsId, [ 'vitec_text1' => (string)($values['insights_text1'] ?? ''), 'vitec_text2' => (string)($values['insights_text2'] ?? ''), 'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''), 'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''), 'vitec_video_person' => (string)($values['insights_video_person'] ?? ''), 'vitec_video_role' => (string)($values['insights_video_role'] ?? ''), 'vitec_video_title' => (string)($values['insights_video_title'] ?? ''), 'vitec_video_url' => (string)($values['insights_video_url'] ?? ''), ]); $this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 3); // --- solution record link ----------------------------------------------------- if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) { $this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = (int)$pageId; } elseif ($recordUid > 0) { $this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = $pageId; } else { $this->report[] = 'no solution record for this sub-solution - detail_page not set'; } $isNewPage = str_starts_with($pageId, 'NEW'); $result = [ 'file' => basename($file), 'group' => $group, 'subsolution' => $subsolution, 'newPage' => $isNewPage, 'pageUid' => $isNewPage ? 0 : (int)$pageId, '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); if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) { $result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId]; } $result['report'] = $this->report; return $result; } // ---- helpers ------------------------------------------------------------- /** @return array name => first-cell value */ private function readNamedValues(Spreadsheet $spreadsheet): array { $values = []; foreach ($spreadsheet->getNamedRanges() as $namedRange) { $name = $namedRange->getName(); $sheet = $namedRange->getWorksheet(); if ($sheet === null) { continue; } $range = str_replace('$', '', (string)$namedRange->getValue()); $range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range; $cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range; $values[$name] = $sheet->getCell($cell)->getValue(); } if (isset($values['meta_subsolution'])) { return $values; } // Names are gone - scan by the LOCKED labels in column A instead // (same Excel round-trip lesson as the sub-market importer). $meta = $spreadsheet->getSheetByName('Meta'); $content = $spreadsheet->getSheetByName('Content'); if ($meta === null || $content === null) { return $values; // caller reports "Meta sheet missing" } $this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead'; $values['meta_subsolution'] = $meta->getCell('B1')->getValue(); $values['meta_group'] = $meta->getCell('B2')->getValue(); $values['meta_record_uid'] = $meta->getCell('B3')->getValue(); $section = 0; $inCardTable = false; $cardIndex = 0; $highestRow = min(200, (int)$content->getHighestRow()); for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) { $label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue())); if (preg_match('/^section (\d)/', $label, $m)) { $section = (int)$m[1]; $inCardTable = false; continue; } if ($inCardTable && $section === 3) { $icon = trim((string)$content->getCell('A' . $rowNum)->getValue()); $title = trim((string)$content->getCell('B' . $rowNum)->getValue()); $front = trim((string)$content->getCell('C' . $rowNum)->getValue()); $back = trim((string)$content->getCell('D' . $rowNum)->getValue()); if ($icon === '' && $title === '' && $front === '' && $back === '') { continue; } $cardIndex++; if ($cardIndex <= self::CARD_ROWS) { $values['flow_card' . $cardIndex . '_icon'] = $icon; $values['flow_card' . $cardIndex . '_title'] = $title; $values['flow_card' . $cardIndex . '_front'] = $front; $values['flow_card' . $cardIndex . '_back'] = $back; } continue; } if ($label === '') { continue; } if ($section === 3 && $label === 'icon') { $inCardTable = true; continue; } $label = rtrim($label, ' *'); $b = $content->getCell('B' . $rowNum)->getValue(); $name = match (true) { $label === 'subline' => 'hero_subline', $label === 'cta button label' => 'hero_cta_label', $label === 'cta button link (url)' => 'hero_cta_url', $label === 'background image url' => 'hero_bgimage_url', $label === 'headline' && $section === 2 => 'intro_headline', $label === 'intro text' => 'intro_text', $label === 'image url (right)' => 'intro_image_url', $label === 'headline' && $section === 3 => 'flow_headline', $label === 'subtext' => 'flow_subtext', $label === 'headline' && $section === 4 => 'testi_headline', str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11), $label === 'featured product' => 'prod_featured', str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13), $label === 'paragraph 1' => 'insights_text1', $label === 'paragraph 2' => 'insights_text2', $label === 'cta label' => 'insights_cta_label', $label === 'cta target' => 'insights_cta_target', $label === 'video card: person' => 'insights_video_person', $label === 'video card: role / title' => 'insights_video_role', $label === 'video card: video title' => 'insights_video_title', $label === 'video card: video url' => 'insights_video_url', $label === 'video card: cover image url' => 'insights_video_cover_url', $label === 'seo title' => 'seo_title', $label === 'seo description' => 'seo_description', default => null, }; if ($name !== null && !array_key_exists($name, $values)) { $values[$name] = $b; } } return $values; } /** @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; } /** Existing element uid for a single-element slot, or a NEW id. */ private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string { if ($subPage !== null) { $existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype); if ($existing !== []) { $this->sequence[$colPos][] = (string)$existing[0]; return (string)$existing[0]; } } $id = $this->newId(); $this->datamap['tt_content'][$id] = [ 'pid' => $this->pidValue($pageId), 'colPos' => $colPos, 'CType' => $ctype, ]; $this->sequence[$colPos][] = $id; return $id; } /** * Writes tt_content.sorting in page order. Necessary because DataHandler * puts every NEW record created with a positive pid at the TOP of its * column - a page built in one run came out exactly reversed (found * 2026-10-07). `sorting` is no TCA column, so it cannot travel through * the datamap; a direct update is the only way. Runs on every apply, so * a re-import also repairs pages imported before this fix. * * @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]); } } } } /** @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] )); } /** @param array $fields */ private function setFields(string $table, string $id, array $fields): void { foreach ($fields as $field => $value) { $this->datamap[$table][$id][$field] = $value; } } /** NEW page ids must stay strings in datamap pid references. */ private function pidValue(string $pageId): int|string { return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId; } /** @return array|null */ private function recordByTitle(string $table, string $title): ?array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); foreach ($connection->fetchAllAssociative( 'SELECT * FROM ' . $table . ' WHERE deleted = 0', [] ) as $row) { if ($this->norm((string)$row['title']) === $this->norm($title)) { return $row; } } return null; } /** * Download a URL into fileadmin/subsolution_import// and attach it * as the element's file field. Editorial review happens in the backend; * a failed download is a warning. Skipped entirely in dry runs. */ private function attachUrlFile(string $elementId, string $fieldName, string $url, string $subsolution, bool $apply, int|string $pid = 0, int $slot = 0): void { $url = trim($url); if ($url === '' || !str_starts_with($url, 'http')) { $this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot); return; } if (!$apply) { $this->report[] = $fieldName . ': would download ' . $url; return; } try { $response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class) ->request($url, 'GET', ['timeout' => 20]); if ($response->getStatusCode() !== 200) { $this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially'; $this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot); return; } $content = (string)$response->getBody(); if ($content === '') { $this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially'; $this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot); return; } $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $folderPath = 'subsolution_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $subsolution)); $folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath); $fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin'); $temp = GeneralUtility::tempnam('subsolution_'); file_put_contents($temp, $content); $file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE); $state = $this->attachFalFile($elementId, $fieldName, $file, $pid); $this->report[] = match ($state) { 'unchanged' => $fieldName . ': ' . $fileName . ' already attached - unchanged', 'swapped' => $fieldName . ': ' . $fileName . ' swapped onto the existing reference', default => $fieldName . ': downloaded ' . $fileName . ' - review editorially', }; } catch (\Throwable $e) { $this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially'; $this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot); } } /** * Attach one already-indexed FAL file to an element's file field, * re-import-idempotent BY CONTENT (lesson 2026-10-02): reuse the first * live reference, delete our own surplus duplicates. Returns 'unchanged', * 'swapped' or 'attached' for the caller's report line. */ 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'; } /** * Attach a flow-card icon by its workbook name. The SVGs under * fileadmin/icons/flow/.svg start as labelled DUMMIES (2026-10-19); * the real icons later REPLACE those files in the Filelist module (same * sys_file uid), so every card follows without a re-import. An unknown * name is reported and skipped - probably a workbook typo. */ private function attachIconFile(string $elementId, int $cardNo, string $iconName, bool $apply, int|string $pid): void { $identifier = 'icons/flow/' . mb_strtolower(trim($iconName)) . '.svg'; $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); if (!$storage->hasFile($identifier)) { $this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" has no file fileadmin/' . $identifier . ' - NOT attached (typo in the workbook?)'; return; } if (!$apply) { $this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" -> would attach ' . $identifier; return; } $state = $this->attachFalFile($elementId, 'vitec_icon', $storage->getFile($identifier), $pid); if ($state !== 'unchanged') { $this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" ' . ($state === 'swapped' ? 'swapped onto the existing reference' : 'attached (' . $identifier . ')'); } } /** * Page abstract from the intro copy: tags stripped, whitespace collapsed, * cut at a word boundary near 300 characters; the SEO description is the * fallback when the workbook carries no intro text. */ 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}"; } /** * Grey stand-in for an empty or failed image slot (Olli, 2026-10-19): * every image slot carries a visible placeholder until the real asset * arrives. A slot that already carries ANY reference - a real image from * an earlier run or attached editorially - is never touched; a later * re-import with a real URL swaps the placeholder away through * attachFalFile's content idempotency. */ 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(); // 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). $identifier = null; if ($slot > 0 && !str_starts_with((string)$pid, 'NEW')) { $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'; } } /** @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] ) ); } /** * DataHandler flexform array for sheet sDEF. * * @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); } private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); } }