Files
VITEC-website/packages/vitec/Classes/Command/RenamePageCommand.php
T
orascheandClaude Fable 5 6b0b2a3511 Engagament rename; redirects installed, CLI-queued and backfilled
- new generic vitec:rename-page --page=<uid|slug> --title="...": renames
  a page, recalculates its own slug from the parent slug plus the new
  title and cascades every descendant slug - the gap a backend rename
  leaves open. Used to fix the "Engagament" page (#465): title, own
  slug and five child slugs in one run, verified live (zero occurrences
  left in the JSON)
- SubsolutionWorkbookImporter::norm() tolerates the engagament spelling:
  the 39 delivered workbook metas carry the old page title, re-imports
  keep matching (verified)
- typo3/cms-redirects installed - it had NEVER been installed, so every
  "redirect follows" of the 10-23 renames was wishful thinking and all
  old URLs answered 404. Backend slug changes now auto-create redirects
- EXT:redirects' auto-creation does not fire for CLI DataHandler runs,
  so vitec:rename-page, vitec:normalize-slugs and
  vitec:align-market-pages now queue their 301 sys_redirect rows
  themselves in the same DataHandler run
- new one-off vitec:backfill-redirects: the 28 paths vacated on
  2026-10-23/26, reconstructed from the command protocols, guarded
  against existing redirects and live slugs; applied and verified.
  Note the headless semantics: a redirect arrives as the JSON envelope
  {"redirectUrl": ..., "statusCode": 301} with HTTP 200 - the front end
  must act on redirectUrl (briefing updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 10:19:16 +01:00

167 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
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\Utility\GeneralUtility;
/**
* Renames ONE page and keeps the slugs consistent: the page's own slug is
* recalculated from its parent slug plus the slugified new title, and every
* descendant slug cascades along. EXT:redirects covers all old URLs.
*
* Why a command: a backend rename changes the title only - the slug keeps
* the old wording and the subtree keeps the old path (the "Engagament" page
* carried its typo into six URLs, the nav and the megamenu). This does the
* whole rename in one idempotent sweep.
*
* --style picks the ampersand convention of the tree ('plain' strips the
* ampersand like TYPO3, used below /solutions; 'and' writes -and-, used
* below /markets). See vitec:normalize-slugs.
*
* vendor/bin/typo3 vitec:rename-page --page=/solutions/enterprise-communications-engagament --title="Enterprise Communications & Engagement"
* vendor/bin/typo3 vitec:rename-page --page=465 --title="..." --apply
*/
#[AsCommand(
name: 'vitec:rename-page',
description: 'Rename a page with consistent slug + descendant-slug cascade (dry-run by default)'
)]
final class RenamePageCommand extends Command
{
private const SKIP_DOKTYPES = [199, 254, 255];
protected function configure(): void
{
$this->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('<error>--page and --title are required.</error>');
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('<error>Page not found: ' . $pageArg . '</error>');
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('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>' . $changes . ' change(s) written. Flush the cache: vendor/bin/typo3 cache:flush</info>');
return Command::SUCCESS;
}
/**
* Descendant slugs follow the renamed page, cascading, titles untouched.
*
* @param array<string,array<int,array<string,mixed>>> $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<string,array<int|string,array<string,mixed>>> $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), '-');
}
}