Sub-solution workbook pipeline on layout 5, fix element order in both importers
- New, deliberately separate pipeline for sub-solutions (markets and solutions must never break each other): generator vitec:generate-subsolution-workbooks (list from the page tree below /solutions, 39 workbooks, every sub-solution matched to its record), SubsolutionWorkbookImporter + CLI vitec:import-subsolution-workbooks, and a "Sub-Solutions" tab in the VITEC Import module with its own inbox migrations/subsolutions_xlsx_in/ - Targets the existing template 5 "Solution Detail" without changing it: hero element in colPos 1, the remaining sections in fixed order in colPos 0, found again by CType on re-import; existing empty sub-solution pages are switched to layout 5, missing ones created hidden; detail_page set on the solution record - Workbook per the Figma "07 Solution Detail page": hero without video teaser, intro with headline and without learn-more/portfolio, extended icon list, own page-layout reference image - Shared content elements: market hero/intro/insights relabelled "Market/Solution", market intro gains the optional intro_headline field (new column, requires extension:setup) - Fix: DataHandler puts every NEW record with a positive pid at the top of its column, so pages built in one run came out reversed. Both importers now track the page order per colPos and write tt_content.sorting explicitly after the DataHandler run; re-applying repairs pages imported earlier (verified on the control-room sub-solution and sub-market page 443) - migrations/fill_test_subsolution.php: realistic test content from vitec.com copy and images for the control-room sub-solution - Docs section 5z
This commit is contained in:
1 parent
3d5459b8d2
commit
8e67f5db23
84 files changed
+1772
-35
No files matched your search
@@ -59,6 +59,8 @@ final class SubmarketWorkbookImporter
|
||||
private array $datamap = [];
|
||||
/** @var array<string,array<int,array<string,int>>> */
|
||||
private array $cmdmap = [];
|
||||
/** @var array<int,string[]> colPos => element ids (uid or NEW id) in page order */
|
||||
private array $sequence = [];
|
||||
private int $newCounter = 0;
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,7 @@ final class SubmarketWorkbookImporter
|
||||
$this->report = [];
|
||||
$this->datamap = [];
|
||||
$this->cmdmap = [];
|
||||
$this->sequence = [];
|
||||
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
$values = $this->readNamedValues($spreadsheet);
|
||||
@@ -171,6 +174,7 @@ final class SubmarketWorkbookImporter
|
||||
$this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW;
|
||||
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
|
||||
}
|
||||
$this->sequence[self::COLPOS_FLOW][] = (string)$cardId;
|
||||
$this->setFields('tt_content', (string)$cardId, [
|
||||
'vitec_layout_variant' => 'flipcard',
|
||||
'header' => $title,
|
||||
@@ -297,6 +301,7 @@ final class SubmarketWorkbookImporter
|
||||
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];
|
||||
}
|
||||
@@ -438,6 +443,7 @@ final class SubmarketWorkbookImporter
|
||||
if ($subPage !== null) {
|
||||
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
|
||||
if ($existing !== []) {
|
||||
$this->sequence[$colPos][] = (string)$existing[0];
|
||||
return (string)$existing[0];
|
||||
}
|
||||
}
|
||||
@@ -447,9 +453,36 @@ final class SubmarketWorkbookImporter
|
||||
'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 - slots holding several elements (130: intro paragraph + cards,
|
||||
* 150: feature + list) came out reversed (found 2026-10-07 on the
|
||||
* sub-solution side). `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<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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return int[] uids in sorting order */
|
||||
private function slotElements(int $pageUid, int $colPos, string $ctype): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
|
||||
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Imports ONE customer-filled sub-solution workbook (produced by
|
||||
* vitec:generate-subsolution-workbooks) into its layout-5 page.
|
||||
*
|
||||
* DELIBERATE SIBLING of SubmarketWorkbookImporter, NOT a shared
|
||||
* implementation (decision 2026-10-05): markets and solutions evolve
|
||||
* independently, changing one must never break the other.
|
||||
*
|
||||
* Differences from the sub-market importer:
|
||||
* - target template is layout 5 "Solution Detail" (zones hero=1, main=0,
|
||||
* sidebar=2), NOT the slotted layout 21: the hero element goes into
|
||||
* colPos 1, everything else into colPos 0 in a fixed order. Elements
|
||||
* are found by CType within their column (each CType appears once,
|
||||
* except the flip cards).
|
||||
* - the sub-solution pages usually ALREADY EXIST as empty shells below
|
||||
* /solutions/<area>/ - 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;
|
||||
|
||||
/** @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[]> 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 --------------------------------------------------
|
||||
$seoTitle = trim((string)($values['seo_title'] ?? ''));
|
||||
$seoDescription = trim((string)($values['seo_description'] ?? ''));
|
||||
if ($seoTitle !== '') {
|
||||
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
|
||||
}
|
||||
if ($seoDescription !== '') {
|
||||
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
|
||||
// --- 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));
|
||||
|
||||
// --- 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->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later';
|
||||
}
|
||||
$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);
|
||||
$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);
|
||||
$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));
|
||||
|
||||
// --- 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<string,mixed> 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<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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @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<string,mixed> $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<string,mixed>|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/<page>/ 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): void
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || !str_starts_with($url, 'http')) {
|
||||
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';
|
||||
return;
|
||||
}
|
||||
$content = (string)$response->getBody();
|
||||
if ($content === '') {
|
||||
$this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially';
|
||||
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);
|
||||
|
||||
// Re-import idempotency BY CONTENT (lesson 2026-10-02): reuse the
|
||||
// first live reference, delete our own surplus duplicates.
|
||||
if (!str_starts_with($elementId, 'NEW')) {
|
||||
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
|
||||
if ($existingRefs !== []) {
|
||||
$first = array_shift($existingRefs);
|
||||
if ((int)$first['uid_local'] === $file->getUid()) {
|
||||
$this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged';
|
||||
} else {
|
||||
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
|
||||
$this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference';
|
||||
}
|
||||
foreach ($existingRefs as $surplus) {
|
||||
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
|
||||
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
$this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially';
|
||||
} catch (\Throwable $e) {
|
||||
$this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially';
|
||||
}
|
||||
}
|
||||
|
||||
/** @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]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DataHandler flexform array for sheet sDEF.
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user