diff --git a/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php index caaa370..8067f9a 100644 --- a/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php +++ b/packages/vitec/Classes/Command/ImportSubmarketWorkbooksCommand.php @@ -4,40 +4,18 @@ declare(strict_types=1); namespace Evomedien\Vitec\Command; -use PhpOffice\PhpSpreadsheet\IOFactory; -use PhpOffice\PhpSpreadsheet\Spreadsheet; +use Evomedien\Vitec\Import\SubmarketWorkbookImporter; 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\DataHandling\DataHandler; -use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior; -use TYPO3\CMS\Core\Resource\StorageRepository; -use TYPO3\CMS\Core\Utility\GeneralUtility; /** - * Imports the customer-filled sub-market workbooks (produced by - * vitec:generate-submarket-workbooks) into layout-21 pages. - * - * Reads ONLY named ranges (hero_subline, testi_story_1, ...) plus the hidden - * Meta sheet - never cell addresses. Per workbook it - * - * 1. resolves the main-market page below /markets via the Meta group, - * 2. finds or CREATES the sub-market page (layout 21, HIDDEN on first - * creation - editorial review publishes it), - * 3. fills the six colPos slots (110-160) - existing elements are updated - * in place, missing ones created; nothing is deleted, - * 4. sets detail_page on the market record (Meta record_uid), - * 5. downloads asset URLs to fileadmin/submarket_import// 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. + * CLI shell around SubmarketWorkbookImporter (the shared implementation also + * used by the "VITEC Import" backend module since 2026-10-05 - behavior and + * lessons are documented THERE). * * vendor/bin/typo3 vitec:import-submarket-workbooks --dir=migrations/submarkets_xlsx_in * vendor/bin/typo3 vitec:import-submarket-workbooks --dir=... --only=broadcast --apply @@ -48,27 +26,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; )] 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; + public function __construct(private readonly SubmarketWorkbookImporter $importer) + { + parent::__construct(); + } protected function configure(): void { @@ -107,11 +68,16 @@ final class ImportSubmarketWorkbooksCommand extends Command $errors = 0; foreach ($files as $file) { - $this->report = []; - $this->datamap = []; - $this->cmdmap = []; try { - $this->importOne($file, $apply, $output); + $result = $this->importer->importFile($file, $apply); + $output->writeln(sprintf("\n%s (%s / %s)", $result['file'], $result['group'], $result['submarket'])); + foreach ($result['report'] as $line) { + $output->writeln(' ' . $line . ''); + } + $output->writeln(sprintf(' page %s · %d element(s) touched · %s', + $result['newPage'] && $result['pageUid'] === 0 ? 'NEW' : '#' . $result['pageUid'], + $result['elements'], + $apply ? 'written.' : 'dry run')); } catch (\Throwable $e) { $errors++; $output->writeln('' . basename($file) . ': ' . $e->getMessage() . ''); @@ -122,538 +88,4 @@ final class ImportSubmarketWorkbooksCommand extends Command } 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/Classes/Controller/Backend/SubmarketImportController.php b/packages/vitec/Classes/Controller/Backend/SubmarketImportController.php new file mode 100644 index 0000000..591780c --- /dev/null +++ b/packages/vitec/Classes/Controller/Backend/SubmarketImportController.php @@ -0,0 +1,181 @@ +render($request, []); + } + + public function submarketUploadAction(ServerRequestInterface $request): ResponseInterface + { + $uploads = $request->getUploadedFiles()['workbooks'] ?? []; + if (!is_array($uploads)) { + $uploads = [$uploads]; + } + $dir = $this->inboxPath(); + if (!is_dir($dir)) { + GeneralUtility::mkdir_deep($dir); + } + $stored = []; + foreach ($uploads as $upload) { + if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) { + continue; + } + $name = $this->safeName((string)$upload->getClientFilename()); + if ($name === '') { + $this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING); + continue; + } + $upload->moveTo($dir . '/' . $name); + GeneralUtility::fixPermissions($dir . '/' . $name); + $stored[] = $name; + } + if ($stored !== []) { + $this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK); + } else { + $this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING); + } + return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.submarkets'), 303); + } + + public function submarketProcessAction(ServerRequestInterface $request): ResponseInterface + { + $body = (array)$request->getParsedBody(); + $apply = (string)($body['op'] ?? 'dry') === 'apply'; + $selected = array_map('strval', (array)($body['files'] ?? [])); + if ($selected === []) { + $this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING); + return $this->render($request, []); + } + + $available = array_flip(array_column($this->workbooks(), 'name')); + $results = []; + foreach ($selected as $name) { + // Whitelist against the actual directory listing - the submitted + // name never touches the filesystem on its own (no traversal). + if (!isset($available[$name])) { + $results[] = ['file' => $name, 'error' => 'File not found in the inbox.']; + continue; + } + try { + $results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply); + } catch (\Throwable $e) { + $results[] = ['file' => $name, 'error' => $e->getMessage()]; + } + } + + $errors = count(array_filter($results, static fn(array $r): bool => isset($r['error']))); + $this->message( + sprintf('%d workbook(s) processed (%s)%s.', + count($results), + $apply ? 'APPLY' : 'dry run', + $errors > 0 ? ', ' . $errors . ' with errors' : '' + ), + $errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK + ); + return $this->render($request, $results); + } + + // ------------------------------------------------------------ internals + + /** @param array> $results */ + private function render(ServerRequestInterface $request, array $results): ResponseInterface + { + $view = $this->moduleTemplateFactory->create($request); + $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css'); + $view->assignMultiple([ + 'workbooks' => $this->workbooks(), + 'inbox' => self::INBOX, + 'results' => $results, + 'hasResults' => $results !== [], + ]); + return $view->renderResponse('Import/Submarkets'); + } + + /** @return array */ + private function workbooks(): array + { + $dir = $this->inboxPath(); + if (!is_dir($dir)) { + return []; + } + $rows = []; + foreach (scandir($dir) ?: [] as $name) { + if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) { + continue; + } + $path = $dir . '/' . $name; + if (!is_file($path)) { + continue; + } + $rows[] = [ + 'name' => $name, + 'size' => sprintf('%.0f KB', filesize($path) / 1024), + 'modified' => date('Y-m-d H:i', (int)filemtime($path)), + ]; + } + usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + return $rows; + } + + private function inboxPath(): string + { + return Environment::getProjectPath() . '/' . self::INBOX; + } + + /** Client filename reduced to a safe basename; '' when it is no .xlsx. */ + private function safeName(string $clientFilename): string + { + $name = basename(str_replace('\\', '/', trim($clientFilename))); + if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) { + return ''; + } + return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name); + } + + private function message(string $text, ContextualFeedbackSeverity $severity): void + { + $message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true); + $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message); + } +} diff --git a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php new file mode 100644 index 0000000..d984961 --- /dev/null +++ b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php @@ -0,0 +1,612 @@ +/ and + * attaches them (review/crop stays editorial - a failed download is a + * warning, never an abort). + * + * 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. + */ +final class SubmarketWorkbookImporter +{ + 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; + + /** + * @return array{file:string,group:string,submarket:string,newPage:bool,pageUid:int,elements:int,applied:bool,report:string[]} + * @throws \RuntimeException on fatal problems (unreadable, meta missing, main market page missing, DataHandler errors) + */ + public function importFile(string $file, bool $apply): array + { + $this->report = []; + $this->datamap = []; + $this->cmdmap = []; + + $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?'); + } + + // --- 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); + + 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'; + } + + $isNewPage = str_starts_with($pageId, 'NEW'); + $result = [ + 'file' => basename($file), + 'group' => $group, + 'submarket' => $submarket, + 'newPage' => $isNewPage, + 'pageUid' => $isNewPage ? 0 : (int)$pageId, + 'elements' => count($this->datamap['tt_content'] ?? []), + 'applied' => $apply, + 'report' => $this->report, + ]; + + if (!$apply) { + return $result; + } + + $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)); + } + if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) { + $result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId]; + } + $result['report'] = $this->report; + return $result; + } + + // ---- 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/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index 2105deb..475cf9c 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Evomedien\Vitec\Controller\Backend\ImportController; use Evomedien\Vitec\Controller\Backend\ProductTextImportController; +use Evomedien\Vitec\Controller\Backend\SubmarketImportController; use Evomedien\Vitec\Controller\Backend\WordPressImportController; use Evomedien\Vitec\Controller\OgImageController; @@ -53,6 +54,17 @@ return [ 'target' => ProductTextImportController::class . '::productApplyAction', 'methods' => ['POST'], ], + 'submarkets' => [ + 'target' => SubmarketImportController::class . '::submarketsAction', + ], + 'submarket_upload' => [ + 'target' => SubmarketImportController::class . '::submarketUploadAction', + 'methods' => ['POST'], + ], + 'submarket_process' => [ + 'target' => SubmarketImportController::class . '::submarketProcessAction', + 'methods' => ['POST'], + ], ], ], 'web_vitecwpimport' => [ diff --git a/packages/vitec/Resources/Private/Templates/Import/Index.html b/packages/vitec/Resources/Private/Templates/Import/Index.html index dd4edba..834b942 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Index.html +++ b/packages/vitec/Resources/Private/Templates/Import/Index.html @@ -26,6 +26,9 @@ + diff --git a/packages/vitec/Resources/Private/Templates/Import/Products.html b/packages/vitec/Resources/Private/Templates/Import/Products.html index 9aefaf2..0ce2ac5 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Products.html +++ b/packages/vitec/Resources/Private/Templates/Import/Products.html @@ -23,6 +23,9 @@ + diff --git a/packages/vitec/Resources/Private/Templates/Import/Seo.html b/packages/vitec/Resources/Private/Templates/Import/Seo.html index 4b1f4e1..f727c50 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Seo.html +++ b/packages/vitec/Resources/Private/Templates/Import/Seo.html @@ -18,6 +18,9 @@ {m.label} + diff --git a/packages/vitec/Resources/Private/Templates/Import/Submarkets.html b/packages/vitec/Resources/Private/Templates/Import/Submarkets.html new file mode 100644 index 0000000..0d0c819 --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/Import/Submarkets.html @@ -0,0 +1,141 @@ + + + + + + +
+
+

VITEC Import — Sub-Markets

+
+
+ + + + +
+
Upload filled workbooks
+
+
+
+ +
+
+ +
+
+ Multiple files allowed. A file with the same name replaces the stored one ({inbox}/). +
+
+
+
+ + +
+
Workbooks in the inbox
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
FileSizeUploaded / changed
{w.name}{w.size}{w.modified}
+ + + + Always dry-run first. Apply updates existing elements in place, creates missing ones, never deletes. + New pages are created HIDDEN for editorial review. + +
+
+ +

No workbooks yet — upload Joe's filled .xlsx files above.

+
+
+
+
+ + + + +
+
+ {r.file} + + + ERROR + + + — {r.group} / {r.submarket} + + written + dry run + + + +
+
+ + +

{r.error}

+
+ +

+ Page: + + + #{r.pageUid} — open in page module + + + NEW (would be created hidden) + + + · {r.elements} element(s) touched +

+ +
    + +
  • {line}
  • +
    +
+
+
+
+
+
+
+
+ +
+ diff --git a/packages/vitec/Resources/Public/Css/backend-import.css b/packages/vitec/Resources/Public/Css/backend-import.css index d2cb954..2cdc52a 100644 --- a/packages/vitec/Resources/Public/Css/backend-import.css +++ b/packages/vitec/Resources/Public/Css/backend-import.css @@ -95,6 +95,14 @@ background-color: #313131; color: #ffffff; } +.vitec-administration .vitec-badge-ok { + background-color: #2f9e44; + color: #ffffff; +} +.vitec-administration .vitec-badge-danger { + background-color: #c0392b; + color: #ffffff; +} /* --- form focus (frontend uses a 3px orange outline) ---------------- */