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
83 files changed
+1745 -8

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* Test data for the sub-solution pipeline: fills the generated workbook
* "Control Room Platforms SOC NOC Command Centres" with realistic copy
* (based on vitec.com product content) and real vitec.com images, and
* drops it into the import inbox.
*
* Starts from the FRESH generated file (named ranges intact), never from
* an Excel-saved copy. Stories/products are picked from the workbook's own
* dropdown lists, so every value is a valid DB title.
*
* php migrations/fill_test_subsolution.php
*/
require __DIR__ . '/../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
$source = __DIR__ . '/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Control Room Platforms SOC NOC Command Centres.xlsx';
$target = __DIR__ . '/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx';
if (!is_file($source)) {
fwrite(STDERR, "Source workbook not found: $source\n(run vendor/bin/typo3 vitec:generate-subsolution-workbooks first)\n");
exit(1);
}
if (is_file($target)) {
copy($target, $target . '.bak');
echo "Backup of the existing inbox file: " . basename($target) . ".bak\n";
}
$spreadsheet = IOFactory::load($source);
// ---- dropdown lists from the workbook itself ------------------------------
$lists = $spreadsheet->getSheetByName('Lists');
$column = static function (string $col) use ($lists): array {
$values = [];
for ($r = 1; $r <= $lists->getHighestRow(); $r++) {
$v = trim((string)$lists->getCell($col . $r)->getValue());
if ($v !== '') {
$values[] = $v;
}
}
return $values;
};
$stories = $column('A');
$products = $column('B');
/** First list entry matching each keyword, in keyword order, no duplicates. */
$pick = static function (array $list, array $keywords, int $max) : array {
$chosen = [];
foreach ($keywords as $keyword) {
foreach ($list as $entry) {
if (stripos($entry, $keyword) !== false && !in_array($entry, $chosen, true)) {
$chosen[] = $entry;
break;
}
}
if (count($chosen) >= $max) {
break;
}
}
return $chosen;
};
$storyPicks = $pick($stories, ['National Defense', 'Defense Visual', 'ISR Video Mission', 'Disease Control', 'Parliament', 'Police', 'Government'], 3);
$productPicks = $pick($products, ['VSN V3', 'VSNMicro', 'VSNMini', 'Aligo', 'QTX100', 'WallControl', 'Fx4'], 4);
$featured = array_shift($productPicks) ?? '';
// ---- content ----------------------------------------------------------------
$values = [
'hero_subline' => 'Unified, low-latency video visualisation for security, network and command operations - built for 24/7 reliability.',
'hero_cta_label' => 'Talk to an expert',
'hero_cta_url' => '/contact',
'hero_bgimage_url' => 'https://www.vitec.com/fileadmin/user_upload/gov_16-9.jpg',
'intro_headline' => 'Every source, every screen, one operational picture',
'intro_text' => 'Security operations centres, network operations centres and command centres all face the same challenge: operators must see and act on many live sources at once - CCTV, IP streams, dashboards, sensor data and broadcast feeds - without delay and without interruption. VITEC control room platforms bring these sources together on configurable video walls and operator workstations. VSN Series controllers render any mix of HDMI, DisplayPort, SDI and IP inputs, Aligo extends the wall to remote positions with visually lossless 4K and sub-millisecond latency, and WallControl 10 lets operators rearrange layouts in seconds as situations evolve.',
'intro_image_url' => 'https://www.vitec.com/fileadmin/user_upload/control-room_control-launch.png',
'flow_headline' => 'Built for mission-critical operations',
'flow_subtext' => 'VITEC control room platforms are designed for environments where every second counts and downtime is not an option. Key advantages include:',
'flow_card1_icon' => 'capture',
'flow_card1_title' => 'Ingest',
'flow_card1_front' => 'Bring every source onto the wall: HDMI, DisplayPort, SDI and IP streams.',
'flow_card1_back' => 'Vision and ActiveSQX capture cards ingest local and networked sources side by side, so CCTV, PCs, dashboards and broadcast feeds share one display surface.',
'flow_card2_icon' => 'view',
'flow_card2_title' => 'Visualise',
'flow_card2_front' => 'Build any wall layout and change it in seconds with WallControl 10.',
'flow_card2_back' => 'Operators drag and drop sources, recall saved layouts per shift or incident, and scale from a single screen to walls of 24 displays and beyond.',
'flow_card3_icon' => 'distribute',
'flow_card3_title' => 'Extend',
'flow_card3_front' => 'Take the wall to every operator position with Aligo AVoIP.',
'flow_card3_back' => 'Visually lossless 4K60 over 10Gb networks with sub-millisecond latency, plus KVM control of remote sources - ideal for supervisor desks and overflow rooms.',
'flow_card4_icon' => 'respond',
'flow_card4_title' => 'Stay resilient',
'flow_card4_front' => 'Rack-mounted VSN V3 controllers with dual-redundant power for 24/7 duty.',
'flow_card4_back' => 'Hardware-based rendering keeps playback smooth and synchronised, while redundant power supplies keep the picture up when it matters most.',
'testi_headline' => 'Trusted in control rooms worldwide',
'prod_featured' => $featured,
'insights_text1' => 'Modern control rooms are moving from fixed matrix switching to flexible, IP-based visualisation - for faster situational awareness, simpler expansion and lower operating costs.',
'insights_text2' => 'See how organisations in defense, government and critical infrastructure use VITEC technology to keep operators informed and in control:',
'insights_cta_label' => 'Explore success stories',
'insights_cta_target' => '/success-stories',
'seo_title' => 'Control Room Platforms for SOC, NOC & Command Centres | VITEC',
'seo_description' => 'Unify CCTV, IP streams and data on resilient video walls: VITEC VSN controllers, Aligo AVoIP and WallControl 10 for SOC, NOC and command centres.',
];
foreach ($storyPicks as $i => $story) {
$values['testi_story_' . ($i + 1)] = $story;
}
foreach ($productPicks as $i => $product) {
$values['prod_card_' . ($i + 1)] = $product;
}
// ---- write via named ranges -------------------------------------------------
$missing = [];
foreach ($values as $name => $value) {
$range = $spreadsheet->getNamedRange($name);
if ($range === null || $range->getWorksheet() === null) {
$missing[] = $name;
continue;
}
$cell = str_replace('$', '', (string)$range->getValue());
$cell = str_contains($cell, '!') ? substr($cell, strpos($cell, '!') + 1) : $cell;
$range->getWorksheet()->getCell($cell)->setValue($value);
}
@mkdir(dirname($target), 0775, true);
(new Xlsx($spreadsheet))->save($target);
$spreadsheet->disconnectWorksheets();
@chmod($target, 0664);
echo "Written: " . $target . "\n";
echo "Featured product: " . ($featured !== '' ? $featured : '(none found!)') . "\n";
echo "Product cards: " . ($productPicks !== [] ? implode(' | ', $productPicks) : '(none found)') . "\n";
echo "Case studies: " . ($storyPicks !== [] ? implode(' | ', $storyPicks) : '(none found)') . "\n";
if ($missing !== []) {
echo "WARNING - named ranges not found: " . implode(', ', $missing) . "\n";
}
@@ -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 = []; private array $datamap = [];
/** @var array<string,array<int,array<string,int>>> */ /** @var array<string,array<int,array<string,int>>> */
private array $cmdmap = []; private array $cmdmap = [];
/** @var array<int,string[]> colPos => element ids (uid or NEW id) in page order */
private array $sequence = [];
private int $newCounter = 0; private int $newCounter = 0;
/** /**
@@ -70,6 +72,7 @@ final class SubmarketWorkbookImporter
$this->report = []; $this->report = [];
$this->datamap = []; $this->datamap = [];
$this->cmdmap = []; $this->cmdmap = [];
$this->sequence = [];
$spreadsheet = IOFactory::load($file); $spreadsheet = IOFactory::load($file);
$values = $this->readNamedValues($spreadsheet); $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]['colPos'] = self::COLPOS_FLOW;
$this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card'; $this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card';
} }
$this->sequence[self::COLPOS_FLOW][] = (string)$cardId;
$this->setFields('tt_content', (string)$cardId, [ $this->setFields('tt_content', (string)$cardId, [
'vitec_layout_variant' => 'flipcard', 'vitec_layout_variant' => 'flipcard',
'header' => $title, 'header' => $title,
@@ -297,6 +301,7 @@ final class SubmarketWorkbookImporter
if ($dataHandler->errorLog !== []) { if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
} }
$this->applySequence($dataHandler->substNEWwithIDs);
if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) { if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) {
$result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId]; $result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId];
} }
@@ -438,6 +443,7 @@ final class SubmarketWorkbookImporter
if ($subPage !== null) { if ($subPage !== null) {
$existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype); $existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype);
if ($existing !== []) { if ($existing !== []) {
$this->sequence[$colPos][] = (string)$existing[0];
return (string)$existing[0]; return (string)$existing[0];
} }
} }
@@ -447,9 +453,36 @@ final class SubmarketWorkbookImporter
'colPos' => $colPos, 'colPos' => $colPos,
'CType' => $ctype, 'CType' => $ctype,
]; ];
$this->sequence[$colPos][] = $id;
return $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 */ /** @return int[] uids in sorting order */
private function slotElements(int $pageUid, int $colPos, string $ctype): array 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)));
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
use Evomedien\Vitec\Controller\Backend\ImportController; use Evomedien\Vitec\Controller\Backend\ImportController;
use Evomedien\Vitec\Controller\Backend\ProductTextImportController; use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
use Evomedien\Vitec\Controller\Backend\SubmarketImportController; use Evomedien\Vitec\Controller\Backend\SubmarketImportController;
use Evomedien\Vitec\Controller\Backend\SubsolutionImportController;
use Evomedien\Vitec\Controller\Backend\WordPressImportController; use Evomedien\Vitec\Controller\Backend\WordPressImportController;
use Evomedien\Vitec\Controller\OgImageController; use Evomedien\Vitec\Controller\OgImageController;
@@ -65,6 +66,17 @@ return [
'target' => SubmarketImportController::class . '::submarketProcessAction', 'target' => SubmarketImportController::class . '::submarketProcessAction',
'methods' => ['POST'], 'methods' => ['POST'],
], ],
'subsolutions' => [
'target' => SubsolutionImportController::class . '::subsolutionsAction',
],
'subsolution_upload' => [
'target' => SubsolutionImportController::class . '::subsolutionUploadAction',
'methods' => ['POST'],
],
'subsolution_process' => [
'target' => SubsolutionImportController::class . '::subsolutionProcessAction',
'methods' => ['POST'],
],
], ],
], ],
'web_vitecwpimport' => [ 'web_vitecwpimport' => [
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2"> <xliff version="1.2">
<file source-language="en" datatype="plaintext" original="messages"> <file source-language="en" datatype="plaintext" original="messages">
<body> <body>
<trans-unit id="title"><source>VITEC · Market Hero</source></trans-unit> <trans-unit id="title"><source>VITEC · Market/Solution Hero</source></trans-unit>
<trans-unit id="description"><source>Sub-market template, section 1: subline, CTA, background image, optional video teaser. The H1 is the page title.</source></trans-unit> <trans-unit id="description"><source>Sub-market template, section 1: subline, CTA, background image, optional video teaser. The H1 is the page title.</source></trans-unit>
<trans-unit id="subline.label"><source>Subline</source></trans-unit> <trans-unit id="subline.label"><source>Subline</source></trans-unit>
<trans-unit id="subline.description"><source>Max ~90 characters visible (two lines in the design).</source></trans-unit> <trans-unit id="subline.description"><source>Max ~90 characters visible (two lines in the design).</source></trans-unit>
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2"> <xliff version="1.2">
<file source-language="en" datatype="plaintext" original="messages"> <file source-language="en" datatype="plaintext" original="messages">
<body> <body>
<trans-unit id="title"><source>VITEC · Market Insights</source></trans-unit> <trans-unit id="title"><source>VITEC · Market/Solution Insights</source></trans-unit>
<trans-unit id="description"><source>Sub-market template, section 6: two paragraphs, optional CTA, optional video card with person.</source></trans-unit> <trans-unit id="description"><source>Sub-market template, section 6: two paragraphs, optional CTA, optional video card with person.</source></trans-unit>
<trans-unit id="text1.label"><source>Paragraph 1</source></trans-unit> <trans-unit id="text1.label"><source>Paragraph 1</source></trans-unit>
<trans-unit id="text1.description"><source>Plain text, max ~250 characters.</source></trans-unit> <trans-unit id="text1.description"><source>Plain text, max ~250 characters.</source></trans-unit>
@@ -5,6 +5,12 @@ prefixFields: true
prefixType: vendor prefixType: vendor
fields: fields:
# Optional headline above the text - the solution detail design has one,
# the sub-market design does not (leave empty there).
- identifier: intro_headline
type: Text
max: 120
- identifier: intro_text - identifier: intro_text
type: Textarea type: Textarea
rows: 6 rows: 6
@@ -2,8 +2,10 @@
<xliff version="1.2"> <xliff version="1.2">
<file source-language="en" datatype="plaintext" original="messages"> <file source-language="en" datatype="plaintext" original="messages">
<body> <body>
<trans-unit id="title"><source>VITEC · Market Intro</source></trans-unit> <trans-unit id="title"><source>VITEC · Market/Solution Intro</source></trans-unit>
<trans-unit id="description"><source>Sub-market template, section 2: intro text, image right, optional learn-more button, portfolio button toggle.</source></trans-unit> <trans-unit id="description"><source>Detail template section 2 (sub-markets AND sub-solutions): optional headline, intro text, image right, optional learn-more button, portfolio button toggle.</source></trans-unit>
<trans-unit id="intro_headline.label"><source>Headline (optional)</source></trans-unit>
<trans-unit id="intro_headline.description"><source>Used on solution detail pages; sub-market pages leave it empty.</source></trans-unit>
<trans-unit id="intro_text.label"><source>Intro text</source></trans-unit> <trans-unit id="intro_text.label"><source>Intro text</source></trans-unit>
<trans-unit id="intro_text.description"><source>Plain text, max ~350 characters. No HTML.</source></trans-unit> <trans-unit id="intro_text.description"><source>Plain text, max ~350 characters. No HTML.</source></trans-unit>
<trans-unit id="image.label"><source>Image (right)</source></trans-unit> <trans-unit id="image.label"><source>Image (right)</source></trans-unit>
Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

@@ -29,6 +29,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.subsolutions')}">Sub-Solutions</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li> </li>
@@ -26,6 +26,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.subsolutions')}">Sub-Solutions</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li> </li>
@@ -21,6 +21,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.subsolutions')}">Sub-Solutions</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a> <a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li> </li>
@@ -21,6 +21,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a> <a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.subsolutions')}">Sub-Solutions</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a> <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li> </li>
@@ -0,0 +1,144 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Backend/Default" />
<f:section name="main">
<div class="module-docheader-bar module-docheader-bar-navigation">
<div class="module-docheader-bar-column-left">
<h2 class="t3js-title-inlineedit">VITEC Import &mdash; Sub-Solutions</h2>
</div>
</div>
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport')}">CSV Import</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.submarkets')}">Sub-Markets</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.subsolutions')}">Sub-Solutions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
</li>
</ul>
<!-- Upload: drops Joe's filled workbooks into the shared inbox directory -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-header"><strong>Upload filled workbooks</strong></div>
<div class="card-body">
<form action="{f:be.uri(route: 'web_vitecimport.subsolution_upload')}" method="post" enctype="multipart/form-data" class="row row-cols-auto align-items-center g-2">
<div class="col">
<input type="file" name="workbooks[]" accept=".xlsx" multiple="multiple" class="form-control" required="required" />
</div>
<div class="col">
<button type="submit" class="btn btn-primary">Upload</button>
</div>
<div class="col form-text">
Multiple files allowed. A file with the same name replaces the stored one ({inbox}/).
</div>
</form>
</div>
</div>
<!-- Inbox + dry-run / apply -->
<div class="card" style="margin-bottom:1rem;">
<div class="card-header"><strong>Workbooks in the inbox</strong></div>
<div class="card-body">
<f:if condition="{workbooks}">
<f:then>
<form action="{f:be.uri(route: 'web_vitecimport.subsolution_process')}" method="post">
<table class="table table-striped table-sm" style="max-width:900px;">
<thead>
<tr>
<th style="width:30px;"></th>
<th>File</th>
<th>Size</th>
<th>Uploaded / changed</th>
</tr>
</thead>
<tbody>
<f:for each="{workbooks}" as="w">
<tr>
<td><input type="checkbox" name="files[]" value="{w.name}" checked="checked" /></td>
<td>{w.name}</td>
<td>{w.size}</td>
<td>{w.modified}</td>
</tr>
</f:for>
</tbody>
</table>
<button type="submit" name="op" value="dry" class="btn btn-default">Dry run (no changes)</button>
<button type="submit" name="op" value="apply" class="btn btn-warning">Apply &mdash; write pages</button>
<span class="form-text" style="display:inline-block;margin-left:.75rem;">
Always dry-run first. Apply updates existing elements in place, creates missing ones, never deletes.
New pages are created HIDDEN for editorial review.
</span>
</form>
</f:then>
<f:else>
<p>No workbooks yet &mdash; upload Joe's filled .xlsx files above.</p>
</f:else>
</f:if>
</div>
</div>
<!-- Results -->
<f:if condition="{hasResults}">
<f:for each="{results}" as="r">
<div class="card" style="margin-bottom:1rem;">
<div class="card-header">
<strong>{r.file}</strong>
<f:if condition="{r.error}">
<f:then>
<span class="vitec-badge vitec-badge-danger">ERROR</span>
</f:then>
<f:else>
&mdash; {r.group} / {r.subsolution}
<f:if condition="{r.applied}">
<f:then><span class="vitec-badge vitec-badge-ok">written</span></f:then>
<f:else><span class="vitec-badge vitec-badge-info">dry run</span></f:else>
</f:if>
</f:else>
</f:if>
</div>
<div class="card-body">
<f:if condition="{r.error}">
<f:then>
<p class="text-danger">{r.error}</p>
</f:then>
<f:else>
<p>
Page:
<f:if condition="{r.pageUid}">
<f:then>
<a href="{f:be.uri(route: 'web_layout', parameters: {id: r.pageUid})}">#{r.pageUid} &mdash; open in page module</a>
</f:then>
<f:else>
NEW (would be created hidden)
</f:else>
</f:if>
&middot; {r.elements} element(s) touched
</p>
<f:if condition="{r.report}">
<ul style="margin-bottom:0;">
<f:for each="{r.report}" as="line">
<li>{line}</li>
</f:for>
</ul>
</f:if>
</f:else>
</f:if>
</div>
</div>
</f:for>
</f:if>
</f:section>
</html>
+2 -2
View File
@@ -23,8 +23,8 @@
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." /> <meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
<link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" />
<title>VITEC</title> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-Bp_49Z5l.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-BpTqLcih.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-Cj1orKsH.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-CAiVTATb.css">
</head> </head>
<body> <body>
<div id="page"></div>␍ <div id="page"></div>␍