"plus") * 2. last page-slug segment == solution slug * Only UNIQUE matches are written; `detail_page` is fill-only (a record that * already points at a page is never changed - a differing find is reported). * Hidden pages match too (pages may be unpublished while content is built); * deleted and sys-folder/link pages never. * * vendor/bin/typo3 vitec:map-solution-pages --dry-run * vendor/bin/typo3 vitec:map-solution-pages */ #[AsCommand( name: 'vitec:map-solution-pages', description: 'Write matching page uids into the solutions\' detail_page field (unique matches, fill-only)' )] final class MapSolutionPagesCommand extends Command { private const TABLE = 'tx_vitec_domain_model_solution'; protected function configure(): void { $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing'); $this->addOption('prune-duplicate-pages', null, InputOption::VALUE_NONE, 'Soft-delete duplicate pages (slug suffix -N, zero content elements) that lost the disambiguation'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $dryRun = (bool)$input->getOption('dry-run'); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE); $solutions = $connection->fetchAllAssociative( 'SELECT uid, title, slug, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0 ORDER BY title' ); // standard pages only (doktype 1); hidden allowed, deleted not $pages = $connection->fetchAllAssociative( 'SELECT uid, pid, title, slug, hidden FROM pages WHERE deleted = 0 AND doktype = 1' ); $pagesByNormTitle = []; $pagesBySlugTail = []; foreach ($pages as $page) { $pagesByNormTitle[$this->normalize((string)$page['title'])][] = $page; $tail = strtolower(trim((string)strrchr('/' . trim((string)$page['slug'], '/'), '/'), '/')); if ($tail !== '') { $pagesBySlugTail[$tail][] = $page; } } $datamap = []; $planned = 0; foreach ($solutions as $solution) { $uid = (int)$solution['uid']; $candidates = $pagesByNormTitle[$this->normalize((string)$solution['title'])] ?? []; if ($candidates === [] && (string)$solution['slug'] !== '') { $candidates = $pagesBySlugTail[strtolower((string)$solution['slug'])] ?? []; } if ($candidates === []) { $output->writeln(sprintf('keine Seite: %s', $solution['title'])); continue; } $duplicatePages = []; if (count($candidates) > 1) { // Page-tree duplicates carry TYPO3's slug dedupe suffix "-N" // (found 2026-09-04: two solution branches were created twice, // empty). Prefer the clean slug; the suffixed ones can be // pruned via --prune-duplicate-pages. $clean = array_values(array_filter($candidates, static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug']))); if (count($clean) === 1) { $duplicatePages = array_values(array_filter($candidates, static fn(array $p): bool => (int)$p['uid'] !== (int)$clean[0]['uid'])); $candidates = $clean; } } if (count($candidates) > 1) { $output->writeln(sprintf('MEHRDEUTIG (%d Seiten): %s - uids %s', count($candidates), $solution['title'], implode(',', array_column($candidates, 'uid')))); continue; } foreach ($duplicatePages as $duplicate) { $this->duplicates[(int)$duplicate['uid']] = (string)$duplicate['slug']; } $page = $candidates[0]; $pageUid = (int)$page['uid']; $current = (int)$solution['detail_page']; if ($current === $pageUid) { $output->writeln(sprintf('ok %s -> Seite %d (bereits gesetzt)', $solution['title'], $pageUid)); continue; } if ($current > 0) { $output->writeln(sprintf('KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst', $solution['title'], $current, $pageUid, $page['title'])); continue; } $datamap[self::TABLE][$uid]['detail_page'] = $pageUid; $planned++; $output->writeln(sprintf('%s %s -> Seite %d "%s" (/%s)%s', $dryRun ? 'plan ' : 'WRITE ', $solution['title'], $pageUid, $page['title'], trim((string)$page['slug'], '/'), $page['hidden'] ? ' [Seite versteckt]' : '')); } $output->writeln(sprintf("\n%d von %d Solutions zu mappen.", $planned, count($solutions))); $prune = (bool)$input->getOption('prune-duplicate-pages'); $cmdmap = []; foreach ($this->duplicates as $pageUid => $slug) { $contentCount = (int)$connection->fetchOne( 'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$pageUid] ); if ($contentCount > 0) { $output->writeln(sprintf('Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht', $pageUid, trim($slug, '/'), $contentCount)); continue; } if ($prune) { $cmdmap['pages'][$pageUid]['delete'] = 1; } $output->writeln(sprintf('%s Duplikat-Seite %d (/%s, leer)', $prune ? ($dryRun ? 'plan-DELETE' : 'DELETE ') : 'Duplikat: ', $pageUid, trim($slug, '/'))); } if (!$prune && $this->duplicates !== []) { $output->writeln('(Duplikat-Seiten bleiben stehen - mit --prune-duplicate-pages loeschen.)'); } if ($datamap === [] && $cmdmap === []) { return Command::SUCCESS; } if ($dryRun) { $output->writeln('DRY RUN - nichts geschrieben.'); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, $cmdmap); if ($datamap !== []) { $dataHandler->process_datamap(); } if ($cmdmap !== []) { $dataHandler->process_cmdmap(); } if ($dataHandler->errorLog !== []) { foreach ($dataHandler->errorLog as $error) { $output->writeln('' . $error . ''); } return Command::FAILURE; } $output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } /** @var array duplicate page uid => slug, collected during matching */ private array $duplicates = []; private function normalize(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); } }