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]; $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(' ? %-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; $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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); return Command::FAILURE; } $output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush'); 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>> $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))), '-'); } }