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:
80
migrations/check_usecase_markets.php
Normal file
80
migrations/check_usecase_markets.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only diagnosis: why do success stories emit empty `markets`?
|
||||||
|
*
|
||||||
|
* Modeled on migrations/check_bg.php - PDO against the credentials from
|
||||||
|
* config/system/settings.php, no TYPO3 bootstrap, SELECT only.
|
||||||
|
*
|
||||||
|
* Run on the server: php migrations/check_usecase_markets.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
|
||||||
|
$db['user'],
|
||||||
|
$db['password'],
|
||||||
|
[PDO::ATTR_ERRMODE => 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";
|
||||||
@@ -58,6 +58,25 @@ final class ImportSuccessStoriesCommand extends Command
|
|||||||
'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation',
|
'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. */
|
/** CTypes that never become content elements. */
|
||||||
private const SKIP_CTYPES = [
|
private const SKIP_CTYPES = [
|
||||||
'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu',
|
'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('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('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('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
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -131,6 +151,10 @@ final class ImportSuccessStoriesCommand extends Command
|
|||||||
$dryRun ? 'DRY-RUN' : 'LIVE'
|
$dryRun ? 'DRY-RUN' : 'LIVE'
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if ((bool)$input->getOption('markets-only')) {
|
||||||
|
return $this->relinkMarkets($stories, $only, $dryRun, $output);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
$this->ensureMarkets($output, $storagePid);
|
$this->ensureMarkets($output, $storagePid);
|
||||||
}
|
}
|
||||||
@@ -158,6 +182,110 @@ final class ImportSuccessStoriesCommand extends Command
|
|||||||
return Command::SUCCESS;
|
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
|
// =========================================================== index building
|
||||||
|
|
||||||
private function buildIndexes(): void
|
private function buildIndexes(): void
|
||||||
@@ -268,7 +396,7 @@ final class ImportSuccessStoriesCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure one market record per canonical industry; fill title->uid map. */
|
/** 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');
|
$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();
|
$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 ($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 = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
$dh->start($datamap, []);
|
$dh->start($datamap, []);
|
||||||
$dh->process_datamap();
|
$dh->process_datamap();
|
||||||
|
|||||||
@@ -173,6 +173,47 @@ final class ImportController
|
|||||||
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
|
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
|
// ------------------------------------------------------------ rendering
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
* products -> level-3.x.y+ rows against tx_vitec_domain_model_product
|
* products -> level-3.x.y+ rows against tx_vitec_domain_model_product
|
||||||
* (3.x rows are categories, not products - skipped)
|
* (3.x rows are categories, not products - skipped)
|
||||||
* Record matching: slug == last URL segment, falling back to a
|
* 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
|
* - diff against the previous delivery, keyed by URL
|
||||||
*
|
*
|
||||||
* Works on the normalized row schema produced by normalizeRows(). Read-only:
|
* Works on the normalized row schema produced by normalizeRows(). Read-only:
|
||||||
@@ -141,7 +144,8 @@ final class SeoResearchService
|
|||||||
$depth = substr_count($r['ref'], '.') + 1;
|
$depth = substr_count($r['ref'], '.') + 1;
|
||||||
return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][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;
|
return $out;
|
||||||
}
|
}
|
||||||
@@ -174,9 +178,10 @@ final class SeoResearchService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int,array<string,string>> $candidates
|
* @param array<int,array<string,string>> $candidates
|
||||||
* @return array{total:int,matched:int,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>}
|
* @param array<string,int> $aliases lowercased CSV name -> record uid
|
||||||
|
* @return array{total:int,matched:int,both:array<int,array<string,string>>,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>,linkable:array<int,array<string,string>>}
|
||||||
*/
|
*/
|
||||||
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);
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||||
$records = $qb->select('uid', 'title', 'slug')->from($table)
|
$records = $qb->select('uid', 'title', 'slug')->from($table)
|
||||||
@@ -185,28 +190,43 @@ final class SeoResearchService
|
|||||||
|
|
||||||
$bySlug = [];
|
$bySlug = [];
|
||||||
$byTitle = [];
|
$byTitle = [];
|
||||||
|
$byUid = [];
|
||||||
foreach ($records as $rec) {
|
foreach ($records as $rec) {
|
||||||
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
|
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
|
||||||
if ($slug !== '') {
|
if ($slug !== '') {
|
||||||
$bySlug[$slug] = $rec;
|
$bySlug[$slug] = $rec;
|
||||||
}
|
}
|
||||||
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
|
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
|
||||||
|
$byUid[(int)$rec['uid']] = $rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
$missing = [];
|
$missing = [];
|
||||||
$both = [];
|
$both = [];
|
||||||
$matchedUids = [];
|
$matchedUids = [];
|
||||||
|
$directUids = [];
|
||||||
foreach ($candidates as $r) {
|
foreach ($candidates as $r) {
|
||||||
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
|
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
|
||||||
$rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null;
|
$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) {
|
if ($rec !== null) {
|
||||||
$matchedUids[(int)$rec['uid']] = true;
|
$matchedUids[(int)$rec['uid']] = true;
|
||||||
|
if ($via === '') {
|
||||||
|
$directUids[(int)$rec['uid']] = true;
|
||||||
|
}
|
||||||
$both[] = [
|
$both[] = [
|
||||||
'ref' => $r['ref'],
|
'ref' => $r['ref'],
|
||||||
'name' => $r['name'],
|
'name' => $r['name'],
|
||||||
'url' => $r['url'],
|
'url' => $r['url'],
|
||||||
'uid' => (string)$rec['uid'],
|
'uid' => (string)$rec['uid'],
|
||||||
'title' => (string)$rec['title'],
|
'title' => (string)$rec['title'],
|
||||||
|
'via' => $via,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$missing[] = $r;
|
$missing[] = $r;
|
||||||
@@ -214,11 +234,19 @@ final class SeoResearchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$extra = [];
|
$extra = [];
|
||||||
|
$linkable = [];
|
||||||
foreach ($records as $rec) {
|
foreach ($records as $rec) {
|
||||||
if (!isset($matchedUids[(int)$rec['uid']])) {
|
if (!isset($matchedUids[(int)$rec['uid']])) {
|
||||||
$extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
|
$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 [
|
return [
|
||||||
'total' => count($candidates),
|
'total' => count($candidates),
|
||||||
@@ -226,6 +254,7 @@ final class SeoResearchService
|
|||||||
'both' => $both,
|
'both' => $both,
|
||||||
'missing' => $missing,
|
'missing' => $missing,
|
||||||
'extra' => $extra,
|
'extra' => $extra,
|
||||||
|
'linkable' => $linkable,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\PageTitle;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\PageTitle\AbstractPageTitleProvider;
|
||||||
|
use TYPO3\CMS\Core\SingletonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page title for the success-story detail page - without this every story
|
||||||
|
* answers with the generic page title "Story".
|
||||||
|
*
|
||||||
|
* Singleton on purpose, same reasoning as ProductPageTitleProvider: the
|
||||||
|
* UsecaseShowJsonRenderer that sets the title and the
|
||||||
|
* PageTitleProviderManager that later reads it both obtain the provider via
|
||||||
|
* GeneralUtility::makeInstance(); without the singleton those are two
|
||||||
|
* different instances and the title is never seen by the manager.
|
||||||
|
*/
|
||||||
|
final class UsecasePageTitleProvider extends AbstractPageTitleProvider implements SingletonInterface
|
||||||
|
{
|
||||||
|
private string $seotitle = '';
|
||||||
|
|
||||||
|
public function setSeoTitle(string $seotitle): void
|
||||||
|
{
|
||||||
|
$this->seotitle = $seotitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->seotitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
|
||||||
|
|
||||||
$backPid = (int)($settings['backPid'] ?? 0);
|
$backPid = (int)($settings['backPid'] ?? 0);
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ return [
|
|||||||
'target' => ImportController::class . '::seoUploadAction',
|
'target' => ImportController::class . '::seoUploadAction',
|
||||||
'methods' => ['POST'],
|
'methods' => ['POST'],
|
||||||
],
|
],
|
||||||
|
'seo_aliases' => [
|
||||||
|
'target' => ImportController::class . '::seoAliasesAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
'products' => [
|
'products' => [
|
||||||
'target' => ProductTextImportController::class . '::productsAction',
|
'target' => ProductTextImportController::class . '::productsAction',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ config {
|
|||||||
before = record
|
before = record
|
||||||
before = seo
|
before = seo
|
||||||
}
|
}
|
||||||
|
vitecUsecase {
|
||||||
|
provider = Evomedien\Vitec\PageTitle\UsecasePageTitleProvider
|
||||||
|
before = record
|
||||||
|
before = seo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,10 @@
|
|||||||
sub-solutions. Products: level x.y and deeper (the top product rows are
|
sub-solutions. Products: level x.y and deeper (the top product rows are
|
||||||
categories). Matching: record slug against the last URL segment, falling back
|
categories). Matching: record slug against the last URL segment, falling back
|
||||||
to a normalized title comparison. The Page Ref column shows the hierarchy.
|
to a normalized title comparison. The Page Ref column shows the hierarchy.
|
||||||
|
A row the matcher misses can be <strong>linked by hand</strong> 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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -98,22 +102,52 @@
|
|||||||
<f:if condition="{area} != 'pages'">
|
<f:if condition="{area} != 'pages'">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<h4 style="text-transform:capitalize;">{area}</h4>
|
<h4 style="text-transform:capitalize;">{area}</h4>
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.seo_aliases')}" method="post">
|
||||||
|
<input type="hidden" name="model" value="{area}" />
|
||||||
<f:if condition="{check.both}">
|
<f:if condition="{check.both}">
|
||||||
<p><strong>Both in CSV and TYPO3 ({check.both -> f:count()})</strong></p>
|
<p><strong>Both in CSV and TYPO3 ({check.both -> f:count()})</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<f:for each="{check.both}" as="pair">
|
<f:for each="{check.both}" as="pair" iteration="bIt">
|
||||||
<li><code>{pair.ref}</code> {pair.name} <small class="text-muted">→ uid {pair.uid} ({pair.title})</small></li>
|
<li>
|
||||||
|
<code>{pair.ref}</code> {pair.name}
|
||||||
|
<small class="text-muted">→ uid {pair.uid} ({pair.title})</small>
|
||||||
|
<f:if condition="{pair.via} == 'link'">
|
||||||
|
<span class="vitec-badge vitec-badge-info">linked</span>
|
||||||
|
<input type="hidden" name="aliaskey[b{bIt.index}]" value="{pair.name}" />
|
||||||
|
<select name="alias[b{bIt.index}]" class="form-select form-select-sm" style="margin:2px 0 6px;">
|
||||||
|
<option value="0">— remove link —</option>
|
||||||
|
<f:for each="{check.linkable}" as="rec">
|
||||||
|
<option value="{rec.uid}" {f:if(condition: '{rec.uid} == {pair.uid}', then: 'selected')}>{rec.title} (uid {rec.uid})</option>
|
||||||
|
</f:for>
|
||||||
|
</select>
|
||||||
|
</f:if>
|
||||||
|
</li>
|
||||||
</f:for>
|
</f:for>
|
||||||
</ul>
|
</ul>
|
||||||
</f:if>
|
</f:if>
|
||||||
<f:if condition="{check.missing}">
|
<f:if condition="{check.missing}">
|
||||||
<p><strong>Missing in TYPO3 ({check.missing -> f:count()})</strong></p>
|
<p><strong>Missing in TYPO3 ({check.missing -> f:count()})</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<f:for each="{check.missing}" as="row">
|
<f:for each="{check.missing}" as="row" iteration="mIt">
|
||||||
<li><code>{row.ref}</code> {row.name}</li>
|
<li>
|
||||||
|
<code>{row.ref}</code> {row.name}
|
||||||
|
<f:if condition="{check.linkable}">
|
||||||
|
<input type="hidden" name="aliaskey[m{mIt.index}]" value="{row.name}" />
|
||||||
|
<select name="alias[m{mIt.index}]" class="form-select form-select-sm" style="margin:2px 0 6px;">
|
||||||
|
<option value="0">— really missing —</option>
|
||||||
|
<f:for each="{check.linkable}" as="rec">
|
||||||
|
<option value="{rec.uid}">{rec.title} (uid {rec.uid})</option>
|
||||||
|
</f:for>
|
||||||
|
</select>
|
||||||
|
</f:if>
|
||||||
|
</li>
|
||||||
</f:for>
|
</f:for>
|
||||||
</ul>
|
</ul>
|
||||||
</f:if>
|
</f:if>
|
||||||
|
<f:if condition="{check.missing} || {check.both}">
|
||||||
|
<p><button type="submit" class="btn btn-default btn-sm">Save record links</button></p>
|
||||||
|
</f:if>
|
||||||
|
</form>
|
||||||
<f:if condition="{check.extra}">
|
<f:if condition="{check.extra}">
|
||||||
<p><strong>Only in TYPO3 ({check.extra -> f:count()})</strong></p>
|
<p><strong>Only in TYPO3 ({check.extra -> f:count()})</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user