Product content check as page content (vitec_productcheck)
The product audit of public/product-check/ becomes data for the React frontend: new content element vitec_productcheck (plugin registration, wizard entry, TypoScript USER func) delivers under content.productcheck either the overview (every product with one {ok,count} check per field - hero, photos, og, video, teaser, subtitle, description, description2, highlights, capabilities, applications, features incl. icon count, resources, datasheets, solutions - plus per-field summary) or, with ?check=<uid>, the detail of one product (texts as stored HTML, images with original URL and 480px thumbnail, videos, feature cards with icons, resources, datasheets, solutions). ProductCheckService ports the PDO queries of the standalone page to Doctrine; the renderer is always USER_INT so the audit is never served from the page cache; check is excluded from the cHash. Migration adds the element to page 607 (/products/product-check). The standalone page stays untouched. Verified on DEV: overview 85 products in 0.13 s warm, detail 0.2-0.9 s, thumbnails resolve.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b36dceb486
commit
4bc38b1760
8 files changed
+519
No files matched your search
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
|
||||
/**
|
||||
* Product content audit as data: which product record has which content,
|
||||
* and the full content of one product for the detail pane.
|
||||
*
|
||||
* This is the data model behind the standalone page public/product-check/
|
||||
* (PHP + PDO, 2026-11-03), ported to Doctrine so the React frontend can
|
||||
* render the same audit as a page (/products/product-check, content
|
||||
* element vitec_productcheck, see ProductCheckJsonRenderer). Both read the
|
||||
* database fresh on every call; nothing here is cached on purpose.
|
||||
*
|
||||
* overview(): every product (alphabetical) with one check per field
|
||||
* {ok, count[, icons]} plus the per-field summary.
|
||||
* detail(): one product with all texts (RTE HTML as stored), images
|
||||
* (original URL + ~480 px thumbnail), videos, feature cards
|
||||
* with icons, resources, datasheets, solutions.
|
||||
*/
|
||||
final class ProductCheckService
|
||||
{
|
||||
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
/**
|
||||
* Columns of the audit table, in display order. kind drives the cell
|
||||
* format in the frontend: images/video/features/list show a count,
|
||||
* text shows a plain check.
|
||||
*/
|
||||
private const FIELDS = [
|
||||
['key' => 'hero', 'label' => 'Hero', 'kind' => 'images'],
|
||||
['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'],
|
||||
['key' => 'og', 'label' => 'OG image', 'kind' => 'images'],
|
||||
['key' => 'video', 'label' => 'Video', 'kind' => 'video'],
|
||||
['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'],
|
||||
['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'],
|
||||
['key' => 'description', 'label' => 'Description', 'kind' => 'text'],
|
||||
['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'],
|
||||
['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'],
|
||||
['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'],
|
||||
['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'],
|
||||
['key' => 'features', 'label' => 'Features', 'kind' => 'features'],
|
||||
['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'],
|
||||
['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'],
|
||||
['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'],
|
||||
];
|
||||
|
||||
private const FLAGS = [
|
||||
'hidden' => 'hidden',
|
||||
'legacy' => 'legacy',
|
||||
'supportproduct' => 'support',
|
||||
'subproduct' => 'sub',
|
||||
'hideonwebsite' => 'hideWeb',
|
||||
'hideonproducts' => 'hideList',
|
||||
'showdatapath' => 'datapath',
|
||||
];
|
||||
|
||||
private const TEXT_FIELDS = [
|
||||
'teaser' => 'Teaser',
|
||||
'subtitle' => 'Subtitle',
|
||||
'description' => 'Description',
|
||||
'description2' => 'Description 2',
|
||||
'highlights' => 'Highlights',
|
||||
'capabilities' => 'Capabilities',
|
||||
'applications' => 'Applications',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{generatedAt: string, fields: array<int, array{key: string, label: string, kind: string}>, summary: array{products: int, filled: array<string, int>}, products: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function overview(): array
|
||||
{
|
||||
$products = $this->products();
|
||||
$uids = array_map(static fn(array $p): int => (int)$p['uid'], $products);
|
||||
$files = $this->fileCounts($uids);
|
||||
$features = $this->featureCounts($uids);
|
||||
$resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0');
|
||||
$datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download');
|
||||
$solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution');
|
||||
|
||||
$filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
|
||||
$rows = [];
|
||||
foreach ($products as $p) {
|
||||
$uid = (int)$p['uid'];
|
||||
$f = $files[$uid] ?? [];
|
||||
$checks = [
|
||||
'hero' => $this->check($f['heroimage'] ?? 0),
|
||||
'photos' => $this->check($f['productimage'] ?? 0),
|
||||
'og' => $this->check($f['ogimage'] ?? 0),
|
||||
'video' => $this->check($f['videofile'] ?? 0),
|
||||
'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0),
|
||||
'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0),
|
||||
'description' => $this->check($this->filled($p['description']) ? 1 : 0),
|
||||
'description2' => $this->check($this->filled($p['description2']) ? 1 : 0),
|
||||
'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0),
|
||||
'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0),
|
||||
'applications' => $this->check($this->filled($p['applications']) ? 1 : 0),
|
||||
'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0],
|
||||
'resources' => $this->check($resources[$uid] ?? 0),
|
||||
'datasheets' => $this->check($datasheets[$uid] ?? 0),
|
||||
'solutions' => $this->check($solutions[$uid] ?? 0),
|
||||
];
|
||||
foreach ($checks as $key => $c) {
|
||||
if ($c['ok']) {
|
||||
$filled[$key]++;
|
||||
}
|
||||
}
|
||||
$rows[] = $this->base($p) + ['checks' => $checks];
|
||||
}
|
||||
|
||||
return [
|
||||
'generatedAt' => date(\DATE_ATOM),
|
||||
'fields' => self::FIELDS,
|
||||
'summary' => ['products' => count($rows), 'filled' => $filled],
|
||||
'products' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null null when the product does not exist
|
||||
*/
|
||||
public function detail(int $uid): ?array
|
||||
{
|
||||
$p = $this->connection()->executeQuery(
|
||||
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
|
||||
capabilities, applications, resources_layout,
|
||||
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
|
||||
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?',
|
||||
[$uid]
|
||||
)->fetchAssociative();
|
||||
if ($p === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = $this->files($uid);
|
||||
$texts = [];
|
||||
foreach (self::TEXT_FIELDS as $field => $label) {
|
||||
$texts[$field] = [
|
||||
'label' => $label,
|
||||
'ok' => $this->filled($p[$field]),
|
||||
'html' => (string)$p[$field],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->base($p) + [
|
||||
'images' => [
|
||||
'hero' => $files['heroimage'] ?? [],
|
||||
'photos' => $files['productimage'] ?? [],
|
||||
'og' => $files['ogimage'] ?? [],
|
||||
],
|
||||
'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []),
|
||||
'texts' => $texts,
|
||||
'features' => $this->features($uid),
|
||||
'resources' => $this->resources($uid),
|
||||
'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid),
|
||||
'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- building blocks ----------------------------------------------------
|
||||
|
||||
/** @param array<string, mixed> $p */
|
||||
private function base(array $p): array
|
||||
{
|
||||
$slug = trim((string)$p['slug'], '/');
|
||||
$flags = [];
|
||||
foreach (self::FLAGS as $column => $label) {
|
||||
if ((int)($p[$column] ?? 0) === 1) {
|
||||
$flags[] = $label;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'uid' => (int)$p['uid'],
|
||||
'title' => (string)$p['title'],
|
||||
'slug' => $slug,
|
||||
'url' => '/product/' . $slug,
|
||||
'flags' => $flags,
|
||||
'resourcesLayout' => (string)($p['resources_layout'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, count: int} */
|
||||
private function check(int $count): array
|
||||
{
|
||||
return ['ok' => $count > 0, 'count' => $count];
|
||||
}
|
||||
|
||||
private function filled(mixed $value): bool
|
||||
{
|
||||
return trim(strip_tags((string)$value)) !== '';
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function products(): array
|
||||
{
|
||||
return $this->connection()->executeQuery(
|
||||
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
|
||||
capabilities, applications, resources_layout,
|
||||
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
|
||||
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid'
|
||||
)->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible FAL references per product and field.
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @return array<int, array<string, int>> [productUid][fieldname] = count
|
||||
*/
|
||||
private function fileCounts(array $uids): array
|
||||
{
|
||||
if ($uids === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = $this->connection()->executeQuery(
|
||||
"SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
|
||||
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product'
|
||||
AND fieldname IN ('heroimage','productimage','ogimage','videofile')
|
||||
AND uid_foreign IN (" . implode(',', array_map('intval', $uids)) . ')
|
||||
GROUP BY uid_foreign, fieldname'
|
||||
)->fetchAllAssociative();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files of one product with original URL and a ~480 px thumbnail
|
||||
* (SVG and non-images keep the original as thumbnail).
|
||||
*
|
||||
* @return array<string, array<int, array{url: string, thumb: string, name: string, mime: string}>>
|
||||
*/
|
||||
private function files(int $uid): array
|
||||
{
|
||||
$rows = $this->connection()->executeQuery(
|
||||
"SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
|
||||
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
||||
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
|
||||
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ?
|
||||
ORDER BY r.fieldname, r.sorting_foreign",
|
||||
[$uid]
|
||||
)->fetchAllAssociative();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$url = '/fileadmin' . $r['identifier'];
|
||||
$out[(string)$r['fieldname']][] = [
|
||||
'url' => $url,
|
||||
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
|
||||
'name' => (string)$r['name'],
|
||||
'mime' => (string)$r['mime_type'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function thumbnail(int $fileUid, string $mime, string $fallback): string
|
||||
{
|
||||
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
|
||||
return $fallback;
|
||||
}
|
||||
try {
|
||||
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
|
||||
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
|
||||
return $imageService->getImageUri($processed);
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $uids
|
||||
* @return array<int, array{cards: int, icons: int}>
|
||||
*/
|
||||
private function featureCounts(array $uids): array
|
||||
{
|
||||
if ($uids === []) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', array_map('intval', $uids));
|
||||
$out = [];
|
||||
foreach ($this->connection()->executeQuery(
|
||||
"SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature
|
||||
WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product"
|
||||
)->fetchAllAssociative() as $r) {
|
||||
$out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0];
|
||||
}
|
||||
foreach ($this->connection()->executeQuery(
|
||||
"SELECT fe.product, COUNT(DISTINCT fe.uid) AS n
|
||||
FROM tx_vitec_product_feature fe
|
||||
JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature'
|
||||
AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0
|
||||
WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product"
|
||||
)->fetchAllAssociative() as $r) {
|
||||
$out[(int)$r['product']]['icons'] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int, array{title: string, text: string, icon: array{url: string, name: string}|null}> */
|
||||
private function features(int $uid): array
|
||||
{
|
||||
$cards = $this->connection()->executeQuery(
|
||||
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
|
||||
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
|
||||
[$uid]
|
||||
)->fetchAllAssociative();
|
||||
if ($cards === []) {
|
||||
return [];
|
||||
}
|
||||
$icons = [];
|
||||
foreach ($this->connection()->executeQuery(
|
||||
"SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
||||
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon'
|
||||
AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ')
|
||||
ORDER BY r.uid_foreign, r.sorting_foreign'
|
||||
)->fetchAllAssociative() as $r) {
|
||||
$icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
|
||||
}
|
||||
return array_map(static fn(array $c): array => [
|
||||
'title' => (string)$c['title'],
|
||||
'text' => (string)$c['cardtext'],
|
||||
'icon' => $icons[(int)$c['uid']] ?? null,
|
||||
], $cards);
|
||||
}
|
||||
|
||||
/** @return array<int, array{title: string, type: string, link: string, text: string}> */
|
||||
private function resources(int $uid): array
|
||||
{
|
||||
return array_map(static fn(array $r): array => [
|
||||
'title' => (string)$r['title'],
|
||||
'type' => (string)$r['restype'],
|
||||
'link' => (string)$r['link'],
|
||||
'text' => (string)$r['cardtext'],
|
||||
], $this->connection()->executeQuery(
|
||||
'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource
|
||||
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
|
||||
[$uid]
|
||||
)->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/** @return array<int, int> [productUid] = count */
|
||||
private function countBy(string $table, string $column, string $where): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->connection()->executeQuery(
|
||||
"SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column"
|
||||
)->fetchAllAssociative() as $r) {
|
||||
$out[(int)$r['k']] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int, int> [productUid] = count of non-deleted foreign records */
|
||||
private function mmCounts(string $mmTable, string $foreignTable): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->connection()->executeQuery(
|
||||
"SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm
|
||||
JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
|
||||
GROUP BY mm.uid_local"
|
||||
)->fetchAllAssociative() as $r) {
|
||||
$out[(int)$r['k']] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int, array{uid: int, title: string}> */
|
||||
private function mmTitles(string $mmTable, string $foreignTable, int $uid): array
|
||||
{
|
||||
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery(
|
||||
"SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
|
||||
WHERE mm.uid_local = ? ORDER BY mm.sorting",
|
||||
[$uid]
|
||||
)->fetchAllAssociative());
|
||||
}
|
||||
|
||||
private function connection(): \TYPO3\CMS\Core\Database\Connection
|
||||
{
|
||||
return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\UserFunc;
|
||||
|
||||
use Evomedien\Vitec\Service\ProductCheckService;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
|
||||
/**
|
||||
* JSON for the content element `vitec_productcheck` (page /products/
|
||||
* product-check): the product content audit, rendered by the React front
|
||||
* end instead of the standalone page public/product-check/.
|
||||
*
|
||||
* Emitted under content.productcheck:
|
||||
*
|
||||
* without query {mode: "overview", generatedAt, fields[], summary, products[]}
|
||||
* ?check=<uid> {mode: "detail", product: {…} | null}
|
||||
* on failure {mode: "error", error}
|
||||
*
|
||||
* Always USER_INT: the audit must show the database as it is right now,
|
||||
* so neither the TYPO3 page cache nor the `check` variant may stick.
|
||||
* `check` is excluded from the cHash in ext_localconf.php. The front end
|
||||
* is expected to load the detail variant client-side from /_json (one
|
||||
* request per opened row), not to navigate the SSR page with the query.
|
||||
*/
|
||||
final class ProductCheckJsonRenderer
|
||||
{
|
||||
protected ?ContentObjectRenderer $cObj = null;
|
||||
|
||||
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
||||
{
|
||||
$this->cObj = $cObj;
|
||||
}
|
||||
|
||||
#[AsAllowedCallable]
|
||||
public function render(string $content, array $conf): string
|
||||
{
|
||||
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
|
||||
$this->cObj->convertToUserIntObject();
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
$service = GeneralUtility::makeInstance(ProductCheckService::class);
|
||||
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
||||
$check = (int)($queryParams['check'] ?? 0);
|
||||
if ($check > 0) {
|
||||
$payload = ['mode' => 'detail', 'product' => $service->detail($check)];
|
||||
} else {
|
||||
$payload = ['mode' => 'overview'] + $service->overview();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
|
||||
}
|
||||
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user