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

per sub-market (25, grouped by the 9 main markets): VITEC-styled Read me,
a Page-layout sheet embedding the Figma design with section markers,
DB-live dropdowns, soft character limits, a hidden Meta sheet and named
input cells. Names must NOT start with a cell-reference token - Excel
silently strips s1_-style names as repairs.

vitec:import-submarket-workbooks (dry-run default, idempotent) finds or
creates the page below its main market (hidden, layout 21), fills the six
colPos slots updating in place and never deleting, links detail_page on
the market record, downloads asset URLs into fileadmin/submarket_import/
for editorial review and writes the flexforms as DataHandler arrays.
Reading is three-layered after a bruising day of real-world Excel: named
ranges first, then a label scan over the locked column A with section
tracking - a customer save stripped ALL defined names and shifted rows,
so fixed addresses are not a contract.

Learned and fixed along the way: DuplicationBehavior moved to
Resource\Enum in v14; sys_file_reference rows via DataHandler need
pid/tablenames/uid_foreign or are dropped silently; handing DataHandler a
fresh NEW reference per run STACKS live references on re-import (caught
by a double-run test), so attachments are now content-idempotent - the
existing live reference is reused or retargeted and surplus duplicates
are deleted via cmdmap; RequestFactory replaces GeneralUtility::getUrl so
download failures carry their reason - which surfaced that the dev basic
auth blocked the server itself (server IP + loopback now allowlisted,
which a future SSR needs anyway) and that the core resource-consistency
check rightly rejects HTML masquerading as PNG.

Verified end to end on Broadcast & Media from a clean slate, twice in a
row: 12 elements in colPos 110-160, layout-21 payload, both images
attached exactly once with publicUrl in the JSON; second run reports
nothing but unchanged.
This commit is contained in:
orasche committed 2026-10-02 13:24:54 +02:00
1 parent 90ae6803a4
commit 8b175211ea
12 files changed
+1214 -6

No files matched your search

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