- 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>
254 lines
12 KiB
PHP
254 lines
12 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;
|
|
|
|
/**
|
|
* Aligns the MARKET PAGE TREE below /markets with Joe's agreed structure
|
|
* (sitemap tracker + Dropbox delivery, decision Olli 2026-10-23):
|
|
*
|
|
* /markets -> exactly 11 market hero pages -> their sub-market pages
|
|
* (the use-case level stays SECTIONS on the sub-market pages, 14.10.)
|
|
*
|
|
* What it does (dry-run by default):
|
|
* - renames 8 of our 9 main market pages onto Joe's market names, slug
|
|
* included (the slug change makes EXT:redirects create the redirect)
|
|
* - creates the 3 new markets HIDDEN (Critical National Infrastructure,
|
|
* Government, Public Safety)
|
|
* - hides "Security & Surveillance" (market dropped by the agency - its
|
|
* themes live in the solutions branch; records and stories untouched)
|
|
* - reports every other child it does not recognise (e.g. the stray
|
|
* "Hospitality & Leisure" page) for an editorial decision
|
|
*
|
|
* DELIBERATELY NOT here: the market RECORD taxonomy (9 mains drive the
|
|
* success-story badges, Solr facets and the story mapping
|
|
* INDUSTRY_TO_MARKET) - aligning that is a separate, editorially decided
|
|
* step. Pages first, so the workbook imports have their tree.
|
|
*
|
|
* vendor/bin/typo3 vitec:align-market-pages
|
|
* vendor/bin/typo3 vitec:align-market-pages --apply
|
|
*/
|
|
#[AsCommand(
|
|
name: 'vitec:align-market-pages',
|
|
description: "Align the /markets page tree with Joe's 11-market structure (dry-run by default)"
|
|
)]
|
|
final class AlignMarketPagesCommand extends Command
|
|
{
|
|
/** Joe's market name => our current page title (null = create new). */
|
|
private const MARKETS = [
|
|
'Hospitality' => 'Retail Hospitality & Leisure',
|
|
'Transport' => 'Transport & Infrastructure',
|
|
'Sports & Entertainment' => 'Sports Venues & Entertainment',
|
|
'Broadcast and Telecoms' => 'Media Broadcast & Telecom',
|
|
'Education' => 'Healthcare & Education',
|
|
'Corporation' => 'Corporate & Enterprise',
|
|
'Industrial' => 'Energy Industrial & Resources',
|
|
'Critical National Infrastructure' => null,
|
|
'Government' => null,
|
|
'Public Safety' => null,
|
|
'Defense and Security' => 'Defense Government & Public Sector',
|
|
];
|
|
|
|
/** Dropped by the agency - page gets hidden, nothing is deleted. */
|
|
private const HIDE = ['Security & Surveillance'];
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
|
$this->addOption('publish', null, InputOption::VALUE_NONE, 'Also unhide the market pages and their children (dev convenience; the dropped market and element shells stay hidden)');
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
Bootstrap::initializeBackendAuthentication();
|
|
$apply = (bool)$input->getOption('apply');
|
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
|
|
|
$root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
|
if (!$root) {
|
|
$output->writeln('<error>Page /markets not found.</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
$rootUid = (int)$root['uid'];
|
|
$children = $connection->fetchAllAssociative(
|
|
'SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
|
|
[$rootUid]
|
|
);
|
|
$byNorm = [];
|
|
foreach ($children as $child) {
|
|
$byNorm[$this->norm((string)$child['title'])] = $child;
|
|
}
|
|
|
|
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /markets uid ' . $rootUid . ' · ' . count($children) . ' direct children');
|
|
$datamap = [];
|
|
$claimed = [];
|
|
$targets = [];
|
|
$newIndex = 0;
|
|
|
|
foreach (self::MARKETS as $joeName => $oldName) {
|
|
$joeSlug = '/markets/' . $this->slugify($joeName);
|
|
$existingJoe = $byNorm[$this->norm($joeName)] ?? null;
|
|
if ($existingJoe !== null) {
|
|
$claimed[(int)$existingJoe['uid']] = true;
|
|
$targets[] = ['uid' => (int)$existingJoe['uid'], 'slug' => (string)$existingJoe['slug']];
|
|
$line = sprintf('= %-36s exists (uid %d%s)', $joeName, (int)$existingJoe['uid'], ((int)$existingJoe['hidden'] === 1) ? ', hidden' : '');
|
|
$output->writeln(' ' . $line);
|
|
continue;
|
|
}
|
|
$old = $oldName !== null ? ($byNorm[$this->norm($oldName)] ?? null) : null;
|
|
if ($old !== null) {
|
|
$claimed[(int)$old['uid']] = true;
|
|
$datamap['pages'][(int)$old['uid']] = [
|
|
'title' => $joeName,
|
|
'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;
|
|
}
|
|
if ($oldName !== null) {
|
|
$output->writeln(sprintf(' <error>? %-36s neither Joe name nor "%s" found - review</error>', $joeName, $oldName));
|
|
continue;
|
|
}
|
|
$newId = 'NEWMKT' . (++$newIndex);
|
|
$datamap['pages'][$newId] = [
|
|
'pid' => $rootUid,
|
|
'title' => $joeName,
|
|
'slug' => $joeSlug,
|
|
'doktype' => 1,
|
|
'hidden' => 1,
|
|
];
|
|
$output->writeln(sprintf(' + %-36s CREATE (hidden)', $joeName));
|
|
}
|
|
|
|
foreach (self::HIDE as $hideName) {
|
|
$page = $byNorm[$this->norm($hideName)] ?? null;
|
|
if ($page === null) {
|
|
$output->writeln(' <comment>hide: "' . $hideName . '" not found - nothing to do</comment>');
|
|
continue;
|
|
}
|
|
$claimed[(int)$page['uid']] = true;
|
|
if ((int)$page['hidden'] === 1) {
|
|
$output->writeln(' - ' . $hideName . ' already hidden (uid ' . (int)$page['uid'] . ')');
|
|
continue;
|
|
}
|
|
$datamap['pages'][(int)$page['uid']] = ['hidden' => 1];
|
|
$output->writeln(' - ' . $hideName . ' HIDE (uid ' . (int)$page['uid'] . ') - dropped by the agency, records/stories untouched');
|
|
}
|
|
|
|
foreach ($children as $child) {
|
|
if (!isset($claimed[(int)$child['uid']])) {
|
|
$output->writeln(sprintf(' <comment>! unexpected child "%s" (uid %d%s) - review editorially</comment>',
|
|
(string)$child['title'], (int)$child['uid'], ((int)$child['hidden'] === 1) ? ', hidden' : ''));
|
|
}
|
|
}
|
|
|
|
// --- optional: publish the tree (dev convenience, Olli 2026-10-23) -------
|
|
// Unhides the 11 market pages and their children. The dropped market
|
|
// ("Security & Surveillance") is no target and stays hidden; hidden
|
|
// ELEMENT shells (trusted-by, case studies) also stay hidden - an
|
|
// unhidden empty story list would render ALL stories.
|
|
if ((bool)$input->getOption('publish')) {
|
|
foreach ($targets as $target) {
|
|
$self = $connection->fetchAssociative('SELECT uid, title, hidden FROM pages WHERE uid = ?', [$target['uid']]);
|
|
$rows = $connection->fetchAllAssociative(
|
|
'SELECT uid, title, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
|
[$target['uid']]
|
|
);
|
|
if ($self) {
|
|
array_unshift($rows, $self);
|
|
}
|
|
foreach ($rows as $row) {
|
|
if ((int)$row['hidden'] === 1) {
|
|
$datamap['pages'][(int)$row['uid']]['hidden'] = 0;
|
|
$output->writeln(' * publish: "' . (string)$row['title'] . '" (uid ' . (int)$row['uid'] . ')');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- child slugs follow their (renamed) parent ---------------------------
|
|
// DataHandler does not cascade slug changes to subpages (only the
|
|
// backend UI offers that), so realign every child slug to
|
|
// <parent-slug>/<slugified-title>; EXT:redirects covers the old URLs.
|
|
// This also normalises slug accidents like embedded slashes from
|
|
// titles ("RTCC / RTIC").
|
|
foreach ($targets as $target) {
|
|
foreach ($connection->fetchAllAssociative(
|
|
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
|
[$target['uid']]
|
|
) as $child) {
|
|
$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));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$apply) {
|
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
|
return Command::SUCCESS;
|
|
}
|
|
if ($datamap === []) {
|
|
$output->writeln('Nothing to change.');
|
|
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>Done. Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
|
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)));
|
|
}
|
|
|
|
private function slugify(string $value): string
|
|
{
|
|
return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-');
|
|
}
|
|
}
|