DEV CommiDEV Committ
This commit is contained in:
1 parent
4541c58fe7
commit
c6759186cb
145 files changed
+2742
-579
No files matched your search
@@ -0,0 +1,262 @@
|
||||
<?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 '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user