Sub-market workbook import as a backend module tab

- Extract the complete import logic (label-scan reading, page
  find-or-create, slot filling, content-idempotent image attach) from
  ImportSubmarketWorkbooksCommand into the shared service
  SubmarketWorkbookImporter; the CLI command and the new controller are
  thin shells around importFile(), so both entry points cannot drift.
  CLI regression verified: identical dry-run report for the reference
  workbook (12 elements, page 443)
- New "Sub-Markets" tab in the VITEC Import module
  (SubmarketImportController + Import/Submarkets.html): upload multiple
  filled workbooks into the SAME inbox the CLI uses
  (migrations/submarkets_xlsx_in/), per-file checkboxes, dry-run and
  apply buttons, result cards with the familiar report and a direct
  link into the page module; backend dry-run verified against the CLI
- Submitted filenames are whitelisted against the actual directory
  listing (no path traversal); uploads restricted to sanitized .xlsx
  basenames
- Routes submarkets/submarket_upload/submarket_process in Modules.php,
  Sub-Markets tab link in the sibling module views, ok/danger badge
  colors in backend-import.css
- Docs section 5y (incl. operations note: apply in small batches in the
  module, mass runs via CLI - image downloads run inside the web
  request)
This commit is contained in:
orasche committed 2026-10-05 15:35:40 +02:00
1 parent d496877a53
commit 3d5459b8d2
9 files changed
+980 -585

No files matched your search

@@ -4,40 +4,18 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Evomedien\Vitec\Import\SubmarketWorkbookImporter;
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.
* CLI shell around SubmarketWorkbookImporter (the shared implementation also
* used by the "VITEC Import" backend module since 2026-10-05 - behavior and
* lessons are documented THERE).
*
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in
* vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply
@@ -48,27 +26,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
)]
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;
public function __construct(private readonly SubmarketWorkbookImporter $importer)
{
parent::__construct();
}
protected function configure(): void
{
@@ -107,11 +68,16 @@ final class ImportSubmarketWorkbooksCommand extends Command
$errors = 0;
foreach ($files as $file) {
$this->report = [];
$this->datamap = [];
$this->cmdmap = [];
try {
$this->importOne($file, $apply, $output);
$result = $this->importer->importFile($file, $apply);
$output->writeln(sprintf("\n<info>%s</info> (%s / %s)", $result['file'], $result['group'], $result['submarket']));
foreach ($result['report'] as $line) {
$output->writeln(' <comment>' . $line . '</comment>');
}
$output->writeln(sprintf(' page %s · %d element(s) touched · %s',
$result['newPage'] && $result['pageUid'] === 0 ? 'NEW' : '#' . $result['pageUid'],
$result['elements'],
$apply ? 'written.' : 'dry run'));
} catch (\Throwable $e) {
$errors++;
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
@@ -122,538 +88,4 @@ final class ImportSubmarketWorkbooksCommand extends Command
}
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)));
}
}