Files
VITEC-website/packages/vitec/Classes/Command/GenerateSubsolutionWorkbooksCommand.php
T
orasche 8e67f5db23 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
2026-10-07 15:39:18 +02:00

493 lines
25 KiB
PHP

<?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));
}
}