From 7442d10e684f91a4848d408bd1089944290a5f0d Mon Sep 17 00:00:00 2001
From: Oliver Rasche
Date: Wed, 26 Aug 2026 19:48:43 +0200
Subject: [PATCH] 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
---
migrations/check_usecase_markets.php | 80 +++++++++++
.../Command/ImportSuccessStoriesCommand.php | 136 +++++++++++++++++-
.../Controller/Backend/ImportController.php | 41 ++++++
.../Classes/Import/SeoResearchService.php | 37 ++++-
.../PageTitle/UsecasePageTitleProvider.php | 33 +++++
.../UserFunc/UsecaseShowJsonRenderer.php | 9 ++
.../vitec/Configuration/Backend/Modules.php | 4 +
.../Sets/Vitecset/setup.typoscript | 5 +
.../Private/Templates/Import/Seo.html | 42 +++++-
9 files changed, 378 insertions(+), 9 deletions(-)
create mode 100644 migrations/check_usecase_markets.php
create mode 100644 packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php
diff --git a/migrations/check_usecase_markets.php b/migrations/check_usecase_markets.php
new file mode 100644
index 0000000..51db527
--- /dev/null
+++ b/migrations/check_usecase_markets.php
@@ -0,0 +1,80 @@
+ PDO::ERRMODE_EXCEPTION]
+);
+
+function one(PDO $pdo, string $sql): array
+{
+ return $pdo->query($sql)->fetch(PDO::FETCH_ASSOC) ?: [];
+}
+function all(PDO $pdo, string $sql): array
+{
+ return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
+}
+
+echo "=== 1. MM-Tabellen: Zeilen ueberhaupt da? ===\n";
+foreach (['tx_vitec_usecase_market_mm', 'tx_vitec_usecase_solution_mm'] as $mm) {
+ $n = one($pdo, "SELECT COUNT(*) c FROM {$mm}");
+ echo sprintf("%-32s %s Zeilen\n", $mm, $n['c'] ?? '?');
+}
+
+echo "\n=== 2. Waisen: MM -> Markets, die es nicht (mehr) gibt ===\n";
+echo "verknuepfte Market-uids und ihr Zustand:\n";
+$rows = all($pdo, "
+ SELECT mm.uid_foreign AS market_uid,
+ COUNT(*) AS stories,
+ MAX(m.uid IS NULL) AS fehlt_ganz,
+ MAX(COALESCE(m.deleted, -1)) AS deleted,
+ MAX(COALESCE(m.hidden, -1)) AS hidden,
+ MAX(COALESCE(m.title, '?')) AS title
+ FROM tx_vitec_usecase_market_mm mm
+ LEFT JOIN tx_vitec_domain_model_market m ON m.uid = mm.uid_foreign
+ GROUP BY mm.uid_foreign
+ ORDER BY mm.uid_foreign
+");
+foreach ($rows as $r) {
+ $state = $r['fehlt_ganz'] ? 'FEHLT KOMPLETT' : (($r['deleted'] ?? 0) ? 'GELOESCHT' : ((($r['hidden'] ?? 0) ? 'versteckt' : 'ok')));
+ echo sprintf(" market uid %-5s %-15s %-40s (%s Stories)\n", $r['market_uid'], $state, mb_substr((string)$r['title'], 0, 40), $r['stories']);
+}
+
+echo "\n=== 3. Markets-Tabelle: Bestand ===\n";
+$m = one($pdo, "SELECT COUNT(*) total, SUM(deleted) del, SUM(hidden) hid, MIN(uid) minuid, MAX(uid) maxuid FROM tx_vitec_domain_model_market");
+echo sprintf("gesamt %s | geloescht %s | versteckt %s | uid-Bereich %s-%s\n", $m['total'], $m['del'], $m['hid'], $m['minuid'], $m['maxuid']);
+echo "geloeschte Markets (falls vorhanden):\n";
+foreach (all($pdo, "SELECT uid, title, FROM_UNIXTIME(tstamp) t FROM tx_vitec_domain_model_market WHERE deleted = 1 ORDER BY uid") as $r) {
+ echo sprintf(" uid %-5s %-45s zuletzt %s\n", $r['uid'], mb_substr((string)$r['title'], 0, 45), $r['t']);
+}
+
+echo "\n=== 4. sys_category-Zuordnungen der Stories (JSON-Feld `categories`) ===\n";
+$c = one($pdo, "SELECT COUNT(*) c FROM sys_category_record_mm WHERE tablenames = 'tx_vitec_domain_model_usecase'");
+echo "sys_category_record_mm fuer usecases: " . ($c['c'] ?? '?') . " Zeilen\n";
+
+echo "\n=== 5. Stichprobe: 3 Stories mit ihren MM-Zeilen ===\n";
+foreach (all($pdo, "
+ SELECT u.uid, u.title,
+ (SELECT COUNT(*) FROM tx_vitec_usecase_market_mm mm WHERE mm.uid_local = u.uid) AS mmrows
+ FROM tx_vitec_domain_model_usecase u
+ WHERE u.deleted = 0
+ ORDER BY u.uid LIMIT 3
+") as $r) {
+ echo sprintf(" story uid %-4s %-40s -> %s MM-Zeile(n)\n", $r['uid'], mb_substr((string)$r['title'], 0, 40), $r['mmrows']);
+}
+
+echo "\nFertig - nichts geschrieben.\n";
diff --git a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
index c349501..1cc4378 100755
--- a/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
+++ b/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
@@ -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> $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(' %-52s 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(
+ ' %-52s 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(
+ 'DRY-RUN: %d stories would be relinked, %d without record, %d unresolved.',
+ $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");
+ }
+ }
+ $output->writeln(sprintf(
+ '%d stories relinked, %d without record, %d unresolved.',
+ $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(" would create market: {$fields['title']}");
+ }
+ return;
+ }
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
diff --git a/packages/vitec/Classes/Controller/Backend/ImportController.php b/packages/vitec/Classes/Controller/Backend/ImportController.php
index 857feee..aa213bd 100644
--- a/packages/vitec/Classes/Controller/Backend/ImportController.php
+++ b/packages/vitec/Classes/Controller/Backend/ImportController.php
@@ -173,6 +173,47 @@ final class ImportController
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
}
+ /**
+ * Persist hand-made record links from the SEO structure check. Shares the
+ * per-model alias store with the import tabs (MappingRepository), so a
+ * link made in either place serves both - and vitec:create-markets stops
+ * proposing linked rows as new records. Key convention is the import
+ * tab's: lowercased, trimmed CSV name. uid 0 removes a link.
+ */
+ public function seoAliasesAction(ServerRequestInterface $request): ResponseInterface
+ {
+ $body = (array)$request->getParsedBody();
+ $modelKey = (string)($body['model'] ?? '');
+ if ($this->registry->has($modelKey)) {
+ $aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? [];
+ $names = is_array($body['aliaskey'] ?? null) ? $body['aliaskey'] : [];
+ $selected = is_array($body['alias'] ?? null) ? $body['alias'] : [];
+ $changed = 0;
+ foreach ($selected as $index => $selectedUid) {
+ $key = mb_strtolower(trim((string)($names[$index] ?? '')));
+ if ($key === '') {
+ continue;
+ }
+ $uid = (int)$selectedUid;
+ $current = (int)($aliases[$key] ?? 0);
+ if ($uid > 0 && $uid !== $current) {
+ $aliases[$key] = $uid;
+ $changed++;
+ } elseif ($uid === 0 && $current > 0) {
+ unset($aliases[$key]);
+ $changed++;
+ }
+ }
+ $this->mappingRepository->saveAliases($modelKey, $aliases);
+ $this->flash(
+ sprintf('%d record link(s) changed for %s.', $changed, $modelKey),
+ 'Record links saved',
+ true
+ );
+ }
+ return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
+ }
+
// ------------------------------------------------------------ rendering
/**
diff --git a/packages/vitec/Classes/Import/SeoResearchService.php b/packages/vitec/Classes/Import/SeoResearchService.php
index aef601c..cdcf6f9 100644
--- a/packages/vitec/Classes/Import/SeoResearchService.php
+++ b/packages/vitec/Classes/Import/SeoResearchService.php
@@ -18,7 +18,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* products -> level-3.x.y+ rows against tx_vitec_domain_model_product
* (3.x rows are categories, not products - skipped)
* Record matching: slug == last URL segment, falling back to a
- * normalized title comparison (parentheses stripped, & -> and).
+ * normalized title comparison (parentheses stripped, & -> and), and
+ * finally the hand-made record links (record_aliases per model in
+ * tx_vitec_import_mapping - the same store the import tabs use, so
+ * one link serves both views and vitec:create-markets).
* - diff against the previous delivery, keyed by URL
*
* Works on the normalized row schema produced by normalizeRows(). Read-only:
@@ -141,7 +144,8 @@ final class SeoResearchService
$depth = substr_count($r['ref'], '.') + 1;
return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][1];
}));
- $out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table']);
+ $aliases = GeneralUtility::makeInstance(MappingRepository::class)->load($cfg['key'])['aliases'] ?? [];
+ $out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table'], $aliases);
}
return $out;
}
@@ -174,9 +178,10 @@ final class SeoResearchService
/**
* @param array> $candidates
- * @return array{total:int,matched:int,missing:array>,extra:array>}
+ * @param array $aliases lowercased CSV name -> record uid
+ * @return array{total:int,matched:int,both:array>,missing:array>,extra:array>,linkable:array>}
*/
- private function recordsCheck(array $candidates, string $table): array
+ private function recordsCheck(array $candidates, string $table, array $aliases): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$records = $qb->select('uid', 'title', 'slug')->from($table)
@@ -185,28 +190,43 @@ final class SeoResearchService
$bySlug = [];
$byTitle = [];
+ $byUid = [];
foreach ($records as $rec) {
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
if ($slug !== '') {
$bySlug[$slug] = $rec;
}
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
+ $byUid[(int)$rec['uid']] = $rec;
}
$missing = [];
$both = [];
$matchedUids = [];
+ $directUids = [];
foreach ($candidates as $r) {
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
$rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null;
+ $via = '';
+ if ($rec === null) {
+ $aliasUid = (int)($aliases[mb_strtolower(trim($r['name']))] ?? 0);
+ if ($aliasUid > 0 && isset($byUid[$aliasUid])) {
+ $rec = $byUid[$aliasUid];
+ $via = 'link';
+ }
+ }
if ($rec !== null) {
$matchedUids[(int)$rec['uid']] = true;
+ if ($via === '') {
+ $directUids[(int)$rec['uid']] = true;
+ }
$both[] = [
'ref' => $r['ref'],
'name' => $r['name'],
'url' => $r['url'],
'uid' => (string)$rec['uid'],
'title' => (string)$rec['title'],
+ 'via' => $via,
];
} else {
$missing[] = $r;
@@ -214,11 +234,19 @@ final class SeoResearchService
}
$extra = [];
+ $linkable = [];
foreach ($records as $rec) {
if (!isset($matchedUids[(int)$rec['uid']])) {
$extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
}
+ // Select options for the hand-made links: everything the direct
+ // match (slug/title) did not claim - alias-linked records stay in
+ // here so a linked row can be re-pointed or unlinked.
+ if (!isset($directUids[(int)$rec['uid']])) {
+ $linkable[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
+ }
}
+ usort($linkable, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title']));
return [
'total' => count($candidates),
@@ -226,6 +254,7 @@ final class SeoResearchService
'both' => $both,
'missing' => $missing,
'extra' => $extra,
+ 'linkable' => $linkable,
];
}
diff --git a/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php b/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php
new file mode 100644
index 0000000..4b87cde
--- /dev/null
+++ b/packages/vitec/Classes/PageTitle/UsecasePageTitleProvider.php
@@ -0,0 +1,33 @@
+seotitle = $seotitle;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->seotitle;
+ }
+}
diff --git a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php
index 40d322b..cc6b26a 100755
--- a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php
+++ b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php
@@ -134,6 +134,15 @@ class UsecaseShowJsonRenderer
: '';
}
+ $pageTitle = trim((string)($usecase['seo_title'] ?? ''));
+ if ($pageTitle === '') {
+ $pageTitle = trim((string)($usecase['title'] ?? ''));
+ }
+ if ($pageTitle !== '') {
+ GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\UsecasePageTitleProvider::class)
+ ->setSeoTitle($pageTitle);
+ }
+
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
$backPid = (int)($settings['backPid'] ?? 0);
diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php
index a1a0fe9..2105deb 100644
--- a/packages/vitec/Configuration/Backend/Modules.php
+++ b/packages/vitec/Configuration/Backend/Modules.php
@@ -35,6 +35,10 @@ return [
'target' => ImportController::class . '::seoUploadAction',
'methods' => ['POST'],
],
+ 'seo_aliases' => [
+ 'target' => ImportController::class . '::seoAliasesAction',
+ 'methods' => ['POST'],
+ ],
'products' => [
'target' => ProductTextImportController::class . '::productsAction',
],
diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
index 37a1dfa..a1f0047 100755
--- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
+++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
@@ -7,6 +7,11 @@ config {
before = record
before = seo
}
+ vitecUsecase {
+ provider = Evomedien\Vitec\PageTitle\UsecasePageTitleProvider
+ before = record
+ before = seo
+ }
}
}
diff --git a/packages/vitec/Resources/Private/Templates/Import/Seo.html b/packages/vitec/Resources/Private/Templates/Import/Seo.html
index 751b775..4b1f4e1 100644
--- a/packages/vitec/Resources/Private/Templates/Import/Seo.html
+++ b/packages/vitec/Resources/Private/Templates/Import/Seo.html
@@ -91,6 +91,10 @@
sub-solutions. Products: level x.y and deeper (the top product rows are
categories). Matching: record slug against the last URL segment, falling back
to a normalized title comparison. The Page Ref column shows the hierarchy.
+ A row the matcher misses can be linked by hand via its select
+ — the link is stored per model (shared with the import tabs) and re-applied
+ on every future delivery. Linked rows show up under "Both" with a badge
+ and can be re-pointed or removed there.
@@ -98,22 +102,52 @@
{area}
+
Only in TYPO3 ({check.extra -> f:count()})