From d87e64f73b7b1e1b45e74d5851f3e664c98f6900 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Wed, 28 Oct 2026 14:10:59 +0100 Subject: [PATCH] Success stories: downloads and product sections become real plugins - comparison report migrations/check_product_downloads.php (kept, read-only): download records vs. the delivered product workbooks - 34 of 74 workbook products have a datasheet, the entire Datapath line has none in the pool (import came from vitec.com; queued for Joe), ~35 orphan datasheets belong to products without records; a vitec:link-product-downloads re-run added seven clean links (Artio x2, Diamond-H, the three TOUGH records, X-View) - RTE resolution fixed at the root: ContentElementResolver and ContainerChildrenProcessor passed bodytext RAW into the payload, so t3:// links leaked unresolved; both now run RTE columns (bodytext, vitec_quote) through RteResolver::html() - the imported "Download Success Story" RTE buttons carried the wrong link identifier (tx_vitec_download -> download, one-time migration fix_download_record_links.php) AND old-site uids - missing here or, worse, accidentally pointing at foreign PDFs. New vitec:convert-story-download-buttons replaces them with real vitec_downloadcard plugins: the download is resolved by STORY title (manual map for ISR / "Okada Manilla", exact-title fallback for suffixless PDFs like "Lord's Cricket Ground"); 29 converted, 4 stories without a delivered PDF have their button hidden. New FlexForm layout "Success Story Download" (story_download) applied everywhere; the FE renders it as the previous centered button - the ADIB "Featured Products" text section (the only one site-wide, verified by scan_story_headers.php) is now ONE vitec_productlist with hand-picked products via old-name aliases and the new FlexForm layout "Success Story Products" (story_products); the four text elements are deleted. vitec:convert-story-product-sections stays re-runnable for future imports - the productlist envelope additionally carries "columns" (= number of products, capped at 4) so the story grid renders as many columns as it has products - additive key, available to every product list; and hand-picked lists now ship successStories as an empty array (the empty category selection used to aggregate stories over the WHOLE tree, leaking story cards into the section) - the story downloadcards carry header "Downloads" (set on convert and backfilled): the front end builds the story anchor nav from element headers, so the section now appears there - diagnosis scripts kept: check_download_record, check_story_elements, scan_story_headers; verified live on the ADIB payload (structured download object, productlist [5,18,6], texts 10 -> 5) Co-Authored-By: Claude Fable 5 --- migrations/check_download_record.php | 15 ++ migrations/check_product_downloads.php | 127 ++++++++++ migrations/check_story_elements.php | 25 ++ migrations/fix_download_record_links.php | 62 +++++ migrations/scan_story_headers.php | 29 +++ .../ConvertStoryDownloadButtonsCommand.php | 235 ++++++++++++++++++ .../ConvertStoryProductSectionsCommand.php | 232 +++++++++++++++++ .../ContainerChildrenProcessor.php | 8 + .../Service/ContentElementResolver.php | 7 + .../UserFunc/ProductListJsonRenderer.php | 13 +- .../Configuration/FlexForms/Downloadcard.xml | 4 + .../Configuration/FlexForms/Productlist.xml | 8 +- 12 files changed, 763 insertions(+), 2 deletions(-) create mode 100644 migrations/check_download_record.php create mode 100644 migrations/check_product_downloads.php create mode 100644 migrations/check_story_elements.php create mode 100644 migrations/fix_download_record_links.php create mode 100644 migrations/scan_story_headers.php create mode 100644 packages/vitec/Classes/Command/ConvertStoryDownloadButtonsCommand.php create mode 100644 packages/vitec/Classes/Command/ConvertStoryProductSectionsCommand.php 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 +