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('No sub-solution pages found below /solutions.'); 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'] : 'KEINER')); $count++; } } $output->writeln(sprintf("\n%d workbook(s) written to %s/", $count, $outDir)); return Command::SUCCESS; } /** * @param array{uid:int,title:string}|null $record * @param string[] $stories * @param string[] $products */ private function writeWorkbook(string $file, string $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 */ 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 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)); } }