Files
VITEC-website/packages/vitec/Classes/Controller/Backend/StructuredDataController.php
2026-07-01 10:34:48 +02:00

263 lines
9.2 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\StructuredDataService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Doctrine\DBAL\ParameterType;
/**
* Backend AJAX endpoint: generate schema.org JSON-LD for a single product and
* return it as a string, ready to be written into the product's
* `structureddata` field by the FormEngine wizard button.
*
* Route: vitec_structureddata_generate (see Configuration/Backend/AjaxRoutes.php)
*
* Generation is based on the *saved* product record (by uid); a new/unsaved
* record returns success=false with a hint to save first. The structured data
* is built via {@see StructuredDataService} and emitted self-contained (no
* cross-document @id references), since the field content may be reused
* standalone.
*/
#[AsController]
final class StructuredDataController
{
public function __construct(
private readonly StructuredDataService $structuredDataService,
) {}
public function generate(ServerRequestInterface $request): ResponseInterface
{
$uid = (int)($request->getQueryParams()['uid'] ?? 0);
if ($uid <= 0) {
return new JsonResponse([
'success' => false,
'message' => 'Bitte das Produkt zuerst speichern, dann erneut generieren.',
]);
}
$product = $this->loadProduct($uid);
if ($product === null) {
return new JsonResponse([
'success' => false,
'message' => 'Produkt nicht gefunden.',
]);
}
$base = $this->resolveCanonicalBase((int)($product['pid'] ?? 0), $request);
$data = [
'uid' => $uid,
'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
'teaser' => (string)($product['teaser'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'categories' => $this->categories($uid),
'videofile' => $this->videoFile($uid, $base),
];
$images = $this->imageUrls($uid, $base);
// Self-contained snippet → no @id reference to the page-level Organization.
$productNode = $this->structuredDataService->buildProduct($data, $base, $images, false);
$videoNode = $this->structuredDataService->buildVideoObject(
$data,
$base,
$images,
(int)($product['crdate'] ?? $product['tstamp'] ?? 0)
);
$nodes = array_values(array_filter([$productNode, $videoNode]));
// One node → standalone object with @context; multiple → @graph.
if (count($nodes) === 1) {
$document = array_merge(['@context' => 'https://schema.org'], $nodes[0]);
} else {
$document = ['@context' => 'https://schema.org', '@graph' => $nodes];
}
$json = (string)json_encode(
$document,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return new JsonResponse([
'success' => true,
'jsonLd' => $json,
]);
}
/**
* @return array<string,mixed>|null
*/
private function loadProduct(int $uid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $queryBuilder
->select('*')
->from('tx_vitec_domain_model_product')
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchAssociative();
return $row ?: null;
}
/**
* @return list<array{title:string}>
*/
private function categories(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.title')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($r) => ['title' => (string)($r['title'] ?? '')], $rows);
}
/**
* Absolute URLs of the product images (original file public URLs).
* Falls back to the OG image when no product images exist.
*
* @return string[]
*/
private function imageUrls(int $productUid, string $base): array
{
$urls = $this->publicUrlsForField($productUid, 'productimage', $base);
if ($urls === []) {
$urls = $this->publicUrlsForField($productUid, 'ogimage', $base);
}
return $urls;
}
/**
* @return string[]
*/
private function publicUrlsForField(int $productUid, string $fieldName, string $base): array
{
$rows = $this->fileReferences($productUid, $fieldName);
if ($rows === []) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$urls = [];
foreach ($rows as $row) {
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
if ($publicUrl !== '') {
$urls[] = $this->structuredDataService->absUrl($publicUrl, $base);
}
} catch (\Throwable $e) {
continue;
}
}
return $urls;
}
/**
* @return array{url:string}|null
*/
private function videoFile(int $productUid, string $base): ?array
{
$rows = $this->fileReferences($productUid, 'videofile');
if ($rows === []) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$rows[0]['uid']);
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
if ($publicUrl === '') {
return null;
}
return ['url' => $this->structuredDataService->absUrl($publicUrl, $base)];
} catch (\Throwable $e) {
return null;
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function fileReferences(int $productUid, string $fieldName): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
return $queryBuilder
->select('uid')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
private function resolveCanonicalBase(int $pid, ServerRequestInterface $request): string
{
if ($pid > 0) {
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pid);
$configured = trim((string)($site->getSettings()->get('seo.site.canonicalBase') ?? ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
} catch (\Throwable $e) {
// fall through to request host
}
}
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
}