per sub-market (25, grouped by the 9 main markets): VITEC-styled Read me, a Page-layout sheet embedding the Figma design with section markers, DB-live dropdowns, soft character limits, a hidden Meta sheet and named input cells. Names must NOT start with a cell-reference token - Excel silently strips s1_-style names as repairs. vitec:import-submarket-workbooks (dry-run default, idempotent) finds or creates the page below its main market (hidden, layout 21), fills the six colPos slots updating in place and never deleting, links detail_page on the market record, downloads asset URLs into fileadmin/submarket_import/ for editorial review and writes the flexforms as DataHandler arrays. Reading is three-layered after a bruising day of real-world Excel: named ranges first, then a label scan over the locked column A with section tracking - a customer save stripped ALL defined names and shifted rows, so fixed addresses are not a contract. Learned and fixed along the way: DuplicationBehavior moved to Resource\Enum in v14; sys_file_reference rows via DataHandler need pid/tablenames/uid_foreign or are dropped silently; handing DataHandler a fresh NEW reference per run STACKS live references on re-import (caught by a double-run test), so attachments are now content-idempotent - the existing live reference is reused or retargeted and surplus duplicates are deleted via cmdmap; RequestFactory replaces GeneralUtility::getUrl so download failures carry their reason - which surfaced that the dev basic auth blocked the server itself (server IP + loopback now allowlisted, which a future SSR needs anyway) and that the core resource-consistency check rightly rejects HTML masquerading as PNG. Verified end to end on Broadcast & Media from a clean slate, twice in a row: 12 elements in colPos 110-160, layout-21 payload, both images attached exactly once with publicUrl in the JSON; second run reports nothing but unchanged.
660 lines
32 KiB
PHP
660 lines
32 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Command;
|
|
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
|
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 the customer-filled sub-market workbooks (produced by
|
|
* vitec:generate-submarket-workbooks) into layout-21 pages.
|
|
*
|
|
* Reads ONLY named ranges (hero_subline, testi_story_1, ...) plus the hidden
|
|
* Meta sheet - never cell addresses. Per workbook it
|
|
*
|
|
* 1. resolves the main-market page below /markets via the Meta group,
|
|
* 2. finds or CREATES the sub-market page (layout 21, HIDDEN on first
|
|
* creation - editorial review publishes it),
|
|
* 3. fills the six colPos slots (110-160) - existing elements are updated
|
|
* in place, missing ones created; nothing is deleted,
|
|
* 4. sets detail_page on the market record (Meta record_uid),
|
|
* 5. downloads asset URLs to fileadmin/submarket_import/<page>/ and
|
|
* attaches them (review/crop stays editorial - a failed download is a
|
|
* warning, never an abort).
|
|
*
|
|
* Dry run by default; --apply writes. Idempotent: re-importing an unchanged
|
|
* workbook updates fields to the same values. Flip-card icons are NOTED but
|
|
* not attached yet - the icon set is still open with the front-end team.
|
|
*
|
|
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in
|
|
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:import-submarket-workbooks',
|
|
description: 'Import filled sub-market workbooks into layout-21 pages (dry-run by default)'
|
|
)]
|
|
final class ImportSubmarketWorkbooksCommand extends Command
|
|
{
|
|
private const LAYOUT = 21;
|
|
private const COLPOS_HERO = 110;
|
|
private const COLPOS_INTRO = 120;
|
|
private const COLPOS_FLOW = 130;
|
|
private const COLPOS_TESTI = 140;
|
|
private const COLPOS_PROD = 150;
|
|
private const COLPOS_INSIGHTS = 160;
|
|
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 = [];
|
|
private int $newCounter = 0;
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/submarkets_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('<error>Directory not found: ' . $dir . '</error>');
|
|
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) {
|
|
$this->report = [];
|
|
$this->datamap = [];
|
|
$this->cmdmap = [];
|
|
try {
|
|
$this->importOne($file, $apply, $output);
|
|
} catch (\Throwable $e) {
|
|
$errors++;
|
|
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
|
}
|
|
}
|
|
if (!$apply) {
|
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
|
}
|
|
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
|
}
|
|
|
|
private function importOne(string $file, bool $apply, OutputInterface $output): void
|
|
{
|
|
$spreadsheet = IOFactory::load($file);
|
|
$values = $this->readNamedValues($spreadsheet);
|
|
$submarket = trim((string)($values['meta_submarket'] ?? ''));
|
|
$group = trim((string)($values['meta_group'] ?? ''));
|
|
$recordUid = (int)($values['meta_record_uid'] ?? 0);
|
|
$spreadsheet->disconnectWorksheets();
|
|
if ($submarket === '' || $group === '') {
|
|
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
|
|
}
|
|
|
|
$output->writeln(sprintf("\n<info>%s</info> (%s / %s)", basename($file), $group, $submarket));
|
|
|
|
// --- resolve pages ---------------------------------------------------
|
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
|
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
|
if (!$marketsRoot) {
|
|
throw new \RuntimeException('Page /markets not found.');
|
|
}
|
|
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
|
|
if ($mainPage === null) {
|
|
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
|
|
}
|
|
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
|
|
|
|
$pageId = null;
|
|
if ($subPage === null) {
|
|
$pageId = $this->newId();
|
|
$this->datamap['pages'][$pageId] = [
|
|
'pid' => (int)$mainPage['uid'],
|
|
'title' => $submarket,
|
|
'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 -> 21 (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;
|
|
}
|
|
|
|
// --- slot 110: hero ---------------------------------------------------
|
|
$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'] ?? ''),
|
|
'vitec_video_title' => (string)($values['hero_video_title'] ?? ''),
|
|
'vitec_video_url' => (string)($values['hero_video_url'] ?? ''),
|
|
]);
|
|
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
$this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
|
|
// --- slot 120: intro ----------------------------------------------------
|
|
$introId = $this->slotElement($pageId, $subPage, self::COLPOS_INTRO, 'vitec_marketintro');
|
|
$this->setFields('tt_content', $introId, [
|
|
'vitec_intro_text' => (string)($values['intro_text'] ?? ''),
|
|
'vitec_learnmore_label' => (string)($values['intro_learnmore_label'] ?? ''),
|
|
'vitec_learnmore_target' => (string)($values['intro_learnmore_target'] ?? ''),
|
|
'vitec_show_portfolio' => strcasecmp(trim((string)($values['intro_portfolio'] ?? 'Show')), 'Hide') === 0 ? 0 : 1,
|
|
]);
|
|
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
|
|
// --- slot 130: workflow (intro-paragraph + flip cards) ------------------
|
|
$flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_FLOW, '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_FLOW, '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_FLOW;
|
|
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
|
|
}
|
|
$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[] = 'workflow: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially';
|
|
}
|
|
|
|
// --- slot 140: 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_TESTI, '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',
|
|
]),
|
|
]);
|
|
|
|
// --- slot 150: 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_PROD, '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_PROD, 'vitec_productlist');
|
|
$this->setFields('tt_content', $listId, [
|
|
'pi_flexform' => $this->flex([
|
|
'settings.selectedproducts' => implode(',', $productUids),
|
|
'settings.layout' => '0',
|
|
'settings.showtoolbar' => '0',
|
|
]),
|
|
]);
|
|
|
|
// --- slot 160: insights ---------------------------------------------------
|
|
$insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_INSIGHTS, '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'] ?? ''), $submarket, $apply, $this->pidValue($pageId));
|
|
|
|
// --- market record link -----------------------------------------------------
|
|
if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) {
|
|
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = (int)$pageId;
|
|
} elseif ($recordUid > 0) {
|
|
$this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = $pageId;
|
|
} else {
|
|
$this->report[] = 'no market record for this sub-market - detail_page not set';
|
|
}
|
|
|
|
foreach ($this->report as $line) {
|
|
$output->writeln(' <comment>' . $line . '</comment>');
|
|
}
|
|
$elementCount = count($this->datamap['tt_content'] ?? []);
|
|
$output->writeln(sprintf(' page %s · %d element(s) touched · %s',
|
|
str_starts_with($pageId, 'NEW') ? 'NEW' : '#' . $pageId, $elementCount, $apply ? 'writing...' : 'dry run'));
|
|
|
|
if (!$apply) {
|
|
return;
|
|
}
|
|
$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));
|
|
}
|
|
$output->writeln(' <info>written.</info>');
|
|
}
|
|
|
|
// ---- 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_submarket'])) {
|
|
return $values;
|
|
}
|
|
|
|
// Names are gone - scan by the LOCKED labels in column A instead.
|
|
// Fixed cell addresses were tried first and failed the same day: the
|
|
// Excel round-trip that strips the names may ALSO shift rows, so the
|
|
// only thing left to trust is the label text we generated ourselves.
|
|
$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_submarket'] = $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;
|
|
}
|
|
// Inside the card table every row is a card until the next
|
|
// section header - icons are optional, so rows with an empty
|
|
// column A still count when B-D carry content.
|
|
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 === 'video teaser: title' => 'hero_video_title',
|
|
$label === 'video teaser: video url' => 'hero_video_url',
|
|
$label === 'video teaser: cover image url' => 'hero_video_cover_url',
|
|
$label === 'intro text' => 'intro_text',
|
|
$label === 'image url (right)' => 'intro_image_url',
|
|
$label === 'learn-more label' => 'intro_learnmore_label',
|
|
$label === 'learn-more target' => 'intro_learnmore_target',
|
|
str_starts_with($label, 'show "product portfolio"') => 'intro_portfolio',
|
|
$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 !== []) {
|
|
return (string)$existing[0];
|
|
}
|
|
}
|
|
$id = $this->newId();
|
|
$this->datamap['tt_content'][$id] = [
|
|
'pid' => $this->pidValue($pageId),
|
|
'colPos' => $colPos,
|
|
'CType' => $ctype,
|
|
];
|
|
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]
|
|
));
|
|
}
|
|
|
|
/** @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/submarket_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 $submarket, 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 {
|
|
// RequestFactory instead of GeneralUtility::getUrl(): the latter
|
|
// swallows the failure reason, which cost a debugging round on
|
|
// 2026-10-02 (the server could not fetch the URL, the report
|
|
// could not say why).
|
|
$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 = 'submarket_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $submarket));
|
|
$folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath);
|
|
$fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin');
|
|
$temp = GeneralUtility::tempnam('submarket_');
|
|
file_put_contents($temp, $content);
|
|
$file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE);
|
|
|
|
// Re-import idempotency BY CONTENT (lesson 2026-10-02: passing a
|
|
// fresh NEW reference every run made DataHandler STACK live
|
|
// references - two visible images after two runs). An existing
|
|
// element therefore reuses its first live reference; surplus
|
|
// live references on the same field - our own earlier duplicates -
|
|
// are deleted via cmdmap.
|
|
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 rejects the reference (found 2026-10-02: downloads
|
|
// succeeded, attachments silently did not).
|
|
$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)));
|
|
}
|
|
}
|