SEO record links; story markets repair; story page titles

Kev's keyword CSV and TYPO3 hold the same solutions under slightly
different names, so the structure check filed them as missing/extra.
Matching is now slug -> normalized title -> record alias, using the
per-model alias store the import tabs got on 2026-08-18
(tx_vitec_import_mapping.record_aliases, key = lowercased CSV name).
One link therefore serves the import union list, the structure check
and vitec:create-markets (same service - linked rows are no longer
proposed as new records).

- every "Missing in TYPO3" row carries a select of all records the
  direct match did not claim; "Save record links" persists per model
- alias-matched rows show under "Both" with a "linked" badge and the
  same select; option 0 removes the link
- SeoResearchService::recordsCheck() additionally returns `linkable`
  (the select options) and flags alias matches with via=link
- new backend POST route seo_aliases

Success stories: --markets-only repair mode
- /success-stories emitted empty `markets` for all 48 stories - a data
  problem (code and TCA untouched since 17.08.); read-only diagnosis
  script migrations/check_usecase_markets.php added
- vitec:import-success-stories --markets-only re-derives each story's
  industries from the export exactly like the full import and rewrites
  ONLY the markets MM relation of the existing record via DataHandler;
  nothing else is touched, no usecase created or deleted, unresolved
  stories keep their current relations
- decision: industries are translated onto the nine main markets of
  the current taxonomy (INDUSTRY_TO_MARKET) instead of recreating the
  seven deleted industry markets; the repair mode never creates
  market records (root cause: those industry markets were deleted
  after the 07.08. taxonomy import, orphaning all story relations)
- ensureMarkets() gained a $create flag for reuse by the full import

Story detail pages: real page title
- every story page answered with the generic page title "Story"; same
  defect and same fix as the product pages on 21.08.: new
  UsecasePageTitleProvider (singleton), registered as vitecUsecase in
  config.pageTitleProviders, fed from UsecaseShowJsonRenderer with
  seo_title falling back to title

Requires on the server:
    vendor/bin/typo3 cache:flush
This commit is contained in:
orasche committed 2026-08-26 19:48:43 +02:00
1 parent d846ba2007
commit 7442d10e68
9 files changed
+378 -9

No files matched your search

@@ -58,6 +58,25 @@ final class ImportSuccessStoriesCommand extends Command
'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation',
];
/**
* --markets-only: old industry names -> the nine main markets of the
* current (SEO-CSV) taxonomy, decision 2026-08-26. The repair mode never
* creates market records; a target title that does not exist is reported
* 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',
];
/** CTypes that never become content elements. */
private const SKIP_CTYPES = [
'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu',
@@ -88,6 +107,7 @@ final class ImportSuccessStoriesCommand extends Command
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the story whose slug tail matches');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse and report only, write nothing');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Delete + recreate stories that already exist (matched by slug)');
$this->addOption('markets-only', null, InputOption::VALUE_NONE, 'Repair mode: only rewrite the markets relation of EXISTING records, touch nothing else');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -131,6 +151,10 @@ final class ImportSuccessStoriesCommand extends Command
$dryRun ? 'DRY-RUN' : 'LIVE'
));
if ((bool)$input->getOption('markets-only')) {
return $this->relinkMarkets($stories, $only, $dryRun, $output);
}
if (!$dryRun) {
$this->ensureMarkets($output, $storagePid);
}
@@ -158,6 +182,110 @@ final class ImportSuccessStoriesCommand extends Command
return Command::SUCCESS;
}
/**
* --markets-only repair mode. Re-derives each story's industries from the
* export exactly like the full import, translates them onto the current
* taxonomy (INDUSTRY_TO_MARKET) and rewrites ONLY the `markets` MM
* relation of the existing usecase record - no other field is touched,
* no record is created or deleted anywhere (unlike the full import this
* mode never creates market records either). Stories whose target market
* does not exist keep their current relations and are reported.
*
* @param array<int,array<string,mixed>> $stories
*/
private function relinkMarkets(array $stories, string $only, bool $dryRun, OutputInterface $output): int
{
$marketConn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
foreach ($marketConn->select(['uid', 'title'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative() as $marketRow) {
$this->marketUidByTitle[$this->normTitle((string)$marketRow['title'])] = (int)$marketRow['uid'];
}
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$datamap = [];
$missing = 0;
$unresolved = 0;
foreach ($stories as $page) {
$slugTail = basename((string)$page['slug']);
if ($only !== '' && $slugTail !== $only) {
continue;
}
$slug = '/' . $slugTail;
$card = $this->cardByPageUid[(int)$page['uid']] ?? null;
$useCase = $this->useCaseByKey[$this->normTitle((string)$page['title'])] ?? null;
$industries = $this->industriesForStory($page, $card, $useCase);
$existing = $conn->select(['uid'], self::TABLE, ['slug' => $slug, 'deleted' => 0])->fetchAssociative()
?: $conn->select(['uid'], self::TABLE, ['slug' => $slugTail, 'deleted' => 0])->fetchAssociative();
if (!$existing) {
$missing++;
$output->writeln(sprintf(' <comment>%-52s</comment> no record for slug %s', $slugTail, $slug));
continue;
}
$targets = array_values(array_unique(array_map(
static fn(string $industry): string => self::INDUSTRY_TO_MARKET[$industry] ?? $industry,
$industries
)));
$uids = array_values(array_unique(array_filter(array_map(
fn(string $title): int => $this->marketUidByTitle[$this->normTitle($title)] ?? 0,
$targets
))));
if ($uids === []) {
$unresolved++;
$output->writeln(sprintf(
' <comment>%-52s</comment> uid %-5d [%s] => [%s] -> unresolved, left untouched',
$slugTail,
(int)$existing['uid'],
implode(',', $industries),
implode(',', $targets)
));
continue;
}
$output->writeln(sprintf(
' %-54s uid %-5d [%s] => [%s] -> markets %s',
$slugTail,
(int)$existing['uid'],
implode(',', $industries),
implode(',', $targets),
implode(',', $uids)
));
$datamap[self::TABLE][(int)$existing['uid']] = ['markets' => implode(',', $uids)];
}
$relinked = count($datamap[self::TABLE] ?? []);
if ($dryRun) {
$output->writeln(sprintf(
'<info>DRY-RUN: %d stories would be relinked, %d without record, %d unresolved.</info>',
$relinked,
$missing,
$unresolved
));
return Command::SUCCESS;
}
if ($datamap !== []) {
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
foreach ($dh->errorLog as $error) {
$output->writeln("<error>$error</error>");
}
}
$output->writeln(sprintf(
'<info>%d stories relinked, %d without record, %d unresolved.</info>',
$relinked,
$missing,
$unresolved
));
return Command::SUCCESS;
}
// =========================================================== index building
private function buildIndexes(): void
@@ -268,7 +396,7 @@ final class ImportSuccessStoriesCommand extends Command
}
/** Ensure one market record per canonical industry; fill title->uid map. */
private function ensureMarkets(OutputInterface $output, int $fallbackPid): void
private function ensureMarkets(OutputInterface $output, int $fallbackPid, bool $create = true): void
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
$rows = $conn->select(['uid', 'title', 'pid'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative();
@@ -285,6 +413,12 @@ final class ImportSuccessStoriesCommand extends Command
}
}
if ($datamap !== []) {
if (!$create) {
foreach ($datamap['tx_vitec_domain_model_market'] as $fields) {
$output->writeln(" <comment>would create market: {$fields['title']}</comment>");
}
return;
}
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();