diff --git a/migrations/check_download_record.php b/migrations/check_download_record.php
new file mode 100644
index 0000000..51a7cfb
--- /dev/null
+++ b/migrations/check_download_record.php
@@ -0,0 +1,15 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+$r = $pdo->query("SELECT uid, pid, title, slug, hidden, deleted, starttime, endtime, private_download, file, fileprefix, useolddl FROM tx_vitec_domain_model_download WHERE uid = $uid")->fetch();
+print_r($r ?: "not found\n");
+if ($r) {
+ foreach ($pdo->query("SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r LEFT JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_download' AND r.uid_foreign = $uid") as $ref) { print_r($ref); }
+}
diff --git a/migrations/check_product_downloads.php b/migrations/check_product_downloads.php
new file mode 100644
index 0000000..eda202a
--- /dev/null
+++ b/migrations/check_product_downloads.php
@@ -0,0 +1,127 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+);
+
+// --- workbook coverage from the manifest ------------------------------------
+$manifest = json_decode((string)file_get_contents($root . '/migrations/products_xlsx/manifest.json'), true) ?: [];
+$workbookSlugs = [];
+foreach (($manifest['workbooks'] ?? []) as $file => $entry) {
+ if (!empty($entry['product'])) {
+ $workbookSlugs[(string)$entry['product']] = basename((string)$file);
+ }
+}
+
+// --- data ---------------------------------------------------------------------
+$products = $pdo->query('SELECT uid, title, slug, hidden, legacy, supportproduct, subproduct FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title')->fetchAll();
+$downloads = $pdo->query('SELECT uid, title, hidden FROM tx_vitec_domain_model_download WHERE deleted = 0')->fetchAll();
+$downloadTitle = array_column($downloads, 'title', 'uid');
+
+$linksByProduct = [];
+$linkedDownloadUids = [];
+foreach ($pdo->query('SELECT uid_local, uid_foreign FROM tx_vitec_product_download_mm') as $r) {
+ $linksByProduct[(int)$r['uid_local']][] = (int)$r['uid_foreign'];
+ $linkedDownloadUids[(int)$r['uid_foreign']] = true;
+}
+$sheetsByProduct = [];
+foreach ($pdo->query('SELECT uid_local, COUNT(*) c FROM tx_vitec_product_datasheet_mm GROUP BY uid_local') as $r) {
+ $sheetsByProduct[(int)$r['uid_local']] = (int)$r['c'];
+}
+
+$isDatasheet = static function (string $title): bool {
+ return (bool)preg_match('/data\s*sheet\s*$/i', trim($title));
+};
+$docType = static function (string $title): string {
+ foreach (['datasheet', 'data sheet', 'brochure', 'user guide', 'quick start guide', 'application note',
+ 'white paper', 'case study', 'success story', 'manual', 'firmware', 'software', 'flyer', 'guide'] as $suffix) {
+ if (preg_match('/' . preg_quote($suffix, '/') . '\s*$/i', trim($title))) {
+ return $suffix;
+ }
+ }
+ return '(no type suffix)';
+};
+
+// --- per-product evaluation ------------------------------------------------------
+$noDownloads = [];
+$noDatasheet = [];
+$okCount = 0;
+$withoutWorkbook = [];
+foreach ($products as $p) {
+ $uid = (int)$p['uid'];
+ $slug = (string)$p['slug'];
+ $linked = $linksByProduct[$uid] ?? [];
+ $hasSheet = false;
+ foreach ($linked as $dUid) {
+ if ($isDatasheet((string)($downloadTitle[$dUid] ?? ''))) { $hasSheet = true; break; }
+ }
+ $flag = ((int)$p['legacy'] ? ' [legacy]' : '') . ((int)$p['hidden'] ? ' [hidden]' : '')
+ . ((int)$p['supportproduct'] ? ' [support]' : '') . ((int)$p['subproduct'] ? ' [sub]' : '');
+ $line = sprintf('#%-4d %-45s dl=%-2d sheetsTab=%d%s', $uid, $p['title'], count($linked), $sheetsByProduct[$uid] ?? 0, $flag);
+ if (!isset($workbookSlugs[$slug])) {
+ $withoutWorkbook[] = $line;
+ }
+ if (isset($workbookSlugs[$slug])) {
+ if ($linked === []) { $noDownloads[] = $line; }
+ elseif (!$hasSheet) { $noDatasheet[] = $line; }
+ else { $okCount++; }
+ }
+}
+
+// --- orphan downloads ---------------------------------------------------------------
+$orphans = [];
+$orphanTypes = [];
+foreach ($downloads as $d) {
+ if (!isset($linkedDownloadUids[(int)$d['uid']])) {
+ $type = $docType((string)$d['title']);
+ $orphanTypes[$type] = ($orphanTypes[$type] ?? 0) + 1;
+ $orphans[] = $d['title'];
+ }
+}
+
+printf("Produkte: %d (davon %d mit Workbook) · Downloads: %d (davon %d verlinkt, %d verwaist)\n\n",
+ count($products), count($workbookSlugs), count($downloads), count($linkedDownloadUids), count($orphans));
+
+echo "=== A) Workbook-Produkte OHNE verknuepfte Downloads (" . count($noDownloads) . ") ===\n";
+foreach ($noDownloads as $l) { echo " $l\n"; }
+
+echo "\n=== B) Workbook-Produkte mit Downloads, aber OHNE Datasheet (" . count($noDatasheet) . ") ===\n";
+foreach ($noDatasheet as $l) { echo " $l\n"; }
+
+echo "\n=== C) Workbook-Produkte mit Datasheet-Abdeckung: $okCount ===\n";
+
+echo "\n=== D) Produkte OHNE Workbook (" . count($withoutWorkbook) . ") - zur Einordnung ===\n";
+foreach ($withoutWorkbook as $l) { echo " $l\n"; }
+
+arsort($orphanTypes);
+echo "\n=== E) Verwaiste Downloads nach Typ ===\n";
+foreach ($orphanTypes as $type => $n) { printf(" %-22s %d\n", $type, $n); }
+echo "\n Beispiele (max 30):\n";
+foreach (array_slice($orphans, 0, 30) as $t) { echo " - $t\n"; }
diff --git a/migrations/check_story_elements.php b/migrations/check_story_elements.php
new file mode 100644
index 0000000..975447e
--- /dev/null
+++ b/migrations/check_story_elements.php
@@ -0,0 +1,25 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+$story = $pdo->query("SELECT uid, title, products FROM tx_vitec_domain_model_usecase WHERE uid = $ucUid")->fetch();
+echo "Story #{$story['uid']} \"{$story['title']}\" products-MM-count-field={$story['products']}\n";
+echo "verknuepfte Produkte: ";
+foreach ($pdo->query("SELECT p.uid, p.title FROM tx_vitec_usecase_product_mm mm JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign WHERE mm.uid_local = $ucUid ORDER BY mm.sorting") as $p) {
+ echo "#{$p['uid']} {$p['title']} | ";
+}
+echo "\n\n";
+function dump(PDO $pdo, string $where, int $depth): void {
+ foreach ($pdo->query("SELECT uid, CType, LEFT(header,45) h, colPos, sorting, hidden, LEFT(REPLACE(REPLACE(COALESCE(bodytext,''),CHAR(10),' '),CHAR(13),''),70) b FROM tt_content WHERE deleted = 0 AND $where ORDER BY sorting") as $r) {
+ printf("%s#%-5d %-20s col=%-4d sort=%-6d hid=%d %-45s | %s\n", str_repeat(' ', $depth), $r['uid'], $r['CType'], $r['colPos'], $r['sorting'], $r['hidden'], $r['h'], $r['b']);
+ dump($pdo, "tx_container_parent = {$r['uid']}", $depth + 1);
+ }
+}
+dump($pdo, "tx_vitec_usecase_content = $ucUid AND tx_container_parent = 0", 0);
diff --git a/migrations/fix_download_record_links.php b/migrations/fix_download_record_links.php
new file mode 100644
index 0000000..414b2a3
--- /dev/null
+++ b/migrations/fix_download_record_links.php
@@ -0,0 +1,62 @@
+ identifier=download
+ * in tt_content.bodytext (plain and &-encoded variants). One-time,
+ * idempotent, dry-run by default.
+ *
+ * Usage: php migrations/fix_download_record_links.php (dry run)
+ * php migrations/fix_download_record_links.php --apply
+ */
+
+error_reporting(E_ALL);
+ini_set('display_errors', '1');
+
+$apply = in_array('--apply', $argv, true);
+
+$root = null;
+$dir = __DIR__;
+for ($i = 0; $i < 5; $i++) {
+ if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
+ $dir = dirname($dir);
+}
+if ($root === null) { exit("no project root\n"); }
+$settings = require $root . '/config/system/settings.php';
+$db = $settings['DB']['Connections']['Default'];
+$pdo = new PDO(
+ sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
+ $db['user'] ?? '', $db['password'] ?? '',
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+);
+
+$needle = 'identifier=tx_vitec_download';
+$rows = $pdo->query(
+ "SELECT uid, pid, CType, LEFT(header, 40) h FROM tt_content
+ WHERE deleted = 0 AND bodytext LIKE '%identifier=tx_vitec_download%' ORDER BY pid, uid"
+)->fetchAll();
+
+echo ($apply ? "APPLY\n" : "DRY-RUN\n");
+echo count($rows) . " element(s) carry the legacy identifier:\n";
+foreach ($rows as $r) {
+ printf(" #%-6d pid=%-5d %-18s %s\n", $r['uid'], $r['pid'], $r['CType'], $r['h']);
+}
+if (!$apply) {
+ exit("DRY RUN - add --apply to rewrite identifier=tx_vitec_download -> identifier=download.\n");
+}
+$stmt = $pdo->prepare(
+ "UPDATE tt_content SET bodytext = REPLACE(bodytext, 'identifier=tx_vitec_download', 'identifier=download'),
+ tstamp = UNIX_TIMESTAMP() WHERE uid = ?"
+);
+foreach ($rows as $r) {
+ $stmt->execute([(int)$r['uid']]);
+}
+echo count($rows) . " element(s) rewritten. Flush the cache, then re-check a story payload.\n";
diff --git a/migrations/scan_story_headers.php b/migrations/scan_story_headers.php
new file mode 100644
index 0000000..430c7a9
--- /dev/null
+++ b/migrations/scan_story_headers.php
@@ -0,0 +1,29 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+echo "=== Header von text-Elementen in Story-Content (gruppiert) ===\n";
+foreach ($pdo->query("SELECT TRIM(header) h, COUNT(*) c FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content > 0 AND CType = 'text' AND TRIM(COALESCE(header,'')) <> '' GROUP BY TRIM(header) ORDER BY c DESC, h") as $r) {
+ printf(" %3d x %s\n", $r['c'], $r['h']);
+}
+echo "\n=== CTypes in Story-Content gesamt ===\n";
+foreach ($pdo->query("SELECT CType, COUNT(*) c FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content > 0 GROUP BY CType ORDER BY c DESC") as $r) {
+ printf(" %3d x %s\n", $r['c'], $r['CType']);
+}
+
+echo "\n=== Header von Kind-Elementen in Story-vitec_columns ===\n";
+foreach ($pdo->query(
+ "SELECT TRIM(c.header) h, c.CType, COUNT(*) n FROM tt_content c
+ WHERE c.deleted = 0 AND TRIM(COALESCE(c.header,'')) <> ''
+ AND c.tx_container_parent IN (
+ SELECT p.uid FROM tt_content p
+ WHERE p.deleted = 0 AND p.tx_vitec_usecase_content > 0 AND p.CType = 'vitec_columns')
+ GROUP BY TRIM(c.header), c.CType ORDER BY n DESC, h"
+) as $r) {
+ printf(" %3d x %-14s %s\n", $r['n'], $r['CType'], $r['h']);
+}
diff --git a/packages/vitec/Classes/Command/ConvertStoryDownloadButtonsCommand.php b/packages/vitec/Classes/Command/ConvertStoryDownloadButtonsCommand.php
new file mode 100644
index 0000000..e1955f6
--- /dev/null
+++ b/packages/vitec/Classes/Command/ConvertStoryDownloadButtonsCommand.php
@@ -0,0 +1,235 @@
+ 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)));
+ }
+}
diff --git a/packages/vitec/Classes/Command/ConvertStoryProductSectionsCommand.php b/packages/vitec/Classes/Command/ConvertStoryProductSectionsCommand.php
new file mode 100644
index 0000000..1957e24
--- /dev/null
+++ b/packages/vitec/Classes/Command/ConvertStoryProductSectionsCommand.php
@@ -0,0 +1,232 @@
+ product slug */
+ private const STORY_PRODUCT_ALIASES = [
+ 'avediaserverc1520' => 'avedia-platform-iptv-signage-cmsserver',
+ 'avediac1210chassis' => 'avedia-modular-chassis-c1101-c1103-c1210',
+ 'vitecendpoints' => 'avedia-end-points',
+ ];
+
+ 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');
+
+ $products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
+ 'SELECT uid, title, slug FROM tx_vitec_domain_model_product WHERE deleted = 0'
+ );
+
+ $stories = $pool->getConnectionForTable('tx_vitec_domain_model_usecase')->fetchAllAssociative(
+ 'SELECT uid, title FROM tx_vitec_domain_model_usecase WHERE deleted = 0 ORDER BY title'
+ );
+
+ $datamap = [];
+ $cmdmap = [];
+ $sortingFixes = [];
+ $converted = 0;
+ $deleted = 0;
+ $unresolvedReport = [];
+ $newIndex = 0;
+
+ foreach ($stories as $story) {
+ $storyUid = (int)$story['uid'];
+ $elements = $content->fetchAllAssociative(
+ "SELECT uid, pid, CType, header, header_layout, colPos, sorting FROM tt_content
+ WHERE deleted = 0 AND tx_vitec_usecase_content = ? AND tx_container_parent = 0
+ ORDER BY sorting",
+ [$storyUid]
+ );
+ // find the heading
+ $startIndex = null;
+ foreach ($elements as $i => $element) {
+ if ($element['CType'] === 'text' && $this->norm((string)$element['header']) === 'featuredproducts') {
+ $startIndex = $i;
+ break;
+ }
+ }
+ if ($startIndex === null) {
+ continue;
+ }
+ $heading = $elements[$startIndex];
+ // collect the teaser texts
+ $section = [];
+ for ($i = $startIndex + 1; $i < count($elements); $i++) {
+ $element = $elements[$i];
+ if ($element['CType'] !== 'text' || trim((string)$element['header']) === '') {
+ break;
+ }
+ $section[] = $element;
+ }
+ if ($section === []) {
+ $unresolvedReport[] = sprintf('"%s": Ueberschrift #%d ohne Produkt-Texte danach - uebersprungen', $story['title'], (int)$heading['uid']);
+ continue;
+ }
+ // resolve products
+ $uids = [];
+ $unresolved = [];
+ foreach ($section as $element) {
+ $product = $this->resolveProduct((string)$element['header'], $products);
+ if ($product !== null) {
+ $uids[] = (int)$product['uid'];
+ } else {
+ $unresolved[] = (string)$element['header'];
+ }
+ }
+ $uids = array_values(array_unique($uids));
+ if ($uids === []) {
+ $unresolvedReport[] = sprintf('"%s": KEIN Produkt aufloesbar (%s) - Elemente bleiben', $story['title'], implode(' | ', $unresolved));
+ continue;
+ }
+ $output->writeln(sprintf('~ "%s": %d Text(e) -> productlist [%s]%s',
+ $story['title'], count($section) + 1, implode(',', $uids),
+ $unresolved === [] ? '' : ' UNAUFGELOEST: ' . implode(' | ', $unresolved)));
+ if ($unresolved !== []) {
+ $unresolvedReport[] = sprintf('"%s": teilweise - fehlend: %s', $story['title'], implode(' | ', $unresolved));
+ }
+ $newId = 'NEWSP' . (++$newIndex);
+ $datamap['tt_content'][$newId] = [
+ 'pid' => (int)$heading['pid'],
+ 'CType' => 'vitec_productlist',
+ 'colPos' => (int)$heading['colPos'],
+ 'header' => (string)$heading['header'],
+ 'header_layout' => (int)$heading['header_layout'],
+ 'tx_vitec_usecase_content' => $storyUid,
+ 'pi_flexform' => [
+ 'data' => ['sDEF' => ['lDEF' => [
+ 'settings.selectedproducts' => ['vDEF' => implode(',', $uids)],
+ 'settings.layout' => ['vDEF' => 'story_products'],
+ ]]],
+ ],
+ ];
+ $sortingFixes[$newId] = (int)$heading['sorting'];
+ $cmdmap['tt_content'][(int)$heading['uid']]['delete'] = 1;
+ foreach ($section as $element) {
+ $cmdmap['tt_content'][(int)$element['uid']]['delete'] = 1;
+ }
+ $converted++;
+ $deleted += count($section) + 1;
+ }
+
+ $output->writeln(sprintf("\n%d Story-Sektion(en) -> productlist, %d Element(e) werden geloescht, %d Hinweis(e):",
+ $converted, $deleted, count($unresolvedReport)));
+ foreach ($unresolvedReport as $line) {
+ $output->writeln(' ! ' . $line);
+ }
+ if (!$apply) {
+ $output->writeln('DRY RUN - nothing written. Add --apply to write.');
+ return Command::SUCCESS;
+ }
+ if ($datamap === [] && $cmdmap === []) {
+ $output->writeln('Nothing to change.');
+ return Command::SUCCESS;
+ }
+ $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+ $dataHandler->start($datamap, $cmdmap);
+ $dataHandler->process_datamap();
+ $dataHandler->process_cmdmap();
+ if ($dataHandler->errorLog !== []) {
+ $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
+ return Command::FAILURE;
+ }
+ foreach ($sortingFixes as $newId => $sorting) {
+ $uid = (int)($dataHandler->substNEWwithIDs[$newId] ?? 0);
+ if ($uid > 0) {
+ $content->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
+ }
+ }
+ $output->writeln('Done. Flush the frontend cache, then re-check a story payload.');
+ return Command::SUCCESS;
+ }
+
+ /** @param array> $products */
+ private function resolveProduct(string $header, array $products): ?array
+ {
+ $full = $this->norm($header);
+ $alias = self::STORY_PRODUCT_ALIASES[$full] ?? null;
+ if ($alias !== null) {
+ foreach ($products as $product) {
+ if ((string)$product['slug'] === $alias) {
+ return $product;
+ }
+ }
+ return null;
+ }
+ $stripped = $this->norm((string)preg_replace('/\s*\(.*$/', '', $header));
+ foreach ([$full, $stripped] as $needle) {
+ foreach ($products as $product) {
+ if ($this->norm((string)$product['title']) === $needle) {
+ return $product;
+ }
+ }
+ }
+ $family = ProductFamilyAliases::resolve($stripped);
+ if ($family !== null) {
+ return $family;
+ }
+ $matches = array_values(array_filter($products, fn(array $product): bool => str_starts_with(
+ $this->norm((string)$product['slug']), $stripped
+ )));
+ return count($matches) === 1 ? $matches[0] : null;
+ }
+
+ private function norm(string $value): string
+ {
+ return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
+ }
+}
diff --git a/packages/vitec/Classes/DataProcessing/ContainerChildrenProcessor.php b/packages/vitec/Classes/DataProcessing/ContainerChildrenProcessor.php
index f5bd374..1297755 100755
--- a/packages/vitec/Classes/DataProcessing/ContainerChildrenProcessor.php
+++ b/packages/vitec/Classes/DataProcessing/ContainerChildrenProcessor.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\DataProcessing;
+use Evomedien\Vitec\Service\RteResolver;
use Evomedien\Vitec\UserFunc\ProductListJsonRenderer;
use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
@@ -306,6 +307,13 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
if (in_array($field, ['header_layout'], true)) {
return (int)$value;
}
+ // RTE columns leave the DB with unresolved t3:// links; resolve them
+ // like every other VITEC renderer does (found 2026-10-28: the
+ // success-story "Download Success Story" buttons reached the payload
+ // as raw t3://record hrefs).
+ if (in_array($field, ['bodytext', 'vitec_quote'], true)) {
+ return RteResolver::html($value);
+ }
return $value;
}
}
diff --git a/packages/vitec/Classes/Service/ContentElementResolver.php b/packages/vitec/Classes/Service/ContentElementResolver.php
index 72d02be..b3cac36 100755
--- a/packages/vitec/Classes/Service/ContentElementResolver.php
+++ b/packages/vitec/Classes/Service/ContentElementResolver.php
@@ -441,6 +441,13 @@ final class ContentElementResolver
if (in_array($field, ['header_layout'], true)) {
return (int)$value;
}
+ // RTE columns leave the DB with unresolved t3:// links; resolve them
+ // like every other VITEC renderer does (found 2026-10-28: the
+ // success-story "Download Success Story" buttons reached the payload
+ // as raw t3://record hrefs).
+ if (in_array($field, ['bodytext', 'vitec_quote'], true)) {
+ return RteResolver::html($value);
+ }
return $value;
}
}
diff --git a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php
index bb8714a..bb73bbf 100755
--- a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php
+++ b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php
@@ -212,6 +212,7 @@ class ProductListJsonRenderer
$productsData[] = $this->serializeProduct($product);
}
+
// Envelope with layout and toolbar flag, matching the other list
// plugins. This turned the payload from a bare array into an object:
// the front end has to read `products.products` instead of
@@ -221,6 +222,11 @@ class ProductListJsonRenderer
// vocabulary the other lists use.
$response = [
'layout' => (string)($settings['layout'] ?? '0'),
+ // grid hint (Olli 2026-10-28): the column count follows the
+ // number of products, so a 2-product story section renders
+ // 2 columns, a 3-product one 3 - the front end reads this
+ // instead of guessing from the array length. Capped at 4.
+ 'columns' => min(4, max(1, count($productsData))),
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
'products' => $productsData,
// Success stories the editor attached to these categories
@@ -231,7 +237,12 @@ class ProductListJsonRenderer
// 2026-09-16 on front-end request - it used to deliver the
// empty envelope), so forCategories keeps driving the
// ?cat=/?subcat= filter exactly like on per-category lists.
- 'successStories' => $this->successStoriesFor(
+ // Hand-picked lists (sub-market Products section, the story
+ // "Featured Products" section) show EXACTLY the picked
+ // products - no ride-along stories (2026-10-28: without this
+ // the empty category selection aggregated stories over the
+ // WHOLE tree and story cards leaked into the section).
+ 'successStories' => ($selectedUids !== [] && !$allProducts) ? [] : $this->successStoriesFor(
($allProducts || $categoryUids === []) ? $this->allProductCategoryUids() : $categoryUids,
(int)($settings['storysinglepid'] ?? 0)
),
diff --git a/packages/vitec/Configuration/FlexForms/Downloadcard.xml b/packages/vitec/Configuration/FlexForms/Downloadcard.xml
index 21f9dea..132f6cb 100755
--- a/packages/vitec/Configuration/FlexForms/Downloadcard.xml
+++ b/packages/vitec/Configuration/FlexForms/Downloadcard.xml
@@ -62,6 +62,10 @@
neutral
+
+
+ story_download
+
diff --git a/packages/vitec/Configuration/FlexForms/Productlist.xml b/packages/vitec/Configuration/FlexForms/Productlist.xml
index 205b152..0286a2e 100644
--- a/packages/vitec/Configuration/FlexForms/Productlist.xml
+++ b/packages/vitec/Configuration/FlexForms/Productlist.xml
@@ -98,7 +98,13 @@
Variation 3 Product List
3
-
+
+
+
+ story_products
+