diff --git a/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php b/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php new file mode 100644 index 0000000..938c6b4 --- /dev/null +++ b/packages/vitec/Classes/Command/AlignMarketRecordsCommand.php @@ -0,0 +1,280 @@ + new name (page title). Renames keep the story relations. */ + private const RENAME_MAP = [ + // main markets (agency mapping, 2026-10-23) + 'Corporate & Enterprise' => 'Corporation', + 'Media Broadcast & Telecom' => 'Broadcast and Telecoms', + 'Retail Hospitality & Leisure' => 'Hospitality', + 'Transport & Infrastructure' => 'Transport', + 'Sports Venues & Entertainment' => 'Sports & Entertainment', + 'Healthcare & Education' => 'Education', + 'Energy Industrial & Resources' => 'Industrial', + 'Defense Government & Public Sector' => 'Defense and Security', + // sub-markets + 'Aviation' => 'Airports', + 'Casinos & Sports Books' => 'Casinos', + 'Education' => 'Education Communications', + 'Government' => 'Government & Agencies', + 'Defense/ Military' => 'Defense & Security', + 'Defense Military' => 'Defense & Security', + 'Emergency Services' => 'Emergency Operation Centres', + 'Sports Clubs' => 'Sports', + 'Sports (Clubs)' => 'Sports', + 'Utilities' => 'Energy & Utilities', + ]; + + /** Renamed records whose stories need an editorial review (market splits). */ + private const SPLIT_REVIEW = [ + 'Defense Government & Public Sector', + 'Energy Industrial & Resources', + ]; + + 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'); + $pool = GeneralUtility::makeInstance(ConnectionPool::class); + $pages = $pool->getConnectionForTable('pages'); + $markets = $pool->getConnectionForTable(self::TABLE); + + // --- targets: the visible /markets tree --------------------------------- + $root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'"); + if (!$root) { + $output->writeln('Page /markets not found.'); + return Command::FAILURE; + } + $targets = []; + foreach ($this->childPages($pages, (int)$root['uid']) as $main) { + $targets[] = ['title' => (string)$main['title'], 'pageUid' => (int)$main['uid'], 'level' => 1]; + foreach ($this->childPages($pages, (int)$main['uid']) as $sub) { + $targets[] = ['title' => (string)$sub['title'], 'pageUid' => (int)$sub['uid'], 'level' => 2]; + } + } + + // --- records -------------------------------------------------------------- + $records = $markets->fetchAllAssociative( + 'SELECT uid, pid, title, slug, detail_page, hidden FROM ' . self::TABLE . ' WHERE deleted = 0 AND sys_language_uid IN (0,-1)' + ); + $pidCounts = array_count_values(array_map(static fn(array $r): int => (int)$r['pid'], $records)); + arsort($pidCounts); + $storagePid = (int)(array_key_first($pidCounts) ?? 0); + + $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($targets) . ' target(s) from the page tree · ' . count($records) . ' record(s)'); + $datamap = []; + $claimedRecords = []; + $claimedTargets = []; + $newIndex = 0; + + // pass 1: explicit renames keep the story relations + foreach (self::RENAME_MAP as $oldTitle => $newName) { + $record = $this->findRecord($records, $claimedRecords, $oldTitle); + if ($record === null) { + continue; + } + $targetIndex = $this->findTarget($targets, $claimedTargets, $newName); + if ($targetIndex === null) { + $output->writeln(' ? rename "' . $oldTitle . '" -> "' . $newName . '": no page target - skipped'); + continue; + } + $claimedRecords[(int)$record['uid']] = true; + $claimedTargets[$targetIndex] = true; + $target = $targets[$targetIndex]; + $fields = []; + if ((string)$record['title'] !== $target['title']) { + $fields['title'] = $target['title']; + $fields['slug'] = $this->slugify($target['title']); + } + if ((int)$record['detail_page'] !== $target['pageUid']) { + $fields['detail_page'] = $target['pageUid']; + } + $stories = $this->stories($pool, (int)$record['uid']); + if ($fields !== []) { + $datamap[self::TABLE][(int)$record['uid']] = $fields; + $output->writeln(sprintf(' ~ RENAME #%d "%s" -> "%s" (detail_page %d, %d stories travel along)', + (int)$record['uid'], (string)$record['title'], $target['title'], $target['pageUid'], count($stories))); + } else { + $output->writeln(sprintf(' = #%d "%s" already aligned', (int)$record['uid'], $target['title'])); + } + if (in_array($oldTitle, self::SPLIT_REVIEW, true) && $stories !== []) { + $output->writeln(' SPLIT REVIEW - these stories stay on "' . $target['title'] . '", some may belong to the new sibling markets:'); + foreach ($stories as $story) { + $output->writeln(' - ' . $story); + } + } + } + + // pass 2: exact title matches + foreach ($targets as $i => $target) { + if (isset($claimedTargets[$i])) { + continue; + } + $record = $this->findRecord($records, $claimedRecords, $target['title']); + if ($record === null) { + continue; + } + $claimedRecords[(int)$record['uid']] = true; + $claimedTargets[$i] = true; + if ((int)$record['detail_page'] !== $target['pageUid']) { + $datamap[self::TABLE][(int)$record['uid']]['detail_page'] = $target['pageUid']; + $output->writeln(sprintf(' ~ #%d "%s": detail_page -> %d', (int)$record['uid'], $target['title'], $target['pageUid'])); + } else { + $output->writeln(sprintf(' = #%d "%s" already aligned', (int)$record['uid'], $target['title'])); + } + } + + // pass 3: targets without a record + foreach ($targets as $i => $target) { + if (isset($claimedTargets[$i])) { + continue; + } + $datamap[self::TABLE]['NEWMKT' . (++$newIndex)] = [ + 'pid' => $storagePid, + 'title' => $target['title'], + 'slug' => $this->slugify($target['title']), + 'detail_page' => $target['pageUid'], + ]; + $output->writeln(sprintf(' + CREATE "%s" (level %d, detail_page %d)', $target['title'], $target['level'], $target['pageUid'])); + } + + // pass 4: records without a target - the editorial decision list + foreach ($records as $record) { + if (isset($claimedRecords[(int)$record['uid']])) { + continue; + } + $stories = $this->stories($pool, (int)$record['uid']); + $output->writeln(sprintf(' ! DROPPED by the agency: #%d "%s"%s - untouched, %d story relation(s)%s', + (int)$record['uid'], (string)$record['title'], + ((int)$record['hidden'] === 1) ? ' (hidden)' : '', + count($stories), $stories === [] ? '' : ':')); + foreach ($stories as $story) { + $output->writeln(' - ' . $story); + } + } + + 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. Reindex Solr and flush the cache afterwards.'); + return Command::SUCCESS; + } + + /** @return array> visible child pages, sorted */ + private function childPages($pages, int $parentUid): array + { + return $pages->fetchAllAssociative( + 'SELECT uid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting', + [$parentUid] + ); + } + + /** @param array> $records */ + private function findRecord(array $records, array $claimed, string $title): ?array + { + foreach ($records as $record) { + if (!isset($claimed[(int)$record['uid']]) && $this->norm((string)$record['title']) === $this->norm($title)) { + return $record; + } + } + return null; + } + + /** @param array $targets */ + private function findTarget(array $targets, array $claimed, string $title): ?int + { + foreach ($targets as $i => $target) { + if (!isset($claimed[$i]) && $this->norm($target['title']) === $this->norm($title)) { + return $i; + } + } + return null; + } + + /** @return string[] story titles attached to one market record */ + private function stories(ConnectionPool $pool, int $marketUid): array + { + $connection = $pool->getConnectionForTable(self::MM); + return array_map('strval', $connection->fetchFirstColumn( + 'SELECT u.title FROM ' . self::MM . ' mm + JOIN tx_vitec_domain_model_usecase u ON u.uid = mm.uid_local AND u.deleted = 0 + WHERE mm.uid_foreign = ? ORDER BY u.title', + [$marketUid] + )); + } + + 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 + { + $value = str_replace('&', ' ', mb_strtolower(trim($value))); + return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-'); + } +} diff --git a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php index 1cc4378..6908025 100755 --- a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php +++ b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php @@ -65,16 +65,22 @@ final class ImportSuccessStoriesCommand extends Command * as unresolved instead. Titles verified against the live records. */ private const INDUSTRY_TO_MARKET = [ - 'Sports' => 'Sports, Venues & Entertainment', - 'Venues' => 'Sports, Venues & Entertainment', - 'Government' => 'Defense, Government & Public Sector', - 'Military' => 'Defense, Government & Public Sector', - 'Corporate' => 'Corporate & Enterprise', - 'Broadcast' => 'Media, Broadcast & Telecom', - 'Education' => 'Healthcare & Education', - 'Healthcare' => 'Healthcare & Education', - 'Hospitality & Leisure' => 'Retail, Hospitality & Leisure', - 'Accommodation' => 'Retail, Hospitality & Leisure', + // Targets follow the agency taxonomy since vitec:align-market-records + // (2026-10-26). The old combined market split: the Government industry + // now has its own market, Military stays on Defense and Security - a + // --markets-only re-run therefore redistributes those stories by their + // original industry. + 'Sports' => 'Sports & Entertainment', + 'Venues' => 'Sports & Entertainment', + 'Government' => 'Government', + 'Military' => 'Defense and Security', + 'Corporate' => 'Corporation', + 'Broadcast' => 'Broadcast and Telecoms', + 'Education' => 'Education', + // 'Healthcare' was dropped by the agency (2026-10-23): no target - + // such stories surface as unresolved for an editorial decision. + 'Hospitality & Leisure' => 'Hospitality', + 'Accommodation' => 'Hospitality', ]; /** CTypes that never become content elements. */