product slug; family entries * are the K-level landing pages and are never imported; product null means * unresolved - reported and skipped). * * The heavy lifting stays in the single command (same reader, same mapping, * same DataHandler path, workbook parses stored for the module) - this * command only orchestrates it per manifest entry and aggregates a report. * * Without --apply every run is a dry run. --init-manifest resolves entries * with product null against the FULL product table (hidden records too): * workbook-URL segment or filename against slug, normalized title, and as a * last resort a UNIQUE slug prefix (mgw-ace -> mgw-ace-encoder). Only * unambiguous hits are written back to the manifest. * * vendor/bin/typo3 vitec:import-product-workbooks --init-manifest * vendor/bin/typo3 vitec:import-product-workbooks * vendor/bin/typo3 vitec:import-product-workbooks --only=aligo * vendor/bin/typo3 vitec:import-product-workbooks --apply */ #[AsCommand( name: 'vitec:import-product-workbooks', description: 'Batch: apply all agency workbooks to their product records via manifest.json (dry-run without --apply)' )] final class ImportProductWorkbooksCommand extends Command { private const TABLE = 'tx_vitec_domain_model_product'; public function __construct(private readonly ProductXlsxReader $reader) { parent::__construct(); } protected function configure(): void { $this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory holding manifest.json', 'migrations/products_xlsx'); $this->addOption('apply', null, InputOption::VALUE_NONE, 'Actually write - without this every run is a dry run'); $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Process only entries whose path or product slug contains this substring'); $this->addOption('init-manifest', null, InputOption::VALUE_NONE, 'Resolve product:null manifest entries against the full product table (writes the manifest, imports nothing)'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dir = rtrim((string)$input->getOption('dir'), '/'); $manifestFile = $dir . '/manifest.json'; if (!is_file($manifestFile)) { $output->writeln('Manifest not found: ' . $manifestFile . ''); return Command::FAILURE; } $manifest = json_decode((string)file_get_contents($manifestFile), true); $entries = $manifest['workbooks'] ?? null; if (!is_array($entries)) { $output->writeln('Manifest malformed (no workbooks key)'); return Command::FAILURE; } if ((bool)$input->getOption('init-manifest')) { return $this->initManifest($dir, $manifestFile, $manifest, $output); } $apply = (bool)$input->getOption('apply'); $only = (string)($input->getOption('only') ?? ''); $single = $this->getApplication()?->find('vitec:import-product-workbook'); if ($single === null) { $output->writeln('vitec:import-product-workbook not available'); return Command::FAILURE; } $output->writeln(sprintf('%d manifest entries · %s', count($entries), $apply ? 'APPLY' : 'DRY-RUN')); $families = 0; $unresolved = []; $processed = 0; $totalWrites = 0; $errors = 0; foreach ($entries as $relPath => $entry) { $slug = $entry['product'] ?? null; if (!empty($entry['family'])) { $families++; continue; } if ($slug === null || $slug === '') { $unresolved[] = $relPath; continue; } if ($only !== '' && !str_contains($relPath, $only) && !str_contains((string)$slug, $only)) { continue; } $file = $dir . '/' . $relPath; $buffer = new BufferedOutput(); $arguments = ['file' => $file, 'product' => (string)$slug]; if (!$apply) { $arguments['--dry-run'] = true; } try { $exit = $single->run(new ArrayInput($arguments), $buffer); } catch (\Throwable $e) { $exit = 1; $buffer->writeln('EXCEPTION: ' . $e->getMessage()); } $text = $buffer->fetch(); $writes = substr_count($text, '-> WRITE'); $unchanged = substr_count($text, '-> unchanged'); $missing = substr_count($text, '-> not in this file'); $mismatch = str_contains($text, 'URL mismatch'); $processed++; $totalWrites += $writes; $flags = []; if ($mismatch) { $flags[] = 'URL-MISMATCH'; } if ($exit !== 0) { $flags[] = 'ERROR'; $errors++; } $output->writeln(sprintf( '%-72s -> %-28s %d write, %d unchanged, %d n/a%s', mb_substr($relPath, 0, 72), mb_substr((string)$slug, 0, 28), $writes, $unchanged, $missing, $flags !== [] ? ' [' . implode(', ', $flags) . ']' : '' )); if ($exit !== 0 || $mismatch || $output->isVerbose()) { foreach (explode("\n", trim($text)) as $line) { $output->writeln(' ' . $line); } } } $output->writeln(''); $output->writeln(sprintf( '%d workbooks processed · %d field writes%s · %d errors · %d family workbooks skipped · %d unresolved', $processed, $totalWrites, $apply ? '' : ' (dry run - nothing written)', $errors, $families, count($unresolved) )); foreach ($unresolved as $relPath) { $output->writeln(' unresolved: ' . $relPath . ''); } if ($apply && $totalWrites > 0) { $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); } return $errors === 0 ? Command::SUCCESS : Command::FAILURE; } /** * Resolve product:null entries against the full product table. Writes * only unambiguous hits back into the manifest; everything else stays * null with its note. * * @param array $manifest */ private function initManifest(string $dir, string $manifestFile, array $manifest, OutputInterface $output): int { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $products = $connection->fetchAllAssociative( 'SELECT uid, slug, title, hidden FROM ' . self::TABLE . ' WHERE deleted = 0' ); $bySlug = []; $byTitle = []; foreach ($products as $product) { $bySlug[(string)$product['slug']] = $product; $byTitle[$this->normalize((string)$product['title'])] = $product; } $resolved = 0; foreach ($manifest['workbooks'] as $relPath => $entry) { if (!empty($entry['family']) || (($entry['product'] ?? null) !== null)) { continue; } $candidates = []; $file = $dir . '/' . $relPath; if (is_file($file)) { try { $parsed = $this->reader->parse($file); $segment = basename(rtrim((string)($parsed['meta']['url'] ?? ''), '/')); if ($segment !== '') { $candidates[] = $segment; } } catch (\Throwable $e) { // unreadable file - fall through to the filename } } $stem = (string)preg_replace('/(_?Product[_ ]Page[_ ]Content|_US_English)/i', '', pathinfo($relPath, PATHINFO_FILENAME)); $candidates[] = $this->slugify($stem); $hit = null; $via = ''; foreach ($candidates as $candidate) { if (isset($bySlug[$candidate])) { $hit = $bySlug[$candidate]; $via = 'slug'; break; } if (isset($byTitle[$this->normalize($candidate)])) { $hit = $byTitle[$this->normalize($candidate)]; $via = 'titel'; break; } $prefixHits = array_values(array_filter( $products, static fn(array $p): bool => str_starts_with((string)$p['slug'], $candidate . '-') )); if (count($prefixHits) === 1) { $hit = $prefixHits[0]; $via = 'slug-prefix'; break; } } if ($hit !== null) { $manifest['workbooks'][$relPath] = [ 'product' => (string)$hit['slug'], 'note' => sprintf('auto-resolved (%s%s) 2026-09-24', $via, ((int)$hit['hidden'] === 1) ? ', Record versteckt!' : ''), ]; $resolved++; $output->writeln(sprintf(' %-70s -> %s (%s%s)', mb_substr($relPath, 0, 70), $hit['slug'], $via, ((int)$hit['hidden'] === 1) ? ', HIDDEN' : '')); } else { $output->writeln(' ' . $relPath . ' -> weiterhin ungeklaert'); } } file_put_contents($manifestFile, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"); $output->writeln(sprintf('%d entries resolved, manifest updated.', $resolved)); return Command::SUCCESS; } private function normalize(string $title): string { $title = str_replace(['&', '-', '_'], ['and', ' ', ' '], mb_strtolower($title)); $title = (string)preg_replace('/\s*\(.*?\)/', '', $title); $title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title); return trim((string)preg_replace('/\s+/', ' ', $title)); } private function slugify(string $value): string { return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-'); } }