Files
VITEC-website/packages/vitec/Classes/Service/ContentElementResolver.php
Oliver Rasche 7bd1161abf Product text import from agency XLSX workbooks
VITEC Import module: Products tab is now a searchable/sortable product
overview. "Edit Product" opens an XLSX upload with per-field source
mapping (component + cell), old/new preview and checkbox apply via
DataHandler; the mapping and manual matches are persisted and preselect
the next workbook. Category workbooks are detected and rejected.

- new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController,
  Products/ProductTexts templates, 4 module routes
- related products: per-pair card text in new side table
  tx_vitec_product_related_text (survives MM rewrites), emitted as
  `cardtext` in the product JSON; missing MM relations added add-only
- card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) -
  the workbooks fill either depending on row type
- product detail page <title> now uses seotitle with title fallback
  (provider made singleton and fed from the JSON renderer)
- per-user recent-search badges; last loaded workbook stored per product
  (tx_vitec_product_workbook), Edit Product reopens on it
- composer: add phpoffice/phpspreadsheet ^5.9
- diagnostics: migrations/check_workbook.php
2026-08-21 11:00:45 +02:00

445 lines
18 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType;
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\ContainerBackgroundRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Resolves a TYPO3 typolink string (as stored by `inputLink` fields like
* Product.contentelement / Product.contentelementcta) to the JSON
* representation of the referenced tt_content element.
*
* Mirrors the shape produced by
* {@see \Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor}:
* { id, type, colPos, sorting, appearance, data }
*
* Nested VITEC list-plugins are resolved through their `renderForRecord()`
* just like container children, so a referenced productlist / productshow /
* usecaselist / usecaseshow appears with its full headless JSON in `data`.
*
* A linked CONTAINER (vitec_container, vitec_cols_*, vitec_cards_carousel)
* additionally carries its resolved `background` and its children under
* `items` - the exact shape ContainerChildrenProcessor emits for page-level
* containers, so the frontend reuses its container component. Nested
* containers recurse, depth-capped and cycle-safe.
*
* Exception-safe: every public entry point returns `null` on any failure
* so the surrounding JSON output stays clean.
*/
final class ContentElementResolver
{
/** 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',
'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',
];
private const KEEP_IF_ZERO = ['header_layout'];
/**
* Container-level fields - stripped from CHILD `data` like the page-level
* ContainerChildrenProcessor does (they carry non-empty defaults and
* belong to the parent). The linked container itself keeps them.
*/
private const CONTAINER_FIELDS = [
'tx_vitec_gap',
'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, for the
* parent's tx_vitec_col{N}_align/justify - same map as the processor.
*/
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],
];
/** Recursion cap for nested containers. */
private const MAX_CONTAINER_DEPTH = 5;
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'],
'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'],
];
/**
* Resolve a typolink string to a normalised tt_content JSON element.
*
* Accepted forms (all may include an optional anchor fragment):
* - "t3://record?identifier=tt_content&uid=N"
* - "t3://page?uid=PAGE#N" (link-popup: pick a CE on a page;
* the fragment is the tt_content uid)
* - "<numeric>" (legacy: bare tt_content uid)
* - everything else → null (pure page links etc.)
*
* @return array<string,mixed>|null
*/
public static function resolveLink(?string $link): ?array
{
try {
$link = trim((string)$link);
if ($link === '') {
return null;
}
$uid = self::extractTtContentUid($link);
if ($uid <= 0) {
return null;
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$row = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$row) {
return null;
}
$element = self::normaliseRecord($row);
self::attachContainerPayload($row, $element, [$uid => true], 0);
return $element;
} catch (\Throwable $e) {
return null;
}
}
/**
* Normalise a tt_content DB row to the same envelope shape that
* {@see \Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor}
* emits for container children. VITEC list-plugin children are
* resolved to their headless JSON.
*
* @param array<string,mixed> $record
* @param bool $isContainerChild strip container-level fields (bg, gap,
* col flex) like the page-level processor
* does for its children
* @return array<string,mixed>
*/
public static function normaliseRecord(array $record, bool $isContainerChild = false): 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 ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) {
continue;
}
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
continue;
}
$data[$field] = self::castValue($field, $value);
}
self::resolvePluginData($record, $data);
return [
'id' => (int)$record['uid'],
'type' => (string)$record['CType'],
'colPos' => (int)($record['colPos'] ?? 0),
'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,
];
}
/**
* Extract the tt_content uid from a typolink string.
*
* Handles four forms:
* 1. plain numeric → tt_content uid
* 2. t3://record?identifier=tt_content&uid=N → uid N
* 3. t3://record?identifier=tt_content&uid=N#X → uid N (anchor ignored)
* 4. t3://page?uid=PAGE#N → uid N from fragment
* (link-popup picks a content element on a page; the fragment is
* the tt_content uid, the query is the page uid)
*/
private static function extractTtContentUid(string $link): int
{
// Form 1: bare numeric uid
if (ctype_digit($link)) {
return (int)$link;
}
$parts = parse_url($link);
if (!is_array($parts)) {
return 0;
}
// Forms 2 & 3: t3://record?identifier=tt_content&uid=N
if (str_starts_with($link, 't3://record') && !empty($parts['query'])) {
$params = [];
parse_str((string)$parts['query'], $params);
$identifier = (string)($params['identifier'] ?? '');
$uid = (int)($params['uid'] ?? 0);
if ($identifier === 'tt_content' && $uid > 0) {
return $uid;
}
}
// Form 4: t3://page?uid=PAGE#N → tt_content uid lives in the fragment
if (str_starts_with($link, 't3://page')) {
$fragment = (string)($parts['fragment'] ?? '');
if (ctype_digit($fragment)) {
return (int)$fragment;
}
}
return 0;
}
/**
* If the record is a VITEC list-plugin, run its renderer for THIS row
* and inject the decoded result under its key. Drops raw pi_flexform.
*
* @param array<string,mixed> $record
* @param array<string,mixed> $data
*/
private static 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;
unset($data['pi_flexform']);
} catch (\Throwable $e) {
// leave raw data on failure
}
}
/**
* If the element has container children (tx_container_parent), attach the
* resolved `background` and the children as `items` in the page-level
* shape: [{config: {colPos, align?, justify?}, contentElements: [...]}].
* Recurses into nested containers; $visited guards against cycles.
*
* @param array<string,mixed> $record raw tt_content row
* @param array<string,mixed> $element normalised element, modified in place
* @param array<int,bool> $visited uids already on this path
*/
private static function attachContainerPayload(array $record, array &$element, array $visited, int $depth): void
{
$background = self::resolveContainerBackground($record);
if ($background !== null) {
$element['background'] = $background;
}
if ($depth >= self::MAX_CONTAINER_DEPTH) {
return;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter((int)$record['uid'], ParameterType::INTEGER)),
$qb->expr()->eq('pid', $qb->createNamedParameter((int)($record['pid'] ?? 0), ParameterType::INTEGER)),
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter((int)($record['sys_language_uid'] ?? 0), ParameterType::INTEGER))
)
->orderBy('colPos')
->addOrderBy('sorting')
->executeQuery()
->fetchAllAssociative();
} catch (\Throwable $e) {
return;
}
if ($rows === []) {
return;
}
$byColPos = [];
foreach ($rows as $childRow) {
$childUid = (int)$childRow['uid'];
$child = self::normaliseRecord($childRow, true);
if (!isset($visited[$childUid])) {
$childVisited = $visited;
$childVisited[$childUid] = true;
self::attachContainerPayload($childRow, $child, $childVisited, $depth + 1);
}
$byColPos[(int)$childRow['colPos']][] = $child;
}
ksort($byColPos);
$colMap = self::COLPOS_TO_COLUMN[(string)($record['CType'] ?? '')] ?? [];
$items = [];
foreach ($byColPos as $colPos => $contentElements) {
$config = ['colPos' => $colPos];
$columnNo = $colMap[$colPos] ?? null;
if ($columnNo !== null) {
$config['align'] = (string)($record['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
$config['justify'] = (string)($record['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
}
$items[] = [
'config' => $config,
'contentElements' => $contentElements,
];
}
$element['items'] = $items;
}
/**
* Resolved {image, size, position} of a container row - the same payload
* ContainerBackgroundRenderer emits in the page JSON. Null without image.
*
* @param array<string,mixed> $record
* @return array<string,mixed>|null
*/
private static function resolveContainerBackground(array $record): ?array
{
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$cObj->start($record, 'tt_content');
$renderer = GeneralUtility::makeInstance(ContainerBackgroundRenderer::class);
$renderer->setContentObjectRenderer($cObj);
$json = $renderer->render('', []);
if ($json === '') {
return null;
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $e) {
return null;
}
}
private static function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === 0 || $value === '0';
}
private static function castValue(string $field, mixed $value): mixed
{
if (in_array($field, ['header_layout'], true)) {
return (int)$value;
}
return $value;
}
}