diff --git a/migrations/check_submarket_refs.php b/migrations/check_submarket_refs.php
new file mode 100644
index 0000000..2b2fff9
--- /dev/null
+++ b/migrations/check_submarket_refs.php
@@ -0,0 +1,50 @@
+ 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";
+}
diff --git a/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx
new file mode 100644
index 0000000..06feb90
Binary files /dev/null and b/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx differ
diff --git a/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx
new file mode 100644
index 0000000..0ac5729
Binary files /dev/null and b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx differ
diff --git a/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php b/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php
new file mode 100644
index 0000000..d778f9c
--- /dev/null
+++ b/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php
@@ -0,0 +1,493 @@
+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('No category tree below "Market" found.');
+ 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'] : 'KEINER'));
+ $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 */
+ 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 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));
+ }
+}
diff --git a/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php
new file mode 100644
index 0000000..caaa370
--- /dev/null
+++ b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php
@@ -0,0 +1,659 @@
+/ 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>> */
+ private array $datamap = [];
+ /** @var array>> */
+ 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('Directory not found: ' . $dir . '');
+ 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('' . basename($file) . ': ' . $e->getMessage() . '');
+ }
+ }
+ 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%s (%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(' ' . $line . '');
+ }
+ $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(' written.');
+ }
+
+ // ---- helpers -------------------------------------------------------------
+
+ /** @return array 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 $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|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// 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 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 $settings
+ * @return array
+ */
+ 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)));
+ }
+}
diff --git a/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml
index 794b039..14fe6e6 100644
--- a/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml
+++ b/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml
@@ -10,7 +10,7 @@ fields:
type: Text
max: 120
- - identifier: cta_section
+ - identifier: hero_cta_section
type: Palette
label: CTA button
fields:
@@ -26,13 +26,13 @@ fields:
maxitems: 1
allowed: common-image-types
- - identifier: video_section
+ - identifier: hero_video_section
type: Palette
label: Video teaser (optional - empty url hides the card)
fields:
- identifier: video_title
type: Text
- max: 80
+ max: 120
- type: Linebreak
- identifier: video_url
type: Text
diff --git a/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml
index 383df9c..e1c25be 100644
--- a/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml
+++ b/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml
@@ -13,7 +13,7 @@ fields:
type: Textarea
rows: 4
- - identifier: cta_section
+ - identifier: insights_cta_section
type: Palette
label: CTA (optional)
fields:
@@ -24,7 +24,7 @@ fields:
type: Text
max: 500
- - identifier: video_section
+ - identifier: insights_video_section
type: Palette
label: Video card (optional - empty url hides the card)
fields:
diff --git a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml
index 0fa2d33..0ca3664 100644
--- a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml
+++ b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml
@@ -15,7 +15,7 @@ fields:
maxitems: 1
allowed: common-image-types
- - identifier: learnmore_section
+ - identifier: intro_learnmore_section
type: Palette
label: Learn-more button (optional)
fields:
diff --git a/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png b/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png
new file mode 100644
index 0000000..8160969
Binary files /dev/null and b/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png differ
diff --git a/public/.htaccess b/public/.htaccess
index 802e9ab..e6bc0b8 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -432,5 +432,11 @@ AuthName "DEV"
AuthType Basic
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
diff --git a/public/testimages/placeholder_1920x600.png b/public/testimages/placeholder_1920x600.png
new file mode 100644
index 0000000..16fd4f7
Binary files /dev/null and b/public/testimages/placeholder_1920x600.png differ
diff --git a/public/testimages/placeholder_636x469.png b/public/testimages/placeholder_636x469.png
new file mode 100644
index 0000000..16fd4f7
Binary files /dev/null and b/public/testimages/placeholder_636x469.png differ