- 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
177 lines
7.8 KiB
PHP
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)));
|
|
}
|
|
}
|