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.
494 lines
25 KiB
PHP
494 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-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));
|
|
}
|
|
}
|