Files
orascheandClaude Fable 5 d87e64f73b 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>
2026-10-28 14:10:59 +01:00

320 lines
14 KiB
PHP
Executable File

<?php
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;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketListJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionListJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\LocationsJsonRenderer;
use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use Evomedien\Vitec\UserFunc\SearchJsonRenderer;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
/**
* Collect container children grouped by colPos and emit a lean, transport-
* ready structure. Each column is normalised to:
* { config: { colPos, align, justify }, contentElements: [ { id, type, … } ] }
* `align`/`justify` are the PARENT container's per-column flex settings
* (tx_vitec_col{N}_align/justify) and therefore apply to ALL children of that
* column. Each child is normalised to { id, type, colPos, sorting, appearance,
* data }, where `data` only contains non-empty content-relevant fields —
* container-level fields (gap, col flex) are never leaked to children.
*
* VITEC list-plugins nested as container children (productlist, productshow,
* usecaselist, usecaseshow) are resolved to the SAME JSON the top-level
* headless rendering produces, injected into `data` under their respective
* key. The raw pi_flexform XML is then dropped from `data`.
*
* Exception-safe.
*/
final class ContainerChildrenProcessor implements DataProcessorInterface
{
/** Fields that go to the envelope (not to `data`). */
private const ENVELOPE = [
'uid', 'CType', 'colPos', 'sorting',
'layout', 'frame_class', 'space_before_class', 'space_after_class',
];
/** Technical / system / TCA-default fields — never sent to frontend. */
private const SYSTEM_FIELDS = [
'pid', 'sys_language_uid', 'l18n_parent', 'l18n_diffsource',
'l10n_source', 'l10n_state', 'l10n_parent',
't3_origuid', 'tx_impexp_origuid',
'tx_container_parent',
'tstamp', 'crdate', 'cruser_id',
'hidden', 'deleted', 'starttime', 'endtime', 'fe_group',
't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage',
't3ver_id', 't3ver_label', 't3ver_count', 't3ver_tstamp',
'editlock', 'sorting_foreign', 'rowDescription',
'spaceBefore', 'spaceAfter', // legacy
'imagecols', 'sectionIndex', 'linkToTop', 'recursive', 'date',
'bullets_type', 'cols',
'table_delimiter', 'table_enclosure', 'table_header_position',
'table_tfoot', 'table_caption',
'filelink_size', 'filelink_sorting', 'filelink_sorting_direction',
'uploads_description', 'uploads_type',
];
/**
* Container-level fields — they belong on the parent container (grid gap
* and per-column flex) and must never appear in a child's `data`.
*/
private const CONTAINER_FIELDS = [
'tx_vitec_gap',
// Background settings belong to the container, not to what sits inside
// it. They carry non-empty defaults ('none', 'cover', 'center center'),
// so without this they would show up in every single child's `data`.
'tx_vitec_bg_variant', 'tx_vitec_bg_image', 'tx_vitec_bg_size',
'tx_vitec_bg_size_percent', 'tx_vitec_bg_position',
'tx_vitec_bg_pos_top', 'tx_vitec_bg_pos_bottom',
'tx_vitec_bg_pos_left', 'tx_vitec_bg_pos_right',
'tx_vitec_col1_align', 'tx_vitec_col1_justify',
'tx_vitec_col2_align', 'tx_vitec_col2_justify',
'tx_vitec_col3_align', 'tx_vitec_col3_justify',
'tx_vitec_col4_align', 'tx_vitec_col4_justify',
];
/**
* Per container CType: colPos value => 1-based column number. Used to pick
* the parent's tx_vitec_col{N}_align/justify for each rendered column.
*/
private const COLPOS_TO_COLUMN = [
'vitec_cols_50_50' => [211 => 1, 212 => 2],
'vitec_cols_33_66' => [251 => 1, 252 => 2],
'vitec_cols_66_33' => [241 => 1, 242 => 2],
'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3],
'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4],
];
/** Fields whose 0/empty value is still meaningful. */
private const KEEP_IF_ZERO = [
'header_layout',
];
/**
* Nested VITEC list-plugins: list_type => [ rendererClass, jsonKey ].
* The renderer's renderForRecord() is invoked with the child's own
* tt_content row so the result is record-accurate (works with multiple
* containers / plugins on the same page).
*/
private const PLUGIN_RENDERERS = [
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
'vitec_usecaselist' => [UsecaseListJsonRenderer::class, 'usecases'],
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'],
'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'],
'vitec_solutionlist' => [SolutionListJsonRenderer::class, 'solutions'],
'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'],
'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'],
'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'],
'vitec_contactform' => [FormsJsonRenderer::class, 'form'],
'vitec_demoform' => [FormsJsonRenderer::class, 'form'],
'vitec_helpdeskform' => [FormsJsonRenderer::class, 'form'],
'solr_pi_results' => [SearchJsonRenderer::class, 'search'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
'news_newsdetail' => [NewsJsonRenderer::class, 'news'],
'news_newsdatemenu' => [NewsJsonRenderer::class, 'news'],
'news_categorylist' => [NewsJsonRenderer::class, 'news'],
'news_newssearchform' => [NewsJsonRenderer::class, 'news'],
'news_newssearchresult' => [NewsJsonRenderer::class, 'news'],
'news_taglist' => [NewsJsonRenderer::class, 'news'],
];
public function process(
ContentObjectRenderer $cObj,
array $contentObjectConfiguration,
array $processorConfiguration,
array $processedData
): array {
$as = (string)($processorConfiguration['as'] ?? 'items');
try {
$parentUid = (int)($cObj->data['uid'] ?? 0);
if ($parentUid <= 0) {
$processedData[$as] = [];
return $processedData;
}
$pid = (int)($cObj->data['pid'] ?? 0);
$sysLanguageUid = (int)($cObj->data['sys_language_uid'] ?? 0);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter($parentUid, Connection::PARAM_INT)),
$qb->expr()->eq('pid', $qb->createNamedParameter($pid, Connection::PARAM_INT)),
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter($sysLanguageUid, Connection::PARAM_INT))
)
->orderBy('colPos')
->addOrderBy('sorting')
->executeQuery()
->fetchAllAssociative();
$byColPos = [];
foreach ($rows as $record) {
$byColPos[(int)$record['colPos']][] = $this->normalise($record);
}
ksort($byColPos);
// Per-column flex settings come from the parent container record.
$parentCType = (string)($cObj->data['CType'] ?? '');
$colMap = self::COLPOS_TO_COLUMN[$parentCType] ?? [];
$items = [];
foreach ($byColPos as $colPos => $contentElements) {
$config = ['colPos' => $colPos];
$columnNo = $colMap[$colPos] ?? null;
if ($columnNo !== null) {
$config['align'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
$config['justify'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
}
$items[] = [
'config' => $config,
'contentElements' => $contentElements,
];
}
$processedData[$as] = $items;
} catch (\Throwable $e) {
$processedData[$as] = [];
}
return $processedData;
}
private function normalise(array $record): array
{
$data = [];
foreach ($record as $field => $value) {
if (in_array($field, self::ENVELOPE, true)) {
continue;
}
if (in_array($field, self::SYSTEM_FIELDS, true)) {
continue;
}
if (in_array($field, self::CONTAINER_FIELDS, true)) {
continue;
}
if ($this->isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
continue;
}
$data[$field] = $this->castValue($field, $value);
}
// Resolve nested VITEC list-plugins to their headless JSON.
$this->resolvePluginData($record, $data);
return [
'id' => (int)$record['uid'],
'type' => (string)$record['CType'],
'colPos' => (int)$record['colPos'],
'sorting' => (int)($record['sorting'] ?? 0),
'appearance' => [
'layout' => (string)($record['layout'] ?? ''),
'frameClass' => (string)($record['frame_class'] ?? 'default'),
'spaceBefore' => (string)($record['space_before_class'] ?? ''),
'spaceAfter' => (string)($record['space_after_class'] ?? ''),
],
'data' => (object)$data,
];
}
/**
* If the child is a VITEC list-plugin, run its JSON renderer for THIS
* record and inject the decoded result under its key. Drops the raw
* pi_flexform XML afterwards. Never throws.
*
* @param array<string,mixed> $record
* @param array<string,mixed> $data
*/
private function resolvePluginData(array $record, array &$data): void
{
// v14: plugins are their own CType; legacy elements still carry list_type.
$cType = (string)($record['CType'] ?? '');
$listType = (string)($record['list_type'] ?? '');
$key = isset(self::PLUGIN_RENDERERS[$cType]) ? $cType
: (isset(self::PLUGIN_RENDERERS[$listType]) ? $listType : null);
if ($key === null) {
return;
}
try {
[$rendererClass, $jsonKey] = self::PLUGIN_RENDERERS[$key];
$renderer = GeneralUtility::makeInstance($rendererClass);
if (!method_exists($renderer, 'renderForRecord')) {
return;
}
$json = $renderer->renderForRecord($record);
if ($json === '' || $json === null) {
return;
}
$decoded = json_decode($json, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
return;
}
$data[$jsonKey] = $decoded;
// The raw FlexForm XML is noise once the plugin is resolved.
unset($data['pi_flexform']);
} catch (\Throwable $e) {
// Leave the raw data untouched on any failure.
}
}
private function isEmpty(mixed $value): bool
{
return $value === null
|| $value === ''
|| $value === 0
|| $value === '0';
}
private function castValue(string $field, mixed $value): mixed
{
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;
}
}