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:
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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user