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('Page /markets not found.');
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];
$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(' ? %-36s neither Joe name nor "%s" found - review', $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(' hide: "' . $hideName . '" not found - nothing to do');
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(' ! unexpected child "%s" (uid %d%s) - review editorially',
(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
// /; 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;
$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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
return Command::FAILURE;
}
$output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush');
return Command::SUCCESS;
}
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))), '-');
}
}