Files
VITEC-website/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php
T
orasche 8e67f5db23 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
2026-10-07 15:39:18 +02:00

621 lines
29 KiB
PHP

<?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)));
}
}