- 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>
156 lines
7.1 KiB
PHP
156 lines
7.1 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;
|
|
|
|
/**
|
|
* Normalises every page slug below a root to <parent-slug>/<slugified-title>,
|
|
* recursively and cascading (a fixed parent slug carries into its children).
|
|
* EXT:redirects covers the old URLs automatically.
|
|
*
|
|
* Why: TYPO3's own slug generator KEEPS slashes that live in page titles
|
|
* ("RTCC / RTIC" -> rtcc-/-rtic), which breaks routes and canonicals - the
|
|
* defect hit us three times (passenger page, solution page 467, RTCC).
|
|
* This command repairs a whole subtree in one sweep.
|
|
*
|
|
* --style picks the ampersand convention of the tree:
|
|
* plain "A & B" -> a-b (TYPO3 default, used below /solutions)
|
|
* and "A & B" -> a-and-b (used below /markets since the 10-23 align)
|
|
* Do not mix styles within one tree: the wrong style renames EVERY page.
|
|
*
|
|
* vendor/bin/typo3 vitec:normalize-slugs --root=/solutions
|
|
* vendor/bin/typo3 vitec:normalize-slugs --root=/solutions --apply
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:normalize-slugs',
|
|
description: 'Normalise page slugs below a root to parent-slug/title-slug (dry-run by default)'
|
|
)]
|
|
final class NormalizeSlugsCommand extends Command
|
|
{
|
|
/** Page types that carry no meaningful slug of their own. */
|
|
private const SKIP_DOKTYPES = [199, 254, 255];
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /solutions');
|
|
$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');
|
|
$rootSlug = rtrim((string)$input->getOption('root'), '/');
|
|
$style = (string)$input->getOption('style') === 'and' ? 'and' : 'plain';
|
|
if ($rootSlug === '') {
|
|
$output->writeln('<error>--root is required (e.g. --root=/solutions)</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
|
$root = $connection->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ?', [$rootSlug]);
|
|
if (!$root) {
|
|
$output->writeln('<error>No page with slug ' . $rootSlug . '</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · root ' . $rootSlug . ' (uid ' . (int)$root['uid'] . ') · style ' . $style);
|
|
$datamap = [];
|
|
$changes = 0;
|
|
$this->walk($connection, (int)$root['uid'], $rootSlug, $style, $datamap, $changes, $output);
|
|
|
|
if ($changes === 0) {
|
|
$output->writeln('All slugs already normalised.');
|
|
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 . ' slug(s) written (redirects follow). Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Depth-first: the parent's EFFECTIVE (possibly just-fixed) slug prefixes
|
|
* the children. Collisions between siblings get a numeric suffix and a
|
|
* warning - that needs an editorial look, never silent overwriting.
|
|
*
|
|
* @param array<string,array<int,array<string,mixed>>> $datamap
|
|
*/
|
|
private function walk($connection, int $parentUid, string $parentSlug, string $style, array &$datamap, int &$changes, OutputInterface $output): void
|
|
{
|
|
$taken = [];
|
|
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->walk($connection, (int)$child['uid'], $parentSlug, $style, $datamap, $changes, $output);
|
|
continue;
|
|
}
|
|
$expected = $parentSlug . '/' . $this->slugify((string)$child['title'], $style);
|
|
if (isset($taken[$expected])) {
|
|
$suffixed = $expected . '-' . ((int)$child['uid']);
|
|
$output->writeln(' <comment>! sibling collision on ' . $expected . ' - "' . (string)$child['title'] . '" (uid ' . (int)$child['uid'] . ') gets ' . $suffixed . ', review editorially</comment>');
|
|
$expected = $suffixed;
|
|
}
|
|
$taken[$expected] = true;
|
|
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(' ~ "%s" (uid %d) %s -> %s', (string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected));
|
|
}
|
|
$this->walk($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), '-');
|
|
}
|
|
}
|