263 lines
9.2 KiB
PHP
Executable File
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 '';
|
|
}
|
|
}
|