Sub-solution workbook pipeline on layout 5, fix element order in both importers

- New, deliberately separate pipeline for sub-solutions (markets and
  solutions must never break each other): generator
  vitec:generate-subsolution-workbooks (list from the page tree below
  /solutions, 39 workbooks, every sub-solution matched to its record),
  SubsolutionWorkbookImporter + CLI vitec:import-subsolution-workbooks,
  and a "Sub-Solutions" tab in the VITEC Import module with its own
  inbox migrations/subsolutions_xlsx_in/
- Targets the existing template 5 "Solution Detail" without changing
  it: hero element in colPos 1, the remaining sections in fixed order in
  colPos 0, found again by CType on re-import; existing empty sub-solution
  pages are switched to layout 5, missing ones created hidden;
  detail_page set on the solution record
- Workbook per the Figma "07 Solution Detail page": hero without video
  teaser, intro with headline and without learn-more/portfolio, extended
  icon list, own page-layout reference image
- Shared content elements: market hero/intro/insights relabelled
  "Market/Solution", market intro gains the optional intro_headline
  field (new column, requires extension:setup)
- Fix: DataHandler puts every NEW record with a positive pid at the top
  of its column, so pages built in one run came out reversed. Both
  importers now track the page order per colPos and write
  tt_content.sorting explicitly after the DataHandler run; re-applying
  repairs pages imported earlier (verified on the control-room
  sub-solution and sub-market page 443)
- migrations/fill_test_subsolution.php: realistic test content from
  vitec.com copy and images for the control-room sub-solution
- Docs section 5z
This commit is contained in:
orasche committed 2026-10-07 15:39:18 +02:00
1 parent 3d5459b8d2
commit 8e67f5db23
84 files changed
+1772 -35

No files matched your search

@@ -0,0 +1,492 @@
<?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-SOLUTION for the solution
* detail template (frontend layout 5, Figma "07 Solution Detail page").
*
* DELIBERATE SIBLING of GenerateSubmarketWorkbooksCommand, NOT a shared
* implementation (decision 2026-10-05): markets and solutions evolve
* independently, changing one must never break the other. Differences from
* the sub-market workbook:
*
* - source of the group/sub list is the PAGE TREE below /solutions (the
* sub-solution pages already exist as empty shells), not a category tree
* - hero has NO video teaser, intro has a HEADLINE and NO learn-more /
* portfolio rows (per the Figma solution design)
* - icon list extended by the solution set (distribute, scale, respond,
* view) - icon artwork still pending with the front-end team
* - meta names: meta_subsolution / meta_group / meta_record_uid
*
* Everything else matches the proven sub-market mechanics: named ranges on
* every input, locked sheet with yellow input cells, dropdowns live from
* the DB, hidden Meta sheet as the importer's ground truth.
*
* Usage:
* vendor/bin/typo3 vitec:generate-subsolution-workbooks
* vendor/bin/typo3 vitec:generate-subsolution-workbooks --only=iptv
* vendor/bin/typo3 vitec:generate-subsolution-workbooks --out=/tmp/xls
*/
#[AsCommand(
name: 'vitec:generate-subsolution-workbooks',
description: 'Generate the customer XLSX template per sub-solution (dropdowns live from the DB)'
)]
final class GenerateSubsolutionWorkbooksCommand 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', 'distribute', 'scale', 'respond', 'view'];
private const COLOR_SECTION = 'FF1F3A93';
private const COLOR_NAVY = 'FF16227E';
private const COLOR_ORANGE = 'FFF36F21';
private const COLOR_INPUT = 'FFFFF8E1';
protected function configure(): void
{
$this->addOption('out', null, InputOption::VALUE_REQUIRED, 'Output directory', 'migrations/subsolutions_xlsx_out');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only sub-solutions 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->solutionGroups();
if ($groups === []) {
$output->writeln('<error>No sub-solution pages found below /solutions.</error>');
return Command::FAILURE;
}
$stories = $this->titles('tx_vitec_domain_model_usecase', false);
$products = $this->titles('tx_vitec_domain_model_product', true);
$recordByTitle = $this->solutionRecordsByNormTitle();
$count = 0;
foreach ($groups as $group) {
foreach ($group['children'] as $subsolution) {
if ($only !== '' && !str_contains(mb_strtolower($subsolution), $only)) {
continue;
}
$record = $recordByTitle[$this->norm($subsolution)] ?? null;
$dir = $outDir . '/' . $this->fileSafe($group['title']);
GeneralUtility::mkdir_deep($dir);
$file = $dir . '/' . $this->fileSafe($subsolution) . '.xlsx';
$this->writeWorkbook($file, $subsolution, $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 $subsolution, 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);
$info->mergeCells('A1:C3');
$info->setCellValue('A1', 'VITEC · Solution Detail 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: ' . $subsolution . ' (solution area: ' . $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.',
'The Industry Insights video card is optional: 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/subsolution-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.');
$labels = [4 => 'SECTION 1 - HERO', 11 => 'SECTION 2 - INTRO', 21 => 'SECTION 3 - ADVANTAGE CARDS', 32 => 'SECTION 4 - TESTIMONIALS', 42 => 'SECTION 5 - PRODUCTS', 55 => '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-solution');
$sheet->setCellValue('B' . $row, $subsolution);
$sheet->setCellValue('C' . $row, 'Solution area');
$sheet->setCellValue('D' . $row, $group);
$sheet->getStyle('A1:D1')->getFont()->setBold(true);
$row += 2;
// Section 1 - Hero (no video teaser in the solution design)
$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++;
// Section 2 - Intro (headline + longer text, image right)
$row = $this->sectionHeader($sheet, $row, 'SECTION 2 - INTRO');
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline', 'intro_headline', 'max ~90 characters', 120);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Intro text *', 'intro_text', 'max ~700 characters, plain text', 900, 4);
$row = $this->inputRow($spreadsheet, $sheet, $row, 'Image URL (right)', 'intro_image_url', '636x469 (4:3); we review');
$row++;
// Section 3 - Advantages (flip cards)
$row = $this->sectionHeader($sheet, $row, 'SECTION 3 - ADVANTAGE 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. "Distribute")');
$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', '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', 'subsolution');
$meta->setCellValue('B1', $subsolution);
$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 (['subsolution' => '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 ---------------------------------------------------------------
/**
* Solution areas with their sub-solutions, read from the PAGE TREE below
* /solutions - the sub-solution pages already exist as (empty) shells,
* so the tree is the ground truth here (markets use the category tree).
*
* @return array<int,array{title:string,children:string[]}>
*/
private function solutionGroups(): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
if (!$root) {
return [];
}
$groups = [];
$mains = $connection->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND pid = ? ORDER BY sorting',
[(int)$root['uid']]
);
foreach ($mains as $main) {
$children = $connection->fetchFirstColumn(
'SELECT title FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND pid = ? ORDER BY sorting',
[(int)$main['uid']]
);
if ($children !== []) {
$groups[] = ['title' => (string)$main['title'], 'children' => array_map('strval', $children)];
}
}
return $groups;
}
/** @return array<string,array{uid:int,title:string}> normalized title => record */
private function solutionRecordsByNormTitle(): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_solution');
$byTitle = [];
foreach ($connection->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_solution 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,91 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Import\SubsolutionWorkbookImporter;
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;
/**
* CLI shell around SubsolutionWorkbookImporter - the deliberate sibling of
* vitec:import-submarket-workbooks (markets and solutions evolve
* independently; behavior and lessons are documented on the importer).
*
* vendor/bin/typo3 vitec:import-subsolution-workbooks --dir=migrations/subsolutions_xlsx_in
* vendor/bin/typo3 vitec:import-subsolution-workbooks --dir=... --only=iptv --apply
*/
#[AsCommand(
name: 'vitec:import-subsolution-workbooks',
description: 'Import filled sub-solution workbooks into layout-5 pages (dry-run by default)'
)]
final class ImportSubsolutionWorkbooksCommand extends Command
{
public function __construct(private readonly SubsolutionWorkbookImporter $importer)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/subsolutions_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) {
try {
$result = $this->importer->importFile($file, $apply);
$output->writeln(sprintf("\n<info>%s</info> (%s / %s)", $result['file'], $result['group'], $result['subsolution']));
foreach ($result['report'] as $line) {
$output->writeln(' <comment>' . $line . '</comment>');
}
$output->writeln(sprintf(' page %s · %d element(s) touched · %s',
$result['newPage'] && $result['pageUid'] === 0 ? 'NEW' : '#' . $result['pageUid'],
$result['elements'],
$apply ? 'written.' : 'dry run'));
} catch (\Throwable $e) {
$errors++;
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Import\SubsolutionWorkbookImporter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sub-Solutions tab of the "VITEC Import" module - the deliberate sibling
* of SubmarketImportController (decision 2026-10-05: dedicated importers,
* markets and solutions must never break each other).
*
* Files live in migrations/subsolutions_xlsx_in/ - the SAME directory the
* CLI vitec:import-subsolution-workbooks uses.
*/
#[AsController]
final class SubsolutionImportController
{
private const INBOX = 'migrations/subsolutions_xlsx_in';
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SubsolutionWorkbookImporter $importer,
private readonly FlashMessageService $flashMessageService,
private readonly PageRenderer $pageRenderer,
private readonly UriBuilder $uriBuilder,
) {}
public function subsolutionsAction(ServerRequestInterface $request): ResponseInterface
{
return $this->render($request, []);
}
public function subsolutionUploadAction(ServerRequestInterface $request): ResponseInterface
{
$uploads = $request->getUploadedFiles()['workbooks'] ?? [];
if (!is_array($uploads)) {
$uploads = [$uploads];
}
$dir = $this->inboxPath();
if (!is_dir($dir)) {
GeneralUtility::mkdir_deep($dir);
}
$stored = [];
foreach ($uploads as $upload) {
if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) {
continue;
}
$name = $this->safeName((string)$upload->getClientFilename());
if ($name === '') {
$this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING);
continue;
}
$upload->moveTo($dir . '/' . $name);
GeneralUtility::fixPermissions($dir . '/' . $name);
$stored[] = $name;
}
if ($stored !== []) {
$this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK);
} else {
$this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING);
}
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.subsolutions'), 303);
}
public function subsolutionProcessAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$apply = (string)($body['op'] ?? 'dry') === 'apply';
$selected = array_map('strval', (array)($body['files'] ?? []));
if ($selected === []) {
$this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING);
return $this->render($request, []);
}
$available = array_flip(array_column($this->workbooks(), 'name'));
$results = [];
foreach ($selected as $name) {
// Whitelist against the actual directory listing - the submitted
// name never touches the filesystem on its own (no traversal).
if (!isset($available[$name])) {
$results[] = ['file' => $name, 'error' => 'File not found in the inbox.'];
continue;
}
try {
$results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply);
} catch (\Throwable $e) {
$results[] = ['file' => $name, 'error' => $e->getMessage()];
}
}
$errors = count(array_filter($results, static fn(array $r): bool => isset($r['error'])));
$this->message(
sprintf('%d workbook(s) processed (%s)%s.',
count($results),
$apply ? 'APPLY' : 'dry run',
$errors > 0 ? ', ' . $errors . ' with errors' : ''
),
$errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK
);
return $this->render($request, $results);
}
// ------------------------------------------------------------ internals
/** @param array<int,array<string,mixed>> $results */
private function render(ServerRequestInterface $request, array $results): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
$view->assignMultiple([
'workbooks' => $this->workbooks(),
'inbox' => self::INBOX,
'results' => $results,
'hasResults' => $results !== [],
]);
return $view->renderResponse('Import/Subsolutions');
}
/** @return array<int,array{name:string,size:string,modified:string}> */
private function workbooks(): array
{
$dir = $this->inboxPath();
if (!is_dir($dir)) {
return [];
}
$rows = [];
foreach (scandir($dir) ?: [] as $name) {
if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) {
continue;
}
$path = $dir . '/' . $name;
if (!is_file($path)) {
continue;
}
$rows[] = [
'name' => $name,
'size' => sprintf('%.0f KB', filesize($path) / 1024),
'modified' => date('Y-m-d H:i', (int)filemtime($path)),
];
}
usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $rows;
}
private function inboxPath(): string
{
return Environment::getProjectPath() . '/' . self::INBOX;
}
/** Client filename reduced to a safe basename; '' when it is no .xlsx. */
private function safeName(string $clientFilename): string
{
$name = basename(str_replace('\\', '/', trim($clientFilename)));
if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) {
return '';
}
return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name);
}
private function message(string $text, ContextualFeedbackSeverity $severity): void
{
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
}
}
@@ -59,6 +59,8 @@ final class SubmarketWorkbookImporter
private array $datamap = [];
/** @var array<string,array<int,array<string,int>>> */
private array $cmdmap = [];
/** @var array<int,string[]> colPos => element ids (uid or NEW id) in page order */
private array $sequence = [];
private int $newCounter = 0;
/**
@@ -70,6 +72,7 @@ final class SubmarketWorkbookImporter
$this->report = [];
$this->datamap = [];
$this->cmdmap = [];
$this->sequence = [];
$spreadsheet = IOFactory::load($file);
$values = $this->readNamedValues($spreadsheet);
@@ -171,6 +174,7 @@ final class SubmarketWorkbookImporter
$this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW;
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
}
$this->sequence[self::COLPOS_FLOW][] = (string)$cardId;
$this->setFields('tt_content', (string)$cardId, [
'vitec_layout_variant' => 'flipcard',
'header' => $title,
@@ -297,6 +301,7 @@ final class SubmarketWorkbookImporter
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
$this->applySequence($dataHandler->substNEWwithIDs);
if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) {
$result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId];
}
@@ -438,6 +443,7 @@ final class SubmarketWorkbookImporter
if ($subPage !== null) {
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
if ($existing !== []) {
$this->sequence[$colPos][] = (string)$existing[0];
return (string)$existing[0];
}
}
@@ -447,9 +453,36 @@ final class SubmarketWorkbookImporter
'colPos' => $colPos,
'CType' => $ctype,
];
$this->sequence[$colPos][] = $id;
return $id;
}
/**
* Writes tt_content.sorting in page order. Necessary because DataHandler
* puts every NEW record created with a positive pid at the TOP of its
* column - slots holding several elements (130: intro paragraph + cards,
* 150: feature + list) came out reversed (found 2026-10-07 on the
* sub-solution side). `sorting` is no TCA column, so it cannot travel
* through the datamap; a direct update is the only way. Runs on every
* apply, so a re-import also repairs pages imported before this fix.
*
* @param array<string,int> $substNEWwithIDs
*/
private function applySequence(array $substNEWwithIDs): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
foreach ($this->sequence as $ids) {
$sorting = 0;
foreach ($ids as $id) {
$uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
if ($uid > 0) {
$sorting += 256;
$connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
}
}
/** @return int[] uids in sorting order */
private function slotElements(int $pageUid, int $colPos, string $ctype): array
{
@@ -0,0 +1,620 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Import;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Imports ONE customer-filled sub-solution workbook (produced by
* vitec:generate-subsolution-workbooks) into its layout-5 page.
*
* DELIBERATE SIBLING of SubmarketWorkbookImporter, NOT a shared
* implementation (decision 2026-10-05): markets and solutions evolve
* independently, changing one must never break the other.
*
* Differences from the sub-market importer:
* - target template is layout 5 "Solution Detail" (zones hero=1, main=0,
* sidebar=2), NOT the slotted layout 21: the hero element goes into
* colPos 1, everything else into colPos 0 in a fixed order. Elements
* are found by CType within their column (each CType appears once,
* except the flip cards).
* - the sub-solution pages usually ALREADY EXIST as empty shells below
* /solutions/<area>/ - they are switched to layout 5; missing pages are
* created HIDDEN as with markets.
* - intro carries a headline (vitec_intro_headline) and no learn-more /
* portfolio (solution design); hero has no video teaser.
* - detail_page is set on tx_vitec_domain_model_solution.
*
* Shared lessons (same as the market importer): named ranges first, then
* label-scan over the locked column A; content-idempotent file attach;
* sys_file_reference needs pid + tablenames + uid_foreign.
*/
final class SubsolutionWorkbookImporter
{
private const LAYOUT = 5;
private const COLPOS_HERO = 1;
private const COLPOS_MAIN = 0;
private const CARD_ROWS = 8;
private const STORY_ROWS = 6;
private const PRODUCT_ROWS = 9;
/** Story detail page; mirrors the site setting vitec.storyDetailPid. */
private const STORY_SINGLE_PID = 26;
/** @var string[] */
private array $report = [];
/** @var array<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var array<string,array<int,array<string,int>>> */
private array $cmdmap = [];
/** @var array<int,string[]> colPos => element ids (uid or NEW id) in page order */
private array $sequence = [];
private int $newCounter = 0;
/**
* @return array{file:string,group:string,subsolution:string,newPage:bool,pageUid:int,elements:int,applied:bool,report:string[]}
* @throws \RuntimeException on fatal problems
*/
public function importFile(string $file, bool $apply): array
{
$this->report = [];
$this->datamap = [];
$this->cmdmap = [];
$this->sequence = [];
$spreadsheet = IOFactory::load($file);
$values = $this->readNamedValues($spreadsheet);
$subsolution = trim((string)($values['meta_subsolution'] ?? ''));
$group = trim((string)($values['meta_group'] ?? ''));
$recordUid = (int)($values['meta_record_uid'] ?? 0);
$spreadsheet->disconnectWorksheets();
if ($subsolution === '' || $group === '') {
throw new \RuntimeException('Meta sheet missing - not a generated sub-solution workbook?');
}
// --- resolve pages ---------------------------------------------------
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$solutionsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
if (!$solutionsRoot) {
throw new \RuntimeException('Page /solutions not found.');
}
$mainPage = $this->childByTitle((int)$solutionsRoot['uid'], $group);
if ($mainPage === null) {
throw new \RuntimeException('Solution area page "' . $group . '" not found below /solutions.');
}
$subPage = $this->childByTitle((int)$mainPage['uid'], $subsolution);
if ($subPage === null) {
$pageId = $this->newId();
$this->datamap['pages'][$pageId] = [
'pid' => (int)$mainPage['uid'],
'title' => $subsolution,
'doktype' => 1,
'layout' => self::LAYOUT,
'hidden' => 1,
'backend_layout' => 'vitec__' . self::LAYOUT,
];
$this->report[] = 'page: CREATE (hidden, review & publish editorially)';
} else {
$pageId = (string)(int)$subPage['uid'];
$updates = [];
if ((int)$subPage['layout'] !== self::LAYOUT) {
$updates['layout'] = self::LAYOUT;
$updates['backend_layout'] = 'vitec__' . self::LAYOUT;
$this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)$subPage['layout'] . ')';
}
if ($updates !== []) {
$this->datamap['pages'][$pageId] = $updates;
}
}
// --- SEO on the page --------------------------------------------------
$seoTitle = trim((string)($values['seo_title'] ?? ''));
$seoDescription = trim((string)($values['seo_description'] ?? ''));
if ($seoTitle !== '') {
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
}
if ($seoDescription !== '') {
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
}
// --- hero zone (colPos 1) ---------------------------------------------
// The blocks below are registered in PAGE ORDER ($this->sequence) and
// their sorting is written explicitly after DataHandler ran - see
// applySequence() for why the creation order cannot be relied on.
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
$this->setFields('tt_content', $heroId, [
'vitec_subline' => (string)($values['hero_subline'] ?? ''),
'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''),
'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''),
]);
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
// --- main column: intro -------------------------------------------------
$introId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketintro');
$this->setFields('tt_content', $introId, [
'vitec_intro_headline' => (string)($values['intro_headline'] ?? ''),
'vitec_intro_text' => (string)($values['intro_text'] ?? ''),
'vitec_show_portfolio' => 0,
]);
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
// --- main column: advantages (intro-paragraph + flip cards) --------------
$flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_introparagraph');
$this->setFields('tt_content', $flowHeaderId, [
'header' => (string)($values['flow_headline'] ?? ''),
'bodytext' => (string)($values['flow_subtext'] ?? ''),
]);
$existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_MAIN, 'vitec_card') : [];
$cardIndex = 0;
for ($i = 1; $i <= self::CARD_ROWS; $i++) {
$title = trim((string)($values['flow_card' . $i . '_title'] ?? ''));
$front = trim((string)($values['flow_card' . $i . '_front'] ?? ''));
$back = trim((string)($values['flow_card' . $i . '_back'] ?? ''));
$icon = trim((string)($values['flow_card' . $i . '_icon'] ?? ''));
if ($title === '' && $front === '') {
continue;
}
$cardId = $existingCards[$cardIndex] ?? $this->newId();
if (!isset($existingCards[$cardIndex])) {
$this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId);
$this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_MAIN;
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
}
$this->sequence[self::COLPOS_MAIN][] = (string)$cardId;
$this->setFields('tt_content', (string)$cardId, [
'vitec_layout_variant' => 'flipcard',
'header' => $title,
'bodytext' => $front,
'vitec_back_headline' => $title,
'vitec_back_text' => $back,
]);
if ($icon !== '') {
$this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later';
}
$cardIndex++;
}
if (count($existingCards) > $cardIndex) {
$this->report[] = 'advantages: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially';
}
// --- main column: testimonials -------------------------------------------
$storyUids = [];
foreach (range(1, self::STORY_ROWS) as $i) {
$storyTitle = trim((string)($values['testi_story_' . $i] ?? ''));
if ($storyTitle === '') {
continue;
}
$story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle);
if ($story === null) {
$this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped';
continue;
}
if (trim((string)$story['quote']) === '') {
$this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record';
}
$storyUids[] = (int)$story['uid'];
}
$testiId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_usecaselist');
$this->setFields('tt_content', $testiId, [
'header' => (string)($values['testi_headline'] ?? ''),
'pi_flexform' => $this->flex([
'settings.layout' => 'testimonials',
'settings.usecases' => implode(',', $storyUids),
'settings.singlePid' => (string)self::STORY_SINGLE_PID,
'settings.showtoolbar' => '0',
]),
]);
// --- main column: products -------------------------------------------------
$featuredTitle = trim((string)($values['prod_featured'] ?? ''));
$featuredUid = 0;
if ($featuredTitle !== '') {
$featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle);
$featured === null
? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND'
: $featuredUid = (int)$featured['uid'];
}
$featureId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productfeature');
$this->setFields('tt_content', $featureId, [
'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]),
]);
$productUids = [];
foreach (range(1, self::PRODUCT_ROWS) as $i) {
$productTitle = trim((string)($values['prod_card_' . $i] ?? ''));
if ($productTitle === '') {
continue;
}
$product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle);
$product === null
? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped'
: $productUids[] = (int)$product['uid'];
}
$listId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productlist');
$this->setFields('tt_content', $listId, [
'pi_flexform' => $this->flex([
'settings.selectedproducts' => implode(',', $productUids),
'settings.layout' => '0',
'settings.showtoolbar' => '0',
]),
]);
// --- main column: insights ---------------------------------------------------
$insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketinsights');
$this->setFields('tt_content', $insightsId, [
'vitec_text1' => (string)($values['insights_text1'] ?? ''),
'vitec_text2' => (string)($values['insights_text2'] ?? ''),
'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''),
'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''),
'vitec_video_person' => (string)($values['insights_video_person'] ?? ''),
'vitec_video_role' => (string)($values['insights_video_role'] ?? ''),
'vitec_video_title' => (string)($values['insights_video_title'] ?? ''),
'vitec_video_url' => (string)($values['insights_video_url'] ?? ''),
]);
$this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
// --- solution record link -----------------------------------------------------
if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) {
$this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = (int)$pageId;
} elseif ($recordUid > 0) {
$this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = $pageId;
} else {
$this->report[] = 'no solution record for this sub-solution - detail_page not set';
}
$isNewPage = str_starts_with($pageId, 'NEW');
$result = [
'file' => basename($file),
'group' => $group,
'subsolution' => $subsolution,
'newPage' => $isNewPage,
'pageUid' => $isNewPage ? 0 : (int)$pageId,
'elements' => count($this->datamap['tt_content'] ?? []),
'applied' => $apply,
'report' => $this->report,
];
if (!$apply) {
return $result;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, $this->cmdmap);
$dataHandler->process_datamap();
if ($this->cmdmap !== []) {
$dataHandler->process_cmdmap();
}
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
$this->applySequence($dataHandler->substNEWwithIDs);
if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) {
$result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId];
}
$result['report'] = $this->report;
return $result;
}
// ---- helpers -------------------------------------------------------------
/** @return array<string,mixed> name => first-cell value */
private function readNamedValues(Spreadsheet $spreadsheet): array
{
$values = [];
foreach ($spreadsheet->getNamedRanges() as $namedRange) {
$name = $namedRange->getName();
$sheet = $namedRange->getWorksheet();
if ($sheet === null) {
continue;
}
$range = str_replace('$', '', (string)$namedRange->getValue());
$range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range;
$cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range;
$values[$name] = $sheet->getCell($cell)->getValue();
}
if (isset($values['meta_subsolution'])) {
return $values;
}
// Names are gone - scan by the LOCKED labels in column A instead
// (same Excel round-trip lesson as the sub-market importer).
$meta = $spreadsheet->getSheetByName('Meta');
$content = $spreadsheet->getSheetByName('Content');
if ($meta === null || $content === null) {
return $values; // caller reports "Meta sheet missing"
}
$this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead';
$values['meta_subsolution'] = $meta->getCell('B1')->getValue();
$values['meta_group'] = $meta->getCell('B2')->getValue();
$values['meta_record_uid'] = $meta->getCell('B3')->getValue();
$section = 0;
$inCardTable = false;
$cardIndex = 0;
$highestRow = min(200, (int)$content->getHighestRow());
for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) {
$label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue()));
if (preg_match('/^section (\d)/', $label, $m)) {
$section = (int)$m[1];
$inCardTable = false;
continue;
}
if ($inCardTable && $section === 3) {
$icon = trim((string)$content->getCell('A' . $rowNum)->getValue());
$title = trim((string)$content->getCell('B' . $rowNum)->getValue());
$front = trim((string)$content->getCell('C' . $rowNum)->getValue());
$back = trim((string)$content->getCell('D' . $rowNum)->getValue());
if ($icon === '' && $title === '' && $front === '' && $back === '') {
continue;
}
$cardIndex++;
if ($cardIndex <= self::CARD_ROWS) {
$values['flow_card' . $cardIndex . '_icon'] = $icon;
$values['flow_card' . $cardIndex . '_title'] = $title;
$values['flow_card' . $cardIndex . '_front'] = $front;
$values['flow_card' . $cardIndex . '_back'] = $back;
}
continue;
}
if ($label === '') {
continue;
}
if ($section === 3 && $label === 'icon') {
$inCardTable = true;
continue;
}
$label = rtrim($label, ' *');
$b = $content->getCell('B' . $rowNum)->getValue();
$name = match (true) {
$label === 'subline' => 'hero_subline',
$label === 'cta button label' => 'hero_cta_label',
$label === 'cta button link (url)' => 'hero_cta_url',
$label === 'background image url' => 'hero_bgimage_url',
$label === 'headline' && $section === 2 => 'intro_headline',
$label === 'intro text' => 'intro_text',
$label === 'image url (right)' => 'intro_image_url',
$label === 'headline' && $section === 3 => 'flow_headline',
$label === 'subtext' => 'flow_subtext',
$label === 'headline' && $section === 4 => 'testi_headline',
str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11),
$label === 'featured product' => 'prod_featured',
str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13),
$label === 'paragraph 1' => 'insights_text1',
$label === 'paragraph 2' => 'insights_text2',
$label === 'cta label' => 'insights_cta_label',
$label === 'cta target' => 'insights_cta_target',
$label === 'video card: person' => 'insights_video_person',
$label === 'video card: role / title' => 'insights_video_role',
$label === 'video card: video title' => 'insights_video_title',
$label === 'video card: video url' => 'insights_video_url',
$label === 'video card: cover image url' => 'insights_video_cover_url',
$label === 'seo title' => 'seo_title',
$label === 'seo description' => 'seo_description',
default => null,
};
if ($name !== null && !array_key_exists($name, $values)) {
$values[$name] = $b;
}
}
return $values;
}
/** @return array{uid:int,layout:int}|null */
private function childByTitle(int $parentUid, string $title): ?array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach ($connection->fetchAllAssociative(
'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid]
) as $row) {
if ($this->norm((string)$row['title']) === $this->norm($title)) {
return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']];
}
}
return null;
}
/** Existing element uid for a single-element slot, or a NEW id. */
private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string
{
if ($subPage !== null) {
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
if ($existing !== []) {
$this->sequence[$colPos][] = (string)$existing[0];
return (string)$existing[0];
}
}
$id = $this->newId();
$this->datamap['tt_content'][$id] = [
'pid' => $this->pidValue($pageId),
'colPos' => $colPos,
'CType' => $ctype,
];
$this->sequence[$colPos][] = $id;
return $id;
}
/**
* Writes tt_content.sorting in page order. Necessary because DataHandler
* puts every NEW record created with a positive pid at the TOP of its
* column - a page built in one run came out exactly reversed (found
* 2026-10-07). `sorting` is no TCA column, so it cannot travel through
* the datamap; a direct update is the only way. Runs on every apply, so
* a re-import also repairs pages imported before this fix.
*
* @param array<string,int> $substNEWwithIDs
*/
private function applySequence(array $substNEWwithIDs): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
foreach ($this->sequence as $ids) {
$sorting = 0;
foreach ($ids as $id) {
$uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
if ($uid > 0) {
$sorting += 256;
$connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
}
}
/** @return int[] uids in sorting order */
private function slotElements(int $pageUid, int $colPos, string $ctype): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
return array_map('intval', $connection->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
[$pageUid, $colPos, $ctype]
));
}
/** @param array<string,mixed> $fields */
private function setFields(string $table, string $id, array $fields): void
{
foreach ($fields as $field => $value) {
$this->datamap[$table][$id][$field] = $value;
}
}
/** NEW page ids must stay strings in datamap pid references. */
private function pidValue(string $pageId): int|string
{
return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId;
}
/** @return array<string,mixed>|null */
private function recordByTitle(string $table, string $title): ?array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
foreach ($connection->fetchAllAssociative(
'SELECT * FROM ' . $table . ' WHERE deleted = 0', []
) as $row) {
if ($this->norm((string)$row['title']) === $this->norm($title)) {
return $row;
}
}
return null;
}
/**
* Download a URL into fileadmin/subsolution_import/<page>/ and attach it
* as the element's file field. Editorial review happens in the backend;
* a failed download is a warning. Skipped entirely in dry runs.
*/
private function attachUrlFile(string $elementId, string $fieldName, string $url, string $subsolution, bool $apply, int|string $pid = 0): void
{
$url = trim($url);
if ($url === '' || !str_starts_with($url, 'http')) {
return;
}
if (!$apply) {
$this->report[] = $fieldName . ': would download ' . $url;
return;
}
try {
$response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class)
->request($url, 'GET', ['timeout' => 20]);
if ($response->getStatusCode() !== 200) {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially';
return;
}
$content = (string)$response->getBody();
if ($content === '') {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially';
return;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$folderPath = 'subsolution_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $subsolution));
$folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath);
$fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin');
$temp = GeneralUtility::tempnam('subsolution_');
file_put_contents($temp, $content);
$file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE);
// Re-import idempotency BY CONTENT (lesson 2026-10-02): reuse the
// first live reference, delete our own surplus duplicates.
if (!str_starts_with($elementId, 'NEW')) {
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
if ($existingRefs !== []) {
$first = array_shift($existingRefs);
if ((int)$first['uid_local'] === $file->getUid()) {
$this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged';
} else {
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
$this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference';
}
foreach ($existingRefs as $surplus) {
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
}
return;
}
}
$refId = $this->newId();
// pid, tablenames and uid_foreign are REQUIRED - without them
// DataHandler silently rejects the reference (lesson 2026-10-02).
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'fieldname' => $fieldName,
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
];
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
$this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially';
} catch (\Throwable $e) {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially';
}
}
/** @return array<int,array{uid:int,uid_local:int}> live refs on one field, oldest first */
private function liveReferences(int $elementUid, string $fieldName): array
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
return array_map(
static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']],
$connection->fetchAllAssociative(
"SELECT uid, uid_local FROM sys_file_reference
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content'
AND fieldname = ? AND uid_foreign = ? ORDER BY uid",
[$fieldName, $elementUid]
)
);
}
/**
* DataHandler flexform array for sheet sDEF.
*
* @param array<string,string> $settings
* @return array<string,mixed>
*/
private function flex(array $settings): array
{
$fields = [];
foreach ($settings as $key => $value) {
$fields[$key] = ['vDEF' => $value];
}
return ['data' => ['sDEF' => ['lDEF' => $fields]]];
}
private function newId(): string
{
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
}
}