addOption('page', null, InputOption::VALUE_REQUIRED, 'Page uid or slug'); $this->addOption('title', null, InputOption::VALUE_REQUIRED, 'The new page title'); $this->addOption('style', null, InputOption::VALUE_REQUIRED, "Ampersand style: 'plain' (A & B -> a-b) or 'and' (-> a-and-b)", 'plain'); $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'); $pageArg = trim((string)$input->getOption('page')); $newTitle = trim((string)$input->getOption('title')); $style = (string)$input->getOption('style') === 'and' ? 'and' : 'plain'; if ($pageArg === '' || $newTitle === '') { $output->writeln('--page and --title are required.'); return Command::FAILURE; } $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $page = ctype_digit($pageArg) ? $connection->fetchAssociative('SELECT uid, pid, title, slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$pageArg]) : $connection->fetchAssociative('SELECT uid, pid, title, slug FROM pages WHERE deleted = 0 AND slug = ?', [rtrim($pageArg, '/')]); if (!$page) { $output->writeln('Page not found: ' . $pageArg . ''); return Command::FAILURE; } $parentSlug = ''; if ((int)$page['pid'] > 0) { $parent = $connection->fetchAssociative('SELECT slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$page['pid']]); $parentSlug = rtrim((string)($parent['slug'] ?? ''), '/'); } $newSlug = $parentSlug . '/' . $this->slugify($newTitle, $style); $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' ยท page #' . (int)$page['uid']); $datamap = []; $changes = 0; if ((string)$page['title'] !== $newTitle) { $datamap['pages'][(int)$page['uid']]['title'] = $newTitle; $changes++; $output->writeln(' ~ title: "' . (string)$page['title'] . '" -> "' . $newTitle . '"'); } if ((string)$page['slug'] !== $newSlug) { $datamap['pages'][(int)$page['uid']]['slug'] = $newSlug; $this->queueRedirect($datamap, (string)$page['slug'], (int)$page['uid']); $changes++; $output->writeln(' ~ slug: ' . (string)$page['slug'] . ' -> ' . $newSlug . ' (redirect follows)'); } $this->cascade($connection, (int)$page['uid'], $newSlug, $style, $datamap, $changes, $output); if ($changes === 0) { $output->writeln('Nothing to change.'); return Command::SUCCESS; } if (!$apply) { $output->writeln("\n" . $changes . ' change(s). DRY RUN - nothing written. Add --apply to write.'); return Command::SUCCESS; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, []); $dataHandler->process_datamap(); if ($dataHandler->errorLog !== []) { $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } $output->writeln('' . $changes . ' change(s) written. Flush the cache: vendor/bin/typo3 cache:flush'); return Command::SUCCESS; } /** * Descendant slugs follow the renamed page, cascading, titles untouched. * * @param array>> $datamap */ private function cascade($connection, int $parentUid, string $parentSlug, string $style, array &$datamap, int &$changes, OutputInterface $output): void { foreach ($connection->fetchAllAssociative( 'SELECT uid, title, slug, doktype FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting', [$parentUid] ) as $child) { if (in_array((int)$child['doktype'], self::SKIP_DOKTYPES, true)) { $this->cascade($connection, (int)$child['uid'], $parentSlug, $style, $datamap, $changes, $output); continue; } $expected = $parentSlug . '/' . $this->slugify((string)$child['title'], $style); if ((string)$child['slug'] !== $expected) { $datamap['pages'][(int)$child['uid']]['slug'] = $expected; $this->queueRedirect($datamap, (string)$child['slug'], (int)$child['uid']); $changes++; $output->writeln(sprintf(' ~ child: "%s" (uid %d) %s -> %s', (string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected)); } $this->cascade($connection, (int)$child['uid'], $expected, $style, $datamap, $changes, $output); } } /** * Queues the 301 for a vacated slug in the same DataHandler run - * EXT:redirects' automatic creation does not fire for CLI DataHandler * runs (found 2026-10-26: every renamed URL answered 404). * * @param array>> $datamap */ private function queueRedirect(array &$datamap, string $oldSlug, int $pageUid): void { $oldSlug = rtrim($oldSlug, '/'); if ($oldSlug === '') { return; } $datamap['sys_redirect']['NEWR' . substr(md5($oldSlug), 0, 10)] = [ 'pid' => 0, 'source_host' => '*', 'source_path' => $oldSlug, 'target' => 't3://page?uid=' . $pageUid, 'target_statuscode' => 301, ]; } private function slugify(string $value, string $style): string { $value = mb_strtolower(trim($value)); $value = str_replace('&', $style === 'and' ? ' and ' : ' ', $value); return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-'); } }