vitec:generate-submarket-workbooks builds one protected customer workbook

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.
This commit is contained in:
orasche committed 2026-10-02 13:24:54 +02:00
1 parent 90ae6803a4
commit 8b175211ea
12 files changed
+1214 -6

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Read-only: did the sub-market import attach its files?
* Lists sys_file rows under submarket_import/ and every sys_file_reference
* on page 443's content elements, plus the elements' file-field counters.
*
* Usage: php migrations/check_submarket_refs.php
*/
$root = null;
$dir = __DIR__;
for ($i = 0; $i < 5; $i++) {
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
$dir = dirname($dir);
}
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
$settings = require $root . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '', $db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
echo "=== sys_file unter submarket_import/ ===\n";
foreach ($pdo->query("SELECT uid, identifier, name FROM sys_file WHERE identifier LIKE '/submarket_import/%'") as $row) {
echo " #{$row['uid']} {$row['identifier']}\n";
}
echo "\n=== tt_content auf Seite 443 (Slots 110-160): Datei-Zaehler ===\n";
foreach ($pdo->query("SELECT uid, CType, colPos, vitec_bgimage, vitec_image, vitec_video_cover FROM tt_content WHERE deleted = 0 AND pid = 443 AND colPos >= 110 ORDER BY colPos") as $row) {
echo " #{$row['uid']} {$row['CType']} (colPos {$row['colPos']}): bgimage={$row['vitec_bgimage']} image={$row['vitec_image']} video_cover={$row['vitec_video_cover']}\n";
}
echo "\n=== sys_file_reference auf diese Elemente ===\n";
$stmt = $pdo->query("SELECT r.uid, r.uid_local, r.uid_foreign, r.fieldname, r.deleted, r.hidden, f.identifier
FROM sys_file_reference r LEFT JOIN sys_file f ON f.uid = r.uid_local
WHERE r.tablenames = 'tt_content' AND r.uid_foreign IN (SELECT uid FROM tt_content WHERE pid = 443 AND colPos >= 110)");
$found = 0;
foreach ($stmt as $row) {
$found++;
echo " ref #{$row['uid']} -> CE #{$row['uid_foreign']} field {$row['fieldname']} file {$row['identifier']} deleted={$row['deleted']} hidden={$row['hidden']}\n";
}
if ($found === 0) {
echo " KEINE Referenzen gefunden.\n";
}
Binary file not shown.
@@ -0,0 +1,493 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\NamedRange;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Protection;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
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\Utility\GeneralUtility;
/**
* Generates one customer-facing XLSX per SUB-MARKET for the sub-market page
* template (field spec: Template_Sub-Market_Spezifikation_2026-10-01.md,
* JSON contract: architecture spec clause 7.17).
*
* The customer (Joe) fills exactly these workbooks; vitec:import-submarket-
* workbooks turns them into layout-21 pages. Design rules baked in here:
*
* - references are DROPDOWNS filled live from the database (stories,
* visible products, icon names) - no free text, no typos
* - every input cell carries a NAMED RANGE (s1_subline, s4_story_1, ...);
* the importer reads names, never cell addresses
* - the sheet is PROTECTED, only input cells are unlocked
* - assets are plain URL fields (decision 2026-10-01), approval editorial
* - a hidden Meta sheet pins submarket/group/record uid for the importer
*
* Usage:
* vendor/bin/typo3 vitec:generate-submarket-workbooks
* vendor/bin/typo3 vitec:generate-submarket-workbooks --only=broadcast
* vendor/bin/typo3 vitec:generate-submarket-workbooks --out=/tmp/xls
*/
#[AsCommand(
name: 'vitec:generate-submarket-workbooks',
description: 'Generate the customer XLSX template per sub-market (dropdowns live from the DB)'
)]
final class GenerateSubmarketWorkbooksCommand extends Command
{
private const CARD_ROWS = 8;
private const STORY_ROWS = 6;
private const PRODUCT_ROWS = 9;
/** Provisional flip-card icon set - extend once agreed with the front end. */
private const ICONS = ['capture', 'process', 'manage', 'deliver', 'engage'];
private const COLOR_SECTION = 'FF1F3A93';
private const COLOR_NAVY = 'FF16227E';
private const COLOR_ORANGE = 'FFF36F21';
private const COLOR_INPUT = 'FFFFF8E1';
private const COLOR_LOCKED = 'FFF2F2F2';
protected function configure(): void
{
$this->addOption('out', null, InputOption::VALUE_REQUIRED, 'Output directory', 'migrations/submarkets_xlsx_out');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only sub-markets whose title contains this substring');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$outDir = rtrim((string)$input->getOption('out'), '/');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$groups = $this->marketGroups();
if ($groups === []) {
$output->writeln('<error>No category tree below "Market" found.</error>');
return Command::FAILURE;
}
$stories = $this->titles('tx_vitec_domain_model_usecase', false);
$products = $this->titles('tx_vitec_domain_model_product', true);
$recordByTitle = $this->marketRecordsByNormTitle();
$count = 0;
foreach ($groups as $group) {
foreach ($group['children'] as $submarket) {
if ($only !== '' && !str_contains(mb_strtolower($submarket), $only)) {
continue;
}
$record = $recordByTitle[$this->norm($submarket)] ?? null;
$dir = $outDir . '/' . $this->fileSafe($group['title']);
GeneralUtility::mkdir_deep($dir);
$file = $dir . '/' . $this->fileSafe($submarket) . '.xlsx';
$this->writeWorkbook($file, $submarket, $group['title'], $record, $stories, $products);
$output->writeln(sprintf('%s (record: %s)', $file, $record ? '#' . $record['uid'] : '<comment>KEINER</comment>'));
$count++;
}
}
$output->writeln(sprintf("\n%d workbook(s) written to %s/", $count, $outDir));
return Command::SUCCESS;
}
/**
* @param array{uid:int,title:string}|null $record
* @param string[] $stories
* @param string[] $products
*/
private function writeWorkbook(string $file, string $submarket, string $group, ?array $record, array $stories, array $products): void
{
$spreadsheet = new Spreadsheet();
// ---- Instructions (VITEC look) --------------------------------------
$info = $spreadsheet->getActiveSheet();
$info->setTitle('Read me');
$info->getColumnDimension('A')->setWidth(3);
$info->getColumnDimension('B')->setWidth(100);
$info->getColumnDimension('C')->setWidth(3);
$info->setShowGridlines(false);
// Navy title band, orange accent line - the VITEC design colours.
$info->mergeCells('A1:C3');
$info->setCellValue('A1', 'VITEC · Sub-Market Page');
$info->getStyle('A1:C3')->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_NAVY);
$info->getStyle('A1')->getFont()->setBold(true)->setSize(20)->getColor()->setARGB('FFFFFFFF');
$info->getStyle('A1:C3')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setIndent(1);
$info->mergeCells('A4:C4');
$info->getStyle('A4:C4')->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_ORANGE);
$info->getRowDimension(4)->setRowHeight(4);
$info->mergeCells('A6:C6');
$info->setCellValue('A6', 'This workbook feeds exactly ONE page: ' . $submarket . ' (main market: ' . $group . ')');
$info->getStyle('A6')->getFont()->setBold(true)->setSize(12)->getColor()->setARGB(self::COLOR_NAVY);
$infoBlocks = [
['HOW TO FILL IT IN', [
'Only the yellow cells are editable - everything else is locked on purpose.',
'Cells with a dropdown arrow: please PICK from the list, do not type.',
'Character limits are design hints, not hard stops - you may see a friendly warning.',
'Rows you do not need (cards, stories, products): simply leave them empty.',
]],
['IMAGES & VIDEOS', [
'These fields take a URL (Dropbox, WeTransfer, any web link).',
'We download, review and crop everything on our side - your page goes live',
'even if an image is still pending.',
'Optional blocks (hero video teaser, insights video card): leave the video URL',
'empty and the whole card simply does not appear.',
]],
['YOUR REFERENCE', [
'The sheet "Page layout" shows the final page design with every section labelled -',
'the section numbers there match the blocks in the "Content" sheet.',
]],
['WHEN YOU ARE DONE', [
'Send the file back. It is imported automatically, and we review every page',
'before it goes live.',
]],
];
$infoRow = 8;
foreach ($infoBlocks as [$blockTitle, $blockLines]) {
$info->setCellValue('B' . $infoRow, $blockTitle);
$info->getStyle('B' . $infoRow)->getFont()->setBold(true)->setSize(11)->getColor()->setARGB(self::COLOR_ORANGE);
$infoRow++;
foreach ($blockLines as $line) {
$info->setCellValue('B' . $infoRow, $line);
$infoRow++;
}
$infoRow++;
}
$info->getProtection()->setSheet(true);
// ---- Page layout reference (Figma screenshot) -----------------------
$referencePng = GeneralUtility::getFileAbsFileName('EXT:vitec/Resources/Private/SubmarketTemplate/submarket-reference.png');
if (is_file($referencePng)) {
$layout = $spreadsheet->createSheet();
$layout->setTitle('Page layout');
$layout->setShowGridlines(false);
$layout->getColumnDimension('A')->setWidth(34);
$layout->setCellValue('A1', 'This is how the page will look.');
$layout->getStyle('A1')->getFont()->setBold(true)->setSize(12)->getColor()->setARGB(self::COLOR_NAVY);
$layout->setCellValue('A2', 'Labels match the Content sheet.');
// Section labels roughly aligned with the design (455x1400 px
// image, default row height ~20 px => ~70 rows).
$labels = [4 => 'SECTION 1 - HERO', 12 => 'SECTION 2 - INTRO', 20 => 'SECTION 3 - WORKFLOW CARDS', 31 => 'SECTION 4 - TESTIMONIALS', 41 => 'SECTION 5 - PRODUCTS', 54 => 'SECTION 6 - INDUSTRY INSIGHTS'];
foreach ($labels as $labelRow => $label) {
$layout->setCellValue('A' . $labelRow, $label . ' →');
$layout->getStyle('A' . $labelRow)->getFont()->setBold(true)->setSize(9)->getColor()->setARGB(self::COLOR_ORANGE);
}
$drawing = new \PhpOffice\PhpSpreadsheet\Worksheet\Drawing();
$drawing->setName('Page design');
$drawing->setPath($referencePng);
$drawing->setCoordinates('B2');
$drawing->setWorksheet($layout);
$layout->getProtection()->setSheet(true);
}
// ---- Content sheet --------------------------------------------------
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('Content');
$sheet->getColumnDimension('A')->setWidth(28);
$sheet->getColumnDimension('B')->setWidth(64);
$sheet->getColumnDimension('C')->setWidth(22);
$sheet->getColumnDimension('D')->setWidth(64);
$sheet->getProtection()->setSheet(true);
$sheet->getStyle('A1:D200')->getAlignment()->setVertical(Alignment::VERTICAL_TOP);
$sheet->getStyle('B1:B200')->getAlignment()->setWrapText(true);
$sheet->getStyle('D1:D200')->getAlignment()->setWrapText(true);
$row = 1;
$sheet->setCellValue('A' . $row, 'Sub-market');
$sheet->setCellValue('B' . $row, $submarket);
$sheet->setCellValue('C' . $row, 'Main market');
$sheet->setCellValue('D' . $row, $group);
$sheet->getStyle('A1:D1')->getFont()->setBold(true);
$row += 2;
// Section 1 - Hero
$row = $this->sectionHeader($sheet, $row, 'SECTION 1 - HERO (the page title is set by us)');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Subline *', 'hero_subline', 'max ~90 characters', 120);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA button label', 'hero_cta_label', 'empty = "Get in touch"', 40);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA button link (URL)', 'hero_cta_url', 'empty = /contact');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Background image URL', 'hero_bgimage_url', '1920x600, dark; we review');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video teaser: title', 'hero_video_title', 'optional', 80);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video teaser: video URL', 'hero_video_url', 'empty = no video card');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video teaser: cover image URL', 'hero_video_cover_url', '');
$row++;
// Section 2 - Intro
$row = $this->sectionHeader($sheet, $row, 'SECTION 2 - INTRO');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Intro text *', 'intro_text', 'max ~350 characters, plain text', 500, 3);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Image URL (right)', 'intro_image_url', '636x469 (4:3); we review');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Learn-more label', 'intro_learnmore_label', 'empty = no button', 40);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Learn-more target', 'intro_learnmore_target', 'pick an anchor or type a URL', 0, 1, 'Anchors');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Show "Product Portfolio" button', 'intro_portfolio', '', 0, 1, 'ShowHide');
$row++;
// Section 3 - Workflow
$row = $this->sectionHeader($sheet, $row, 'SECTION 3 - WORKFLOW FLIP CARDS');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline *', 'flow_headline', 'max ~70 characters', 90);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Subtext', 'flow_subtext', 'max ~230 characters', 300, 2);
$sheet->setCellValue('A' . $row, 'Cards - one per row, leave surplus rows empty:');
$sheet->getStyle('A' . $row)->getFont()->setItalic(true);
$row++;
$sheet->setCellValue('A' . $row, 'Icon');
$sheet->setCellValue('B' . $row, 'Title (short, e.g. "Capture")');
$sheet->setCellValue('C' . $row, 'Front text (~80 characters)');
$sheet->setCellValue('D' . $row, 'Back text - flip side (~200 characters)');
$sheet->getStyle('A' . $row . ':D' . $row)->getFont()->setBold(true);
$row++;
for ($i = 1; $i <= self::CARD_ROWS; $i++) {
$this->unlock($sheet, 'A' . $row);
$this->dropdown($sheet, 'A' . $row, 'Icons');
$this->name($spreadsheet, $sheet, 'flow_card' . $i . '_icon', 'A' . $row);
$this->unlock($sheet, 'B' . $row);
$this->name($spreadsheet, $sheet, 'flow_card' . $i . '_title', 'B' . $row);
$this->unlock($sheet, 'C' . $row);
$this->name($spreadsheet, $sheet, 'flow_card' . $i . '_front', 'C' . $row);
$sheet->getColumnDimension('C')->setWidth(40);
$this->unlock($sheet, 'D' . $row);
$this->name($spreadsheet, $sheet, 'flow_card' . $i . '_back', 'D' . $row);
$row++;
}
$row++;
// Section 4 - Testimonials
$row = $this->sectionHeader($sheet, $row, 'SECTION 4 - TESTIMONIALS (pick case studies; quotes are maintained by VITEC)');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline', 'testi_headline', 'empty = "Testimonials from the Field"', 90);
for ($i = 1; $i <= self::STORY_ROWS; $i++) {
$sheet->setCellValue('A' . $row, 'Case study ' . $i);
$this->unlock($sheet, 'B' . $row);
$this->dropdown($sheet, 'B' . $row, 'Stories');
$this->name($spreadsheet, $sheet, 'testi_story_' . $i, 'B' . $row);
$row++;
}
$row++;
// Section 5 - Products
$row = $this->sectionHeader($sheet, $row, 'SECTION 5 - PRODUCTS (pick from the list; all copy comes from our product data)');
$sheet->setCellValue('A' . $row, 'Featured product *');
$this->unlock($sheet, 'B' . $row);
$this->dropdown($sheet, 'B' . $row, 'Products');
$this->name($spreadsheet, $sheet, 'prod_featured', 'B' . $row);
$row++;
for ($i = 1; $i <= self::PRODUCT_ROWS; $i++) {
$sheet->setCellValue('A' . $row, 'Product card ' . $i);
$this->unlock($sheet, 'B' . $row);
$this->dropdown($sheet, 'B' . $row, 'Products');
$this->name($spreadsheet, $sheet, 'prod_card_' . $i, 'B' . $row);
$row++;
}
$row++;
// Section 6 - Insights
$row = $this->sectionHeader($sheet, $row, 'SECTION 6 - INDUSTRY INSIGHTS');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Paragraph 1', 'insights_text1', 'max ~250 characters', 300, 2);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Paragraph 2', 'insights_text2', 'max ~250 characters', 300, 2);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA label', 'insights_cta_label', 'empty = no button', 40);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA target', 'insights_cta_target', 'anchor or URL; empty = /success-stories', 0, 1, 'Anchors');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: person', 'insights_video_person', 'optional', 80);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: role / title', 'insights_video_role', '', 120);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: video title', 'insights_video_title', '', 120);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: video URL', 'insights_video_url', 'empty = no video card');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: cover image URL', 'insights_video_cover_url', '');
$row++;
// SEO
$row = $this->sectionHeader($sheet, $row, 'SEO (optional - we compose sensible defaults if empty)');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'SEO title', 'seo_title', 'ca. 60 characters', 80);
$this->inputRow($spreadsheet, $sheet, $row, 'SEO description', 'seo_description', 'ca. 155 characters', 200, 2);
// ---- Lists (hidden) -------------------------------------------------
$lists = $spreadsheet->createSheet();
$lists->setTitle('Lists');
$this->fillList($spreadsheet, $lists, 'A', 'Stories', $stories);
$this->fillList($spreadsheet, $lists, 'B', 'Products', $products);
$this->fillList($spreadsheet, $lists, 'C', 'Icons', self::ICONS);
$this->fillList($spreadsheet, $lists, 'D', 'ShowHide', ['Show', 'Hide']);
$this->fillList($spreadsheet, $lists, 'E', 'Anchors', ['workflow', 'testimonials', 'products', 'insights']);
$lists->setSheetState(Worksheet::SHEETSTATE_HIDDEN);
// ---- Meta (hidden) - the importer's ground truth --------------------
$meta = $spreadsheet->createSheet();
$meta->setTitle('Meta');
$meta->setCellValue('A1', 'submarket');
$meta->setCellValue('B1', $submarket);
$meta->setCellValue('A2', 'group');
$meta->setCellValue('B2', $group);
$meta->setCellValue('A3', 'record_uid');
$meta->setCellValue('B3', $record ? (int)$record['uid'] : 0);
$meta->setCellValue('A4', 'generated');
$meta->setCellValue('B4', date('Y-m-d H:i'));
$meta->setCellValue('A5', 'template_version');
$meta->setCellValue('B5', '1');
foreach (['submarket' => 'B1', 'group' => 'B2', 'record_uid' => 'B3', 'template_version' => 'B5'] as $metaName => $cell) {
$spreadsheet->addNamedRange(new NamedRange('meta_' . $metaName, $meta, '$' . substr($cell, 0, 1) . '$' . substr($cell, 1)));
}
$meta->setSheetState(Worksheet::SHEETSTATE_HIDDEN);
$spreadsheet->setActiveSheetIndex(1); // Content
(new Xlsx($spreadsheet))->save($file);
$spreadsheet->disconnectWorksheets();
}
// ---- sheet helpers ------------------------------------------------------
private function sectionHeader(Worksheet $sheet, int $row, string $title): int
{
$sheet->setCellValue('A' . $row, $title);
$sheet->mergeCells('A' . $row . ':D' . $row);
$style = $sheet->getStyle('A' . $row);
$style->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$style->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_SECTION);
return $row + 1;
}
private function inputRow(
Spreadsheet $spreadsheet,
Worksheet $sheet,
int $row,
string $label,
string $rangeName,
string $hint,
int $maxLength = 0,
int $rowHeightLines = 1,
string $listName = ''
): int {
$sheet->setCellValue('A' . $row, $label);
$this->unlock($sheet, 'B' . $row);
if ($listName !== '') {
$this->dropdown($sheet, 'B' . $row, $listName, $listName === 'Anchors');
}
if ($hint !== '') {
$sheet->setCellValue('C' . $row, $hint);
$sheet->getStyle('C' . $row)->getFont()->setItalic(true)->setSize(9);
}
if ($maxLength > 0) {
$validation = $sheet->getCell('B' . $row)->getDataValidation();
$validation->setType(DataValidation::TYPE_TEXTLENGTH)
->setOperator(DataValidation::OPERATOR_LESSTHANOREQUAL)
->setFormula1((string)$maxLength)
->setAllowBlank(true)
->setShowErrorMessage(true)
->setErrorStyle(DataValidation::STYLE_WARNING)
->setErrorTitle('A bit long')
->setError('The design is tuned for shorter copy here - this may get cropped.');
}
if ($rowHeightLines > 1) {
$sheet->getRowDimension($row)->setRowHeight(15 * $rowHeightLines);
}
$this->name($spreadsheet, $sheet, $rangeName, 'B' . $row);
return $row + 1;
}
private function unlock(Worksheet $sheet, string $cell): void
{
$sheet->getStyle($cell)->getProtection()->setLocked(Protection::PROTECTION_UNPROTECTED);
$sheet->getStyle($cell)->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_INPUT);
}
private function dropdown(Worksheet $sheet, string $cell, string $listName, bool $allowFreeText = false): void
{
$validation = $sheet->getCell($cell)->getDataValidation();
$validation->setType(DataValidation::TYPE_LIST)
->setFormula1($listName)
->setAllowBlank(true)
->setShowDropDown(true)
->setShowErrorMessage(!$allowFreeText)
->setErrorStyle($allowFreeText ? DataValidation::STYLE_INFORMATION : DataValidation::STYLE_STOP)
->setErrorTitle('Please pick from the list')
->setError($allowFreeText ? 'Anchors come from the list - URLs may be typed freely.' : 'This field only accepts values from the dropdown list.');
}
/** @param string[] $values */
private function fillList(Spreadsheet $spreadsheet, Worksheet $lists, string $column, string $name, array $values): void
{
$rowIndex = 1;
foreach ($values as $value) {
$lists->setCellValue($column . $rowIndex, $value);
$rowIndex++;
}
$last = max(1, $rowIndex - 1);
$spreadsheet->addNamedRange(new NamedRange($name, $lists, '$' . $column . '$1:$' . $column . '$' . $last));
}
private function name(Spreadsheet $spreadsheet, Worksheet $sheet, string $rangeName, string $cell): void
{
preg_match('/^([A-Z]+)(\d+)$/', $cell, $m);
$spreadsheet->addNamedRange(new NamedRange($rangeName, $sheet, '$' . $m[1] . '$' . $m[2]));
}
// ---- data ---------------------------------------------------------------
/** @return array<int,array{title:string,children:string[]}> */
private function marketGroups(): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
$root = $connection->fetchAssociative(
'SELECT uid FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = 0 AND title = ?', ['Market']
) ?: $connection->fetchAssociative(
'SELECT uid FROM sys_category WHERE deleted = 0 AND hidden = 0 AND title = ? ORDER BY uid LIMIT 1', ['Market']
);
if (!$root) {
return [];
}
$groups = [];
$groupRows = $connection->fetchAllAssociative(
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [(int)$root['uid']]
);
foreach ($groupRows as $groupRow) {
$children = $connection->fetchFirstColumn(
'SELECT title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [(int)$groupRow['uid']]
);
if ($children !== []) {
$groups[] = ['title' => (string)$groupRow['title'], 'children' => array_map('strval', $children)];
}
}
return $groups;
}
/** @return array<string,array{uid:int,title:string}> normalized title => record */
private function marketRecordsByNormTitle(): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
$byTitle = [];
foreach ($connection->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_market WHERE deleted = 0') as $row) {
$byTitle[$this->norm((string)$row['title'])] = ['uid' => (int)$row['uid'], 'title' => (string)$row['title']];
}
return $byTitle;
}
/** @return string[] */
private function titles(string $table, bool $productFilters): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
$where = 'deleted = 0 AND hidden = 0';
if ($productFilters) {
$where .= ' AND legacy = 0 AND supportproduct = 0 AND hideonwebsite = 0 AND subproduct = 0';
}
return array_map('strval', $connection->fetchFirstColumn(
'SELECT title FROM ' . $table . ' WHERE ' . $where . ' ORDER BY title'
));
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
}
private function fileSafe(string $value): string
{
$safe = (string)preg_replace('/[^A-Za-z0-9 _&\\-]+/', '', $value);
return trim((string)preg_replace('/\s+/', ' ', $safe));
}
}
@@ -0,0 +1,659 @@
<?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)));
}
}
@@ -10,7 +10,7 @@ fields:
type: Text type: Text
max: 120 max: 120
- identifier: cta_section - identifier: hero_cta_section
type: Palette type: Palette
label: CTA button label: CTA button
fields: fields:
@@ -26,13 +26,13 @@ fields:
maxitems: 1 maxitems: 1
allowed: common-image-types allowed: common-image-types
- identifier: video_section - identifier: hero_video_section
type: Palette type: Palette
label: Video teaser (optional - empty url hides the card) label: Video teaser (optional - empty url hides the card)
fields: fields:
- identifier: video_title - identifier: video_title
type: Text type: Text
max: 80 max: 120
- type: Linebreak - type: Linebreak
- identifier: video_url - identifier: video_url
type: Text type: Text
@@ -13,7 +13,7 @@ fields:
type: Textarea type: Textarea
rows: 4 rows: 4
- identifier: cta_section - identifier: insights_cta_section
type: Palette type: Palette
label: CTA (optional) label: CTA (optional)
fields: fields:
@@ -24,7 +24,7 @@ fields:
type: Text type: Text
max: 500 max: 500
- identifier: video_section - identifier: insights_video_section
type: Palette type: Palette
label: Video card (optional - empty url hides the card) label: Video card (optional - empty url hides the card)
fields: fields:
@@ -15,7 +15,7 @@ fields:
maxitems: 1 maxitems: 1
allowed: common-image-types allowed: common-image-types
- identifier: learnmore_section - identifier: intro_learnmore_section
type: Palette type: Palette
label: Learn-more button (optional) label: Learn-more button (optional)
fields: fields:
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+6
View File
@@ -432,5 +432,11 @@ AuthName "DEV"
AuthType Basic AuthType Basic
<RequireAny> <RequireAny>
Require ip 217.70.192.164 Require ip 217.70.192.164
# The server itself (136.243.123.171 public, loopback variants): lets the
# sub-market importer download assets from dev URLs and keeps the door
# open for a future SSR process fetching /_json server-side (2026-10-02).
Require ip 136.243.123.171
Require ip 127.0.0.1
Require ip ::1
Require valid-user Require valid-user
</RequireAny> </RequireAny>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB