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>
This commit is contained in:
1 parent
b1357a6ffe
commit
6b0b2a3511
7 files changed
+940
-489
No files matched your search
@@ -114,6 +114,7 @@ final class AlignMarketPagesCommand extends Command
|
||||
'slug' => $joeSlug,
|
||||
];
|
||||
$targets[] = ['uid' => (int)$old['uid'], 'slug' => $joeSlug];
|
||||
$this->queueRedirect($datamap, (string)$old['slug'], (int)$old['uid']);
|
||||
$output->writeln(sprintf(' ~ %-36s RENAME from "%s" (uid %d), slug %s -> %s (redirect follows)',
|
||||
$joeName, $oldName, (int)$old['uid'], $old['slug'], $joeSlug));
|
||||
continue;
|
||||
@@ -193,6 +194,7 @@ final class AlignMarketPagesCommand extends Command
|
||||
$expected = rtrim((string)$target['slug'], '/') . '/' . $this->slugify((string)$child['title']);
|
||||
if ((string)$child['slug'] !== $expected) {
|
||||
$datamap['pages'][(int)$child['uid']]['slug'] = $expected;
|
||||
$this->queueRedirect($datamap, (string)$child['slug'], (int)$child['uid']);
|
||||
$output->writeln(sprintf(' ~ child slug: "%s" (uid %d) %s -> %s (redirect follows)',
|
||||
(string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected));
|
||||
}
|
||||
@@ -218,6 +220,27 @@ final class AlignMarketPagesCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 norm(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* One-off: creates the sys_redirect records for every slug our CLI
|
||||
* commands changed on 2026-10-23/26. EXT:redirects' automatic creation
|
||||
* only fires in the backend FormEngine context, NOT for DataHandler runs
|
||||
* from the CLI (found 2026-10-26: all old URLs answered 404) - the three
|
||||
* slug-changing commands now queue their redirects themselves; this
|
||||
* command backfills the changes made before that fix.
|
||||
*
|
||||
* Idempotent and guarded: a source that already has a redirect is
|
||||
* skipped, and a source that is currently a LIVE page slug is refused
|
||||
* (the redirect middleware runs before routing and would shadow it).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:backfill-redirects
|
||||
* vendor/bin/typo3 vitec:backfill-redirects --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:backfill-redirects',
|
||||
description: 'Create redirects for the CLI slug changes of 2026-10-23/26 (dry-run by default)'
|
||||
)]
|
||||
final class BackfillRedirectsCommand extends Command
|
||||
{
|
||||
/** old path => page uid; reconstructed from the command protocols. */
|
||||
private const REDIRECTS = [
|
||||
// markets realign 2026-10-23 (main pages)
|
||||
'/markets/retail-hospitality-and-leisure' => 41,
|
||||
'/markets/transport-and-infrastructure' => 45,
|
||||
'/markets/sports-venues-and-entertainment' => 43,
|
||||
'/markets/media-broadcast-and-telecom' => 40,
|
||||
'/markets/healthcare-and-education' => 39,
|
||||
'/markets/corporate-and-enterprise' => 36,
|
||||
'/markets/energy-industrial-and-resources' => 38,
|
||||
'/markets/defense-government-and-public-sector' => 37,
|
||||
// markets child-slug pass 2026-10-23
|
||||
'/markets/sports-and-entertainment/events-experiences' => 538,
|
||||
'/markets/media-broadcast-and-telecom/broadcast-and-media' => 443,
|
||||
'/markets/media-broadcast-and-telecom/telecoms-and-networks' => 444,
|
||||
'/markets/corporate-and-enterprise/corporate-environments' => 445,
|
||||
'/markets/corporate-and-enterprise/enterprise-operations' => 446,
|
||||
'/markets/critical-national-infrastructure/energy-utilities' => 536,
|
||||
'/markets/government/government-agencies' => 539,
|
||||
'/markets/government/traffic-smart-mobility' => 546,
|
||||
'/markets/public-safety/rtcc-/-rtic' => 544,
|
||||
'/markets/defense-and-security/defense-security' => 532,
|
||||
// solutions normalize 2026-10-23
|
||||
'/solutions/control-room-command-centre-solutions/control-room-platforms-soc-/-noc-/-command-centres' => 467,
|
||||
'/solutions/video-capture-encoding-processing/video-encoding-/-decoding' => 481,
|
||||
'/solutions/digital-signage-content-delivery/passenger-/-public-information-displays' => 502,
|
||||
'/solutions/enterprise-communications-engagament/guest-/-visitor-engagement' => 509,
|
||||
// Engagament rename 2026-10-26
|
||||
'/solutions/enterprise-communications-engagament' => 465,
|
||||
'/solutions/enterprise-communications-engagament/internal-communications-iptv' => 506,
|
||||
'/solutions/enterprise-communications-engagament/staff-messaging-systems' => 507,
|
||||
'/solutions/enterprise-communications-engagament/corporate-broadcasting' => 508,
|
||||
'/solutions/enterprise-communications-engagament/guest-visitor-engagement' => 509,
|
||||
'/solutions/enterprise-communications-engagament/multi-location-communication-networks' => 510,
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$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');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$redirects = $connection->getConnectionForTable('sys_redirect');
|
||||
$pages = $connection->getConnectionForTable('pages');
|
||||
|
||||
$datamap = [];
|
||||
$created = 0;
|
||||
$i = 0;
|
||||
foreach (self::REDIRECTS as $source => $uid) {
|
||||
$i++;
|
||||
$exists = $redirects->fetchOne(
|
||||
"SELECT uid FROM sys_redirect WHERE deleted = 0 AND source_path = ? AND source_host IN ('*','dev.vitec.com')",
|
||||
[$source]
|
||||
);
|
||||
if ($exists) {
|
||||
$output->writeln(' = ' . $source . ' already has a redirect (#' . (int)$exists . ')');
|
||||
continue;
|
||||
}
|
||||
$live = $pages->fetchOne('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$source]);
|
||||
if ($live) {
|
||||
$output->writeln(' <error>! ' . $source . ' is a LIVE page slug (uid ' . (int)$live . ') - refused</error>');
|
||||
continue;
|
||||
}
|
||||
$target = $pages->fetchOne('SELECT uid FROM pages WHERE deleted = 0 AND uid = ?', [$uid]);
|
||||
if (!$target) {
|
||||
$output->writeln(' <error>! target page ' . $uid . ' missing for ' . $source . ' - skipped</error>');
|
||||
continue;
|
||||
}
|
||||
$datamap['sys_redirect']['NEWR' . $i] = [
|
||||
'pid' => 0,
|
||||
'source_host' => '*',
|
||||
'source_path' => $source,
|
||||
'target' => 't3://page?uid=' . $uid,
|
||||
'target_statuscode' => 301,
|
||||
];
|
||||
$created++;
|
||||
$output->writeln(' + ' . $source . ' -> page #' . $uid);
|
||||
}
|
||||
|
||||
if ($created === 0) {
|
||||
$output->writeln('Nothing to create.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if (!$apply) {
|
||||
$output->writeln("\n" . $created . ' redirect(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>' . $created . ' redirect(s) written. Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,7 @@ final class NormalizeSlugsCommand extends Command
|
||||
$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));
|
||||
}
|
||||
@@ -124,6 +125,27 @@ final class NormalizeSlugsCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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), '-');
|
||||
}
|
||||
}
|
||||
@@ -787,6 +787,10 @@ final class SubsolutionWorkbookImporter
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
// The 39 delivered workbook metas carry the "Engagament" page-title
|
||||
// typo (the generator read it from the tree before the 2026-10-26
|
||||
// rename); tolerate it so re-imports keep matching the fixed page.
|
||||
$value = str_ireplace('engagament', 'engagement', $value);
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user