addArgument('file', InputArgument::REQUIRED, 'Path to the workbook (XLSX)'); $this->addArgument('product', InputArgument::REQUIRED, 'Slug or uid of the target product'); $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dryRun = (bool)$input->getOption('dry-run'); $file = (string)$input->getArgument('file'); if (!is_file($file)) { $output->writeln('File not found: ' . $file . ''); return Command::FAILURE; } $product = $this->loadProduct((string)$input->getArgument('product')); if ($product === null) { $output->writeln('No product record for "' . $input->getArgument('product') . '".'); return Command::FAILURE; } $uid = (int)$product['uid']; $output->writeln(sprintf('Target: uid %d "%s" (/%s)%s', $uid, $product['title'], $product['slug'], $dryRun ? ' - DRY RUN' : '')); $parsed = $this->reader->parse($file); $pageType = (string)($parsed['pageType'] ?? 'unknown'); if ($pageType !== 'product') { $output->writeln('Not a product workbook (pageType "' . $pageType . '") - nothing imported.'); return Command::FAILURE; } // Workbook URL vs record slug - warn only, same as the module. $url = trim((string)($parsed['meta']['url'] ?? ''), '/'); $slug = trim((string)$product['slug'], '/'); if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) { $output->writeln(sprintf('URL mismatch: /%s (workbook) vs /%s (record) - check the target!', $url, $slug)); } // Defaults as the base, the stored module mapping as per-field // override - a field the module never saved (its dropdown was empty // on an old-format workbook, e.g. `highlights`) still imports with // its default selector. Found in the 2026-09-24 batch run: the // stored mapping predated the highlights default and silently // dropped the field on all 69 products. $mapping = array_merge( ProductTextImportController::DEFAULT_MAPPING, array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []) ); $data = []; foreach ($mapping as $field => $selector) { if (in_array($field, self::PROTECTED_FIELDS, true)) { $output->writeln(sprintf('%-20s %-34s -> protected (canonical, never written)', $field, $selector)); continue; } $value = $this->reader->valueFor($parsed, $selector); if ($value === null) { $output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector)); continue; } $old = (string)($product[$field] ?? ''); if ($this->isUnchanged($old, $value)) { $output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector)); continue; } $data[$field] = $value; $output->writeln(sprintf('%-20s %-34s -> WRITE (%d chars%s)', $field, $selector, mb_strlen($value), $old === '' ? ', was empty' : '')); } if ($data === []) { $output->writeln('All mapped fields are up to date - nothing to write.'); return Command::SUCCESS; } if ($dryRun) { $output->writeln(sprintf('DRY RUN - %d field(s) would be written.', count($data))); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start([self::TABLE => [(string)$uid => $data]], []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { foreach ($dataHandler->errorLog as $error) { $output->writeln('' . $error . ''); } return Command::FAILURE; } $this->storeWorkbook($uid, $parsed, basename($file)); $output->writeln(sprintf('%d field(s) written (%s). Workbook stored for the module. Flush the cache: vendor/bin/typo3 cache:flush', count($data), implode(', ', array_keys($data)))); return Command::SUCCESS; } /** * Compare tolerant of the RTE storage transform - shared with the * module's "differs" badge, see RteCompare. The local version missed * inter-tag whitespace ("