From 17b08df283cbb1e64c5ef76ad00c94b07ad10df3 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 13 Nov 2026 18:47:12 +0100 Subject: [PATCH] Content checks: flag placeholder images instead of counting them as done Files below fileadmin/placeholders/ (or named "...placeholder...") are grey dummies. PageCheckService and ProductCheckService now report them: - image cells carry `placeholder` = number of placeholder files (only when > 0); `ok` is unchanged (something is attached) - summary.filled counts only cells without placeholders, new summary.placeholder counts the cells that have one - detail images carry `placeholder: true|false` - cards: `placeholder` = cards whose icon/image is a placeholder; record: the record image Additive change to the JSON contract; frontend rendering follows (ticket). Requested by Harry 2026-11-13. Co-Authored-By: Claude Fable 5.1 --- migrations/pagecheck_placeholder_20261113.sh | 19 +++ .../Classes/Service/PageCheckService.php | 114 +++++++++++++----- .../Classes/Service/ProductCheckService.php | 51 +++++--- 3 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 migrations/pagecheck_placeholder_20261113.sh diff --git a/migrations/pagecheck_placeholder_20261113.sh b/migrations/pagecheck_placeholder_20261113.sh new file mode 100644 index 0000000..6bc2e08 --- /dev/null +++ b/migrations/pagecheck_placeholder_20261113.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Placeholder flag for the content checks (2026-11-13): permissions, syntax check, +# cache flush and SSR purge of the three check pages; "commit" as first argument +# commits the two services afterwards. +set -e +cd /usr/www/users/vitecevo/live +SRV=packages/vitec/Classes/Service +chmod 644 $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh +php -l $SRV/PageCheckService.php +php -l $SRV/ProductCheckService.php +if [ "$1" = "commit" ]; then + git add $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh + git commit -q -F migrations/pagecheck_placeholder_20261113.msg + git log --oneline -1 + exit 0 +fi +vendor/bin/typo3 cache:flush +vendor/bin/typo3 vitec:ssr-purge /markets/market-check /solutions/solution-check /products/product-check +vendor/bin/typo3 vitec:ssr-warm /markets/market-check /solutions/solution-check /products/product-check diff --git a/packages/vitec/Classes/Service/PageCheckService.php b/packages/vitec/Classes/Service/PageCheckService.php index 3445c37..b311ac8 100644 --- a/packages/vitec/Classes/Service/PageCheckService.php +++ b/packages/vitec/Classes/Service/PageCheckService.php @@ -30,10 +30,25 @@ use TYPO3\CMS\Extbase\Service\ImageService; * logos on a sub-market, insights on a main market) is delivered as * {ok: null, count: 0} and counted neither as filled nor as applicable. * + * Placeholders (Harry 2026-11-13): files below fileadmin/placeholders/ (or + * named "...placeholder...") are grey dummies that must not pass as finished. + * Image cells carry `placeholder` = number of such files when there is at + * least one; summary.filled counts only cells without placeholders, and + * summary.placeholder counts the cells that have one. Detail images carry + * `placeholder: true|false`. `ok` itself is unchanged (something is attached). + * * Read fresh on every call, nothing cached (USER_INT in the renderer). */ final class PageCheckService { + /** SQL condition on sys_file f that marks a placeholder file. */ + public const PLACEHOLDER_SQL = "(f.identifier LIKE '/placeholders/%' OR LOWER(f.name) LIKE '%placeholder%')"; + + public static function isPlaceholder(string $identifier, string $name): bool + { + return str_starts_with($identifier, '/placeholders/') || str_contains(strtolower($name), 'placeholder'); + } + public const SCOPES = [ 'markets' => [ 'label' => 'Markets', @@ -137,9 +152,13 @@ final class PageCheckService } } $elementFiles = $this->referenceCounts('tt_content', $allElementUids); + $elementPlaceholders = $this->referenceCounts('tt_content', $allElementUids, true); $pageFiles = $this->referenceCounts('pages', $pageUids); + $pagePlaceholders = $this->referenceCounts('pages', $pageUids, true); $records = $this->records($cfg['recordTable'], $pageUids); - $recordFiles = $this->referenceCounts($cfg['recordTable'], array_map(static fn(array $r): int => (int)$r['uid'], $records)); + $recordUids = array_map(static fn(array $r): int => (int)$r['uid'], $records); + $recordFiles = $this->referenceCounts($cfg['recordTable'], $recordUids); + $recordPlaceholders = $this->referenceCounts($cfg['recordTable'], $recordUids, true); // Empty selection = what the testimonial band shows: quoted stories, // at most twelve (UsecaseListJsonRenderer::testimonialRows). $allStories = min(12, $this->countAll('tx_vitec_domain_model_usecase', "deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND TRIM(COALESCE(quote, '')) <> ''")); @@ -147,6 +166,7 @@ final class PageCheckService $keys = array_column(self::FIELDS, 'key'); $filled = array_fill_keys($keys, 0); + $placeholder = array_fill_keys($keys, 0); $applicable = array_fill_keys($keys, 0); $byType = []; $rows = []; @@ -158,7 +178,18 @@ final class PageCheckService $visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0)); $hiddenCount = count($els) - count($visible); $record = $records[$uid] ?? null; - $raw = $this->pageChecks($type, $visible, $elementFiles, $pageFiles[$uid] ?? [], $p, $record, $record ? ($recordFiles[(int)$record['uid']] ?? []) : [], $allStories, $allCustomers); + $recordUid = $record ? (int)$record['uid'] : 0; + $raw = $this->pageChecks( + $type, + $visible, + ['n' => $elementFiles, 'ph' => $elementPlaceholders], + ['n' => $pageFiles[$uid] ?? [], 'ph' => $pagePlaceholders[$uid] ?? []], + $p, + $record, + ['n' => $recordFiles[$recordUid] ?? [], 'ph' => $recordPlaceholders[$recordUid] ?? []], + $allStories, + $allCustomers + ); $checks = []; foreach (self::FIELDS as $field) { @@ -169,7 +200,9 @@ final class PageCheckService } $checks[$key] = $raw[$key]; $applicable[$key]++; - if ($raw[$key]['ok'] === true) { + if (($raw[$key]['placeholder'] ?? 0) > 0) { + $placeholder[$key]++; + } elseif ($raw[$key]['ok'] === true) { $filled[$key]++; } } @@ -181,7 +214,7 @@ final class PageCheckService 'scopeLabel' => $cfg['label'], 'generatedAt' => date(\DATE_ATOM), 'fields' => self::FIELDS, - 'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'applicable' => $applicable], + 'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'placeholder' => $placeholder, 'applicable' => $applicable], 'pages' => $rows, ]; } @@ -275,17 +308,19 @@ final class PageCheckService /** * @param array> $visible visible elements in page order - * @param array> $elementFiles [elementUid][fieldname] = count - * @param array $pageFiles [fieldname] = count + * @param array{n: array>, ph: array>} $elementFiles n/ph: [elementUid][fieldname] = count of all / of placeholder files + * @param array{n: array, ph: array} $pageFiles [fieldname] = count * @param array $page * @param array|null $record - * @param array $recordFiles - * @return array + * @param array{n: array, ph: array} $recordFiles + * @return array */ private function pageChecks(string $type, array $visible, array $elementFiles, array $pageFiles, array $page, ?array $record, array $recordFiles, int $allStories, int $allCustomers): array { $hero = $this->firstOf($visible, 'vitec_markethero') ?? $this->firstOf($visible, 'vitec_herosection'); - $heroFiles = $hero ? ($elementFiles[(int)$hero['uid']] ?? []) : []; + $heroFiles = $hero ? ($elementFiles['n'][(int)$hero['uid']] ?? []) : []; + $heroPh = $hero ? ($elementFiles['ph'][(int)$hero['uid']] ?? []) : []; + $heroFields = ['vitec_bgimage', 'vitec_hero_bgimage', 'vitec_hero_image']; $lead = null; foreach ($visible as $el) { if ($el['CType'] === 'vitec_introparagraph' && in_array((int)$el['colPos'], self::HERO_COLPOS, true)) { @@ -294,15 +329,21 @@ final class PageCheckService } } $intro = $this->firstOf($visible, 'vitec_marketintro'); - $introFiles = $intro ? ($elementFiles[(int)$intro['uid']] ?? []) : []; + $introFiles = $intro ? ($elementFiles['n'][(int)$intro['uid']] ?? []) : []; + $introPh = $intro ? ($elementFiles['ph'][(int)$intro['uid']] ?? []) : []; $cards = array_values(array_filter($visible, static fn(array $e): bool => $e['CType'] === 'vitec_card')); $cardVisuals = 0; + $cardPlaceholders = 0; foreach ($cards as $c) { - $f = $elementFiles[(int)$c['uid']] ?? []; + $f = $elementFiles['n'][(int)$c['uid']] ?? []; + $ph = $elementFiles['ph'][(int)$c['uid']] ?? []; if (($f['vitec_icon'] ?? 0) > 0 || ($f['vitec_image'] ?? 0) > 0) { $cardVisuals++; } + if (($ph['vitec_icon'] ?? 0) > 0 || ($ph['vitec_image'] ?? 0) > 0) { + $cardPlaceholders++; + } } $stories = $this->firstOf($visible, 'vitec_usecaselist'); @@ -327,7 +368,8 @@ final class PageCheckService } $insights = $this->firstOf($visible, 'vitec_marketinsights'); - $insightsFiles = $insights ? ($elementFiles[(int)$insights['uid']] ?? []) : []; + $insightsFiles = $insights ? ($elementFiles['n'][(int)$insights['uid']] ?? []) : []; + $insightsPh = $insights ? ($elementFiles['ph'][(int)$insights['uid']] ?? []) : []; $logos = $this->firstOf($visible, 'vitec_customerlogos'); $logoCount = 0; @@ -341,30 +383,33 @@ final class PageCheckService $recordFilled = 0; if ($record !== null) { - $recordFilled += (($recordFiles['image'] ?? 0) > 0) ? 1 : 0; + $recordFilled += (($recordFiles['n']['image'] ?? 0) > 0) ? 1 : 0; $recordFilled += $this->filled($record['teaser'] ?? '') ? 1 : 0; $recordFilled += $this->filled($record['description'] ?? '') ? 1 : 0; } + $sum = static fn(array $counts, array $fields): int => array_sum(array_map(static fn(string $f): int => (int)($counts[$f] ?? 0), $fields)); + return [ - 'hero' => $this->check(($heroFiles['vitec_bgimage'] ?? 0) + ($heroFiles['vitec_hero_bgimage'] ?? 0) + ($heroFiles['vitec_hero_image'] ?? 0)), + 'hero' => $this->check($sum($heroFiles, $heroFields), $sum($heroPh, $heroFields)), 'subline' => $this->check($hero && ($this->filled($hero['vitec_subline'] ?? '') || $this->filled($hero['subheader'] ?? '')) ? 1 : 0), 'lead' => $this->check($lead && $this->filled($lead['bodytext'] ?? '') ? 1 : 0), 'intro' => $this->check($intro && $this->filled($intro['vitec_intro_text'] ?? '') ? 1 : 0), - 'introImage' => $this->check($introFiles['vitec_image'] ?? 0), + 'introImage' => $this->check($introFiles['vitec_image'] ?? 0, $introPh['vitec_image'] ?? 0), 'introCta' => $this->check($intro && $this->filled($intro['vitec_learnmore_label'] ?? '') && $this->filled($intro['vitec_learnmore_target'] ?? '') ? 1 : 0), - 'cards' => $this->check(count($cards)) + ['icons' => $cardVisuals], + 'cards' => $this->check(count($cards), $cardPlaceholders) + ['icons' => $cardVisuals], 'stories' => ['ok' => $stories !== null, 'count' => $storyCount], 'products' => ['ok' => $hasProducts, 'count' => $productCount], 'insights' => $this->check($insights && $this->filled($insights['vitec_text1'] ?? '') ? 1 : 0), - 'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0), + 'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0, $insightsPh['vitec_video_cover'] ?? 0), 'insightsCta' => $this->check($insights && $this->filled($insights['vitec_cta_label'] ?? '') && $this->filled($insights['vitec_cta_target'] ?? '') ? 1 : 0), 'logos' => ['ok' => $logos !== null, 'count' => $logoCount], 'cta' => $this->check($cta && $this->filled($cta['vitec_primary_cta_label'] ?? '') ? 1 : 0), 'seo' => $this->check($this->filled($page['description'] ?? '') ? 1 : 0), - 'og' => $this->check($pageFiles['og_image'] ?? 0), - 'media' => $this->check($pageFiles['media'] ?? 0), - 'record' => ['ok' => $record !== null && ($recordFiles['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled], + 'og' => $this->check($pageFiles['n']['og_image'] ?? 0, $pageFiles['ph']['og_image'] ?? 0), + 'media' => $this->check($pageFiles['n']['media'] ?? 0, $pageFiles['ph']['media'] ?? 0), + 'record' => ['ok' => $record !== null && ($recordFiles['n']['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled] + + ((($recordFiles['ph']['image'] ?? 0) > 0) ? ['placeholder' => (int)$recordFiles['ph']['image']] : []), ]; } @@ -404,10 +449,13 @@ final class PageCheckService ]; } - /** @return array{ok: bool, count: int} */ - private function check(int $count): array + /** + * @param int $placeholder number of placeholder files among $count; emitted only when > 0 + * @return array{ok: bool, count: int, placeholder?: int} + */ + private function check(int $count, int $placeholder = 0): array { - return ['ok' => $count > 0, 'count' => $count]; + return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []); } private function filled(mixed $value): bool @@ -526,17 +574,20 @@ final class PageCheckService * @param int[] $uids * @return array> [uid][fieldname] = count */ - private function referenceCounts(string $table, array $uids): array + private function referenceCounts(string $table, array $uids, bool $placeholdersOnly = false): array { if ($uids === []) { return []; } - $rows = $this->connection('sys_file_reference')->fetchAllAssociative( - 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference - WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . implode(',', array_map('intval', $uids)) . ') - GROUP BY uid_foreign, fieldname', - [$table] - ); + $in = implode(',', array_map('intval', $uids)); + $sql = $placeholdersOnly + ? 'SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n 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 = ? AND r.uid_foreign IN (' . $in . ') AND ' . self::PLACEHOLDER_SQL . ' + GROUP BY r.uid_foreign, r.fieldname' + : 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . $in . ') + GROUP BY uid_foreign, fieldname'; + $rows = $this->connection('sys_file_reference')->fetchAllAssociative($sql, [$table]); $out = []; foreach ($rows as $r) { $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; @@ -548,7 +599,7 @@ final class PageCheckService * Files of one record with original URL and a ~480 px thumbnail. * * @param string[]|null $fieldnames null = all fields - * @return array + * @return array */ private function files(string $table, int $uid, ?array $fieldnames = null): array { @@ -570,6 +621,7 @@ final class PageCheckService 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), 'name' => (string)$r['name'], 'mime' => (string)$r['mime_type'], + 'placeholder' => self::isPlaceholder((string)$r['identifier'], (string)$r['name']), ]; } return $out; diff --git a/packages/vitec/Classes/Service/ProductCheckService.php b/packages/vitec/Classes/Service/ProductCheckService.php index cca8b4d..9468e0c 100644 --- a/packages/vitec/Classes/Service/ProductCheckService.php +++ b/packages/vitec/Classes/Service/ProductCheckService.php @@ -24,6 +24,13 @@ use TYPO3\CMS\Extbase\Service\ImageService; * detail(): one product with all texts (RTE HTML as stored), images * (original URL + ~480 px thumbnail), videos, feature cards * with icons, resources, datasheets, solutions. + * + * Placeholders (Harry 2026-11-13, same rule as PageCheckService): files below + * fileadmin/placeholders/ or named "...placeholder..." do not pass as + * finished. Image cells carry `placeholder` = number of such files (only when + * > 0), summary.filled counts only cells without placeholders and + * summary.placeholder the cells that have one; detail images carry + * `placeholder: true|false`. */ final class ProductCheckService { @@ -84,20 +91,23 @@ final class ProductCheckService $products = $this->products(); $uids = array_map(static fn(array $p): int => (int)$p['uid'], $products); $files = $this->fileCounts($uids); + $placeholders = $this->fileCounts($uids, true); $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); + $placeholder = array_fill_keys(array_column(self::FIELDS, 'key'), 0); $rows = []; foreach ($products as $p) { $uid = (int)$p['uid']; $f = $files[$uid] ?? []; + $ph = $placeholders[$uid] ?? []; $checks = [ - 'hero' => $this->check($f['heroimage'] ?? 0), - 'photos' => $this->check($f['productimage'] ?? 0), - 'og' => $this->check($f['ogimage'] ?? 0), + 'hero' => $this->check($f['heroimage'] ?? 0, $ph['heroimage'] ?? 0), + 'photos' => $this->check($f['productimage'] ?? 0, $ph['productimage'] ?? 0), + 'og' => $this->check($f['ogimage'] ?? 0, $ph['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), @@ -112,7 +122,9 @@ final class ProductCheckService 'solutions' => $this->check($solutions[$uid] ?? 0), ]; foreach ($checks as $key => $c) { - if ($c['ok']) { + if (($c['placeholder'] ?? 0) > 0) { + $placeholder[$key]++; + } elseif ($c['ok']) { $filled[$key]++; } } @@ -122,7 +134,7 @@ final class ProductCheckService return [ 'generatedAt' => date(\DATE_ATOM), 'fields' => self::FIELDS, - 'summary' => ['products' => count($rows), 'filled' => $filled], + 'summary' => ['products' => count($rows), 'filled' => $filled, 'placeholder' => $placeholder], 'products' => $rows, ]; } @@ -191,9 +203,9 @@ final class ProductCheckService } /** @return array{ok: bool, count: int} */ - private function check(int $count): array + private function check(int $count, int $placeholder = 0): array { - return ['ok' => $count > 0, 'count' => $count]; + return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []); } private function filled(mixed $value): bool @@ -218,18 +230,24 @@ final class ProductCheckService * @param int[] $uids * @return array> [productUid][fieldname] = count */ - private function fileCounts(array $uids): array + private function fileCounts(array $uids, bool $placeholdersOnly = false): 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(); + $in = implode(',', array_map('intval', $uids)); + $sql = $placeholdersOnly + ? "SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n 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 IN (" . $in . ') AND ' . PageCheckService::PLACEHOLDER_SQL . ' + GROUP BY r.uid_foreign, r.fieldname' + : "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 (" . $in . ') + GROUP BY uid_foreign, fieldname'; + $rows = $this->connection()->executeQuery($sql)->fetchAllAssociative(); $out = []; foreach ($rows as $r) { $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n']; @@ -241,7 +259,7 @@ final class ProductCheckService * Files of one product with original URL and a ~480 px thumbnail * (SVG and non-images keep the original as thumbnail). * - * @return array> + * @return array> */ private function files(int $uid): array { @@ -261,6 +279,7 @@ final class ProductCheckService 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url), 'name' => (string)$r['name'], 'mime' => (string)$r['mime_type'], + 'placeholder' => PageCheckService::isPlaceholder((string)$r['identifier'], (string)$r['name']), ]; } return $out;