exact download title (spelling variants, missing suffix) */
private const MANUAL_MAP = [
'isrvideomissioncommander' => 'ISR Mission Commander',
'okadamanila' => 'VITEC Okada Manilla Usecase',
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$content = $pool->getConnectionForTable('tt_content');
$downloads = [];
$allNorm = [];
$uidByTitle = [];
foreach ($pool->getConnectionForTable('tx_vitec_domain_model_download')->fetchAllAssociative(
'SELECT uid, title FROM tx_vitec_domain_model_download WHERE deleted = 0'
) as $download) {
$uidByTitle[(string)$download['title']] = (int)$download['uid'];
$allNorm[] = ['uid' => (int)$download['uid'], 'title' => (string)$download['title'], 'norm' => $this->norm((string)$download['title'])];
$base = $this->stripSuffix((string)$download['title']);
if ($base !== null) {
$downloads[] = ['uid' => (int)$download['uid'], 'title' => (string)$download['title'], 'norm' => $this->norm($base)];
}
}
$elements = $content->fetchAllAssociative(
"SELECT c.uid, c.bodytext, c.hidden, u.title AS story_title
FROM tt_content c
LEFT JOIN tx_vitec_domain_model_usecase u ON u.uid = c.tx_vitec_usecase_content AND u.deleted = 0
WHERE c.deleted = 0 AND c.CType NOT IN ('vitec_downloadcard')
AND c.bodytext LIKE '%identifier=download%'
ORDER BY u.title, c.uid"
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' ยท ' . count($elements) . ' RTE download button(s)');
$datamap = [];
$converted = 0;
$hiddenCount = 0;
$manual = [];
foreach ($elements as $element) {
$uid = (int)$element['uid'];
$story = trim((string)($element['story_title'] ?? ''));
$label = sprintf('#%d "%s"', $uid, $story !== '' ? $story : '- kein Usecase -');
if ($story === '') {
$manual[] = $label . ': Element haengt an keinem Usecase';
continue;
}
// safety: the body must be essentially just the button
$plain = trim((string)preg_replace('/\s+/', ' ', strip_tags((string)$element['bodytext'])));
if (mb_strlen($plain) > 60) {
$manual[] = $label . ': Bodytext enthaelt mehr als den Button (' . mb_strlen($plain) . ' Zeichen) - manuell';
continue;
}
$target = $this->resolveDownload($story, $downloads, $uidByTitle, $allNorm);
if ($target === null) {
if ((int)$element['hidden'] === 1) {
$output->writeln(' = ' . $label . ': kein PDF im Pool, Element bereits versteckt');
continue;
}
$datamap['tt_content'][(string)$uid] = ['hidden' => 1];
$hiddenCount++;
$output->writeln(' - ' . $label . ': kein PDF im Pool -> Element wird VERSTECKT');
continue;
}
$datamap['tt_content'][(string)$uid] = [
'CType' => 'vitec_downloadcard',
'header' => 'Downloads',
'bodytext' => '',
'pi_flexform' => [
'data' => ['sDEF' => ['lDEF' => [
'settings.download' => ['vDEF' => (string)$target['uid']],
'settings.layout' => ['vDEF' => 'story_download'],
]]],
],
];
$converted++;
$output->writeln(sprintf(' ~ %s -> vitec_downloadcard (download #%d "%s")', $label, $target['uid'], $target['title']));
}
// follow-up pass: every story downloadcard gets the dedicated
// "Success Story Download" layout (DataHandler merges the value
// into the existing FlexForm) - covers cards converted before the
// variant existed, including the interim `neutral` ones
$layoutFixes = 0;
$headerFixes = 0;
foreach ($content->fetchAllAssociative(
"SELECT uid, pi_flexform, header FROM tt_content
WHERE deleted = 0 AND CType = 'vitec_downloadcard' AND tx_vitec_usecase_content > 0"
) as $card) {
$cardId = (string)(int)$card['uid'];
if (!str_contains((string)$card['pi_flexform'], 'story_download')) {
$datamap['tt_content'][$cardId]['pi_flexform'] =
['data' => ['sDEF' => ['lDEF' => ['settings.layout' => ['vDEF' => 'story_download']]]]];
$layoutFixes++;
}
// the front end builds the story anchor nav from element
// HEADERS - without one the section is missing from it
// (found 2026-10-28, "Downloads" anchor)
if (trim((string)$card['header']) === '') {
$datamap['tt_content'][$cardId]['header'] = 'Downloads';
$headerFixes++;
}
}
if ($layoutFixes > 0) {
$output->writeln(' ~ ' . $layoutFixes . ' bestehende Story-Downloadcard(s): settings.layout -> story_download');
}
if ($headerFixes > 0) {
$output->writeln(' ~ ' . $headerFixes . ' bestehende Story-Downloadcard(s): header -> "Downloads" (Anker-Nav)');
}
$output->writeln(sprintf("\n%d Konvertierung(en), %d versteckt, %d manuell:", $converted, $hiddenCount, count($manual)));
foreach ($manual as $line) {
$output->writeln(' ! ' . $line);
}
if (!$apply) {
$output->writeln('DRY RUN - nothing written. Add --apply to write.');
return Command::SUCCESS;
}
if ($datamap === []) {
$output->writeln('Nothing to change.');
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
return Command::FAILURE;
}
$output->writeln('Done. Flush the frontend cache, then re-check a story payload.');
return Command::SUCCESS;
}
/**
* @param array $downloads
* @param array $uidByTitle
* @return array{uid:int,title:string}|null unique match only
*/
private function resolveDownload(string $story, array $downloads, array $uidByTitle, array $allNorm = []): ?array
{
$storyNorm = $this->norm($story);
$manualTitle = self::MANUAL_MAP[$storyNorm] ?? null;
if ($manualTitle !== null && isset($uidByTitle[$manualTitle])) {
return ['uid' => $uidByTitle[$manualTitle], 'title' => $manualTitle];
}
$candidates = [];
foreach ($downloads as $download) {
$a = $download['norm'];
if ($a !== '' && $storyNorm !== '' && ($a === $storyNorm || str_contains($a, $storyNorm) || str_contains($storyNorm, $a))) {
$candidates[] = $download;
}
}
if (count($candidates) === 1) {
return ['uid' => $candidates[0]['uid'], 'title' => $candidates[0]['title']];
}
// fallback: a download titled EXACTLY like the story, without any
// document-type suffix ("Lord's Cricket Ground") - strict equality
// only, so brochures and overviews cannot sneak in
$exact = array_values(array_filter($allNorm, fn(array $d): bool => $d['norm'] === $storyNorm));
return count($exact) === 1 ? ['uid' => $exact[0]['uid'], 'title' => $exact[0]['title']] : null;
}
private function stripSuffix(string $title): ?string
{
foreach (['success story', 'use case', 'usecase'] as $suffix) {
if (preg_match('/^(.*?)\s*' . preg_quote($suffix, '/') . '\s*$/i', trim($title), $matches)) {
return trim($matches[1]);
}
}
return null;
}
private function norm(string $value): string
{
$value = (string)preg_replace('/\s*\([^)]*\)/', ' ', $value);
$value = (string)preg_replace('/^\s*(vitec|datapath)\s+/i', '', $value);
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
}
}