Files
VITEC-website/packages/vitec/Classes/Command/MapSolutionPagesCommand.php
Oliver Rasche e9eaa62d89 Align solutions to the sitemap's 39 Generic Solutions; map detail pages
- vitec:align-solutions: declarative target = the 39 Generic Solutions in 8
  group categories under the Solution root (sheet is binding, group typo
  Engagament->Engagement fixed). Two legacy records renamed onto their sheet
  names instead of deleted (keeps the only editorial teaser/description),
  slugs generated for all, market categories stripped from solutions, the
  ~82 market-specific records soft-deleted (markets link editorially)
- vitec:map-solution-pages: writes matching page uids into detail_page
  (unique matches, fill-only); disambiguates the duplicated page branches
  by slug suffix and prunes the 10 empty duplicate pages
- read-only diagnostics: check_solutions.php, check_dup_solution_pages.php
2026-09-04 15:30:27 +02:00

177 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Map backend pages to solution records: writes the page uid into the
* solution's `detail_page` field (2026-09-04, request before the weekend).
*
* Matching per solution, conservative:
* 1. page title == solution title (normalized: lowercase, alphanumerics,
* "+" -> "plus")
* 2. last page-slug segment == solution slug
* Only UNIQUE matches are written; `detail_page` is fill-only (a record that
* already points at a page is never changed - a differing find is reported).
* Hidden pages match too (pages may be unpublished while content is built);
* deleted and sys-folder/link pages never.
*
* vendor/bin/typo3 vitec:map-solution-pages --dry-run
* vendor/bin/typo3 vitec:map-solution-pages
*/
#[AsCommand(
name: 'vitec:map-solution-pages',
description: 'Write matching page uids into the solutions\' detail_page field (unique matches, fill-only)'
)]
final class MapSolutionPagesCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_solution';
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
$this->addOption('prune-duplicate-pages', null, InputOption::VALUE_NONE, 'Soft-delete duplicate pages (slug suffix -N, zero content elements) that lost the disambiguation');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$dryRun = (bool)$input->getOption('dry-run');
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$solutions = $connection->fetchAllAssociative(
'SELECT uid, title, slug, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0 ORDER BY title'
);
// standard pages only (doktype 1); hidden allowed, deleted not
$pages = $connection->fetchAllAssociative(
'SELECT uid, pid, title, slug, hidden FROM pages WHERE deleted = 0 AND doktype = 1'
);
$pagesByNormTitle = [];
$pagesBySlugTail = [];
foreach ($pages as $page) {
$pagesByNormTitle[$this->normalize((string)$page['title'])][] = $page;
$tail = strtolower(trim((string)strrchr('/' . trim((string)$page['slug'], '/'), '/'), '/'));
if ($tail !== '') {
$pagesBySlugTail[$tail][] = $page;
}
}
$datamap = [];
$planned = 0;
foreach ($solutions as $solution) {
$uid = (int)$solution['uid'];
$candidates = $pagesByNormTitle[$this->normalize((string)$solution['title'])] ?? [];
if ($candidates === [] && (string)$solution['slug'] !== '') {
$candidates = $pagesBySlugTail[strtolower((string)$solution['slug'])] ?? [];
}
if ($candidates === []) {
$output->writeln(sprintf('<comment>keine Seite: %s</comment>', $solution['title']));
continue;
}
$duplicatePages = [];
if (count($candidates) > 1) {
// Page-tree duplicates carry TYPO3's slug dedupe suffix "-N"
// (found 2026-09-04: two solution branches were created twice,
// empty). Prefer the clean slug; the suffixed ones can be
// pruned via --prune-duplicate-pages.
$clean = array_values(array_filter($candidates, static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug'])));
if (count($clean) === 1) {
$duplicatePages = array_values(array_filter($candidates, static fn(array $p): bool => (int)$p['uid'] !== (int)$clean[0]['uid']));
$candidates = $clean;
}
}
if (count($candidates) > 1) {
$output->writeln(sprintf('<comment>MEHRDEUTIG (%d Seiten): %s - uids %s</comment>',
count($candidates), $solution['title'], implode(',', array_column($candidates, 'uid'))));
continue;
}
foreach ($duplicatePages as $duplicate) {
$this->duplicates[(int)$duplicate['uid']] = (string)$duplicate['slug'];
}
$page = $candidates[0];
$pageUid = (int)$page['uid'];
$current = (int)$solution['detail_page'];
if ($current === $pageUid) {
$output->writeln(sprintf('ok %s -> Seite %d (bereits gesetzt)', $solution['title'], $pageUid));
continue;
}
if ($current > 0) {
$output->writeln(sprintf('<comment>KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst</comment>',
$solution['title'], $current, $pageUid, $page['title']));
continue;
}
$datamap[self::TABLE][$uid]['detail_page'] = $pageUid;
$planned++;
$output->writeln(sprintf('%s %s -> Seite %d "%s" (/%s)%s',
$dryRun ? 'plan ' : 'WRITE ', $solution['title'], $pageUid, $page['title'],
trim((string)$page['slug'], '/'), $page['hidden'] ? ' [Seite versteckt]' : ''));
}
$output->writeln(sprintf("\n%d von %d Solutions zu mappen.", $planned, count($solutions)));
$prune = (bool)$input->getOption('prune-duplicate-pages');
$cmdmap = [];
foreach ($this->duplicates as $pageUid => $slug) {
$contentCount = (int)$connection->fetchOne(
'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$pageUid]
);
if ($contentCount > 0) {
$output->writeln(sprintf('<comment>Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht</comment>', $pageUid, trim($slug, '/'), $contentCount));
continue;
}
if ($prune) {
$cmdmap['pages'][$pageUid]['delete'] = 1;
}
$output->writeln(sprintf('%s Duplikat-Seite %d (/%s, leer)', $prune ? ($dryRun ? 'plan-DELETE' : 'DELETE ') : 'Duplikat: ', $pageUid, trim($slug, '/')));
}
if (!$prune && $this->duplicates !== []) {
$output->writeln('(Duplikat-Seiten bleiben stehen - mit --prune-duplicate-pages loeschen.)');
}
if ($datamap === [] && $cmdmap === []) {
return Command::SUCCESS;
}
if ($dryRun) {
$output->writeln('DRY RUN - nichts geschrieben.');
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, $cmdmap);
if ($datamap !== []) {
$dataHandler->process_datamap();
}
if ($cmdmap !== []) {
$dataHandler->process_cmdmap();
}
if ($dataHandler->errorLog !== []) {
foreach ($dataHandler->errorLog as $error) {
$output->writeln('<error>' . $error . '</error>');
}
return Command::FAILURE;
}
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
return Command::SUCCESS;
}
/** @var array<int,string> duplicate page uid => slug, collected during matching */
private array $duplicates = [];
private function normalize(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
}
}