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 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-28 14:10:59 +01:00
1 parent 80f6da7949
commit d87e64f73b
12 files changed
+763 -2

No files matched your search

@@ -0,0 +1,235 @@
<?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;
/**
* Converts the success-story "Download Success Story" RTE buttons into real
* vitec_downloadcard plugins (decision Olli 2026-10-28).
*
* Background: the story import wrote text elements whose RTE body carries
* `t3://record?identifier=...&uid=N` links built from the OLD site's
* download uids - #259 etc. do not exist here, an accidentally existing
* number may point at a FOREIGN pdf, and the front end would have to parse
* HTML to render a button. The Downloadcard plugin solves all of it:
* `settings.download` in the FlexForm, structured `download` object in the
* JSON, rendered by ContentElementResolver inside the story content.
*
* Per text element with such a link (inside a usecase's content_elements):
* - target download resolved by STORY TITLE (manual map first, then
* download title minus "Success Story"/"Use Case"/"Usecase", minus
* parentheticals and brand prefix, contains-tolerant; unique only) -
* the stored uid is never trusted
* - safety: converted only when the body is essentially just the button
* (plain text aside from the link <= 60 chars); otherwise reported
* - stories whose pdf is simply not in the pool: the button element is
* HIDDEN instead of leaving a dead link (reported)
*
* Idempotent: converted elements no longer match the query.
*
* vendor/bin/typo3 vitec:convert-story-download-buttons
* vendor/bin/typo3 vitec:convert-story-download-buttons --apply
*/
#[AsCommand(
name: 'vitec:convert-story-download-buttons',
description: 'Convert story RTE download buttons into vitec_downloadcard plugins (dry-run by default)'
)]
final class ConvertStoryDownloadButtonsCommand extends Command
{
/** story-norm => 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('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>Done. Flush the frontend cache, then re-check a story payload.</info>');
return Command::SUCCESS;
}
/**
* @param array<int,array{uid:int,title:string,norm:string}> $downloads
* @param array<string,int> $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)));
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Import\ProductFamilyAliases;
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;
/**
* Replaces the success-story "Featured Products" text sections with ONE
* vitec_productlist plugin per story (decision Olli 2026-10-28).
*
* The story import left the section as plain text elements: a "Featured
* Products" heading followed by product-teaser texts whose HEADERS carry
* the OLD site's product names ("Avedia server c1520", "VITEC End-Points",
* ...). Per story this command:
*
* - finds the heading text (header ~ "Featured Products") and collects
* the consecutive text elements after it (same story, by sorting)
* until the first non-text element or a text without header
* - resolves each header to a product record: STORY_PRODUCT_ALIASES
* first (old names), then exact title, title minus parenthesis,
* ProductFamilyAliases, unique slug prefix
* - creates ONE vitec_productlist in place of the heading (header
* "Featured Products", FlexForm settings.selectedproducts = resolved
* uids in section order, settings.layout = story_products - the NEW
* "Success Story Products" layout) and DELETES the old text elements
* - stories where NOTHING resolves keep their elements (reported);
* partially resolved sections convert, the unresolved names are
* reported for the alias map
*
* Idempotent: converted stories no longer have the heading text element.
*
* vendor/bin/typo3 vitec:convert-story-product-sections
* vendor/bin/typo3 vitec:convert-story-product-sections --apply
*/
#[AsCommand(
name: 'vitec:convert-story-product-sections',
description: 'Replace story "Featured Products" text sections with a productlist plugin (dry-run by default)'
)]
final class ConvertStoryProductSectionsCommand extends Command
{
/** old-site product names (normalised) => 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('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
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('<info>Done. Flush the frontend cache, then re-check a story payload.</info>');
return Command::SUCCESS;
}
/** @param array<int,array<string,mixed>> $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)));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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)
),