diff --git a/migrations/check_submarket_refs.php b/migrations/check_submarket_refs.php new file mode 100644 index 0000000..2b2fff9 --- /dev/null +++ b/migrations/check_submarket_refs.php @@ -0,0 +1,50 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +echo "=== sys_file unter submarket_import/ ===\n"; +foreach ($pdo->query("SELECT uid, identifier, name FROM sys_file WHERE identifier LIKE '/submarket_import/%'") as $row) { + echo " #{$row['uid']} {$row['identifier']}\n"; +} + +echo "\n=== tt_content auf Seite 443 (Slots 110-160): Datei-Zaehler ===\n"; +foreach ($pdo->query("SELECT uid, CType, colPos, vitec_bgimage, vitec_image, vitec_video_cover FROM tt_content WHERE deleted = 0 AND pid = 443 AND colPos >= 110 ORDER BY colPos") as $row) { + echo " #{$row['uid']} {$row['CType']} (colPos {$row['colPos']}): bgimage={$row['vitec_bgimage']} image={$row['vitec_image']} video_cover={$row['vitec_video_cover']}\n"; +} + +echo "\n=== sys_file_reference auf diese Elemente ===\n"; +$stmt = $pdo->query("SELECT r.uid, r.uid_local, r.uid_foreign, r.fieldname, r.deleted, r.hidden, f.identifier + FROM sys_file_reference r LEFT JOIN sys_file f ON f.uid = r.uid_local + WHERE r.tablenames = 'tt_content' AND r.uid_foreign IN (SELECT uid FROM tt_content WHERE pid = 443 AND colPos >= 110)"); +$found = 0; +foreach ($stmt as $row) { + $found++; + echo " ref #{$row['uid']} -> CE #{$row['uid_foreign']} field {$row['fieldname']} file {$row['identifier']} deleted={$row['deleted']} hidden={$row['hidden']}\n"; +} +if ($found === 0) { + echo " KEINE Referenzen gefunden.\n"; +} diff --git a/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx new file mode 100644 index 0000000..06feb90 Binary files /dev/null and b/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx new file mode 100644 index 0000000..0ac5729 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx differ diff --git a/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php b/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php new file mode 100644 index 0000000..d778f9c --- /dev/null +++ b/packages/vitec/Classes/Command/GenerateSubmarketWorkbooksCommand.php @@ -0,0 +1,493 @@ +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('No category tree below "Market" found.'); + 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'] : '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 $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 */ + 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 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)); + } +} diff --git a/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php new file mode 100644 index 0000000..caaa370 --- /dev/null +++ b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php @@ -0,0 +1,659 @@ +/ and + * attaches them (review/crop stays editorial - a failed download is a + * warning, never an abort). + * + * Dry run by default; --apply writes. Idempotent: re-importing an unchanged + * workbook updates fields to the same values. Flip-card icons are NOTED but + * not attached yet - the icon set is still open with the front-end team. + * + * vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in + * vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply + */ +#[AsCommand( + name: 'vitec:import-submarket-workbooks', + description: 'Import filled sub-market workbooks into layout-21 pages (dry-run by default)' +)] +final class ImportSubmarketWorkbooksCommand extends Command +{ + private const LAYOUT = 21; + private const COLPOS_HERO = 110; + private const COLPOS_INTRO = 120; + private const COLPOS_FLOW = 130; + private const COLPOS_TESTI = 140; + private const COLPOS_PROD = 150; + private const COLPOS_INSIGHTS = 160; + private const CARD_ROWS = 8; + private const STORY_ROWS = 6; + private const PRODUCT_ROWS = 9; + + /** Story detail page; mirrors the site setting vitec.storyDetailPid. */ + private const STORY_SINGLE_PID = 26; + + /** @var string[] */ + private array $report = []; + /** @var array>> */ + private array $datamap = []; + /** @var array>> */ + private array $cmdmap = []; + private int $newCounter = 0; + + protected function configure(): void + { + $this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/submarkets_xlsx_in'); + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring'); + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $only = mb_strtolower((string)($input->getOption('only') ?? '')); + $dir = rtrim((string)$input->getOption('dir'), '/'); + + if (!is_dir($dir)) { + $output->writeln('Directory not found: ' . $dir . ''); + return Command::FAILURE; + } + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)); + foreach ($iterator as $fileInfo) { + $name = $fileInfo->getFilename(); + if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) { + if ($only === '' || str_contains(mb_strtolower($name), $only)) { + $files[] = $fileInfo->getPathname(); + } + } + } + sort($files); + if ($files === []) { + $output->writeln('No workbooks found in ' . $dir); + return Command::SUCCESS; + } + $output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN')); + + $errors = 0; + foreach ($files as $file) { + $this->report = []; + $this->datamap = []; + $this->cmdmap = []; + try { + $this->importOne($file, $apply, $output); + } catch (\Throwable $e) { + $errors++; + $output->writeln('' . basename($file) . ': ' . $e->getMessage() . ''); + } + } + if (!$apply) { + $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); + } + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } + + private function importOne(string $file, bool $apply, OutputInterface $output): void + { + $spreadsheet = IOFactory::load($file); + $values = $this->readNamedValues($spreadsheet); + $submarket = trim((string)($values['meta_submarket'] ?? '')); + $group = trim((string)($values['meta_group'] ?? '')); + $recordUid = (int)($values['meta_record_uid'] ?? 0); + $spreadsheet->disconnectWorksheets(); + if ($submarket === '' || $group === '') { + throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?'); + } + + $output->writeln(sprintf("\n%s (%s / %s)", basename($file), $group, $submarket)); + + // --- resolve pages --------------------------------------------------- + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'"); + if (!$marketsRoot) { + throw new \RuntimeException('Page /markets not found.'); + } + $mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group); + if ($mainPage === null) { + throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.'); + } + $subPage = $this->childByTitle((int)$mainPage['uid'], $submarket); + + $pageId = null; + if ($subPage === null) { + $pageId = $this->newId(); + $this->datamap['pages'][$pageId] = [ + 'pid' => (int)$mainPage['uid'], + 'title' => $submarket, + 'doktype' => 1, + 'layout' => self::LAYOUT, + 'hidden' => 1, + 'backend_layout' => 'vitec__' . self::LAYOUT, + ]; + $this->report[] = 'page: CREATE (hidden, review & publish editorially)'; + } else { + $pageId = (string)(int)$subPage['uid']; + $updates = []; + if ((int)$subPage['layout'] !== self::LAYOUT) { + $updates['layout'] = self::LAYOUT; + $updates['backend_layout'] = 'vitec__' . self::LAYOUT; + $this->report[] = 'page: switch layout -> 21 (was ' . (int)$subPage['layout'] . ')'; + } + if ($updates !== []) { + $this->datamap['pages'][$pageId] = $updates; + } + } + + // --- SEO on the page -------------------------------------------------- + $seoTitle = trim((string)($values['seo_title'] ?? '')); + $seoDescription = trim((string)($values['seo_description'] ?? '')); + if ($seoTitle !== '') { + $this->datamap['pages'][$pageId]['seo_title'] = $seoTitle; + } + if ($seoDescription !== '') { + $this->datamap['pages'][$pageId]['description'] = $seoDescription; + } + + // --- slot 110: hero --------------------------------------------------- + $heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero'); + $this->setFields('tt_content', $heroId, [ + 'vitec_subline' => (string)($values['hero_subline'] ?? ''), + 'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''), + 'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''), + 'vitec_video_title' => (string)($values['hero_video_title'] ?? ''), + 'vitec_video_url' => (string)($values['hero_video_url'] ?? ''), + ]); + $this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + $this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + + // --- slot 120: intro ---------------------------------------------------- + $introId = $this->slotElement($pageId, $subPage, self::COLPOS_INTRO, 'vitec_marketintro'); + $this->setFields('tt_content', $introId, [ + 'vitec_intro_text' => (string)($values['intro_text'] ?? ''), + 'vitec_learnmore_label' => (string)($values['intro_learnmore_label'] ?? ''), + 'vitec_learnmore_target' => (string)($values['intro_learnmore_target'] ?? ''), + 'vitec_show_portfolio' => strcasecmp(trim((string)($values['intro_portfolio'] ?? 'Show')), 'Hide') === 0 ? 0 : 1, + ]); + $this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + + // --- slot 130: workflow (intro-paragraph + flip cards) ------------------ + $flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_FLOW, 'vitec_introparagraph'); + $this->setFields('tt_content', $flowHeaderId, [ + 'header' => (string)($values['flow_headline'] ?? ''), + 'bodytext' => (string)($values['flow_subtext'] ?? ''), + ]); + $existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_FLOW, 'vitec_card') : []; + $cardIndex = 0; + for ($i = 1; $i <= self::CARD_ROWS; $i++) { + $title = trim((string)($values['flow_card' . $i . '_title'] ?? '')); + $front = trim((string)($values['flow_card' . $i . '_front'] ?? '')); + $back = trim((string)($values['flow_card' . $i . '_back'] ?? '')); + $icon = trim((string)($values['flow_card' . $i . '_icon'] ?? '')); + if ($title === '' && $front === '') { + continue; + } + $cardId = $existingCards[$cardIndex] ?? $this->newId(); + if (!isset($existingCards[$cardIndex])) { + $this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId); + $this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW; + $this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card'; + } + $this->setFields('tt_content', (string)$cardId, [ + 'vitec_layout_variant' => 'flipcard', + 'header' => $title, + 'bodytext' => $front, + 'vitec_back_headline' => $title, + 'vitec_back_text' => $back, + ]); + if ($icon !== '') { + $this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later'; + } + $cardIndex++; + } + if (count($existingCards) > $cardIndex) { + $this->report[] = 'workflow: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially'; + } + + // --- slot 140: testimonials -------------------------------------------- + $storyUids = []; + foreach (range(1, self::STORY_ROWS) as $i) { + $storyTitle = trim((string)($values['testi_story_' . $i] ?? '')); + if ($storyTitle === '') { + continue; + } + $story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle); + if ($story === null) { + $this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped'; + continue; + } + if (trim((string)$story['quote']) === '') { + $this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record'; + } + $storyUids[] = (int)$story['uid']; + } + $testiId = $this->slotElement($pageId, $subPage, self::COLPOS_TESTI, 'vitec_usecaselist'); + $this->setFields('tt_content', $testiId, [ + 'header' => (string)($values['testi_headline'] ?? ''), + 'pi_flexform' => $this->flex([ + 'settings.layout' => 'testimonials', + 'settings.usecases' => implode(',', $storyUids), + 'settings.singlePid' => (string)self::STORY_SINGLE_PID, + 'settings.showtoolbar' => '0', + ]), + ]); + + // --- slot 150: products -------------------------------------------------- + $featuredTitle = trim((string)($values['prod_featured'] ?? '')); + $featuredUid = 0; + if ($featuredTitle !== '') { + $featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle); + $featured === null + ? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND' + : $featuredUid = (int)$featured['uid']; + } + $featureId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productfeature'); + $this->setFields('tt_content', $featureId, [ + 'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]), + ]); + + $productUids = []; + foreach (range(1, self::PRODUCT_ROWS) as $i) { + $productTitle = trim((string)($values['prod_card_' . $i] ?? '')); + if ($productTitle === '') { + continue; + } + $product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle); + $product === null + ? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped' + : $productUids[] = (int)$product['uid']; + } + $listId = $this->slotElement($pageId, $subPage, self::COLPOS_PROD, 'vitec_productlist'); + $this->setFields('tt_content', $listId, [ + 'pi_flexform' => $this->flex([ + 'settings.selectedproducts' => implode(',', $productUids), + 'settings.layout' => '0', + 'settings.showtoolbar' => '0', + ]), + ]); + + // --- slot 160: insights --------------------------------------------------- + $insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_INSIGHTS, 'vitec_marketinsights'); + $this->setFields('tt_content', $insightsId, [ + 'vitec_text1' => (string)($values['insights_text1'] ?? ''), + 'vitec_text2' => (string)($values['insights_text2'] ?? ''), + 'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''), + 'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''), + 'vitec_video_person' => (string)($values['insights_video_person'] ?? ''), + 'vitec_video_role' => (string)($values['insights_video_role'] ?? ''), + 'vitec_video_title' => (string)($values['insights_video_title'] ?? ''), + 'vitec_video_url' => (string)($values['insights_video_url'] ?? ''), + ]); + $this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + + // --- market record link ----------------------------------------------------- + if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) { + $this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = (int)$pageId; + } elseif ($recordUid > 0) { + $this->datamap['tx_vitec_domain_model_market'][$recordUid]['detail_page'] = $pageId; + } else { + $this->report[] = 'no market record for this sub-market - detail_page not set'; + } + + foreach ($this->report as $line) { + $output->writeln(' ' . $line . ''); + } + $elementCount = count($this->datamap['tt_content'] ?? []); + $output->writeln(sprintf(' page %s · %d element(s) touched · %s', + str_starts_with($pageId, 'NEW') ? 'NEW' : '#' . $pageId, $elementCount, $apply ? 'writing...' : 'dry run')); + + if (!$apply) { + return; + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($this->datamap, $this->cmdmap); + $dataHandler->process_datamap(); + if ($this->cmdmap !== []) { + $dataHandler->process_cmdmap(); + } + if ($dataHandler->errorLog !== []) { + throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); + } + $output->writeln(' written.'); + } + + // ---- helpers ------------------------------------------------------------- + + /** @return array name => first-cell value */ + private function readNamedValues(Spreadsheet $spreadsheet): array + { + $values = []; + foreach ($spreadsheet->getNamedRanges() as $namedRange) { + $name = $namedRange->getName(); + $sheet = $namedRange->getWorksheet(); + if ($sheet === null) { + continue; + } + $range = str_replace('$', '', (string)$namedRange->getValue()); + $range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range; + $cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range; + $values[$name] = $sheet->getCell($cell)->getValue(); + } + if (isset($values['meta_submarket'])) { + return $values; + } + + // Names are gone - scan by the LOCKED labels in column A instead. + // Fixed cell addresses were tried first and failed the same day: the + // Excel round-trip that strips the names may ALSO shift rows, so the + // only thing left to trust is the label text we generated ourselves. + $meta = $spreadsheet->getSheetByName('Meta'); + $content = $spreadsheet->getSheetByName('Content'); + if ($meta === null || $content === null) { + return $values; // caller reports "Meta sheet missing" + } + $this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead'; + $values['meta_submarket'] = $meta->getCell('B1')->getValue(); + $values['meta_group'] = $meta->getCell('B2')->getValue(); + $values['meta_record_uid'] = $meta->getCell('B3')->getValue(); + + $section = 0; + $inCardTable = false; + $cardIndex = 0; + $highestRow = min(200, (int)$content->getHighestRow()); + for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) { + $label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue())); + if (preg_match('/^section (\d)/', $label, $m)) { + $section = (int)$m[1]; + $inCardTable = false; + continue; + } + // Inside the card table every row is a card until the next + // section header - icons are optional, so rows with an empty + // column A still count when B-D carry content. + if ($inCardTable && $section === 3) { + $icon = trim((string)$content->getCell('A' . $rowNum)->getValue()); + $title = trim((string)$content->getCell('B' . $rowNum)->getValue()); + $front = trim((string)$content->getCell('C' . $rowNum)->getValue()); + $back = trim((string)$content->getCell('D' . $rowNum)->getValue()); + if ($icon === '' && $title === '' && $front === '' && $back === '') { + continue; + } + $cardIndex++; + if ($cardIndex <= self::CARD_ROWS) { + $values['flow_card' . $cardIndex . '_icon'] = $icon; + $values['flow_card' . $cardIndex . '_title'] = $title; + $values['flow_card' . $cardIndex . '_front'] = $front; + $values['flow_card' . $cardIndex . '_back'] = $back; + } + continue; + } + if ($label === '') { + continue; + } + if ($section === 3 && $label === 'icon') { + $inCardTable = true; + continue; + } + $label = rtrim($label, ' *'); + $b = $content->getCell('B' . $rowNum)->getValue(); + $name = match (true) { + $label === 'subline' => 'hero_subline', + $label === 'cta button label' => 'hero_cta_label', + $label === 'cta button link (url)' => 'hero_cta_url', + $label === 'background image url' => 'hero_bgimage_url', + $label === 'video teaser: title' => 'hero_video_title', + $label === 'video teaser: video url' => 'hero_video_url', + $label === 'video teaser: cover image url' => 'hero_video_cover_url', + $label === 'intro text' => 'intro_text', + $label === 'image url (right)' => 'intro_image_url', + $label === 'learn-more label' => 'intro_learnmore_label', + $label === 'learn-more target' => 'intro_learnmore_target', + str_starts_with($label, 'show "product portfolio"') => 'intro_portfolio', + $label === 'headline' && $section === 3 => 'flow_headline', + $label === 'subtext' => 'flow_subtext', + $label === 'headline' && $section === 4 => 'testi_headline', + str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11), + $label === 'featured product' => 'prod_featured', + str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13), + $label === 'paragraph 1' => 'insights_text1', + $label === 'paragraph 2' => 'insights_text2', + $label === 'cta label' => 'insights_cta_label', + $label === 'cta target' => 'insights_cta_target', + $label === 'video card: person' => 'insights_video_person', + $label === 'video card: role / title' => 'insights_video_role', + $label === 'video card: video title' => 'insights_video_title', + $label === 'video card: video url' => 'insights_video_url', + $label === 'video card: cover image url' => 'insights_video_cover_url', + $label === 'seo title' => 'seo_title', + $label === 'seo description' => 'seo_description', + default => null, + }; + if ($name !== null && !array_key_exists($name, $values)) { + $values[$name] = $b; + } + } + return $values; + } + + /** @return array{uid:int,layout:int}|null */ + private function childByTitle(int $parentUid, string $title): ?array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + foreach ($connection->fetchAllAssociative( + 'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid] + ) as $row) { + if ($this->norm((string)$row['title']) === $this->norm($title)) { + return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']]; + } + } + return null; + } + + /** Existing element uid for a single-element slot, or a NEW id. */ + private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string + { + if ($subPage !== null) { + $existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype); + if ($existing !== []) { + return (string)$existing[0]; + } + } + $id = $this->newId(); + $this->datamap['tt_content'][$id] = [ + 'pid' => $this->pidValue($pageId), + 'colPos' => $colPos, + 'CType' => $ctype, + ]; + return $id; + } + + /** @return int[] uids in sorting order */ + private function slotElements(int $pageUid, int $colPos, string $ctype): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + return array_map('intval', $connection->fetchFirstColumn( + 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting', + [$pageUid, $colPos, $ctype] + )); + } + + /** @param array $fields */ + private function setFields(string $table, string $id, array $fields): void + { + foreach ($fields as $field => $value) { + $this->datamap[$table][$id][$field] = $value; + } + } + + /** NEW page ids must stay strings in datamap pid references. */ + private function pidValue(string $pageId): int|string + { + return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId; + } + + /** @return array|null */ + private function recordByTitle(string $table, string $title): ?array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); + foreach ($connection->fetchAllAssociative( + 'SELECT * FROM ' . $table . ' WHERE deleted = 0', [] + ) as $row) { + if ($this->norm((string)$row['title']) === $this->norm($title)) { + return $row; + } + } + return null; + } + + /** + * Download a URL into fileadmin/submarket_import// and attach it as + * the element's file field. Editorial review happens in the backend; a + * failed download is a warning. Skipped entirely in dry runs. + */ + private function attachUrlFile(string $elementId, string $fieldName, string $url, string $submarket, bool $apply, int|string $pid = 0): void + { + $url = trim($url); + if ($url === '' || !str_starts_with($url, 'http')) { + return; + } + if (!$apply) { + $this->report[] = $fieldName . ': would download ' . $url; + return; + } + try { + // RequestFactory instead of GeneralUtility::getUrl(): the latter + // swallows the failure reason, which cost a debugging round on + // 2026-10-02 (the server could not fetch the URL, the report + // could not say why). + $response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class) + ->request($url, 'GET', ['timeout' => 20]); + if ($response->getStatusCode() !== 200) { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially'; + return; + } + $content = (string)$response->getBody(); + if ($content === '') { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially'; + return; + } + $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + $folderPath = 'submarket_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $submarket)); + $folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath); + $fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin'); + $temp = GeneralUtility::tempnam('submarket_'); + file_put_contents($temp, $content); + $file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE); + + // Re-import idempotency BY CONTENT (lesson 2026-10-02: passing a + // fresh NEW reference every run made DataHandler STACK live + // references - two visible images after two runs). An existing + // element therefore reuses its first live reference; surplus + // live references on the same field - our own earlier duplicates - + // are deleted via cmdmap. + if (!str_starts_with($elementId, 'NEW')) { + $existingRefs = $this->liveReferences((int)$elementId, $fieldName); + if ($existingRefs !== []) { + $first = array_shift($existingRefs); + if ((int)$first['uid_local'] === $file->getUid()) { + $this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged'; + } else { + $this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()]; + $this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference'; + } + foreach ($existingRefs as $surplus) { + $this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1; + $this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid']; + } + return; + } + } + + $refId = $this->newId(); + // pid, tablenames and uid_foreign are REQUIRED - without them + // DataHandler rejects the reference (found 2026-10-02: downloads + // succeeded, attachments silently did not). + $this->datamap['sys_file_reference'][$refId] = [ + 'pid' => $pid, + 'table_local' => 'sys_file', + 'uid_local' => $file->getUid(), + 'tablenames' => 'tt_content', + 'fieldname' => $fieldName, + 'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId, + ]; + $this->datamap['tt_content'][$elementId][$fieldName] = $refId; + $this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially'; + } catch (\Throwable $e) { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially'; + } + } + + /** @return array live refs on one field, oldest first */ + private function liveReferences(int $elementUid, string $fieldName): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); + return array_map( + static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']], + $connection->fetchAllAssociative( + "SELECT uid, uid_local FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content' + AND fieldname = ? AND uid_foreign = ? ORDER BY uid", + [$fieldName, $elementUid] + ) + ); + } + + /** + * DataHandler flexform array for sheet sDEF. + * + * @param array $settings + * @return array + */ + private function flex(array $settings): array + { + $fields = []; + foreach ($settings as $key => $value) { + $fields[$key] = ['vDEF' => $value]; + } + return ['data' => ['sDEF' => ['lDEF' => $fields]]]; + } + + private function newId(): string + { + return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); + } + + private function norm(string $value): string + { + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); + } +} diff --git a/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml index 794b039..14fe6e6 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml +++ b/packages/vitec/ContentBlocks/ContentElements/market-hero/config.yaml @@ -10,7 +10,7 @@ fields: type: Text max: 120 - - identifier: cta_section + - identifier: hero_cta_section type: Palette label: CTA button fields: @@ -26,13 +26,13 @@ fields: maxitems: 1 allowed: common-image-types - - identifier: video_section + - identifier: hero_video_section type: Palette label: Video teaser (optional - empty url hides the card) fields: - identifier: video_title type: Text - max: 80 + max: 120 - type: Linebreak - identifier: video_url type: Text diff --git a/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml index 383df9c..e1c25be 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml +++ b/packages/vitec/ContentBlocks/ContentElements/market-insights/config.yaml @@ -13,7 +13,7 @@ fields: type: Textarea rows: 4 - - identifier: cta_section + - identifier: insights_cta_section type: Palette label: CTA (optional) fields: @@ -24,7 +24,7 @@ fields: type: Text max: 500 - - identifier: video_section + - identifier: insights_video_section type: Palette label: Video card (optional - empty url hides the card) fields: diff --git a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml index 0fa2d33..0ca3664 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml +++ b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml @@ -15,7 +15,7 @@ fields: maxitems: 1 allowed: common-image-types - - identifier: learnmore_section + - identifier: intro_learnmore_section type: Palette label: Learn-more button (optional) fields: diff --git a/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png b/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png new file mode 100644 index 0000000..8160969 Binary files /dev/null and b/packages/vitec/Resources/Private/SubmarketTemplate/submarket-reference.png differ diff --git a/public/.htaccess b/public/.htaccess index 802e9ab..e6bc0b8 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -432,5 +432,11 @@ AuthName "DEV" AuthType Basic Require ip 217.70.192.164 + # The server itself (136.243.123.171 public, loopback variants): lets the + # sub-market importer download assets from dev URLs and keeps the door + # open for a future SSR process fetching /_json server-side (2026-10-02). + Require ip 136.243.123.171 + Require ip 127.0.0.1 + Require ip ::1 Require valid-user diff --git a/public/testimages/placeholder_1920x600.png b/public/testimages/placeholder_1920x600.png new file mode 100644 index 0000000..16fd4f7 Binary files /dev/null and b/public/testimages/placeholder_1920x600.png differ diff --git a/public/testimages/placeholder_636x469.png b/public/testimages/placeholder_636x469.png new file mode 100644 index 0000000..16fd4f7 Binary files /dev/null and b/public/testimages/placeholder_636x469.png differ