281 lines
11 KiB
PHP
Executable File
281 lines
11 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\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\NewsJsonRenderer;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* 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`.
|
|
*
|
|
* 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'];
|
|
|
|
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_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
|
|
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
|
|
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
|
|
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
|
|
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
|
|
'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;
|
|
}
|
|
|
|
return self::normaliseRecord($row);
|
|
} 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
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function normaliseRecord(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 (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
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|