Compare commits
53
Commits
2c0da281bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b08df283 | ||
|
|
20bcbeb731 | ||
|
|
47c8e2902f | ||
|
|
6a00926b89 | ||
|
|
4f47382679 | ||
|
|
8031247acf | ||
|
|
e14f94ba63 | ||
|
|
06b9bc8293 | ||
|
|
4bc38b1760 | ||
|
|
b36dceb486 | ||
|
|
90cb2faaf9 | ||
|
|
994f9f2a1b | ||
|
|
62f3f5394e | ||
|
|
cda26947f2 | ||
|
|
1b72e65a1d | ||
|
|
67ded6e502 | ||
|
|
a9923e19e0 | ||
|
|
8cdcd99eb3 | ||
|
|
086a178341 | ||
|
|
07f74e372b | ||
|
|
5824981e2b | ||
|
|
da2ca2690d | ||
|
|
f4f18e913a | ||
|
|
2aa8865b23 | ||
|
|
e357f42b81 | ||
|
|
4b6731786d | ||
|
|
f7b1eacecb | ||
|
|
37c4c116d0 | ||
|
|
08c02276e5 | ||
|
|
33bdb8d856 | ||
|
|
8a652063c5 | ||
|
|
5121eaa000 | ||
|
|
ff6f86b829 | ||
|
|
be4bb44c40 | ||
|
|
dc9b5fce47 | ||
|
|
5c640d02f7 | ||
|
|
c9b00e6f10 | ||
|
|
be163a88c2 | ||
|
|
8f8ae039d4 | ||
|
|
cb53ff67ee | ||
|
|
bc2e031b0c | ||
|
|
b9736b9ac7 | ||
|
|
be519f9bc1 | ||
|
|
5b524bfce9 | ||
|
|
fa63871da7 | ||
|
|
91c2ceb5b1 | ||
|
|
63f950f9db | ||
|
|
d52d49974f | ||
|
|
d153cc2eba | ||
|
|
232aaf2ca8 | ||
|
|
351349ec1f | ||
|
|
dcd713f222 | ||
|
|
14623ed4fa |
No files matched your search
+7
-2
@@ -9,8 +9,10 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/config/system/settings.php
|
# The whole directory, not just the two known files: editor backups like
|
||||||
/config/system/additional.php
|
# additional.php.save would otherwise slip into a careless `git add -A`
|
||||||
|
# (settings.php holds credentials).
|
||||||
|
/config/system/
|
||||||
auth.json
|
auth.json
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
@@ -142,3 +144,6 @@ migrations/product_categories_xlsx_in/
|
|||||||
# generated internal report (php migrations/build_product_audit.php)
|
# generated internal report (php migrations/build_product_audit.php)
|
||||||
public/product-audit/
|
public/product-audit/
|
||||||
migrations/datapath_stories_in/
|
migrations/datapath_stories_in/
|
||||||
|
|
||||||
|
migrations/product_overviews_xlsx_in/
|
||||||
|
migrations/product_images_pending/
|
||||||
@@ -168,7 +168,3 @@ bash fix-asset-perms.sh # fix asset permission
|
|||||||
[GPL‑2.0‑or‑later](LICENSE) — © evomedien.
|
[GPL‑2.0‑or‑later](LICENSE) — © evomedien.
|
||||||
|
|
||||||
<div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div>
|
<div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div>
|
||||||
|
|
||||||
|
|
||||||
vitecdev
|
|
||||||
17ZJfy75sJoU68J6
|
|
||||||
@@ -41,6 +41,23 @@ languages:
|
|||||||
solr_core_read: core_en
|
solr_core_read: core_en
|
||||||
rootPageId: 1
|
rootPageId: 1
|
||||||
routeEnhancers:
|
routeEnhancers:
|
||||||
|
# Pretty, resolvable URLs for ALL XML-sitemap parts. The typo3/seo-sitemap
|
||||||
|
# set maps only "pages"; links to the other parts were generated as
|
||||||
|
# ?sitemap=<name> query URLs that the renderer cannot resolve (it reads
|
||||||
|
# tx_seo[sitemap]) - the index then returned itself. SEO audit 2026-11-09.
|
||||||
|
Sitemap:
|
||||||
|
type: Simple
|
||||||
|
routePath: 'sitemap-type/{sitemap}'
|
||||||
|
aspects:
|
||||||
|
sitemap:
|
||||||
|
type: StaticValueMapper
|
||||||
|
map:
|
||||||
|
pages: pages
|
||||||
|
news: news
|
||||||
|
products: products
|
||||||
|
stories: stories
|
||||||
|
_arguments:
|
||||||
|
sitemap: 'tx_seo/sitemap'
|
||||||
ProductPlugin:
|
ProductPlugin:
|
||||||
type: Extbase
|
type: Extbase
|
||||||
limitToPages:
|
limitToPages:
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
vitec.debugMode: true
|
vitec.debugMode: true
|
||||||
my.example.setting: Test
|
|
||||||
menu.footer.pageUids: '1,2,3'
|
menu.footer.pageUids: '1,2,3'
|
||||||
menu.meta.pageUids: '5'
|
menu.meta.pageUids: '5'
|
||||||
megamenu.contentUid: 10366
|
megamenu.contentUid: 10366
|
||||||
vitec.storyDetailPid: 26
|
vitec.storyDetailPid: 26
|
||||||
felogin.pid: 520
|
felogin.pid: '520'
|
||||||
# felogin v14 und EXT:headless belegen beide templateRootPaths.10 - je nach
|
|
||||||
# Set-Reihenfolge gewinnt das HTML-Template. Das Setting speist genau diesen
|
|
||||||
# Index, darum hier fest auf die Headless-JSON-Templates zeigen:
|
|
||||||
felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/'
|
felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/'
|
||||||
felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/'
|
felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/'
|
||||||
felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/'
|
felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/'
|
||||||
|
seo.site.canonicalBase: 'https://www.vitec.com'
|
||||||
|
seo.site.name: VITEC
|
||||||
|
seo.organization.name: VITEC
|
||||||
|
seo.organization.legalName: 'VITEC SA'
|
||||||
|
seo.organization.logoUrl: /fileadmin/vitec-brand/vitec-logo.png
|
||||||
|
seo.organization.sameAs: 'https://www.linkedin.com/company/vitec-multimedia/, https://www.youtube.com/user/vitecmm'
|
||||||
|
seo.organization.phone: '+33 1 46 73 06 06'
|
||||||
|
seo.organization.street: '99 rue Pierre Sémard'
|
||||||
|
seo.organization.postalCode: '92320'
|
||||||
|
seo.organization.locality: Châtillon
|
||||||
|
seo.organization.country: FR
|
||||||
|
vitec.ssr.enabled: true
|
||||||
|
|
||||||
|
# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap
|
||||||
|
# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09.
|
||||||
|
seo.sitemap.pages.excludePagesRecursive: '10,21,26,106'
|
||||||
|
vitec.ssr.alertEmails:
|
||||||
|
- info@evomedien.de
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Puts the vitec_productcheck content element on the page /products/product-check
|
||||||
|
// (uid 607) once. Idempotent: a second run reports the existing element.
|
||||||
|
// Run on dedi: php migrations/add_product_check_element_20261112.php
|
||||||
|
error_reporting(E_ALL); ini_set('display_errors', '1');
|
||||||
|
$pageUid = 607;
|
||||||
|
$root = null; $dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); }
|
||||||
|
if ($root === null) { exit("no root\n"); }
|
||||||
|
$db = (require $root . '/config/system/settings.php')['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||||
|
|
||||||
|
$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch();
|
||||||
|
if ($page === false) { exit("page $pageUid not found\n"); }
|
||||||
|
echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n";
|
||||||
|
|
||||||
|
$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch();
|
||||||
|
if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); }
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted)
|
||||||
|
VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)');
|
||||||
|
$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']);
|
||||||
|
echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// READ-ONLY: hero references + sys_file rows of the three products that got a banner on 2026-11-10
|
||||||
|
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||||
|
foreach ([25, 24, 22] as $uid) {
|
||||||
|
$p = $pdo->query("SELECT uid, slug FROM tx_vitec_domain_model_product WHERE uid = $uid")->fetch();
|
||||||
|
echo "\n=== #{$p['uid']} {$p['slug']}\n";
|
||||||
|
foreach ($pdo->query("SELECT r.uid ref, r.uid_local, r.hidden, f.identifier, f.tstamp FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'heroimage' AND r.uid_foreign = $uid ORDER BY r.sorting_foreign") as $r) {
|
||||||
|
echo " heroimage ref#{$r['ref']} file#{$r['uid_local']} {$r['identifier']} (file tstamp " . date('Y-m-d H:i', (int)$r['tstamp']) . ")" . ($r['hidden'] ? ' HIDDEN' : '') . "\n";
|
||||||
|
}
|
||||||
|
foreach ($pdo->query("SELECT uid, identifier, tstamp FROM sys_file WHERE identifier LIKE '/product_import/{$p['slug']}/%'") as $f) {
|
||||||
|
echo " sys_file#{$f['uid']} {$f['identifier']} (" . date('Y-m-d H:i', (int)$f['tstamp']) . ")\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n=== feature icons prism/sff ===\n";
|
||||||
|
foreach ($pdo->query("SELECT f.product, f.title, (SELECT COUNT(*) FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.uid_foreign = f.uid) icons FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product IN (29, 30) ORDER BY f.product, f.sorting") as $r) {
|
||||||
|
echo " #{$r['product']} \"{$r['title']}\" icons={$r['icons']}\n";
|
||||||
|
}
|
||||||
|
echo "DONE\n";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnose (2026-11-10): state of the 20 product overview pages before the
|
||||||
|
* "Enriched" workbook import, plus product slugs / solution pages needed for
|
||||||
|
* banner, icon and solution-card mapping. READ-ONLY, plain PDO.
|
||||||
|
*
|
||||||
|
* php migrations/check_overview_pages_20261110.php > migrations/check_overview_out.txt
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
$slugs = ['avedia', 'ez-tv', 'x-player-end-points', 'x-view-integrated-displays', 'aetria', 'operator-workstations',
|
||||||
|
'appliances', 'modular-systems', 'rugged-appliances', 'avedia-modular-system', 'channellink-ip-gateway',
|
||||||
|
'prism-transcoder', 'aligo', 'vsn-series-video-wall-controllers', 'video-wall-management-software',
|
||||||
|
'vms-plugins-and-integrations', 'x-series-multi-display-processors', 'image-graphics-cards',
|
||||||
|
'vision-video-capture-cards', 'activesqx'];
|
||||||
|
|
||||||
|
$ce = $pdo->prepare('SELECT uid, colPos, CType, header, header_layout, vitec_layout_variant, vitec_primary_cta, vitec_primary_cta_label, tx_container_parent, sorting, hidden, LENGTH(bodytext) blen, LEFT(bodytext, 90) bshort FROM tt_content WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY colPos, sorting');
|
||||||
|
$kids = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_container_parent = ? AND CType = 'vitec_card'");
|
||||||
|
foreach ($slugs as $slug) {
|
||||||
|
$p = $pdo->prepare('SELECT uid, title, nav_title, layout, seo_title, description, keywords, abstract FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
|
||||||
|
$p->execute(['/products/' . $slug]);
|
||||||
|
$page = $p->fetch();
|
||||||
|
echo "\n=== /products/$slug ";
|
||||||
|
if (!$page) { echo "NOT FOUND\n"; continue; }
|
||||||
|
printf("#%d \"%s\" nav=\"%s\" layout=%s\n seo_title=%s | desc=%s | kw=%s\n", $page['uid'], $page['title'], $page['nav_title'], $page['layout'], $page['seo_title'], mb_substr((string)$page['description'], 0, 80), $page['keywords']);
|
||||||
|
$ce->execute([$page['uid']]);
|
||||||
|
foreach ($ce->fetchAll() as $r) {
|
||||||
|
$n = '';
|
||||||
|
if (str_starts_with($r['CType'], 'vitec_cols')) { $kids->execute([$r['uid']]); $n = ' cards=' . $kids->fetchColumn(); }
|
||||||
|
if ((int)$r['tx_container_parent'] > 0 && $r['CType'] === 'vitec_card') { continue; }
|
||||||
|
printf(" col%-3s s%-6d #%-6d %-22s %-12s %-10s \"%s\" len=%d%s %s%s\n", $r['colPos'], $r['sorting'], $r['uid'], $r['CType'], $r['vitec_layout_variant'], $r['vitec_primary_cta_label'], $r['header'], $r['blen'], $n, (int)$r['tx_container_parent'] > 0 ? ' IN#' . $r['tx_container_parent'] : '', $r['hidden'] ? ' HIDDEN' : '');
|
||||||
|
if ($r['blen'] > 0 && !str_starts_with($r['CType'], 'vitec_cols')) { echo " > " . str_replace("\n", ' ', (string)$r['bshort']) . "\n"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== products (slug | title) matching banner/icon deliveries ===\n";
|
||||||
|
foreach ($pdo->query("SELECT uid, slug, title, hidden FROM tx_vitec_domain_model_product WHERE deleted = 0 AND (slug LIKE '%mges%' OR slug LIKE '%ace%' OR slug LIKE '%chassis%' OR slug LIKE '%prism%' OR slug LIKE '%diamond%' OR slug LIKE '%avedia%') ORDER BY slug") as $r) {
|
||||||
|
printf(" #%-4d %-45s %s%s\n", $r['uid'], $r['slug'], $r['title'], $r['hidden'] ? ' (hidden)' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== pages under /solutions (2 levels) ===\n";
|
||||||
|
$root = $pdo->query("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'")->fetchColumn();
|
||||||
|
$q = $pdo->prepare('SELECT uid, title, slug, doktype, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
|
||||||
|
$q->execute([$root]);
|
||||||
|
foreach ($q->fetchAll() as $g) {
|
||||||
|
printf(" #%-4d %-55s %s%s\n", $g['uid'], $g['title'], $g['slug'], $g['hidden'] ? ' (hidden)' : '');
|
||||||
|
$q2 = $pdo->prepare('SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
|
||||||
|
$q2->execute([$g['uid']]);
|
||||||
|
foreach ($q2->fetchAll() as $s) {
|
||||||
|
printf(" #%-4d %-55s %s%s\n", $s['uid'], $s['title'], $s['slug'], $s['hidden'] ? ' (hidden)' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== products with feature cards (count) for prism/sff/avedia chassis ===\n";
|
||||||
|
foreach ($pdo->query("SELECT p.uid, p.slug, (SELECT COUNT(*) FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product = p.uid) n FROM tx_vitec_domain_model_product p WHERE p.deleted = 0 AND (p.slug LIKE '%prism%' OR p.slug LIKE '%chassis%')") as $r) {
|
||||||
|
printf(" #%-4d %-45s features=%d\n", $r['uid'], $r['slug'], $r['n']);
|
||||||
|
}
|
||||||
|
echo "\nDONE\n";
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixes from the second Seobility crawl of 2026-11-10 (after the duplicate clean-up).
|
||||||
|
*
|
||||||
|
* A. Card CTAs pointing at hidden/deleted product URLs (internal 301 hops):
|
||||||
|
* ArtioSign/ArtioView cards -> /products/aetria (and the two redirects
|
||||||
|
* follow), X-View self-link cleared, chassis card -> surviving product.
|
||||||
|
* B. News shells still carrying the InfoComm import placeholder text:
|
||||||
|
* #6/#7/#5 are duplicates of real articles (#17/#18/#16) -> deleted + 301;
|
||||||
|
* #4 (EKOM) gets its text from the old /en/news page. #551 is a byte-
|
||||||
|
* identical copy of #550 under a title that does not match the text -> 301.
|
||||||
|
* C. Three typos.
|
||||||
|
* D. Dead vitec.com links in old articles: "https://www.vitec.com/ ",
|
||||||
|
* trailing-dot host, /industries, /green-initiative, two dead PDFs ->
|
||||||
|
* internal targets.
|
||||||
|
* E. SEO titles for the short top-level pages (/solutions "Solutions | VITEC" etc.).
|
||||||
|
* F. Meta descriptions for the nine category pages whose generated text was
|
||||||
|
* under 60 characters ("VITEC Avedia: Avedia Platform.").
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/crawl2_fixes_20261110.php
|
||||||
|
* Write: php migrations/crawl2_fixes_20261110.php --apply (--only=A,B / --skip=F)
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
$only = []; $skip = [];
|
||||||
|
foreach ($argv as $arg) {
|
||||||
|
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
|
||||||
|
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
|
||||||
|
}
|
||||||
|
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
$now = time();
|
||||||
|
$purgePaths = [];
|
||||||
|
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
|
||||||
|
|
||||||
|
// --- helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$addRedirect = static function (string $source, string $target) use ($pdo, $hasRedirect, $insertRedirect, $apply, &$purgePaths): void {
|
||||||
|
$purgePaths[$source] = true;
|
||||||
|
$hasRedirect->execute([$source]);
|
||||||
|
$existing = $hasRedirect->fetch();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing['target'] === $target) { return; }
|
||||||
|
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$existing['uid']]); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo " redirect $source -> $target\n";
|
||||||
|
if ($apply) { $insertRedirect->execute([$source, $target]); }
|
||||||
|
};
|
||||||
|
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
|
||||||
|
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
|
||||||
|
$st->execute([$oldTarget]);
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
|
||||||
|
echo " loeschen $table #$uid\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
|
||||||
|
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$newsRow = static function (int $uid) use ($pdo): ?array {
|
||||||
|
$st = $pdo->prepare("SELECT uid, title, path_segment, type, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
|
||||||
|
$st->execute([$uid]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r ?: null;
|
||||||
|
};
|
||||||
|
$updateField = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
|
||||||
|
};
|
||||||
|
// Replace patterns in a text field of every matching row; prints each change.
|
||||||
|
$replaceIn = static function (string $table, string $field, array $pairs, string $label) use ($pdo, $apply, $now, &$purgePaths): int {
|
||||||
|
// pair = [needle, replacement, isRegex, likeNeedle (plain text for the SQL prefilter; required for regex pairs)]
|
||||||
|
$like = implode(' OR ', array_fill(0, count($pairs), "$field LIKE ?"));
|
||||||
|
$params = array_map(fn($p) => '%' . ($p[3] ?? $p[0]) . '%', $pairs);
|
||||||
|
$extra = $table === 'tx_news_domain_model_news' ? ', path_segment AS ref' : ($table === 'tt_content' ? ', pid AS ref' : ', uid AS ref');
|
||||||
|
$st = $pdo->prepare("SELECT uid, $field AS v $extra FROM $table WHERE deleted = 0 AND ($like)");
|
||||||
|
$st->execute($params);
|
||||||
|
$n = 0;
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$new = (string)$r['v'];
|
||||||
|
foreach ($pairs as [$needle, $replacement, $isRegex]) {
|
||||||
|
$new = $isRegex ? (string)preg_replace($needle, $replacement, $new) : str_replace($needle, $replacement, $new);
|
||||||
|
}
|
||||||
|
if ($new === (string)$r['v']) { continue; }
|
||||||
|
$n++;
|
||||||
|
echo " $label: $table #{$r['uid']} ({$r['ref']})\n";
|
||||||
|
if ($table === 'tx_news_domain_model_news') { $purgePaths['/news/article/' . $r['ref']] = true; }
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]); }
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- A. card CTAs onto hidden/deleted products -----------------------------------
|
||||||
|
|
||||||
|
if ($runs('A')) {
|
||||||
|
echo "\nA. Karten-Buttons auf versteckte/geloeschte Produkt-URLs\n";
|
||||||
|
$ctas = [
|
||||||
|
11744 => ['/product/artiosign', '/products/aetria'],
|
||||||
|
11745 => ['/product/artioview', '/products/aetria'],
|
||||||
|
11649 => ['/product/artiosign', '/products/aetria'],
|
||||||
|
11650 => ['/product/artioview', '/products/aetria'],
|
||||||
|
12179 => ['/product/avedia-modular-chassis-c1101-c1103-c1210-1', '/product/avedia-modular-chassis-c1101-c1103-c1210'],
|
||||||
|
];
|
||||||
|
$ce = $pdo->prepare("SELECT uid, pid, header, vitec_primary_cta, vitec_primary_cta_label FROM tt_content WHERE deleted = 0 AND uid = ?");
|
||||||
|
foreach ($ctas as $uid => [$old, $new]) {
|
||||||
|
$ce->execute([$uid]); $c = $ce->fetch();
|
||||||
|
if (!$c) { continue; }
|
||||||
|
if (trim((string)$c['vitec_primary_cta']) !== $old) { echo " CE #$uid: CTA ist '{$c['vitec_primary_cta']}', erwartet '$old' - uebersprungen\n"; continue; }
|
||||||
|
echo " CE #$uid (Seite {$c['pid']}, \"{$c['header']}\"): $old -> $new\n";
|
||||||
|
$updateField('tt_content', $uid, 'vitec_primary_cta', $new);
|
||||||
|
$pageSlug = $pdo->query("SELECT slug FROM pages WHERE uid = {$c['pid']}")->fetchColumn();
|
||||||
|
if ($pageSlug) { $purgePaths[$pageSlug] = true; }
|
||||||
|
}
|
||||||
|
// X-View card links to the page it sits on: no button.
|
||||||
|
$ce->execute([12133]); $c = $ce->fetch();
|
||||||
|
if ($c && trim((string)$c['vitec_primary_cta']) === '/product/x-view-integrated-displays') {
|
||||||
|
echo " CE #12133 (\"{$c['header']}\"): Selbst-Link entfernt (Button weg)\n";
|
||||||
|
$updateField('tt_content', 12133, 'vitec_primary_cta', '');
|
||||||
|
$updateField('tt_content', 12133, 'vitec_primary_cta_label', '');
|
||||||
|
$purgePaths['/products/x-view-integrated-displays'] = true;
|
||||||
|
}
|
||||||
|
// The two product redirects follow the cards.
|
||||||
|
$addRedirect('/product/artiosign', '/products/aetria');
|
||||||
|
$addRedirect('/product/artioview', '/products/aetria');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B. placeholder news shells -----------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('B')) {
|
||||||
|
echo "\nB. Platzhalter-News (InfoComm-Importtext)\n";
|
||||||
|
$placeholderMarker = 'VITEC will demonstrate its latest enterprise video technologies at InfoComm 2026';
|
||||||
|
$isPlaceholder = static function (int $uid) use ($pdo, $placeholderMarker): bool {
|
||||||
|
$b = (string)$pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = $uid")->fetchColumn();
|
||||||
|
return strlen($b) === 1308 && str_contains($b, $placeholderMarker);
|
||||||
|
};
|
||||||
|
// duplicate shell => real article
|
||||||
|
foreach ([6 => 17, 7 => 18, 5 => 16] as $shell => $real) {
|
||||||
|
$s = $newsRow($shell); $r = $newsRow($real);
|
||||||
|
if (!$s || !$r) { echo " #$shell/#$real fehlt, uebersprungen\n"; continue; }
|
||||||
|
if (!$isPlaceholder($shell)) { echo " #$shell ist kein Platzhalter mehr, uebersprungen\n"; continue; }
|
||||||
|
echo " #$shell \"{$s['title']}\" (Platzhalter) -> #$real \"{$r['title']}\" ({$r['bl']} Zeichen)\n";
|
||||||
|
$softDelete('tx_news_domain_model_news', $shell);
|
||||||
|
$addRedirect('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
|
||||||
|
$retarget('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
|
||||||
|
$purgePaths['/news/article/' . $r['path_segment']] = true;
|
||||||
|
}
|
||||||
|
// EKOM: text lives on the old page
|
||||||
|
$s = $newsRow(4);
|
||||||
|
if ($s && $isPlaceholder(4)) {
|
||||||
|
$page = $pdo->query("SELECT uid, slug FROM pages WHERE deleted = 0 AND slug LIKE '%/news/vitec-partners-with-ekom%' ORDER BY uid LIMIT 1")->fetch();
|
||||||
|
$html = '';
|
||||||
|
if ($page) {
|
||||||
|
foreach ($pdo->query("SELECT CType, bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$page['uid']} AND CType IN ('text','textmedia','html') AND bodytext <> '' ORDER BY sorting") as $c) {
|
||||||
|
$part = (string)$c['bodytext'];
|
||||||
|
if (str_contains($part, 'vitec.com/ ')) { $part = str_replace('https://www.vitec.com/ ', '<a href="/">www.vitec.com</a>', $part); }
|
||||||
|
$html .= $part . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$len = mb_strlen($plain($html));
|
||||||
|
if ($len >= 500) {
|
||||||
|
echo " #4 \"{$s['title']}\": Text von Seite #{$page['uid']} ({$page['slug']}) uebernommen, $len Zeichen\n";
|
||||||
|
$updateField('tx_news_domain_model_news', 4, 'bodytext', trim($html));
|
||||||
|
$purgePaths['/news/article/' . $s['path_segment']] = true;
|
||||||
|
} else {
|
||||||
|
echo " #4: keine alte Seite mit Text gefunden ($len Zeichen) - bleibt\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// byte-identical Datapath pair: keep the one whose title matches the text
|
||||||
|
$a = $newsRow(551); $b = $newsRow(550);
|
||||||
|
if ($a && $b) {
|
||||||
|
$ba = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 551")->fetchColumn();
|
||||||
|
$bb = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 550")->fetchColumn();
|
||||||
|
if ($ba === $bb) {
|
||||||
|
echo " #551 \"{$a['title']}\" = Textkopie von #550 \"{$b['title']}\" -> weg\n";
|
||||||
|
$softDelete('tx_news_domain_model_news', 551);
|
||||||
|
$addRedirect('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
|
||||||
|
$retarget('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C. typos ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('C')) {
|
||||||
|
echo "\nC. Tippfehler\n";
|
||||||
|
$replaceIn('tx_news_domain_model_news', 'bodytext', [
|
||||||
|
['/\bsponser\b/', 'sponsor', true, 'sponser'],
|
||||||
|
['/\bSponser\b/', 'Sponsor', true, 'Sponser'],
|
||||||
|
['/\breconnaisance\b/', 'reconnaissance', true, 'reconnaisance'],
|
||||||
|
['/\bReconnaisance\b/', 'Reconnaissance', true, 'Reconnaisance'],
|
||||||
|
['/\bauxillary\b/', 'auxiliary', true, 'auxillary'],
|
||||||
|
['/\bAuxillary\b/', 'Auxiliary', true, 'Auxillary'],
|
||||||
|
], 'Tippfehler');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- D. dead vitec.com links -----------------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('D')) {
|
||||||
|
echo "\nD. Tote vitec.com-Links\n";
|
||||||
|
$pairs = [
|
||||||
|
['https://www.vitec.com/ ', '<a href="/">www.vitec.com</a>', false],
|
||||||
|
['~https?://www\.vitec\.com\.(?=[/"#\s<])/?~', '/', true, 'www.vitec.com.'],
|
||||||
|
['https://www.vitec.com/green-initiative', '/insights/sustainability', false],
|
||||||
|
['https://www.vitec.com/industries/military/government', '/markets/defense-and-security', false],
|
||||||
|
['https://www.vitec.com/industries', '/markets', false],
|
||||||
|
['~https?://www\.vitec\.com/fileadmin/downloads/Company_Downloads/Use_Cases/Northwestern_University_USE_CASE_WebVersion\.pdf~', '/success-stories/northwestern-university', true, 'Northwestern_University_USE_CASE'],
|
||||||
|
['~https?://datapathdocuments\.co\.uk/wp-content/uploads/Datapath-Fx4_datasheet_EN\.pdf~', '/product/fx4', true, 'Datapath-Fx4_datasheet'],
|
||||||
|
];
|
||||||
|
$n = $replaceIn('tx_news_domain_model_news', 'bodytext', $pairs, 'Link');
|
||||||
|
$n += $replaceIn('tt_content', 'bodytext', $pairs, 'Link');
|
||||||
|
echo " $n Datensaetze\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E. SEO titles for short top-level pages -------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('E')) {
|
||||||
|
echo "\nE. Seitentitel der Hauptseiten\n";
|
||||||
|
$titles = [
|
||||||
|
'/solutions' => 'Video Solutions for Control Rooms, Streaming & Signage',
|
||||||
|
'/products' => 'Products: IPTV, Video Streaming, Video Walls & Capture',
|
||||||
|
'/support' => 'Support, Training & Product Resources',
|
||||||
|
'/datasheets' => 'Datasheets: Encoders, IPTV, Video Walls & Capture Cards',
|
||||||
|
'/contact' => 'Contact VITEC: Sales, Support & Offices Worldwide',
|
||||||
|
'/contact/locations' => 'VITEC Offices & Locations Worldwide',
|
||||||
|
'/success-stories' => 'Success Stories from Broadcast, Defense & Enterprise',
|
||||||
|
];
|
||||||
|
$st = $pdo->prepare("SELECT uid, title, seo_title FROM pages WHERE deleted = 0 AND slug = ?");
|
||||||
|
foreach ($titles as $slug => $seo) {
|
||||||
|
$st->execute([$slug]); $p = $st->fetch();
|
||||||
|
if (!$p) { continue; }
|
||||||
|
if ($p['seo_title'] === $seo) { continue; }
|
||||||
|
printf(" %-20s \"%s\" -> \"%s\"\n", $slug, $p['seo_title'], $seo);
|
||||||
|
$updateField('pages', (int)$p['uid'], 'seo_title', $seo);
|
||||||
|
$purgePaths[$slug] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- F. descriptions for the thinnest category pages -------------------------------------
|
||||||
|
|
||||||
|
if ($runs('F')) {
|
||||||
|
echo "\nF. Meta-Descriptions der kuerzesten Kategorieseiten\n";
|
||||||
|
$desc = [
|
||||||
|
'/products/ez-tv' => 'EZ TV Platform by VITEC: enterprise IPTV and digital signage for live TV, video distribution and targeted content across screens and devices.',
|
||||||
|
'/products/activesqx' => 'ActiveSQX2 by VITEC and Datapath: IP stream decoding card that brings network video sources onto video walls. Specifications, features and datasheets.',
|
||||||
|
'/products/avedia' => 'Avedia Platform by VITEC: one platform for IP video, digital signage and interactive experiences across enterprise networks.',
|
||||||
|
'/products/rf-gateway' => 'VITEC RF gateways for the Avedia Modular System: receive satellite, cable and terrestrial TV and distribute it as IPTV across the network.',
|
||||||
|
'/products/prism-transcoder' => 'PRISM and PRISM SFF by VITEC: broadcast-grade video transcoders for converting, scaling and re-streaming live video, including a compact small-form-factor model.',
|
||||||
|
'/products/channellink-ip-gateway' => 'ChannelLink IP Gateway by VITEC: brings IP video streams into IPTV and digital signage networks, with transcoding for reliable delivery to every screen.',
|
||||||
|
'/products/aligo' => 'Aligo by VITEC: AV over IP and KVM encoders, receivers and workstations (QTX100, TX100, RX100, TX100 SQX) for control rooms and multi-display environments.',
|
||||||
|
'/products/vms-plugins-and-integrations' => 'VMS plugins and integrations for VITEC and Datapath video walls: bring Genetec and Milestone video management systems onto operator displays.',
|
||||||
|
'/products/image-graphics-cards' => 'Datapath Image graphics cards from VITEC (Image 2K, Image 4K, IQS4): multi-output graphics for video walls and multi-display workstations.',
|
||||||
|
];
|
||||||
|
$st = $pdo->prepare("SELECT uid, description FROM pages WHERE deleted = 0 AND slug = ?");
|
||||||
|
foreach ($desc as $slug => $d) {
|
||||||
|
$st->execute([$slug]); $p = $st->fetch();
|
||||||
|
if (!$p || $p['description'] === $d) { continue; }
|
||||||
|
printf(" %-42s (%d -> %d Zeichen)\n alt: %s\n neu: %s\n", $slug, strlen((string)$p['description']), strlen($d), $p['description'], $d);
|
||||||
|
$updateField('pages', (int)$p['uid'], 'description', $d);
|
||||||
|
$purgePaths[$slug] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
|
||||||
|
echo $apply ? "\nFERTIG.\n" : "\n(Trockenlauf, nichts geschrieben)\n";
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate clean-up after the Seobility crawl of 2026-11-10.
|
||||||
|
*
|
||||||
|
* A. Product copy "avedia-modular-chassis-c1101-c1103-c1210-1" (empty) -> deleted, 301 to the original.
|
||||||
|
* B. News duplicates. Where the "-1/-2" copy carries the real text and the
|
||||||
|
* base record is an empty shell or an import placeholder, the shell is
|
||||||
|
* deleted and the copy takes over the clean slug (301 from the "-1" URL).
|
||||||
|
* Records without any content anywhere (#9/#229 global events, #42 DSEI
|
||||||
|
* 2023 pointer) are deleted and their URLs sent to the news overview.
|
||||||
|
* C. Records that are NOT duplicates but carry a "-1/-2" slug from the
|
||||||
|
* import: slug rebuilt from the title, 301 from the old URL.
|
||||||
|
* D. /about (page 7): only an office list, superseded by /insights/who-we-are
|
||||||
|
* and /about/locations -> hidden, 301, two links re-pointed.
|
||||||
|
* E. Stories with copied teaser / SEO description (#11 UAE University,
|
||||||
|
* #30 Kansas City Royals, #35 Minnesota United): texts rebuilt from the
|
||||||
|
* story's own first paragraph.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/dedupe_20261110.php
|
||||||
|
* Write: php migrations/dedupe_20261110.php --apply
|
||||||
|
* Sections: --only=A,B or --skip=D,E
|
||||||
|
*
|
||||||
|
* Deletions are TYPO3 soft deletes (deleted = 1, recoverable via the recycler).
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
$only = []; $skip = [];
|
||||||
|
foreach ($argv as $arg) {
|
||||||
|
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
|
||||||
|
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
|
||||||
|
}
|
||||||
|
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
$now = time();
|
||||||
|
$purgePaths = [];
|
||||||
|
|
||||||
|
// --- helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$updateRedirectBySource = $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?");
|
||||||
|
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $updateRedirectBySource, $apply, &$purgePaths): void {
|
||||||
|
$purgePaths[$source] = true;
|
||||||
|
$hasRedirect->execute([$source]);
|
||||||
|
$existing = $hasRedirect->fetch();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing['target'] === $target) { return; }
|
||||||
|
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
|
||||||
|
if ($apply) { $updateRedirectBySource->execute([$target, (int)$existing['uid']]); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo " redirect $source -> $target\n";
|
||||||
|
if ($apply) { $insertRedirect->execute([$source, $target]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirects whose TARGET is the URL that goes away get the new target (no 301 chains).
|
||||||
|
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
|
||||||
|
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
|
||||||
|
$st->execute([$oldTarget]);
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
|
||||||
|
echo " loeschen $table #$uid\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
|
||||||
|
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$newsRow = static function (int $uid) use ($pdo): ?array {
|
||||||
|
$st = $pdo->prepare("SELECT uid, title, path_segment, type, hidden, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
|
||||||
|
$st->execute([$uid]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r ?: null;
|
||||||
|
};
|
||||||
|
$setSegment = static function (int $uid, string $segment) use ($pdo, $apply, $now): void {
|
||||||
|
echo " news #$uid slug -> $segment\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$segment, $now, $uid]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$slugify = static function (string $t): string {
|
||||||
|
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
|
||||||
|
$t = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT', $t) ?: $t), '-'));
|
||||||
|
return $t;
|
||||||
|
};
|
||||||
|
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
|
||||||
|
$cut = static function (string $s, int $max): string {
|
||||||
|
if (mb_strlen($s) <= $max) { return $s; }
|
||||||
|
$s = mb_substr($s, 0, $max);
|
||||||
|
$s = preg_replace('/\s+\S*$/u', '', $s) ?? $s;
|
||||||
|
return rtrim($s, ' ,;:-') . '…';
|
||||||
|
};
|
||||||
|
|
||||||
|
$newsOverview = '/insights/news';
|
||||||
|
|
||||||
|
// --- A. product copy -----------------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('A')) {
|
||||||
|
echo "\nA. Produkt-Kopie\n";
|
||||||
|
$st = $pdo->query("SELECT uid, slug, LENGTH(description) dl FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug IN ('avedia-modular-chassis-c1101-c1103-c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1')");
|
||||||
|
$p = [];
|
||||||
|
foreach ($st as $r) { $p[$r['slug']] = $r; }
|
||||||
|
$copy = $p['avedia-modular-chassis-c1101-c1103-c1210-1'] ?? null;
|
||||||
|
$base = $p['avedia-modular-chassis-c1101-c1103-c1210'] ?? null;
|
||||||
|
if ($copy && $base && (int)$copy['dl'] === 0) {
|
||||||
|
echo " Produkt #{$copy['uid']} (leer) -> #{$base['uid']}\n";
|
||||||
|
$softDelete('tx_vitec_domain_model_product', (int)$copy['uid']);
|
||||||
|
$addRedirect('/product/' . $copy['slug'], '/product/' . $base['slug']);
|
||||||
|
$purgePaths['/product/' . $base['slug']] = true;
|
||||||
|
} else {
|
||||||
|
echo " nichts zu tun\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B. news duplicates ----------------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('B')) {
|
||||||
|
echo "\nB. News-Dubletten\n";
|
||||||
|
|
||||||
|
// [keep uid, clean segment, [uids to delete], [old segments to redirect]]
|
||||||
|
$takeovers = [
|
||||||
|
[12, 'vitec-to-showcase-expanding-enterprise-iptv-and-digital-signage-innovations-at-infocomm-2026', [1]],
|
||||||
|
[13, 'vitec-and-lg-electronics-to-deliver-large-scale-iptv-and-digital-signage-over-next-generation-private-5g-networks', [2]],
|
||||||
|
[14, 'vitec-to-launch-prism-sff-its-most-compact-transcoder-at-eurosatory-2026', [3]],
|
||||||
|
[258, 'ise-2026-a-milestone-for-vitec-and-datapath', [10, 230]],
|
||||||
|
[21, 'vitec-acquires-datapath-ltd-to-expand-video-capabilities', [263]],
|
||||||
|
[231, 'the-impact-of-remote-work-on-security', [344]],
|
||||||
|
];
|
||||||
|
foreach ($takeovers as [$keepUid, $clean, $deleteUids]) {
|
||||||
|
$keep = $newsRow($keepUid);
|
||||||
|
if ($keep === null) { echo " #$keepUid fehlt, uebersprungen\n"; continue; }
|
||||||
|
echo " behalten #$keepUid ({$keep['bl']} Zeichen) als /news/article/$clean | {$keep['title']}\n";
|
||||||
|
foreach ($deleteUids as $d) {
|
||||||
|
$row = $newsRow($d);
|
||||||
|
if ($row === null) { echo " #$d schon weg\n"; continue; }
|
||||||
|
echo " weg: #$d /news/article/{$row['path_segment']} (type {$row['type']}, {$row['bl']} Zeichen)\n";
|
||||||
|
$softDelete('tx_news_domain_model_news', $d);
|
||||||
|
if ($row['path_segment'] !== $clean) {
|
||||||
|
$addRedirect('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
|
||||||
|
$retarget('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($keep['path_segment'] !== $clean) {
|
||||||
|
$old = $keep['path_segment'];
|
||||||
|
$setSegment($keepUid, $clean);
|
||||||
|
$addRedirect('/news/article/' . $old, '/news/article/' . $clean);
|
||||||
|
$retarget('/news/article/' . $old, '/news/article/' . $clean);
|
||||||
|
}
|
||||||
|
$purgePaths['/news/article/' . $clean] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records with no content anywhere.
|
||||||
|
echo " ohne Inhalt (Record + alte Seite leer):\n";
|
||||||
|
foreach ([9, 229] as $d) {
|
||||||
|
$row = $newsRow($d);
|
||||||
|
if ($row === null) { continue; }
|
||||||
|
echo " weg: #$d /news/article/{$row['path_segment']} | {$row['title']}\n";
|
||||||
|
$softDelete('tx_news_domain_model_news', $d);
|
||||||
|
$addRedirect('/news/article/' . $row['path_segment'], $newsOverview);
|
||||||
|
$retarget('/news/article/' . $row['path_segment'], $newsOverview);
|
||||||
|
}
|
||||||
|
$row = $newsRow(42);
|
||||||
|
if ($row !== null) {
|
||||||
|
$alt = $pdo->query("SELECT path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND uid <> 42 AND type = 0 AND path_segment LIKE '%dsei-2023%' LIMIT 1")->fetchColumn();
|
||||||
|
$target = $alt ? '/news/article/' . $alt : $newsOverview;
|
||||||
|
echo " weg: #42 /news/article/{$row['path_segment']} (Zeiger auf leere Seite 321) -> $target\n";
|
||||||
|
$softDelete('tx_news_domain_model_news', 42);
|
||||||
|
$addRedirect('/news/article/' . $row['path_segment'], $target);
|
||||||
|
$retarget('/news/article/' . $row['path_segment'], $target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C. wrong "-N" slugs on distinct articles --------------------------------------
|
||||||
|
|
||||||
|
if ($runs('C')) {
|
||||||
|
echo "\nC. Eigene Artikel mit Import-Suffix im Slug\n";
|
||||||
|
foreach ([
|
||||||
|
435 => 'wallcontrol-10-scoops-installation-product-awards-crown',
|
||||||
|
334 => '2023-end-of-year-message-from-ceo-bjorn-krylander',
|
||||||
|
376 => '2022-end-of-year-message-from-ceo-bjorn-krylander',
|
||||||
|
] as $uid => $segment) {
|
||||||
|
$row = $newsRow($uid);
|
||||||
|
if ($row === null || $row['path_segment'] === $segment) { continue; }
|
||||||
|
echo " #$uid | {$row['title']}\n";
|
||||||
|
$old = $row['path_segment'];
|
||||||
|
$setSegment($uid, $segment);
|
||||||
|
$addRedirect('/news/article/' . $old, '/news/article/' . $segment);
|
||||||
|
$retarget('/news/article/' . $old, '/news/article/' . $segment);
|
||||||
|
$purgePaths['/news/article/' . $segment] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- D. /about ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ($runs('D')) {
|
||||||
|
echo "\nD. /about (Seite 7) -> /insights/who-we-are\n";
|
||||||
|
$page = $pdo->query("SELECT uid, hidden, slug FROM pages WHERE uid = 7 AND deleted = 0")->fetch();
|
||||||
|
if ($page && (int)$page['hidden'] === 0) {
|
||||||
|
echo " Seite 7 verstecken\n";
|
||||||
|
if ($apply) { $pdo->exec("UPDATE pages SET hidden = 1, tstamp = $now WHERE uid = 7"); }
|
||||||
|
}
|
||||||
|
$addRedirect('/about', '/insights/who-we-are');
|
||||||
|
$addRedirect('/about/', '/insights/who-we-are');
|
||||||
|
foreach ($pdo->query("SELECT uid, pid, bodytext, vitec_button_link, header_link FROM tt_content WHERE deleted = 0 AND uid IN (117, 10291)") as $c) {
|
||||||
|
$changes = [];
|
||||||
|
foreach (['bodytext', 'vitec_button_link', 'header_link'] as $f) {
|
||||||
|
$v = (string)($c[$f] ?? '');
|
||||||
|
$n = (string)preg_replace('/t3:\/\/page\?uid=7(?![0-9])/', 't3://page?uid=550', $v);
|
||||||
|
$n = str_replace(['"/about"', '"/about/"'], '"/insights/who-we-are"', $n);
|
||||||
|
if (in_array($v, ['/about', '/about/', '7'], true)) { $n = $v === '7' ? '550' : 't3://page?uid=550'; }
|
||||||
|
if ($n !== $v) { $changes[$f] = $n; }
|
||||||
|
elseif ($f !== 'bodytext' && $v !== '') { echo " CE #{$c['uid']} $f = $v\n"; }
|
||||||
|
}
|
||||||
|
if ($changes) {
|
||||||
|
echo " CE #{$c['uid']} (Seite {$c['pid']}): " . implode(', ', array_keys($changes)) . " -> who-we-are\n";
|
||||||
|
if ($apply) {
|
||||||
|
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($changes)));
|
||||||
|
$pdo->prepare("UPDATE tt_content SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($changes), (int)$c['uid']]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " CE #{$c['uid']}: kein /about-Link gefunden (manuell pruefen)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$purgePaths['/'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E. story teasers / descriptions copied from the wrong story -----------------------
|
||||||
|
|
||||||
|
if ($runs('E')) {
|
||||||
|
echo "\nE. Story-Teaser/-Beschreibungen aus dem eigenen Text\n";
|
||||||
|
$firstPara = static function (int $usecase) use ($pdo, $plain): string {
|
||||||
|
$st = $pdo->prepare("SELECT bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_vitec_usecase_content = ? AND bodytext <> '' ORDER BY sorting");
|
||||||
|
$st->execute([$usecase]);
|
||||||
|
$fallback = '';
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $html) {
|
||||||
|
$html = (string)preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', (string)$html);
|
||||||
|
$parts = preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $html, $m) ? $m[1] : [$html];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$t = $plain($p);
|
||||||
|
if (mb_strlen($t) < 80 || str_contains($t, 'document.') || str_contains($t, 'function (')) { continue; }
|
||||||
|
// Prefer a real sentence over a title-case sub-headline.
|
||||||
|
if (mb_strlen($t) >= 120 && preg_match('/[.!?]$/u', $t) === 1) { return $t; }
|
||||||
|
if ($fallback === '') { $fallback = $t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $fallback;
|
||||||
|
};
|
||||||
|
// Texts derived from each story's own (correct) teaser or headline; the
|
||||||
|
// automatic first-paragraph pick was tried and read badly.
|
||||||
|
$texts = [
|
||||||
|
11 => ['seo_description' => 'VITEC IPTV and digital signage power streaming and next-generation learning at United Arab Emirates University. Read the success story.'],
|
||||||
|
30 => ['teaser' => 'Reliable, high-quality, low-latency streams and digital signage tools: the Kansas City Royals improve fan engagement with VITEC’s IPTV platform.'],
|
||||||
|
35 => [
|
||||||
|
'teaser' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
|
||||||
|
'seo_description' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
foreach ($texts as $uid => $values) {
|
||||||
|
$st = $pdo->prepare("SELECT uid, title, teaser, seo_description FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
|
||||||
|
$st->execute([$uid]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) { continue; }
|
||||||
|
echo " #$uid {$row['title']} (erster Absatz der Story: " . $cut($firstPara($uid), 80) . ")\n";
|
||||||
|
foreach ($values as $f => $v) {
|
||||||
|
echo " $f alt: " . $cut($plain($row[$f]), 90) . "\n $f neu: $v\n";
|
||||||
|
}
|
||||||
|
if ($apply) {
|
||||||
|
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($values)));
|
||||||
|
$pdo->prepare("UPDATE tx_vitec_domain_model_usecase SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($values), $uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
|
||||||
|
echo $apply ? "\nFERTIG. Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 referenceindex:update\n" : "\n(Trockenlauf, nichts geschrieben)\n";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data points from the frontend answers 0100-FE / 0104-FE / 0108-FE (2026-11-10).
|
||||||
|
*
|
||||||
|
* 1. /solutions: header element #1 "H1 Header" is an import placeholder -> hidden.
|
||||||
|
* 2. Home: #757 vitec_featuredcontent "Featured Product" carries lorem ipsum
|
||||||
|
* and links the empty /product shell -> hidden (placeholder).
|
||||||
|
* 3. Home: #702 vitec_card "Government" linked /markets/defense-and-security
|
||||||
|
* -> /markets/government (where the footer sends "Government").
|
||||||
|
* 4. Home: #699 text, button "Learn more" -> "Explore our solutions".
|
||||||
|
* 5. Story CDC: <h4>Product Highlight</h4> under an h2 -> <h3>.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/fe_answers_fixes_20261111.php
|
||||||
|
* Write: php migrations/fe_answers_fixes_20261111.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
$now = time();
|
||||||
|
$set = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
|
||||||
|
};
|
||||||
|
$ce = static function (int $uid) use ($pdo): ?array {
|
||||||
|
$st = $pdo->prepare("SELECT uid, pid, CType, header, hidden, bodytext, vitec_primary_cta, vitec_cta FROM tt_content WHERE deleted = 0 AND uid = ?");
|
||||||
|
$st->execute([$uid]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r ?: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
echo "\n1./2. Platzhalter-Elemente verstecken\n";
|
||||||
|
foreach ([1 => 'H1 Header', 757 => 'Featured Product'] as $uid => $expect) {
|
||||||
|
$c = $ce($uid);
|
||||||
|
if (!$c) { echo " #$uid fehlt\n"; continue; }
|
||||||
|
if ($c['header'] !== $expect) { echo " #$uid hat Header '{$c['header']}' (erwartet '$expect') - uebersprungen\n"; continue; }
|
||||||
|
if ((int)$c['hidden'] === 1) { echo " #$uid schon versteckt\n"; continue; }
|
||||||
|
echo " #$uid {$c['CType']} \"{$c['header']}\" auf Seite {$c['pid']}: hidden = 1\n";
|
||||||
|
$set('tt_content', $uid, 'hidden', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n3. Karte 'Government' (#702)\n";
|
||||||
|
$c = $ce(702);
|
||||||
|
if ($c && $c['vitec_primary_cta'] === 't3://page?uid=37') {
|
||||||
|
echo " #702 CTA t3://page?uid=37 (/markets/defense-and-security) -> t3://page?uid=526 (/markets/government)\n";
|
||||||
|
$set('tt_content', 702, 'vitec_primary_cta', 't3://page?uid=526');
|
||||||
|
} else {
|
||||||
|
echo " #702: CTA ist '" . ($c['vitec_primary_cta'] ?? '?') . "' - uebersprungen\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n4. Text #699: Button-Text\n";
|
||||||
|
$c = $ce(699);
|
||||||
|
if ($c && str_contains((string)$c['bodytext'], '>Learn more</a>')) {
|
||||||
|
echo " #699: 'Learn more' -> 'Explore our solutions'\n";
|
||||||
|
$set('tt_content', 699, 'bodytext', str_replace('>Learn more</a>', '>Explore our solutions</a>', (string)$c['bodytext']));
|
||||||
|
} else {
|
||||||
|
echo " #699: kein 'Learn more'-Button gefunden - uebersprungen\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n5. Story CDC: h4 'Product Highlight' -> h3\n";
|
||||||
|
$pattern = '~<h4(\s[^>]*)?>(\s*Product Highlight\s*)</h4>~i';
|
||||||
|
$n = 0;
|
||||||
|
foreach ($pdo->query("SELECT c.uid, u.slug, c.bodytext FROM tt_content c LEFT JOIN tx_vitec_domain_model_usecase u ON u.uid = c.tx_vitec_usecase_content WHERE c.deleted = 0 AND c.bodytext LIKE '%Product Highlight%'")->fetchAll() as $r) {
|
||||||
|
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['bodytext'], -1, $count);
|
||||||
|
if ($count === 0) { continue; }
|
||||||
|
$n++;
|
||||||
|
echo " tt_content #{$r['uid']} (Story " . ($r['slug'] ?? '-') . "): $count x h4 -> h3\n";
|
||||||
|
$set('tt_content', (int)$r['uid'], 'bodytext', $new);
|
||||||
|
}
|
||||||
|
// The story text may also live on the record itself.
|
||||||
|
$cols = $pdo->query("SHOW COLUMNS FROM tx_vitec_domain_model_usecase")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($cols as $col) {
|
||||||
|
if (!in_array($col, ['description', 'teaser', 'subtitle'], true)) { continue; }
|
||||||
|
foreach ($pdo->query("SELECT uid, slug, $col AS v FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND $col LIKE '%Product Highlight%'")->fetchAll() as $r) {
|
||||||
|
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['v'], -1, $count);
|
||||||
|
if ($count === 0) { continue; }
|
||||||
|
$n++;
|
||||||
|
echo " usecase #{$r['uid']} ({$r['slug']}) Feld $col: $count x h4 -> h3\n";
|
||||||
|
$set('tx_vitec_domain_model_usecase', (int)$r['uid'], $col, $new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Story sections live in the content-block collection table vitec_items.
|
||||||
|
foreach ($pdo->query("SELECT uid, text FROM vitec_items WHERE deleted = 0 AND text LIKE '%Product Highlight%'")->fetchAll() as $r) {
|
||||||
|
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['text'], -1, $count);
|
||||||
|
if ($count === 0) { continue; }
|
||||||
|
$n++;
|
||||||
|
echo " vitec_items #{$r['uid']}: $count x h4 -> h3\n";
|
||||||
|
$set('vitec_items', (int)$r['uid'], 'text', $new);
|
||||||
|
}
|
||||||
|
if ($n === 0) { echo " nichts gefunden (Text liegt woanders)\n"; }
|
||||||
|
|
||||||
|
echo "\nSSR-Pfade: / /solutions /success-stories/centers-for-disease-control-cdc\n";
|
||||||
|
echo $apply ? "FERTIG.\n" : "(Trockenlauf, nichts geschrieben)\n";
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix dead old-site links inside news bodytexts (SEO follow-up 2026-11-10).
|
||||||
|
*
|
||||||
|
* The imported Datapath articles and the 17 new SEO articles carry hrefs
|
||||||
|
* from the OLD datapath.com information architecture: /datapath-products/…,
|
||||||
|
* old /markets/… market names and /video-wall-solutions/…. None of these
|
||||||
|
* paths exist on the relaunch site (404 on every page view).
|
||||||
|
*
|
||||||
|
* 1. Rewrites every known old href inside tx_news_domain_model_news
|
||||||
|
* bodytext (and teaser, defensively) onto the matching new product
|
||||||
|
* detail page (/product/<slug>), product category page
|
||||||
|
* (/products/<slug>), market or solution page.
|
||||||
|
* 2. Queues a 301 sys_redirect for every old path (with and without
|
||||||
|
* trailing slash) so external backlinks land correctly once the
|
||||||
|
* datapath.com domains point at this site.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/fix_datapath_links_20261110.php
|
||||||
|
* Write: php migrations/fix_datapath_links_20261110.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
/* Old path (no trailing slash) => new target path. */
|
||||||
|
$mapping = [
|
||||||
|
// Aetria
|
||||||
|
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-network-manager' => '/product/aetria-network-manager',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-touch' => '/product/aetria-touch',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/operator-workstations' => '/products/operator-workstations',
|
||||||
|
// AVoIP / KVM
|
||||||
|
'/datapath-products/arqa-avoip-with-kvm' => '/product/arqa',
|
||||||
|
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
|
||||||
|
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
|
||||||
|
'/datapath-products/video-distribution-kvm/aligo-qtx100-quad-transmitter' => '/product/qtx100',
|
||||||
|
'/datapath-products/video-distribution-kvm/aligo-rx100-receiver' => '/product/rx100',
|
||||||
|
'/datapath-products/video-distribution-kvm/aligo-tx100-transmitter' => '/product/tx100',
|
||||||
|
// Capture & graphics
|
||||||
|
'/datapath-products/graphics-products' => '/products/capture-and-graphics',
|
||||||
|
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
|
||||||
|
'/datapath-products/graphics-products/image4k' => '/product/image-4k',
|
||||||
|
'/datapath-products/sqx-ip-capture' => '/products/activesqx',
|
||||||
|
'/datapath-products/vision-range' => '/products/vision-video-capture-cards',
|
||||||
|
'/datapath-products/video-capture-cards/capture-card-features' => '/products/vision-video-capture-cards',
|
||||||
|
'/datapath-products/video-capture-cards/vision-range/visionrgb-e1s' => '/product/visionrgb-e1s',
|
||||||
|
'/datapath-products/video-capture-cards/vision-range/visionsd41s' => '/product/visionsd4-1s',
|
||||||
|
'/datapath-products/video-capture-cards/vision-range/visionsdi2' => '/products/vision-video-capture-cards',
|
||||||
|
'/datapath-products/video-capture-cards/visionav-range' => '/product/visionav',
|
||||||
|
'/datapath-products/video-capture-cards/visionav-range/visionhd4' => '/product/visionhd4',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range' => '/products/vision-video-capture-cards',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-dp2' => '/product/visionsc-dp2',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
|
||||||
|
// Multi-display / Fx4 / Hx4
|
||||||
|
'/datapath-products/multi-display-controllers/datapath-fx4' => '/product/fx4',
|
||||||
|
'/datapath-products/multi-display-controllers/datapath-fx4-sdi' => '/product/fx4-sdi',
|
||||||
|
'/datapath-products/multi-display-controllers/datapath-hx4' => '/product/hx4',
|
||||||
|
'/datapath-products/multi-display-controllers/multi-display-overview' => '/products/x-series-multi-display-processors',
|
||||||
|
'/datapath-products/multi-display-controllers/video-wall-designer-software' => '/products/video-wall-management-software',
|
||||||
|
// Video walls / VSN / WallControl
|
||||||
|
'/datapath-products/video-wall-control/industrial-wall-controllers' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-control/video-wall-controllers' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-control/vsn-11-series' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-control/vsn-9-series' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-controllers' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
|
||||||
|
'/datapath-products/video-wall-controllers/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
|
||||||
|
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-management-software' => '/products/video-wall-management-software',
|
||||||
|
'/datapath-products/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
|
||||||
|
// Accessories (no equivalent records - portfolio page)
|
||||||
|
'/datapath-products/components-accessories/components-accessories-overview/displayport-accessories' => '/products',
|
||||||
|
'/datapath-products/components-accessories/pcie-backplanes/express11-g3' => '/products',
|
||||||
|
// Old market / solution names
|
||||||
|
'/markets/airport-and-aviation' => '/markets/transport/airports',
|
||||||
|
'/markets/corporate' => '/markets/corporation',
|
||||||
|
'/markets/emergency-operation-center' => '/markets/public-safety/emergency-operation-centres',
|
||||||
|
'/markets/events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
|
||||||
|
'/markets/military-control-room' => '/markets/defense-and-security',
|
||||||
|
'/markets/network-operation-center' => '/solutions/control-room-command-centre-solutions/control-room-platforms-soc-noc-command-centres',
|
||||||
|
'/markets/retail-and-hospitality' => '/markets/hospitality',
|
||||||
|
'/markets/security-and-surveillance' => '/solutions/security-surveillance-solutions',
|
||||||
|
'/markets/traffic-management-control-centers' => '/markets/government/traffic-and-smart-mobility',
|
||||||
|
'/markets/utilities' => '/markets/critical-national-infrastructure/energy-and-utilities',
|
||||||
|
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
|
||||||
|
'/video-wall-solutions/retail-events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
|
||||||
|
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sanity: every target must exist (page slug, or product slug). */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||||
|
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND slug = ? LIMIT 1");
|
||||||
|
$missing = 0;
|
||||||
|
foreach (array_unique(array_values($mapping)) as $target) {
|
||||||
|
if (str_starts_with($target, '/product/')) {
|
||||||
|
$productBySlug->execute([substr($target, strlen('/product/'))]);
|
||||||
|
if ((int)$productBySlug->fetchColumn() > 0) { continue; }
|
||||||
|
} else {
|
||||||
|
$pageBySlug->execute([$target]);
|
||||||
|
if ((int)$pageBySlug->fetchColumn() > 0) { continue; }
|
||||||
|
}
|
||||||
|
echo " !! Ziel fehlt: $target\n";
|
||||||
|
$missing++;
|
||||||
|
}
|
||||||
|
if ($missing > 0) {
|
||||||
|
echo "$missing Ziele nicht gefunden - Mapping pruefen.\n";
|
||||||
|
if ($apply) { exit("Abbruch.\n"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 1. Rewrite hrefs in news bodytext/teaser */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "1. Links in News-Texten ersetzen:\n";
|
||||||
|
$findNews = $pdo->prepare("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)");
|
||||||
|
$fixNews = $pdo->prepare(
|
||||||
|
"UPDATE tx_news_domain_model_news
|
||||||
|
SET bodytext = REPLACE(bodytext, ?, ?), teaser = REPLACE(teaser, ?, ?), tstamp = UNIX_TIMESTAMP()
|
||||||
|
WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)"
|
||||||
|
);
|
||||||
|
$totalHits = 0;
|
||||||
|
foreach ($mapping as $old => $new) {
|
||||||
|
// Both href variants: with and without trailing slash; plus the one
|
||||||
|
// broken absolute form (http://datapath-products/...).
|
||||||
|
$variants = ['href="' . $old . '/"', 'href="' . $old . '"', 'href="http:/' . $old . '/"'];
|
||||||
|
foreach ($variants as $needle) {
|
||||||
|
$like = '%' . $needle . '%';
|
||||||
|
$findNews->execute([$like, $like]);
|
||||||
|
$count = (int)$findNews->fetchColumn();
|
||||||
|
if ($count === 0) { continue; }
|
||||||
|
$totalHits += $count;
|
||||||
|
echo " $needle -> href=\"$new\" ($count Record(s))\n";
|
||||||
|
if ($apply) {
|
||||||
|
$fixNews->execute([$needle, 'href="' . $new . '"', $needle, 'href="' . $new . '"', $like, $like]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " Summe Record-Treffer: $totalHits\n";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 2. 301 redirects for the old paths */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "2. Redirects anlegen:\n";
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$newRedirects = 0;
|
||||||
|
foreach ($mapping as $old => $new) {
|
||||||
|
foreach ([$old, $old . '/'] as $source) {
|
||||||
|
$hasRedirect->execute([$source]);
|
||||||
|
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
|
||||||
|
$newRedirects++;
|
||||||
|
if ($apply) {
|
||||||
|
$insertRedirect->execute([$source, $new]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " $newRedirects neue Redirects (mit + ohne Slash; bestehende unangetastet)\n";
|
||||||
|
|
||||||
|
echo $apply ? "FERTIG. Cache flushen.\n" : "DRY RUN beendet.\n";
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer columns (pid 19, html elements #132-#135, #119): align the old-site
|
||||||
|
* link texts with the relaunch structure.
|
||||||
|
*
|
||||||
|
* The footer HTML still carries the old website's wording ("Venues",
|
||||||
|
* "Military", "VITEC OG", "PCIe Encoder" …) although every href points at
|
||||||
|
* the new pages since seo_fixes_20261109. This migration, per internal link:
|
||||||
|
*
|
||||||
|
* 1. sets the link text (and title attribute) to the target page's
|
||||||
|
* navigation title (nav_title, falling back to title);
|
||||||
|
* 2. drops leaf entries that only duplicate an earlier entry of the same
|
||||||
|
* column (several old items collapsed onto one new page);
|
||||||
|
* 3. drops the orphaned catch-all entries that point at the bare /markets
|
||||||
|
* or /products overview ("Healthcare", "Telemetry" - no equivalent page);
|
||||||
|
* 4. removes list containers left empty by 2./3.
|
||||||
|
*
|
||||||
|
* External links (LinkedIn, YouTube, HelpDesk) and t3:// links stay as they
|
||||||
|
* are. Headings (<h4>) stay.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/fix_footer_link_texts_20261110.php
|
||||||
|
* Write: php migrations/fix_footer_link_texts_20261110.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
$pageTitle = $pdo->prepare("SELECT title, nav_title FROM pages WHERE deleted = 0 AND slug = ? LIMIT 1");
|
||||||
|
$resolveTitle = static function (string $href) use ($pageTitle): ?string {
|
||||||
|
$pageTitle->execute([$href]);
|
||||||
|
$row = $pageTitle->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$t = trim((string)($row['nav_title'] ?? '')) ?: trim((string)($row['title'] ?? ''));
|
||||||
|
return $t !== '' ? $t : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$dropOverviewHrefs = ['/markets', '/products'];
|
||||||
|
|
||||||
|
// Product detail pages are records, not pages.
|
||||||
|
$productTitle = $pdo->prepare("SELECT title FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||||
|
$resolveAnyTitle = static function (string $href) use ($resolveTitle, $productTitle): ?string {
|
||||||
|
if (str_starts_with($href, '/product/')) {
|
||||||
|
$productTitle->execute([substr($href, strlen('/product/'))]);
|
||||||
|
$t = trim((string)($productTitle->fetchColumn() ?: ''));
|
||||||
|
return $t !== '' ? $t : null;
|
||||||
|
}
|
||||||
|
return $resolveTitle($href);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Editorial overrides where the mechanical "target page title" is the wrong
|
||||||
|
* wording or the earlier redirect mapping landed on a too-generic page.
|
||||||
|
* Keyed by element uid; matched by old link text (decoded) or by href.
|
||||||
|
*/
|
||||||
|
$overrides = [
|
||||||
|
133 => [
|
||||||
|
// Solutions column: "Digital Signage" is a solution, not the product category
|
||||||
|
['matchHref' => '/products/iptv-and-digital-signage', 'href' => '/solutions/digital-signage-content-delivery'],
|
||||||
|
],
|
||||||
|
134 => [
|
||||||
|
// Fx4 is a product of its own, not the whole video-wall category
|
||||||
|
['matchText' => 'Fx4 Series', 'href' => '/product/fx4', 'text' => 'Fx4'],
|
||||||
|
// the gateways/transcoding parent has its own category page
|
||||||
|
['matchText' => 'IP/RF Gateways & Transcoders', 'href' => '/products/gateways-and-transcoding'],
|
||||||
|
],
|
||||||
|
135 => [
|
||||||
|
// sits directly below the external "Log in to HelpDesk" link
|
||||||
|
['matchHref' => '/support/login-to-helpdesk', 'text' => 'HelpDesk Access'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$elements = $pdo->query(
|
||||||
|
"SELECT uid, bodytext FROM tt_content WHERE pid = 19 AND deleted = 0 AND CType = 'html' ORDER BY colPos, sorting"
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$update = $pdo->prepare("UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
|
||||||
|
|
||||||
|
foreach ($elements as $element) {
|
||||||
|
$uid = (int)$element['uid'];
|
||||||
|
$html = (string)$element['bodytext'];
|
||||||
|
$log = [];
|
||||||
|
|
||||||
|
// --- 1. rename internal links -----------------------------------------
|
||||||
|
$seen = [];
|
||||||
|
$elementOverrides = $overrides[$uid] ?? [];
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'#<a\b([^>]*?)href="(/[^"]*)"([^>]*)>([^<]*)</a>#s',
|
||||||
|
static function (array $m) use ($resolveAnyTitle, &$log, &$seen, $elementOverrides): string {
|
||||||
|
$href = $m[2];
|
||||||
|
$oldText = trim(strip_tags($m[4]));
|
||||||
|
$oldDecoded = html_entity_decode($oldText, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$forcedText = null;
|
||||||
|
foreach ($elementOverrides as $o) {
|
||||||
|
$hit = (isset($o['matchText']) && $o['matchText'] === $oldDecoded)
|
||||||
|
|| (isset($o['matchHref']) && $o['matchHref'] === $href);
|
||||||
|
if ($hit) {
|
||||||
|
if (!empty($o['href']) && $o['href'] !== $href) {
|
||||||
|
$log[] = sprintf(' > [%s] Ziel %s -> %s', $oldText, $href, $o['href']);
|
||||||
|
$href = $o['href'];
|
||||||
|
}
|
||||||
|
$forcedText = $o['text'] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$newTitle = $forcedText ?? $resolveAnyTitle($href);
|
||||||
|
$seen[$href] = ($seen[$href] ?? 0) + 1;
|
||||||
|
if ($newTitle === null) {
|
||||||
|
$log[] = sprintf(' ? [%s] -> %s (keine Seite, unveraendert)', $oldText, $href);
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
$escaped = htmlspecialchars($newTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
|
||||||
|
$attrs = $m[1] . 'href="' . $href . '"' . $m[3];
|
||||||
|
if (preg_match('/\stitle="[^"]*"/', $attrs)) {
|
||||||
|
$attrs = preg_replace('/\stitle="[^"]*"/', ' title="' . $escaped . '"', $attrs, 1);
|
||||||
|
} else {
|
||||||
|
$attrs .= ' title="' . $escaped . '"';
|
||||||
|
}
|
||||||
|
if ($oldDecoded !== $newTitle) {
|
||||||
|
$log[] = sprintf(' ~ [%s] -> [%s] (%s)', $oldText, $newTitle, $href);
|
||||||
|
}
|
||||||
|
return '<a' . $attrs . '>' . $escaped . '</a>';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
) ?? $html;
|
||||||
|
|
||||||
|
// --- 2.+3. drop duplicate leaf items and overview catch-alls -------------
|
||||||
|
// Parent items (a link followed by a nested <ul>) always stay; a leaf
|
||||||
|
// pointing at the same page as a parent or an earlier leaf is a duplicate.
|
||||||
|
$kept = [];
|
||||||
|
// Link texts are plain ([^<]*), so neither pattern can span from a parent
|
||||||
|
// link into its nested list.
|
||||||
|
preg_match_all('#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>[^<]*</a>\s*<ul>#s', $html, $parents);
|
||||||
|
foreach ($parents[1] as $parentHref) {
|
||||||
|
$kept[$parentHref] = true;
|
||||||
|
}
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>([^<]*)</a>\s*</li>\s*#s',
|
||||||
|
static function (array $m) use (&$kept, &$log, $dropOverviewHrefs): string {
|
||||||
|
$href = $m[1];
|
||||||
|
$text = trim(strip_tags($m[2]));
|
||||||
|
if (in_array($href, $dropOverviewHrefs, true)) {
|
||||||
|
$log[] = sprintf(' - [%s] -> %s (Uebersichts-Platzhalter entfernt)', $text, $href);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (isset($kept[$href])) {
|
||||||
|
$log[] = sprintf(' - [%s] -> %s (Dublette entfernt)', $text, $href);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$kept[$href] = true;
|
||||||
|
return $m[0];
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
) ?? $html;
|
||||||
|
// --- 4. empty lists ------------------------------------------------------
|
||||||
|
$before = '';
|
||||||
|
while ($before !== $html) {
|
||||||
|
$before = $html;
|
||||||
|
$html = preg_replace('#<ul>\s*</ul>#s', '', $html) ?? $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $html !== (string)$element['bodytext'];
|
||||||
|
echo "\n##### tt_content #$uid " . ($changed ? '(AENDERUNGEN)' : '(unveraendert)') . "\n";
|
||||||
|
foreach ($log as $line) {
|
||||||
|
echo $line . "\n";
|
||||||
|
}
|
||||||
|
if ($changed && $apply) {
|
||||||
|
$update->execute([$html, $uid]);
|
||||||
|
echo " gespeichert\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $apply ? "\nFERTIG. Cache flushen + SSR purgen (Direkt-SQL loest keinen Auto-Purge aus).\n" : "\nDRY RUN beendet.\n";
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product group pages (two middle levels of the /products tree) were
|
||||||
|
* turned into folders by the editor on 2026-11-10 (decision confirmed by
|
||||||
|
* Harry 2026-11-11: intentional). Their former URLs answered 404 since
|
||||||
|
* then (Seobility crawl 3: 8 "technische Probleme"). Every folder under
|
||||||
|
* /products gets a 301 to /products; the content elements on the folders
|
||||||
|
* stay untouched.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/folder_pages_redirects_20261111.php
|
||||||
|
* Write: php migrations/folder_pages_redirects_20261111.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$target = '/products';
|
||||||
|
$paths = [];
|
||||||
|
$n = 0;
|
||||||
|
foreach ($pdo->query("SELECT uid, slug, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $p) {
|
||||||
|
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$p['uid']}")->fetchColumn();
|
||||||
|
foreach ([$p['slug'], $p['slug'] . '/'] as $source) {
|
||||||
|
$hasRedirect->execute([$source]);
|
||||||
|
$existing = $hasRedirect->fetch();
|
||||||
|
if ($existing) {
|
||||||
|
printf(" #%-4d %-55s (%2d CEs) %s: Redirect existiert -> %s\n", $p['uid'], $source, $ce, $p['title'], $existing['target']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printf(" #%-4d %-55s (%2d CEs) %s -> %s\n", $p['uid'], $source, $ce, $p['title'], $target);
|
||||||
|
$n++;
|
||||||
|
if ($apply) { $insertRedirect->execute([$source, $target]); }
|
||||||
|
}
|
||||||
|
$paths[] = $p['slug'];
|
||||||
|
// Older redirects that point AT this folder (t3://page?uid=… or its slug)
|
||||||
|
// would now end in a 404 chain: send them to /products as well.
|
||||||
|
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND (target = ? OR target = ? OR target LIKE ?)");
|
||||||
|
$st->execute(['t3://page?uid=' . $p['uid'], $p['slug'], 't3://page?uid=' . $p['uid'] . '&%']);
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
printf(" redirect #%d %s: Ziel %s -> %s\n", $r['uid'], $r['source_path'], $r['target'], $target);
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n$n Redirect(s) " . ($apply ? 'angelegt' : 'wuerden angelegt') . ".\n";
|
||||||
|
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow-up to folder_pages_redirects_20261111: a product folder that holds
|
||||||
|
* exactly one visible overview page sends its visitors to that page instead
|
||||||
|
* of /products (RF Gateway -> Avedia Modular System, AV over IP & KVM -> Aligo).
|
||||||
|
* Applies to the folder's own redirects and to older redirects that were
|
||||||
|
* pointed at /products in the previous step because they targeted the folder.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/folder_redirect_targets_20261111.php
|
||||||
|
* Write: php migrations/folder_redirect_targets_20261111.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
// Older redirects (sys_redirect uid) that targeted the folder before the previous step.
|
||||||
|
$legacyByFolder = [566 => [34, 124, 125], 590 => [63, 66]];
|
||||||
|
$n = 0;
|
||||||
|
foreach ($pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $folder) {
|
||||||
|
$children = $pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = {$folder['uid']}")->fetchAll();
|
||||||
|
if (count($children) !== 1) { continue; }
|
||||||
|
$target = $children[0]['slug'];
|
||||||
|
printf(" Ordner #%d %s -> einzige Seite #%d %s\n", $folder['uid'], $folder['slug'], $children[0]['uid'], $target);
|
||||||
|
$uids = $legacyByFolder[(int)$folder['uid']] ?? [];
|
||||||
|
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND target = '/products' AND (source_path = ? OR source_path = ?" . ($uids ? ' OR uid IN (' . implode(',', array_map('intval', $uids)) . ')' : '') . ")");
|
||||||
|
$st->execute([$folder['slug'], $folder['slug'] . '/']);
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
printf(" redirect #%-4d %-55s /products -> %s\n", $r['uid'], $r['source_path'], $target);
|
||||||
|
$n++;
|
||||||
|
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n$n Redirect(s) " . ($apply ? 'umgebogen' : 'wuerden umgebogen') . ".\n";
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* News articles that carry the same text as a success story get the story
|
||||||
|
* as canonical (field tx_news_domain_model_news.vitec_canonical_usecase,
|
||||||
|
* evaluated by VitecMetaHandler). The article stays in the news list.
|
||||||
|
*
|
||||||
|
* Pairs from the Seobility duplicate-content report of 2026-11-10.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/news_story_canonical_20261110.php
|
||||||
|
* Write: php migrations/news_story_canonical_20261110.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
// news uid => usecase (story) uid
|
||||||
|
$pairs = [
|
||||||
|
361 => 56, // Denver Sportsbook Bar & Grill
|
||||||
|
121 => 24, // European Space Agency
|
||||||
|
164 => 26, // Feature Story News
|
||||||
|
336 => 62, // Kyndryl Cyber Operations Center
|
||||||
|
388 => 74, // Leading Video Capture for the Medical Market
|
||||||
|
239 => 66, // RRS Sir David Attenborough
|
||||||
|
158 => 46, // Sacramento Kings
|
||||||
|
310 => 68, // SENTRY Video Walls – 2024 Olympic Games
|
||||||
|
290 => 72, // UK Mission Critical Control Room
|
||||||
|
389 => 59, // Intercity Istanbul Park
|
||||||
|
436 => 64, // NHS tribute onto Parliament
|
||||||
|
395 => 54, // Bahrain International Circuit
|
||||||
|
327 => 60, // ITV News Headquarters
|
||||||
|
];
|
||||||
|
|
||||||
|
$news = $pdo->prepare("SELECT uid, title, path_segment, vitec_canonical_usecase FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
|
||||||
|
$story = $pdo->prepare("SELECT uid, title, slug, hidden FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
|
||||||
|
$update = $pdo->prepare("UPDATE tx_news_domain_model_news SET vitec_canonical_usecase = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
|
||||||
|
$set = 0;
|
||||||
|
$paths = [];
|
||||||
|
foreach ($pairs as $newsUid => $storyUid) {
|
||||||
|
$news->execute([$newsUid]); $n = $news->fetch();
|
||||||
|
$story->execute([$storyUid]); $s = $story->fetch();
|
||||||
|
if (!$n || !$s) { echo " #$newsUid -> #$storyUid: Datensatz fehlt, uebersprungen\n"; continue; }
|
||||||
|
if ((int)$s['hidden'] === 1) { echo " #$newsUid -> #$storyUid: Story versteckt, uebersprungen\n"; continue; }
|
||||||
|
$state = (int)$n['vitec_canonical_usecase'] === $storyUid ? 'schon gesetzt' : 'setzen';
|
||||||
|
printf(" news #%-4d %-70s -> story #%-3d /success-stories/%s [%s]\n", $newsUid, mb_substr($n['title'], 0, 70), $storyUid, $s['slug'], $state);
|
||||||
|
$paths[] = '/news/article/' . $n['path_segment'];
|
||||||
|
if ($state === 'setzen') {
|
||||||
|
$set++;
|
||||||
|
if ($apply) { $update->execute([$storyUid, $newsUid]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n$set Zuordnung(en) " . ($apply ? 'geschrieben' : 'wuerden geschrieben') . ".\n";
|
||||||
|
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# vitec_pagecheck (2026-11-13): permissions, syntax check, cache flush, page setup (dry-run unless --apply).
|
||||||
|
set -e
|
||||||
|
cd /usr/www/users/vitecevo/live
|
||||||
|
EXT=packages/vitec
|
||||||
|
CFG=$EXT/Configuration
|
||||||
|
chmod 644 $EXT/Classes/Command/CreateCheckPagesCommand.php $EXT/Classes/Service/PageCheckService.php $EXT/Classes/UserFunc/PageCheckJsonRenderer.php \
|
||||||
|
$CFG/FlexForms/Pagecheck.xml $CFG/Services.yaml $CFG/Sets/Vitecset/setup.typoscript $CFG/TCA/Overrides/tt_content.php $CFG/page.tsconfig migrations/pagecheck_setup_20261113.sh
|
||||||
|
php -l $EXT/Classes/Service/PageCheckService.php
|
||||||
|
php -l $EXT/Classes/UserFunc/PageCheckJsonRenderer.php
|
||||||
|
php -l $EXT/Classes/Command/CreateCheckPagesCommand.php
|
||||||
|
php -l $CFG/TCA/Overrides/tt_content.php
|
||||||
|
vendor/bin/typo3 cache:flush
|
||||||
|
vendor/bin/typo3 vitec:pagecheck-pages "$@"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO fixes (audit 2026-11-09):
|
||||||
|
*
|
||||||
|
* 1. Clear SELF-referencing canonical_link values. The page imports wrote
|
||||||
|
* "t3://page?uid=<self>" / "<self>" into 161 pages; TYPO3 emits a
|
||||||
|
* self-canonical anyway, and the pages sitemap EXCLUDES every page with
|
||||||
|
* a canonical_link (additionalWhere) - which is why markets, solutions,
|
||||||
|
* support, contact, insights and even the home page were missing from
|
||||||
|
* sitemap.xml.
|
||||||
|
* 2. no_index = 1 for technical pages (search, megamenu, footer shells...)
|
||||||
|
* and the imported old news pages under /en/news + /news (duplicates of
|
||||||
|
* the EXT:news articles).
|
||||||
|
* 3. 301 redirects for old-site URLs onto their new equivalents - the
|
||||||
|
* footer link targets plus every old news page that matches an EXT:news
|
||||||
|
* article by slug segment.
|
||||||
|
* 4. Rewrites the old-site hrefs inside tt_content bodytext (footer html
|
||||||
|
* columns) onto the new targets, so no page carries dead internal links.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/seo_fixes_20261109.php
|
||||||
|
* Write: php migrations/seo_fixes_20261109.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 1. Self-canonicals */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
// Comparison in PHP: CONCAT() in SQL trips over mixed column collations.
|
||||||
|
$selfCanonical = [];
|
||||||
|
foreach ($pdo->query("SELECT uid, canonical_link FROM pages WHERE deleted = 0 AND canonical_link <> ''") as $row) {
|
||||||
|
$uid = (int)$row['uid'];
|
||||||
|
$link = trim((string)$row['canonical_link']);
|
||||||
|
if ($link === 't3://page?uid=' . $uid || $link === (string)$uid) {
|
||||||
|
$selfCanonical[] = $uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "1. Self-Canonicals leeren: " . count($selfCanonical) . " Seiten\n";
|
||||||
|
if ($apply && $selfCanonical !== []) {
|
||||||
|
$pdo->exec(
|
||||||
|
"UPDATE pages SET canonical_link = '', tstamp = UNIX_TIMESTAMP()
|
||||||
|
WHERE uid IN (" . implode(',', $selfCanonical) . ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 2. no_index for technical + old news pages */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
$technicalUids = [12, 16, 19, 106, 107, 448, 450, 516, 517, 519, 521, 523, 607];
|
||||||
|
$in = implode(',', $technicalUids);
|
||||||
|
$cnt = (int)$pdo->query("SELECT COUNT(*) FROM pages WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)")->fetchColumn();
|
||||||
|
echo "2. no_index=1 setzen: $cnt Seiten (technische Seiten + alte News-Seiten unter #106)\n";
|
||||||
|
if ($apply && $cnt > 0) {
|
||||||
|
$pdo->exec("UPDATE pages SET no_index = 1, tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 3a. Old-site URL mapping (footer targets) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
$mapping = [
|
||||||
|
'/about/careers' => '/insights/careers',
|
||||||
|
'/about/company' => '/insights/who-we-are',
|
||||||
|
'/about/events' => '/insights/events',
|
||||||
|
'/about/locations' => '/contact/locations',
|
||||||
|
'/about/news' => '/insights/news',
|
||||||
|
'/about/sustainability' => '/insights/sustainability',
|
||||||
|
'/about/who-we-are' => '/insights/who-we-are',
|
||||||
|
'/about/why-vitec' => '/insights/who-we-are',
|
||||||
|
'/downloads' => '/datasheets',
|
||||||
|
'/markets/accommodation' => '/markets/hospitality',
|
||||||
|
'/markets/broadcast' => '/markets/broadcast-and-telecoms',
|
||||||
|
'/markets/corporate' => '/markets/corporation',
|
||||||
|
'/markets/healthcare' => '/markets',
|
||||||
|
'/markets/military' => '/markets/defense-and-security',
|
||||||
|
'/markets/sports' => '/markets/sports-and-entertainment',
|
||||||
|
'/markets/venues' => '/markets/sports-and-entertainment',
|
||||||
|
'/products/encoders-decoders' => '/products/video-encoding-and-streaming',
|
||||||
|
'/products/encoders-decoders/appliances' => '/products/appliances',
|
||||||
|
'/products/encoders-decoders/modular-systems' => '/products/modular-systems',
|
||||||
|
'/products/encoders-decoders/modular-systems/avedia-encoders' => '/products/avedia-modular-system',
|
||||||
|
'/products/encoders-decoders/modular-systems/iptv-blade-system' => '/products/avedia-modular-system',
|
||||||
|
'/products/encoders-decoders/modular-systems/vitec-og' => '/products/modular-systems',
|
||||||
|
'/products/encoders-decoders/pcie-encoder' => '/products/activesqx',
|
||||||
|
'/products/encoders-decoders/rugged' => '/products/rugged-appliances',
|
||||||
|
'/products/ip/rf-gateways-transcoders' => '/products/rf-gateway',
|
||||||
|
'/products/ip/rf-gateways-transcoders/ip-gateways' => '/products/channellink-ip-gateway',
|
||||||
|
'/products/ip/rf-gateways-transcoders/prism-transcoder' => '/products/prism-transcoder',
|
||||||
|
'/products/ip/rf-gateways-transcoders/rf-gateways' => '/products/rf-gateway',
|
||||||
|
'/products/iptv-digital-signage-platform' => '/products/iptv-and-digital-signage',
|
||||||
|
'/products/iptv-digital-signage-platform/all-in-one-platform' => '/products/avedia',
|
||||||
|
'/products/iptv-digital-signage-platform/digital-signage' => '/products/iptv-and-digital-signage',
|
||||||
|
'/products/iptv-digital-signage-platform/end-points' => '/products/end-points',
|
||||||
|
'/products/iptv-digital-signage-platform/integrated-displays' => '/products/x-view-integrated-displays',
|
||||||
|
'/products/iptv-digital-signage-platform/interactive-tv-portal' => '/products/ez-tv',
|
||||||
|
'/products/iptv-digital-signage-platform/video-wall-controllers' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/products/iptv-digital-signage-platform/video-wall-controllers/aligo' => '/products/aligo',
|
||||||
|
'/products/iptv-digital-signage-platform/video-wall-controllers/fx4-series' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/products/iptv-digital-signage-platform/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/products/iptv-digital-signage-platform/x-player' => '/products/x-player-end-points',
|
||||||
|
'/products/services' => '/support',
|
||||||
|
'/products/services/deployment-services' => '/support/commissioning',
|
||||||
|
'/products/services/digital-signage-and-design-services' => '/support',
|
||||||
|
'/products/services/training' => '/support/training',
|
||||||
|
'/products/telemetry' => '/products',
|
||||||
|
'/solutions/contribution-remote-production' => '/solutions/video-capture-encoding-processing/contribution-remote-production',
|
||||||
|
'/solutions/iptv-distribution' => '/solutions/video-distribution-streaming/iptv-distribution',
|
||||||
|
'/solutions/situational-awareness-and-isr' => '/solutions/real-time-monitoring-situational-awareness',
|
||||||
|
'/solutions/video-streaming' => '/solutions/video-distribution-streaming',
|
||||||
|
'/support/material-compliance' => '/support/materials-compliance',
|
||||||
|
'/support/request-helpdesk-access' => '/support/login-to-helpdesk',
|
||||||
|
'/en/news' => '/insights/news',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve every target slug to a page uid (sanity: fail loudly on typos).
|
||||||
|
$targetUids = [];
|
||||||
|
$stmt = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||||
|
$missing = 0;
|
||||||
|
foreach (array_unique(array_values($mapping)) as $target) {
|
||||||
|
$stmt->execute([$target]);
|
||||||
|
$uid = (int)$stmt->fetchColumn();
|
||||||
|
if ($uid <= 0) {
|
||||||
|
echo " !! Zielseite fehlt: $target\n";
|
||||||
|
$missing++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$targetUids[$target] = $uid;
|
||||||
|
}
|
||||||
|
if ($missing > 0 && $apply) {
|
||||||
|
exit("Abbruch: $missing Zielseiten nicht gefunden - Mapping pruefen.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "3a. Redirects Alt-URLs -> neue Seiten:\n";
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$newRedirects = 0;
|
||||||
|
foreach ($mapping as $old => $new) {
|
||||||
|
if (!isset($targetUids[$new])) { continue; }
|
||||||
|
$hasRedirect->execute([$old]);
|
||||||
|
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
|
||||||
|
$newRedirects++;
|
||||||
|
if ($apply) {
|
||||||
|
$insertRedirect->execute([$old, 't3://page?uid=' . $targetUids[$new]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " $newRedirects neue Redirects (bestehende unangetastet)\n";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 3b. Old imported news pages -> EXT:news articles (slug match) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "3b. Alte News-Seiten -> /news/article/<slug>:\n";
|
||||||
|
$oldNewsPages = $pdo->query(
|
||||||
|
"SELECT uid, slug FROM pages WHERE deleted = 0 AND (pid = 106 OR uid = 107) AND slug <> ''"
|
||||||
|
)->fetchAll();
|
||||||
|
$newsBySegment = $pdo->prepare(
|
||||||
|
"SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND path_segment = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$matched = 0; $unmatched = [];
|
||||||
|
foreach ($oldNewsPages as $page) {
|
||||||
|
$segment = basename((string)$page['slug']);
|
||||||
|
$newsBySegment->execute([$segment]);
|
||||||
|
$newsUid = (int)$newsBySegment->fetchColumn();
|
||||||
|
if ($newsUid <= 0) {
|
||||||
|
$unmatched[] = $page['slug'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hasRedirect->execute([$page['slug']]);
|
||||||
|
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
|
||||||
|
$matched++;
|
||||||
|
if ($apply) {
|
||||||
|
$insertRedirect->execute([(string)$page['slug'], '/news/article/' . $segment]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " $matched Redirects auf Artikel, " . count($unmatched) . " ohne Artikel-Match (bleiben als noindex-Seiten erreichbar)\n";
|
||||||
|
foreach ($unmatched as $slug) {
|
||||||
|
echo " kein Match: $slug\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 4. Dead hrefs inside tt_content bodytext */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "4. Alt-Links in Inhalten ersetzen:\n";
|
||||||
|
$findContent = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND bodytext LIKE ?");
|
||||||
|
$fixContent = $pdo->prepare("UPDATE tt_content SET bodytext = REPLACE(bodytext, ?, ?), tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND bodytext LIKE ?");
|
||||||
|
$replaced = 0;
|
||||||
|
foreach ($mapping as $old => $new) {
|
||||||
|
$needle = 'href="' . $old . '"';
|
||||||
|
$findContent->execute(['%' . $needle . '%']);
|
||||||
|
$count = (int)$findContent->fetchColumn();
|
||||||
|
if ($count === 0) { continue; }
|
||||||
|
echo " $needle -> href=\"$new\" ($count Element(e))\n";
|
||||||
|
$replaced += $count;
|
||||||
|
if ($apply) {
|
||||||
|
$fixContent->execute([$needle, 'href="' . $new . '"', '%' . $needle . '%']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " Summe: $replaced Element-Treffer\n";
|
||||||
|
|
||||||
|
echo $apply ? "FERTIG. Cache flushen (Redirects + Seiten sind gecacht).\n" : "DRY RUN beendet.\n";
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixes from the Seobility crawl of 2026-11-10 (1,094 pages).
|
||||||
|
*
|
||||||
|
* 1. Placeholder SEO title "Title for search engines [seo_title]" on /solutions.
|
||||||
|
* 2. News records whose title/path_segment were saved twice in a row.
|
||||||
|
* 3. News path_segments that contain slashes (imported from old URL paths).
|
||||||
|
* 4. Typos reported by the crawler (content tables).
|
||||||
|
* 5. Second wave of dead old-site links: /resource-center, /services,
|
||||||
|
* /contact-sales, /aetria-use-cases, /video-wall-solutions, absolute
|
||||||
|
* datapath.co.uk / datapath-us.com links and tracking-parameter variants.
|
||||||
|
* 6. "http://www.vitec.com./" (trailing dot) links.
|
||||||
|
* 7. Redirects: record shells (/product, /news/article, /success-stories/
|
||||||
|
* story) onto their overviews, plus a few orphaned product URLs.
|
||||||
|
* 8. Meta description for /datasheets.
|
||||||
|
* 9. WordPress remnants inside the 17 SEO articles (lazy-load placeholder
|
||||||
|
* images, leftover script fragment, wp-/data- attributes, bare text).
|
||||||
|
* 10. News records of type "internal page" pointing at the old /en/news
|
||||||
|
* pages: their body lives only on those pages (which now redirect onto
|
||||||
|
* the empty article URLs). The page content is copied into the record,
|
||||||
|
* images become fal_media, the record becomes a regular article, and
|
||||||
|
* every old page gets its 301.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/seo_report_fixes_20261110.php
|
||||||
|
* Write: php migrations/seo_report_fixes_20261110.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
|
||||||
|
$insertRedirect = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
|
||||||
|
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
|
||||||
|
);
|
||||||
|
$redirectsAdded = 0;
|
||||||
|
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $apply, &$redirectsAdded): void {
|
||||||
|
$hasRedirect->execute([$source]);
|
||||||
|
if ((int)$hasRedirect->fetchColumn() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$redirectsAdded++;
|
||||||
|
if ($apply) {
|
||||||
|
$insertRedirect->execute([$source, $target]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||||
|
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
|
||||||
|
$newsBySegment = $pdo->prepare("SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment = ? LIMIT 1");
|
||||||
|
$targetExists = static function (string $target) use ($pageBySlug, $productBySlug, $newsBySegment): bool {
|
||||||
|
if (str_starts_with($target, '/product/')) {
|
||||||
|
$productBySlug->execute([substr($target, 9)]);
|
||||||
|
return (int)$productBySlug->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
if (str_starts_with($target, '/news/article/')) {
|
||||||
|
$newsBySegment->execute([substr($target, 14)]);
|
||||||
|
return (int)$newsBySegment->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
$pageBySlug->execute([$target]);
|
||||||
|
return (int)$pageBySlug->fetchColumn() > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n1. Platzhalter-SEO-Titel/-Beschreibungen\n";
|
||||||
|
$rows = $pdo->query("SELECT uid, slug, seo_title FROM pages WHERE deleted = 0 AND (seo_title LIKE 'Title for search engines%' OR description LIKE 'Description for search engines%')")->fetchAll();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo " #{$r['uid']} {$r['slug']}: seo_title/description geleert\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE pages SET seo_title = IF(seo_title LIKE 'Title for search engines%', '', seo_title), description = IF(description LIKE 'Description for search engines%', '', description), tstamp = ? WHERE uid = ?")->execute([$now, $r['uid']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n2. News mit verdoppeltem Titel / path_segment\n";
|
||||||
|
$halve = static function (string $s): ?string {
|
||||||
|
$len = mb_strlen($s);
|
||||||
|
if ($len < 20 || $len % 2 !== 0) { return null; }
|
||||||
|
$h = mb_substr($s, 0, intdiv($len, 2));
|
||||||
|
return $h === mb_substr($s, intdiv($len, 2)) ? $h : null;
|
||||||
|
};
|
||||||
|
foreach ($pdo->query("SELECT uid, title, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND CHAR_LENGTH(title) > 40") as $r) {
|
||||||
|
$newTitle = $halve((string)$r['title']);
|
||||||
|
if ($newTitle === null) { continue; }
|
||||||
|
$seg = (string)$r['path_segment'];
|
||||||
|
$newSeg = $seg;
|
||||||
|
// doubled slug: "a-b-ca-b-c" -> "a-b-c"
|
||||||
|
$segLen = strlen($seg);
|
||||||
|
if ($segLen % 2 === 0 && substr($seg, 0, intdiv($segLen, 2)) === substr($seg, intdiv($segLen, 2))) {
|
||||||
|
$newSeg = substr($seg, 0, intdiv($segLen, 2));
|
||||||
|
}
|
||||||
|
echo " #{$r['uid']}: Titel -> [$newTitle]" . ($newSeg !== $seg ? ", Slug -> $newSeg (+Redirect)" : '') . "\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE tx_news_domain_model_news SET title = ?, path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$newTitle, $newSeg, $now, $r['uid']]);
|
||||||
|
}
|
||||||
|
if ($newSeg !== $seg) {
|
||||||
|
$addRedirect('/news/article/' . $seg, '/news/article/' . $newSeg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n3. News path_segment mit Slash\n";
|
||||||
|
foreach ($pdo->query("SELECT uid, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment LIKE '%/%'") as $r) {
|
||||||
|
$old = (string)$r['path_segment'];
|
||||||
|
$new = basename($old);
|
||||||
|
$newsBySegment->execute([$new]);
|
||||||
|
$taken = (int)$newsBySegment->fetchColumn();
|
||||||
|
if ($taken > 0 && $taken !== (int)$r['uid']) {
|
||||||
|
$new .= '-' . $r['uid'];
|
||||||
|
}
|
||||||
|
echo " #{$r['uid']}: $old -> $new (+Redirect)\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, $r['uid']]);
|
||||||
|
}
|
||||||
|
$addRedirect('/news/article/' . $old, '/news/article/' . $new);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n4. Tippfehler\n";
|
||||||
|
$typos = [
|
||||||
|
'continously' => 'continuously', 'continous' => 'continuous', 'indpendent' => 'independent',
|
||||||
|
'aircrafts' => 'aircraft', 'sponser' => 'sponsor', 'auxillary' => 'auxiliary',
|
||||||
|
'processer' => 'processor', 'accomodation' => 'accommodation',
|
||||||
|
'Accomodation' => 'Accommodation', 'Continous' => 'Continuous',
|
||||||
|
];
|
||||||
|
$typoTargets = [
|
||||||
|
'tt_content' => ['bodytext', 'header'],
|
||||||
|
'tx_news_domain_model_news' => ['bodytext', 'teaser', 'title'],
|
||||||
|
'tx_vitec_domain_model_usecase' => ['description', 'teaser'],
|
||||||
|
];
|
||||||
|
foreach ($typos as $wrong => $right) {
|
||||||
|
foreach ($typoTargets as $table => $fields) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$n = (int)$pdo->query("SELECT COUNT(*) FROM $table WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'))->fetchColumn();
|
||||||
|
if ($n === 0) { continue; }
|
||||||
|
echo " $wrong -> $right: $table.$field ($n)\n";
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->exec("UPDATE $table SET $field = REPLACE($field, " . $pdo->quote($wrong) . ", " . $pdo->quote($right) . "), tstamp = $now WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n5. Tote Alt-Links (zweite Welle, inkl. datapath.co.uk / utm-Varianten)\n";
|
||||||
|
$awardSeg = 'aetria-award-wins-set-the-tone-for-datapaths-best-ever-ise-week';
|
||||||
|
$newsBySegment->execute([$awardSeg]);
|
||||||
|
$awardTarget = (int)$newsBySegment->fetchColumn() > 0 ? '/news/article/' . $awardSeg : '/insights/news';
|
||||||
|
$mapping2 = [
|
||||||
|
'/resource-center/legacy-products' => '/products/capture-and-graphics',
|
||||||
|
'/resource-center/legacy-products/imagedp4' => '/products/image-graphics-cards',
|
||||||
|
'/resource-center/legacy-products/datapath-x4' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/resource-center/legacy-products/datapath-dl8' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/legacy-products/imagedp4-plus' => '/products/image-graphics-cards',
|
||||||
|
'/resource-center/get-support' => '/support',
|
||||||
|
'/services/premium-service-package' => '/support/support-plans',
|
||||||
|
'/services/training' => '/support/training',
|
||||||
|
'/contact-sales' => '/contact/contact-form',
|
||||||
|
'/aetria-use-cases/oil-and-gas-control-room' => '/markets/critical-national-infrastructure/energy-and-utilities',
|
||||||
|
'/aetria-use-cases/energy-trading-and-dispatch' => '/markets/critical-national-infrastructure/energy-and-utilities',
|
||||||
|
'/aetria-use-cases/cctv-for-university-campus' => '/markets/education',
|
||||||
|
'/video-wall-solutions/commercial-video-walls' => '/solutions/video-wall-data-visualisation',
|
||||||
|
'/video-wall-solutions/control-rooms' => '/solutions/control-room-command-centre-solutions',
|
||||||
|
'/video-wall-solutions/projection-mapping' => '/solutions/video-wall-data-visualisation',
|
||||||
|
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
|
||||||
|
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
|
||||||
|
'/insights/success-stories' => '/success-stories',
|
||||||
|
'/datapath-products' => '/products',
|
||||||
|
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-command-center' => '/product/aetria-command-center',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-workstation' => '/product/aetria-workstation',
|
||||||
|
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
|
||||||
|
'/datapath-products/video-wall-controllers/vsn-v3' => '/product/vsn-v3',
|
||||||
|
'/datapath-products/video-wall-controllers/vsn' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
|
||||||
|
'/datapath-products/video-wall-controllers/vsnmicro-600' => '/product/vsnmicro-600',
|
||||||
|
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
|
||||||
|
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
|
||||||
|
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
|
||||||
|
'/datapath-products/video-capture-cards/visionsc-range/visionsc-s2' => '/product/visionsc-s2',
|
||||||
|
'/datapath-products/video-wall-management-software/wallcontrol-10-software' => '/product/wall-control-10',
|
||||||
|
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
|
||||||
|
'/datapath-products/components-accessories/rack-mounting-kits' => '/products',
|
||||||
|
'/datapath-products/sqx-ip-capture/active-sqx' => '/product/activesqx',
|
||||||
|
'/datapath-products/video-wall-control' => '/products/video-wall-controllers-and-management',
|
||||||
|
'/' . $awardSeg => $awardTarget,
|
||||||
|
];
|
||||||
|
$missing = 0;
|
||||||
|
foreach (array_unique(array_values($mapping2)) as $t) {
|
||||||
|
if (!$targetExists($t)) { echo " !! Ziel fehlt: $t\n"; $missing++; }
|
||||||
|
}
|
||||||
|
if ($missing > 0 && $apply) { exit("Abbruch: Ziele fehlen.\n"); }
|
||||||
|
|
||||||
|
$hostPrefix = '(?:https?://(?:www\.)?datapath(?:-us)?\.(?:co\.uk|com))?';
|
||||||
|
$linkTables = ['tx_news_domain_model_news' => 'bodytext', 'tt_content' => 'bodytext'];
|
||||||
|
$linkHits = 0;
|
||||||
|
foreach ($linkTables as $table => $field) {
|
||||||
|
$select = $pdo->query("SELECT uid, $field AS body FROM $table WHERE deleted = 0 AND ($field LIKE '%datapath%' OR $field LIKE '%/resource-center/%' OR $field LIKE '%/services/%' OR $field LIKE '%/contact-sales%' OR $field LIKE '%/aetria-use-cases/%' OR $field LIKE '%/video-wall-solutions%' OR $field LIKE '%/insights/success-stories%' OR $field LIKE '%/legacy-products/%' OR $field LIKE '%aetria-award-wins%')");
|
||||||
|
$update = $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?");
|
||||||
|
foreach ($select as $r) {
|
||||||
|
$body = (string)$r['body'];
|
||||||
|
$new = $body;
|
||||||
|
foreach ($mapping2 as $old => $target) {
|
||||||
|
$pattern = '#href="' . $hostPrefix . preg_quote($old, '#') . '/?(?:\?[^"]*)?"#';
|
||||||
|
$new = preg_replace($pattern, 'href="' . $target . '"', $new) ?? $new;
|
||||||
|
}
|
||||||
|
if ($new !== $body) {
|
||||||
|
$linkHits++;
|
||||||
|
echo " $table #{$r['uid']}: Links ersetzt\n";
|
||||||
|
if ($apply) { $update->execute([$new, $now, $r['uid']]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " Records mit ersetzten Links: $linkHits\n";
|
||||||
|
foreach ($mapping2 as $old => $target) {
|
||||||
|
$addRedirect($old, $target);
|
||||||
|
$addRedirect($old . '/', $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n6. http://www.vitec.com./ (Punkt-Tippfehler)\n";
|
||||||
|
$n = (int)$pdo->query("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'")->fetchColumn();
|
||||||
|
echo " $n Record(s)\n";
|
||||||
|
if ($apply && $n > 0) {
|
||||||
|
$pdo->exec("UPDATE tx_news_domain_model_news SET bodytext = REPLACE(REPLACE(bodytext, 'http://www.vitec.com./', 'https://www.vitec.com/'), 'https://www.vitec.com./', 'https://www.vitec.com/'), tstamp = $now WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n7. Redirects fuer Record-Shells und verwaiste Produkt-URLs\n";
|
||||||
|
foreach ([
|
||||||
|
'/product' => '/products',
|
||||||
|
'/news/article' => '/insights/news',
|
||||||
|
'/success-stories/story' => '/success-stories',
|
||||||
|
'/product/artiosign' => '/products/video-wall-management-software',
|
||||||
|
'/product/artioview' => '/products/video-wall-management-software',
|
||||||
|
'/product/x-view-integrated-displays' => '/products/x-view-integrated-displays',
|
||||||
|
] as $old => $target) {
|
||||||
|
$addRedirect($old, $target);
|
||||||
|
$addRedirect($old . '/', $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n8. Meta-Description /datasheets\n";
|
||||||
|
$st = $pdo->prepare("UPDATE pages SET description = ?, tstamp = ? WHERE uid = 17 AND (description IS NULL OR description = '')");
|
||||||
|
$desc = 'Download VITEC and Datapath product datasheets: encoders and decoders, IPTV and digital signage platforms, video wall controllers, capture cards and more - searchable by product and category.';
|
||||||
|
if ($apply) { $st->execute([$desc, $now]); echo " gesetzt (" . $st->rowCount() . ")\n"; } else { echo " wird gesetzt, falls leer\n"; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n9. WordPress-Reste in den SEO-Artikeln\n";
|
||||||
|
$seoUids = [232,234,235,236,237,238,240,241,242,243,244,245,246,247,248,249,252];
|
||||||
|
$sel = $pdo->query("SELECT uid, path_segment, bodytext FROM tx_news_domain_model_news WHERE deleted = 0 AND uid IN (" . implode(',', $seoUids) . ")");
|
||||||
|
$upd = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, tstamp = ? WHERE uid = ?");
|
||||||
|
foreach ($sel as $r) {
|
||||||
|
$b = (string)$r['bodytext'];
|
||||||
|
$o = $b;
|
||||||
|
$log = [];
|
||||||
|
$b = preg_replace('#<img[^>]*src="data:image/[^"]*"[^>]*>\s*#i', '', $b, -1, $c1) ?? $b; if ($c1) { $log[] = "$c1 Platzhalter-Bilder"; }
|
||||||
|
$b = preg_replace('#<p>[^<]*getWR360PopupSkin[^<]*</p>\s*#i', '', $b, -1, $c2) ?? $b; if ($c2) { $log[] = "Script-Rest"; }
|
||||||
|
$b = preg_replace('#\s+class="wp-[^"]*"#i', '', $b, -1, $c3) ?? $b; if ($c3) { $log[] = "$c3 wp-Klassen"; }
|
||||||
|
$b = preg_replace('#\s+data-[a-z0-9-]+="[^"]*"#i', '', $b, -1, $c4) ?? $b; if ($c4) { $log[] = "$c4 data-Attribute"; }
|
||||||
|
// bare description text between a product <h3> and the following link
|
||||||
|
$b = preg_replace('#(</h3>)\s*([^<\s][^<]{20,}?)\s*(?=<a |<h[2-4]|<p>|<ul>)#s', '$1<p>$2</p>', $b, -1, $c5) ?? $b; if ($c5) { $log[] = "$c5 nackte Textabsaetze gekapselt"; }
|
||||||
|
$b = preg_replace('#<a([^>]*)>\s*View products?\s*</a>#i', '<a$1>View products</a>', $b, -1, $c6) ?? $b; if ($c6) { $log[] = "$c6 View-products-Links normalisiert"; }
|
||||||
|
if ($b !== $o) {
|
||||||
|
echo " #{$r['uid']} {$r['path_segment']}: " . implode(', ', $log) . "\n";
|
||||||
|
if ($apply) { $upd->execute([$b, $now, $r['uid']]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
echo "\n10. News-Zeiger-Records (type=1) -> Inhalt der alten Seite uebernehmen\n";
|
||||||
|
$allowedTags = '<p><h2><h3><h4><ul><ol><li><strong><em><b><i><a><br><blockquote>';
|
||||||
|
$stripAttrs = static function (string $html): string {
|
||||||
|
// keep href/target on links, drop everything else (style, class, ids)
|
||||||
|
$html = preg_replace_callback('#<a\b([^>]*)>#i', static function (array $m): string {
|
||||||
|
preg_match('/href="([^"]*)"/i', $m[1], $h);
|
||||||
|
preg_match('/target="([^"]*)"/i', $m[1], $t);
|
||||||
|
return '<a' . (isset($h[1]) ? ' href="' . $h[1] . '"' : '') . (isset($t[1]) ? ' target="' . $t[1] . '"' : '') . '>';
|
||||||
|
}, $html) ?? $html;
|
||||||
|
return preg_replace('#<(p|h[2-4]|ul|ol|li|strong|em|b|i|blockquote|br)\b[^>]*>#i', '<$1>', $html) ?? $html;
|
||||||
|
};
|
||||||
|
$collectContent = null;
|
||||||
|
$collectContent = static function (int $pid, int $parent, array &$texts, array &$imageRefs) use ($pdo, &$collectContent, $allowedTags, $stripAttrs): void {
|
||||||
|
$st = $pdo->prepare("SELECT uid, CType, bodytext, header FROM tt_content WHERE pid = ? AND tx_container_parent = ? AND deleted = 0 AND hidden = 0 ORDER BY colPos, sorting");
|
||||||
|
$st->execute([$pid, $parent]);
|
||||||
|
foreach ($st->fetchAll() as $el) {
|
||||||
|
$ctype = (string)$el['CType'];
|
||||||
|
if (str_starts_with($ctype, 'vitec_container') || str_starts_with($ctype, 'vitec_cols')) {
|
||||||
|
$collectContent($pid, (int)$el['uid'], $texts, $imageRefs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($ctype, ['image', 'textpic', 'textmedia'], true)) {
|
||||||
|
$refs = $pdo->prepare("SELECT uid FROM sys_file_reference WHERE tablenames = 'tt_content' AND uid_foreign = ? AND fieldname IN ('image', 'assets', 'media') AND deleted = 0 AND hidden = 0 ORDER BY sorting_foreign");
|
||||||
|
$refs->execute([(int)$el['uid']]);
|
||||||
|
foreach ($refs->fetchAll() as $ref) { $imageRefs[] = (int)$ref['uid']; }
|
||||||
|
}
|
||||||
|
if (in_array($ctype, ['text', 'textpic', 'textmedia'], true)) {
|
||||||
|
$t = trim((string)$el['bodytext']);
|
||||||
|
if ($t !== '') { $texts[] = $stripAttrs(strip_tags($t, $allowedTags)); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ctype === 'html') {
|
||||||
|
$raw = (string)$el['bodytext'];
|
||||||
|
if (preg_match('#<blockquote[^>]*>(.*?)</blockquote>#is', $raw, $q)) {
|
||||||
|
$cite = '';
|
||||||
|
if (preg_match('#</blockquote>\s*<span[^>]*>(.*?)</span>#is', $raw, $s)) { $cite = trim(strip_tags($s[1])); }
|
||||||
|
$texts[] = '<blockquote><p>' . trim(strip_tags($q[1])) . '</p>' . ($cite !== '' ? '<p><em>' . $cite . '</em></p>' : '') . '</blockquote>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stripos($raw, '<style') !== false || stripos($raw, '<script') !== false || mb_strlen(trim(strip_tags($raw))) < 200) {
|
||||||
|
continue; // layout snippets (buttons, styles)
|
||||||
|
}
|
||||||
|
$texts[] = $stripAttrs(strip_tags($raw, $allowedTags));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$pointerRows = $pdo->query("SELECT n.uid, n.pid, n.title, n.path_segment, n.internalurl, n.bodytext, n.fal_media FROM tx_news_domain_model_news n WHERE n.deleted = 0 AND n.type = '1'")->fetchAll();
|
||||||
|
$pageSlugs = $pdo->prepare("SELECT uid, slug, pid FROM pages WHERE uid = ? AND deleted = 0");
|
||||||
|
$maxRefSorting = $pdo->prepare("SELECT COALESCE(MAX(sorting_foreign), 0) FROM sys_file_reference WHERE tablenames = 'tx_news_domain_model_news' AND fieldname = 'fal_media' AND uid_foreign = ?");
|
||||||
|
$copyRef = $pdo->prepare(
|
||||||
|
"INSERT INTO sys_file_reference (pid, tstamp, crdate, uid_local, uid_foreign, tablenames, fieldname, sorting_foreign, title, alternative, description, crop, link)
|
||||||
|
SELECT ?, ?, ?, uid_local, ?, 'tx_news_domain_model_news', 'fal_media', ?, title, alternative, description, crop, link FROM sys_file_reference WHERE uid = ?"
|
||||||
|
);
|
||||||
|
$updNews = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, type = '0', internalurl = '', fal_media = ?, tstamp = ? WHERE uid = ?");
|
||||||
|
$updType = $pdo->prepare("UPDATE tx_news_domain_model_news SET type = '0', internalurl = '', tstamp = ? WHERE uid = ?");
|
||||||
|
$migrated = 0; $typeOnly = 0; $skipped = 0;
|
||||||
|
foreach ($pointerRows as $n) {
|
||||||
|
$pid = 0;
|
||||||
|
if (preg_match('/uid=(\d+)/', (string)$n['internalurl'], $m)) { $pid = (int)$m[1]; } elseif (ctype_digit((string)$n['internalurl'])) { $pid = (int)$n['internalurl']; }
|
||||||
|
$pageSlugs->execute([$pid]);
|
||||||
|
$page = $pageSlugs->fetch();
|
||||||
|
if (!$page || !in_array((int)$page['pid'], [106], true) && (int)$page['uid'] !== 107) {
|
||||||
|
echo " #{$n['uid']}: Ziel-Seite $pid nicht im alten News-Ast - uebersprungen\n";
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$oldSlug = (string)$page['slug'];
|
||||||
|
$newUrl = '/news/article/' . $n['path_segment'];
|
||||||
|
if (trim((string)$n['bodytext']) !== '') {
|
||||||
|
echo " #{$n['uid']}: hat eigenen Text (" . strlen((string)$n['bodytext']) . " Z.) -> nur type=0" . "\n";
|
||||||
|
$typeOnly++;
|
||||||
|
if ($apply) { $updType->execute([$now, $n['uid']]); }
|
||||||
|
$addRedirect($oldSlug, $newUrl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$texts = []; $imageRefs = [];
|
||||||
|
$collectContent($pid, 0, $texts, $imageRefs);
|
||||||
|
$body = trim(implode("\n", $texts));
|
||||||
|
$plainLen = mb_strlen(trim(strip_tags($body)));
|
||||||
|
if ($plainLen < 500) {
|
||||||
|
echo " #{$n['uid']}: Seite $pid liefert nur $plainLen Zeichen Text - uebersprungen (bleibt Zeiger)\n";
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$imageRefs = array_slice(array_unique($imageRefs), 0, 3);
|
||||||
|
// Imported placeholder slugs ("default-<hash>") take the old page's slug.
|
||||||
|
$segment = (string)$n['path_segment'];
|
||||||
|
if (str_starts_with($segment, 'default-')) {
|
||||||
|
$candidate = basename($oldSlug);
|
||||||
|
$newsBySegment->execute([$candidate]);
|
||||||
|
if ((int)$newsBySegment->fetchColumn() === 0) {
|
||||||
|
$segment = $candidate;
|
||||||
|
$newUrl = '/news/article/' . $segment;
|
||||||
|
echo " #{$n['uid']}: Slug default-... -> $segment\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " #{$n['uid']} $segment: $plainLen Zeichen aus " . count($texts) . " Elementen, " . count($imageRefs) . " Bild(er) <- $oldSlug\n";
|
||||||
|
$migrated++;
|
||||||
|
if ($apply) {
|
||||||
|
if ($segment !== (string)$n['path_segment']) {
|
||||||
|
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ? WHERE uid = ?")->execute([$segment, $n['uid']]);
|
||||||
|
}
|
||||||
|
$sorting = 0;
|
||||||
|
$maxRefSorting->execute([$n['uid']]);
|
||||||
|
$sorting = (int)$maxRefSorting->fetchColumn();
|
||||||
|
$existing = (int)$n['fal_media'];
|
||||||
|
foreach ($imageRefs as $refUid) {
|
||||||
|
$sorting += 256;
|
||||||
|
$copyRef->execute([(int)$n['pid'], $now, $now, (int)$n['uid'], $sorting, $refUid]);
|
||||||
|
$existing++;
|
||||||
|
}
|
||||||
|
$updNews->execute([$body, $existing, $now, $n['uid']]);
|
||||||
|
}
|
||||||
|
$addRedirect($oldSlug, $newUrl);
|
||||||
|
}
|
||||||
|
echo " uebernommen: $migrated, nur type=0: $typeOnly, uebersprungen: $skipped\n";
|
||||||
|
|
||||||
|
echo "\nRedirects neu: $redirectsAdded\n";
|
||||||
|
echo $apply ? "FERTIG. cache:flush + SSR purge + warm.\n" : "DRY RUN beendet.\n";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Meta description for the /solutions overview (page 2) - was the TCA
|
||||||
|
// placeholder text until seo_report_fixes_20261110 cleared it. Fill-up only.
|
||||||
|
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
|
||||||
|
$s=require $root.'/config/system/settings.php';$db=$s['DB']['Connections']['Default'];
|
||||||
|
$pdo=new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',$db['host']??'localhost',$db['port']??3306,$db['dbname']??''),$db['user']??'',$db['password']??'',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$desc='Explore VITEC video solutions for control rooms and command centres, real-time monitoring, video distribution and streaming, capture and encoding, video walls, digital signage, enterprise communications and security.';
|
||||||
|
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=2 AND (description IS NULL OR description='')");
|
||||||
|
$st->execute([$desc]);
|
||||||
|
echo "Seite 2 (/solutions): ".$st->rowCount()." Zeile(n) aktualisiert\n";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Setzt die fehlende Meta-Description der Success-Stories-Uebersicht (Seite 4).
|
||||||
|
// Fill-up only: eine vorhandene Description wird nicht ueberschrieben.
|
||||||
|
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
|
||||||
|
$s=require $root.'/config/system/settings.php';$db=$s['DB']['Connections']['Default'];
|
||||||
|
$pdo=new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',$db['host']??'localhost',$db['port']??3306,$db['dbname']??''),$db['user']??'',$db['password']??'',[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$desc='Discover how organizations worldwide use VITEC and Datapath video solutions: customer success stories from broadcast, defense, enterprise, sports and public safety.';
|
||||||
|
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=4 AND (description IS NULL OR description='')");
|
||||||
|
$st->execute([$desc]);
|
||||||
|
echo "Seite 4: ".$st->rowCount()." Zeile(n) aktualisiert\n";
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old Datapath/VITEC articles link to partner sites that no longer exist
|
||||||
|
* (Seobility "Probleme mit externen Links", decision Harry 2026-11-10:
|
||||||
|
* remove the link, keep the text). The anchor is replaced by its text.
|
||||||
|
*
|
||||||
|
* Dry run: php migrations/unlink_dead_external_20261110.php
|
||||||
|
* Write: php migrations/unlink_dead_external_20261110.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$hosts = [
|
||||||
|
'queensawardsmagazine.com',
|
||||||
|
'samminton.com',
|
||||||
|
'eetgroup.com',
|
||||||
|
'broadcast-asia.com',
|
||||||
|
'tecnomultimedia.com',
|
||||||
|
'sentryvideowalls.com',
|
||||||
|
'exertis.co.uk',
|
||||||
|
'infocomm-sea.com',
|
||||||
|
'surveymonkey.co.uk',
|
||||||
|
'exterity.com',
|
||||||
|
'eurekanetwork.org',
|
||||||
|
];
|
||||||
|
$hostAlt = implode('|', array_map(fn($h) => preg_quote($h, '~'), $hosts));
|
||||||
|
// <a ... href="...host..." ...>text</a> -> text
|
||||||
|
$pattern = '~<a\b[^>]*\bhref="[^"]*(?:' . $hostAlt . ')[^"]*"[^>]*>(.*?)</a>~is';
|
||||||
|
$purge = [];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach (['tx_news_domain_model_news' => 'path_segment', 'tt_content' => 'pid'] as $table => $ref) {
|
||||||
|
$like = implode(' OR ', array_map(fn($h) => "bodytext LIKE '%$h%'", $hosts));
|
||||||
|
foreach ($pdo->query("SELECT uid, $ref AS ref, bodytext FROM $table WHERE deleted = 0 AND ($like)") as $r) {
|
||||||
|
$new = (string)preg_replace_callback($pattern, static function (array $m): string {
|
||||||
|
return $m[1];
|
||||||
|
}, (string)$r['bodytext'], -1, $count);
|
||||||
|
if ($count === 0 || $new === (string)$r['bodytext']) {
|
||||||
|
echo " $table #{$r['uid']}: Host im Text, aber kein <a>-Tag erkannt (manuell pruefen)\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
preg_match_all('~https?://[^"\s<]*(?:' . $hostAlt . ')[^"\s<]*~i', (string)$r['bodytext'], $urls);
|
||||||
|
printf(" %s #%-4d (%s): %d Link(s) entfernt: %s\n", $table, $r['uid'], $r['ref'], $count, implode(', ', array_unique($urls[0])));
|
||||||
|
$total += $count;
|
||||||
|
if ($table === 'tx_news_domain_model_news') { $purge[] = '/news/article/' . $r['ref']; }
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->prepare("UPDATE $table SET bodytext = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n$total Link(s) in " . count($purge) . " News" . ($apply ? ' entfernt' : ' wuerden entfernt') . ".\n";
|
||||||
|
echo "SSR-Pfade:\n " . implode("\n ", $purge) . "\n";
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* why_band_unwrap_20261111.php (Ticket 0106-FE, Abschnitt "alte Seiten")
|
||||||
|
*
|
||||||
|
* Die drei 2026-10-Kategorieseiten ohne Menueplatz tragen ihre "Why VITEC?"-
|
||||||
|
* Karten noch als `vertical`-Karten IN vitec_cols_33_33_33-Containern
|
||||||
|
* (wrap-cards-grid vom 27.10.). Das Frontend baut das Icon-Band (0039-BE)
|
||||||
|
* aber nur aus `vitec_card` mit layout_variant icon_card DIREKT hinter dem
|
||||||
|
* Intro. Entscheidung Harry 2026-11-11: diese Seiten bekommen das Band auch.
|
||||||
|
*
|
||||||
|
* Umbau je Seite: Intro "Why VITEC?" finden, die direkt folgenden Container
|
||||||
|
* (nur solche, die ausschliesslich vitec_card enthalten) aufloesen - Karten
|
||||||
|
* nach colPos 0 / tx_container_parent 0 / icon_card, leere Container soft-
|
||||||
|
* loeschen, colPos 0 mit Stride 256 neu sortieren (Band direkt hinter dem
|
||||||
|
* Intro, Rest in bisheriger Folge). Direktes SQL -> Auto-Purge greift NICHT:
|
||||||
|
* danach cache:flush + vitec:ssr-purge/-warm mit den Pfaden.
|
||||||
|
*
|
||||||
|
* php migrations/why_band_unwrap_20261111.php # INSPECT (default)
|
||||||
|
* php migrations/why_band_unwrap_20261111.php --apply # schreibt
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$apply = in_array('--apply', $argv, true);
|
||||||
|
|
||||||
|
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
|
||||||
|
|
||||||
|
$slugs = [
|
||||||
|
'/products/graphics-and-multi-display',
|
||||||
|
'/products/infrastructure-and-accessories',
|
||||||
|
'/products/video-capture',
|
||||||
|
];
|
||||||
|
|
||||||
|
$norm = static fn(string $s): string => (string)preg_replace('/[^a-z0-9]+/', '', strtolower($s));
|
||||||
|
|
||||||
|
$done = 0;
|
||||||
|
foreach ($slugs as $slug) {
|
||||||
|
$st = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
|
||||||
|
$st->execute([$slug]);
|
||||||
|
$page = $st->fetch();
|
||||||
|
if (!$page) {
|
||||||
|
echo "$slug: Seite NICHT gefunden - uebersprungen\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pid = (int)$page['uid'];
|
||||||
|
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'SELECT uid, CType, header, sorting, vitec_layout_variant FROM tt_content
|
||||||
|
WHERE pid = ? AND deleted = 0 AND colPos = 0 AND sys_language_uid = 0
|
||||||
|
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
|
||||||
|
ORDER BY sorting, uid'
|
||||||
|
);
|
||||||
|
$st->execute([$pid]);
|
||||||
|
$top = $st->fetchAll();
|
||||||
|
|
||||||
|
echo "== $slug (pid $pid) \"{$page['title']}\" - " . count($top) . " Top-Level-Elemente in colPos 0\n";
|
||||||
|
|
||||||
|
// bereits umgebaut? (Band = Intro + direkt folgende Karten top-level)
|
||||||
|
$whyIdx = null;
|
||||||
|
foreach ($top as $i => $el) {
|
||||||
|
if ($el['CType'] === 'vitec_introparagraph' && str_starts_with($norm((string)$el['header']), 'whyvitec')) {
|
||||||
|
$whyIdx = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($whyIdx === null) {
|
||||||
|
echo " kein Why-VITEC-Intro gefunden - uebersprungen\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$intro = $top[$whyIdx];
|
||||||
|
if (isset($top[$whyIdx + 1]) && $top[$whyIdx + 1]['CType'] === 'vitec_card') {
|
||||||
|
echo " Band liegt bereits top-level hinter Intro #{$intro['uid']} - nichts zu tun\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// direkt folgende Container einsammeln, solange sie nur Karten enthalten
|
||||||
|
$containers = [];
|
||||||
|
$cards = [];
|
||||||
|
for ($i = $whyIdx + 1; $i < count($top); $i++) {
|
||||||
|
$el = $top[$i];
|
||||||
|
if (!str_starts_with((string)$el['CType'], 'vitec_cols')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$kids = $pdo->query(
|
||||||
|
'SELECT uid, CType, header, colPos, sorting, vitec_layout_variant FROM tt_content
|
||||||
|
WHERE deleted = 0 AND tx_container_parent = ' . (int)$el['uid'] . ' ORDER BY colPos, sorting, uid'
|
||||||
|
)->fetchAll();
|
||||||
|
$onlyCards = $kids !== [] && count(array_filter($kids, static fn(array $k): bool => $k['CType'] !== 'vitec_card')) === 0;
|
||||||
|
if (!$onlyCards) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$containers[] = $el;
|
||||||
|
foreach ($kids as $k) {
|
||||||
|
$cards[] = $k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($cards === []) {
|
||||||
|
echo " Intro #{$intro['uid']} \"{$intro['header']}\" ohne folgende Karten-Container - uebersprungen\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(" Intro #%d \"%s\"\n", $intro['uid'], $intro['header']);
|
||||||
|
printf(" %d Container (#%s) mit %d Karte(n):\n", count($containers), implode(', #', array_column($containers, 'uid')), count($cards));
|
||||||
|
foreach ($cards as $c) {
|
||||||
|
printf(" #%-6d %-14s \"%s\"\n", $c['uid'], (string)$c['vitec_layout_variant'], (string)$c['header']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// neue Reihenfolge: bis Intro, dann Karten, dann Rest ohne die Container
|
||||||
|
$containerUids = array_map('intval', array_column($containers, 'uid'));
|
||||||
|
$newOrder = [];
|
||||||
|
foreach ($top as $i => $el) {
|
||||||
|
if (in_array((int)$el['uid'], $containerUids, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newOrder[] = ['uid' => (int)$el['uid'], 'label' => $el['CType'] . ' ' . $el['header']];
|
||||||
|
if ($i === $whyIdx) {
|
||||||
|
foreach ($cards as $c) {
|
||||||
|
$newOrder[] = ['uid' => (int)$c['uid'], 'label' => 'vitec_card (icon_card) ' . $c['header']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo " Neue Reihenfolge colPos 0:\n";
|
||||||
|
$sorting = 0;
|
||||||
|
foreach ($newOrder as $el) {
|
||||||
|
$sorting += 256;
|
||||||
|
printf(" %5d #%-6d %s\n", $sorting, $el['uid'], $el['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$upCard = $pdo->prepare("UPDATE tt_content SET colPos = 0, tx_container_parent = 0, vitec_layout_variant = 'icon_card', tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
|
||||||
|
foreach ($cards as $c) {
|
||||||
|
$upCard->execute([(int)$c['uid']]);
|
||||||
|
}
|
||||||
|
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', $containerUids) . ')');
|
||||||
|
$upSort = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
|
||||||
|
$sorting = 0;
|
||||||
|
foreach ($newOrder as $el) {
|
||||||
|
$sorting += 256;
|
||||||
|
$upSort->execute([$sorting, $el['uid']]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
echo " GESCHRIEBEN\n";
|
||||||
|
}
|
||||||
|
$done++;
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ($apply ? 'FERTIG' : 'WUERDE') . ": $done Seite(n) umgebaut.\n";
|
||||||
|
if ($apply && $done > 0) {
|
||||||
|
echo "Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge " . implode(' ', $slugs) . " && vendor/bin/typo3 vitec:ssr-warm " . implode(' ', $slugs) . "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:client-error-cleanup',
|
||||||
|
description: 'Delete client error reports (tx_vitec_clienterrorlog) older than 30 days; run daily via cron',
|
||||||
|
)]
|
||||||
|
final class ClientErrorCleanupCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_clienterrorlog';
|
||||||
|
private const DEFAULT_DAYS = 30;
|
||||||
|
|
||||||
|
public function __construct(private readonly ConnectionPool $connectionPool)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$days = (int)$input->getOption('days');
|
||||||
|
if ($days < 1) {
|
||||||
|
$output->writeln('<error>--days must be at least 1</error>');
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$cutoff = time() - $days * 86400;
|
||||||
|
$conn = $this->connectionPool->getConnectionForTable(self::TABLE);
|
||||||
|
$deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]);
|
||||||
|
$remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne();
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'deleted %d client error report(s) older than %d days (before %s), %d remaining',
|
||||||
|
$deleted,
|
||||||
|
$days,
|
||||||
|
date('Y-m-d H:i', $cutoff),
|
||||||
|
$remaining
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the two internal audit pages for the page content check (decision
|
||||||
|
* Harry 2026-11-13, sibling of /products/product-check):
|
||||||
|
*
|
||||||
|
* /markets/market-check vitec_pagecheck, scope markets
|
||||||
|
* /solutions/solution-check vitec_pagecheck, scope solutions
|
||||||
|
*
|
||||||
|
* Page properties are taken from the product check page (/products/
|
||||||
|
* product-check: doktype, backend layout, no_index, nav_hide, nav_title
|
||||||
|
* handling) so all three audit pages behave alike. Idempotent: an existing
|
||||||
|
* slug is reported and skipped.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:pagecheck-pages # dry run (default)
|
||||||
|
* vendor/bin/typo3 vitec:pagecheck-pages --apply # creates
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:pagecheck-pages',
|
||||||
|
description: 'Create /markets/market-check and /solutions/solution-check with the vitec_pagecheck element (dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class CreateCheckPagesCommand extends Command
|
||||||
|
{
|
||||||
|
private const TEMPLATE_SLUG = '/products/product-check';
|
||||||
|
|
||||||
|
private const PAGES = [
|
||||||
|
['parentSlug' => '/markets', 'title' => 'Market Check', 'slug' => '/markets/market-check', 'header' => 'Market Content Check', 'scope' => 'markets'],
|
||||||
|
['parentSlug' => '/solutions', 'title' => 'Solution Check', 'slug' => '/solutions/solution-check', 'header' => 'Solution Content Check', 'scope' => 'solutions'],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Create the pages (default: dry run)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$apply = (bool)$input->getOption('apply');
|
||||||
|
$db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
|
||||||
|
$template = $db->fetchAssociative(
|
||||||
|
'SELECT uid, doktype, backend_layout, backend_layout_next_level, no_index, no_follow, nav_hide, no_search, seo_title, description FROM pages WHERE deleted = 0 AND slug = ?',
|
||||||
|
[self::TEMPLATE_SLUG]
|
||||||
|
);
|
||||||
|
if ($template === false) {
|
||||||
|
$output->writeln('<error>Template page ' . self::TEMPLATE_SLUG . ' not found.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'Template %s (uid %d): doktype %d, layout "%s", no_index %d, nav_hide %d, no_search %d',
|
||||||
|
self::TEMPLATE_SLUG, $template['uid'], $template['doktype'], $template['backend_layout'], $template['no_index'], $template['nav_hide'], $template['no_search']
|
||||||
|
));
|
||||||
|
$output->writeln($apply ? '<comment>APPLY</comment>' : 'DRY RUN - nothing is written (--apply to create)');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
foreach (self::PAGES as $def) {
|
||||||
|
$existing = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$def['slug']]);
|
||||||
|
if ($existing !== false) {
|
||||||
|
$output->writeln(sprintf(' %s exists (uid %d) - skipped', $def['slug'], $existing['uid']));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parent = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$def['parentSlug']]);
|
||||||
|
if ($parent === false) {
|
||||||
|
$output->writeln(sprintf(' <error>parent %s not found - %s skipped</error>', $def['parentSlug'], $def['slug']));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' create %s "%s" under %s (pid %d) + vitec_pagecheck "%s" (scope %s)', $def['slug'], $def['title'], $def['parentSlug'], $parent['uid'], $def['header'], $def['scope']));
|
||||||
|
$created++;
|
||||||
|
if (!$apply) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pageId = 'NEWpage' . $def['scope'];
|
||||||
|
$datamap = [
|
||||||
|
'pages' => [
|
||||||
|
$pageId => [
|
||||||
|
'pid' => -(int)$this->lastChild((int)$parent['uid'], (int)$parent['uid']),
|
||||||
|
'title' => $def['title'],
|
||||||
|
'slug' => $def['slug'],
|
||||||
|
'doktype' => (int)$template['doktype'],
|
||||||
|
'backend_layout' => (string)$template['backend_layout'],
|
||||||
|
'backend_layout_next_level' => (string)$template['backend_layout_next_level'],
|
||||||
|
'no_index' => (int)$template['no_index'],
|
||||||
|
'no_follow' => (int)$template['no_follow'],
|
||||||
|
'nav_hide' => (int)$template['nav_hide'],
|
||||||
|
'no_search' => (int)$template['no_search'],
|
||||||
|
'hidden' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'tt_content' => [
|
||||||
|
'NEWce' . $def['scope'] => [
|
||||||
|
'pid' => $pageId,
|
||||||
|
'colPos' => 0,
|
||||||
|
'sys_language_uid' => 0,
|
||||||
|
'CType' => 'vitec_pagecheck',
|
||||||
|
'header' => $def['header'],
|
||||||
|
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => ['settings.scope' => ['vDEF' => $def['scope']]]]]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($datamap, []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
|
||||||
|
$output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('%s: %d page(s).', $apply ? 'DONE' : 'DRY RUN - would create', $created));
|
||||||
|
if ($apply && $created > 0) {
|
||||||
|
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush');
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** uid of the last live child page (to append after it), or the parent itself when there is none */
|
||||||
|
private function lastChild(int $parentUid, int $fallback): int
|
||||||
|
{
|
||||||
|
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
||||||
|
'SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND pid = ? ORDER BY sorting DESC LIMIT 1',
|
||||||
|
[$parentUid]
|
||||||
|
);
|
||||||
|
return $uid === false ? $fallback : (int)$uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the product OVERVIEW pages (/products/<slug>, layout 9, built by
|
||||||
|
* vitec:build-product-overviews on 2026-11-02) with the agency's editorial
|
||||||
|
* copy from the "<Overview> - Enriched.xlsx" workbooks (Dropbox delivery
|
||||||
|
* 2026-11-09, staged in migrations/product_overviews_xlsx_in/).
|
||||||
|
*
|
||||||
|
* Workbook shape: sheet "Content" with rows "Section | Content field |
|
||||||
|
* Website copy / value | Production notes", sections 01 HERO, 02 INTRO,
|
||||||
|
* 03 WHY VITEC, 04 COMPARISON, 05 PRODUCT RANGE, 06 SOLUTIONS, 07 CTA,
|
||||||
|
* 08 SEO. The page is found by its title (= the workbook's H1).
|
||||||
|
*
|
||||||
|
* The pages already carry generated content (hero, optional "Why VITEC?"
|
||||||
|
* icon band, "Product range" intro, product card grids, contact banner).
|
||||||
|
* This importer UPDATES in place and only ADDS what is missing:
|
||||||
|
*
|
||||||
|
* 01 HERO -> hero bodytext (subline) + CTA label; title stays
|
||||||
|
* 02 INTRO -> NEW introparagraph (H2 + introduction) at the top of
|
||||||
|
* the main column, plus the use-case paragraph
|
||||||
|
* 03 WHY VITEC -> page with icon band: benefit copy goes onto the card
|
||||||
|
* whose title matches the benefit heading (theme match
|
||||||
|
* like vitec:build-why-vitec-bands), unmatched benefits
|
||||||
|
* are REPORTED only (the band is the agency's icon set);
|
||||||
|
* page without band: NEW band of icon_card cards without
|
||||||
|
* icon (0106-FE: the frontend builds the band from
|
||||||
|
* icon_card only - text_only cards fall apart; a band
|
||||||
|
* card still carrying text_only is repaired on re-run)
|
||||||
|
* 04 COMPARISON -> skipped unless --comparison: the workbook holds no
|
||||||
|
* specifications ("populate from approved datasheets"),
|
||||||
|
* only the card descriptions again
|
||||||
|
* 05 PRODUCT RANGE -> the "Product range" intro gets the H2 + intro copy;
|
||||||
|
* product CARDS stay untouched (they show the record
|
||||||
|
* teasers from the product workbooks - the overview
|
||||||
|
* workbook texts are mostly boilerplate)
|
||||||
|
* 06 SOLUTIONS -> NEW intro + one text_only card per solution, linked
|
||||||
|
* to the solution group page under /solutions
|
||||||
|
* 07 CTA -> contact banner header/body/button (link stays)
|
||||||
|
* 08 SEO -> seo/og/twitter title + description, keywords, abstract
|
||||||
|
*
|
||||||
|
* Main-column order after apply: intro, use case, Why VITEC band,
|
||||||
|
* [comparison], range intro, product cards (unchanged), solutions.
|
||||||
|
* Idempotent: existing elements are found by header; nothing is duplicated.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:import-product-overview-workbooks
|
||||||
|
* vendor/bin/typo3 vitec:import-product-overview-workbooks --only=aetria --apply
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:import-product-overview-workbooks',
|
||||||
|
description: 'Fill the product overview pages from the agency "Enriched" workbooks (dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class ImportProductOverviewWorkbooksCommand extends Command
|
||||||
|
{
|
||||||
|
private const COLPOS_HERO = 1;
|
||||||
|
private const COLPOS_MAIN = 0;
|
||||||
|
private const COLPOS_PREFOOTER = 3;
|
||||||
|
private const WHY_HEADER = 'Why VITEC?';
|
||||||
|
private const RANGE_PLACEHOLDER = 'Product range';
|
||||||
|
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
|
||||||
|
|
||||||
|
/** workbook H1 (post-norm) => page slug where the title match fails */
|
||||||
|
private const TITLE_TO_SLUG = [
|
||||||
|
'xplayerendpoints' => '/products/x-player-end-points',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** workbook solution names that differ from the group page titles beyond spelling (post-norm) */
|
||||||
|
private const SOLUTION_ALIASES = [
|
||||||
|
'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||||
|
private array $datamap = [];
|
||||||
|
/** @var string[] */
|
||||||
|
private array $report = [];
|
||||||
|
private int $newCounter = 0;
|
||||||
|
private bool $apply = false;
|
||||||
|
private bool $withComparison = false;
|
||||||
|
private int $touched = 0;
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_overviews_xlsx_in');
|
||||||
|
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
|
||||||
|
$this->addOption('comparison', null, InputOption::VALUE_NONE, 'Also create the comparison table (text element)');
|
||||||
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$this->apply = (bool)$input->getOption('apply');
|
||||||
|
$this->withComparison = (bool)$input->getOption('comparison');
|
||||||
|
$only = mb_strtolower((string)$input->getOption('only'));
|
||||||
|
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||||
|
|
||||||
|
$files = glob($dir . '/*.xlsx') ?: [];
|
||||||
|
$files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
|
||||||
|
&& ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
|
||||||
|
sort($files);
|
||||||
|
$output->writeln(($this->apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($files) . ' workbook(s) in ' . $dir
|
||||||
|
. ($this->withComparison ? ' · with comparison table' : ' · comparison table skipped'));
|
||||||
|
|
||||||
|
$ok = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
if ($this->importOne($file, $output)) {
|
||||||
|
$ok++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
"\n<info>%d of %d workbook(s) %s, %d element/page write(s).</info>%s",
|
||||||
|
$ok, count($files), $this->apply ? 'applied' : 'planned', $this->touched,
|
||||||
|
$this->apply ? ' Flush the cache, then vitec:ssr-purge --all + vitec:ssr-warm.' : ' DRY RUN - nothing written. Add --apply to write.'
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importOne(string $file, OutputInterface $output): bool
|
||||||
|
{
|
||||||
|
$this->datamap = [];
|
||||||
|
$this->report = [];
|
||||||
|
$wb = $this->readWorkbook($file);
|
||||||
|
$h1 = $wb['hero']['h1'] ?? '';
|
||||||
|
if ($h1 === '') {
|
||||||
|
$output->writeln('<comment> ' . basename($file) . ': no "01 · HERO / H1" row - skipped</comment>');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$page = $this->pageByTitle($h1);
|
||||||
|
if ($page === null) {
|
||||||
|
$output->writeln('<error> ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped'
|
||||||
|
. ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . '</error>');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$pageUid = (int)$page['uid'];
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> -> %s (#%d)", basename($file), $page['slug'], $pageUid));
|
||||||
|
|
||||||
|
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||||
|
$conn = $pool->getConnectionForTable('tt_content');
|
||||||
|
$top = $conn->fetchAllAssociative(
|
||||||
|
'SELECT uid, colPos, CType, header, bodytext, vitec_layout_variant, vitec_primary_cta_label, vitec_cta_label FROM tt_content
|
||||||
|
WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)
|
||||||
|
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
|
||||||
|
ORDER BY colPos, sorting, uid',
|
||||||
|
[$pageUid]
|
||||||
|
);
|
||||||
|
$main = array_values(array_filter($top, static fn(array $r): bool => (int)$r['colPos'] === self::COLPOS_MAIN));
|
||||||
|
$byUid = [];
|
||||||
|
foreach ($main as $row) {
|
||||||
|
$byUid[(string)$row['uid']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 01 hero ------------------------------------------------------------------
|
||||||
|
$hero = $this->first($top, self::COLPOS_HERO, 'vitec_herosection');
|
||||||
|
if ($hero === null) {
|
||||||
|
$this->report[] = 'hero: NO vitec_herosection in colPos 1 - subline/CTA not placed';
|
||||||
|
} else {
|
||||||
|
$this->update((string)$hero['uid'], $hero, [
|
||||||
|
'bodytext' => $this->p($wb['hero']['subline'] ?? ''),
|
||||||
|
'vitec_cta_label' => $wb['hero']['cta'] ?? '',
|
||||||
|
], 'hero #' . $hero['uid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 02 intro + use case ----------------------------------------------------------
|
||||||
|
$introH2 = ($wb['intro']['h2'] ?? '') !== '' ? $wb['intro']['h2'] : $h1;
|
||||||
|
$introId = $this->ensureIntro($main, $byUid, $pageUid, $introH2, $wb['intro']['body'] ?? '', 'intro');
|
||||||
|
$useId = null;
|
||||||
|
if (($wb['intro']['usecase_body'] ?? '') !== '') {
|
||||||
|
$useHeading = ($wb['intro']['usecase_heading'] ?? '') !== '' ? $wb['intro']['usecase_heading'] : 'Applications and workflows';
|
||||||
|
$useId = $this->ensureIntro($main, $byUid, $pageUid, $useHeading, $wb['intro']['usecase_body'], 'use case');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 03 why vitec -----------------------------------------------------------------
|
||||||
|
$whyIds = [];
|
||||||
|
$whyIndex = $this->indexOf($main, 'vitec_introparagraph', self::WHY_HEADER);
|
||||||
|
if ($whyIndex !== null) {
|
||||||
|
$whyIds[] = (string)$main[$whyIndex]['uid'];
|
||||||
|
$cards = [];
|
||||||
|
for ($i = $whyIndex + 1; $i < count($main) && $main[$i]['CType'] === 'vitec_card'; $i++) {
|
||||||
|
$cards[] = $main[$i];
|
||||||
|
$whyIds[] = (string)$main[$i]['uid'];
|
||||||
|
}
|
||||||
|
$used = [];
|
||||||
|
foreach ($wb['why']['benefits'] as $benefit) {
|
||||||
|
$match = null;
|
||||||
|
foreach ($cards as $card) {
|
||||||
|
if (isset($used[(string)$card['uid']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->norm((string)$card['header']) === $this->norm($benefit['heading']) || $this->sameTheme($benefit['heading'], (string)$card['header'])) {
|
||||||
|
$match = $card;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($match === null) {
|
||||||
|
$this->report[] = 'why: benefit "' . $benefit['heading'] . '" has no matching icon card - NOT placed (band = agency icon set; editorial decision)';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$used[(string)$match['uid']] = true;
|
||||||
|
if ((string)$match['vitec_layout_variant'] !== 'icon_card') {
|
||||||
|
// 0106-FE: band cards must be icon_card (first run wrote text_only)
|
||||||
|
$this->update((string)$match['uid'], $match, ['vitec_layout_variant' => 'icon_card'], 'why card "' . $match['header'] . '" -> icon_card');
|
||||||
|
}
|
||||||
|
if (trim(strip_tags((string)$match['bodytext'])) !== '' && $this->norm(strip_tags((string)$match['bodytext'])) !== $this->norm($benefit['body'])) {
|
||||||
|
$this->report[] = 'why: card "' . $match['header'] . '" already has own text - left as is';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->update((string)$match['uid'], $match, ['bodytext' => $this->p($benefit['body'])], 'why card "' . $match['header'] . '" <- "' . $benefit['heading'] . '"');
|
||||||
|
}
|
||||||
|
} elseif ($wb['why']['benefits'] !== []) {
|
||||||
|
$id = $this->create($pageUid, 'vitec_introparagraph', ['header' => self::WHY_HEADER, 'bodytext' => ''], 'why: NEW band intro');
|
||||||
|
$whyIds[] = $id;
|
||||||
|
foreach ($wb['why']['benefits'] as $benefit) {
|
||||||
|
$whyIds[] = $this->create($pageUid, 'vitec_card', [
|
||||||
|
'vitec_layout_variant' => 'icon_card',
|
||||||
|
'header' => $benefit['heading'],
|
||||||
|
'bodytext' => $this->p($benefit['body']),
|
||||||
|
'vitec_primary_cta_label' => '',
|
||||||
|
], 'why: NEW card "' . $benefit['heading'] . '" (icon_card, no icon)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 04 comparison (opt-in) ------------------------------------------------------------
|
||||||
|
$comparisonId = null;
|
||||||
|
if ($this->withComparison && ($wb['comparison']['rows'] ?? []) !== []) {
|
||||||
|
$h2 = $this->fixCase($wb['comparison']['h2'] ?? 'Compare ' . $h1, $h1);
|
||||||
|
$body = $this->p($wb['comparison']['intro'] ?? '')
|
||||||
|
. '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Description</th></tr></thead><tbody>';
|
||||||
|
foreach ($wb['comparison']['rows'] as $row) {
|
||||||
|
$body .= '<tr><td>' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '</td><td>'
|
||||||
|
. htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
|
||||||
|
}
|
||||||
|
$body .= '</tbody></table>';
|
||||||
|
$idx = $this->indexOf($main, 'text', $h2);
|
||||||
|
if ($idx !== null) {
|
||||||
|
$comparisonId = (string)$main[$idx]['uid'];
|
||||||
|
$this->update($comparisonId, $main[$idx], ['bodytext' => $body], 'comparison #' . $comparisonId);
|
||||||
|
} else {
|
||||||
|
$comparisonId = $this->create($pageUid, 'text', ['header' => $h2, 'bodytext' => $body], 'comparison: NEW text element (' . count($wb['comparison']['rows']) . ' rows)');
|
||||||
|
}
|
||||||
|
} elseif (($wb['comparison']['rows'] ?? []) !== []) {
|
||||||
|
$this->report[] = 'comparison: ' . count($wb['comparison']['rows']) . ' row(s) skipped (no specifications in the workbook; --comparison to create)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 05 product range intro ----------------------------------------------------------
|
||||||
|
$rangeH2 = $this->fixCase(($wb['range']['h2'] ?? '') !== '' ? $wb['range']['h2'] : ('Explore the ' . $h1 . ' range'), $h1);
|
||||||
|
$rangeIdx = $this->indexOf($main, 'vitec_introparagraph', $rangeH2) ?? $this->indexOf($main, 'vitec_introparagraph', self::RANGE_PLACEHOLDER);
|
||||||
|
if ($rangeIdx !== null) {
|
||||||
|
$rangeId = (string)$main[$rangeIdx]['uid'];
|
||||||
|
$this->update($rangeId, $main[$rangeIdx], ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range intro #' . $rangeId);
|
||||||
|
} else {
|
||||||
|
$rangeId = $this->create($pageUid, 'vitec_introparagraph', ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range: NEW intro (grouped page)');
|
||||||
|
}
|
||||||
|
$names = array_map(static fn(array $p): string => $p['name'], $wb['range']['products'] ?? []);
|
||||||
|
$cardsOnPage = (int)$conn->fetchOne(
|
||||||
|
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_card' AND colPos >= 200",
|
||||||
|
[$pageUid]
|
||||||
|
);
|
||||||
|
$this->report[] = 'range: ' . count($names) . ' workbook product(s), ' . $cardsOnPage . ' card(s) in grids on the page - cards untouched (record teasers win)';
|
||||||
|
|
||||||
|
// --- 06 solutions ------------------------------------------------------------------------
|
||||||
|
$solIds = [];
|
||||||
|
if (($wb['solutions']['items'] ?? []) !== []) {
|
||||||
|
$h2 = $this->fixCase(($wb['solutions']['h2'] ?? '') !== '' ? $wb['solutions']['h2'] : ('Solutions using ' . $h1), $h1);
|
||||||
|
$idx = $this->indexOf($main, 'vitec_introparagraph', $h2);
|
||||||
|
if ($idx !== null) {
|
||||||
|
$this->report[] = 'solutions: intro "' . $h2 . '" exists - block left as is';
|
||||||
|
} else {
|
||||||
|
$solIds[] = $this->create($pageUid, 'vitec_introparagraph', ['header' => $h2, 'bodytext' => ''], 'solutions: NEW intro "' . $h2 . '"');
|
||||||
|
foreach ($wb['solutions']['items'] as $item) {
|
||||||
|
$fields = [
|
||||||
|
'vitec_layout_variant' => 'text_only',
|
||||||
|
'header' => $item['title'],
|
||||||
|
'bodytext' => $this->p($item['text']),
|
||||||
|
'vitec_primary_cta_label' => 'Learn more',
|
||||||
|
];
|
||||||
|
$target = $this->resolveSolutionPage($item['title']);
|
||||||
|
if ($target > 0) {
|
||||||
|
$fields['vitec_primary_cta'] = 't3://page?uid=' . $target;
|
||||||
|
}
|
||||||
|
$solIds[] = $this->create($pageUid, 'vitec_card', $fields, 'solutions: NEW card "' . $item['title'] . '" -> ' . ($target > 0 ? 'page #' . $target : 'NO solution page resolved (link empty)'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 07 cta banner -------------------------------------------------------------------------
|
||||||
|
$cta = $this->first($top, self::COLPOS_PREFOOTER, 'vitec_ctabanner');
|
||||||
|
if ($cta === null) {
|
||||||
|
$this->report[] = 'cta: NO vitec_ctabanner in colPos 3 - not placed';
|
||||||
|
} elseif (($wb['cta']['h2'] ?? '') !== '') {
|
||||||
|
$this->update((string)$cta['uid'], $cta, [
|
||||||
|
'header' => $this->fixCase($wb['cta']['h2'], $h1),
|
||||||
|
'bodytext' => $this->p($wb['cta']['body'] ?? ''),
|
||||||
|
'vitec_primary_cta_label' => ($wb['cta']['button'] ?? '') !== '' ? $wb['cta']['button'] : 'Contact us',
|
||||||
|
], 'cta #' . $cta['uid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 08 seo / page properties -------------------------------------------------------------
|
||||||
|
$seoTitle = ($wb['seo']['title'] ?? '') !== '' ? $wb['seo']['title'] : $h1 . ' | VITEC';
|
||||||
|
$desc = $wb['seo']['description'] ?? '';
|
||||||
|
$keywords = implode(', ', array_filter([$wb['seo']['primary'] ?? '', $wb['seo']['secondary'] ?? '']));
|
||||||
|
$props = [
|
||||||
|
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
|
||||||
|
'description' => $desc, 'og_description' => $desc, 'twitter_description' => $desc,
|
||||||
|
'keywords' => $keywords,
|
||||||
|
'abstract' => mb_substr(trim(($wb['intro']['body'] ?? '') !== '' ? $wb['intro']['body'] : $desc), 0, 300),
|
||||||
|
];
|
||||||
|
$changed = [];
|
||||||
|
foreach ($props as $field => $value) {
|
||||||
|
if (trim((string)($page[$field] ?? '')) !== trim($value)) {
|
||||||
|
$changed[] = $field;
|
||||||
|
$this->datamap['pages'][(string)$pageUid][$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($changed !== []) {
|
||||||
|
$this->touched++;
|
||||||
|
$this->report[] = 'page meta: ' . implode(', ', $changed);
|
||||||
|
} else {
|
||||||
|
$this->report[] = 'page meta: unchanged';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->report as $line) {
|
||||||
|
$output->writeln(' ' . $line);
|
||||||
|
}
|
||||||
|
if (!$this->apply) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write -----------------------------------------------------------------------------------
|
||||||
|
$subst = [];
|
||||||
|
if ($this->datamap !== []) {
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($this->datamap, []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||||
|
}
|
||||||
|
$subst = $dataHandler->substNEWwithIDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// final main-column order: intro, use case, why band, [comparison], range intro, the rest (cards/grids/group intros), solutions
|
||||||
|
$order = array_merge([$introId], $useId !== null ? [$useId] : [], $whyIds, $comparisonId !== null ? [$comparisonId] : [], [$rangeId]);
|
||||||
|
$placed = array_flip($order);
|
||||||
|
foreach ($main as $row) {
|
||||||
|
if (!isset($placed[(string)$row['uid']])) {
|
||||||
|
$order[] = (string)$row['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$order = array_merge($order, $solIds);
|
||||||
|
$sorting = 0;
|
||||||
|
foreach ($order as $id) {
|
||||||
|
$uid = str_starts_with($id, 'NEW') ? (int)($subst[$id] ?? 0) : (int)$id;
|
||||||
|
if ($uid > 0) {
|
||||||
|
$sorting += 256;
|
||||||
|
$conn->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- element helpers ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @param array<int,array<string,mixed>> $rows */
|
||||||
|
private function first(array $rows, int $colPos, string $ctype): ?array
|
||||||
|
{
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ((int)$row['colPos'] === $colPos && $row['CType'] === $ctype) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,array<string,mixed>> $main */
|
||||||
|
private function indexOf(array $main, string $ctype, string $header): ?int
|
||||||
|
{
|
||||||
|
$needle = $this->norm($header);
|
||||||
|
foreach ($main as $i => $row) {
|
||||||
|
if ($row['CType'] === $ctype && $this->norm((string)$row['header']) === $needle) {
|
||||||
|
return $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing introparagraph with this header (updated) or a new one.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $main
|
||||||
|
* @param array<string,array<string,mixed>> $byUid
|
||||||
|
*/
|
||||||
|
private function ensureIntro(array $main, array $byUid, int $pageUid, string $header, string $body, string $label): string
|
||||||
|
{
|
||||||
|
$idx = $this->indexOf($main, 'vitec_introparagraph', $header);
|
||||||
|
if ($idx !== null) {
|
||||||
|
$id = (string)$main[$idx]['uid'];
|
||||||
|
$this->update($id, $byUid[$id], ['header' => $header, 'bodytext' => $this->p($body)], $label . ' #' . $id);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
return $this->create($pageUid, 'vitec_introparagraph', ['header' => $header, 'bodytext' => $this->p($body)], $label . ': NEW "' . $header . '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $fields */
|
||||||
|
private function create(int $pageUid, string $ctype, array $fields, string $label): string
|
||||||
|
{
|
||||||
|
$id = $this->newId();
|
||||||
|
$this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => self::COLPOS_MAIN, 'CType' => $ctype] + $fields;
|
||||||
|
$this->report[] = $label;
|
||||||
|
$this->touched++;
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes only fields whose current value differs (RTE-tolerant on bodytext).
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $current
|
||||||
|
* @param array<string,string> $fields
|
||||||
|
*/
|
||||||
|
private function update(string $uid, array $current, array $fields, string $label): void
|
||||||
|
{
|
||||||
|
$diff = [];
|
||||||
|
foreach ($fields as $field => $value) {
|
||||||
|
$old = (string)($current[$field] ?? '');
|
||||||
|
$same = $field === 'bodytext'
|
||||||
|
? $this->norm(strip_tags($old)) === $this->norm(strip_tags($value))
|
||||||
|
: trim($old) === trim($value);
|
||||||
|
if (!$same) {
|
||||||
|
$diff[] = $field;
|
||||||
|
$this->datamap['tt_content'][$uid][$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($diff === []) {
|
||||||
|
$this->report[] = $label . ': unchanged';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->touched++;
|
||||||
|
$this->report[] = $label . ': ' . implode(', ', $diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolution --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function pageByTitle(string $title): ?array
|
||||||
|
{
|
||||||
|
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$needle = $this->norm($title);
|
||||||
|
$rows = $pages->fetchAllAssociative(
|
||||||
|
"SELECT uid, slug, title, nav_title, seo_title, og_title, twitter_title, description, og_description, twitter_description, keywords, abstract
|
||||||
|
FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND slug LIKE '/products/%'"
|
||||||
|
);
|
||||||
|
if (isset(self::TITLE_TO_SLUG[$needle])) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row['slug'] === self::TITLE_TO_SLUG[$needle]) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['title']) === $needle));
|
||||||
|
if (count($hits) === 1) {
|
||||||
|
return $hits[0];
|
||||||
|
}
|
||||||
|
if (count($hits) > 1) {
|
||||||
|
// prefer the flat overview slug (one segment below /products)
|
||||||
|
$flat = array_values(array_filter($hits, static fn(array $r): bool => substr_count((string)$r['slug'], '/') === 2));
|
||||||
|
if (count($flat) === 1) {
|
||||||
|
return $flat[0];
|
||||||
|
}
|
||||||
|
$this->report[] = 'ambiguous title "' . $title . '": ' . implode(', ', array_map(static fn(array $r): string => $r['slug'], $hits));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['nav_title']) === $needle));
|
||||||
|
return count($hits) === 1 ? $hits[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSolutionPage(string $title): int
|
||||||
|
{
|
||||||
|
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$root = $pages->fetchOne("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
|
||||||
|
if (!$root) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$needle = $this->norm($title);
|
||||||
|
$needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
|
||||||
|
$strip = static fn(string $n): string => (string)preg_replace('/solutions$/', '', $n);
|
||||||
|
$rows = $pages->fetchAllAssociative(
|
||||||
|
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) AND doktype = 1',
|
||||||
|
[(int)$root]
|
||||||
|
);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $needle) {
|
||||||
|
return (int)$row['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($strip($this->norm((string)$row['title'])) === $strip($needle)) {
|
||||||
|
return (int)$row['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- workbook reading --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
private function readWorkbook(string $file): array
|
||||||
|
{
|
||||||
|
$spreadsheet = IOFactory::load($file);
|
||||||
|
$sheet = $spreadsheet->getSheetByName('Content') ?? $spreadsheet->getSheet($spreadsheet->getSheetCount() - 1);
|
||||||
|
$rows = $sheet->toArray(null, false, false, false);
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
|
||||||
|
$wb = ['hero' => [], 'intro' => [], 'why' => ['benefits' => []], 'comparison' => ['rows' => []],
|
||||||
|
'range' => ['products' => []], 'solutions' => ['items' => []], 'cta' => [], 'seo' => []];
|
||||||
|
$benefits = [];
|
||||||
|
$products = [];
|
||||||
|
$solutions = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$section = trim((string)($row[0] ?? ''));
|
||||||
|
$field = trim((string)($row[1] ?? ''));
|
||||||
|
$value = trim((string)($row[2] ?? ''));
|
||||||
|
if ($section === '' || $field === '' || !preg_match('/^(\d\d)/', $section, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$f = mb_strtolower($field);
|
||||||
|
switch ($m[1]) {
|
||||||
|
case '01':
|
||||||
|
if ($f === 'h1') {
|
||||||
|
$wb['hero']['h1'] = $value;
|
||||||
|
} elseif (str_contains($f, 'subline')) {
|
||||||
|
$wb['hero']['subline'] = $value;
|
||||||
|
} elseif (str_contains($f, 'cta')) {
|
||||||
|
$wb['hero']['cta'] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '02':
|
||||||
|
if ($f === 'h2') {
|
||||||
|
$wb['intro']['h2'] = $value;
|
||||||
|
} elseif ($f === 'introduction') {
|
||||||
|
$wb['intro']['body'] = $value;
|
||||||
|
} elseif (str_contains($f, 'use-case card heading')) {
|
||||||
|
$wb['intro']['usecase_heading'] = $value;
|
||||||
|
} elseif (str_contains($f, 'use-case description')) {
|
||||||
|
$wb['intro']['usecase_body'] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '03':
|
||||||
|
if (preg_match('/^benefit\s*(\d+)\s*[—–-]+\s*(heading|body)$/u', $f, $b)) {
|
||||||
|
$benefits[(int)$b[1]][$b[2]] = $value;
|
||||||
|
} elseif (str_contains($f, 'h2')) {
|
||||||
|
$wb['why']['h2'] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '04':
|
||||||
|
if ($f === 'h2') {
|
||||||
|
$wb['comparison']['h2'] = $value;
|
||||||
|
} elseif ($f === 'intro') {
|
||||||
|
$wb['comparison']['intro'] = $value;
|
||||||
|
} elseif (preg_match('/^product\s*\d+\s*:\s*(.+)$/iu', $field, $c)) {
|
||||||
|
$wb['comparison']['rows'][] = ['name' => trim($c[1]), 'text' => $value];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '05':
|
||||||
|
if ($f === 'h2') {
|
||||||
|
$wb['range']['h2'] = $value;
|
||||||
|
} elseif ($f === 'intro') {
|
||||||
|
$wb['range']['intro'] = $value;
|
||||||
|
} elseif (preg_match('/^product\s*(\d+)\s*[—–-]+\s*(group|name|description|cta|target url)$/u', $f, $p)) {
|
||||||
|
$products[(int)$p[1]][$p[2]] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '06':
|
||||||
|
if ($f === 'h2') {
|
||||||
|
$wb['solutions']['h2'] = $value;
|
||||||
|
} elseif (preg_match('/^solution\s*(\d+)\s*[—–-]+\s*(title|description|link)$/u', $f, $s)) {
|
||||||
|
$solutions[(int)$s[1]][$s[2]] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '07':
|
||||||
|
if ($f === 'h2') {
|
||||||
|
$wb['cta']['h2'] = $value;
|
||||||
|
} elseif ($f === 'body') {
|
||||||
|
$wb['cta']['body'] = $value;
|
||||||
|
} elseif ($f === 'button') {
|
||||||
|
$wb['cta']['button'] = $value;
|
||||||
|
} elseif ($f === 'url') {
|
||||||
|
$wb['cta']['url'] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '08':
|
||||||
|
if (str_contains($f, 'seo title')) {
|
||||||
|
$wb['seo']['title'] = $value;
|
||||||
|
} elseif (str_contains($f, 'meta description')) {
|
||||||
|
$wb['seo']['description'] = $value;
|
||||||
|
} elseif (str_contains($f, 'primary keyword')) {
|
||||||
|
$wb['seo']['primary'] = $value;
|
||||||
|
} elseif (str_contains($f, 'secondary keyword')) {
|
||||||
|
$wb['seo']['secondary'] = $value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($benefits);
|
||||||
|
foreach ($benefits as $b) {
|
||||||
|
if (($b['heading'] ?? '') !== '') {
|
||||||
|
$wb['why']['benefits'][] = ['heading' => $b['heading'], 'body' => $b['body'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($products);
|
||||||
|
foreach ($products as $p) {
|
||||||
|
if (($p['name'] ?? '') !== '') {
|
||||||
|
$wb['range']['products'][] = ['group' => $p['group'] ?? '', 'name' => $p['name'], 'text' => $p['description'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($solutions);
|
||||||
|
foreach ($solutions as $s) {
|
||||||
|
if (($s['title'] ?? '') !== '') {
|
||||||
|
$wb['solutions']['items'][] = ['title' => $s['title'], 'text' => $s['description'] ?? '', 'link' => $s['link'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $wb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- text helpers --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** "Compare aetria" -> "Compare Aetria": the template lower-cased the product name inside the H2s */
|
||||||
|
private function fixCase(string $text, string $h1): string
|
||||||
|
{
|
||||||
|
$lower = mb_strtolower($h1);
|
||||||
|
if ($lower === '' || $lower === $h1) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
return (string)preg_replace('/' . preg_quote($lower, '/') . '/u', $h1, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sameTheme(string $a, string $b): bool
|
||||||
|
{
|
||||||
|
$words = fn(string $text): array => array_values(array_diff(
|
||||||
|
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
|
||||||
|
self::STOPWORDS
|
||||||
|
));
|
||||||
|
$x = $words($a);
|
||||||
|
$y = $words($b);
|
||||||
|
if ($x === [] || count($x) !== count($y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ($x as $i => $word) {
|
||||||
|
if (!str_starts_with($y[$i], $word) && !str_starts_with($word, $y[$i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function p(string $text): string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
$value = mb_strtolower($value);
|
||||||
|
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newId(): string
|
||||||
|
{
|
||||||
|
return 'NEWOV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Resource\ResourceStorage;
|
||||||
|
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||||
|
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket 0124-FE (2026-11-12): Joe aligned the market pages with Figma, the
|
||||||
|
* front end renders the new bands (0118-FE) - this command creates the
|
||||||
|
* content they need. Per main market page (/markets/<slug>, 11 pages):
|
||||||
|
*
|
||||||
|
* B1 vitec_marketintro: numbered placeholder image placeholders/pages/<uid>-02.png
|
||||||
|
* when the element has no image yet; learnmore_target "/contact" when empty.
|
||||||
|
* B2 vitec_customerlogos ("Trusted by"): the 2026-10-23 page import left a
|
||||||
|
* HIDDEN shell after the sub-market cards - it is unhidden and gets the
|
||||||
|
* settings of the home page element (#758: layout, customer selection).
|
||||||
|
* A page without a shell gets a copy of #758 instead.
|
||||||
|
* B3 vitec_usecaselist ("Case studies" shell, hidden as well): unhidden as
|
||||||
|
* layout testimonials. Stories = what an editor already picked on the
|
||||||
|
* shell, else the union of the market's sub-market testimonial lists,
|
||||||
|
* else the most common sub-market list. No shell: a new element after
|
||||||
|
* the logos, i.e. before the CTA banner.
|
||||||
|
* B5 the lead paragraph in the hero slot (vitec_introparagraph, colPos 1)
|
||||||
|
* is HIDDEN, not deleted (decision aehm 12.11.: copy raw material for Joe).
|
||||||
|
*
|
||||||
|
* Sub-market pages (children of the 11):
|
||||||
|
* B4 vitec_marketinsights: cta_target /success-stories -> /insights/news,
|
||||||
|
* label "Explore success stories" -> "Explore our insights" (placeholder).
|
||||||
|
*
|
||||||
|
* Idempotent: elements that already exist are left alone, filled fields are
|
||||||
|
* never overwritten. Everything goes through DataHandler (history, refindex,
|
||||||
|
* SSR auto-purge), still run cache:flush + vitec:ssr-purge/-warm afterwards -
|
||||||
|
* the command prints the exact lines.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:markets-figma-elements # dry run (default)
|
||||||
|
* vendor/bin/typo3 vitec:markets-figma-elements --apply # writes
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:markets-figma-elements',
|
||||||
|
description: 'Create the Figma structure elements on the 11 main market pages (0124-FE, dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class MarketsFigmaElementsCommand extends Command
|
||||||
|
{
|
||||||
|
private const MARKET_SLUGS = [
|
||||||
|
'/markets/broadcast-and-telecoms',
|
||||||
|
'/markets/corporation',
|
||||||
|
'/markets/critical-national-infrastructure',
|
||||||
|
'/markets/defense-and-security',
|
||||||
|
'/markets/education',
|
||||||
|
'/markets/government',
|
||||||
|
'/markets/hospitality',
|
||||||
|
'/markets/industrial',
|
||||||
|
'/markets/public-safety',
|
||||||
|
'/markets/sports-and-entertainment',
|
||||||
|
'/markets/transport',
|
||||||
|
];
|
||||||
|
private const LOGO_TEMPLATE_UID = 758;
|
||||||
|
private const STORY_SINGLE_PID = 26;
|
||||||
|
private const CONTACT_TARGET = '/contact';
|
||||||
|
private const LEARNMORE_LABEL = 'Talk to our team';
|
||||||
|
private const OLD_INSIGHTS_TARGET = '/success-stories';
|
||||||
|
private const NEW_INSIGHTS_TARGET = '/insights/news';
|
||||||
|
private const OLD_INSIGHTS_LABEL = 'Explore success stories';
|
||||||
|
private const NEW_INSIGHTS_LABEL = 'Explore our insights';
|
||||||
|
private const TESTIMONIALS_HEADER = 'Testimonials from the Field';
|
||||||
|
private const GENERIC_PLACEHOLDER = 'placeholders/placeholder-grey.png';
|
||||||
|
|
||||||
|
private Connection $db;
|
||||||
|
private ResourceStorage $storage;
|
||||||
|
private OutputInterface $output;
|
||||||
|
private bool $apply = false;
|
||||||
|
|
||||||
|
/** @var array<string,int> */
|
||||||
|
private array $counts = [
|
||||||
|
'images' => 0, 'targets' => 0, 'labels' => 0, 'logos' => 0, 'testimonials' => 0,
|
||||||
|
'hiddenLeads' => 0, 'insightsTargets' => 0, 'insightsLabels' => 0, 'warnings' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write the changes (default: dry run)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$this->output = $output;
|
||||||
|
$this->apply = (bool)$input->getOption('apply');
|
||||||
|
$this->db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
$this->storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||||
|
|
||||||
|
$output->writeln($this->apply ? '<comment>APPLY - writing via DataHandler</comment>' : 'DRY RUN - nothing is written (--apply to write)');
|
||||||
|
$output->writeln('');
|
||||||
|
|
||||||
|
// --- template element for the logo band ------------------------------------
|
||||||
|
$template = $this->db->fetchAssociative(
|
||||||
|
'SELECT uid, pid, CType, header, hidden, deleted FROM tt_content WHERE uid = ?',
|
||||||
|
[self::LOGO_TEMPLATE_UID]
|
||||||
|
);
|
||||||
|
if (!$template || $template['CType'] !== 'vitec_customerlogos' || (int)$template['deleted'] === 1) {
|
||||||
|
$output->writeln(sprintf('<error>Logo template #%d is not a live vitec_customerlogos element.</error>', self::LOGO_TEMPLATE_UID));
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$templateSettings = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID]));
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'Logo template: #%d "%s" (page %d) - layout %s, customers %s, onlySelected %s',
|
||||||
|
$template['uid'], $template['header'], $template['pid'],
|
||||||
|
$templateSettings['layout'] ?? '-', ($templateSettings['customers'] ?? '') !== '' ? $templateSettings['customers'] : '(all)', $templateSettings['onlySelected'] ?? '0'
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- the 11 market pages -------------------------------------------------------
|
||||||
|
$markets = [];
|
||||||
|
foreach (self::MARKET_SLUGS as $slug) {
|
||||||
|
$page = $this->db->fetchAssociative(
|
||||||
|
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?',
|
||||||
|
[$slug]
|
||||||
|
);
|
||||||
|
if (!$page) {
|
||||||
|
$this->warn("page $slug NOT FOUND - skipped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$markets[(int)$page['uid']] = $page;
|
||||||
|
}
|
||||||
|
if ($markets === []) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$marketUids = array_keys($markets);
|
||||||
|
$in = implode(',', array_map('intval', $marketUids));
|
||||||
|
|
||||||
|
// --- sub-market testimonial lists (story selection for B3) ----------------------
|
||||||
|
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
|
||||||
|
$storiesByMarket = [];
|
||||||
|
$listFrequency = [];
|
||||||
|
$rows = $this->db->fetchAllAssociative(
|
||||||
|
"SELECT c.uid, c.pid, p.pid AS market, c.pi_flexform FROM tt_content c
|
||||||
|
JOIN pages p ON p.uid = c.pid
|
||||||
|
WHERE c.CType = 'vitec_usecaselist' AND c.deleted = 0 AND c.hidden = 0 AND c.sys_language_uid = 0
|
||||||
|
AND p.deleted = 0 AND p.pid IN ($in)
|
||||||
|
ORDER BY p.sorting, c.sorting"
|
||||||
|
);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$settings = $this->flexSettings((string)$row['pi_flexform']);
|
||||||
|
$uids = array_values(array_filter(array_map('intval', explode(',', (string)($settings['usecases'] ?? '')))));
|
||||||
|
if ($output->isVerbose()) {
|
||||||
|
$output->writeln(sprintf(' list #%d (page %d, market %d): layout=%s usecases=%d', $row['uid'], $row['pid'], $row['market'], $settings['layout'] ?? '-', count($uids)));
|
||||||
|
}
|
||||||
|
if (($settings['layout'] ?? '') !== 'testimonials' || $uids === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$market = (int)$row['market'];
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
if (!in_array($uid, $storiesByMarket[$market] ?? [], true)) {
|
||||||
|
$storiesByMarket[$market][] = $uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$key = implode(',', $uids);
|
||||||
|
$listFrequency[$key] = ($listFrequency[$key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
arsort($listFrequency);
|
||||||
|
$defaultStories = $listFrequency === [] ? [] : array_map('intval', explode(',', (string)array_key_first($listFrequency)));
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'Sub-market testimonial lists: %d element(s) on %d market(s); default list (most common, %dx): %s',
|
||||||
|
count($rows), count($storiesByMarket), $listFrequency === [] ? 0 : reset($listFrequency), implode(',', $defaultStories)
|
||||||
|
));
|
||||||
|
$output->writeln('');
|
||||||
|
|
||||||
|
$purgePaths = [];
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// main market pages
|
||||||
|
// =====================================================================
|
||||||
|
foreach ($markets as $pid => $page) {
|
||||||
|
$output->writeln(sprintf('<info>== %s (pid %d) "%s"</info>', $page['slug'], $pid, $page['title']));
|
||||||
|
$purgePaths[] = (string)$page['slug'];
|
||||||
|
|
||||||
|
$elements = $this->db->fetchAllAssociative(
|
||||||
|
'SELECT uid, CType, colPos, sorting, hidden, header, tx_container_parent,
|
||||||
|
vitec_learnmore_label, vitec_learnmore_target
|
||||||
|
FROM tt_content
|
||||||
|
WHERE pid = ? AND deleted = 0 AND sys_language_uid = 0
|
||||||
|
ORDER BY colPos, sorting, uid',
|
||||||
|
[$pid]
|
||||||
|
);
|
||||||
|
foreach ($elements as $el) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
' col %-3d %5d #%-6d %-22s %s%s',
|
||||||
|
$el['colPos'], $el['sorting'], $el['uid'], $el['CType'],
|
||||||
|
(int)$el['hidden'] === 1 ? '[hidden] ' : '',
|
||||||
|
$this->short((string)$el['header'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$topLevel = array_values(array_filter($elements, static fn(array $e): bool => (int)$e['tx_container_parent'] === 0));
|
||||||
|
$live = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['hidden'] === 0));
|
||||||
|
$col0 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 0));
|
||||||
|
$col1 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 1));
|
||||||
|
$col0All = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['colPos'] === 0));
|
||||||
|
|
||||||
|
$datamap = ['tt_content' => [], 'sys_file_reference' => []];
|
||||||
|
|
||||||
|
// --- B1: intro image + learn-more target --------------------------------
|
||||||
|
$intro = $this->first($col0, 'vitec_marketintro');
|
||||||
|
if ($intro === null) {
|
||||||
|
$this->warn('no live vitec_marketintro in colPos 0 - B1 skipped');
|
||||||
|
} else {
|
||||||
|
$introUid = (int)$intro['uid'];
|
||||||
|
$refs = (int)$this->db->fetchOne(
|
||||||
|
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_image' AND uid_foreign = ?",
|
||||||
|
[$introUid]
|
||||||
|
);
|
||||||
|
if ($refs > 0) {
|
||||||
|
$output->writeln(sprintf(' B1 intro #%d already has %d image(s) - unchanged', $introUid, $refs));
|
||||||
|
} else {
|
||||||
|
$identifier = 'placeholders/pages/' . $pid . '-02.png';
|
||||||
|
if (!$this->storage->hasFile($identifier)) {
|
||||||
|
$this->warn("$identifier missing - using generic " . self::GENERIC_PLACEHOLDER);
|
||||||
|
$identifier = self::GENERIC_PLACEHOLDER;
|
||||||
|
}
|
||||||
|
if (!$this->storage->hasFile($identifier)) {
|
||||||
|
$this->warn("$identifier missing as well - no image attached");
|
||||||
|
} else {
|
||||||
|
$file = $this->storage->getFile($identifier);
|
||||||
|
$refId = 'NEWimg' . $pid;
|
||||||
|
$datamap['sys_file_reference'][$refId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'table_local' => 'sys_file',
|
||||||
|
'uid_local' => $file->getUid(),
|
||||||
|
'tablenames' => 'tt_content',
|
||||||
|
'fieldname' => 'vitec_image',
|
||||||
|
'uid_foreign' => $introUid,
|
||||||
|
];
|
||||||
|
$datamap['tt_content'][$introUid]['vitec_image'] = $refId;
|
||||||
|
$output->writeln(sprintf(' B1 intro #%d: attach %s (sys_file %d)', $introUid, $identifier, $file->getUid()));
|
||||||
|
$this->counts['images']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trim((string)$intro['vitec_learnmore_target']) === '') {
|
||||||
|
$datamap['tt_content'][$introUid]['vitec_learnmore_target'] = self::CONTACT_TARGET;
|
||||||
|
$output->writeln(sprintf(' B1 intro #%d: learnmore_target "" -> %s', $introUid, self::CONTACT_TARGET));
|
||||||
|
$this->counts['targets']++;
|
||||||
|
if (trim((string)$intro['vitec_learnmore_label']) === '') {
|
||||||
|
$datamap['tt_content'][$introUid]['vitec_learnmore_label'] = self::LEARNMORE_LABEL;
|
||||||
|
$output->writeln(sprintf(' B1 intro #%d: learnmore_label "" -> %s', $introUid, self::LEARNMORE_LABEL));
|
||||||
|
$this->counts['labels']++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$output->writeln(sprintf(' B1 intro #%d: learnmore_target already "%s" - unchanged', $introUid, $intro['vitec_learnmore_target']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B5: hide the lead paragraph in the hero slot --------------------------
|
||||||
|
$leads = array_values(array_filter($col1, static fn(array $e): bool => $e['CType'] === 'vitec_introparagraph'));
|
||||||
|
if (count($leads) !== 1) {
|
||||||
|
$this->warn(sprintf('%d live vitec_introparagraph in colPos 1 (expected 1)%s', count($leads), $leads === [] ? ' - B5 skipped' : ' - hiding all of them'));
|
||||||
|
}
|
||||||
|
foreach ($leads as $lead) {
|
||||||
|
$datamap['tt_content'][(int)$lead['uid']]['hidden'] = 1;
|
||||||
|
$output->writeln(sprintf(' B5 lead #%d "%s": hide', $lead['uid'], $this->short((string)$lead['header'])));
|
||||||
|
$this->counts['hiddenLeads']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- anchor: last sub-market card after the "Explore" intro paragraph -----
|
||||||
|
$anchorUid = 0;
|
||||||
|
$anchorLabel = '';
|
||||||
|
$exploreIdx = null;
|
||||||
|
foreach ($col0 as $i => $el) {
|
||||||
|
if ($el['CType'] === 'vitec_introparagraph') {
|
||||||
|
$exploreIdx = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($exploreIdx !== null) {
|
||||||
|
$anchorUid = (int)$col0[$exploreIdx]['uid'];
|
||||||
|
$anchorLabel = 'intro paragraph #' . $anchorUid;
|
||||||
|
for ($i = $exploreIdx + 1; $i < count($col0) && $col0[$i]['CType'] === 'vitec_card'; $i++) {
|
||||||
|
$anchorUid = (int)$col0[$i]['uid'];
|
||||||
|
$anchorLabel = 'card #' . $anchorUid . ' "' . $this->short((string)$col0[$i]['header']) . '"';
|
||||||
|
}
|
||||||
|
} elseif ($intro !== null) {
|
||||||
|
$anchorUid = (int)$intro['uid'];
|
||||||
|
$anchorLabel = 'market intro #' . $anchorUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the 2026-10-23 page import left HIDDEN shells for both bands - reuse them
|
||||||
|
$logosShell = $this->first($col0All, 'vitec_customerlogos');
|
||||||
|
$testiShell = $this->first($col0All, 'vitec_usecaselist');
|
||||||
|
$hasLogos = $logosShell !== null && (int)$logosShell['hidden'] === 0;
|
||||||
|
$hasTesti = $testiShell !== null && (int)$testiShell['hidden'] === 0;
|
||||||
|
$hasCta = $this->first($col0, 'vitec_ctabanner') !== null;
|
||||||
|
if (!$hasCta) {
|
||||||
|
$this->warn('no vitec_ctabanner in colPos 0 - the new bands will end the page');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B2 (shell): unhide the "Trusted by" shell with the home page settings -----
|
||||||
|
if ($logosShell !== null && !$hasLogos) {
|
||||||
|
$logosUid = (int)$logosShell['uid'];
|
||||||
|
$datamap['tt_content'][$logosUid] = [
|
||||||
|
'hidden' => 0,
|
||||||
|
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
|
||||||
|
'settings.layout' => ['vDEF' => (string)($templateSettings['layout'] ?? 'marquee')],
|
||||||
|
'settings.customers' => ['vDEF' => (string)($templateSettings['customers'] ?? '')],
|
||||||
|
'settings.onlySelected' => ['vDEF' => (string)($templateSettings['onlySelected'] ?? '0')],
|
||||||
|
]]]],
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' B2 unhide logo shell #%d "%s", settings as #%d', $logosUid, $this->short((string)$logosShell['header']), self::LOGO_TEMPLATE_UID));
|
||||||
|
$this->counts['logos']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B3: testimonials -------------------------------------------------------------
|
||||||
|
// Selection: explicit lists of the market's sub-markets when they have
|
||||||
|
// any; today all 24 sub-market sliders have an EMPTY selection, which
|
||||||
|
// the plugin renders as "all stories" - the main pages do the same.
|
||||||
|
$stories = $storiesByMarket[$pid] ?? [];
|
||||||
|
$storySource = 'own sub-markets';
|
||||||
|
if ($stories === []) {
|
||||||
|
$stories = $defaultStories;
|
||||||
|
$storySource = 'most common sub-market list';
|
||||||
|
}
|
||||||
|
if ($stories === []) {
|
||||||
|
$storySource = 'empty selection = all stories, like the sub-market pages';
|
||||||
|
}
|
||||||
|
if ($hasTesti) {
|
||||||
|
$output->writeln(' B3 vitec_usecaselist already on the page - unchanged');
|
||||||
|
} elseif ($testiShell !== null) {
|
||||||
|
$testiUid = (int)$testiShell['uid'];
|
||||||
|
$current = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [$testiUid]));
|
||||||
|
$currentStories = array_values(array_filter(array_map('intval', explode(',', (string)($current['usecases'] ?? '')))));
|
||||||
|
if ($currentStories !== []) {
|
||||||
|
$stories = $currentStories;
|
||||||
|
$storySource = 'already selected on the shell';
|
||||||
|
}
|
||||||
|
{
|
||||||
|
$datamap['tt_content'][$testiUid] = [
|
||||||
|
'hidden' => 0,
|
||||||
|
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
|
||||||
|
'settings.layout' => ['vDEF' => 'testimonials'],
|
||||||
|
'settings.usecases' => ['vDEF' => implode(',', $stories)],
|
||||||
|
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
|
||||||
|
'settings.showtoolbar' => ['vDEF' => '0'],
|
||||||
|
]]]],
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' B3 unhide shell #%d "%s" as testimonials (%s%s)', $testiUid, $this->short((string)$testiShell['header']), $storySource, $stories === [] ? '' : ': ' . implode(',', $stories)));
|
||||||
|
$this->counts['testimonials']++;
|
||||||
|
}
|
||||||
|
} elseif ($anchorUid === 0) {
|
||||||
|
$this->warn('no anchor element found - B2/B3 skipped');
|
||||||
|
} else {
|
||||||
|
$datamap['tt_content']['NEWtesti' . $pid] = [
|
||||||
|
'pid' => -$anchorUid,
|
||||||
|
'colPos' => 0,
|
||||||
|
'sys_language_uid' => 0,
|
||||||
|
'CType' => 'vitec_usecaselist',
|
||||||
|
'header' => self::TESTIMONIALS_HEADER,
|
||||||
|
'pi_flexform' => [
|
||||||
|
'data' => ['sDEF' => ['lDEF' => [
|
||||||
|
'settings.layout' => ['vDEF' => 'testimonials'],
|
||||||
|
'settings.usecases' => ['vDEF' => implode(',', $stories)],
|
||||||
|
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
|
||||||
|
'settings.showtoolbar' => ['vDEF' => '0'],
|
||||||
|
]]],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' B3 new vitec_usecaselist (testimonials, %s%s) after %s', $storySource, $stories === [] ? '' : ': ' . implode(',', $stories), $anchorLabel));
|
||||||
|
$this->counts['testimonials']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write datamap ------------------------------------------------------------
|
||||||
|
$this->runDataHandler($datamap, []);
|
||||||
|
|
||||||
|
// --- B2 (no shell): logo band copied from #758, after the anchor (= before testimonials)
|
||||||
|
if ($hasLogos) {
|
||||||
|
$output->writeln(' B2 vitec_customerlogos already visible on the page - unchanged');
|
||||||
|
} elseif ($logosShell !== null) {
|
||||||
|
// handled above (shell unhidden in the datamap)
|
||||||
|
} elseif ($anchorUid > 0) {
|
||||||
|
$cmdmap = ['tt_content' => [self::LOGO_TEMPLATE_UID => ['copy' => [
|
||||||
|
'action' => 'paste',
|
||||||
|
'target' => -$anchorUid,
|
||||||
|
'update' => ['colPos' => 0],
|
||||||
|
]]]];
|
||||||
|
$output->writeln(sprintf(' B2 copy logo band #%d after %s', self::LOGO_TEMPLATE_UID, $anchorLabel));
|
||||||
|
$this->counts['logos']++;
|
||||||
|
$this->runDataHandler([], $cmdmap);
|
||||||
|
}
|
||||||
|
$output->writeln('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// sub-market pages: insights CTA -> news
|
||||||
|
// =====================================================================
|
||||||
|
$output->writeln('<info>== sub-market pages: vitec_marketinsights CTA</info>');
|
||||||
|
$insights = $this->db->fetchAllAssociative(
|
||||||
|
"SELECT c.uid, c.pid, c.hidden, c.vitec_cta_label, c.vitec_cta_target, p.slug, p.hidden AS page_hidden
|
||||||
|
FROM tt_content c JOIN pages p ON p.uid = c.pid
|
||||||
|
WHERE c.CType = 'vitec_marketinsights' AND c.deleted = 0 AND c.sys_language_uid = 0
|
||||||
|
AND p.deleted = 0 AND p.pid IN ($in)
|
||||||
|
ORDER BY p.slug"
|
||||||
|
);
|
||||||
|
$datamap = ['tt_content' => []];
|
||||||
|
foreach ($insights as $row) {
|
||||||
|
$uid = (int)$row['uid'];
|
||||||
|
$target = trim((string)$row['vitec_cta_target']);
|
||||||
|
$label = trim((string)$row['vitec_cta_label']);
|
||||||
|
$changes = [];
|
||||||
|
if ($target === self::OLD_INSIGHTS_TARGET || $target === '') {
|
||||||
|
$datamap['tt_content'][$uid]['vitec_cta_target'] = self::NEW_INSIGHTS_TARGET;
|
||||||
|
$changes[] = sprintf('target "%s" -> %s', $target, self::NEW_INSIGHTS_TARGET);
|
||||||
|
$this->counts['insightsTargets']++;
|
||||||
|
if ($label === self::OLD_INSIGHTS_LABEL || $label === '') {
|
||||||
|
$datamap['tt_content'][$uid]['vitec_cta_label'] = self::NEW_INSIGHTS_LABEL;
|
||||||
|
$changes[] = sprintf('label "%s" -> %s', $label, self::NEW_INSIGHTS_LABEL);
|
||||||
|
$this->counts['insightsLabels']++;
|
||||||
|
} else {
|
||||||
|
$changes[] = sprintf('label "%s" kept (not the standard text)', $label);
|
||||||
|
}
|
||||||
|
$purgePaths[] = (string)$row['slug'];
|
||||||
|
} elseif ($target === self::NEW_INSIGHTS_TARGET) {
|
||||||
|
$changes[] = 'already ' . self::NEW_INSIGHTS_TARGET;
|
||||||
|
} else {
|
||||||
|
$changes[] = sprintf('target "%s" is individual - unchanged', $target);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' #%-6d %-60s %s%s', $uid, $row['slug'], (int)$row['hidden'] === 1 || (int)$row['page_hidden'] === 1 ? '[hidden] ' : '', implode('; ', $changes)));
|
||||||
|
}
|
||||||
|
$this->runDataHandler($datamap, []);
|
||||||
|
$output->writeln('');
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'%s: %d intro image(s), %d learnmore target(s) (+%d label), %d logo band(s), %d testimonial slider(s), %d lead paragraph(s) hidden, %d insights target(s) (+%d label); %d warning(s).',
|
||||||
|
$this->apply ? 'DONE' : 'DRY RUN - would write',
|
||||||
|
$this->counts['images'], $this->counts['targets'], $this->counts['labels'], $this->counts['logos'], $this->counts['testimonials'],
|
||||||
|
$this->counts['hiddenLeads'], $this->counts['insightsTargets'], $this->counts['insightsLabels'], $this->counts['warnings']
|
||||||
|
));
|
||||||
|
$purgePaths = array_values(array_unique($purgePaths));
|
||||||
|
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge ' . implode(' ', $purgePaths) . ' && vendor/bin/typo3 vitec:ssr-warm ' . implode(' ', $purgePaths));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,array<string|int,array<string,mixed>>> $datamap */
|
||||||
|
private function runDataHandler(array $datamap, array $cmdmap): void
|
||||||
|
{
|
||||||
|
$datamap = array_filter($datamap);
|
||||||
|
if (!$this->apply || ($datamap === [] && $cmdmap === [])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($datamap, $cmdmap);
|
||||||
|
if ($datamap !== []) {
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
}
|
||||||
|
if ($cmdmap !== []) {
|
||||||
|
$dataHandler->process_cmdmap();
|
||||||
|
}
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$this->warn('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||||
|
}
|
||||||
|
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
|
||||||
|
$this->output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
|
||||||
|
}
|
||||||
|
foreach ($dataHandler->copyMappingArray_merged['tt_content'] ?? [] as $from => $to) {
|
||||||
|
$this->output->writeln(sprintf(' copied: #%d -> #%d', $from, $to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,string> flat "settings" sheet of a pi_flexform XML (empty when none) */
|
||||||
|
private function flexSettings(string $xml): array
|
||||||
|
{
|
||||||
|
if (trim($xml) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
|
||||||
|
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,array<string,mixed>> $elements */
|
||||||
|
private function first(array $elements, string $cType): ?array
|
||||||
|
{
|
||||||
|
foreach ($elements as $el) {
|
||||||
|
if ($el['CType'] === $cType) {
|
||||||
|
return $el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warn(string $message): void
|
||||||
|
{
|
||||||
|
$this->counts['warnings']++;
|
||||||
|
$this->output->writeln(' <comment>WARN ' . $message . '</comment>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function short(string $text): string
|
||||||
|
{
|
||||||
|
$text = trim((string)preg_replace('/\s+/', ' ', strip_tags($text)));
|
||||||
|
return mb_strlen($text) > 60 ? mb_substr($text, 0, 57) . '...' : $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
|
||||||
|
use TYPO3\CMS\Scheduler\Execution;
|
||||||
|
use TYPO3\CMS\Scheduler\Service\TaskService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the project's recurring jobs into the TYPO3 Scheduler (group
|
||||||
|
* "VITEC"), so they are visible, startable and switchable in
|
||||||
|
* System › Scheduler instead of hiding in the crontab. The only cron
|
||||||
|
* line left on dedi5092 is the core "scheduler:run" every minute.
|
||||||
|
*
|
||||||
|
* Idempotent: a task whose command already exists in the scheduler is
|
||||||
|
* left alone. --disabled creates new tasks switched off (use while the
|
||||||
|
* old crontab lines still run, so nothing runs twice), --enable switches
|
||||||
|
* the VITEC tasks on afterwards.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:scheduler-setup',
|
||||||
|
description: 'Create the VITEC scheduler tasks (SSR health every 5 min, client error cleanup daily) in the group "VITEC"; safe to run again'
|
||||||
|
)]
|
||||||
|
final class SchedulerSetupCommand extends Command
|
||||||
|
{
|
||||||
|
private const GROUP = 'VITEC';
|
||||||
|
|
||||||
|
/** command => [cron expression, description] */
|
||||||
|
private const TASKS = [
|
||||||
|
'vitec:ssr-health' => [
|
||||||
|
'*/5 * * * *',
|
||||||
|
'SSR health check: samples /__health and /__stats of the SSR node into the health log (cockpit charts), '
|
||||||
|
. 'mails the alert recipients from Site settings › VITEC on down/up changes and falls back to the SPA '
|
||||||
|
. 'after repeated failures. Formerly crontab.',
|
||||||
|
],
|
||||||
|
'vitec:client-error-cleanup' => [
|
||||||
|
'23 4 * * *',
|
||||||
|
'Deletes browser error reports (client errors from the React frontend) older than 30 days. Formerly crontab.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TaskService $taskService,
|
||||||
|
private readonly SchedulerTaskRepository $taskRepository,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('disabled', null, InputOption::VALUE_NONE, 'Create new tasks switched off');
|
||||||
|
$this->addOption('enable', null, InputOption::VALUE_NONE, 'Switch on all VITEC tasks created by this command');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$groupUid = $this->groupUid();
|
||||||
|
$output->writeln('Scheduler group "' . self::GROUP . '": uid ' . $groupUid);
|
||||||
|
|
||||||
|
foreach (self::TASKS as $command => [$cron, $description]) {
|
||||||
|
$existing = $this->findTask($command);
|
||||||
|
if ($existing !== null) {
|
||||||
|
if ($input->getOption('enable') && (int)$existing['disable'] === 1) {
|
||||||
|
$task = $this->taskRepository->findByUid((int)$existing['uid']);
|
||||||
|
$task->setDisabled(false);
|
||||||
|
$this->taskRepository->update($task);
|
||||||
|
$output->writeln(sprintf('<info>%s: enabled (uid %d)</info>', $command, $existing['uid']));
|
||||||
|
} else {
|
||||||
|
$output->writeln(sprintf('%s: exists (uid %d, %s)', $command, $existing['uid'],
|
||||||
|
(int)$existing['disable'] === 1 ? 'disabled' : 'enabled'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$this->taskService->isTaskTypeRegistered($command)) {
|
||||||
|
$output->writeln('<error>' . $command . ': not available as scheduler task type</error>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$task = $this->taskService->createNewTask($command);
|
||||||
|
$task->setDescription($description);
|
||||||
|
$task->setTaskGroup($groupUid);
|
||||||
|
$task->setExecution(Execution::createRecurringExecution(time() - 60, 0, 0, false, $cron));
|
||||||
|
$task->setDisabled((bool)$input->getOption('disabled'));
|
||||||
|
if ($this->taskRepository->add($task)) {
|
||||||
|
$output->writeln(sprintf('<info>%s: created (uid %d, %s, %s)</info>', $command, $task->getTaskUid(), $cron,
|
||||||
|
$task->isDisabled() ? 'disabled' : 'enabled'));
|
||||||
|
} else {
|
||||||
|
$output->writeln('<error>' . $command . ': could not be created</error>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function groupUid(): int
|
||||||
|
{
|
||||||
|
$connection = $this->connectionPool->getConnectionForTable('tx_scheduler_task_group');
|
||||||
|
$uid = $connection->executeQuery(
|
||||||
|
'SELECT uid FROM tx_scheduler_task_group WHERE groupName = ? AND deleted = 0 ORDER BY uid LIMIT 1',
|
||||||
|
[self::GROUP]
|
||||||
|
)->fetchOne();
|
||||||
|
if ($uid !== false) {
|
||||||
|
return (int)$uid;
|
||||||
|
}
|
||||||
|
$sorting = (int)$connection->executeQuery('SELECT MAX(sorting) FROM tx_scheduler_task_group')->fetchOne();
|
||||||
|
$connection->insert('tx_scheduler_task_group', [
|
||||||
|
'pid' => 0,
|
||||||
|
'tstamp' => time(),
|
||||||
|
'crdate' => time(),
|
||||||
|
'groupName' => self::GROUP,
|
||||||
|
'color' => '#f47937',
|
||||||
|
'description' => 'Recurring jobs of the VITEC website (SSR monitoring, cleanups)',
|
||||||
|
'sorting' => $sorting + 256,
|
||||||
|
], ['pid' => Connection::PARAM_INT, 'tstamp' => Connection::PARAM_INT, 'crdate' => Connection::PARAM_INT, 'sorting' => Connection::PARAM_INT]);
|
||||||
|
return (int)$connection->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findTask(string $command): ?array
|
||||||
|
{
|
||||||
|
$row = $this->connectionPool->getConnectionForTable('tx_scheduler_task')->executeQuery(
|
||||||
|
'SELECT uid, disable FROM tx_scheduler_task WHERE tasktype = ? AND deleted = 0 ORDER BY uid LIMIT 1',
|
||||||
|
[$command]
|
||||||
|
)->fetchAssociative();
|
||||||
|
return $row === false ? null : $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,13 +29,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
|
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
|
||||||
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
|
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
|
||||||
* - a product that already HAS feature records is never touched - after
|
* - a product that already HAS feature records is never touched - after
|
||||||
* the seed the editors own the cards.
|
* the seed the editors own the cards. EXCEPT with --attach-icons
|
||||||
|
* (2026-11-10, later icon deliveries such as PRISM/SFF PRISM/Avedia
|
||||||
|
* chassis): then its cards WITHOUT an icon get the matching staged icon
|
||||||
|
* (same matcher); titles, texts and cards with an icon stay untouched.
|
||||||
*
|
*
|
||||||
* Unmatched icons and unmatched cards are reported so the editors can map
|
* Unmatched icons and unmatched cards are reported so the editors can map
|
||||||
* the leftovers by hand (0036-FE asks for exactly that list).
|
* the leftovers by hand (0036-FE asks for exactly that list).
|
||||||
*
|
*
|
||||||
* vendor/bin/typo3 vitec:seed-product-features
|
* vendor/bin/typo3 vitec:seed-product-features
|
||||||
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
|
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
|
||||||
|
* vendor/bin/typo3 vitec:seed-product-features --only=prism --attach-icons --apply
|
||||||
*/
|
*/
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'vitec:seed-product-features',
|
name: 'vitec:seed-product-features',
|
||||||
@@ -60,6 +64,7 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
|
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
|
||||||
|
$this->addOption('attach-icons', null, InputOption::VALUE_NONE, 'Products with existing cards: attach staged icons to cards without one');
|
||||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
{
|
{
|
||||||
Bootstrap::initializeBackendAuthentication();
|
Bootstrap::initializeBackendAuthentication();
|
||||||
$apply = (bool)$input->getOption('apply');
|
$apply = (bool)$input->getOption('apply');
|
||||||
|
$attachIcons = (bool)$input->getOption('attach-icons');
|
||||||
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
||||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||||
|
|
||||||
@@ -81,6 +87,7 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
$iconsMatched = 0;
|
$iconsMatched = 0;
|
||||||
$skippedExisting = 0;
|
$skippedExisting = 0;
|
||||||
$skippedEmpty = 0;
|
$skippedEmpty = 0;
|
||||||
|
$attached = 0;
|
||||||
|
|
||||||
foreach ($products as $product) {
|
foreach ($products as $product) {
|
||||||
$uid = (int)$product['uid'];
|
$uid = (int)$product['uid'];
|
||||||
@@ -93,17 +100,11 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
|
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
|
||||||
[$uid]
|
[$uid]
|
||||||
);
|
);
|
||||||
if ($existing > 0) {
|
if ($existing > 0 && !$attachIcons) {
|
||||||
$skippedExisting++;
|
$skippedExisting++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$pairs = $this->parseApplications((string)$product['applications']);
|
|
||||||
if ($pairs === []) {
|
|
||||||
$skippedEmpty++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// staged icons of this product, filename theme => identifier
|
// staged icons of this product, filename theme => identifier
|
||||||
$context = self::ICON_DIRS[$slug] ?? $slug;
|
$context = self::ICON_DIRS[$slug] ?? $slug;
|
||||||
$iconByTheme = [];
|
$iconByTheme = [];
|
||||||
@@ -119,48 +120,70 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
if ($existing > 0) {
|
||||||
|
// --attach-icons: existing cards without an icon get a staged one
|
||||||
// match icons onto the pairs
|
if ($iconByTheme === []) {
|
||||||
$usedThemes = [];
|
$skippedExisting++;
|
||||||
foreach ($pairs as $i => $pair) {
|
|
||||||
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
|
|
||||||
if ($theme !== null) {
|
|
||||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
|
||||||
$usedThemes[$theme] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// second pass: token containment, e.g. theme "ManagementFoundation"
|
|
||||||
// inside the card title "Aetria management foundation"
|
|
||||||
foreach ($pairs as $i => $pair) {
|
|
||||||
if (isset($pair['icon'])) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
|
$bare = $pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
|
||||||
$titleTokens = $this->tokens($pair['title']);
|
'SELECT f.uid, f.title FROM ' . self::TABLE . ' f WHERE f.deleted = 0 AND f.product = ?
|
||||||
$themeTokens = $this->tokens($theme);
|
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = ?
|
||||||
if ($titleTokens !== [] && $themeTokens !== []
|
AND r.fieldname = ? AND r.uid_foreign = f.uid)
|
||||||
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
|
ORDER BY f.sorting',
|
||||||
) {
|
[$uid, self::TABLE, 'icon']
|
||||||
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
);
|
||||||
$usedThemes[$theme] = true;
|
if ($bare === []) {
|
||||||
break;
|
$skippedExisting++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
$pairs = array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title'], 'text' => '-'], $bare);
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - ATTACH: %d existing card(s) without icon, %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
||||||
|
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
$output->writeln(' "' . $pair['title'] . '"' . (isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'));
|
||||||
}
|
}
|
||||||
|
foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) {
|
||||||
|
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
|
||||||
|
}
|
||||||
|
$iconsMatched += count($usedThemes);
|
||||||
|
$attached += count($usedThemes);
|
||||||
|
if (!$apply || $usedThemes === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$datamap = [];
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
if (!isset($pair['icon'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$refId = $this->newId();
|
||||||
|
$datamap['sys_file_reference'][$refId] = [
|
||||||
|
'pid' => (int)$product['pid'],
|
||||||
|
'table_local' => 'sys_file',
|
||||||
|
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
|
||||||
|
];
|
||||||
|
$datamap[self::TABLE][(string)$pair['uid']] = ['icon' => $refId];
|
||||||
|
}
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($datamap, []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// last pair standing: a complete set (#icons == #cards) with exactly
|
$pairs = $this->parseApplications((string)$product['applications']);
|
||||||
// one icon and one card left over - they belong to each other
|
if ($pairs === []) {
|
||||||
if (count($iconByTheme) === count($pairs)) {
|
$skippedEmpty++;
|
||||||
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
|
continue;
|
||||||
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
|
|
||||||
if (count($freeThemes) === 1 && count($freePairs) === 1) {
|
|
||||||
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
|
|
||||||
$usedThemes[$freeThemes[0]] = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
|
||||||
|
|
||||||
|
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
|
||||||
|
|
||||||
foreach ($pairs as $pair) {
|
foreach ($pairs as $pair) {
|
||||||
$output->writeln(sprintf(
|
$output->writeln(sprintf(
|
||||||
' "%s"%s%s',
|
' "%s"%s%s',
|
||||||
@@ -224,11 +247,11 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$output->writeln(sprintf(
|
$output->writeln(sprintf(
|
||||||
"\n<info>%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s</info>",
|
"\n<info>%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s</info>",
|
||||||
$seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty,
|
$seeded, $cards, $iconsMatched, $attachIcons ? ', ' . $attached . ' attached to existing cards' : '', $skippedExisting, $skippedEmpty,
|
||||||
$apply ? '' : ' (dry run - nothing written)'
|
$apply ? '' : ' (dry run - nothing written)'
|
||||||
));
|
));
|
||||||
if ($apply && $seeded > 0) {
|
if ($apply && ($seeded > 0 || $attached > 0)) {
|
||||||
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
||||||
}
|
}
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
@@ -276,6 +299,58 @@ final class SeedProductFeaturesCommand extends Command
|
|||||||
return $pairs;
|
return $pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the staged icons onto the cards (three passes: normalised title
|
||||||
|
* / Levenshtein, token containment, last pair standing). Sets 'icon' on
|
||||||
|
* the matched pairs and returns the used themes.
|
||||||
|
*
|
||||||
|
* @param array<int,array{title:string,text:string,icon?:string,uid?:int}> $pairs
|
||||||
|
* @param array<string,string> $iconByTheme theme => identifier
|
||||||
|
* @return array<string,bool>
|
||||||
|
*/
|
||||||
|
private function matchIcons(array &$pairs, array $iconByTheme): array
|
||||||
|
{
|
||||||
|
$usedThemes = [];
|
||||||
|
foreach ($pairs as $i => $pair) {
|
||||||
|
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
|
||||||
|
if ($theme !== null) {
|
||||||
|
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||||
|
$usedThemes[$theme] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass: token containment, e.g. theme "ManagementFoundation"
|
||||||
|
// inside the card title "Aetria management foundation"
|
||||||
|
foreach ($pairs as $i => $pair) {
|
||||||
|
if (isset($pair['icon'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
|
||||||
|
$titleTokens = $this->tokens($pair['title']);
|
||||||
|
$themeTokens = $this->tokens($theme);
|
||||||
|
if ($titleTokens !== [] && $themeTokens !== []
|
||||||
|
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
|
||||||
|
) {
|
||||||
|
$pairs[$i]['icon'] = $iconByTheme[$theme];
|
||||||
|
$usedThemes[$theme] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// last pair standing: a complete set (#icons == #cards) with exactly
|
||||||
|
// one icon and one card left over - they belong to each other
|
||||||
|
if (count($iconByTheme) === count($pairs)) {
|
||||||
|
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
|
||||||
|
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
|
||||||
|
if (count($freeThemes) === 1 && count($freePairs) === 1) {
|
||||||
|
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
|
||||||
|
$usedThemes[$freeThemes[0]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $usedThemes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The icon theme that matches a card title: exact on the normalised
|
* The icon theme that matches a card title: exact on the normalised
|
||||||
* strings (stop words removed on both sides), else the closest theme
|
* strings (stop words removed on both sides), else the closest theme
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\SsrFlagService;
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||||
|
use TYPO3\CMS\Core\Mail\Mailer;
|
||||||
|
use TYPO3\CMS\Core\Mail\MailMessage;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:ssr-health',
|
||||||
|
description: 'Sample the SSR node /__health into tx_vitec_ssrhealthlog; mails on ok/down transitions, auto-failover to SPA after N consecutive misses',
|
||||||
|
)]
|
||||||
|
final class SsrHealthSampleCommand extends Command
|
||||||
|
{
|
||||||
|
private const KEEP_DAYS = 14;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SsrPurgeService $purgeService,
|
||||||
|
private readonly SsrFlagService $flagService,
|
||||||
|
private readonly RequestFactory $requestFactory,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'test-mail',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_OPTIONAL,
|
||||||
|
'Send a sample alert mail through the real alert path and exit (no health sample). Optional address; default: the configured alert recipients',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$testMail = $input->getOption('test-mail');
|
||||||
|
if ($testMail !== false) {
|
||||||
|
return $this->sendTestMail((string)$testMail, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = $this->purgeService->getHealthEndpoint();
|
||||||
|
if ($endpoint === '') {
|
||||||
|
$output->writeln('<error>SSR_HEALTH_ENDPOINT missing in var/vitec-ssr-purge.env</error>');
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$row = ['crdate' => time(), 'ok' => 0, 'release_id' => '', 'cached_pages' => 0, 'cache_mb' => 0, 'response_ms' => 0];
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $this->requestFactory->request($endpoint, 'GET', [
|
||||||
|
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
|
||||||
|
]);
|
||||||
|
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
|
||||||
|
if ($response->getStatusCode() === 200) {
|
||||||
|
$data = json_decode((string)$response->getBody(), true);
|
||||||
|
$row['ok'] = !empty($data['ok']) ? 1 : 0;
|
||||||
|
$row['release_id'] = substr((string)($data['release']['release'] ?? ''), 0, 100);
|
||||||
|
$row['cached_pages'] = (int)($data['cachedPages'] ?? 0);
|
||||||
|
$row['cache_mb'] = (int)($data['cacheMB'] ?? 0);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative node counters from /__stats (-1 = no data this sample,
|
||||||
|
// e.g. node down or release without the endpoint). The errors tile
|
||||||
|
// computes 24h deltas from these, skipping -1 rows; the counters
|
||||||
|
// reset whenever the node restarts, which the delta logic clamps.
|
||||||
|
$stats = [
|
||||||
|
'stat_hit' => -1, 'stat_miss' => -1, 'stat_stale' => -1,
|
||||||
|
'stat_bypass' => -1, 'stat_render_errors' => -1, 'stat_backend_errors' => -1,
|
||||||
|
];
|
||||||
|
if ($row['ok'] === 1 && ($statsEndpoint = $this->purgeService->getStatsEndpoint()) !== '') {
|
||||||
|
try {
|
||||||
|
$statsResponse = $this->requestFactory->request($statsEndpoint, 'GET', [
|
||||||
|
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
|
||||||
|
]);
|
||||||
|
if ($statsResponse->getStatusCode() === 200) {
|
||||||
|
$s = json_decode((string)$statsResponse->getBody(), true) ?: [];
|
||||||
|
$stats = [
|
||||||
|
'stat_hit' => (int)($s['hit'] ?? -1),
|
||||||
|
'stat_miss' => (int)($s['miss'] ?? -1),
|
||||||
|
'stat_stale' => (int)($s['stale'] ?? -1),
|
||||||
|
'stat_bypass' => (int)($s['bypass'] ?? -1),
|
||||||
|
'stat_render_errors' => (int)($s['renderErrors'] ?? -1),
|
||||||
|
'stat_backend_errors' => (int)($s['backendErrors'] ?? -1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Stats are optional - the health sample must never fail on them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$row = array_merge($row, $stats);
|
||||||
|
// Previous samples, newest first, read BEFORE this one is inserted:
|
||||||
|
// [0] is the state every transition decision compares against.
|
||||||
|
$failoverAfter = $this->purgeService->getFailoverAfter();
|
||||||
|
$previous = array_map('intval', $this->connectionPool
|
||||||
|
->getQueryBuilderForTable('tx_vitec_ssrhealthlog')
|
||||||
|
->select('ok')->from('tx_vitec_ssrhealthlog')
|
||||||
|
->orderBy('crdate', 'DESC')->addOrderBy('uid', 'DESC')
|
||||||
|
->setMaxResults($failoverAfter)
|
||||||
|
->executeQuery()->fetchFirstColumn());
|
||||||
|
|
||||||
|
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
|
||||||
|
$conn->insert('tx_vitec_ssrhealthlog', $row);
|
||||||
|
$conn->executeStatement(
|
||||||
|
'DELETE FROM tx_vitec_ssrhealthlog WHERE crdate < ?',
|
||||||
|
[time() - self::KEEP_DAYS * 86400]
|
||||||
|
);
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'%s release=%s pages=%d cache=%dMB %dms',
|
||||||
|
$row['ok'] ? '<info>ok</info>' : '<error>DOWN</error>',
|
||||||
|
$row['release_id'] ?: '-',
|
||||||
|
$row['cached_pages'],
|
||||||
|
$row['cache_mb'],
|
||||||
|
$row['response_ms']
|
||||||
|
));
|
||||||
|
if ($row['stat_hit'] >= 0) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'stats: hit=%d miss=%d stale=%d bypass=%d renderErrors=%d backendErrors=%d',
|
||||||
|
$row['stat_hit'],
|
||||||
|
$row['stat_miss'],
|
||||||
|
$row['stat_stale'],
|
||||||
|
$row['stat_bypass'],
|
||||||
|
$row['stat_render_errors'],
|
||||||
|
$row['stat_backend_errors']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$prevOk = $previous === [] ? null : $previous[0];
|
||||||
|
if ($prevOk !== null && $prevOk !== $row['ok']) {
|
||||||
|
if ($row['ok'] === 1) {
|
||||||
|
$this->sendAlert(
|
||||||
|
'[VITEC SSR] Recovered: SSR server reachable again',
|
||||||
|
"The 5-minute health check reaches the SSR server again (status ok).\n\n"
|
||||||
|
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
|
||||||
|
. 'Release: ' . ($row['release_id'] ?: '-') . "\n"
|
||||||
|
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
|
||||||
|
. 'Current state: switch vitec.ssr.enabled = '
|
||||||
|
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
|
||||||
|
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n"
|
||||||
|
. "If the auto-failover was triggered, the site deliberately stays on SPA.\n"
|
||||||
|
. "Re-enable: TYPO3 backend > Site Management > Settings > SSR delivery ON,\n"
|
||||||
|
. "then a full purge (vendor/bin/typo3 vitec:ssr-purge --all).\n",
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->sendAlert(
|
||||||
|
'[VITEC SSR] ALERT: SSR server not responding',
|
||||||
|
"The 5-minute health check could not reach the SSR server\n"
|
||||||
|
. "(or /__health reported an error).\n\n"
|
||||||
|
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
|
||||||
|
. 'Endpoint: ' . $endpoint . "\n"
|
||||||
|
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
|
||||||
|
. 'As long as the flag file exists, dev.vitec.com keeps routing to the SSR server. After '
|
||||||
|
. $failoverAfter . ' failed checks in a row (about ' . ($failoverAfter * 5) . " minutes) the\n"
|
||||||
|
. "auto-failover switches to SPA delivery automatically - the site stays reachable.\n\n"
|
||||||
|
. "This mail is sent only on the ok -> down transition, not for every check.\n",
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($row['ok'] === 0 && $this->flagService->isFlagSet()) {
|
||||||
|
$consecutive = 1;
|
||||||
|
foreach ($previous as $ok) {
|
||||||
|
if ($ok !== 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$consecutive++;
|
||||||
|
}
|
||||||
|
if ($consecutive >= $failoverAfter) {
|
||||||
|
try {
|
||||||
|
$this->flagService->disableSsr();
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'<error>AUTO-FAILOVER after %d consecutive misses: vitec.ssr.enabled=false, caches flushed, flag removed</error>',
|
||||||
|
$consecutive
|
||||||
|
));
|
||||||
|
$this->sendAlert(
|
||||||
|
'[VITEC SSR] AUTO-FAILOVER: site switched to SPA',
|
||||||
|
'AUTO-FAILOVER TRIGGERED: ' . $consecutive . " consecutive DOWN checks.\n\n"
|
||||||
|
. "dev.vitec.com now serves the SPA (rendering in the browser).\n"
|
||||||
|
. "The site stays available for visitors.\n\n"
|
||||||
|
. "What happened automatically:\n"
|
||||||
|
. "- site setting vitec.ssr.enabled set to false (settings.yaml)\n"
|
||||||
|
. "- caches flushed\n"
|
||||||
|
. "- flag file var/vitec-ssr-on.flag removed\n\n"
|
||||||
|
. "Re-enable (deliberately MANUAL only) once the SSR server is back:\n"
|
||||||
|
. "1. Check on the VPS: systemctl status vitec-ssr and /__health\n"
|
||||||
|
. "2. TYPO3 backend > Site Management > Settings > SSR delivery back ON\n"
|
||||||
|
. "3. Full purge: vendor/bin/typo3 vitec:ssr-purge --all\n",
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>auto-failover FAILED: ' . $e->getMessage() . '</error>');
|
||||||
|
$this->sendAlert(
|
||||||
|
'[VITEC SSR] ERROR: auto-failover FAILED',
|
||||||
|
"The auto-failover should have triggered but failed:\n"
|
||||||
|
. $e->getMessage() . "\n\n"
|
||||||
|
. "Please check manually: site setting vitec.ssr.enabled, file var/vitec-ssr-on.flag.\n"
|
||||||
|
. "Emergency off without TYPO3: delete the flag file AND set the setting to false\n"
|
||||||
|
. "(otherwise the SyncSsrFlag middleware recreates the flag file immediately).\n",
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliverability check (e.g. mail-tester.com): same sender, mailer and
|
||||||
|
* plain-text format as a real alert, clearly marked as a test.
|
||||||
|
*/
|
||||||
|
private function sendTestMail(string $address, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$override = [];
|
||||||
|
if ($address !== '') {
|
||||||
|
if (!GeneralUtility::validEmail($address)) {
|
||||||
|
$output->writeln('<error>invalid address: ' . $address . '</error>');
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$override = [$address];
|
||||||
|
}
|
||||||
|
$this->sendAlert(
|
||||||
|
'[VITEC SSR] TEST: alert mail check',
|
||||||
|
"This is a test of the VITEC SSR alert mail - nothing is wrong.\n\n"
|
||||||
|
. "Real alerts look like this one and are sent when the 5-minute health\n"
|
||||||
|
. "check sees the SSR server go down or come back, and when the\n"
|
||||||
|
. "auto-failover switches the site to SPA delivery.\n\n"
|
||||||
|
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
|
||||||
|
. 'Current state: switch vitec.ssr.enabled = '
|
||||||
|
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
|
||||||
|
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n\n"
|
||||||
|
. "Recipients are maintained in the TYPO3 backend:\n"
|
||||||
|
. "Site Management > Settings > VITEC > SSR alert mail recipients.\n",
|
||||||
|
$output,
|
||||||
|
$override
|
||||||
|
);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mails are best effort: a mail problem must never break the sampler
|
||||||
|
* (the cron would stop logging history). Recipients come from the site
|
||||||
|
* setting vitec.ssr.alertEmails (Site Management › Settings › VITEC),
|
||||||
|
* fallback SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - none at all
|
||||||
|
* means "alerts off", which the output states so the cron log shows why.
|
||||||
|
*/
|
||||||
|
private function sendAlert(string $subject, string $body, OutputInterface $output, array $override = []): void
|
||||||
|
{
|
||||||
|
$alert = $this->purgeService->getAlertRecipients();
|
||||||
|
if ($alert['invalid'] !== [] && $override === []) {
|
||||||
|
$output->writeln('<comment>skipped invalid alert addresses: ' . implode(', ', $alert['invalid']) . '</comment>');
|
||||||
|
}
|
||||||
|
$recipients = $override !== [] ? $override : $alert['recipients'];
|
||||||
|
if ($recipients === []) {
|
||||||
|
$output->writeln('<comment>transition detected but no alert recipients are configured (Site settings › VITEC or SSR_ALERT_EMAIL) - no mail sent</comment>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$mail = GeneralUtility::makeInstance(MailMessage::class);
|
||||||
|
$mail->to(...$recipients)->subject($subject)->text($body);
|
||||||
|
GeneralUtility::makeInstance(Mailer::class)->send($mail);
|
||||||
|
$output->writeln('alert mail sent: ' . $subject . ' -> ' . implode(', ', $recipients));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>alert mail failed: ' . $e->getMessage() . '</error>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:ssr-purge',
|
||||||
|
description: 'Purge paths (or everything with --all) from the SSR node page cache',
|
||||||
|
)]
|
||||||
|
final class SsrPurgeCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SsrPurgeService $purgeService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to purge, e.g. /products/aligo');
|
||||||
|
$this->addOption('all', null, InputOption::VALUE_NONE, 'Purge the whole cache ("*")');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$paths = $input->getOption('all') ? ['*'] : (array)$input->getArgument('paths');
|
||||||
|
if ($paths === []) {
|
||||||
|
$output->writeln('<error>No paths given (use arguments or --all).</error>');
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
$result = $this->purgeService->purge($paths, 'cli');
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'%s HTTP %d - %s (paths: %s)',
|
||||||
|
$result['success'] ? '<info>OK</info>' : '<error>FAILED</error>',
|
||||||
|
$result['httpCode'],
|
||||||
|
$result['message'],
|
||||||
|
implode(', ', $result['paths'])
|
||||||
|
));
|
||||||
|
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: shows which SSR paths the auto-purge would clear when the
|
||||||
|
* given record is saved (SsrPurgeTargetResolver). Purges nothing.
|
||||||
|
*
|
||||||
|
* vitec:ssr-purge-targets tx_vitec_domain_model_product 12
|
||||||
|
* vitec:ssr-purge-targets tx_news_domain_model_news 300 --fields=title,bodytext
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:ssr-purge-targets',
|
||||||
|
description: 'Show which SSR paths the auto-purge would clear for a saved record (read-only, purges nothing)'
|
||||||
|
)]
|
||||||
|
final class SsrPurgeTargetsCommand extends Command
|
||||||
|
{
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product');
|
||||||
|
$this->addArgument('uid', InputArgument::REQUIRED, 'Record uid');
|
||||||
|
$this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true);
|
||||||
|
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
|
||||||
|
->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields);
|
||||||
|
if ($paths === []) {
|
||||||
|
$output->writeln('nothing to purge (record not rendered anywhere)');
|
||||||
|
} elseif ($paths === [SsrPurgeTargetResolver::ALL]) {
|
||||||
|
$output->writeln('<comment>* (everything)</comment>');
|
||||||
|
} else {
|
||||||
|
$output->writeln(count($paths) . ' path(s):');
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$output->writeln(' ' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:ssr-warm',
|
||||||
|
description: 'Start a background warm run on the SSR node (all sitemap pages, or the given paths)',
|
||||||
|
)]
|
||||||
|
final class SsrWarmCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SsrPurgeService $purgeService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to warm; none = every page of sitemap.xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$result = $this->purgeService->warm((array)$input->getArgument('paths'));
|
||||||
|
$output->writeln(($result['success'] ? '<info>OK</info> ' : '<error>FAILED</error> ') . $result['message']);
|
||||||
|
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\ContextMenu;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\AbstractProvider;
|
||||||
|
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page tree context menu: "Purge from SSR cache" - evicts exactly this
|
||||||
|
* page from the SSR node's full-page cache, with toast feedback.
|
||||||
|
* Shown only for real pages and only while the purge endpoint is
|
||||||
|
* configured (var/vitec-ssr-purge.env).
|
||||||
|
*/
|
||||||
|
final class SsrPurgeItemProvider extends AbstractProvider
|
||||||
|
{
|
||||||
|
protected $itemsConfiguration = [
|
||||||
|
'ssrPurge' => [
|
||||||
|
'type' => 'item',
|
||||||
|
'label' => 'Purge from SSR cache',
|
||||||
|
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
|
||||||
|
'callbackAction' => 'ssrPurge',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function canHandle(): bool
|
||||||
|
{
|
||||||
|
return $this->table === 'pages';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below the core PageProvider (100): we run after it and splice our
|
||||||
|
* item into the finished menu, right behind "Clear cache".
|
||||||
|
*/
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addItems(array $items): array
|
||||||
|
{
|
||||||
|
if (!is_readable(Environment::getVarPath() . '/vitec-ssr-purge.env')) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
$page = BackendUtility::getRecord('pages', (int)$this->identifier, 'uid,doktype');
|
||||||
|
if ($page === null || (int)$page['doktype'] >= 199) {
|
||||||
|
// Spacers, folders, recycler - nothing cached for those.
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
$this->initDisabledItems();
|
||||||
|
$localItems = $this->prepareItems($this->itemsConfiguration);
|
||||||
|
if (isset($items['clearCache'])) {
|
||||||
|
$new = [];
|
||||||
|
foreach ($items as $key => $item) {
|
||||||
|
$new[$key] = $item;
|
||||||
|
if ($key === 'clearCache') {
|
||||||
|
$new = array_merge($new, $localItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
return array_merge($items, $localItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAdditionalAttributes(string $itemName): array
|
||||||
|
{
|
||||||
|
// No ".js" here: the context menu JS appends it when importing.
|
||||||
|
return ['data-callback-module' => '@evomedien/vitec/ssr-context-menu'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Controller\Backend;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\ClientErrorClassifier;
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||||
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||||
|
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
|
||||||
|
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
|
||||||
|
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||||
|
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||||
|
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||||
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||||
|
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||||
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||||
|
use TYPO3\CMS\Core\Page\PageRenderer;
|
||||||
|
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend module "VITEC SSR": live state of the SSR node, manual purge
|
||||||
|
* (single paths or everything) and the purge/health protocol.
|
||||||
|
*/
|
||||||
|
#[AsController]
|
||||||
|
final class SsrController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||||
|
private readonly SsrPurgeService $purgeService,
|
||||||
|
private readonly RequestFactory $requestFactory,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
private readonly FlashMessageService $flashMessageService,
|
||||||
|
private readonly UriBuilder $uriBuilder,
|
||||||
|
private readonly PageRenderer $pageRenderer,
|
||||||
|
private readonly ComponentFactory $componentFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$view = $this->moduleTemplateFactory->create($request);
|
||||||
|
$view->setTitle('VITEC SSR');
|
||||||
|
|
||||||
|
[$health, $healthError] = $this->fetchHealth();
|
||||||
|
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-cockpit.js');
|
||||||
|
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-module.js');
|
||||||
|
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-ssr.css');
|
||||||
|
|
||||||
|
// DocHeader: shortcut button (0096-FE); the reload button comes from the core.
|
||||||
|
$view->addButtonToButtonBar(
|
||||||
|
$this->componentFactory->createShortcutButton()
|
||||||
|
->setRouteIdentifier('web_vitecssr')
|
||||||
|
->setDisplayName('VITEC SSR'),
|
||||||
|
ButtonBar::BUTTON_POSITION_RIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
$limit = (int)($request->getQueryParams()['limit'] ?? 30);
|
||||||
|
if (!in_array($limit, [30, 100, 500], true)) {
|
||||||
|
$limit = 30;
|
||||||
|
}
|
||||||
|
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
|
||||||
|
$lastSample = $healthLog[0] ?? null;
|
||||||
|
$errorReport = $this->clientErrors(50);
|
||||||
|
$clientErrors = $errorReport['list'];
|
||||||
|
$cockpit = $this->cockpitData($health, $lastSample);
|
||||||
|
$alert = $this->purgeService->getAlertRecipients();
|
||||||
|
|
||||||
|
$settingsUri = '';
|
||||||
|
if ($GLOBALS['BE_USER']->isAdmin()) {
|
||||||
|
try {
|
||||||
|
$sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites();
|
||||||
|
$site = reset($sites);
|
||||||
|
if ($site) {
|
||||||
|
$settingsUri = (string)$this->uriBuilder->buildUriFromRoute(
|
||||||
|
'site_configuration.editSettings',
|
||||||
|
['site' => $site->getIdentifier()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$settingsUri = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$view->assignMultiple([
|
||||||
|
'cockpit' => $cockpit,
|
||||||
|
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
|
||||||
|
'settingsUri' => $settingsUri,
|
||||||
|
'failoverAfter' => $this->purgeService->getFailoverAfter(),
|
||||||
|
'alert' => $alert,
|
||||||
|
'alertList' => implode(', ', $alert['recipients']),
|
||||||
|
'alertCount' => count($alert['recipients']),
|
||||||
|
'alertInvalid' => implode(', ', $alert['invalid']),
|
||||||
|
'chunkCount24h' => $errorReport['chunk'],
|
||||||
|
'jsErrorCount24h' => $errorReport['js'],
|
||||||
|
'testCount24h' => $errorReport['tests'],
|
||||||
|
'botCount24h' => $errorReport['bots'],
|
||||||
|
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
|
||||||
|
'configured' => $this->purgeService->isConfigured(),
|
||||||
|
'health' => $health,
|
||||||
|
'healthError' => $healthError,
|
||||||
|
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
|
||||||
|
'lastSample' => $lastSample,
|
||||||
|
'lastSampleAge' => $lastSample ? BackendUtility::calcAge(time() - (int)$lastSample['crdate']) : '',
|
||||||
|
'purgeLog' => $this->purgeRows($limit),
|
||||||
|
'purgeLimit' => $limit,
|
||||||
|
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
|
||||||
|
'healthLog' => $healthLog,
|
||||||
|
'clientErrors' => $clientErrors,
|
||||||
|
'clientErrorCount24h' => $errorReport['real'],
|
||||||
|
]);
|
||||||
|
return $view->renderResponse('Ssr/Index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client error reports of the last 24 hours (POST /api/vitec/client-error,
|
||||||
|
* ClientErrorMiddleware), newest first. Test reports (smoke tests, curl)
|
||||||
|
* are counted apart and left out of the list (0114-FE); bot reports stay
|
||||||
|
* in with a label. Rows carry a "path" and a shortened user agent for the
|
||||||
|
* list; the full values stay in the row for the details toggle.
|
||||||
|
*
|
||||||
|
* @return array{list: list<array>, real: int, chunk: int, js: int, tests: int, bots: int}
|
||||||
|
*/
|
||||||
|
private function clientErrors(int $limit): array
|
||||||
|
{
|
||||||
|
$report = ['list' => [], 'real' => 0, 'chunk' => 0, 'js' => 0, 'tests' => 0, 'bots' => 0];
|
||||||
|
try {
|
||||||
|
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog');
|
||||||
|
$rows = $qb->select('*')->from('tx_vitec_clienterrorlog')
|
||||||
|
->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT)))
|
||||||
|
->orderBy('uid', 'DESC')->setMaxResults(2000)
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$ua = (string)$row['user_agent'];
|
||||||
|
if (ClientErrorClassifier::isTest((string)$row['message'], $ua, (string)$row['release'])) {
|
||||||
|
$report['tests']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row['is_chunk_reload'] = ClientErrorClassifier::isChunkReload((string)$row['message']);
|
||||||
|
$row['is_bot'] = ClientErrorClassifier::isBot($ua);
|
||||||
|
$report['real']++;
|
||||||
|
$report[$row['is_chunk_reload'] ? 'chunk' : 'js']++;
|
||||||
|
$report['bots'] += $row['is_bot'] ? 1 : 0;
|
||||||
|
if (count($report['list']) < $limit) {
|
||||||
|
$path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? '');
|
||||||
|
$row['path'] = $path !== '' ? $path : (string)$row['url'];
|
||||||
|
$row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua;
|
||||||
|
$report['list'][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge log rows prepared for the template: trigger split into a short
|
||||||
|
* label plus the user, paths as count + first two + the rest behind a
|
||||||
|
* disclosure (the raw list was a wall of text, 0096-FE).
|
||||||
|
*/
|
||||||
|
private function purgeRows(int $limit): array
|
||||||
|
{
|
||||||
|
$rows = $this->rows('tx_vitec_ssrpurgelog', $limit);
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$yesterday = date('Y-m-d', time() - 86400);
|
||||||
|
$lastDay = '';
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
// Day separators for the activity timeline.
|
||||||
|
$day = date('Y-m-d', (int)$row['crdate']);
|
||||||
|
$row['day_label'] = match ($day) {
|
||||||
|
$today => 'Today',
|
||||||
|
$yesterday => 'Yesterday',
|
||||||
|
default => date('D, d.m.', (int)$row['crdate']),
|
||||||
|
};
|
||||||
|
$row['day_first'] = $day !== $lastDay;
|
||||||
|
$lastDay = $day;
|
||||||
|
|
||||||
|
$trigger = (string)$row['trigger_source'];
|
||||||
|
$who = '';
|
||||||
|
if (str_contains($trigger, ':')) {
|
||||||
|
[$trigger, $who] = explode(':', $trigger, 2);
|
||||||
|
}
|
||||||
|
$row['trigger_label'] = match ($trigger) {
|
||||||
|
'module' => 'manual',
|
||||||
|
'contextmenu' => 'context menu',
|
||||||
|
'editor-save', 'editor-command' => 'auto-purge',
|
||||||
|
default => $trigger,
|
||||||
|
};
|
||||||
|
$row['trigger_who'] = $who;
|
||||||
|
// Cockpit tone per trigger (pill + timeline dot colour).
|
||||||
|
$row['trigger_tone'] = match ($trigger) {
|
||||||
|
'editor-save', 'editor-command' => 'ok',
|
||||||
|
'module' => 'orange',
|
||||||
|
'contextmenu' => 'navy',
|
||||||
|
'switch-on' => 'warn',
|
||||||
|
default => 'info',
|
||||||
|
};
|
||||||
|
$paths = preg_split('/\s*,\s*/', trim((string)$row['paths']), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$row['paths_all'] = $paths === ['*'];
|
||||||
|
$row['paths_count'] = count($paths);
|
||||||
|
$row['paths_first'] = array_slice($paths, 0, 2);
|
||||||
|
$row['paths_rest'] = array_slice($paths, 2);
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the cockpit draws (ssr-cockpit.js reads it as JSON):
|
||||||
|
* availability strip, traffic buckets for 24 h / 7 days, response
|
||||||
|
* sparkline, hit rate, client errors per hour. Source is the health
|
||||||
|
* log (cron vitec:ssr-health, every 5 min, kept 14 days); the node's
|
||||||
|
* hit/miss/stale counters are cumulative since its last start, so
|
||||||
|
* traffic is the difference between two samples (a smaller value
|
||||||
|
* than before means the node restarted: then the new value counts).
|
||||||
|
*/
|
||||||
|
private function cockpitData(?array $health, ?array $lastSample): array
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$tz = (int)date('Z');
|
||||||
|
$from = $now - 7 * 86400 - 4 * 3600;
|
||||||
|
|
||||||
|
$samples = [];
|
||||||
|
$purges = [];
|
||||||
|
$errors = [];
|
||||||
|
try {
|
||||||
|
$samples = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
|
||||||
|
'SELECT crdate, ok, response_ms, cached_pages, cache_mb, stat_hit, stat_miss, stat_stale, stat_bypass,'
|
||||||
|
. ' stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog WHERE crdate > ? ORDER BY crdate, uid',
|
||||||
|
[$from]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
$purges = $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->executeQuery(
|
||||||
|
'SELECT crdate, paths, success FROM tx_vitec_ssrpurgelog WHERE crdate > ? ORDER BY crdate',
|
||||||
|
[$from]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
$errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
|
||||||
|
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
|
||||||
|
[$now - 86400]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Tables missing on a fresh system: the cockpit shows empty charts.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic deltas between consecutive samples with counters.
|
||||||
|
$keys = ['hit', 'miss', 'stale', 'bypass', 'render_errors', 'backend_errors'];
|
||||||
|
$prev = null;
|
||||||
|
foreach ($samples as &$s) {
|
||||||
|
foreach ($s as $k => $v) {
|
||||||
|
$s[$k] = (int)$v;
|
||||||
|
}
|
||||||
|
$s['d'] = null;
|
||||||
|
if ($s['stat_hit'] >= 0) {
|
||||||
|
if ($prev !== null) {
|
||||||
|
$d = [];
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
$c = max(0, $s['stat_' . $k]);
|
||||||
|
$p = max(0, $prev['stat_' . $k]);
|
||||||
|
$d[$k] = $c >= $p ? $c - $p : $c;
|
||||||
|
}
|
||||||
|
$s['d'] = $d;
|
||||||
|
}
|
||||||
|
$prev = $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($s);
|
||||||
|
|
||||||
|
$buckets = function (int $size, int $count) use ($now, $tz, $samples, $purges): array {
|
||||||
|
$end = intdiv($now + $tz, $size) * $size - $tz + $size;
|
||||||
|
$start = $end - $size * $count;
|
||||||
|
$out = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$out[] = ['t' => $start + $i * $size, 'hit' => 0, 'stale' => 0, 'miss' => 0, 'bypass' => 0,
|
||||||
|
'errors' => 0, 'msSum' => 0, 'msN' => 0, 'purges' => 0, 'purgeAll' => 0];
|
||||||
|
}
|
||||||
|
foreach ($samples as $s) {
|
||||||
|
$i = intdiv($s['crdate'] - $start, $size);
|
||||||
|
if ($i < 0 || $i >= $count) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($s['d'] !== null) {
|
||||||
|
$out[$i]['hit'] += $s['d']['hit'];
|
||||||
|
$out[$i]['stale'] += $s['d']['stale'];
|
||||||
|
$out[$i]['miss'] += $s['d']['miss'];
|
||||||
|
$out[$i]['bypass'] += $s['d']['bypass'];
|
||||||
|
$out[$i]['errors'] += $s['d']['render_errors'] + $s['d']['backend_errors'];
|
||||||
|
}
|
||||||
|
if ($s['ok'] === 1) {
|
||||||
|
$out[$i]['msSum'] += $s['response_ms'];
|
||||||
|
$out[$i]['msN']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($purges as $p) {
|
||||||
|
$i = intdiv((int)$p['crdate'] - $start, $size);
|
||||||
|
if ($i < 0 || $i >= $count) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$i]['purges']++;
|
||||||
|
if (trim((string)$p['paths']) === '*') {
|
||||||
|
$out[$i]['purgeAll']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($out as &$b) {
|
||||||
|
$b['ms'] = $b['msN'] > 0 ? (int)round($b['msSum'] / $b['msN']) : null;
|
||||||
|
unset($b['msSum'], $b['msN']);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Availability strip: 96 windows of 15 minutes.
|
||||||
|
$slot = 900;
|
||||||
|
$stripEnd = intdiv($now, $slot) * $slot + $slot;
|
||||||
|
$strip = array_fill(0, 96, null);
|
||||||
|
$ok24 = 0;
|
||||||
|
$n24 = 0;
|
||||||
|
$ms24 = [];
|
||||||
|
$slow = 0;
|
||||||
|
$down = 0;
|
||||||
|
foreach ($samples as $s) {
|
||||||
|
$i = 95 - intdiv($stripEnd - 1 - $s['crdate'], $slot);
|
||||||
|
if ($i < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$n24++;
|
||||||
|
$state = $s['ok'] !== 1 ? 2 : ($s['response_ms'] > 400 ? 1 : 0);
|
||||||
|
if ($s['ok'] === 1) {
|
||||||
|
$ok24++;
|
||||||
|
$ms24[] = $s['response_ms'];
|
||||||
|
}
|
||||||
|
$cur = $strip[$i];
|
||||||
|
if ($cur === null || $state > $cur['s']) {
|
||||||
|
$strip[$i] = ['s' => $state, 'ms' => $s['response_ms']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($strip as $w) {
|
||||||
|
if ($w !== null && $w['s'] === 1) {
|
||||||
|
$slow++;
|
||||||
|
}
|
||||||
|
if ($w !== null && $w['s'] === 2) {
|
||||||
|
$down++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($ms24);
|
||||||
|
$pct = static function (array $sorted, float $p): ?int {
|
||||||
|
if ($sorted === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $sorted[(int)min(count($sorted) - 1, floor($p * (count($sorted) - 1) + 0.5))];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hit rate now (last 24 h) and the 24 h before.
|
||||||
|
$rate = static function (array $samples, int $a, int $b): ?float {
|
||||||
|
$hit = 0;
|
||||||
|
$all = 0;
|
||||||
|
foreach ($samples as $s) {
|
||||||
|
if ($s['d'] === null || $s['crdate'] <= $a || $s['crdate'] > $b) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hit += $s['d']['hit'] + $s['d']['stale'];
|
||||||
|
$all += $s['d']['hit'] + $s['d']['stale'] + $s['d']['miss'];
|
||||||
|
}
|
||||||
|
return $all > 0 ? round($hit / $all * 100, 1) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response time sparkline: the last 48 good samples (4 hours).
|
||||||
|
$spark = [];
|
||||||
|
foreach (array_slice(array_values(array_filter($samples, static fn(array $s): bool => $s['ok'] === 1)), -48) as $s) {
|
||||||
|
$spark[] = [$s['crdate'], $s['response_ms']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client errors per hour, last 24 h.
|
||||||
|
$errHours = array_fill(0, 24, 0);
|
||||||
|
$hourEnd = intdiv($now, 3600) * 3600 + 3600;
|
||||||
|
foreach ($errors as $e) {
|
||||||
|
if (ClientErrorClassifier::isTest((string)$e['message'], (string)$e['user_agent'], (string)$e['release'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600);
|
||||||
|
if ($i >= 0 && $i < 24) {
|
||||||
|
$errHours[$i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cachedPages = $health['cachedPages'] ?? ($lastSample['cached_pages'] ?? null);
|
||||||
|
$cacheMb = $health['cacheMB'] ?? ($lastSample['cache_mb'] ?? null);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'now' => $now,
|
||||||
|
'online' => $health !== null,
|
||||||
|
'warming' => $health['warming'] ?? null,
|
||||||
|
'strip' => ['end' => $stripEnd, 'slot' => $slot, 'w' => $strip],
|
||||||
|
'uptimePct' => $n24 > 0 ? round($ok24 / $n24 * 100, 1) : null,
|
||||||
|
'slowWindows' => $slow,
|
||||||
|
'downWindows' => $down,
|
||||||
|
'msMedian' => $pct($ms24, 0.5),
|
||||||
|
'msP95' => $pct($ms24, 0.95),
|
||||||
|
'spark' => $spark,
|
||||||
|
'hitRate' => $rate($samples, $now - 86400, $now),
|
||||||
|
'hitRatePrev' => $rate($samples, $now - 2 * 86400, $now - 86400),
|
||||||
|
'cachedPages' => $cachedPages !== null ? (int)$cachedPages : null,
|
||||||
|
'cacheMb' => $cacheMb !== null ? (int)$cacheMb : null,
|
||||||
|
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
|
||||||
|
'errHours' => $errHours,
|
||||||
|
'range' => [
|
||||||
|
'24h' => ['size' => 3600, 'b' => $buckets(3600, 24)],
|
||||||
|
'7d' => ['size' => 14400, 'b' => $buckets(14400, 42)],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purgeAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$body = (array)$request->getParsedBody();
|
||||||
|
$mode = (string)($body['mode'] ?? 'paths');
|
||||||
|
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
|
||||||
|
|
||||||
|
if ($mode === 'all') {
|
||||||
|
$paths = ['*'];
|
||||||
|
} else {
|
||||||
|
$paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paths === []) {
|
||||||
|
$this->flash('No paths given.', 'Purge', false);
|
||||||
|
} else {
|
||||||
|
$result = $this->purgeService->purge($paths, 'module:' . $user);
|
||||||
|
$this->flash(
|
||||||
|
sprintf('HTTP %d — %s', $result['httpCode'], $result['message']),
|
||||||
|
$result['success'] ? 'Purge successful' : 'Purge failed',
|
||||||
|
$result['success']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Warm cache" button: asks the node to render every page of the
|
||||||
|
* sitemap in the background (0066-FE). Returns at once; progress is
|
||||||
|
* the "Cache warming" row of the status table.
|
||||||
|
*/
|
||||||
|
public function warmAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$result = $this->purgeService->warm();
|
||||||
|
$this->flash(
|
||||||
|
$result['message'],
|
||||||
|
$result['success'] ? 'Cache warming' : 'Cache warming failed',
|
||||||
|
$result['success']
|
||||||
|
);
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
|
||||||
|
* the page tree context menu item (ssr-context-menu.js).
|
||||||
|
*/
|
||||||
|
public function purgePage(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$body = (array)($request->getParsedBody() ?? []);
|
||||||
|
$pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0));
|
||||||
|
if ($pageUid <= 0) {
|
||||||
|
return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
|
||||||
|
$path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']);
|
||||||
|
}
|
||||||
|
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
|
||||||
|
$result = $this->purgeService->purge([$path], 'contextmenu:' . $user);
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => $result['success'],
|
||||||
|
'path' => $path,
|
||||||
|
'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /__health of the SSR node. Returns [health array|null, error string].
|
||||||
|
*/
|
||||||
|
private function fetchHealth(): array
|
||||||
|
{
|
||||||
|
$endpoint = $this->purgeService->getHealthEndpoint();
|
||||||
|
if ($endpoint === '') {
|
||||||
|
return [null, 'var/vitec-ssr-purge.env missing or incomplete.'];
|
||||||
|
}
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $this->requestFactory->request($endpoint, 'GET', [
|
||||||
|
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
||||||
|
]);
|
||||||
|
if ($response->getStatusCode() !== 200) {
|
||||||
|
return [null, 'HTTP ' . $response->getStatusCode()];
|
||||||
|
}
|
||||||
|
$health = json_decode((string)$response->getBody(), true) ?: null;
|
||||||
|
if ($health === null) {
|
||||||
|
return [null, 'invalid JSON from /__health'];
|
||||||
|
}
|
||||||
|
$health['responseMs'] = (int)round((microtime(true) - $start) * 1000);
|
||||||
|
return [$health, ''];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [null, $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajax route vitec_ssr_health: live status for the cockpit (warm
|
||||||
|
* progress ring, cache fill, online state; ssr-cockpit.js polls it).
|
||||||
|
*/
|
||||||
|
public function healthJson(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
[$health, $error] = $this->fetchHealth();
|
||||||
|
return new JsonResponse(['health' => $health, 'error' => $error]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rows(string $table, int $limit): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$qb = $this->connectionPool->getQueryBuilderForTable($table);
|
||||||
|
return $qb->select('*')->from($table)
|
||||||
|
->orderBy('uid', 'DESC')->setMaxResults($limit)
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flash(string $message, string $title, bool $ok): void
|
||||||
|
{
|
||||||
|
$flash = GeneralUtility::makeInstance(
|
||||||
|
FlashMessage::class,
|
||||||
|
$message,
|
||||||
|
$title,
|
||||||
|
$ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,14 @@ use TYPO3\CMS\Extbase\Service\ImageService;
|
|||||||
* pages the visitor reaches from the overview page; the menu does not show
|
* pages the visitor reaches from the overview page; the menu does not show
|
||||||
* them, the count and the list serve the "N sub-products" line and search.
|
* them, the count and the list serve the "N sub-products" line and search.
|
||||||
*
|
*
|
||||||
|
* An overview page with exactly ONE product is shown as that product
|
||||||
|
* (decision Harry 2026-11-12, e.g. Avedia, EZ TV, X-Player, ChannelLink,
|
||||||
|
* ActiveSQX): `link` is the product detail page and `image` the product's
|
||||||
|
* own image (productimage, else heroimage, else the page media). `linkType`
|
||||||
|
* says which case it is (`product` | `overview`), `overviewLink` always
|
||||||
|
* keeps the overview page. The page itself stays reachable (breadcrumb,
|
||||||
|
* search, sitemap).
|
||||||
|
*
|
||||||
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
|
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
|
||||||
* category and sub-category is a clickable page. Both levels now carry
|
* category and sub-category is a clickable page. Both levels now carry
|
||||||
* `link` (additive; '' while a level is still a folder). Folders are still
|
* `link` (additive; '' while a level is still a folder). Folders are still
|
||||||
@@ -119,16 +127,21 @@ final class ProvideProductCatalog
|
|||||||
$uid = (int)$itemRow['uid'];
|
$uid = (int)$itemRow['uid'];
|
||||||
$pagePos[$uid] = [$c, $g, $p];
|
$pagePos[$uid] = [$c, $g, $p];
|
||||||
$products = $productsByPage[$uid] ?? [];
|
$products = $productsByPage[$uid] ?? [];
|
||||||
|
// exactly one product: the tile is that product (link + image), see class comment
|
||||||
|
$single = count($products) === 1 ? $products[0] : null;
|
||||||
$item = [
|
$item = [
|
||||||
'type' => 'page',
|
'type' => 'page',
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'title' => (string)$itemRow['title'],
|
'title' => (string)$itemRow['title'],
|
||||||
'link' => (string)$itemRow['slug'],
|
'link' => $single !== null ? $single['link'] : (string)$itemRow['slug'],
|
||||||
|
'linkType' => $single !== null ? 'product' : 'overview',
|
||||||
|
'overviewLink' => (string)$itemRow['slug'],
|
||||||
'productCount' => count($products),
|
'productCount' => count($products),
|
||||||
'products' => $products,
|
'products' => array_map(static fn(array $p): array => ['title' => $p['title'], 'link' => $p['link']], $products),
|
||||||
];
|
];
|
||||||
if ($stage >= 2) {
|
if ($stage >= 2) {
|
||||||
$item['image'] = $this->pageImage($uid);
|
$item['image'] = ($single !== null && $single['uid'] > 0 ? $this->productImage($single['uid']) : null)
|
||||||
|
?? $this->pageImage($uid);
|
||||||
}
|
}
|
||||||
$items[] = $item;
|
$items[] = $item;
|
||||||
}
|
}
|
||||||
@@ -228,7 +241,7 @@ final class ProvideProductCatalog
|
|||||||
* product detail page (container children included), creation order.
|
* product detail page (container children included), creation order.
|
||||||
*
|
*
|
||||||
* @param int[] $pageUids
|
* @param int[] $pageUids
|
||||||
* @return array<int,array<int,array{title:string,link:string}>> page uid => products
|
* @return array<int,array<int,array{title:string,link:string,uid:int}>> page uid => products
|
||||||
*/
|
*/
|
||||||
private function productsOnPages(array $pageUids): array
|
private function productsOnPages(array $pageUids): array
|
||||||
{
|
{
|
||||||
@@ -244,16 +257,49 @@ final class ProvideProductCatalog
|
|||||||
)
|
)
|
||||||
->orderBy('uid')
|
->orderBy('uid')
|
||||||
->executeQuery()->fetchAllAssociative();
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$uidBySlug = $rows === [] ? [] : $this->productUidsBySlug();
|
||||||
$byPage = [];
|
$byPage = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$link = (string)$row['vitec_primary_cta'];
|
||||||
|
$slug = trim((string)parse_url($link, PHP_URL_PATH), '/');
|
||||||
|
$slug = substr($slug, strlen('product/'));
|
||||||
$byPage[(int)$row['pid']][] = [
|
$byPage[(int)$row['pid']][] = [
|
||||||
'title' => (string)$row['header'],
|
'title' => (string)$row['header'],
|
||||||
'link' => (string)$row['vitec_primary_cta'],
|
'link' => $link,
|
||||||
|
'uid' => $uidBySlug[$slug] ?? 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $byPage;
|
return $byPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string,int> product slug (without slashes) => uid, live products only */
|
||||||
|
private function productUidsBySlug(): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
||||||
|
$rows = $queryBuilder->select('uid', 'slug')->from('tx_vitec_domain_model_product')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[trim((string)$row['slug'], '/')] = (int)$row['uid'];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The product's own picture (productimage, else heroimage) as 640px tile - or null. */
|
||||||
|
private function productImage(int $productUid): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
foreach (['productimage', 'heroimage'] as $field) {
|
||||||
|
$references = $this->fileRepository->findByRelation('tx_vitec_domain_model_product', $field, $productUid);
|
||||||
|
if ($references !== []) {
|
||||||
|
return $this->tile($references[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
|
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
|
||||||
private function pageImage(int $pageUid): ?array
|
private function pageImage(int $pageUid): ?array
|
||||||
{
|
{
|
||||||
@@ -262,6 +308,17 @@ final class ProvideProductCatalog
|
|||||||
if ($references === []) {
|
if ($references === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return $this->tile($references[0]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{url:string,alternative:string,width:int,height:int}|null */
|
||||||
|
private function tile(\TYPO3\CMS\Core\Resource\FileReference $reference): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$references = [$reference];
|
||||||
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
|
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
|
||||||
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
|
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
|
||||||
$url = $imageService->getImageUri($processed);
|
$url = $imageService->getImageUri($processed);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Headless;
|
||||||
|
|
||||||
|
use Netzbewegung\NbHeadlessContentBlocks\DataProcessing\ToArray\FileReferenceToArray;
|
||||||
|
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XCLASS of the headless-content-blocks file serializer: delivers
|
||||||
|
* processed, responsive images instead of the original upload.
|
||||||
|
*
|
||||||
|
* Why (Lighthouse 2026-11-10, home page score 40 / LCP 25 s / 42 MB): the
|
||||||
|
* stock serializer emits `publicUrl` = the ORIGINAL file for every
|
||||||
|
* content-block image field (hero_bgimage, intro_image, card images …).
|
||||||
|
* Editors upload 4-13 MB JPEGs, so that is what every visitor downloads -
|
||||||
|
* while news/product images already go through the ImageService.
|
||||||
|
*
|
||||||
|
* Output per image (additive to the stock keys id/alt/title):
|
||||||
|
* - publicUrl: now the processed default variant (WebP, max. 1920 px wide,
|
||||||
|
* editor crop applied) - the frontend needs no change to benefit.
|
||||||
|
* - originalUrl: the untouched upload, should anyone need it.
|
||||||
|
* - srcset: WebP steps 480-1920 px (steps that would fall back to the
|
||||||
|
* original are skipped), as {url,width,descriptor} like elsewhere.
|
||||||
|
* - width/height: pixel size of the default variant (aspect ratio for
|
||||||
|
* CLS-free layout) - mimeType alongside.
|
||||||
|
* Non-images (video, pdf …), SVG and GIF keep the stock behaviour and only
|
||||||
|
* gain mimeType/size (GIF processing would drop animation; SVG scales).
|
||||||
|
*
|
||||||
|
* Exception-safe: any processing failure falls back to the stock output.
|
||||||
|
*/
|
||||||
|
class ProcessedFileReferenceToArray extends FileReferenceToArray
|
||||||
|
{
|
||||||
|
private const DEFAULT_WIDTH = 1920;
|
||||||
|
private const SRCSET_WIDTHS = [480, 768, 1024, 1440, 1920];
|
||||||
|
private const SKIP_EXTENSIONS = ['svg', 'gif'];
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$data = parent::toArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$file = $this->fileReference;
|
||||||
|
$mimeType = (string)$file->getMimeType();
|
||||||
|
$extension = strtolower((string)$file->getExtension());
|
||||||
|
|
||||||
|
// Alt-text fallback (Seobility: 215 pages with empty alt): the
|
||||||
|
// reference title, then the file's title property. Editors can
|
||||||
|
// still override per reference.
|
||||||
|
if (trim((string)($data['alt'] ?? '')) === '') {
|
||||||
|
$fallbackAlt = trim((string)($file->getTitle() ?? ''));
|
||||||
|
if ($fallbackAlt === '') {
|
||||||
|
$fallbackAlt = trim((string)($file->getOriginalFile()->getProperty('title') ?? ''));
|
||||||
|
}
|
||||||
|
if ($fallbackAlt !== '') {
|
||||||
|
$data['alt'] = $fallbackAlt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['mimeType'] = $mimeType;
|
||||||
|
$data['size'] = (int)$file->getSize();
|
||||||
|
|
||||||
|
if (!str_starts_with($mimeType, 'image/') || in_array($extension, self::SKIP_EXTENSIONS, true)) {
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
$data['width'] = (int)$file->getProperty('width');
|
||||||
|
$data['height'] = (int)$file->getProperty('height');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$crop = null;
|
||||||
|
if ($file->hasProperty('crop') && $file->getProperty('crop')) {
|
||||||
|
$cropArea = CropVariantCollection::create((string)$file->getProperty('crop'))->getCropArea('default');
|
||||||
|
if (!$cropArea->isEmpty()) {
|
||||||
|
$crop = $cropArea->makeAbsoluteBasedOnFile($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||||
|
$instructions = static fn (int $width): array => array_filter([
|
||||||
|
'width' => $width,
|
||||||
|
'crop' => $crop,
|
||||||
|
'fileExtension' => 'webp',
|
||||||
|
], static fn ($v) => $v !== null);
|
||||||
|
|
||||||
|
$default = $imageService->applyProcessingInstructions($file, $instructions(self::DEFAULT_WIDTH));
|
||||||
|
|
||||||
|
$srcset = [];
|
||||||
|
foreach (self::SRCSET_WIDTHS as $width) {
|
||||||
|
$variant = $imageService->applyProcessingInstructions($file, $instructions($width));
|
||||||
|
if ($variant->usesOriginalFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$srcset[] = [
|
||||||
|
'url' => $imageService->getImageUri($variant, true),
|
||||||
|
'width' => (int)$variant->getProperty('width'),
|
||||||
|
'descriptor' => (int)$variant->getProperty('width') . 'w',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['originalUrl'] = (string)($data['publicUrl'] ?? '');
|
||||||
|
$data['originalSize'] = (int)$file->getSize();
|
||||||
|
$data['publicUrl'] = $imageService->getImageUri($default, true);
|
||||||
|
$data['width'] = (int)$default->getProperty('width');
|
||||||
|
$data['height'] = (int)$default->getProperty('height');
|
||||||
|
$data['size'] = (int)$default->getSize();
|
||||||
|
$data['srcset'] = $srcset;
|
||||||
|
$data['mimeType'] = (string)($default->getMimeType() ?: 'image/webp');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// keep the stock payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Hooks;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-purge: tells the SSR node which pages to drop from its full-page
|
||||||
|
* cache after editors change content (ticket 0054-FE section 3).
|
||||||
|
*
|
||||||
|
* Mapping rules (v1, deliberately conservative):
|
||||||
|
* - pages: navigation-relevant field changed (title, nav_title, slug,
|
||||||
|
* hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page),
|
||||||
|
* otherwise just that page's path.
|
||||||
|
* - tt_content: the hosting page's path; the megamenu element -> '*'.
|
||||||
|
* - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that
|
||||||
|
* show the record (SsrPurgeTargetResolver: detail URL, explicit
|
||||||
|
* selections, list plugins, embedding records); '*' for URL/visibility
|
||||||
|
* changes, the newest news, events and anything unknown.
|
||||||
|
* - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page.
|
||||||
|
* - delete/move/undelete commands: pages, records and megamenu -> '*',
|
||||||
|
* content elements -> hosting page.
|
||||||
|
*
|
||||||
|
* Changes are queued per request and flushed ONCE in afterAllOperations,
|
||||||
|
* so bulk operations (importer!) end up as a single purge call.
|
||||||
|
* Runs only while the SSR routing flag is ON and the Site Set switch
|
||||||
|
* vitec.ssr.autoPurge is enabled.
|
||||||
|
*/
|
||||||
|
final class DataHandlerSsrPurge
|
||||||
|
{
|
||||||
|
private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
|
||||||
|
|
||||||
|
private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
|
||||||
|
|
||||||
|
/** @var array<string,true> */
|
||||||
|
private static array $queue = [];
|
||||||
|
|
||||||
|
public function processDatamap_afterDatabaseOperations(
|
||||||
|
string $status,
|
||||||
|
string $table,
|
||||||
|
$id,
|
||||||
|
array $fieldArray,
|
||||||
|
DataHandler $dataHandler
|
||||||
|
): void {
|
||||||
|
if (!$this->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($table === 'pages') {
|
||||||
|
if (array_intersect(array_keys($fieldArray), self::NAV_FIELDS) !== []) {
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pageId = $this->realId($id, $dataHandler);
|
||||||
|
if ($pageId > 0) {
|
||||||
|
$this->queuePagePath($pageId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($table === 'tt_content') {
|
||||||
|
$recordId = $this->realId($id, $dataHandler);
|
||||||
|
if ($this->isMegamenuElement($recordId)) {
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pid = $fieldArray['pid'] ?? null;
|
||||||
|
if (!is_numeric($pid) || (int)$pid <= 0) {
|
||||||
|
$pid = $this->pidOfRecord($table, $recordId);
|
||||||
|
}
|
||||||
|
if ((int)$pid > 0) {
|
||||||
|
$this->queuePagePath((int)$pid);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (str_starts_with($table, self::MEGAMENU_PREFIX)) {
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
|
||||||
|
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
|
||||||
|
->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray));
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
self::$queue[$path] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
|
||||||
|
{
|
||||||
|
$this->flush('editor-save');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processCmdmap_postProcess(
|
||||||
|
string $command,
|
||||||
|
string $table,
|
||||||
|
$id,
|
||||||
|
$value,
|
||||||
|
DataHandler $dataHandler
|
||||||
|
): void {
|
||||||
|
if (!$this->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($table === 'pages') {
|
||||||
|
// Delete/move/copy of a page changes menus everywhere.
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($table === 'tt_content') {
|
||||||
|
if ($this->isMegamenuElement((int)$id)) {
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pid = $this->pidOfRecord($table, (int)$id);
|
||||||
|
if ($pid > 0) {
|
||||||
|
$this->queuePagePath($pid);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')
|
||||||
|
|| str_starts_with($table, self::MEGAMENU_PREFIX)) {
|
||||||
|
// Delete, move, copy, undelete: lists change everywhere.
|
||||||
|
self::$queue['*'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The megamenu element (CType evomegamenujson_megamenu) renders on
|
||||||
|
* every page through the site setting megamenu.contentUid.
|
||||||
|
*/
|
||||||
|
private function isMegamenuElement(int $uid): bool
|
||||||
|
{
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
|
||||||
|
$qb->getRestrictions()->removeAll();
|
||||||
|
$cType = $qb->select('CType')->from('tt_content')
|
||||||
|
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
return $cType === 'evomegamenujson_megamenu';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processCmdmap_afterFinish(DataHandler $dataHandler): void
|
||||||
|
{
|
||||||
|
$this->flush('editor-command');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flush(string $trigger): void
|
||||||
|
{
|
||||||
|
if (self::$queue === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
|
||||||
|
self::$queue = [];
|
||||||
|
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isActive(): bool
|
||||||
|
{
|
||||||
|
if (!file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
|
||||||
|
if ($site->getSettings()->get('vitec.ssr.autoPurge', true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queuePagePath(int $pageId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
|
||||||
|
$uri = $site->getRouter()->generateUri($pageId);
|
||||||
|
$path = $uri->getPath() ?: '/';
|
||||||
|
self::$queue[$path] = true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Page outside a site (folders etc.) - nothing cached for it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function realId($id, DataHandler $dataHandler): int
|
||||||
|
{
|
||||||
|
if (is_numeric($id)) {
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
return (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pidOfRecord(string $table, int $uid): int
|
||||||
|
{
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||||
|
$qb->getRestrictions()->removeAll();
|
||||||
|
$pid = $qb->select('pid')->from($table)
|
||||||
|
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
return (int)$pid;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\NormalizedParams;
|
||||||
|
use TYPO3\CMS\Core\Http\Response;
|
||||||
|
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client error endpoint: POST /api/vitec/client-error
|
||||||
|
*
|
||||||
|
* The React frontend reports browser-side failures here (window.onerror,
|
||||||
|
* unhandledrejection, chunk reloads after a deploy, element error
|
||||||
|
* boundaries) - contract 0072-FE section 4, accepted in 0077-BE:
|
||||||
|
*
|
||||||
|
* {url, message, stack, release, userAgent} -> 204 No Content
|
||||||
|
*
|
||||||
|
* Guards, all answered with a 4xx and no body:
|
||||||
|
* 405 method other than POST
|
||||||
|
* 413 body larger than 8 KB (Content-Length or actual bytes)
|
||||||
|
* 400 not a JSON object, or "message" missing/empty
|
||||||
|
* 429 more than 30 reports per minute from one IP (sliding window
|
||||||
|
* via TYPO3's rate limiter, cache "ratelimiter")
|
||||||
|
*
|
||||||
|
* Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
|
||||||
|
* last octet zeroed, IPv6 cut to /48); the full IP is only the key of
|
||||||
|
* the rate limiter and is never stored. No session, no cookies - a
|
||||||
|
* navigator.sendBeacon() call must get through unchanged. Rows older
|
||||||
|
* than 30 days are removed by vitec:client-error-cleanup.
|
||||||
|
*/
|
||||||
|
final class ClientErrorMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private const PATH = '/api/vitec/client-error';
|
||||||
|
private const MAX_BODY_BYTES = 8192;
|
||||||
|
private const LIMIT_PER_MINUTE = 30;
|
||||||
|
private const TABLE = 'tx_vitec_clienterrorlog';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
private readonly RateLimiterFactory $rateLimiterFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$path = rtrim($request->getUri()->getPath(), '/');
|
||||||
|
if ($path !== self::PATH) {
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = strtoupper($request->getMethod());
|
||||||
|
if ($method === 'OPTIONS') {
|
||||||
|
return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
$declared = (int)$request->getHeaderLine('Content-Length');
|
||||||
|
if ($declared > self::MAX_BODY_BYTES) {
|
||||||
|
return $this->empty(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit first, so a flood of oversized or broken bodies is
|
||||||
|
// cheap to refuse as well.
|
||||||
|
$ip = '';
|
||||||
|
try {
|
||||||
|
$normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
|
||||||
|
$ip = (string)$normalized->getRemoteAddress();
|
||||||
|
$limiter = $this->rateLimiterFactory->createLimiter([
|
||||||
|
'id' => 'vitec-client-error',
|
||||||
|
'policy' => 'sliding_window',
|
||||||
|
'limit' => self::LIMIT_PER_MINUTE,
|
||||||
|
'interval' => '1 minute',
|
||||||
|
], $ip);
|
||||||
|
$limit = $limiter->consume(1);
|
||||||
|
if (!$limit->isAccepted()) {
|
||||||
|
$retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
|
||||||
|
return $this->empty(429)->withHeader('Retry-After', (string)$retry);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// A broken limiter must not take the endpoint down; the
|
||||||
|
// body limit and the 30-day cleanup still bound the damage.
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getBody();
|
||||||
|
if ($body->isSeekable()) {
|
||||||
|
$body->rewind();
|
||||||
|
}
|
||||||
|
$raw = $body->read(self::MAX_BODY_BYTES + 1);
|
||||||
|
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||||
|
return $this->empty(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
if (!\is_array($data) || array_is_list($data)) {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
$message = trim($this->str($data['message'] ?? ''));
|
||||||
|
if ($message === '') {
|
||||||
|
return $this->empty(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'pid' => 0,
|
||||||
|
'crdate' => time(),
|
||||||
|
'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
|
||||||
|
'message' => $this->cut($message, 2000),
|
||||||
|
'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
|
||||||
|
'release' => $this->cut($this->str($data['release'] ?? ''), 100),
|
||||||
|
'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
|
||||||
|
'ip' => self::anonymizeIp($ip),
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
$this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Table missing (extension:setup not run yet) or DB hiccup:
|
||||||
|
// the client gets its 204 anyway - a reporting endpoint must
|
||||||
|
// never turn into a second error in the browser console.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->empty(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
|
||||||
|
* kept (2001:db8:1::). Anything else: empty.
|
||||||
|
*/
|
||||||
|
public static function anonymizeIp(string $ip): string
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||||
|
$parts = explode('.', $ip);
|
||||||
|
$parts[3] = '0';
|
||||||
|
return implode('.', $parts);
|
||||||
|
}
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||||
|
$packed = inet_pton($ip);
|
||||||
|
if ($packed === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$hex = bin2hex($packed);
|
||||||
|
$groups = [];
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
|
||||||
|
}
|
||||||
|
return implode(':', $groups) . '::';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function str(mixed $value): string
|
||||||
|
{
|
||||||
|
if (\is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (\is_int($value) || \is_float($value) || \is_bool($value)) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cut(string $value, int $max): string
|
||||||
|
{
|
||||||
|
$value = str_replace("\0", '', $value);
|
||||||
|
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function empty(int $status): ResponseInterface
|
||||||
|
{
|
||||||
|
return (new Response())
|
||||||
|
->withStatus($status)
|
||||||
|
->withHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes client caching for the headless JSON API (/_json):
|
||||||
|
* a single "Cache-Control: no-cache" plus a content ETag, answering
|
||||||
|
* If-None-Match with an empty 304 so browsers revalidate instead of
|
||||||
|
* re-downloading (tickets 0011-FE / 0055-BE).
|
||||||
|
*
|
||||||
|
* "Expires: 0" is set on purpose: mod_expires leaves responses alone
|
||||||
|
* that already carry an Expires header, otherwise it would re-add
|
||||||
|
* max-age/Expires on top of ours (public/.htaccess ExpiresByType).
|
||||||
|
*
|
||||||
|
* Logged-in visitors (fe_typo_user cookie) keep TYPO3's own headers:
|
||||||
|
* their responses may be personalized and must not be eligible for
|
||||||
|
* shared revalidation.
|
||||||
|
*/
|
||||||
|
final class JsonCacheHeaders implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$response = $handler->handle($request);
|
||||||
|
|
||||||
|
$path = $request->getUri()->getPath();
|
||||||
|
if (
|
||||||
|
!\in_array($request->getMethod(), ['GET', 'HEAD'], true)
|
||||||
|
|| $response->getStatusCode() !== 200
|
||||||
|
|| ($path !== '/_json' && !str_starts_with($path, '/_json/'))
|
||||||
|
|| isset($request->getCookieParams()['fe_typo_user'])
|
||||||
|
) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$etag = '"' . md5((string)$response->getBody()) . '"';
|
||||||
|
|
||||||
|
// mod_deflate suffixes the ETag it compressed ("<md5>-gzip");
|
||||||
|
// browsers send that form back, so accept it too (0054-FE 5.3).
|
||||||
|
$ifNoneMatch = str_replace('-gzip"', '"', trim($request->getHeaderLine('If-None-Match')));
|
||||||
|
if ($ifNoneMatch !== '' && str_contains($ifNoneMatch, $etag)) {
|
||||||
|
return (new Response())
|
||||||
|
->withStatus(304)
|
||||||
|
->withHeader('ETag', $etag)
|
||||||
|
->withHeader('Cache-Control', 'no-cache')
|
||||||
|
->withHeader('Expires', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Cache-Control', 'no-cache')
|
||||||
|
->withoutHeader('Pragma')
|
||||||
|
->withHeader('Expires', '0')
|
||||||
|
->withHeader('ETag', $etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Seo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits editor-pasted schema.org JSON blocks out of RTE HTML.
|
||||||
|
*
|
||||||
|
* The SEO-maintained news articles carry a raw FAQPage JSON object in a
|
||||||
|
* trailing <p>…</p> of their bodytext (found 2026-11-10: 17 records). Left
|
||||||
|
* in place, the frontend renders the JSON as visible text while Google
|
||||||
|
* never sees it as structured data. This helper removes such paragraphs
|
||||||
|
* from the HTML and returns the decoded nodes, ready to join the page's
|
||||||
|
* JSON-LD @graph (their own @context is dropped - the graph carries one).
|
||||||
|
*
|
||||||
|
* Fail-safe: a paragraph that merely looks like JSON but does not decode
|
||||||
|
* to a schema.org node stays in the HTML untouched.
|
||||||
|
*/
|
||||||
|
final class EmbeddedJsonLd
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{html: string, nodes: list<array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
public static function extract(string $html): array
|
||||||
|
{
|
||||||
|
if ($html === '' || !str_contains($html, 'schema.org')) {
|
||||||
|
return ['html' => $html, 'nodes' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodes = [];
|
||||||
|
$clean = preg_replace_callback(
|
||||||
|
'#<p[^>]*>\s*(\{.+?\})\s*</p>#s',
|
||||||
|
static function (array $match) use (&$nodes): string {
|
||||||
|
$candidate = $match[1];
|
||||||
|
if (!str_contains($candidate, 'schema.org')) {
|
||||||
|
return $match[0];
|
||||||
|
}
|
||||||
|
$decoded = json_decode($candidate, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
// RTE processing may have entity-encoded the quotes.
|
||||||
|
$decoded = json_decode(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5), true);
|
||||||
|
}
|
||||||
|
if (!is_array($decoded) || empty($decoded['@type'])) {
|
||||||
|
return $match[0];
|
||||||
|
}
|
||||||
|
unset($decoded['@context']);
|
||||||
|
$nodes[] = $decoded;
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
) ?? $html;
|
||||||
|
|
||||||
|
return ['html' => trim($clean), 'nodes' => $nodes];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Seo;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success-story sitemap entries with their PUBLIC URLs.
|
||||||
|
*
|
||||||
|
* The route enhancer generates /success-stories/story/<slug>, but the
|
||||||
|
* public canonical URL is /success-stories/<slug> (SuccessStoryPathRewrite
|
||||||
|
* middleware, old-site SEO URLs). The sitemap must list the canonical
|
||||||
|
* form, so the generated loc is rewritten accordingly.
|
||||||
|
*/
|
||||||
|
final class StoriesXmlSitemapDataProvider extends RecordsXmlSitemapDataProvider
|
||||||
|
{
|
||||||
|
protected function defineUrl(array $data): array
|
||||||
|
{
|
||||||
|
$data = parent::defineUrl($data);
|
||||||
|
|
||||||
|
if (isset($data['loc']) && is_string($data['loc'])) {
|
||||||
|
$data['loc'] = str_replace('/success-stories/story/', '/success-stories/', $data['loc']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,34 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Evomedien\Vitec\Seo;
|
namespace Evomedien\Vitec\Seo;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
|
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the real page <title> into `meta.title` of the page JSON.
|
* Completes the page JSON's `seo` block for the headless frontend.
|
||||||
*
|
*
|
||||||
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page
|
* On top of the stock headless MetaHandler this adds (SEO audit 2026-11-09):
|
||||||
* content, so on product/usecase detail pages it still shows the generic
|
*
|
||||||
* page title ("Product") - the record's seotitle only reaches the
|
* 1. Record-aware metadata: news, product and success-story detail pages
|
||||||
* PageTitleProvider while the content renders. Headless fixes `seo.title`
|
* take title, meta description and og:image from the routed record -
|
||||||
* afterwards through this handler; we extend it so `meta.title` gets the
|
* the shell page itself carries none of that (news details rendered
|
||||||
* same final value. Registered via the MetaHandlerInterface alias in
|
* as <title>article</title> before this).
|
||||||
* Services.yaml, which covers both the cacheable listener and the
|
* 2. A uniform "| VITEC" title suffix wherever the brand is missing.
|
||||||
* USER_INT middleware path.
|
* 3. Fallback meta description from the page record (description/abstract).
|
||||||
|
* 4. og:title / og:description / og:image plus the twitter:* counterparts
|
||||||
|
* whenever they are missing; twitter:card upgrades to
|
||||||
|
* summary_large_image once an image exists.
|
||||||
|
* 5. Canonical rewrite for success stories: the public URL is
|
||||||
|
* /success-stories/<slug>, not the internal /success-stories/story/<slug>
|
||||||
|
* (see SuccessStoryPathRewrite middleware).
|
||||||
|
* 6. Mirrors the final page <title> into `meta.title` (pre-existing: the
|
||||||
|
* TypoScript lib.meta renders before the record providers run).
|
||||||
|
*
|
||||||
|
* Exception-safe: any failure keeps the stock result untouched.
|
||||||
*/
|
*/
|
||||||
class VitecMetaHandler extends MetaHandler
|
class VitecMetaHandler extends MetaHandler
|
||||||
{
|
{
|
||||||
@@ -25,6 +39,12 @@ class VitecMetaHandler extends MetaHandler
|
|||||||
{
|
{
|
||||||
$content = parent::process($request, $content);
|
$content = parent::process($request, $content);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $this->enrich($request, $content);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// keep stock output on any failure
|
||||||
|
}
|
||||||
|
|
||||||
$title = trim((string)($content['seo']['title'] ?? ''));
|
$title = trim((string)($content['seo']['title'] ?? ''));
|
||||||
if ($title !== '' && is_array($content['meta'] ?? null)) {
|
if ($title !== '' && is_array($content['meta'] ?? null)) {
|
||||||
$content['meta']['title'] = $title;
|
$content['meta']['title'] = $title;
|
||||||
@@ -32,4 +52,432 @@ class VitecMetaHandler extends MetaHandler
|
|||||||
|
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $content
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function enrich(ServerRequestInterface $request, array $content): array
|
||||||
|
{
|
||||||
|
$base = $this->resolveBase($request);
|
||||||
|
$args = $this->routeArguments($request);
|
||||||
|
$pageInfo = $request->getAttribute('frontend.page.information');
|
||||||
|
$page = $pageInfo !== null ? $pageInfo->getPageRecord() : [];
|
||||||
|
|
||||||
|
$title = trim((string)($content['seo']['title'] ?? ''));
|
||||||
|
$description = '';
|
||||||
|
$image = '';
|
||||||
|
$canonicalPath = '';
|
||||||
|
|
||||||
|
// --- record context -------------------------------------------------
|
||||||
|
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
|
||||||
|
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
|
||||||
|
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
|
||||||
|
|
||||||
|
if ($newsUid > 0) {
|
||||||
|
$news = $this->fetchRow('tx_news_domain_model_news', ['uid' => $newsUid]);
|
||||||
|
if ($news !== null) {
|
||||||
|
$recordTitle = trim((string)($news['alternative_title'] ?? ''));
|
||||||
|
if ($recordTitle === '') {
|
||||||
|
$recordTitle = trim((string)($news['title'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($recordTitle !== '') {
|
||||||
|
$title = $recordTitle;
|
||||||
|
}
|
||||||
|
$description = $this->plainText((string)($news['teaser'] ?? ''));
|
||||||
|
if ($description === '') {
|
||||||
|
$description = $this->plainText(
|
||||||
|
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
|
||||||
|
// Same text as a success story: the story is the original.
|
||||||
|
$storyUid = (int)($news['vitec_canonical_usecase'] ?? 0);
|
||||||
|
if ($storyUid > 0) {
|
||||||
|
$story = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $storyUid]);
|
||||||
|
$storySlug = trim((string)($story['slug'] ?? ''));
|
||||||
|
if ($storySlug !== '') {
|
||||||
|
$canonicalPath = '/success-stories/' . $storySlug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($productArg !== '') {
|
||||||
|
$product = is_numeric($productArg)
|
||||||
|
? $this->fetchRow('tx_vitec_domain_model_product', ['uid' => (int)$productArg])
|
||||||
|
: $this->fetchRow('tx_vitec_domain_model_product', ['slug' => $productArg]);
|
||||||
|
if ($product !== null) {
|
||||||
|
$description = trim((string)($product['seometa'] ?? ''));
|
||||||
|
if ($description === '') {
|
||||||
|
$description = $this->plainText((string)($product['teaser'] ?? ''));
|
||||||
|
}
|
||||||
|
$productUid = (int)$product['uid'];
|
||||||
|
$image = $this->firstImage('tx_vitec_domain_model_product', 'ogimage', $productUid);
|
||||||
|
if ($image === '') {
|
||||||
|
$image = $this->firstImage('tx_vitec_domain_model_product', 'productimage', $productUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($usecaseUid > 0) {
|
||||||
|
$usecase = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $usecaseUid]);
|
||||||
|
if ($usecase !== null) {
|
||||||
|
$recordTitle = trim((string)($usecase['seo_title'] ?? ''));
|
||||||
|
if ($recordTitle !== '') {
|
||||||
|
$title = $recordTitle;
|
||||||
|
}
|
||||||
|
// "Betfred HQ | VITEC" is too short to say what the page is.
|
||||||
|
$storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title));
|
||||||
|
if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) {
|
||||||
|
$title = $storyCore . ' | Success Story';
|
||||||
|
}
|
||||||
|
$description = trim((string)($usecase['seo_description'] ?? ''));
|
||||||
|
if ($description === '') {
|
||||||
|
$description = trim((string)($usecase['og_description'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($description === '') {
|
||||||
|
$description = $this->plainText((string)($usecase['teaser'] ?? ''));
|
||||||
|
}
|
||||||
|
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'og_image', $usecaseUid);
|
||||||
|
if ($image === '') {
|
||||||
|
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'card_image', $usecaseUid);
|
||||||
|
}
|
||||||
|
if ($image === '') {
|
||||||
|
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'hero_bgimage', $usecaseUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fallbacks from the page record ----------------------------------
|
||||||
|
if ($description === '') {
|
||||||
|
$description = trim((string)($page['description'] ?? ''));
|
||||||
|
if ($description === '') {
|
||||||
|
$description = trim((string)($page['abstract'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($image === '' && !empty($page['uid'])) {
|
||||||
|
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- short page titles get their section as context -------------------
|
||||||
|
// "Aligo | VITEC" (Seobility: 456 titles "zu kurz") becomes
|
||||||
|
// "Aligo | AV over IP & KVM | VITEC"; only for plain pages without an
|
||||||
|
// editor-set SEO title, never for record detail pages.
|
||||||
|
// An SEO title that merely repeats the page title (the import copied
|
||||||
|
// title -> seo_title) counts as "not maintained".
|
||||||
|
$seoTitle = trim((string)($page['seo_title'] ?? ''));
|
||||||
|
$pageTitle = trim((string)($page['title'] ?? ''));
|
||||||
|
// "Aligo | VITEC" as seo_title is the import pattern, not editor work.
|
||||||
|
$seoCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $seoTitle));
|
||||||
|
$seoTitleMaintained = $seoCore !== '' && mb_strtolower($seoCore) !== mb_strtolower($pageTitle);
|
||||||
|
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
|
||||||
|
&& $pageTitle !== '' && mb_strlen($pageTitle) < 30
|
||||||
|
&& !$seoTitleMaintained
|
||||||
|
) {
|
||||||
|
$parentTitle = $this->parentTitle($pageInfo);
|
||||||
|
if ($parentTitle !== '' && mb_stripos($pageTitle, $parentTitle) === false) {
|
||||||
|
$title = $pageTitle . ' | ' . $parentTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- uniform brand suffix ---------------------------------------------
|
||||||
|
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
|
||||||
|
$title .= ' | VITEC';
|
||||||
|
}
|
||||||
|
if ($title !== '') {
|
||||||
|
$content['seo']['title'] = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- compose meta list ------------------------------------------------
|
||||||
|
$meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : [];
|
||||||
|
// ~155 characters is what search engines show (Seobility: 527 "zu lang").
|
||||||
|
$description = $this->crop($description, 155);
|
||||||
|
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
|
||||||
|
|
||||||
|
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
|
||||||
|
$meta[] = ['name' => 'description', 'content' => $description];
|
||||||
|
}
|
||||||
|
// Editorial descriptions already in the list get the same length cap.
|
||||||
|
foreach ($meta as $index => $entry) {
|
||||||
|
if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) {
|
||||||
|
$meta[$index]['content'] = $this->crop(trim($entry['content']), 155);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$finalDescription = $this->metaContent($meta, 'name', 'description');
|
||||||
|
if ($finalDescription === '') {
|
||||||
|
$finalDescription = $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($title !== '' && !$this->hasMeta($meta, 'property', 'og:title')) {
|
||||||
|
$meta[] = ['property' => 'og:title', 'content' => $title];
|
||||||
|
}
|
||||||
|
if ($finalDescription !== '' && !$this->hasMeta($meta, 'property', 'og:description')) {
|
||||||
|
$meta[] = ['property' => 'og:description', 'content' => $finalDescription];
|
||||||
|
}
|
||||||
|
if ($absImage !== '' && !$this->hasMeta($meta, 'property', 'og:image')) {
|
||||||
|
$meta[] = ['property' => 'og:image', 'content' => $absImage];
|
||||||
|
}
|
||||||
|
if ($title !== '' && !$this->hasMeta($meta, 'name', 'twitter:title')) {
|
||||||
|
$meta[] = ['name' => 'twitter:title', 'content' => $title];
|
||||||
|
}
|
||||||
|
if ($finalDescription !== '' && !$this->hasMeta($meta, 'name', 'twitter:description')) {
|
||||||
|
$meta[] = ['name' => 'twitter:description', 'content' => $finalDescription];
|
||||||
|
}
|
||||||
|
$finalImage = $this->metaContent($meta, 'property', 'og:image');
|
||||||
|
if ($finalImage !== '' && !$this->hasMeta($meta, 'name', 'twitter:image')) {
|
||||||
|
$meta[] = ['name' => 'twitter:image', 'content' => $finalImage];
|
||||||
|
}
|
||||||
|
if ($finalImage !== '') {
|
||||||
|
$hasCard = false;
|
||||||
|
foreach ($meta as $index => $entry) {
|
||||||
|
if (($entry['name'] ?? '') === 'twitter:card') {
|
||||||
|
$meta[$index]['content'] = 'summary_large_image';
|
||||||
|
$hasCard = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasCard) {
|
||||||
|
$meta[] = ['name' => 'twitter:card', 'content' => 'summary_large_image'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$content['seo']['meta'] = $meta;
|
||||||
|
|
||||||
|
// --- success-story canonical: public short URL ------------------------
|
||||||
|
if (is_array($content['seo']['link'] ?? null)) {
|
||||||
|
foreach ($content['seo']['link'] as $index => $link) {
|
||||||
|
if (isset($link['href']) && is_string($link['href'])) {
|
||||||
|
$content['seo']['link'][$index]['href'] =
|
||||||
|
str_replace('/success-stories/story/', '/success-stories/', $link['href']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- news article whose canonical is a success story --------------------
|
||||||
|
if ($canonicalPath !== '') {
|
||||||
|
$href = $this->absolute($canonicalPath, $base);
|
||||||
|
$links = is_array($content['seo']['link'] ?? null) ? $content['seo']['link'] : [];
|
||||||
|
$replaced = false;
|
||||||
|
foreach ($links as $index => $link) {
|
||||||
|
if (($link['rel'] ?? '') === 'canonical') {
|
||||||
|
$links[$index]['href'] = $href;
|
||||||
|
$replaced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$replaced) {
|
||||||
|
$links[] = ['href' => $href, 'rel' => 'canonical'];
|
||||||
|
}
|
||||||
|
$content['seo']['link'] = $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- paginated / filtered list pages: self-referencing canonical --------
|
||||||
|
// /insights/news?page=2 pointed at page 1, which marks every further
|
||||||
|
// page as a duplicate and weakens the links to its articles (0100-FE).
|
||||||
|
if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) {
|
||||||
|
$query = $request->getQueryParams();
|
||||||
|
$keep = [];
|
||||||
|
foreach (['category', 'page'] as $key) {
|
||||||
|
$value = $query[$key] ?? null;
|
||||||
|
if (!is_scalar($value) || trim((string)$value) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key === 'page' && (int)$value <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$keep[$key] = trim((string)$value);
|
||||||
|
}
|
||||||
|
if ($keep !== [] && is_array($content['seo']['link'] ?? null)) {
|
||||||
|
foreach ($content['seo']['link'] as $index => $link) {
|
||||||
|
if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) {
|
||||||
|
$content['seo']['link'][$index]['href'] =
|
||||||
|
strtok($link['href'], '?') . '?' . http_build_query($keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Self-canonical pages need their own title/description, otherwise
|
||||||
|
// they count as duplicates of page 1 ("VITEC News and Articles" x3).
|
||||||
|
if ($keep !== []) {
|
||||||
|
$suffix = [];
|
||||||
|
if (isset($keep['category'])) {
|
||||||
|
$suffix[] = ucfirst(str_replace('-', ' ', $keep['category']));
|
||||||
|
}
|
||||||
|
if (isset($keep['page'])) {
|
||||||
|
$suffix[] = 'Page ' . (int)$keep['page'];
|
||||||
|
}
|
||||||
|
$suffixText = ' – ' . implode(' – ', $suffix);
|
||||||
|
$currentTitle = trim((string)($content['seo']['title'] ?? ''));
|
||||||
|
if ($currentTitle !== '') {
|
||||||
|
$content['seo']['title'] = preg_match('/^(.*?)(\s*\|\s*VITEC)$/i', $currentTitle, $m) === 1
|
||||||
|
? $m[1] . $suffixText . $m[2]
|
||||||
|
: $currentTitle . $suffixText;
|
||||||
|
}
|
||||||
|
foreach ($content['seo']['meta'] as $index => $entry) {
|
||||||
|
if (in_array($entry['name'] ?? '', ['description', 'twitter:description'], true)
|
||||||
|
|| ($entry['property'] ?? '') === 'og:description'
|
||||||
|
) {
|
||||||
|
$content['seo']['meta'][$index]['content'] = rtrim((string)$entry['content'], '. ') . $suffixText . '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation title of the parent page, or '' when the parent is the site
|
||||||
|
* root, a technical container (user_nav, folders) or missing.
|
||||||
|
*/
|
||||||
|
private function parentTitle($pageInfo): string
|
||||||
|
{
|
||||||
|
if ($pageInfo === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// Rootline keys are the depth (0 = root), but the array is krsort()ed
|
||||||
|
// (current page first). Walk up from the parent, skipping structural
|
||||||
|
// levels (folders, shortcuts, spacers, technical names) until a page
|
||||||
|
// with a presentable name turns up; the site root never counts.
|
||||||
|
$rootLine = $pageInfo->getRootLine();
|
||||||
|
ksort($rootLine);
|
||||||
|
$byDepth = array_values($rootLine);
|
||||||
|
for ($i = count($byDepth) - 2; $i >= 1; $i--) {
|
||||||
|
$candidate = $byDepth[$i];
|
||||||
|
if (!is_array($candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? ''));
|
||||||
|
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route arguments of the current request (record detail context).
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function routeArguments(ServerRequestInterface $request): array
|
||||||
|
{
|
||||||
|
$routing = $request->getAttribute('routing');
|
||||||
|
$args = $routing instanceof PageArguments ? $routing->getArguments() : [];
|
||||||
|
// Query-parameter fallback (uncached plugin context).
|
||||||
|
return array_replace_recursive($request->getQueryParams(), $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,int|string> $where equality constraints
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function fetchRow(string $table, array $where): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||||
|
$constraints = [
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('hidden', 0),
|
||||||
|
];
|
||||||
|
foreach ($where as $field => $value) {
|
||||||
|
$constraints[] = is_int($value)
|
||||||
|
? $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::INTEGER))
|
||||||
|
: $qb->expr()->eq($field, $qb->createNamedParameter((string)$value, ParameterType::STRING));
|
||||||
|
}
|
||||||
|
$row = $qb->select('*')->from($table)->where(...$constraints)
|
||||||
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root-relative URL of the first FAL image attached to a record field.
|
||||||
|
*/
|
||||||
|
private function firstImage(string $table, string $field, int $uid): string
|
||||||
|
{
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
|
||||||
|
$row = $qb
|
||||||
|
->select('f.identifier')
|
||||||
|
->from('sys_file_reference', 'fr')
|
||||||
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('fr.deleted', 0),
|
||||||
|
$qb->expr()->eq('fr.hidden', 0),
|
||||||
|
$qb->expr()->eq('f.missing', 0)
|
||||||
|
)
|
||||||
|
->orderBy('fr.sorting_foreign', 'ASC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAssociative();
|
||||||
|
|
||||||
|
$identifier = (string)($row['identifier'] ?? '');
|
||||||
|
return $identifier !== '' ? '/fileadmin' . $identifier : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,string>> $meta
|
||||||
|
*/
|
||||||
|
private function hasMeta(array $meta, string $attribute, string $key): bool
|
||||||
|
{
|
||||||
|
return $this->metaContent($meta, $attribute, $key) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,string>> $meta
|
||||||
|
*/
|
||||||
|
private function metaContent(array $meta, string $attribute, string $key): string
|
||||||
|
{
|
||||||
|
foreach ($meta as $entry) {
|
||||||
|
if (strtolower((string)($entry[$attribute] ?? '')) === $key) {
|
||||||
|
return trim((string)($entry['content'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBase(ServerRequestInterface $request): string
|
||||||
|
{
|
||||||
|
$normalizedParams = $request->getAttribute('normalizedParams');
|
||||||
|
if ($normalizedParams !== null) {
|
||||||
|
return rtrim($normalizedParams->getSiteUrl(), '/');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function absolute(string $url, string $base): string
|
||||||
|
{
|
||||||
|
if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
return $base . '/' . ltrim($url, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse RTE HTML to a single plain-text line.
|
||||||
|
*/
|
||||||
|
private function plainText(string $value): string
|
||||||
|
{
|
||||||
|
$value = strip_tags($value);
|
||||||
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop to $max characters at a word boundary (ellipsis appended).
|
||||||
|
*/
|
||||||
|
private function crop(string $value, int $max): string
|
||||||
|
{
|
||||||
|
if ($value === '' || mb_strlen($value) <= $max) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($value, 0, $max);
|
||||||
|
$lastSpace = mb_strrpos($cut, ' ');
|
||||||
|
if ($lastSpace !== false && $lastSpace > (int)($max * 0.6)) {
|
||||||
|
$cut = mb_substr($cut, 0, $lastSpace);
|
||||||
|
}
|
||||||
|
return rtrim($cut, " \t.,;:") . '...';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells test reports and bot reports in tx_vitec_clienterrorlog apart
|
||||||
|
* from real visitor errors (ticket 0114-FE: the cockpit tile must not
|
||||||
|
* count smoke tests). One rule set for the module and the dashboard.
|
||||||
|
*
|
||||||
|
* Test: sent with curl, a user agent that says "smoke" or "client-error
|
||||||
|
* test", a message starting with "test", or the dummy release "t" of the
|
||||||
|
* endpoint smoke test. Bot: a crawler user agent (Googlebot & Co.); bot
|
||||||
|
* reports stay real errors, they only get a label.
|
||||||
|
*/
|
||||||
|
final class ClientErrorClassifier
|
||||||
|
{
|
||||||
|
public static function isTest(string $message, string $userAgent, string $release = ''): bool
|
||||||
|
{
|
||||||
|
if ($release === 't') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('#curl/|smoke|client-error test#i', $userAgent) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return preg_match('#^\s*test\b#i', $message) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isBot(string $userAgent): bool
|
||||||
|
{
|
||||||
|
return preg_match('#bot\b|crawler|spider|slurp|lighthouse|headlesschrome#i', $userAgent) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isChunkReload(string $message): bool
|
||||||
|
{
|
||||||
|
return str_contains(strtolower($message), 'chunk reload');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||||
|
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page content audit for the market and solution trees (sibling of
|
||||||
|
* ProductCheckService, decision Harry 2026-11-13): which market / sub-market /
|
||||||
|
* solution-group / sub-solution PAGE carries which content, and the full
|
||||||
|
* content of one page for the detail pane.
|
||||||
|
*
|
||||||
|
* Unlike products these are pages built from content elements, so the audit
|
||||||
|
* looks at the elements in page order (hero, intro, cards, testimonials,
|
||||||
|
* products, insights, logos, CTA banner), at the page properties (meta
|
||||||
|
* description, OG image, page media) and at the market / solution record that
|
||||||
|
* points to the page via detail_page.
|
||||||
|
*
|
||||||
|
* Scopes: 'markets' = children of /markets (11 markets, 24 sub-markets)
|
||||||
|
* 'solutions' = children of /solutions (8 groups, 39 sub-solutions)
|
||||||
|
*
|
||||||
|
* Columns carry an `applies` list - a column a page type does not have (e.g.
|
||||||
|
* 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',
|
||||||
|
'rootSlug' => '/markets',
|
||||||
|
'types' => ['market', 'submarket'],
|
||||||
|
'recordTable' => 'tx_vitec_domain_model_market',
|
||||||
|
],
|
||||||
|
'solutions' => [
|
||||||
|
'label' => 'Solutions',
|
||||||
|
'rootSlug' => '/solutions',
|
||||||
|
'types' => ['solution', 'subsolution'],
|
||||||
|
'recordTable' => 'tx_vitec_domain_model_solution',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ALL = ['market', 'submarket', 'solution', 'subsolution'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns in display order. kind as in the product check (images / video /
|
||||||
|
* text / features / list), applies = page types that have the column.
|
||||||
|
*/
|
||||||
|
private const FIELDS = [
|
||||||
|
['key' => 'hero', 'label' => 'Hero image', 'kind' => 'images', 'applies' => self::ALL],
|
||||||
|
['key' => 'subline', 'label' => 'Hero subline', 'kind' => 'text', 'applies' => self::ALL],
|
||||||
|
['key' => 'lead', 'label' => 'Lead', 'kind' => 'text', 'applies' => ['solution', 'subsolution']],
|
||||||
|
['key' => 'intro', 'label' => 'Intro text', 'kind' => 'text', 'applies' => self::ALL],
|
||||||
|
['key' => 'introImage', 'label' => 'Intro image', 'kind' => 'images', 'applies' => self::ALL],
|
||||||
|
['key' => 'introCta', 'label' => 'Intro CTA', 'kind' => 'text', 'applies' => self::ALL],
|
||||||
|
['key' => 'cards', 'label' => 'Cards', 'kind' => 'features', 'applies' => self::ALL],
|
||||||
|
['key' => 'stories', 'label' => 'Testimonials', 'kind' => 'list', 'applies' => self::ALL],
|
||||||
|
['key' => 'products', 'label' => 'Products', 'kind' => 'list', 'applies' => ['submarket', 'subsolution']],
|
||||||
|
['key' => 'insights', 'label' => 'Insights text', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
|
||||||
|
['key' => 'insightsCover', 'label' => 'Insights cover', 'kind' => 'images', 'applies' => ['submarket', 'solution', 'subsolution']],
|
||||||
|
['key' => 'insightsCta', 'label' => 'Insights CTA', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
|
||||||
|
['key' => 'logos', 'label' => 'Logos', 'kind' => 'list', 'applies' => ['market']],
|
||||||
|
['key' => 'cta', 'label' => 'CTA banner', 'kind' => 'text', 'applies' => ['market', 'solution']],
|
||||||
|
['key' => 'seo', 'label' => 'Meta description', 'kind' => 'text', 'applies' => self::ALL],
|
||||||
|
['key' => 'og', 'label' => 'OG image', 'kind' => 'images', 'applies' => self::ALL],
|
||||||
|
['key' => 'media', 'label' => 'Page media', 'kind' => 'images', 'applies' => self::ALL],
|
||||||
|
['key' => 'record', 'label' => 'Record', 'kind' => 'list', 'applies' => self::ALL],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const HERO_COLPOS = [1, 110];
|
||||||
|
private const CONTAINER_PREFIXES = ['vitec_cols_', 'vitec_container', 'vitec_cards_carousel'];
|
||||||
|
|
||||||
|
/** Text fields per element type for the detail pane: column => label. */
|
||||||
|
private const ELEMENT_TEXTS = [
|
||||||
|
'vitec_markethero' => ['vitec_subline' => 'Subline', 'vitec_cta_label' => 'CTA label', 'vitec_video_title' => 'Video title'],
|
||||||
|
'vitec_herosection' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_cta_label' => 'CTA label'],
|
||||||
|
'vitec_introparagraph' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_button_text' => 'Button'],
|
||||||
|
'vitec_marketintro' => ['vitec_intro_headline' => 'Headline', 'vitec_intro_text' => 'Text', 'vitec_learnmore_label' => 'Button'],
|
||||||
|
'vitec_card' => ['vitec_eyebrow' => 'Eyebrow', 'header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_back_headline' => 'Back headline', 'vitec_back_text' => 'Back text', 'vitec_primary_cta_label' => 'Button'],
|
||||||
|
'vitec_usecaselist' => ['header' => 'Header'],
|
||||||
|
'vitec_customerlogos' => ['header' => 'Header'],
|
||||||
|
'vitec_marketinsights' => ['vitec_text1' => 'Text 1', 'vitec_text2' => 'Text 2', 'vitec_cta_label' => 'CTA label', 'vitec_video_person' => 'Video person', 'vitec_video_role' => 'Video role', 'vitec_video_title' => 'Video title'],
|
||||||
|
'vitec_ctabanner' => ['header' => 'Header', 'bodytext' => 'Text', 'vitec_primary_cta_label' => 'Primary button', 'vitec_secondary_cta_label' => 'Secondary button'],
|
||||||
|
'vitec_productfeature' => ['header' => 'Header'],
|
||||||
|
'vitec_productlist' => ['header' => 'Header'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Link fields per element type: column => label. */
|
||||||
|
private const ELEMENT_LINKS = [
|
||||||
|
'vitec_markethero' => ['vitec_cta_link' => 'CTA link', 'vitec_video_url' => 'Video URL'],
|
||||||
|
'vitec_herosection' => ['vitec_cta' => 'CTA link'],
|
||||||
|
'vitec_introparagraph' => ['vitec_button_link' => 'Button link'],
|
||||||
|
'vitec_marketintro' => ['vitec_learnmore_target' => 'Button target'],
|
||||||
|
'vitec_card' => ['vitec_primary_cta' => 'Button link', 'vitec_card_link' => 'Card link'],
|
||||||
|
'vitec_marketinsights' => ['vitec_cta_target' => 'CTA target', 'vitec_video_url' => 'Video URL'],
|
||||||
|
'vitec_ctabanner' => ['vitec_primary_cta' => 'Primary link', 'vitec_secondary_cta' => 'Secondary link'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ELEMENT_LABELS = [
|
||||||
|
'vitec_markethero' => 'Hero',
|
||||||
|
'vitec_herosection' => 'Hero section',
|
||||||
|
'vitec_introparagraph' => 'Intro paragraph',
|
||||||
|
'vitec_marketintro' => 'Intro (text | image)',
|
||||||
|
'vitec_card' => 'Card',
|
||||||
|
'vitec_usecaselist' => 'Testimonials',
|
||||||
|
'vitec_customerlogos' => 'Customer logos',
|
||||||
|
'vitec_marketinsights' => 'Insights',
|
||||||
|
'vitec_ctabanner' => 'CTA banner',
|
||||||
|
'vitec_productfeature' => 'Featured product',
|
||||||
|
'vitec_productlist' => 'Product list',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function overview(string $scope): array
|
||||||
|
{
|
||||||
|
$cfg = self::SCOPES[$scope] ?? throw new \InvalidArgumentException('Unknown scope ' . $scope);
|
||||||
|
$pages = $this->pages($cfg);
|
||||||
|
$pageUids = array_map(static fn(array $p): int => (int)$p['uid'], $pages);
|
||||||
|
$elementsByPage = $this->elementsByPage($pageUids);
|
||||||
|
$allElementUids = [];
|
||||||
|
foreach ($elementsByPage as $els) {
|
||||||
|
foreach ($els as $el) {
|
||||||
|
$allElementUids[] = (int)$el['uid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
$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, '')) <> ''"));
|
||||||
|
$allCustomers = $this->countAll('tx_vitec_domain_model_customer', 'deleted = 0 AND hidden = 0');
|
||||||
|
|
||||||
|
$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 = [];
|
||||||
|
foreach ($pages as $p) {
|
||||||
|
$uid = (int)$p['uid'];
|
||||||
|
$type = (string)$p['_type'];
|
||||||
|
$byType[$type] = ($byType[$type] ?? 0) + 1;
|
||||||
|
$els = $elementsByPage[$uid] ?? [];
|
||||||
|
$visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0));
|
||||||
|
$hiddenCount = count($els) - count($visible);
|
||||||
|
$record = $records[$uid] ?? null;
|
||||||
|
$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) {
|
||||||
|
$key = $field['key'];
|
||||||
|
if (!in_array($type, $field['applies'], true)) {
|
||||||
|
$checks[$key] = ['ok' => null, 'count' => 0];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$checks[$key] = $raw[$key];
|
||||||
|
$applicable[$key]++;
|
||||||
|
if (($raw[$key]['placeholder'] ?? 0) > 0) {
|
||||||
|
$placeholder[$key]++;
|
||||||
|
} elseif ($raw[$key]['ok'] === true) {
|
||||||
|
$filled[$key]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $this->base($p, $type, $record, $hiddenCount) + ['checks' => $checks];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope' => $scope,
|
||||||
|
'scopeLabel' => $cfg['label'],
|
||||||
|
'generatedAt' => date(\DATE_ATOM),
|
||||||
|
'fields' => self::FIELDS,
|
||||||
|
'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'placeholder' => $placeholder, 'applicable' => $applicable],
|
||||||
|
'pages' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null null when the page is not part of any scope */
|
||||||
|
public function detail(int $uid): ?array
|
||||||
|
{
|
||||||
|
foreach (self::SCOPES as $scope => $cfg) {
|
||||||
|
foreach ($this->pages($cfg) as $p) {
|
||||||
|
if ((int)$p['uid'] !== $uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$type = (string)$p['_type'];
|
||||||
|
$records = $this->records($cfg['recordTable'], [$uid]);
|
||||||
|
$record = $records[$uid] ?? null;
|
||||||
|
$els = $this->elementsByPage([$uid])[$uid] ?? [];
|
||||||
|
$hiddenCount = count(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 1));
|
||||||
|
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
|
||||||
|
|
||||||
|
$elements = [];
|
||||||
|
foreach ($els as $el) {
|
||||||
|
$cType = (string)$el['CType'];
|
||||||
|
$texts = [];
|
||||||
|
foreach (self::ELEMENT_TEXTS[$cType] ?? ['header' => 'Header', 'bodytext' => 'Text'] as $column => $label) {
|
||||||
|
$value = (string)($el[$column] ?? '');
|
||||||
|
$texts[] = ['key' => $column, 'label' => $label, 'ok' => $this->filled($value), 'html' => $value];
|
||||||
|
}
|
||||||
|
$links = [];
|
||||||
|
foreach (self::ELEMENT_LINKS[$cType] ?? [] as $column => $label) {
|
||||||
|
$value = trim((string)($el[$column] ?? ''));
|
||||||
|
if ($value !== '') {
|
||||||
|
$links[] = ['key' => $column, 'label' => $label, 'target' => $value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$settings = [];
|
||||||
|
if (trim((string)($el['pi_flexform'] ?? '')) !== '') {
|
||||||
|
$settings = $flexFormService->convertFlexFormContentToArray((string)$el['pi_flexform'])['settings'] ?? [];
|
||||||
|
$settings = array_filter($settings, static fn($v): bool => is_scalar($v) && (string)$v !== '' && (string)$v !== '0');
|
||||||
|
$settings = array_map(static fn($v): string => (string)$v, $settings);
|
||||||
|
}
|
||||||
|
$elements[] = [
|
||||||
|
'uid' => (int)$el['uid'],
|
||||||
|
'type' => $cType,
|
||||||
|
'label' => self::ELEMENT_LABELS[$cType] ?? $cType,
|
||||||
|
'colPos' => (int)$el['colPos'],
|
||||||
|
'slot' => in_array((int)$el['colPos'], self::HERO_COLPOS, true) ? 'hero' : 'main',
|
||||||
|
'hidden' => (int)$el['hidden'] === 1,
|
||||||
|
'header' => (string)($el['header'] ?? ''),
|
||||||
|
'layoutVariant' => (string)($el['vitec_layout_variant'] ?? ''),
|
||||||
|
'texts' => $texts,
|
||||||
|
'links' => $links,
|
||||||
|
'images' => $this->files('tt_content', (int)$el['uid']),
|
||||||
|
'settings' => $settings,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = [];
|
||||||
|
if ((int)$p['_level'] === 1) {
|
||||||
|
foreach ($this->pages($cfg) as $c) {
|
||||||
|
if ((int)$c['pid'] === $uid) {
|
||||||
|
$children[] = ['uid' => (int)$c['uid'], 'title' => (string)$c['title'], 'url' => (string)$c['slug'], 'hidden' => (int)$c['hidden'] === 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->base($p, $type, $record, $hiddenCount) + [
|
||||||
|
'seo' => [
|
||||||
|
'title' => (string)$p['title'],
|
||||||
|
'seoTitle' => (string)($p['seo_title'] ?? ''),
|
||||||
|
'description' => (string)($p['description'] ?? ''),
|
||||||
|
'og' => $this->files('pages', $uid, ['og_image']),
|
||||||
|
],
|
||||||
|
'media' => $this->files('pages', $uid, ['media']),
|
||||||
|
'record' => $record === null ? null : [
|
||||||
|
'uid' => (int)$record['uid'],
|
||||||
|
'title' => (string)$record['title'],
|
||||||
|
'subtitle' => (string)($record['subtitle'] ?? ''),
|
||||||
|
'teaser' => (string)($record['teaser'] ?? ''),
|
||||||
|
'description' => (string)($record['description'] ?? ''),
|
||||||
|
'image' => $this->files($cfg['recordTable'], (int)$record['uid'], ['image']),
|
||||||
|
],
|
||||||
|
'elements' => $elements,
|
||||||
|
'children' => $children,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- checks -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $visible visible elements in page order
|
||||||
|
* @param array{n: array<int, array<string, int>>, ph: array<int, array<string, int>>} $elementFiles n/ph: [elementUid][fieldname] = count of all / of placeholder files
|
||||||
|
* @param array{n: array<string, int>, ph: array<string, int>} $pageFiles [fieldname] = count
|
||||||
|
* @param array<string, mixed> $page
|
||||||
|
* @param array<string, mixed>|null $record
|
||||||
|
* @param array{n: array<string, int>, ph: array<string, int>} $recordFiles
|
||||||
|
* @return array<string, array{ok: bool, count: int, icons?: int, placeholder?: int}>
|
||||||
|
*/
|
||||||
|
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['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)) {
|
||||||
|
$lead = $el;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$intro = $this->firstOf($visible, 'vitec_marketintro');
|
||||||
|
$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['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');
|
||||||
|
$storyCount = 0;
|
||||||
|
if ($stories) {
|
||||||
|
$sel = $this->flexSettings((string)$stories['pi_flexform']);
|
||||||
|
$selected = GeneralUtility::intExplode(',', (string)($sel['usecases'] ?? ''), true);
|
||||||
|
$storyCount = $selected === [] ? $allStories : count($selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
$productCount = 0;
|
||||||
|
$hasProducts = false;
|
||||||
|
foreach ($visible as $el) {
|
||||||
|
if ($el['CType'] === 'vitec_productfeature') {
|
||||||
|
$hasProducts = true;
|
||||||
|
$productCount += (int)(($this->flexSettings((string)$el['pi_flexform'])['product'] ?? 0) > 0);
|
||||||
|
} elseif ($el['CType'] === 'vitec_productlist') {
|
||||||
|
$hasProducts = true;
|
||||||
|
$sel = $this->flexSettings((string)$el['pi_flexform']);
|
||||||
|
$productCount += count(GeneralUtility::intExplode(',', (string)($sel['selectedproducts'] ?? ''), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$insights = $this->firstOf($visible, 'vitec_marketinsights');
|
||||||
|
$insightsFiles = $insights ? ($elementFiles['n'][(int)$insights['uid']] ?? []) : [];
|
||||||
|
$insightsPh = $insights ? ($elementFiles['ph'][(int)$insights['uid']] ?? []) : [];
|
||||||
|
|
||||||
|
$logos = $this->firstOf($visible, 'vitec_customerlogos');
|
||||||
|
$logoCount = 0;
|
||||||
|
if ($logos) {
|
||||||
|
$sel = $this->flexSettings((string)$logos['pi_flexform']);
|
||||||
|
$selected = GeneralUtility::intExplode(',', (string)($sel['customers'] ?? ''), true);
|
||||||
|
$logoCount = $selected === [] || ($sel['onlySelected'] ?? '0') !== '1' ? $allCustomers : count($selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cta = $this->firstOf($visible, 'vitec_ctabanner');
|
||||||
|
|
||||||
|
$recordFilled = 0;
|
||||||
|
if ($record !== null) {
|
||||||
|
$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($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, $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), $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, $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['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']] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- building blocks ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $p
|
||||||
|
* @param array<string, mixed>|null $record
|
||||||
|
*/
|
||||||
|
private function base(array $p, string $type, ?array $record, int $hiddenElements): array
|
||||||
|
{
|
||||||
|
$flags = [];
|
||||||
|
if ((int)$p['hidden'] === 1) {
|
||||||
|
$flags[] = 'hidden';
|
||||||
|
}
|
||||||
|
if ((int)$p['nav_hide'] === 1) {
|
||||||
|
$flags[] = 'navHide';
|
||||||
|
}
|
||||||
|
if ((int)$p['no_index'] === 1) {
|
||||||
|
$flags[] = 'noindex';
|
||||||
|
}
|
||||||
|
if ($record === null) {
|
||||||
|
$flags[] = 'noRecord';
|
||||||
|
}
|
||||||
|
if ($hiddenElements > 0) {
|
||||||
|
$flags[] = 'hiddenElements:' . $hiddenElements;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'uid' => (int)$p['uid'],
|
||||||
|
'title' => (string)$p['title'],
|
||||||
|
'slug' => trim((string)$p['slug'], '/'),
|
||||||
|
'url' => (string)$p['slug'],
|
||||||
|
'type' => $type,
|
||||||
|
'level' => (int)$p['_level'],
|
||||||
|
'parent' => (int)$p['pid'],
|
||||||
|
'flags' => $flags,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filled(mixed $value): bool
|
||||||
|
{
|
||||||
|
return trim(strip_tags((string)$value)) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array<string, mixed>> $elements */
|
||||||
|
private function firstOf(array $elements, string $cType): ?array
|
||||||
|
{
|
||||||
|
foreach ($elements as $el) {
|
||||||
|
if ($el['CType'] === $cType) {
|
||||||
|
return $el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function flexSettings(string $xml): array
|
||||||
|
{
|
||||||
|
if (trim($xml) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
|
||||||
|
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scope's pages: level 1 = children of the root page, level 2 = their
|
||||||
|
* children. Standard pages only, in tree order; a page carrying the check
|
||||||
|
* element itself is left out.
|
||||||
|
*
|
||||||
|
* @param array{rootSlug: string, types: array<int, string>} $cfg
|
||||||
|
* @return array<int, array<string, mixed>> rows with _level and _type
|
||||||
|
*/
|
||||||
|
private function pages(array $cfg): array
|
||||||
|
{
|
||||||
|
$conn = $this->connection('pages');
|
||||||
|
$root = $conn->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$cfg['rootSlug']]);
|
||||||
|
if ($root === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$select = 'SELECT uid, pid, title, slug, hidden, nav_hide, no_index, description, seo_title, sorting FROM pages
|
||||||
|
WHERE deleted = 0 AND sys_language_uid = 0 AND doktype = 1 AND pid IN (%s) ORDER BY pid, sorting';
|
||||||
|
$level1 = $conn->fetchAllAssociative(sprintf($select, (int)$root['uid']));
|
||||||
|
$checkPages = $this->pagesWithCheckElement();
|
||||||
|
$out = [];
|
||||||
|
foreach ($level1 as $p1) {
|
||||||
|
if (isset($checkPages[(int)$p1['uid']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = $p1 + ['_level' => 1, '_type' => $cfg['types'][0]];
|
||||||
|
$level2 = $conn->fetchAllAssociative(sprintf($select, (int)$p1['uid']));
|
||||||
|
foreach ($level2 as $p2) {
|
||||||
|
if (isset($checkPages[(int)$p2['uid']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = $p2 + ['_level' => 2, '_type' => $cfg['types'][1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, true> page uid => true for pages that hold a check element */
|
||||||
|
private function pagesWithCheckElement(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->connection('tt_content')->fetchFirstColumn(
|
||||||
|
"SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN ('vitec_pagecheck', 'vitec_productcheck')"
|
||||||
|
) as $pid) {
|
||||||
|
$out[(int)$pid] = true;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All non-deleted default-language elements of the pages, page order,
|
||||||
|
* containers themselves left out (their children count as page content).
|
||||||
|
*
|
||||||
|
* @param int[] $pageUids
|
||||||
|
* @return array<int, array<int, array<string, mixed>>> [pageUid] = rows
|
||||||
|
*/
|
||||||
|
private function elementsByPage(array $pageUids): array
|
||||||
|
{
|
||||||
|
if ($pageUids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = $this->connection('tt_content')->fetchAllAssociative(
|
||||||
|
'SELECT * FROM tt_content WHERE deleted = 0 AND sys_language_uid = 0 AND pid IN ('
|
||||||
|
. implode(',', array_map('intval', $pageUids)) . ') ORDER BY pid, colPos, sorting, uid'
|
||||||
|
);
|
||||||
|
// page order: hero slot first, then the main column(s) by colPos and sorting
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
$heroA = in_array((int)$a['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
|
||||||
|
$heroB = in_array((int)$b['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
|
||||||
|
return [(int)$a['pid'], $heroA, (int)$a['colPos'], (int)$a['sorting'], (int)$a['uid']]
|
||||||
|
<=> [(int)$b['pid'], $heroB, (int)$b['colPos'], (int)$b['sorting'], (int)$b['uid']];
|
||||||
|
});
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cType = (string)$row['CType'];
|
||||||
|
foreach (self::CONTAINER_PREFIXES as $prefix) {
|
||||||
|
if (str_starts_with($cType, $prefix)) {
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out[(int)$row['pid']][] = $row;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible file references per record and field.
|
||||||
|
*
|
||||||
|
* @param int[] $uids
|
||||||
|
* @return array<int, array<string, int>> [uid][fieldname] = count
|
||||||
|
*/
|
||||||
|
private function referenceCounts(string $table, array $uids, bool $placeholdersOnly = false): array
|
||||||
|
{
|
||||||
|
if ($uids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$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'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files of one record with original URL and a ~480 px thumbnail.
|
||||||
|
*
|
||||||
|
* @param string[]|null $fieldnames null = all fields
|
||||||
|
* @return array<int, array{field: string, url: string, thumb: string, name: string, mime: string, placeholder: bool}>
|
||||||
|
*/
|
||||||
|
private function files(string $table, int $uid, ?array $fieldnames = null): array
|
||||||
|
{
|
||||||
|
$sql = "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 = ? AND r.uid_foreign = ?";
|
||||||
|
$params = [$table, $uid];
|
||||||
|
if ($fieldnames !== null) {
|
||||||
|
$sql .= ' AND r.fieldname IN (' . implode(',', array_fill(0, count($fieldnames), '?')) . ')';
|
||||||
|
$params = array_merge($params, $fieldnames);
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY r.fieldname, r.sorting_foreign';
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->connection('sys_file_reference')->fetchAllAssociative($sql, $params) as $r) {
|
||||||
|
$url = '/fileadmin' . $r['identifier'];
|
||||||
|
$out[] = [
|
||||||
|
'field' => (string)preg_replace('/^vitec_/', '', (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'],
|
||||||
|
'placeholder' => self::isPlaceholder((string)$r['identifier'], (string)$r['name']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records pointing to the pages via detail_page (first live record per page).
|
||||||
|
*
|
||||||
|
* @param int[] $pageUids
|
||||||
|
* @return array<int, array<string, mixed>> [pageUid] = record row
|
||||||
|
*/
|
||||||
|
private function records(string $table, array $pageUids): array
|
||||||
|
{
|
||||||
|
if ($pageUids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = $this->connection($table)->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, subtitle, teaser, description, detail_page FROM ' . $table . '
|
||||||
|
WHERE deleted = 0 AND detail_page IN (' . implode(',', array_map('intval', $pageUids)) . ') ORDER BY hidden, uid'
|
||||||
|
);
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[(int)$r['detail_page']] ??= $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countAll(string $table, string $where): int
|
||||||
|
{
|
||||||
|
return (int)$this->connection($table)->fetchOne("SELECT COUNT(*) FROM $table WHERE $where");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connection(string $table): Connection
|
||||||
|
{
|
||||||
|
return $this->connectionPool->getConnectionForTable($table);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
<?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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
$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, $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),
|
||||||
|
'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['placeholder'] ?? 0) > 0) {
|
||||||
|
$placeholder[$key]++;
|
||||||
|
} elseif ($c['ok']) {
|
||||||
|
$filled[$key]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $this->base($p) + ['checks' => $checks];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'generatedAt' => date(\DATE_ATOM),
|
||||||
|
'fields' => self::FIELDS,
|
||||||
|
'summary' => ['products' => count($rows), 'filled' => $filled, 'placeholder' => $placeholder],
|
||||||
|
'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, int $placeholder = 0): array
|
||||||
|
{
|
||||||
|
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, bool $placeholdersOnly = false): array
|
||||||
|
{
|
||||||
|
if ($uids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$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'];
|
||||||
|
}
|
||||||
|
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, placeholder: bool}>>
|
||||||
|
*/
|
||||||
|
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'],
|
||||||
|
'placeholder' => PageCheckService::isPlaceholder((string)$r['identifier'], (string)$r['name']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Evomedien\Vitec\Service;
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Cache\CacheManager;
|
||||||
use TYPO3\CMS\Core\Core\Environment;
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the SSR routing flag file in sync with the site setting
|
* Keeps the SSR routing flag file in sync with the site setting
|
||||||
@@ -81,12 +83,58 @@ final class SsrFlagService
|
|||||||
. "flip the switch in Site Management > Settings instead.\n"
|
. "flip the switch in Site Management > Settings instead.\n"
|
||||||
. 'Last sync: ' . date('c') . "\n"
|
. 'Last sync: ' . date('c') . "\n"
|
||||||
);
|
);
|
||||||
|
// Fresh ON: whatever the node cached while the switch was OFF
|
||||||
|
// is stale by definition - drop its whole page cache once.
|
||||||
|
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class)
|
||||||
|
->purgeQuietly(['*'], 'switch-on');
|
||||||
} else {
|
} else {
|
||||||
@unlink($this->getFlagPath());
|
@unlink($this->getFlagPath());
|
||||||
}
|
}
|
||||||
return $desired;
|
return $desired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-failover (vitec:ssr-health): turns SSR delivery hard OFF.
|
||||||
|
*
|
||||||
|
* Removing only the flag file would not stick - the self-healing
|
||||||
|
* SyncSsrFlag middleware recreates it from the still-true site setting
|
||||||
|
* on the very next request. So the setting itself is flipped to false
|
||||||
|
* in config/sites/<identifier>/settings.yaml (the file the backend
|
||||||
|
* settings editor writes, flat dotted keys), caches are flushed so the
|
||||||
|
* compiled site settings forget the old value, and only then the flag
|
||||||
|
* is removed. Re-enabling is deliberately manual (backend switch or
|
||||||
|
* settings.yaml + cache flush).
|
||||||
|
*/
|
||||||
|
public function disableSsr(): void
|
||||||
|
{
|
||||||
|
$changed = false;
|
||||||
|
foreach ($this->siteFinder->getAllSites() as $site) {
|
||||||
|
$file = Environment::getConfigPath() . '/sites/' . $site->getIdentifier() . '/settings.yaml';
|
||||||
|
if (!is_file($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$yaml = (string)file_get_contents($file);
|
||||||
|
$new = preg_replace('/^(vitec\.ssr\.enabled\s*:).*$/m', '$1 false', $yaml, 1, $count);
|
||||||
|
if ($new !== null && $count > 0 && $new !== $yaml) {
|
||||||
|
if (file_put_contents($file, $new) === false) {
|
||||||
|
throw new \RuntimeException('Could not write ' . $file, 1762800001);
|
||||||
|
}
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$changed && $this->isEnabledInSettings()) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'vitec.ssr.enabled not found in any config/sites/*/settings.yaml - cannot disable it, '
|
||||||
|
. 'and the SyncSsrFlag middleware would recreate the flag from the setting',
|
||||||
|
1762800002
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($changed) {
|
||||||
|
GeneralUtility::makeInstance(CacheManager::class)->flushCaches();
|
||||||
|
}
|
||||||
|
@unlink($this->getFlagPath());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync variant for the request hot path: never lets an IO hiccup
|
* Sync variant for the request hot path: never lets an IO hiccup
|
||||||
* (read-only FS, concurrent unlink ...) break request handling.
|
* (read-only FS, concurrent unlink ...) break request handling.
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||||
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
|
||||||
|
* keeps a log of every attempt in tx_vitec_ssrpurgelog.
|
||||||
|
*
|
||||||
|
* Endpoint + secret live OUTSIDE the repository in var/vitec-ssr-purge.env
|
||||||
|
* (KEY=VALUE lines: SSR_PURGE_ENDPOINT, SSR_PURGE_SECRET, SSR_HEALTH_ENDPOINT,
|
||||||
|
* SSR_ALERT_EMAIL, SSR_FAILOVER_AFTER)
|
||||||
|
* - same pattern as the routing flag file, nothing secret in git.
|
||||||
|
*/
|
||||||
|
final class SsrPurgeService
|
||||||
|
{
|
||||||
|
private ?array $config = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestFactory $requestFactory,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
private readonly SiteFinder $siteFinder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function getConfigPath(): string
|
||||||
|
{
|
||||||
|
return Environment::getVarPath() . '/vitec-ssr-purge.env';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$c = $this->getConfig();
|
||||||
|
return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHealthEndpoint(): string
|
||||||
|
{
|
||||||
|
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /__stats sibling of the health endpoint (cumulative hit/miss/
|
||||||
|
* error counters, in the node since release 20261109-211544). Derived
|
||||||
|
* from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly.
|
||||||
|
*/
|
||||||
|
public function getStatsEndpoint(): string
|
||||||
|
{
|
||||||
|
$explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? '';
|
||||||
|
if ($explicit !== '') {
|
||||||
|
return $explicit;
|
||||||
|
}
|
||||||
|
$health = $this->getHealthEndpoint();
|
||||||
|
return $health === '' ? '' : str_replace('/__health', '/__stats', $health);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated recipient list for vitec:ssr-health transition and
|
||||||
|
* failover alerts (SSR_ALERT_EMAIL); empty string = alerting off.
|
||||||
|
*/
|
||||||
|
public function getAlertEmail(): string
|
||||||
|
{
|
||||||
|
return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who gets the vitec:ssr-health alert mails. Maintained by editors in
|
||||||
|
* Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
|
||||||
|
* address per entry); the first site with at least one valid address
|
||||||
|
* wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
|
||||||
|
* fallback. Invalid entries are skipped and reported back.
|
||||||
|
*
|
||||||
|
* @return array{recipients: list<string>, source: string, invalid: list<string>}
|
||||||
|
*/
|
||||||
|
public function getAlertRecipients(): array
|
||||||
|
{
|
||||||
|
$invalid = [];
|
||||||
|
try {
|
||||||
|
foreach ($this->siteFinder->getAllSites() as $site) {
|
||||||
|
$list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
|
||||||
|
if (is_string($list)) {
|
||||||
|
$list = GeneralUtility::trimExplode(',', $list, true);
|
||||||
|
}
|
||||||
|
$valid = [];
|
||||||
|
foreach ((array)$list as $address) {
|
||||||
|
$address = trim((string)$address);
|
||||||
|
if ($address === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (GeneralUtility::validEmail($address)) {
|
||||||
|
$valid[] = $address;
|
||||||
|
} else {
|
||||||
|
$invalid[] = $address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($valid !== []) {
|
||||||
|
return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// No site configuration readable (e.g. broken YAML): use the env fallback.
|
||||||
|
}
|
||||||
|
$env = array_values(array_filter(
|
||||||
|
GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
|
||||||
|
static fn(string $a): bool => GeneralUtility::validEmail($a)
|
||||||
|
));
|
||||||
|
return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive DOWN samples after which vitec:ssr-health fails over to
|
||||||
|
* the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute
|
||||||
|
* cron cadence). Never below 1.
|
||||||
|
*/
|
||||||
|
public function getFailoverAfter(): int
|
||||||
|
{
|
||||||
|
return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSR_CACHE_MB of the node (/etc/vitec-ssr.env on the VPS) as the
|
||||||
|
* module knows it — the node does not report its limit in /__health.
|
||||||
|
* Optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env; 0 = unknown.
|
||||||
|
*/
|
||||||
|
public function getCacheLimitMb(): int
|
||||||
|
{
|
||||||
|
return max(0, (int)($this->getConfig()['SSR_CACHE_LIMIT_MB'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /__warm on the node (contract 0066-FE), derived from
|
||||||
|
* SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly.
|
||||||
|
*/
|
||||||
|
public function getWarmEndpoint(): string
|
||||||
|
{
|
||||||
|
$explicit = $this->getConfig()['SSR_WARM_ENDPOINT'] ?? '';
|
||||||
|
if ($explicit !== '') {
|
||||||
|
return $explicit;
|
||||||
|
}
|
||||||
|
$purge = $this->getConfig()['SSR_PURGE_ENDPOINT'] ?? '';
|
||||||
|
return $purge === '' ? '' : str_replace('/__purge', '/__warm', $purge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a background warm run on the node: no paths (or '*') = every
|
||||||
|
* page of sitemap.xml, otherwise exactly the given paths. The node
|
||||||
|
* answers at once and renders in the background; progress shows up as
|
||||||
|
* "warming" in /__health and /__stats. Not written to the purge log -
|
||||||
|
* a warm run evicts nothing, and the log feeds the purge statistics.
|
||||||
|
*
|
||||||
|
* @param string[] $paths
|
||||||
|
* @return array{success:bool, httpCode:int, message:string, data:array}
|
||||||
|
*/
|
||||||
|
public function warm(array $paths = []): array
|
||||||
|
{
|
||||||
|
if (!$this->isConfigured()) {
|
||||||
|
return ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'data' => []];
|
||||||
|
}
|
||||||
|
$paths = $this->normalize($paths);
|
||||||
|
$payload = ($paths === [] || $paths === ['*']) ? ['source' => 'sitemap'] : ['paths' => $paths];
|
||||||
|
try {
|
||||||
|
$response = $this->requestFactory->request($this->getWarmEndpoint(), 'POST', [
|
||||||
|
'headers' => [
|
||||||
|
'X-Purge-Secret' => $this->getConfig()['SSR_PURGE_SECRET'],
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
|
||||||
|
// The node reads the sitemap (index + sub-sitemaps) before
|
||||||
|
// it answers; the pages themselves are not awaited.
|
||||||
|
'timeout' => 30,
|
||||||
|
'connect_timeout' => 3,
|
||||||
|
'http_errors' => false,
|
||||||
|
]);
|
||||||
|
$code = $response->getStatusCode();
|
||||||
|
$body = (string)$response->getBody();
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
$data = is_array($data) ? $data : [];
|
||||||
|
if ($code === 200 && !empty($data['started'])) {
|
||||||
|
$message = sprintf(
|
||||||
|
'Warm run started: %d pages queued (source: %s, %d at a time)',
|
||||||
|
(int)($data['queued'] ?? 0),
|
||||||
|
(string)($data['source'] ?? '?'),
|
||||||
|
(int)($data['concurrency'] ?? 0)
|
||||||
|
);
|
||||||
|
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
|
||||||
|
}
|
||||||
|
if ($code === 200 && !empty($data['running'])) {
|
||||||
|
$w = (array)($data['warming'] ?? []);
|
||||||
|
$message = sprintf(
|
||||||
|
'A warm run is already active: %d of %d pages done',
|
||||||
|
(int)($w['done'] ?? 0) + (int)($w['skipped'] ?? 0) + (int)($w['failed'] ?? 0),
|
||||||
|
(int)($w['queued'] ?? 0)
|
||||||
|
);
|
||||||
|
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
|
||||||
|
}
|
||||||
|
$message = 'HTTP ' . $code . ' - ' . (string)($data['error'] ?? substr($body, 0, 200));
|
||||||
|
return ['success' => false, 'httpCode' => $code, 'message' => $message, 'data' => $data];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'data' => []];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes, dedupes and sends the paths; logs the outcome.
|
||||||
|
* Never throws - editors' saves must not fail on a purge problem.
|
||||||
|
*
|
||||||
|
* @param string[] $paths page paths, or ['*'] for everything
|
||||||
|
* @return array{success:bool, httpCode:int, message:string, paths:string[]}
|
||||||
|
*/
|
||||||
|
public function purge(array $paths, string $trigger): array
|
||||||
|
{
|
||||||
|
$paths = $this->normalize($paths);
|
||||||
|
if ($paths === []) {
|
||||||
|
return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []];
|
||||||
|
}
|
||||||
|
if (!$this->isConfigured()) {
|
||||||
|
$result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths];
|
||||||
|
$this->log($trigger, $result, 0);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$c = $this->getConfig();
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [
|
||||||
|
'headers' => [
|
||||||
|
'X-Purge-Secret' => $c['SSR_PURGE_SECRET'],
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR),
|
||||||
|
'timeout' => 4,
|
||||||
|
'connect_timeout' => 2,
|
||||||
|
'http_errors' => false,
|
||||||
|
]);
|
||||||
|
$code = $response->getStatusCode();
|
||||||
|
$body = substr((string)$response->getBody(), 0, 200);
|
||||||
|
$result = [
|
||||||
|
'success' => $code === 200,
|
||||||
|
'httpCode' => $code,
|
||||||
|
'message' => $body,
|
||||||
|
'paths' => $paths,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths];
|
||||||
|
}
|
||||||
|
$this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000));
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purgeQuietly(array $paths, string $trigger): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->purge($paths, $trigger);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Logged inside purge(); never disturb the caller.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query
|
||||||
|
* stripped, no trailing slash (root stays '/'), '*' wins over all.
|
||||||
|
*
|
||||||
|
* @param string[] $paths
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function normalize(array $paths): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
$p = trim((string)$p);
|
||||||
|
if ($p === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($p === '*') {
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
$p = parse_url($p, PHP_URL_PATH) ?: '/';
|
||||||
|
if ($p !== '/' ) {
|
||||||
|
$p = '/' . trim($p, '/');
|
||||||
|
}
|
||||||
|
$out[$p] = true;
|
||||||
|
}
|
||||||
|
return array_keys($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getConfig(): array
|
||||||
|
{
|
||||||
|
if ($this->config !== null) {
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
$this->config = [];
|
||||||
|
$file = self::getConfigPath();
|
||||||
|
if (is_readable($file)) {
|
||||||
|
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||||
|
if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$k, $v] = explode('=', $line, 2);
|
||||||
|
$this->config[trim($k)] = trim($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $trigger, array $result, int $durationMs): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert(
|
||||||
|
'tx_vitec_ssrpurgelog',
|
||||||
|
[
|
||||||
|
'crdate' => time(),
|
||||||
|
'trigger_source' => substr($trigger, 0, 100),
|
||||||
|
'paths' => implode(', ', $result['paths']),
|
||||||
|
'success' => $result['success'] ? 1 : 0,
|
||||||
|
'http_code' => $result['httpCode'],
|
||||||
|
'message' => substr($result['message'], 0, 255),
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Table missing (before schema update) must not break anything.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targeted auto-purge (2026-11-12, Harry): which SSR paths show a given
|
||||||
|
* VITEC or news record, so saving one product no longer empties the
|
||||||
|
* whole SSR cache.
|
||||||
|
*
|
||||||
|
* A record shows up on
|
||||||
|
* 1. its own detail URL (route enhancers in config/sites/vitec/config.yaml),
|
||||||
|
* 2. pages whose content elements select it explicitly (sys_refindex),
|
||||||
|
* 3. pages with list plugins that pick records of its type on their own,
|
||||||
|
* 4. the pages of records that embed it (related products, product
|
||||||
|
* downloads, product solutions) - one level deep.
|
||||||
|
* Everything still goes ('*') when the change can touch links or menus on
|
||||||
|
* every page: URL, visibility or timing fields, the newest news and the
|
||||||
|
* events (the megamenu spotlight shows them live), unknown tables, or
|
||||||
|
* more than MAX_PATHS paths. When in doubt: '*'.
|
||||||
|
*/
|
||||||
|
final class SsrPurgeTargetResolver
|
||||||
|
{
|
||||||
|
public const ALL = '*';
|
||||||
|
private const MAX_PATHS = 150;
|
||||||
|
|
||||||
|
/** Changing one of these can change URLs, menus or visibility everywhere. */
|
||||||
|
private const GLOBAL_FIELDS = ['slug', 'path_segment', 'hidden', 'deleted', 'starttime', 'endtime',
|
||||||
|
'fe_group', 'pid', 'sys_language_uid', 'type', 'datetime', 'is_group'];
|
||||||
|
|
||||||
|
/** Backend-only tables: nothing of them is rendered. */
|
||||||
|
private const INTERNAL_TABLES = ['tx_vitec_form_submission', 'tx_vitec_import_mapping', 'tx_vitec_seo_research',
|
||||||
|
'tx_vitec_product_workbook', 'tx_vitec_ssrpurgelog', 'tx_vitec_ssrhealthlog', 'tx_vitec_clienterrorlog'];
|
||||||
|
|
||||||
|
/** Inline children: child table => [parent field, parent table]. */
|
||||||
|
private const CHILD_TABLES = [
|
||||||
|
'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
|
||||||
|
'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
|
||||||
|
'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
|
||||||
|
'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Shown in the megamenu or rarely changed and used broadly. */
|
||||||
|
private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
|
||||||
|
'tx_news_domain_model_tag'];
|
||||||
|
|
||||||
|
/** List plugins that show records of a table without a fixed selection. */
|
||||||
|
private const LIST_CTYPES = [
|
||||||
|
// Product lists (vitec_productlist) filter by category: productListPages().
|
||||||
|
'tx_vitec_domain_model_product' => ['vitec_productcheck'],
|
||||||
|
'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
|
||||||
|
'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
|
||||||
|
'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
|
||||||
|
'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
|
||||||
|
'tx_vitec_domain_model_location' => ['vitec_locationlist'],
|
||||||
|
'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
|
||||||
|
'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Records that embed a record of the key table: [embedding table, field]. */
|
||||||
|
private const EMBEDDED_IN = [
|
||||||
|
'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
|
||||||
|
['tx_vitec_product_related_text', 'related_uid']],
|
||||||
|
'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
|
||||||
|
'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
|
||||||
|
'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
|
||||||
|
private const DETAIL_ROUTES = [
|
||||||
|
'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
|
||||||
|
'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
|
||||||
|
'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
|
||||||
|
private const SPOTLIGHT_NEWS = 5;
|
||||||
|
private const SPOTLIGHT_NEWS_PIDS = [11, 442];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $changedFields field names of the save ([] = unknown)
|
||||||
|
* @return list<string> paths to purge, ['*'] for everything, [] for nothing visible
|
||||||
|
*/
|
||||||
|
public function resolve(string $table, int $uid, array $changedFields): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->doResolve($table, $uid, $changedFields);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [self::ALL];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function doResolve(string $table, int $uid, array $changedFields): array
|
||||||
|
{
|
||||||
|
if (in_array($table, self::INTERNAL_TABLES, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return [self::ALL];
|
||||||
|
}
|
||||||
|
if (isset(self::CHILD_TABLES[$table])) {
|
||||||
|
[$field, $parentTable] = self::CHILD_TABLES[$table];
|
||||||
|
$parentUid = (int)$this->connection($table)->executeQuery(
|
||||||
|
'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
|
||||||
|
)->fetchOne();
|
||||||
|
return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
|
||||||
|
}
|
||||||
|
if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
|
||||||
|
return [self::ALL];
|
||||||
|
}
|
||||||
|
if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
|
||||||
|
return [self::ALL];
|
||||||
|
}
|
||||||
|
if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
|
||||||
|
return [self::ALL];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = $this->pathsOf($table, $uid);
|
||||||
|
foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
|
||||||
|
foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
|
||||||
|
$paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
|
||||||
|
$paths[] = $this->pagePath($pid);
|
||||||
|
}
|
||||||
|
if ($table === 'tx_vitec_domain_model_product') {
|
||||||
|
foreach ($this->productListPages($uid) as $pid) {
|
||||||
|
$paths[] = $this->pagePath($pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$paths = array_values(array_unique(array_filter($paths)));
|
||||||
|
return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail URL(s) plus the pages that select the record explicitly. */
|
||||||
|
private function pathsOf(string $table, int $uid): array
|
||||||
|
{
|
||||||
|
$paths = $this->detailPaths($table, $uid);
|
||||||
|
$pids = $this->connection('sys_refindex')->executeQuery(
|
||||||
|
'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
|
||||||
|
WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
|
||||||
|
['tt_content', $table, $uid]
|
||||||
|
)->fetchFirstColumn();
|
||||||
|
foreach ($pids as $pid) {
|
||||||
|
$paths[] = $this->pagePath((int)$pid);
|
||||||
|
}
|
||||||
|
if ($table === 'tx_vitec_domain_model_solution') {
|
||||||
|
$detailPage = (int)$this->connection($table)->executeQuery(
|
||||||
|
'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
|
||||||
|
)->fetchOne();
|
||||||
|
if ($detailPage > 0) {
|
||||||
|
$paths[] = $this->pagePath($detailPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{0: string, 1: int}> records embedding the given one */
|
||||||
|
private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
|
||||||
|
{
|
||||||
|
if ($embeddingTable === 'tx_vitec_product_related_text') {
|
||||||
|
$rows = $this->connection($embeddingTable)->executeQuery(
|
||||||
|
'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
|
||||||
|
)->fetchFirstColumn();
|
||||||
|
return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
|
||||||
|
}
|
||||||
|
$rows = $this->connection('sys_refindex')->executeQuery(
|
||||||
|
'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
|
||||||
|
[$embeddingTable, $field, $table, $uid]
|
||||||
|
)->fetchFirstColumn();
|
||||||
|
return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detailPaths(string $table, int $uid): array
|
||||||
|
{
|
||||||
|
if (!isset(self::DETAIL_ROUTES[$table])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
[$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
|
||||||
|
$paths = [];
|
||||||
|
foreach ($pageUids as $pageUid) {
|
||||||
|
try {
|
||||||
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
|
||||||
|
$paths[] = $site->getRouter()->generateUri($pageUid, [
|
||||||
|
$namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
|
||||||
|
])->getPath();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Record not routable (hidden, no slug): its pages are covered by the lists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pagesWithCTypes(array $cTypes): array
|
||||||
|
{
|
||||||
|
if ($cTypes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return array_map('intval', $this->connection('tt_content')->executeQuery(
|
||||||
|
'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
|
||||||
|
[$cTypes],
|
||||||
|
[Connection::PARAM_STR_ARRAY]
|
||||||
|
)->fetchFirstColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages with a product list that can show the product. Mirrors
|
||||||
|
* ProductListJsonRenderer: selected products (settings.selectedproducts)
|
||||||
|
* show exactly those - covered by the refindex; otherwise a category
|
||||||
|
* filter matches the selected categories and their whole subtree;
|
||||||
|
* "all products" or no category shows every product.
|
||||||
|
*/
|
||||||
|
private function productListPages(int $productUid): array
|
||||||
|
{
|
||||||
|
// The product's categories plus all their ancestors.
|
||||||
|
$categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
|
||||||
|
"SELECT uid_local FROM sys_category_record_mm
|
||||||
|
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
|
||||||
|
[$productUid]
|
||||||
|
)->fetchFirstColumn());
|
||||||
|
$seen = [];
|
||||||
|
$queue = $categories;
|
||||||
|
while ($queue !== []) {
|
||||||
|
$cat = array_shift($queue);
|
||||||
|
if ($cat <= 0 || isset($seen[$cat])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$cat] = true;
|
||||||
|
$queue[] = (int)$this->connection('sys_category')->executeQuery(
|
||||||
|
'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
|
||||||
|
)->fetchOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = [];
|
||||||
|
$lists = $this->connection('tt_content')->executeQuery(
|
||||||
|
"SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
foreach ($lists as $list) {
|
||||||
|
$refs = $this->connection('sys_refindex')->executeQuery(
|
||||||
|
"SELECT ref_table, ref_uid FROM sys_refindex
|
||||||
|
WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
|
||||||
|
AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
|
||||||
|
[(int)$list['uid']]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
$selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
|
||||||
|
if ($selected !== []) {
|
||||||
|
continue; // fixed selection: in the refindex pages already when it contains the product
|
||||||
|
}
|
||||||
|
$listCategories = array_map('intval', array_column(
|
||||||
|
array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
|
||||||
|
'ref_uid'
|
||||||
|
));
|
||||||
|
$allProducts = preg_match('#settings\.allproducts">\s*<value index="vDEF">1<#', (string)$list['pi_flexform']) === 1;
|
||||||
|
if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
|
||||||
|
$pages[] = (int)$list['pid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values(array_unique($pages));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSpotlightNews(int $uid): bool
|
||||||
|
{
|
||||||
|
$newest = $this->connection('tx_news_domain_model_news')->executeQuery(
|
||||||
|
'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
|
||||||
|
ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
|
||||||
|
[self::SPOTLIGHT_NEWS_PIDS],
|
||||||
|
[Connection::PARAM_INT_ARRAY]
|
||||||
|
)->fetchFirstColumn();
|
||||||
|
return in_array($uid, array_map('intval', $newest), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pagePath(int $pageId): string
|
||||||
|
{
|
||||||
|
if ($pageId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
|
||||||
|
return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ''; // Folder or page outside a site: nothing cached for it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connection(string $table): Connection
|
||||||
|
{
|
||||||
|
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,8 +155,40 @@ final class NewsJsonRenderer
|
|||||||
return json_encode($response) ?: '';
|
return json_encode($response) ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-side pagination for the big overview list (contract
|
||||||
|
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
|
||||||
|
// WITHOUT a hand-picked selection paginates. That is exactly the
|
||||||
|
// /insights/news element (#130); every teaser list carries a
|
||||||
|
// limit (1-3) and keeps today's full behaviour, as do selected/
|
||||||
|
// search lists - the frontend falls back to client-side paging
|
||||||
|
// wherever the `pagination` key is absent.
|
||||||
|
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
|
||||||
|
&& (int)($settings['limit'] ?? 0) === 0
|
||||||
|
&& trim((string)($settings['selectedList'] ?? '')) === '';
|
||||||
|
|
||||||
|
if ($paginated
|
||||||
|
&& $this->cObj !== null
|
||||||
|
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
|
||||||
|
) {
|
||||||
|
// `page`/`category` sit in cacheHash.excludedParameters, so the
|
||||||
|
// TYPO3 page cache does NOT distinguish the query variants -
|
||||||
|
// a cacheable render would bake page 1 in for every URL. As
|
||||||
|
// USER_INT the element renders per request; EXT:headless wraps
|
||||||
|
// the INT marker itself (JsonContentObject detects
|
||||||
|
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
|
||||||
|
// variant per path+query, so per-request cost stays off the
|
||||||
|
// visitors' hot path.
|
||||||
|
$this->cObj->convertToUserIntObject();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
$newsRows = $this->fetchNewsList($contentElement, $settings);
|
$newsRows = $this->fetchNewsList($contentElement, $settings);
|
||||||
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows);
|
|
||||||
|
if ($paginated) {
|
||||||
|
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
|
||||||
|
|
||||||
$response = [
|
$response = [
|
||||||
'mode' => 'list',
|
'mode' => 'list',
|
||||||
@@ -323,12 +355,28 @@ final class NewsJsonRenderer
|
|||||||
* @param array<string,mixed> $settings
|
* @param array<string,mixed> $settings
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
private function serializeNews(array $news, array $settings = []): array
|
private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array
|
||||||
{
|
{
|
||||||
$uid = (int)($news['uid'] ?? 0);
|
$uid = (int)($news['uid'] ?? 0);
|
||||||
$pathSegment = (string)($news['path_segment'] ?? '');
|
$pathSegment = (string)($news['path_segment'] ?? '');
|
||||||
$urls = $this->buildNewsUrls($news, $settings);
|
$urls = $this->buildNewsUrls($news, $settings);
|
||||||
|
|
||||||
|
// Detail view only (contract 0083-BE): the SEO articles carry a
|
||||||
|
// "Frequently asked questions" section as bare <p> pairs plus inline
|
||||||
|
// product links. The Q&A pairs move into a structured `faq` band
|
||||||
|
// (accordion in the frontend), linked products/categories become
|
||||||
|
// `mentionedProducts` cards; the body keeps everything else.
|
||||||
|
$detailBody = null;
|
||||||
|
$faqBand = null;
|
||||||
|
$mentionedProducts = [];
|
||||||
|
if ($mode !== 'list') {
|
||||||
|
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
|
||||||
|
$faqSplit = $this->extractFaqBand($detailBody);
|
||||||
|
$detailBody = $faqSplit['html'];
|
||||||
|
$faqBand = $faqSplit['faq'];
|
||||||
|
$mentionedProducts = $this->collectMentionedProducts($detailBody);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'title' => (string)($news['title'] ?? ''),
|
'title' => (string)($news['title'] ?? ''),
|
||||||
@@ -338,7 +386,17 @@ final class NewsJsonRenderer
|
|||||||
'detailUrl' => $urls['detailUrl'],
|
'detailUrl' => $urls['detailUrl'],
|
||||||
'canonicalUrl' => $urls['canonicalUrl'],
|
'canonicalUrl' => $urls['canonicalUrl'],
|
||||||
'teaser' => (string)($news['teaser'] ?? ''),
|
'teaser' => (string)($news['teaser'] ?? ''),
|
||||||
'bodytext' => RteResolver::html($news['bodytext'] ?? ''),
|
// List payloads do not carry the full article body: with ~600
|
||||||
|
// records that inflated /_json/insights/news to 2.6 MB (and the
|
||||||
|
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
|
||||||
|
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
|
||||||
|
// card goes blank. The detail mode is unchanged.
|
||||||
|
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
|
||||||
|
// paragraph) are split out of the body - they reach Google via
|
||||||
|
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
|
||||||
|
'bodytext' => $mode === 'list'
|
||||||
|
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
|
||||||
|
: (string)$detailBody,
|
||||||
'datetime' => (int)($news['datetime'] ?? 0),
|
'datetime' => (int)($news['datetime'] ?? 0),
|
||||||
'archive' => (int)($news['archive'] ?? 0),
|
'archive' => (int)($news['archive'] ?? 0),
|
||||||
'istopnews' => (bool)($news['istopnews'] ?? false),
|
'istopnews' => (bool)($news['istopnews'] ?? false),
|
||||||
@@ -350,9 +408,375 @@ final class NewsJsonRenderer
|
|||||||
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
|
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
|
||||||
'related' => $this->resolveRelatedNews($uid),
|
'related' => $this->resolveRelatedNews($uid),
|
||||||
'relatedLinks' => $this->resolveRelatedLinks($uid),
|
'relatedLinks' => $this->resolveRelatedLinks($uid),
|
||||||
|
'faq' => $faqBand,
|
||||||
|
'mentionedProducts' => $mentionedProducts,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
|
||||||
|
* page's cards plus pagination meta, filter chips with full-list counts
|
||||||
|
* and the active category. Request: ?page=<n>&category=<slug> on the page
|
||||||
|
* URL; page is clamped into [1..pages] (page > pages serves the last
|
||||||
|
* page), an unknown category slug falls back to "all".
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $contentElement
|
||||||
|
* @param array<string,mixed> $settings
|
||||||
|
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
|
||||||
|
*/
|
||||||
|
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
|
||||||
|
{
|
||||||
|
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
||||||
|
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
|
||||||
|
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
|
||||||
|
|
||||||
|
// USER_INT renders on every request; the 12-card page payload (image
|
||||||
|
// processing included) took 1-6 s on cold SSR misses (Seobility: 50
|
||||||
|
// slow URLs, all ?page=N). Keep each page/category variant for 15 min
|
||||||
|
// in the hash cache - cache:flush clears it, news edits are rare.
|
||||||
|
$cache = null;
|
||||||
|
$cacheId = '';
|
||||||
|
try {
|
||||||
|
$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('hash');
|
||||||
|
$cacheId = 'vitec-newslist-' . (int)($contentElement['uid'] ?? 0)
|
||||||
|
. '-' . (int)($contentElement['sys_language_uid'] ?? 0)
|
||||||
|
. '-' . $requestedPage . '-' . md5($requestedCategory);
|
||||||
|
$cached = $cache->get($cacheId);
|
||||||
|
if (is_string($cached) && $cached !== '') {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All categories of all entries in ONE query - feeds both the filter
|
||||||
|
// chips (counts over the FULL list, independent of the active filter)
|
||||||
|
// and the per-card category labels.
|
||||||
|
$categoriesByNews = $this->categoriesForNewsUids(
|
||||||
|
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
|
||||||
|
);
|
||||||
|
|
||||||
|
$categoryIndex = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
||||||
|
$slug = $this->slugify((string)$category['title']);
|
||||||
|
if ($slug === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($categoryIndex[$slug])) {
|
||||||
|
$categoryIndex[$slug] = [
|
||||||
|
'uid' => (int)$category['uid'],
|
||||||
|
'title' => (string)$category['title'],
|
||||||
|
'slug' => $slug,
|
||||||
|
'count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$categoryIndex[$slug]['count']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($categoryIndex);
|
||||||
|
|
||||||
|
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
|
||||||
|
|
||||||
|
if ($activeCategory !== '') {
|
||||||
|
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
|
||||||
|
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
||||||
|
if ($this->slugify((string)$category['title']) === $activeCategory) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first, server-side (the flexform historically says
|
||||||
|
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
|
||||||
|
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
|
||||||
|
|
||||||
|
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
|
||||||
|
if ($itemsPerPage <= 0) {
|
||||||
|
$itemsPerPage = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($rows);
|
||||||
|
$pages = max(1, (int)ceil($total / $itemsPerPage));
|
||||||
|
$page = min($requestedPage, $pages);
|
||||||
|
|
||||||
|
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
|
||||||
|
$pageRows
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'mode' => 'list',
|
||||||
|
'items' => $items,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'pages' => $pages,
|
||||||
|
'itemsPerPage' => $itemsPerPage,
|
||||||
|
'total' => $total,
|
||||||
|
],
|
||||||
|
'categories' => array_values($categoryIndex),
|
||||||
|
'activeCategory' => $activeCategory,
|
||||||
|
'settings' => $this->buildSettingsPayload($contentElement, $settings),
|
||||||
|
];
|
||||||
|
|
||||||
|
$json = json_encode($response) ?: '';
|
||||||
|
if ($cache !== null && $json !== '') {
|
||||||
|
try {
|
||||||
|
$cache->set($cacheId, $json, ['tx_news'], 900);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// caching is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lean card shape for paginated pages - only what the cards render
|
||||||
|
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
|
||||||
|
* deliberately absent here.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $news
|
||||||
|
* @param array<string,mixed> $settings
|
||||||
|
* @param array<int,array<string,mixed>> $categories
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function serializeListCard(array $news, array $settings, array $categories): array
|
||||||
|
{
|
||||||
|
$uid = (int)($news['uid'] ?? 0);
|
||||||
|
$urls = $this->buildNewsUrls($news, $settings);
|
||||||
|
$teaser = trim((string)($news['teaser'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'title' => (string)($news['title'] ?? ''),
|
||||||
|
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
|
||||||
|
'type' => (string)($news['type'] ?? '0'),
|
||||||
|
'detailUrl' => $urls['detailUrl'],
|
||||||
|
'datetime' => (int)($news['datetime'] ?? 0),
|
||||||
|
'istopnews' => (bool)($news['istopnews'] ?? false),
|
||||||
|
'teaser' => $teaser,
|
||||||
|
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
|
||||||
|
'categories' => array_map(
|
||||||
|
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
|
||||||
|
$categories
|
||||||
|
),
|
||||||
|
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uid => categories map for a set of news records, resolved in one query
|
||||||
|
* (the per-record resolveCategories() would fire ~600 queries here).
|
||||||
|
*
|
||||||
|
* @param list<int> $newsUids
|
||||||
|
* @return array<int,array<int,array{uid:int,title:string}>>
|
||||||
|
*/
|
||||||
|
private function categoriesForNewsUids(array $newsUids): array
|
||||||
|
{
|
||||||
|
$newsUids = array_values(array_filter($newsUids));
|
||||||
|
if ($newsUids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('sys_category');
|
||||||
|
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('c.uid', 'c.title', 'mm.uid_foreign')
|
||||||
|
->from('sys_category', 'c')
|
||||||
|
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
|
||||||
|
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
||||||
|
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
|
||||||
|
$queryBuilder->expr()->eq('c.deleted', 0),
|
||||||
|
$queryBuilder->expr()->eq('c.hidden', 0)
|
||||||
|
)
|
||||||
|
->orderBy('c.title', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int)$row['uid_foreign']][] = [
|
||||||
|
'uid' => (int)$row['uid'],
|
||||||
|
'title' => (string)$row['title'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category title -> filter slug ("Press Release" -> "press-release"),
|
||||||
|
* mirroring the slugs the page URLs already carry.
|
||||||
|
*/
|
||||||
|
private function slugify(string $value): string
|
||||||
|
{
|
||||||
|
$value = mb_strtolower(trim($value));
|
||||||
|
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
|
||||||
|
return trim($value, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
|
||||||
|
*/
|
||||||
|
private function listExcerpt(string $html): string
|
||||||
|
{
|
||||||
|
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
|
||||||
|
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
|
||||||
|
if (mb_strlen($text) <= 300) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($text, 0, 300);
|
||||||
|
$lastSpace = mb_strrpos($cut, ' ');
|
||||||
|
if ($lastSpace !== false && $lastSpace > 180) {
|
||||||
|
$cut = mb_substr($cut, 0, $lastSpace);
|
||||||
|
}
|
||||||
|
return rtrim($cut, " .,;:") . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits the trailing FAQ section out of an article body.
|
||||||
|
*
|
||||||
|
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
|
||||||
|
* with "Frequently asked" or "FAQ", followed by alternating question
|
||||||
|
* paragraphs (plain text ending in "?") and answer paragraphs (may
|
||||||
|
* contain links). Fail-safe: anything that does not yield at least two
|
||||||
|
* clean Q&A pairs leaves the HTML untouched.
|
||||||
|
*
|
||||||
|
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
|
||||||
|
*/
|
||||||
|
private function extractFaqBand(string $html): array
|
||||||
|
{
|
||||||
|
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
|
||||||
|
return ['html' => $html, 'faq' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$heading = trim((string)$headingMatch[2][0]);
|
||||||
|
$sectionStart = (int)$headingMatch[0][1];
|
||||||
|
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
|
||||||
|
$sectionEnd = $cursor;
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$question = null;
|
||||||
|
$answerParts = [];
|
||||||
|
|
||||||
|
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
|
||||||
|
$text = trim(strip_tags($paragraph[1]));
|
||||||
|
if ($text === '') {
|
||||||
|
$cursor += strlen($paragraph[0]);
|
||||||
|
$sectionEnd = $cursor;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_ends_with($text, '?')) {
|
||||||
|
if ($question !== null && $answerParts !== []) {
|
||||||
|
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
|
||||||
|
}
|
||||||
|
$question = $text;
|
||||||
|
$answerParts = [];
|
||||||
|
} elseif ($question !== null) {
|
||||||
|
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$cursor += strlen($paragraph[0]);
|
||||||
|
$sectionEnd = $cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($question !== null && $answerParts !== []) {
|
||||||
|
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($items) < 2) {
|
||||||
|
return ['html' => $html, 'faq' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'html' => trim($clean),
|
||||||
|
'faq' => ['heading' => $heading, 'items' => $items],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for every product detail page (/product/<slug>) and product
|
||||||
|
* category page (/products/<slug>) linked inside an article body -
|
||||||
|
* order of first appearance, deduplicated, at most six.
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function collectMentionedProducts(string $html): array
|
||||||
|
{
|
||||||
|
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cards = [];
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$href = (string)$match[1];
|
||||||
|
if (isset($cards[$href]) || count($cards) >= 6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!empty($match[2])) {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
||||||
|
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
|
||||||
|
->from('tx_vitec_domain_model_product')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('hidden', 0),
|
||||||
|
$qb->expr()->eq('hideonwebsite', 0),
|
||||||
|
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
|
||||||
|
)
|
||||||
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cards[$href] = [
|
||||||
|
'type' => 'product',
|
||||||
|
'title' => (string)($row['title'] ?? ''),
|
||||||
|
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
|
||||||
|
'link' => $href,
|
||||||
|
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('pages');
|
||||||
|
$row = $qb->select('uid', 'title', 'description', 'abstract')
|
||||||
|
->from('pages')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('hidden', 0),
|
||||||
|
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
|
||||||
|
)
|
||||||
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cards[$href] = [
|
||||||
|
'type' => 'category',
|
||||||
|
'title' => (string)($row['title'] ?? ''),
|
||||||
|
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
|
||||||
|
'link' => $href,
|
||||||
|
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($cards);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a news item actually points.
|
* Where a news item actually points.
|
||||||
*
|
*
|
||||||
@@ -673,6 +1097,9 @@ final class NewsJsonRenderer
|
|||||||
$files[] = [
|
$files[] = [
|
||||||
'uid' => (int)$row['uid'],
|
'uid' => (int)$row['uid'],
|
||||||
'url' => $imageService->getImageUri($processed),
|
'url' => $imageService->getImageUri($processed),
|
||||||
|
// Editor-maintained alt text; the frontend falls back to
|
||||||
|
// the article title when this is empty (0070-FE, B5).
|
||||||
|
'alternative' => (string)($fileReference->getAlternative() ?? ''),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\UserFunc;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\PageCheckService;
|
||||||
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||||
|
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON for the content element `vitec_pagecheck` (pages /markets/market-check
|
||||||
|
* and /solutions/solution-check): the page content audit of one scope,
|
||||||
|
* rendered by the React front end like the product check.
|
||||||
|
*
|
||||||
|
* Emitted under content.pagecheck:
|
||||||
|
*
|
||||||
|
* without query {mode: "overview", scope, scopeLabel, generatedAt, fields[], summary, pages[]}
|
||||||
|
* ?check=<pageUid> {mode: "detail", page: {…} | null}
|
||||||
|
* on failure {mode: "error", error}
|
||||||
|
*
|
||||||
|
* The scope (markets | solutions) comes from the element's FlexForm
|
||||||
|
* (settings.scope). Always USER_INT - the audit shows the database as it is
|
||||||
|
* right now; `check` is excluded from the cHash (ext_localconf.php, shared
|
||||||
|
* with the product check). The front end loads the detail variant
|
||||||
|
* client-side from /_json, one request per opened row.
|
||||||
|
*/
|
||||||
|
final class PageCheckJsonRenderer
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
$scope = 'markets';
|
||||||
|
$flex = (string)($this->cObj?->data['pi_flexform'] ?? '');
|
||||||
|
if (trim($flex) !== '') {
|
||||||
|
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($flex)['settings'] ?? [];
|
||||||
|
if (isset(PageCheckService::SCOPES[(string)($settings['scope'] ?? '')])) {
|
||||||
|
$scope = (string)$settings['scope'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$service = GeneralUtility::makeInstance(PageCheckService::class);
|
||||||
|
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
||||||
|
$check = (int)($queryParams['check'] ?? 0);
|
||||||
|
if ($check > 0) {
|
||||||
|
$payload = ['mode' => 'detail', 'scope' => $scope, 'page' => $service->detail($check)];
|
||||||
|
} else {
|
||||||
|
$payload = ['mode' => 'overview'] + $service->overview($scope);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,17 @@ final class PageJsonLdRenderer
|
|||||||
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
|
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
|
||||||
}
|
}
|
||||||
|
|
||||||
// BreadcrumbList from the rootline.
|
// BreadcrumbList from the rootline. On record detail pages the last
|
||||||
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base);
|
// crumb is the generic shell page ("Story", "Product", "Article")
|
||||||
|
// and is swapped for the record itself (0108-FE, F11).
|
||||||
|
$args = $this->routeArguments($request);
|
||||||
|
$crumbs = $this->breadcrumbItems($rootLine);
|
||||||
|
$recordCrumb = $this->recordCrumb($args);
|
||||||
|
if ($recordCrumb !== null && $crumbs !== []) {
|
||||||
|
array_pop($crumbs);
|
||||||
|
$crumbs[] = $recordCrumb;
|
||||||
|
}
|
||||||
|
$breadcrumb = $service->buildBreadcrumbList($crumbs, $base);
|
||||||
if ($breadcrumb !== null) {
|
if ($breadcrumb !== null) {
|
||||||
$nodes[] = $breadcrumb;
|
$nodes[] = $breadcrumb;
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,59 @@ final class PageJsonLdRenderer
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Product on product detail pages (routed record, SEO audit 2026-11-09).
|
||||||
|
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
|
||||||
|
if ($productArg !== '') {
|
||||||
|
$productRow = $this->fetchDetailRecord(
|
||||||
|
'tx_vitec_domain_model_product',
|
||||||
|
is_numeric($productArg) ? 'uid' : 'slug',
|
||||||
|
$productArg
|
||||||
|
);
|
||||||
|
if ($productRow !== null) {
|
||||||
|
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'productimage', (int)$productRow['uid'], $base, $service);
|
||||||
|
if ($productImages === []) {
|
||||||
|
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'ogimage', (int)$productRow['uid'], $base, $service);
|
||||||
|
}
|
||||||
|
$nodes[] = $service->buildProduct([
|
||||||
|
'uid' => (int)$productRow['uid'],
|
||||||
|
'title' => (string)($productRow['title'] ?? ''),
|
||||||
|
'teaser' => (string)($productRow['teaser'] ?? ''),
|
||||||
|
'description' => (string)($productRow['description'] ?? ''),
|
||||||
|
'slug' => (string)($productRow['slug'] ?? ''),
|
||||||
|
], $base, $productImages, $hasOrganization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsArticle on EXT:news detail pages (the shell page carries no record data).
|
||||||
|
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
|
||||||
|
if ($newsUid > 0) {
|
||||||
|
$newsRow = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
|
||||||
|
if ($newsRow !== null) {
|
||||||
|
$newsTitle = trim((string)($newsRow['alternative_title'] ?? ''));
|
||||||
|
if ($newsTitle === '') {
|
||||||
|
$newsTitle = (string)($newsRow['title'] ?? '');
|
||||||
|
}
|
||||||
|
$nodes[] = $service->buildNewsArticle([
|
||||||
|
'seo_title' => '',
|
||||||
|
'title' => $newsTitle,
|
||||||
|
'description' => trim(strip_tags((string)($newsRow['teaser'] ?? ''))),
|
||||||
|
'abstract' => '',
|
||||||
|
'crdate' => (int)($newsRow['datetime'] ?? 0),
|
||||||
|
'SYS_LASTCHANGED' => (int)($newsRow['tstamp'] ?? 0),
|
||||||
|
'slug' => '/news/article/' . (string)($newsRow['path_segment'] ?? ''),
|
||||||
|
'author' => (string)($newsRow['author'] ?? ''),
|
||||||
|
'author_email' => (string)($newsRow['author_email'] ?? ''),
|
||||||
|
], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization);
|
||||||
|
|
||||||
|
// Editor-pasted schema blocks (e.g. FAQPage JSON in a
|
||||||
|
// trailing bodytext paragraph) join the @graph here;
|
||||||
|
// NewsJsonRenderer strips them out of the rendered body.
|
||||||
|
foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) {
|
||||||
|
$nodes[] = $embeddedNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $service->encodeGraph($nodes);
|
return $service->encodeGraph($nodes);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return '';
|
return '';
|
||||||
@@ -166,6 +228,40 @@ final class PageJsonLdRenderer
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadcrumb item for the routed detail record (product, news, story), or
|
||||||
|
* null on plain pages.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $args
|
||||||
|
* @return array{name:string, url:string}|null
|
||||||
|
*/
|
||||||
|
private function recordCrumb(array $args): ?array
|
||||||
|
{
|
||||||
|
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
|
||||||
|
if ($productArg !== '') {
|
||||||
|
$row = $this->fetchDetailRecord('tx_vitec_domain_model_product', is_numeric($productArg) ? 'uid' : 'slug', $productArg);
|
||||||
|
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
|
||||||
|
return ['name' => (string)($row['title'] ?? ''), 'url' => '/product/' . $row['slug']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
|
||||||
|
if ($newsUid > 0) {
|
||||||
|
$row = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
|
||||||
|
if ($row !== null && (string)($row['path_segment'] ?? '') !== '') {
|
||||||
|
$name = trim((string)($row['alternative_title'] ?? '')) ?: (string)($row['title'] ?? '');
|
||||||
|
return ['name' => $name, 'url' => '/news/article/' . $row['path_segment']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
|
||||||
|
if ($usecaseUid > 0) {
|
||||||
|
$row = $this->fetchDetailRecord('tx_vitec_domain_model_usecase', 'uid', (string)$usecaseUid);
|
||||||
|
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
|
||||||
|
return ['name' => (string)($row['title'] ?? ''), 'url' => '/success-stories/' . $row['slug']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build ordered breadcrumb items (root → current) from the rootline.
|
* Build ordered breadcrumb items (root → current) from the rootline.
|
||||||
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
|
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
|
||||||
@@ -410,4 +506,77 @@ final class PageJsonLdRenderer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route arguments of the current request (record detail context).
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function routeArguments($request): array
|
||||||
|
{
|
||||||
|
$routing = $request->getAttribute('routing');
|
||||||
|
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
|
||||||
|
return array_replace_recursive($request->getQueryParams(), $routing->getArguments());
|
||||||
|
}
|
||||||
|
return $request->getQueryParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One visible record by uid or slug. Null when missing.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function fetchDetailRecord(string $table, string $field, string $value): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||||
|
$row = $qb->select('*')->from($table)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('hidden', 0),
|
||||||
|
$field === 'uid'
|
||||||
|
? $qb->expr()->eq('uid', $qb->createNamedParameter((int)$value, ParameterType::INTEGER))
|
||||||
|
: $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::STRING))
|
||||||
|
)
|
||||||
|
->setMaxResults(1)->executeQuery()->fetchAssociative();
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute URLs of a record's FAL images (unprocessed originals).
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function recordImageUrls(string $table, string $field, int $uid, string $base, StructuredDataService $service): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
|
||||||
|
$rows = $qb->select('f.identifier')
|
||||||
|
->from('sys_file_reference', 'fr')
|
||||||
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('fr.deleted', 0),
|
||||||
|
$qb->expr()->eq('fr.hidden', 0),
|
||||||
|
$qb->expr()->eq('f.missing', 0)
|
||||||
|
)
|
||||||
|
->orderBy('fr.sorting_foreign', 'ASC')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$urls = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$identifier = (string)($row['identifier'] ?? '');
|
||||||
|
if ($identifier !== '') {
|
||||||
|
$urls[] = $service->absUrl('/fileadmin' . $identifier, $base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $urls;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
*
|
*
|
||||||
* Extends ProductListJsonRenderer purely to reuse its serializeProduct()
|
* Extends ProductListJsonRenderer purely to reuse its serializeProduct()
|
||||||
* and image handling - same filters, same shape, one source of truth.
|
* and image handling - same filters, same shape, one source of truth.
|
||||||
|
*
|
||||||
|
* "No product" returns an EMPTY STRING, never json_encode(null): headless
|
||||||
|
* only decodes strings that look like objects/arrays, so the text "null"
|
||||||
|
* went out as the string "null" and crashed the front end (ticket
|
||||||
|
* 0068-FE). The TypoScript sets ifEmptyReturnNull = 1 on this field,
|
||||||
|
* which turns the empty string into a real JSON null.
|
||||||
*/
|
*/
|
||||||
final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
|
final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
|
||||||
{
|
{
|
||||||
@@ -33,7 +39,7 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
|
|||||||
->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
|
->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
|
||||||
$productUid = (int)($flexFormData['settings']['product'] ?? 0);
|
$productUid = (int)($flexFormData['settings']['product'] ?? 0);
|
||||||
if ($productUid <= 0) {
|
if ($productUid <= 0) {
|
||||||
return (string)json_encode(null);
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
@@ -47,12 +53,12 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
|
|||||||
)
|
)
|
||||||
->executeQuery()->fetchAssociative();
|
->executeQuery()->fetchAssociative();
|
||||||
if (!$product) {
|
if (!$product) {
|
||||||
return (string)json_encode(null);
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return (string)json_encode(null);
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,9 +165,24 @@ class UsecaseListJsonRenderer
|
|||||||
$rows = $ordered;
|
$rows = $ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Testimonial bands on sub-market/solution pages shipped ALL 70
|
||||||
|
// stories (226 kB per page, the same quotes on ~90 pages - Seobility
|
||||||
|
// 2026-11-10: 34 pages > 500 kB, 1,846 repeated text blocks). Only
|
||||||
|
// stories with a maintained quote are testimonials; the current
|
||||||
|
// market's stories come first, capped at twelve. Editor-picked
|
||||||
|
// lists stay untouched.
|
||||||
|
if ($layout === 'testimonials' && $selectedUids === []) {
|
||||||
|
$rows = $this->testimonialRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
$usecases = array_map(
|
$usecases = array_map(
|
||||||
static function (array $u) use ($serializer, $detailBase): array {
|
static function (array $u) use ($serializer, $detailBase): array {
|
||||||
$item = $serializer->serializeListItem($u);
|
$item = $serializer->serializeListItem($u);
|
||||||
|
// The list cards read url/srcset/alternative only; the card
|
||||||
|
// crop variants cost about 1 kB per story (0102-FE, F8).
|
||||||
|
if (is_array($item['cardImage'] ?? null)) {
|
||||||
|
unset($item['cardImage']['crops']);
|
||||||
|
}
|
||||||
// A story with its own detail page wins; only the others
|
// A story with its own detail page wins; only the others
|
||||||
// fall back to the Single PID plus the slug, so nothing
|
// fall back to the Single PID plus the slug, so nothing
|
||||||
// changes for the stories that have no page assigned.
|
// changes for the stories that have no page assigned.
|
||||||
@@ -210,6 +225,80 @@ class UsecaseListJsonRenderer
|
|||||||
* Callers here build paths by concatenation, so the empty string stays the
|
* Callers here build paths by concatenation, so the empty string stays the
|
||||||
* "no link" value; LinkResolver's null is coalesced away.
|
* "no link" value; LinkResolver's null is coalesced away.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Stories for a testimonial band: quoted ones only, current market first,
|
||||||
|
* at most twelve. Falls back to the first twelve rows when no story has
|
||||||
|
* a quote yet.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $rows
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function testimonialRows(array $rows): array
|
||||||
|
{
|
||||||
|
$quoted = array_values(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $r): bool => trim((string)($r['quote'] ?? '')) !== ''
|
||||||
|
));
|
||||||
|
if ($quoted === []) {
|
||||||
|
return array_slice($rows, 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$marketUids = $this->currentMarketUids();
|
||||||
|
if ($marketUids !== []) {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_usecase_market_mm');
|
||||||
|
$inMarket = array_map('intval', $qb
|
||||||
|
->select('uid_local')
|
||||||
|
->from('tx_vitec_usecase_market_mm')
|
||||||
|
->where($qb->expr()->in('uid_foreign', $qb->createNamedParameter($marketUids, \Doctrine\DBAL\ArrayParameterType::INTEGER)))
|
||||||
|
->executeQuery()
|
||||||
|
->fetchFirstColumn());
|
||||||
|
$matching = array_values(array_filter($quoted, static fn (array $r): bool => in_array((int)$r['uid'], $inMarket, true)));
|
||||||
|
if (count($matching) >= 3) {
|
||||||
|
$others = array_values(array_filter($quoted, static fn (array $r): bool => !in_array((int)$r['uid'], $inMarket, true)));
|
||||||
|
$quoted = array_merge($matching, $others);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// keep the quoted stories in default order
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($quoted, 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Market records whose detail page lies on the current rootline.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function currentMarketUids(): array
|
||||||
|
{
|
||||||
|
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
||||||
|
$pageInfo = $request?->getAttribute('frontend.page.information');
|
||||||
|
if ($pageInfo === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$pageUids = array_values(array_filter(array_map(
|
||||||
|
static fn (array $p): int => (int)($p['uid'] ?? 0),
|
||||||
|
$pageInfo->getRootLine()
|
||||||
|
)));
|
||||||
|
if ($pageUids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_domain_model_market');
|
||||||
|
return array_map('intval', $qb
|
||||||
|
->select('uid')
|
||||||
|
->from('tx_vitec_domain_model_market')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->in('detail_page', $qb->createNamedParameter($pageUids, \Doctrine\DBAL\ArrayParameterType::INTEGER))
|
||||||
|
)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchFirstColumn());
|
||||||
|
}
|
||||||
|
|
||||||
private function resolvePageUrl(int $pageUid): string
|
private function resolvePageUrl(int $pageUid): string
|
||||||
{
|
{
|
||||||
return LinkResolver::pageUrl($pageUid) ?? '';
|
return LinkResolver::pageUrl($pageUid) ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Widgets;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared markup for the three SSR dashboard tiles, in the look of the
|
||||||
|
* "VITEC SSR" cockpit module (dark VITEC navy, orange accents).
|
||||||
|
* The dashboard inserts widget HTML without running scripts, so rings
|
||||||
|
* and bars are drawn here as plain SVG/HTML. Styles:
|
||||||
|
* Resources/Public/Css/dashboard-ssr.css (scoped to .vc-widget).
|
||||||
|
*/
|
||||||
|
final class CockpitHtml
|
||||||
|
{
|
||||||
|
public const CSS = 'EXT:vitec/Resources/Public/Css/dashboard-ssr.css';
|
||||||
|
|
||||||
|
public static function e(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tile wrapper; $info becomes the round "?" button top right whose
|
||||||
|
* tooltip explains the tile (CSS only: hover or keyboard focus).
|
||||||
|
*/
|
||||||
|
public static function wrap(string $inner, string $info = '', string $id = ''): string
|
||||||
|
{
|
||||||
|
$button = '';
|
||||||
|
if ($info !== '') {
|
||||||
|
$tipId = 'vcw-tip-' . ($id !== '' ? $id : substr(md5($info), 0, 8));
|
||||||
|
$button = '<span class="vcw-info"><button type="button" class="vcw-info-btn" aria-label="About this tile"'
|
||||||
|
. ' aria-describedby="' . $tipId . '">?</button><span class="vcw-tip" role="tooltip" id="' . $tipId . '">'
|
||||||
|
. self::e($info) . '</span></span>';
|
||||||
|
}
|
||||||
|
return '<div class="vc-widget">' . $button . $inner . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pill(string $tone, string $text, string $title = ''): string
|
||||||
|
{
|
||||||
|
return '<span class="vcw-pill is-' . self::e($tone) . '"'
|
||||||
|
. ($title !== '' ? ' title="' . self::e($title) . '"' : '') . '>' . self::e($text) . '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring gauge, 0..1, with a short text in the middle.
|
||||||
|
*/
|
||||||
|
public static function ring(float $fraction, string $tone, string $center, int $size = 76): string
|
||||||
|
{
|
||||||
|
$c = 2 * M_PI * 40;
|
||||||
|
$offset = $c * (1 - max(0.0, min(1.0, $fraction)));
|
||||||
|
return '<div class="vcw-ring" style="width:' . $size . 'px;height:' . $size . 'px">'
|
||||||
|
. '<svg viewBox="0 0 96 96" width="' . $size . '" height="' . $size . '" aria-hidden="true">'
|
||||||
|
. '<circle class="vcw-track" cx="48" cy="48" r="40"/>'
|
||||||
|
. '<circle class="vcw-val is-' . self::e($tone) . '" cx="48" cy="48" r="40" stroke-dasharray="'
|
||||||
|
. round($c, 2) . '" stroke-dashoffset="' . round($offset, 2) . '"/></svg>'
|
||||||
|
. '<span class="vcw-ring-c">' . self::e($center) . '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same labels and tones as the purge timeline in the module.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string, 2: string} [label, tone, user]
|
||||||
|
*/
|
||||||
|
public static function trigger(string $source): array
|
||||||
|
{
|
||||||
|
$who = '';
|
||||||
|
if (str_contains($source, ':')) {
|
||||||
|
[$source, $who] = explode(':', $source, 2);
|
||||||
|
}
|
||||||
|
return match ($source) {
|
||||||
|
'module' => ['manual', 'orange', $who],
|
||||||
|
'contextmenu' => ['context menu', 'navy', $who],
|
||||||
|
'editor-save', 'editor-command' => ['auto-purge', 'ok', $who],
|
||||||
|
'switch-on' => ['switch-on', 'warn', $who],
|
||||||
|
default => [$source, 'sky', $who],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Availability strip of the last 24 hours, 96 windows of 15 minutes,
|
||||||
|
* from the health log (cron vitec:ssr-health every 5 minutes).
|
||||||
|
*
|
||||||
|
* @return array{html: string, uptime: ?float, slow: int, down: int, samples: int}
|
||||||
|
*/
|
||||||
|
public static function strip(ConnectionPool $pool): array
|
||||||
|
{
|
||||||
|
$slot = 900;
|
||||||
|
$end = intdiv(time(), $slot) * $slot + $slot;
|
||||||
|
$windows = array_fill(0, 96, null);
|
||||||
|
$ok = 0;
|
||||||
|
$n = 0;
|
||||||
|
try {
|
||||||
|
$rows = $pool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
|
||||||
|
'SELECT crdate, ok, response_ms FROM tx_vitec_ssrhealthlog WHERE crdate > ?',
|
||||||
|
[$end - 96 * $slot]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$i = 95 - intdiv($end - 1 - (int)$r['crdate'], $slot);
|
||||||
|
if ($i < 0 || $i > 95) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$n++;
|
||||||
|
$isOk = (int)$r['ok'] === 1;
|
||||||
|
$ok += $isOk ? 1 : 0;
|
||||||
|
$state = !$isOk ? 2 : ((int)$r['response_ms'] > 400 ? 1 : 0);
|
||||||
|
if ($windows[$i] === null || $state > $windows[$i]['s']) {
|
||||||
|
$windows[$i] = ['s' => $state, 'ms' => (int)$r['response_ms']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$slow = 0;
|
||||||
|
$down = 0;
|
||||||
|
$html = '<div class="vcw-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours">';
|
||||||
|
foreach ($windows as $i => $w) {
|
||||||
|
$from = $end - (96 - $i) * $slot;
|
||||||
|
$label = date('H:i', $from) . '–' . date('H:i', $from + $slot) . ' · ';
|
||||||
|
if ($w === null) {
|
||||||
|
$html .= '<i class="is-none" title="' . self::e($label . 'no sample') . '"></i>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cls = ['', 'is-slow', 'is-down'][$w['s']];
|
||||||
|
$slow += $w['s'] === 1 ? 1 : 0;
|
||||||
|
$down += $w['s'] === 2 ? 1 : 0;
|
||||||
|
$text = $w['s'] === 2 ? 'down' : ($w['s'] === 1 ? 'slow, ' : 'ok, ') . ($w['s'] === 2 ? '' : $w['ms'] . ' ms');
|
||||||
|
$html .= '<i class="' . $cls . '" title="' . self::e($label . $text) . '"></i>';
|
||||||
|
}
|
||||||
|
$html .= '</div><div class="vcw-axis"><span>' . date('H:i', $end - 96 * $slot) . '</span><span>'
|
||||||
|
. date('H:i', $end - 48 * $slot) . '</span><span>now</span></div>';
|
||||||
|
return [
|
||||||
|
'html' => $html,
|
||||||
|
'uptime' => $n > 0 ? round($ok / $n * 100, 1) : null,
|
||||||
|
'slow' => $slow,
|
||||||
|
'down' => $down,
|
||||||
|
'samples' => $n,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function moduleLink(string $label = 'Open the SSR cockpit'): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$uri = (string)GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute('web_vitecssr');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '<a class="vcw-link" href="' . self::e($uri) . '">' . self::e($label) . ' →</a>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Widgets;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard tile: SSR error counters and cache hit rate.
|
||||||
|
*
|
||||||
|
* Source: the cumulative /__stats counters of the SSR node, sampled every
|
||||||
|
* 5 minutes by vitec:ssr-health into tx_vitec_ssrhealthlog (stat_* columns,
|
||||||
|
* -1 = no data for that sample). The counters reset whenever the node
|
||||||
|
* restarts (deploy!), so "totals" are "since node start" and the 24h error
|
||||||
|
* figures are sums of positive deltas between consecutive samples - a
|
||||||
|
* reset simply contributes nothing.
|
||||||
|
*/
|
||||||
|
final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WidgetConfigurationInterface $configuration,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function renderWidgetContent(): string
|
||||||
|
{
|
||||||
|
$h = CockpitHtml::class;
|
||||||
|
$latest = $this->latestStatsRow();
|
||||||
|
if ($latest === null) {
|
||||||
|
return $h::wrap('<p class="vcw-dim">No /__stats samples yet. They arrive with the next '
|
||||||
|
. '<code>vitec:ssr-health</code> cron run (every 5 minutes) once the node release '
|
||||||
|
. 'provides <code>/__stats</code>.</p>', 'Cache hit rate and error counters of the SSR node, '
|
||||||
|
. 'filled by the health cron every 5 minutes.', 'errors');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hit = (int)$latest['stat_hit'];
|
||||||
|
$stale = (int)$latest['stat_stale'];
|
||||||
|
$miss = (int)$latest['stat_miss'];
|
||||||
|
$served = $hit + $miss + $stale;
|
||||||
|
$rate = $served > 0 ? ($hit + $stale) / $served : null;
|
||||||
|
|
||||||
|
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
|
||||||
|
$down24h = $this->downSamples24h();
|
||||||
|
|
||||||
|
// Hit rate ring + split bar hit / stale / miss
|
||||||
|
$pct = static fn(int $v): string => $served > 0 ? round($v / $served * 100, 1) . '%' : '0%';
|
||||||
|
$html = '<div class="vcw-gauge vcw-gauge-wide">'
|
||||||
|
. $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84)
|
||||||
|
. '<div class="vcw-grow"><div class="vcw-label">Cache hit rate</div>'
|
||||||
|
. '<div class="vcw-sub">since the node started · ' . number_format($served, 0, '.', ',') . ' requests</div>'
|
||||||
|
. '<div class="vcw-split-bar" role="img" aria-label="Share of requests by cache result">'
|
||||||
|
. '<i class="is-navy" style="width:' . $pct($hit) . '" title="served from cache: ' . $hit . '"></i>'
|
||||||
|
. '<i class="is-sky" style="width:' . $pct($stale) . '" title="stale, refreshed in background: ' . $stale . '"></i>'
|
||||||
|
. '<i class="is-warn" style="width:' . $pct($miss) . '" title="rendered fresh: ' . $miss . '"></i></div>'
|
||||||
|
. '<div class="vcw-legend"><span><i class="is-navy"></i>cache ' . $hit . '</span>'
|
||||||
|
. '<span><i class="is-sky"></i>stale ' . $stale . '</span><span><i class="is-warn"></i>fresh ' . $miss . '</span></div>'
|
||||||
|
. '</div></div>';
|
||||||
|
|
||||||
|
$tile = static fn(string $label, string $value, string $sub, string $tone): string =>
|
||||||
|
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
|
||||||
|
. $value . '</div><div class="vcw-sub">' . $sub . '</div></div>';
|
||||||
|
$newCell = static fn(?int $new): array => $new === null
|
||||||
|
? ['n/a', '']
|
||||||
|
: ['+' . $new, $new > 0 ? 'vcw-bad' : 'vcw-ok'];
|
||||||
|
[$renderVal, $renderTone] = $newCell($newRender24h);
|
||||||
|
[$backendVal, $backendTone] = $newCell($newBackend24h);
|
||||||
|
|
||||||
|
$html .= '<div class="vcw-tiles">'
|
||||||
|
. $tile('Render errors', $renderVal, 'last 24 h · ' . (int)$latest['stat_render_errors'] . ' since node start', $renderTone)
|
||||||
|
. $tile('Backend errors', $backendVal, 'last 24 h · ' . (int)$latest['stat_backend_errors'] . ' since node start', $backendTone)
|
||||||
|
. $tile('Down samples', (string)$down24h['count'], $down24h['count'] > 0 ? 'last ' . $h::e($down24h['last']) : 'last 24 h', $down24h['count'] > 0 ? 'vcw-bad' : 'vcw-ok')
|
||||||
|
. $tile('Sample age', $h::e($this->age((int)$latest['crdate'])), 'cron every 5 min', '')
|
||||||
|
. '</div>';
|
||||||
|
$html .= '<p class="vcw-note">Totals count since the node last started; a deploy resets them. '
|
||||||
|
. 'Error details: <code>journalctl -u vitec-ssr</code> on the VPS.</p>';
|
||||||
|
return $h::wrap($html, 'How well the SSR cache works and whether the node has problems. The ring is the '
|
||||||
|
. 'share of requests answered from the cache since the node last started; the bar splits them into '
|
||||||
|
. 'served from cache, stale and refreshed in the background, and rendered fresh. Render errors are pages '
|
||||||
|
. 'the node could not render, backend errors are failed requests to TYPO3. Down samples are failed '
|
||||||
|
. 'health checks in the last 24 hours.', 'errors');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCssFiles(): array
|
||||||
|
{
|
||||||
|
return [CockpitHtml::CSS];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestStatsRow(): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
|
||||||
|
->executeQuery(
|
||||||
|
'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors'
|
||||||
|
. ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1'
|
||||||
|
)->fetchAssociative();
|
||||||
|
return $row === false ? null : $row;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null; // Column missing before database:updateschema - render the empty state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New errors in the last 24 h = sum of positive deltas between
|
||||||
|
* consecutive stats samples. Rows with -1 (node down / no stats) are
|
||||||
|
* skipped; counter resets produce a negative step, clamped to 0.
|
||||||
|
*
|
||||||
|
* @return array{0: ?int, 1: ?int} [renderErrors, backendErrors]
|
||||||
|
*/
|
||||||
|
private function errorDeltas24h(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
|
||||||
|
->executeQuery(
|
||||||
|
'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog'
|
||||||
|
. ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC'
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
if (count($rows) < 2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
$render = 0;
|
||||||
|
$backend = 0;
|
||||||
|
$prev = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($prev !== null) {
|
||||||
|
$render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']);
|
||||||
|
$backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']);
|
||||||
|
}
|
||||||
|
$prev = $r;
|
||||||
|
}
|
||||||
|
return [$render, $backend];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{count: int, last: string}
|
||||||
|
*/
|
||||||
|
private function downSamples24h(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
|
||||||
|
$count = (int)$conn->executeQuery(
|
||||||
|
'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0'
|
||||||
|
)->fetchOne();
|
||||||
|
$last = $count > 0
|
||||||
|
? date('d.m. H:i', (int)$conn->executeQuery(
|
||||||
|
'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0'
|
||||||
|
)->fetchOne())
|
||||||
|
: '-';
|
||||||
|
return ['count' => $count, 'last' => $last];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ['count' => 0, 'last' => '-'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function age(int $crdate): string
|
||||||
|
{
|
||||||
|
$minutes = (int)floor((time() - $crdate) / 60);
|
||||||
|
return $minutes < 1 ? 'just now' : $minutes . ' min';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Widgets;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\ClientErrorClassifier;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard tile: purge and health statistics from the two protocol
|
||||||
|
* tables. History depth = what vitec:ssr-health sampling (cron, every
|
||||||
|
* 5 minutes) and the purge log hold (14 days). Cockpit look (CockpitHtml):
|
||||||
|
* availability strip of the last 24 h plus number tiles.
|
||||||
|
*/
|
||||||
|
final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WidgetConfigurationInterface $configuration,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function renderWidgetContent(): string
|
||||||
|
{
|
||||||
|
$h = CockpitHtml::class;
|
||||||
|
$now = time();
|
||||||
|
$d1 = $now - 86400;
|
||||||
|
$d7 = $now - 7 * 86400;
|
||||||
|
|
||||||
|
$purges24h = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d1);
|
||||||
|
$purges7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7);
|
||||||
|
$failed7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7 . ' AND success = 0');
|
||||||
|
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
|
||||||
|
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
|
||||||
|
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
|
||||||
|
// Browser-side errors reported via POST /api/vitec/client-error,
|
||||||
|
// without smoke/curl test reports (0114-FE).
|
||||||
|
[$clientErrors24h, $clientErrors7d] = $this->realClientErrors($d1, $d7);
|
||||||
|
|
||||||
|
$strip = $h::strip($this->connectionPool);
|
||||||
|
$uptime = $strip['uptime'];
|
||||||
|
$uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad'));
|
||||||
|
|
||||||
|
$html = '<div class="vcw-strip-h"><div><div class="vcw-label">Availability · 24 h</div>'
|
||||||
|
. '<div class="vcw-num ' . $uptimeTone . '">' . ($uptime === null ? '–' : number_format($uptime, 1)) . '<small>%</small></div></div>'
|
||||||
|
. '<div class="vcw-sub vcw-right">' . $strip['samples'] . ' samples<br>' . $strip['slow'] . ' slow · '
|
||||||
|
. $strip['down'] . ' down</div></div>'
|
||||||
|
. $strip['html'];
|
||||||
|
|
||||||
|
$tile = static fn(string $label, string $value, string $sub = '', string $tone = ''): string =>
|
||||||
|
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
|
||||||
|
. $value . '</div>' . ($sub !== '' ? '<div class="vcw-sub">' . $sub . '</div>' : '') . '</div>';
|
||||||
|
|
||||||
|
$html .= '<div class="vcw-tiles">'
|
||||||
|
. $tile('Purges', $purges24h . '<small>/ ' . $purges7d . '</small>', '24 h / 7 days')
|
||||||
|
. $tile('Full purges', (string)$full7d, 'last 7 days', $full7d > 0 ? 'vcw-warn' : '')
|
||||||
|
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
|
||||||
|
. $tile('Avg. response', $avgMs !== null ? $avgMs . '<small>ms</small>' : '–', 'last 24 h')
|
||||||
|
. $tile('Max. cache', $maxCache !== null ? $maxCache . '<small>MB</small>' : '–', 'last 7 days')
|
||||||
|
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
|
||||||
|
. '</div>';
|
||||||
|
|
||||||
|
if ($strip['samples'] === 0) {
|
||||||
|
$html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
|
||||||
|
}
|
||||||
|
$html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>';
|
||||||
|
return $h::wrap($html, 'Availability of the SSR node over the last 24 hours, one bar per 15 minutes: '
|
||||||
|
. 'green ok, yellow slower than 400 ms, red down, grey no sample. Below are counts from the purge and '
|
||||||
|
. 'health logs: purges, full purges and failed purges, average response time, the largest cache size '
|
||||||
|
. 'and the browser errors reported by the frontend.', 'stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCssFiles(): array
|
||||||
|
{
|
||||||
|
return [CockpitHtml::CSS];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int} real client error reports in 24 h / 7 days
|
||||||
|
*/
|
||||||
|
private function realClientErrors(int $d1, int $d7): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
|
||||||
|
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
|
||||||
|
[$d7]
|
||||||
|
)->fetchAllAssociative();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
$day = 0;
|
||||||
|
$week = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (ClientErrorClassifier::isTest((string)$r['message'], (string)$r['user_agent'], (string)$r['release'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$week++;
|
||||||
|
$day += (int)$r['crdate'] > $d1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
return [$day, $week];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function count(string $table, string $where): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (int)$this->connectionPool->getConnectionForTable($table)
|
||||||
|
->executeQuery('SELECT COUNT(*) FROM ' . $table . ' WHERE ' . $where)->fetchOne();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scalar(string $sql): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$v = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
|
||||||
|
->executeQuery($sql)->fetchOne();
|
||||||
|
return $v === null || $v === false ? null : (string)$v;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Widgets;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\SsrPurgeService;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
|
||||||
|
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard tile: live state of the SSR node on the Solr VPS plus the
|
||||||
|
* delivery switch - green/red at a glance after backend login. Same look
|
||||||
|
* as the "VITEC SSR" cockpit module (CockpitHtml).
|
||||||
|
*/
|
||||||
|
final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WidgetConfigurationInterface $configuration,
|
||||||
|
private readonly SsrPurgeService $purgeService,
|
||||||
|
private readonly RequestFactory $requestFactory,
|
||||||
|
private readonly ConnectionPool $connectionPool,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function renderWidgetContent(): string
|
||||||
|
{
|
||||||
|
$h = CockpitHtml::class;
|
||||||
|
$flagOn = file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag');
|
||||||
|
$health = $this->fetchHealth();
|
||||||
|
$lastPurge = $this->lastPurgeRow();
|
||||||
|
$limit = $this->purgeService->getCacheLimitMb();
|
||||||
|
|
||||||
|
// Head: pulse + state
|
||||||
|
if ($health === null) {
|
||||||
|
$head = '<span class="vcw-pulse is-down" aria-hidden="true"></span><div>'
|
||||||
|
. '<div class="vcw-title">SSR <span class="vcw-bad">not reachable</span></div>'
|
||||||
|
. '<div class="vcw-sub">The cron falls back to the SPA after '
|
||||||
|
. $this->purgeService->getFailoverAfter() . ' failed checks in a row</div></div>';
|
||||||
|
} else {
|
||||||
|
$ok = (bool)($health['ok'] ?? false);
|
||||||
|
$head = '<span class="vcw-pulse' . ($ok ? '' : ' is-warn') . '" aria-hidden="true"></span><div>'
|
||||||
|
. '<div class="vcw-title">SSR <span class="' . ($ok ? 'vcw-ok">online' : 'vcw-warn">reports errors') . '</span></div>'
|
||||||
|
. '<div class="vcw-sub">' . ($flagOn ? 'Pages are rendered on the VPS' : 'Delivery is off: TYPO3 serves the SPA')
|
||||||
|
. ' · ' . (int)$health['responseMs'] . ' ms</div></div>';
|
||||||
|
}
|
||||||
|
$html = '<div class="vcw-head">' . $head . '</div>';
|
||||||
|
|
||||||
|
// Cache ring + facts
|
||||||
|
$html .= '<div class="vcw-split">';
|
||||||
|
if ($health !== null) {
|
||||||
|
$mb = (int)($health['cacheMB'] ?? 0);
|
||||||
|
$pages = (int)($health['cachedPages'] ?? 0);
|
||||||
|
$frac = $limit > 0 ? $mb / $limit : 0.0;
|
||||||
|
$html .= '<div class="vcw-gauge">' . $h::ring($frac, 'sky', $limit > 0 ? round($frac * 100) . ' %' : $mb . ' MB')
|
||||||
|
. '<div><div class="vcw-label">Cache</div><div class="vcw-num">' . number_format($pages, 0, '.', ',')
|
||||||
|
. '<small>pages</small></div><div class="vcw-sub">' . $mb . ($limit > 0 ? ' / ' . $limit : '') . ' MB</div></div></div>';
|
||||||
|
}
|
||||||
|
$html .= '<dl class="vcw-facts">'
|
||||||
|
. '<div><dt>Delivery</dt><dd>' . ($flagOn ? $h::pill('ok', 'ON') : $h::pill('muted', 'OFF')) . '</dd></div>';
|
||||||
|
if ($health !== null) {
|
||||||
|
$html .= '<div><dt>Release</dt><dd><code title="built ' . $h::e($this->fmtDate((string)($health['release']['builtAt'] ?? '')))
|
||||||
|
. '">' . $h::e((string)($health['release']['release'] ?? '?')) . '</code></dd></div>'
|
||||||
|
. '<div><dt>Commit</dt><dd><code>' . $h::e((string)($health['release']['commit'] ?? '?')) . '</code></dd></div>';
|
||||||
|
}
|
||||||
|
$html .= '<div><dt>Webhook</dt><dd>' . ($this->purgeService->isConfigured()
|
||||||
|
? $h::pill('ok', 'configured')
|
||||||
|
: $h::pill('warn', 'not configured', 'var/vitec-ssr-purge.env is missing or incomplete')) . '</dd></div>'
|
||||||
|
. '</dl></div>';
|
||||||
|
|
||||||
|
// Last purge
|
||||||
|
if ($lastPurge !== null) {
|
||||||
|
[$label, $tone, $who] = $h::trigger((string)$lastPurge['trigger_source']);
|
||||||
|
$paths = trim((string)$lastPurge['paths']);
|
||||||
|
$html .= '<div class="vcw-last"><span class="vcw-label">Last purge</span>'
|
||||||
|
. '<span class="vcw-time">' . date('d.m. H:i', (int)$lastPurge['crdate']) . '</span>'
|
||||||
|
. $h::pill($tone, $label) . ($who !== '' ? ' <span class="vcw-dim">' . $h::e($who) . '</span>' : '')
|
||||||
|
. ' ' . ((int)$lastPurge['success'] ? $h::pill('ok', 'ok') : $h::pill('bad', 'failed'))
|
||||||
|
. '<span class="vcw-path" title="' . $h::e($paths) . '">'
|
||||||
|
. ($paths === '*' ? 'all pages (*)' : $h::e(mb_strimwidth($paths, 0, 70, '…'))) . '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
|
||||||
|
return $h::wrap($html, 'Live state of the SSR node on the VPS: whether it answers and how fast, '
|
||||||
|
. 'how full its page cache is against the limit, whether SSR delivery is switched on, which frontend '
|
||||||
|
. 'release runs, and the most recent cache purge. The link opens the cockpit with charts, protocol '
|
||||||
|
. 'and the purge and warm buttons.', 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCssFiles(): array
|
||||||
|
{
|
||||||
|
return [CockpitHtml::CSS];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchHealth(): ?array
|
||||||
|
{
|
||||||
|
$endpoint = $this->purgeService->getHealthEndpoint();
|
||||||
|
if ($endpoint === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
$response = $this->requestFactory->request($endpoint, 'GET', [
|
||||||
|
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
||||||
|
]);
|
||||||
|
if ($response->getStatusCode() !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = json_decode((string)$response->getBody(), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data['responseMs'] = (int)round((microtime(true) - $start) * 1000);
|
||||||
|
return $data;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastPurgeRow(): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_ssrpurgelog');
|
||||||
|
$row = $qb->select('*')->from('tx_vitec_ssrpurgelog')
|
||||||
|
->orderBy('uid', 'DESC')->setMaxResults(1)
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
return $row ?: null;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fmtDate(string $iso): string
|
||||||
|
{
|
||||||
|
$ts = strtotime($iso);
|
||||||
|
return $ts ? date('Y-m-d H:i', $ts) : '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,16 @@ return [
|
|||||||
'path' => '/vitec/product/field/save',
|
'path' => '/vitec/product/field/save',
|
||||||
'target' => ProductTextImportController::class . '::fieldSave',
|
'target' => ProductTextImportController::class . '::fieldSave',
|
||||||
],
|
],
|
||||||
|
// Context menu "Purge from SSR cache" (SsrPurgeItemProvider +
|
||||||
|
// Resources/Public/Javascript/ssr-context-menu.js).
|
||||||
|
'vitec_ssr_purge_page' => [
|
||||||
|
'path' => '/vitec/ssr/purge-page',
|
||||||
|
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgePage',
|
||||||
|
],
|
||||||
|
// Live status of the VITEC SSR cockpit
|
||||||
|
// (Resources/Public/Javascript/ssr-cockpit.js polls it).
|
||||||
|
'vitec_ssr_health' => [
|
||||||
|
'path' => '/vitec/ssr/health',
|
||||||
|
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::healthJson',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -127,4 +127,30 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'web_vitecssr' => [
|
||||||
|
'parent' => 'web',
|
||||||
|
'position' => ['after' => 'web_vitecimport'],
|
||||||
|
'access' => 'user',
|
||||||
|
'workspaces' => 'live',
|
||||||
|
'path' => '/module/web/vitec-ssr',
|
||||||
|
'labels' => [
|
||||||
|
'title' => 'VITEC SSR',
|
||||||
|
'shortDescription' => 'SSR delivery: VPS status, manual purge, purge/health protocol',
|
||||||
|
],
|
||||||
|
'extensionName' => 'Vitec',
|
||||||
|
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
|
||||||
|
'routes' => [
|
||||||
|
'_default' => [
|
||||||
|
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::indexAction',
|
||||||
|
],
|
||||||
|
'purge' => [
|
||||||
|
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgeAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
|
'warm' => [
|
||||||
|
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::warmAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<T3DataStructure>
|
||||||
|
<meta>
|
||||||
|
<langDisable>1</langDisable>
|
||||||
|
</meta>
|
||||||
|
<sheets>
|
||||||
|
<sDEF>
|
||||||
|
<ROOT>
|
||||||
|
<sheetTitle>Page Content Check</sheetTitle>
|
||||||
|
<type>array</type>
|
||||||
|
<el>
|
||||||
|
<settings.scope>
|
||||||
|
<label>Page tree to audit</label>
|
||||||
|
<description>Markets = the 11 markets and their sub-markets below /markets. Solutions = the 8 solution groups and their sub-solutions below /solutions.</description>
|
||||||
|
<config>
|
||||||
|
<type>select</type>
|
||||||
|
<renderType>selectSingle</renderType>
|
||||||
|
<items>
|
||||||
|
<numIndex index="0">
|
||||||
|
<label>Markets and sub-markets</label>
|
||||||
|
<value>markets</value>
|
||||||
|
</numIndex>
|
||||||
|
<numIndex index="1">
|
||||||
|
<label>Solutions and sub-solutions</label>
|
||||||
|
<value>solutions</value>
|
||||||
|
</numIndex>
|
||||||
|
</items>
|
||||||
|
<default>markets</default>
|
||||||
|
</config>
|
||||||
|
</settings.scope>
|
||||||
|
</el>
|
||||||
|
</ROOT>
|
||||||
|
</sDEF>
|
||||||
|
</sheets>
|
||||||
|
</T3DataStructure>
|
||||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
|||||||
* JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js').
|
* JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js').
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
'dependencies' => ['backend', 'core'],
|
'dependencies' => ['backend', 'core', 'dashboard'],
|
||||||
'imports' => [
|
'imports' => [
|
||||||
'@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/',
|
'@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -56,6 +56,33 @@ return [
|
|||||||
'typo3/cms-core/normalized-params-attribute',
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Client error reports from the React frontend:
|
||||||
|
// POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204
|
||||||
|
// (0072-FE section 4 / 0077-BE). Answers before site resolution,
|
||||||
|
// no session, rate-limited per IP, 8 KB body limit. Placed after
|
||||||
|
// the /_json strip so the prefixed form reaches it as well.
|
||||||
|
'vitec/client-error' => [
|
||||||
|
'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class,
|
||||||
|
'after' => [
|
||||||
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
|
'vitec/json-prefix-rewrite',
|
||||||
|
],
|
||||||
|
'before' => [
|
||||||
|
'typo3/cms-frontend/site',
|
||||||
|
'vitec/form-submission',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Outer response filter: Cache-Control: no-cache + ETag/304 for
|
||||||
|
// /_json (tickets 0011-FE / 0055-BE).
|
||||||
|
'vitec/json-cache-headers' => [
|
||||||
|
'target' => \Evomedien\Vitec\Middleware\JsonCacheHeaders::class,
|
||||||
|
'after' => [
|
||||||
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
|
],
|
||||||
|
'before' => [
|
||||||
|
'vitec/json-prefix-rewrite',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'backend' => [
|
'backend' => [
|
||||||
'vitec/sync-ssr-flag' => [
|
'vitec/sync-ssr-flag' => [
|
||||||
|
|||||||
@@ -22,3 +22,50 @@ services:
|
|||||||
# the final page title (seo.title) into meta.title as well.
|
# the final page title (seo.title) into meta.title as well.
|
||||||
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
|
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
|
||||||
alias: Evomedien\Vitec\Seo\VitecMetaHandler
|
alias: Evomedien\Vitec\Seo\VitecMetaHandler
|
||||||
|
|
||||||
|
# The DataHandler hook fetches this via GeneralUtility::makeInstance,
|
||||||
|
# which resolves constructor arguments only for container-public services.
|
||||||
|
Evomedien\Vitec\Service\SsrPurgeService:
|
||||||
|
public: true
|
||||||
|
|
||||||
|
# Fetched via GeneralUtility::makeInstance from the USER func
|
||||||
|
# ProductCheckJsonRenderer (TypoScript userFunc, no DI there).
|
||||||
|
Evomedien\Vitec\Service\ProductCheckService:
|
||||||
|
public: true
|
||||||
|
Evomedien\Vitec\Service\PageCheckService:
|
||||||
|
public: true
|
||||||
|
|
||||||
|
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
|
||||||
|
# injected by the dashboard compiler pass via the tag).
|
||||||
|
Evomedien\Vitec\Widgets\SsrStatusWidget:
|
||||||
|
tags:
|
||||||
|
- name: dashboard.widget
|
||||||
|
identifier: vitecSsrStatus
|
||||||
|
groupNames: systemInfo
|
||||||
|
title: 'VITEC: SSR / VPS status'
|
||||||
|
description: 'Live state of the SSR server (release, cache, reachability) and the delivery switch'
|
||||||
|
iconIdentifier: 'content-widget-text'
|
||||||
|
height: 'medium'
|
||||||
|
width: 'medium'
|
||||||
|
|
||||||
|
Evomedien\Vitec\Widgets\SsrStatsWidget:
|
||||||
|
tags:
|
||||||
|
- name: dashboard.widget
|
||||||
|
identifier: vitecSsrStats
|
||||||
|
groupNames: systemInfo
|
||||||
|
title: 'VITEC: SSR statistics'
|
||||||
|
description: 'Purge protocol and availability of the SSR server (14 days of history)'
|
||||||
|
iconIdentifier: 'content-widget-number'
|
||||||
|
height: 'medium'
|
||||||
|
width: 'medium'
|
||||||
|
|
||||||
|
Evomedien\Vitec\Widgets\SsrErrorsWidget:
|
||||||
|
tags:
|
||||||
|
- name: dashboard.widget
|
||||||
|
identifier: vitecSsrErrors
|
||||||
|
groupNames: systemInfo
|
||||||
|
title: 'VITEC: SSR errors & hit rate'
|
||||||
|
description: 'Render/backend error counters and cache hit rate of the SSR node (/__stats, sampled every 5 minutes)'
|
||||||
|
iconIdentifier: 'content-widget-list'
|
||||||
|
height: 'medium'
|
||||||
|
width: 'medium'
|
||||||
@@ -11,3 +11,7 @@ dependencies:
|
|||||||
# solr must load before us so our plugin.tx_solr overrides (statistics,
|
# solr must load before us so our plugin.tx_solr overrides (statistics,
|
||||||
# highlighting, content extraction) survive - set order follows dependencies
|
# highlighting, content extraction) survive - set order follows dependencies
|
||||||
- apache-solr-for-typo3/solr
|
- apache-solr-for-typo3/solr
|
||||||
|
# sitemap sets must load before us so our plugin.tx_seo sitemap overrides
|
||||||
|
# (news pids, products/stories record sitemaps) survive
|
||||||
|
- typo3/seo-sitemap
|
||||||
|
- georgringer/news-sitemap
|
||||||
@@ -10,18 +10,6 @@ categories:
|
|||||||
description: 'Footer & meta menu page selection'
|
description: 'Footer & meta menu page selection'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
my.vitec.setting:
|
|
||||||
label: 'My example setting'
|
|
||||||
category: VITEC
|
|
||||||
type: string
|
|
||||||
default: ''
|
|
||||||
|
|
||||||
my.seoRelevantSetting:
|
|
||||||
label: 'My SEO relevant setting'
|
|
||||||
category: seo
|
|
||||||
type: int
|
|
||||||
default: 5
|
|
||||||
|
|
||||||
vitec.debugMode:
|
vitec.debugMode:
|
||||||
label: 'Enable Debug Mode'
|
label: 'Enable Debug Mode'
|
||||||
description: 'Enable debug mode for VITEC extensions'
|
description: 'Enable debug mode for VITEC extensions'
|
||||||
@@ -139,3 +127,17 @@ settings:
|
|||||||
category: VITEC
|
category: VITEC
|
||||||
type: bool
|
type: bool
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
vitec.ssr.autoPurge:
|
||||||
|
label: 'SSR auto-purge on editor changes'
|
||||||
|
description: 'ON: saving pages, content, news or VITEC records automatically evicts the pages that show them from the SSR page cache - a product only from its detail page and the lists that contain it. Menu-relevant changes (titles, URLs, visibility), the newest news, events and the megamenu evict everything. OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.'
|
||||||
|
category: VITEC
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
|
||||||
|
vitec.ssr.alertEmails:
|
||||||
|
label: 'SSR alert mail recipients'
|
||||||
|
description: 'E-mail addresses that get the alert mails of the SSR health check: node down, node back, automatic fallback to the SPA. One address per entry; invalid addresses are skipped. Empty: the addresses from SSR_ALERT_EMAIL in var/vitec-ssr-purge.env are used.'
|
||||||
|
category: VITEC
|
||||||
|
type: stringlist
|
||||||
|
default: []
|
||||||
@@ -77,6 +77,9 @@ tt_content {
|
|||||||
fields {
|
fields {
|
||||||
productfeature = USER
|
productfeature = USER
|
||||||
productfeature.userFunc = Evomedien\Vitec\UserFunc\ProductFeatureJsonRenderer->render
|
productfeature.userFunc = Evomedien\Vitec\UserFunc\ProductFeatureJsonRenderer->render
|
||||||
|
# No product selected/visible -> real JSON null, not
|
||||||
|
# the string "null" (ticket 0068-FE).
|
||||||
|
productfeature.ifEmptyReturnNull = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,35 @@ tt_content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Product Content Check - the product audit table as data (page
|
||||||
|
# /products/product-check). Always USER_INT, see ProductCheckJsonRenderer.
|
||||||
|
vitec_productcheck < lib.contentElementWithHeader
|
||||||
|
vitec_productcheck {
|
||||||
|
fields {
|
||||||
|
content {
|
||||||
|
fields {
|
||||||
|
productcheck = USER
|
||||||
|
productcheck.userFunc = Evomedien\Vitec\UserFunc\ProductCheckJsonRenderer->render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page Content Check - the market / solution page audit as data (pages
|
||||||
|
# /markets/market-check, /solutions/solution-check). Always USER_INT, see PageCheckJsonRenderer.
|
||||||
|
vitec_pagecheck < lib.contentElementWithHeader
|
||||||
|
vitec_pagecheck {
|
||||||
|
fields {
|
||||||
|
content {
|
||||||
|
fields {
|
||||||
|
pagecheck = USER
|
||||||
|
pagecheck.userFunc = Evomedien\Vitec\UserFunc\PageCheckJsonRenderer->render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
||||||
solr_pi_results < lib.contentElementWithHeader
|
solr_pi_results < lib.contentElementWithHeader
|
||||||
solr_pi_results {
|
solr_pi_results {
|
||||||
@@ -1017,3 +1049,59 @@ plugin.tx_solr.suggest {
|
|||||||
# recip(age) = 10 when new, ~5 after one year, ~0.7 after 13 years.
|
# recip(age) = 10 when new, ~5 after one year, ~0.7 after 13 years.
|
||||||
# The gate keeps products/pages/stories out of the decay entirely.
|
# The gate keeps products/pages/stories out of the decay entirely.
|
||||||
plugin.tx_solr.search.query.boostFunction = if(termfreq(type,'tx_news_domain_model_news'),recip(ms(NOW,created),3.16e-11,10,1),10)
|
plugin.tx_solr.search.query.boostFunction = if(termfreq(type,'tx_news_domain_model_news'),recip(ms(NOW,created),3.16e-11,10,1),10)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# XML-Sitemap (SEO-Audit 2026-11-09)
|
||||||
|
#
|
||||||
|
# - news: the georgringer/news-sitemap set ships pid=0 / pageId=0, which
|
||||||
|
# rendered an EMPTY news sitemap. Records live in folders 11 (Blog) and
|
||||||
|
# 442 (Press); the detail route sits on page 21 (/news/article/<slug>).
|
||||||
|
# - products: record sitemap for /product/<slug> detail pages (folder 30).
|
||||||
|
# - stories: record sitemap for success stories (folder 14) with the
|
||||||
|
# PUBLIC /success-stories/<slug> URLs (custom provider strips the
|
||||||
|
# internal /story/ segment).
|
||||||
|
#
|
||||||
|
# Requires vitecset to load AFTER typo3/seo-sitemap + georgringer/
|
||||||
|
# news-sitemap - see the dependencies in this set's config.yaml.
|
||||||
|
# ======================================================================
|
||||||
|
plugin.tx_seo.config.xmlSitemap.sitemaps {
|
||||||
|
news.config {
|
||||||
|
pid = 11,442
|
||||||
|
url.pageId = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
products {
|
||||||
|
provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider
|
||||||
|
config {
|
||||||
|
table = tx_vitec_domain_model_product
|
||||||
|
sortField = title
|
||||||
|
lastModifiedField = tstamp
|
||||||
|
pid = 30
|
||||||
|
additionalWhere = {#hideonwebsite} = 0
|
||||||
|
url {
|
||||||
|
pageId = 10
|
||||||
|
fieldToParameterMap {
|
||||||
|
uid = tx_vitec_productshow[product]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stories {
|
||||||
|
provider = Evomedien\Vitec\Seo\StoriesXmlSitemapDataProvider
|
||||||
|
config {
|
||||||
|
table = tx_vitec_domain_model_usecase
|
||||||
|
sortField = title
|
||||||
|
lastModifiedField = tstamp
|
||||||
|
pid = 14
|
||||||
|
additionalWhere = {#hideonwebsite} = 0 AND {#no_index} = 0
|
||||||
|
url {
|
||||||
|
pageId = 26
|
||||||
|
fieldToParameterMap {
|
||||||
|
uid = tx_vitec_usecaseshow[usecase]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,20 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
|
|||||||
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
||||||
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
||||||
$registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productfeature', 'Featured Product Card', 'FILE:EXT:vitec/Configuration/FlexForms/Productfeature.xml', 'vitec-plugin-productlist');
|
||||||
|
// Product content audit as page content (2026-11-12): no FlexForm, the
|
||||||
|
// element has nothing to configure - data comes from ProductCheckService.
|
||||||
|
ExtensionUtility::registerPlugin(
|
||||||
|
'Vitec',
|
||||||
|
'Productcheck',
|
||||||
|
'Product Content Check (audit table)',
|
||||||
|
'vitec-plugin-productlist',
|
||||||
|
'plugins',
|
||||||
|
'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.'
|
||||||
|
);
|
||||||
|
// Page content audit for the market and solution trees (2026-11-13):
|
||||||
|
// scope (markets | solutions) via FlexForm, data from PageCheckService.
|
||||||
|
$registerPluginWithFlexForm('Pagecheck', 'Page Content Check (markets / solutions audit)', 'FILE:EXT:vitec/Configuration/FlexForms/Pagecheck.xml', 'vitec-plugin-productlist');
|
||||||
|
|
||||||
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
||||||
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
||||||
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
defined('TYPO3') or die();
|
||||||
|
|
||||||
|
// Canonical story for a news article: when a success story carries the same
|
||||||
|
// text as the article, the article stays in the news list but its canonical
|
||||||
|
// link points to the story (VitecMetaHandler reads this field).
|
||||||
|
(static function (): void {
|
||||||
|
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('tx_news_domain_model_news', [
|
||||||
|
'vitec_canonical_usecase' => [
|
||||||
|
'label' => 'Canonical success story',
|
||||||
|
'description' => 'Set when a success story carries the same text as this article. The article stays in the news list; search engines count the story as the original.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'renderType' => 'selectSingle',
|
||||||
|
'foreign_table' => 'tx_vitec_domain_model_usecase',
|
||||||
|
'foreign_table_where' => 'AND {#tx_vitec_domain_model_usecase}.{#deleted} = 0 ORDER BY tx_vitec_domain_model_usecase.title',
|
||||||
|
'items' => [['label' => '— none —', 'value' => 0]],
|
||||||
|
'default' => 0,
|
||||||
|
'size' => 1,
|
||||||
|
'maxitems' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
|
||||||
|
'tx_news_domain_model_news',
|
||||||
|
'--div--;VITEC SEO, vitec_canonical_usecase'
|
||||||
|
);
|
||||||
|
})();
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# VITEC menus — Headless / JSON output
|
# VITEC menus — Headless / JSON output
|
||||||
#
|
#
|
||||||
# Three menus are exposed at the page root of the JSON response:
|
# Two menus are exposed at the page root of the JSON response:
|
||||||
# - mainNavigation : full hierarchy from root, all visible pages
|
|
||||||
# - footerMenu : curated, configured via site setting menu.footer.pageUids
|
# - footerMenu : curated, configured via site setting menu.footer.pageUids
|
||||||
# - metaMenu : curated, configured via site setting menu.meta.pageUids
|
# - metaMenu : curated, configured via site setting menu.meta.pageUids
|
||||||
#
|
#
|
||||||
|
# The main navigation is NOT part of the page JSON any more (0110-FE, 12.11.2026):
|
||||||
|
# the frontend reads it from the headless initial-data type (?type=834, field
|
||||||
|
# `navigation`) in both SSR and SPA mode. Same for `i18n` (no language switch)
|
||||||
|
# and `favicons` (static in the frontend's root.tsx) — removed at the end.
|
||||||
|
#
|
||||||
# Each menu uses friendsoftypo3/headless MenuProcessor which:
|
# Each menu uses friendsoftypo3/headless MenuProcessor which:
|
||||||
# * resolves Shortcut pages to their target
|
# * resolves Shortcut pages to their target
|
||||||
# * skips pages with "Show in menu = no" (nav_hide=1) by default
|
# * skips pages with "Show in menu = no" (nav_hide=1) by default
|
||||||
@@ -13,23 +17,6 @@
|
|||||||
# * exposes `children` for sub-levels
|
# * exposes `children` for sub-levels
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Main navigation — full hierarchy
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
lib.mainNavigation = JSON
|
|
||||||
lib.mainNavigation {
|
|
||||||
dataProcessing {
|
|
||||||
10 = FriendsOfTYPO3\Headless\DataProcessing\MenuProcessor
|
|
||||||
10 {
|
|
||||||
levels = 10
|
|
||||||
expandAll = 1
|
|
||||||
includeSpacer = 0
|
|
||||||
titleField = nav_title // title
|
|
||||||
as = mainNavigation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Footer menu — curated by site setting (UIDs)
|
# Footer menu — curated by site setting (UIDs)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -69,13 +56,14 @@ lib.metaMenu {
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Wire them into the page-level JSON output
|
# Wire them into the page-level JSON output
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
page.10.fields.mainNavigation =< lib.mainNavigation
|
|
||||||
page.10.fields.footerMenu =< lib.footerMenu
|
page.10.fields.footerMenu =< lib.footerMenu
|
||||||
page.10.fields.metaMenu =< lib.metaMenu
|
page.10.fields.metaMenu =< lib.metaMenu
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Favicon / app-icon set (site-wide) — see FaviconsJsonRenderer
|
# Not delivered any more (0110-FE): nobody reads these in the frontend.
|
||||||
# Icon files: public/fileadmin/icons/
|
# mainNavigation — was the full hierarchy (~15 kB per page), see ?type=834
|
||||||
|
# i18n — headless default (lib.i18n); there is no language switch
|
||||||
|
# favicons — FaviconsJsonRenderer stays in the code base, unused
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
page.10.fields.favicons = USER
|
page.10.fields.i18n >
|
||||||
page.10.fields.favicons.userFunc = Evomedien\Vitec\UserFunc\FaviconsJsonRenderer->render
|
page.10.fields.favicons >
|
||||||
@@ -32,6 +32,23 @@ mod {
|
|||||||
CType = vitec_productfinder
|
CType = vitec_productfinder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
productcheck {
|
||||||
|
iconIdentifier = vitec-plugin-productlist
|
||||||
|
title = Product Content Check (audit table)
|
||||||
|
description = Live audit of all product records - one row per product, one check per content field. Internal page /products/product-check.
|
||||||
|
tt_content_defValues {
|
||||||
|
CType = vitec_productcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pagecheck {
|
||||||
|
iconIdentifier = vitec-plugin-productlist
|
||||||
|
title = Page Content Check (markets / solutions audit)
|
||||||
|
description = Live audit of the market or solution page tree - one row per page, one check per content slot. Internal pages /markets/market-check and /solutions/solution-check.
|
||||||
|
tt_content_defValues {
|
||||||
|
CType = vitec_pagecheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
productshow {
|
productshow {
|
||||||
iconIdentifier = vitec-plugin-productshow
|
iconIdentifier = vitec-plugin-productshow
|
||||||
title = Show single VITEC Product
|
title = Show single VITEC Product
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
|
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
|
||||||
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
|
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
|
||||||
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
|
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
|
||||||
|
| 1.33 | 2026‑11‑12 | **Interface change (additive).** Products menu: an overview page with exactly one product is presented as that product — `items[].link` is the product detail page and `items[].image` the product picture (productimage, else heroimage, else page media); new `items[].linkType` (`product` | `overview`) and `items[].overviewLink` (always the overview page). Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway, ActiveSQX (decision Harry 2026-11-12). Front end: no sub-product count for `linkType = product`. |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -1229,8 +1230,13 @@ reached from the overview pages.
|
|||||||
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
||||||
"items": [
|
"items": [
|
||||||
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
||||||
|
"linkType": "overview", "overviewLink": "/products/appliances",
|
||||||
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
||||||
"image": null },
|
"image": null },
|
||||||
|
{ "type": "page", "uid": 575, "title": "Avedia", "link": "/product/avedia-platform-iptv-signage-cmsserver",
|
||||||
|
"linkType": "product", "overviewLink": "/products/avedia",
|
||||||
|
"productCount": 1, "products": [ { "title": "Avedia Platform", "link": "/product/avedia-platform-iptv-signage-cmsserver" } ],
|
||||||
|
"image": { "url": "/fileadmin/_processed_/…webp", "alternative": "", "width": 640, "height": 360 } },
|
||||||
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
|
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
|
||||||
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
|
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
|
||||||
}
|
}
|
||||||
@@ -1251,8 +1257,18 @@ reached from the overview pages.
|
|||||||
- `products` are the product cards of the overview page that link a detail
|
- `products` are the product cards of the overview page that link a detail
|
||||||
page (container children included, creation order); `productCount` is
|
page (container children included, creation order); `productCount` is
|
||||||
their number - a card without a resolvable product is not counted.
|
their number - a card without a resolvable product is not counted.
|
||||||
|
- **Single-product overviews (v1.33, additive):** an overview page with
|
||||||
|
exactly one product is presented as that product - `link` is the product
|
||||||
|
detail page (`/product/<slug>`) and `image` the product's own picture
|
||||||
|
(*productimage*, else *heroimage*, else the page media). `linkType` tells
|
||||||
|
the two cases apart: `product` | `overview`; `overviewLink` always holds
|
||||||
|
the overview page (`/products/<slug>`) for breadcrumb-style uses. Today
|
||||||
|
this applies to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway
|
||||||
|
and ActiveSQX; it switches automatically when an editor adds a second
|
||||||
|
card. The front end should not render a sub-product count for these.
|
||||||
- `image` (from content stage 2) is the overview page's first page media as a
|
- `image` (from content stage 2) is the overview page's first page media as a
|
||||||
640 px WebP, `null` when none is set.
|
640 px WebP (`linkType = overview`) or the product picture (`linkType =
|
||||||
|
product`), `null` when none is set.
|
||||||
- The promo boxes of the left column ("Product highlight", "Product news")
|
- The promo boxes of the left column ("Product highlight", "Product news")
|
||||||
are the entry's editorial `teasers` next to `catalog` (`kicker` = box
|
are the entry's editorial `teasers` next to `catalog` (`kicker` = box
|
||||||
label, plus `title`, `text`, `link`, `image`; designed for two, an empty
|
label, plus `title`, `text`, `link`, `image`; designed for two, an empty
|
||||||
@@ -1710,4 +1726,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.32.*
|
*End of document EVO‑VITEC‑HL‑001 v1.33.*
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||||
|
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
|
||||||
|
data-namespace-typo3-fluid="true">
|
||||||
|
|
||||||
|
<f:layout name="Module" />
|
||||||
|
|
||||||
|
<f:section name="Content">
|
||||||
|
|
||||||
|
<div class="vitec-cockpit" id="ssr-cockpit" data-json="{cockpitJson}">
|
||||||
|
|
||||||
|
<h1 class="vc-sr">VITEC SSR — Control & Protocol</h1>
|
||||||
|
|
||||||
|
<div class="vc-grid">
|
||||||
|
|
||||||
|
<!-- Live band: node state, release, delivery switch, 24 h availability -->
|
||||||
|
<section class="vc-card vc-hero vc-s12" aria-label="Live status">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-live">?</button><span class="vc-tip" role="tooltip" id="vc-tip-live">Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA. Alert mails go to the addresses under Site settings › VITEC › SSR alert mail recipients; hover “Alert mail” to see them.</span></span>
|
||||||
|
<div class="vc-hero-top">
|
||||||
|
<div class="vc-live">
|
||||||
|
<f:if condition="{health}">
|
||||||
|
<f:then>
|
||||||
|
<span class="vc-pulse" id="vc-pulse" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-ok-text">online</span></h2>
|
||||||
|
<p class="vc-live-sub">
|
||||||
|
<f:if condition="{flagOn}">
|
||||||
|
<f:then>Pages are rendered on the VPS</f:then>
|
||||||
|
<f:else>Node is ready, but delivery is off: TYPO3 serves the SPA</f:else>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{lastSample}"> · last check <span title="{f:format.date(date: '@{lastSample.crdate}', format: 'Y-m-d H:i:s')}">{lastSampleAge} ago</span></f:if>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
<span class="vc-pulse is-down" id="vc-pulse" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<h2 class="vc-live-title" id="vc-live-title">SSR <span class="vc-bad-text">not reachable</span></h2>
|
||||||
|
<p class="vc-live-sub">{healthError} · after {failoverAfter} failed checks in a row the cron switches delivery back to the SPA</p>
|
||||||
|
</div>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
<dl class="vc-facts">
|
||||||
|
<div class="vc-fact">
|
||||||
|
<dt>Delivery</dt>
|
||||||
|
<dd>
|
||||||
|
<f:if condition="{flagOn}">
|
||||||
|
<f:then><span class="vc-pill is-ok">ON</span></f:then>
|
||||||
|
<f:else><span class="vc-pill is-muted">OFF</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{settingsUri}">
|
||||||
|
<f:then><a href="{settingsUri}" class="vc-link" title="Site Management › Settings › VITEC › SSR delivery. Switching on purges the node cache once.">Site settings</a></f:then>
|
||||||
|
<f:else><span class="vc-dim" title="Site Management › Settings › VITEC › SSR delivery">via Site settings</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<f:if condition="{health}">
|
||||||
|
<div class="vc-fact"><dt>Release</dt><dd><code>{health.release.release}</code></dd></div>
|
||||||
|
<div class="vc-fact"><dt>Commit</dt><dd><code>{health.release.commit}</code></dd></div>
|
||||||
|
</f:if>
|
||||||
|
<div class="vc-fact">
|
||||||
|
<dt>Purge webhook</dt>
|
||||||
|
<dd>
|
||||||
|
<f:if condition="{configured}">
|
||||||
|
<f:then><span class="vc-pill is-ok" title="Endpoint and secret from var/vitec-ssr-purge.env">configured</span></f:then>
|
||||||
|
<f:else><span class="vc-pill is-warn" title="var/vitec-ssr-purge.env is missing or incomplete">not configured</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="vc-fact">
|
||||||
|
<dt>Alert mail</dt>
|
||||||
|
<dd>
|
||||||
|
<f:if condition="{alertCount}">
|
||||||
|
<f:then><span class="vc-pill {f:if(condition: alertInvalid, then: 'is-warn', else: 'is-ok')}" title="{alertList} (from {alert.source}){f:if(condition: alertInvalid, then: ' · skipped, not valid: {alertInvalid}')}">{alertCount} recipient<f:if condition="{alertCount} != 1">s</f:if></span></f:then>
|
||||||
|
<f:else><span class="vc-pill is-warn" title="No alert mails are sent. Add addresses in Site Management › Settings › VITEC › SSR alert mail recipients.">none</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="vc-fact">
|
||||||
|
<dt>Auto failover</dt>
|
||||||
|
<dd><span class="vc-pill is-navy" title="Cron vitec:ssr-health every 5 minutes: alert mail and fallback to the SPA after {failoverAfter} failed checks in a row">after {failoverAfter} failed checks</span></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="vc-uptime">
|
||||||
|
<div class="vc-uptime-h">
|
||||||
|
<span>Availability, last 24 hours · one bar per 15 minutes</span>
|
||||||
|
<span id="vc-uptime-sum">–</span>
|
||||||
|
</div>
|
||||||
|
<div class="vc-bars" id="vc-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours"></div>
|
||||||
|
<div class="vc-uptime-f" id="vc-uptime-axis"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Gauges -->
|
||||||
|
<section class="vc-card vc-s3" aria-label="Cache hit rate">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-hit">?</button><span class="vc-tip" role="tooltip" id="vc-tip-hit">Share of page requests in the last 24 hours that the node answered from its cache, either fresh or stale and refreshed in the background. All others had to be rendered first. Higher is better. It drops after a full purge until the cache is warm again. The arrow compares with the 24 hours before.</span></span>
|
||||||
|
<div class="vc-tile">
|
||||||
|
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-ok" id="vc-ring-hit" cx="48" cy="48" r="40"/></svg>
|
||||||
|
<div class="vc-tile-body">
|
||||||
|
<div class="vc-label">Hit rate · 24 h</div>
|
||||||
|
<div class="vc-big"><span id="vc-hit">–</span><small>%</small></div>
|
||||||
|
<div class="vc-sub" id="vc-hit-trend">served from cache or refreshed in the background</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="vc-card vc-s3" aria-label="Response time">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-ms">?</button><span class="vc-tip" role="tooltip" id="vc-tip-ms">The big number is the last check: how long TYPO3 needed to reach the node, measured when this page loads and every 15 seconds after. Median and p95 come from the cron samples of the last 24 hours, taken every 5 minutes. The curve shows the last 4 hours. Values above 400 ms turn the availability strip yellow.</span></span>
|
||||||
|
<div class="vc-label">Response time</div>
|
||||||
|
<div class="vc-big"><span id="vc-ms"><f:if condition="{health}"><f:then>{health.responseMs}</f:then><f:else>–</f:else></f:if></span><small>ms</small></div>
|
||||||
|
<div class="vc-big-note">last check · updates every 15 s</div>
|
||||||
|
<div class="vc-sub">median 24 h <b id="vc-ms-median">–</b> · p95 <b id="vc-ms-p95">–</b></div>
|
||||||
|
<svg class="vc-spark" id="vc-spark" viewBox="0 0 300 46" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="vc-card vc-s3" aria-label="Cache fill">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-cache">?</button><span class="vc-tip" role="tooltip" id="vc-tip-cache">How much of the node page cache is in use: pages and megabytes against the configured limit (SSR_CACHE_LIMIT_MB). When the cache is full, the pages used least recently drop out. After “Purge everything” it falls to almost zero until “Warm cache” fills it again.</span></span>
|
||||||
|
<div class="vc-tile">
|
||||||
|
<svg class="vc-ring" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-sky" id="vc-ring-cache" cx="48" cy="48" r="40"/></svg>
|
||||||
|
<div class="vc-tile-body">
|
||||||
|
<div class="vc-label">Cache fill</div>
|
||||||
|
<div class="vc-big"><span id="vc-cache-mb">{cockpit.cacheMb}</span><small id="vc-cache-limit"><f:if condition="{cacheLimitMb}"><f:then>/ {cacheLimitMb} MB</f:then><f:else>MB</f:else></f:if></small></div>
|
||||||
|
<div class="vc-sub"><b id="vc-cache-pages">{cockpit.cachedPages}</b> pages cached<span id="vc-cache-pct"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="vc-card vc-s3" aria-label="Client errors">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errtile">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errtile">Errors that visitor browsers reported from the React frontend in the last 24 hours. A chunk reload happens when an open tab still runs an older bundle after a deploy; that is usually harmless. JS errors point to a real bug. Test reports (smoke tests, curl) are left out; reports from crawlers like Googlebot count and carry a bot label. The bars show reports per hour, details are in the Client errors card below.</span></span>
|
||||||
|
<div class="vc-label">Client errors · 24 h</div>
|
||||||
|
<div class="vc-big">{clientErrorCount24h}<small>report<f:if condition="{clientErrorCount24h} != 1">s</f:if></small></div>
|
||||||
|
<div class="vc-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if><f:if condition="{testCount24h}"> · <span class="vc-dim" title="Smoke tests and curl reports are left out">{testCount24h} test<f:if condition="{testCount24h} != 1">s</f:if> left out</span></f:if></div>
|
||||||
|
<div class="vc-mini" id="vc-mini" role="img" aria-label="Client errors per hour, last 24 hours"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Traffic chart -->
|
||||||
|
<section class="vc-card vc-s8 vc-chart-card" aria-label="Traffic and cache">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-traffic">?</button><span class="vc-tip" role="tooltip" id="vc-tip-traffic">Requests per hour, or per 4 hours in the 7-day view, split by how the node answered: served from cache, stale and refreshed in the background, or rendered fresh. The orange line is the response time, red triangles mark purges. Spikes of fresh renders usually follow a purge or a warm run. Source: node counters sampled every 5 minutes.</span></span>
|
||||||
|
<div class="vc-card-h">
|
||||||
|
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 3v18h18M7 15l4-5 4 3 5-7"/></svg>Traffic and cache</h2>
|
||||||
|
<div class="vc-seg" role="group" aria-label="Time range">
|
||||||
|
<button type="button" data-range="24h" aria-pressed="true">24 h</button>
|
||||||
|
<button type="button" data-range="7d" aria-pressed="false">7 days</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-legend">
|
||||||
|
<span><i class="is-navy"></i>Served from cache</span>
|
||||||
|
<span><i class="is-sky"></i>Stale, refreshed in background</span>
|
||||||
|
<span><i class="is-warn"></i>Rendered fresh</span>
|
||||||
|
<span><i class="is-orange is-round"></i>Response time</span>
|
||||||
|
<span><i class="is-bad is-tri"></i>Purge</span>
|
||||||
|
</div>
|
||||||
|
<div class="vc-chart"><canvas id="vc-traffic" aria-label="Requests by cache result, with response time and purges"></canvas></div>
|
||||||
|
<p class="vc-empty" id="vc-traffic-empty" hidden>No traffic counters in this range yet. The cron <code>vitec:ssr-health</code> collects them every 5 minutes.</p>
|
||||||
|
<dl class="vc-stats">
|
||||||
|
<div><dt>Requests</dt><dd id="vc-st-req">–</dd></div>
|
||||||
|
<div><dt>Hit rate</dt><dd id="vc-st-hit">–</dd></div>
|
||||||
|
<div><dt>Render errors</dt><dd id="vc-st-err">–</dd></div>
|
||||||
|
<div><dt>Purges</dt><dd id="vc-st-purge">–</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<section class="vc-card vc-s4" aria-label="Actions">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-actions">?</button><span class="vc-tip" role="tooltip" id="vc-tip-actions">Warm cache renders every sitemap page in advance, and the ring shows the progress live. Purge paths drops single pages from the cache, for example after a change the auto-purge did not catch. Purge everything empties the whole cache, which is needed after menu or footer changes and after a deploy; run Warm cache right after. Both heavy actions ask for confirmation.</span></span>
|
||||||
|
<div class="vc-card-h"><h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>Actions</h2></div>
|
||||||
|
<div class="vc-actions">
|
||||||
|
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecssr.warm')}" method="post" class="vc-act"
|
||||||
|
data-confirm-title="Warm the whole cache?"
|
||||||
|
data-confirm="Renders every sitemap page (about 850) in the background and sends a few thousand requests to TYPO3. Takes about one minute with a warm TYPO3 cache, up to ten minutes with a cold one."
|
||||||
|
data-confirm-ok="Start warming" data-confirm-severity="primary">
|
||||||
|
<div class="vc-act-h"><h3 class="vc-act-t">Warm cache</h3><span class="vc-pill" id="vc-warm-pill" hidden></span></div>
|
||||||
|
<div class="vc-warm">
|
||||||
|
<div class="vc-warm-ring">
|
||||||
|
<svg width="92" height="92" viewBox="0 0 96 96" aria-hidden="true" class="vc-ring"><circle class="vc-track" cx="48" cy="48" r="40"/><circle class="vc-val is-orange is-fast" id="vc-ring-warm" cx="48" cy="48" r="40"/></svg>
|
||||||
|
<div class="vc-warm-pct" id="vc-warm-pct">–</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-warm-meta" id="vc-warm-meta">no run since the server started</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-btn-row">
|
||||||
|
<button type="submit" class="vc-btn is-primary" id="ssr-warm-button" {f:if(condition: health.warming.running, then: 'disabled="disabled"')}>
|
||||||
|
<core:icon identifier="actions-play" size="small" /><span id="vc-warm-label">{f:if(condition: health.warming.running, then: 'Warming …', else: 'Warm cache')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="vc-help">Renders every sitemap page in advance, so no visitor waits for a first render. Run it after “Purge everything” or a deploy.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act">
|
||||||
|
<h3 class="vc-act-t">Purge paths</h3>
|
||||||
|
<label class="vc-lbl" for="purge-paths">Paths, one per line</label>
|
||||||
|
<textarea class="vc-input" id="purge-paths" name="paths" rows="3" placeholder="/products/aligo /insights/news"></textarea>
|
||||||
|
<input type="hidden" name="mode" value="paths">
|
||||||
|
<div class="vc-btn-row"><button type="submit" class="vc-btn is-ghost"><core:icon identifier="actions-refresh" size="small" />Purge these paths</button></div>
|
||||||
|
<p class="vc-help">Drops those pages, all hosts and query variants, from the SSR cache. They render again on their next request.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecssr.purge')}" method="post" class="vc-act"
|
||||||
|
data-confirm-title="Purge the entire SSR cache?"
|
||||||
|
data-confirm="Every cached page is dropped and rendered again on its next request. Run “Warm cache” afterwards so visitors do not wait for the first render."
|
||||||
|
data-confirm-ok="Purge everything" data-confirm-severity="danger">
|
||||||
|
<h3 class="vc-act-t">Purge everything</h3>
|
||||||
|
<p class="vc-help">For menu or footer changes while auto-purge is off, and after a frontend deploy.</p>
|
||||||
|
<input type="hidden" name="mode" value="all">
|
||||||
|
<div class="vc-btn-row"><button type="submit" class="vc-btn is-danger"><core:icon identifier="actions-delete" size="small" />Purge everything</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Purge activity -->
|
||||||
|
<section class="vc-card vc-s7" aria-label="Purge activity">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-activity">?</button><span class="vc-tip" role="tooltip" id="vc-tip-activity">Every cache purge, newest first. The colour shows the trigger: auto-purge when an editor saves, manual from this module, context menu from the page tree, cli for commands and deploys, switch-on when SSR delivery was turned on. Result and duration are on the right. Switch between 30, 100 and 500 entries.</span></span>
|
||||||
|
<div class="vc-card-h">
|
||||||
|
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Purge activity</h2>
|
||||||
|
<div class="vc-seg" role="group" aria-label="Entries to show">
|
||||||
|
<a href="{moduleUri}&limit=30" aria-pressed="{f:if(condition: '{purgeLimit} == 30', then: 'true', else: 'false')}">30</a>
|
||||||
|
<a href="{moduleUri}&limit=100" aria-pressed="{f:if(condition: '{purgeLimit} == 100', then: 'true', else: 'false')}">100</a>
|
||||||
|
<a href="{moduleUri}&limit=500" aria-pressed="{f:if(condition: '{purgeLimit} == 500', then: 'true', else: 'false')}">500</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-legend vc-legend-tl">
|
||||||
|
<span><i class="is-ok is-round"></i>auto-purge</span>
|
||||||
|
<span><i class="is-orange is-round"></i>manual</span>
|
||||||
|
<span><i class="is-navy is-round"></i>context menu</span>
|
||||||
|
<span><i class="is-sky is-round"></i>cli / deploy</span>
|
||||||
|
<span><i class="is-warn is-round"></i>switch-on</span>
|
||||||
|
</div>
|
||||||
|
<f:if condition="{purgeLog}">
|
||||||
|
<f:then>
|
||||||
|
<ol class="vc-timeline">
|
||||||
|
<f:for each="{purgeLog}" as="row">
|
||||||
|
<f:if condition="{row.day_first}"><li class="vc-day">{row.day_label}</li></f:if>
|
||||||
|
<li class="vc-ev">
|
||||||
|
<time title="{f:format.date(date: '@{row.crdate}', format: 'Y-m-d H:i:s')}"><f:format.date format="H:i">@{row.crdate}</f:format.date></time>
|
||||||
|
<span class="vc-dot is-{row.trigger_tone}"></span>
|
||||||
|
<div class="vc-ev-main">
|
||||||
|
<div class="vc-ev-line">
|
||||||
|
<span class="vc-pill is-{row.trigger_tone}">{row.trigger_label}</span>
|
||||||
|
<f:if condition="{row.trigger_who}"><span class="vc-dim">{row.trigger_who}</span></f:if>
|
||||||
|
</div>
|
||||||
|
<div class="vc-paths">
|
||||||
|
<f:if condition="{row.paths_all}">
|
||||||
|
<f:then><span class="vc-pill is-warn">all pages (*)</span></f:then>
|
||||||
|
<f:else>
|
||||||
|
<f:for each="{row.paths_first}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
|
||||||
|
<f:if condition="{row.paths_rest}">
|
||||||
|
<details class="vc-more">
|
||||||
|
<summary>+<f:count subject="{row.paths_rest}" /> more</summary>
|
||||||
|
<f:for each="{row.paths_rest}" as="p"><span class="vc-path" title="{p}">{p}</span></f:for>
|
||||||
|
</details>
|
||||||
|
</f:if>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-ev-r">
|
||||||
|
<f:if condition="{row.success}">
|
||||||
|
<f:then><span class="vc-pill is-ok">ok</span></f:then>
|
||||||
|
<f:else><span class="vc-pill is-bad" title="{row.message}">HTTP {row.http_code}</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
<span>{row.duration_ms} ms</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</f:for>
|
||||||
|
</ol>
|
||||||
|
</f:then>
|
||||||
|
<f:else><p class="vc-empty">No purges logged yet. Saving a page, the context menu “Purge from SSR cache” or the buttons on the right create the first entry.</p></f:else>
|
||||||
|
</f:if>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Client errors -->
|
||||||
|
<section class="vc-card vc-s5" aria-label="Client errors">
|
||||||
|
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errors">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errors">Every report from visitor browsers in the last 24 hours, newest first. Open an entry for release, URL, browser, anonymized IP and stack trace. “old bundle” means the tab ran an older release than the node. Test reports (smoke tests, curl) are counted under Tests but not listed. Reports are limited to 30 per minute and IP and are kept for 30 days.</span></span>
|
||||||
|
<div class="vc-card-h">
|
||||||
|
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3 2 21h20zM12 10v4M12 17.5v.5"/></svg>Client errors</h2>
|
||||||
|
<span class="vc-pill is-sky" title="Reported by the React frontend via POST /api/vitec/client-error; max. 30 per minute and IP, IPs anonymized, cleaned up after 30 days by cron vitec:client-error-cleanup">kept 30 days</span>
|
||||||
|
</div>
|
||||||
|
<dl class="vc-err-sum">
|
||||||
|
<div><dt>24 h</dt><dd>{clientErrorCount24h}</dd></div>
|
||||||
|
<div><dt>Chunk reloads</dt><dd class="vc-warn-text">{chunkCount24h}</dd></div>
|
||||||
|
<div><dt>JS errors</dt><dd class="vc-bad-text">{jsErrorCount24h}</dd></div>
|
||||||
|
<div title="Smoke tests and curl reports are not counted and not listed"><dt>Tests</dt><dd class="vc-muted-text">{testCount24h}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<f:if condition="{clientErrors}">
|
||||||
|
<f:then>
|
||||||
|
<div class="vc-errs">
|
||||||
|
<f:for each="{clientErrors}" as="row">
|
||||||
|
<details class="vc-err">
|
||||||
|
<summary>
|
||||||
|
<span class="vc-err-pills">
|
||||||
|
<f:if condition="{row.is_chunk_reload}">
|
||||||
|
<f:then><span class="vc-pill is-warn">chunk reload</span></f:then>
|
||||||
|
<f:else><span class="vc-pill is-bad">error</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
<f:if condition="{row.is_bot}"><span class="vc-pill is-muted" title="Reported by a crawler, e.g. Googlebot. Still a real error: the page broke for this bot.">bot</span></f:if>
|
||||||
|
</span>
|
||||||
|
<span class="vc-err-msg">{row.message}<small>{row.path}</small></span>
|
||||||
|
<span class="vc-err-t"><f:format.date format="H:i">@{row.crdate}</f:format.date></span>
|
||||||
|
</summary>
|
||||||
|
<div class="vc-err-body">
|
||||||
|
<dl class="vc-kv">
|
||||||
|
<dt>Reported</dt><dd><f:format.date format="Y-m-d H:i:s">@{row.crdate}</f:format.date> · entry #{row.uid}</dd>
|
||||||
|
<dt>Release</dt><dd><f:if condition="{row.release}"><f:then><code>{row.release}</code><f:if condition="{health.release.release}"><f:if condition="{row.release} != {health.release.release}"> <span class="vc-pill is-warn">old bundle</span></f:if></f:if></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
|
||||||
|
<dt>URL</dt><dd><f:if condition="{row.url}"><f:then><a class="vc-link" href="{row.url}" target="_blank" rel="noopener">{row.url}</a></f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
|
||||||
|
<dt>Browser</dt><dd><f:if condition="{row.user_agent}"><f:then>{row.user_agent}</f:then><f:else><span class="vc-dim">not transmitted</span></f:else></f:if></dd>
|
||||||
|
<dt>IP</dt><dd><code>{row.ip}</code> <span class="vc-dim">anonymized</span></dd>
|
||||||
|
</dl>
|
||||||
|
<f:if condition="{row.stack}">
|
||||||
|
<f:then><pre class="vc-stack">{row.stack}</pre></f:then>
|
||||||
|
<f:else><p class="vc-dim">No stack transmitted.</p></f:else>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</f:for>
|
||||||
|
</div>
|
||||||
|
</f:then>
|
||||||
|
<f:else><p class="vc-empty">No client errors in the last 24 hours. The frontend reports JS errors and chunk reloads here as they happen.</p></f:else>
|
||||||
|
</f:if>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</f:section>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/* VITEC SSR cockpit (module web_vitecssr).
|
||||||
|
Deliberately dark, in VITEC colours, independent of the backend theme
|
||||||
|
(decision Harry, 2026-11-11). The TYPO3 frame stays core: doc header,
|
||||||
|
flash messages, confirmation modals. Everything here is scoped to
|
||||||
|
.vitec-cockpit or to this module's body.
|
||||||
|
Layout: live band + availability / four gauges / traffic 8 + actions 4 /
|
||||||
|
purge activity 7 + client errors 5; below 1200 px the wide pairs stack. */
|
||||||
|
|
||||||
|
[data-module-name="web_vitecssr"] .module-body {
|
||||||
|
--module-bg: #080c26;
|
||||||
|
background-color: #080c26;
|
||||||
|
}
|
||||||
|
[data-module-name="web_vitecssr"] .module-body-container {
|
||||||
|
max-width: 1680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vitec-cockpit {
|
||||||
|
--vc-ink: #080c26; /* page: VITEC navy pushed to night */
|
||||||
|
--vc-panel: #0f1640; /* card */
|
||||||
|
--vc-panel-2: #151e52; /* raised inside a card */
|
||||||
|
--vc-line: rgba(140, 156, 255, .14);
|
||||||
|
--vc-line-strong: rgba(140, 156, 255, .28);
|
||||||
|
--vc-navy: #26358c; /* VITEC blue */
|
||||||
|
--vc-navy-hi: #4a5fd6; /* VITEC blue lifted for the dark ground */
|
||||||
|
--vc-orange: #f47937; /* VITEC orange: actions and highlights */
|
||||||
|
--vc-text: #e9ecfb;
|
||||||
|
--vc-muted: #8e97c6;
|
||||||
|
--vc-faint: #6a73a6;
|
||||||
|
--vc-ok: #35d39a;
|
||||||
|
--vc-warn: #ffc65c;
|
||||||
|
--vc-bad: #ff5f73;
|
||||||
|
--vc-sky: #6fc3ff;
|
||||||
|
--vc-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
--vc-r: 14px;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: var(--vc-text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.vitec-cockpit *,
|
||||||
|
.vitec-cockpit *::before,
|
||||||
|
.vitec-cockpit *::after { box-sizing: border-box; }
|
||||||
|
.vitec-cockpit code { font-family: var(--vc-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
|
||||||
|
.vitec-cockpit h2, .vitec-cockpit h3 { color: inherit; }
|
||||||
|
.vc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
|
||||||
|
.vc-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 20px; }
|
||||||
|
.vc-card { background: var(--vc-panel); border: 1px solid var(--vc-line); border-radius: var(--vc-r); padding: 20px; min-width: 0; }
|
||||||
|
.vc-s12 { grid-column: span 12; } .vc-s8 { grid-column: span 8; } .vc-s4 { grid-column: span 4; }
|
||||||
|
.vc-s7 { grid-column: span 7; } .vc-s5 { grid-column: span 5; } .vc-s3 { grid-column: span 3; }
|
||||||
|
@media (max-width: 1199.98px) {
|
||||||
|
.vc-s8, .vc-s4, .vc-s7, .vc-s5 { grid-column: span 12; }
|
||||||
|
.vc-s3 { grid-column: span 6; }
|
||||||
|
}
|
||||||
|
@media (max-width: 619.98px) { .vc-s3 { grid-column: span 12; } .vc-grid { gap: 14px; } .vc-card { padding: 16px; } }
|
||||||
|
|
||||||
|
.vc-card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.vc-card-t { font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted) !important; margin: 0; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.vc-card-t svg { color: var(--vc-navy-hi); }
|
||||||
|
.vc-dim { color: var(--vc-muted); font-size: 12px; }
|
||||||
|
.vc-ok-text { color: var(--vc-ok); } .vc-bad-text { color: var(--vc-bad); } .vc-warn-text { color: var(--vc-warn); }
|
||||||
|
.vc-link { color: var(--vc-sky); text-decoration: none; font-size: 12px; font-weight: 500; }
|
||||||
|
.vc-link:hover, .vc-link:focus { color: var(--vc-sky); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Pills */
|
||||||
|
.vc-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .04em; padding: 5px 9px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
|
||||||
|
.vc-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vc-ok); }
|
||||||
|
.vc-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vc-warn); }
|
||||||
|
.vc-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vc-bad); }
|
||||||
|
.vc-pill.is-sky, .vc-pill.is-info { background: rgba(111, 195, 255, .14); color: var(--vc-sky); }
|
||||||
|
.vc-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
|
||||||
|
.vc-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vc-orange); }
|
||||||
|
.vc-pill.is-muted { background: rgba(140, 156, 255, .1); color: var(--vc-muted); }
|
||||||
|
|
||||||
|
/* Live band */
|
||||||
|
.vc-hero { padding: 24px 24px 20px; background:
|
||||||
|
radial-gradient(120% 140% at 0% 0%, rgba(74, 95, 214, .35), transparent 55%),
|
||||||
|
radial-gradient(80% 120% at 100% 0%, rgba(244, 121, 55, .12), transparent 50%),
|
||||||
|
var(--vc-panel); }
|
||||||
|
.vc-hero-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px 28px; }
|
||||||
|
.vc-live { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.vc-pulse { position: relative; width: 14px; height: 14px; border-radius: 50%; background: var(--vc-ok); flex: none; }
|
||||||
|
.vc-pulse::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid currentColor; color: var(--vc-ok); opacity: 0; animation: vc-ping 2.4s ease-out infinite; }
|
||||||
|
.vc-pulse.is-down { background: var(--vc-bad); }
|
||||||
|
.vc-pulse.is-down::after { color: var(--vc-bad); }
|
||||||
|
@keyframes vc-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||||
|
.vc-live-title { font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; margin: 0; }
|
||||||
|
.vc-live-sub { margin: 2px 0 0; color: var(--vc-muted); font-size: 13px; }
|
||||||
|
.vc-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 0 0 0 auto; }
|
||||||
|
.vc-fact dt { font-size: 11px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); }
|
||||||
|
.vc-fact dd { margin: 2px 0 0; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||||
|
@media (max-width: 899.98px) { .vc-facts { margin-left: 0; } }
|
||||||
|
|
||||||
|
.vc-uptime { margin-top: 22px; }
|
||||||
|
.vc-uptime-h { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--vc-muted); margin-bottom: 8px; }
|
||||||
|
.vc-uptime-h b { color: var(--vc-text); font-weight: 600; }
|
||||||
|
.vc-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 2px; height: 30px; align-items: end; }
|
||||||
|
.vc-bars i { display: block; height: 100%; border-radius: 2px; background: var(--vc-ok); opacity: .85; transition: opacity .15s, transform .15s; }
|
||||||
|
.vc-bars i.is-slow { background: var(--vc-warn); }
|
||||||
|
.vc-bars i.is-down { background: var(--vc-bad); }
|
||||||
|
.vc-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
|
||||||
|
.vc-bars i:hover { opacity: 1; transform: scaleY(1.12); }
|
||||||
|
@media (max-width: 619.98px) { .vc-bars { gap: 1px; } }
|
||||||
|
.vc-uptime-f { display: flex; justify-content: space-between; font: 11px var(--vc-mono); color: var(--vc-faint); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Gauges */
|
||||||
|
.vc-tile { display: flex; gap: 14px; align-items: center; }
|
||||||
|
.vc-tile .vc-ring { width: 78px; height: 78px; }
|
||||||
|
.vc-tile-body { min-width: 0; flex: 1; }
|
||||||
|
.vc-label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-muted); font-weight: 600; }
|
||||||
|
.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
|
||||||
|
.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; }
|
||||||
|
.vc-sub { font-size: 12px; color: var(--vc-muted); }
|
||||||
|
.vc-big-note { font-size: 10.5px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); margin: -2px 0 6px; }
|
||||||
|
.vc-sub b { color: var(--vc-text); }
|
||||||
|
.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); }
|
||||||
|
.vc-ring { flex: none; }
|
||||||
|
.vc-ring circle { fill: none; stroke-width: 9; }
|
||||||
|
.vc-ring .vc-track { stroke: rgba(140, 156, 255, .12); }
|
||||||
|
.vc-ring .vc-val { stroke-linecap: round; transform: rotate(-90deg); transform-origin: 50% 50%; stroke-dasharray: 251.33; stroke-dashoffset: 251.33; transition: stroke-dashoffset 1s cubic-bezier(.2, .8, .2, 1); }
|
||||||
|
.vc-ring .vc-val.is-fast { transition-duration: .4s; }
|
||||||
|
.vc-val.is-ok { stroke: var(--vc-ok); } .vc-val.is-sky { stroke: var(--vc-sky); } .vc-val.is-orange { stroke: var(--vc-orange); }
|
||||||
|
.vc-spark { display: block; width: 100%; height: 46px; margin-top: 6px; }
|
||||||
|
.vc-mini { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 2px; align-items: end; height: 40px; margin-top: 8px; }
|
||||||
|
.vc-mini i { display: block; background: var(--vc-navy-hi); border-radius: 2px 2px 0 0; min-height: 2px; }
|
||||||
|
.vc-mini i.is-hot { background: var(--vc-bad); }
|
||||||
|
|
||||||
|
/* Segmented control */
|
||||||
|
.vc-seg { display: inline-flex; background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 99px; padding: 3px; }
|
||||||
|
.vc-seg button, .vc-seg a { border: 0; background: transparent; color: var(--vc-muted); font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 99px; cursor: pointer; text-decoration: none; line-height: 1.4; }
|
||||||
|
.vc-seg button:hover, .vc-seg a:hover { color: var(--vc-text); text-decoration: none; }
|
||||||
|
.vc-seg [aria-pressed="true"] { background: var(--vc-navy); color: #fff; }
|
||||||
|
.vitec-cockpit :is(button, a, textarea, summary):focus-visible { outline: 2px solid var(--vc-orange); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.vc-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--vc-muted); }
|
||||||
|
.vc-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.vc-legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||||
|
.vc-legend i.is-navy { background: var(--vc-navy-hi); } .vc-legend i.is-sky { background: var(--vc-sky); }
|
||||||
|
.vc-legend i.is-warn { background: var(--vc-warn); } .vc-legend i.is-orange { background: var(--vc-orange); }
|
||||||
|
.vc-legend i.is-bad { background: var(--vc-bad); } .vc-legend i.is-ok { background: var(--vc-ok); }
|
||||||
|
.vc-legend i.is-round { border-radius: 50%; }
|
||||||
|
.vc-legend i.is-tri { border-radius: 0; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||||
|
.vc-legend-tl { margin: -6px 0 10px; }
|
||||||
|
.vc-chart-card { display: flex; flex-direction: column; }
|
||||||
|
.vc-chart { position: relative; flex: 1 1 auto; min-height: 320px; margin-top: 12px; overflow: hidden; }
|
||||||
|
.vc-chart canvas { max-width: 100%; }
|
||||||
|
@media (max-width: 619.98px) { .vc-chart { min-height: 260px; } }
|
||||||
|
.vc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--vc-line); }
|
||||||
|
.vc-stats dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||||
|
.vc-stats dd { margin: 2px 0 0; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
@media (max-width: 619.98px) { .vc-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
.vc-empty { color: var(--vc-muted); font-size: 13px; margin: 12px 0 0; }
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.vc-actions { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.vc-act { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 12px; padding: 16px; margin: 0; }
|
||||||
|
.vc-act-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.vc-act-t { font-size: 15px; font-weight: 700; margin: 0; }
|
||||||
|
.vc-help { margin: 8px 0 0; color: var(--vc-muted); font-size: 12.5px; }
|
||||||
|
.vc-warm { display: flex; gap: 16px; align-items: center; margin-top: 12px; }
|
||||||
|
.vc-warm-ring { position: relative; flex: none; width: 92px; height: 92px; }
|
||||||
|
.vc-warm-pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.vc-warm-meta { font-size: 12.5px; color: var(--vc-muted); min-width: 0; }
|
||||||
|
.vc-warm-meta b { color: var(--vc-text); font-variant-numeric: tabular-nums; }
|
||||||
|
.vc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.vc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; border: 1px solid transparent; font-size: 13px; font-weight: 700; line-height: 1.2; padding: 9px 14px; cursor: pointer; transition: transform .1s, filter .15s, background-color .15s, border-color .15s; }
|
||||||
|
.vc-btn:active { transform: scale(.98); }
|
||||||
|
.vc-btn.is-primary { background: var(--vc-orange); color: #1b0b02; }
|
||||||
|
.vc-btn.is-primary:hover { filter: brightness(1.08); }
|
||||||
|
.vc-btn.is-primary[disabled] { background: rgba(244, 121, 55, .3); color: rgba(255, 255, 255, .65); cursor: progress; filter: none; }
|
||||||
|
.vc-btn.is-ghost { background: transparent; color: var(--vc-text); border-color: var(--vc-line-strong); }
|
||||||
|
.vc-btn.is-ghost:hover { border-color: var(--vc-navy-hi); background: rgba(74, 95, 214, .14); }
|
||||||
|
.vc-btn.is-danger { background: transparent; color: var(--vc-bad); border-color: rgba(255, 95, 115, .45); }
|
||||||
|
.vc-btn.is-danger:hover { background: rgba(255, 95, 115, .15); }
|
||||||
|
.vc-lbl { display: block; font-size: 12px; color: var(--vc-muted); margin: 12px 0 6px; font-weight: 600; }
|
||||||
|
.vc-input { width: 100%; resize: vertical; min-height: 84px; background: var(--vc-ink); color: var(--vc-text); border: 1px solid var(--vc-line-strong); border-radius: 9px; padding: 10px 12px; font: 12.5px/1.6 var(--vc-mono); }
|
||||||
|
.vc-input::placeholder { color: var(--vc-faint); }
|
||||||
|
.vc-input:focus { border-color: var(--vc-navy-hi); outline: none; }
|
||||||
|
|
||||||
|
/* Purge activity timeline */
|
||||||
|
.vc-timeline { list-style: none; margin: 0; padding: 0; position: relative; }
|
||||||
|
.vc-timeline::before { content: ""; position: absolute; left: 62px; top: 6px; bottom: 6px; width: 1px; background: var(--vc-line-strong); }
|
||||||
|
.vc-ev { display: grid; grid-template-columns: 52px 22px minmax(0, 1fr) auto; gap: 0 10px; align-items: start; padding: 10px 0; }
|
||||||
|
.vc-ev + .vc-ev { border-top: 1px dashed rgba(140, 156, 255, .08); }
|
||||||
|
.vc-ev time { font: 500 12px var(--vc-mono); color: var(--vc-muted); text-align: right; padding-top: 2px; }
|
||||||
|
.vc-dot { width: 11px; height: 11px; border-radius: 50%; margin: 5px auto 0; box-shadow: 0 0 0 4px var(--vc-panel); position: relative; z-index: 1; background: var(--vc-sky); }
|
||||||
|
.vc-dot.is-ok { background: var(--vc-ok); } .vc-dot.is-orange { background: var(--vc-orange); }
|
||||||
|
.vc-dot.is-navy { background: var(--vc-navy-hi); } .vc-dot.is-warn { background: var(--vc-warn); }
|
||||||
|
.vc-ev-main { min-width: 0; }
|
||||||
|
.vc-ev-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
||||||
|
.vc-paths { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
|
||||||
|
.vc-path { font: 12px var(--vc-mono); background: rgba(140, 156, 255, .08); color: #cdd4ff; border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vc-more { font-size: 12px; }
|
||||||
|
.vc-more summary { color: var(--vc-sky); cursor: pointer; list-style: none; padding: 2px 4px; }
|
||||||
|
.vc-more summary::-webkit-details-marker { display: none; }
|
||||||
|
.vc-more[open] { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.vc-more[open] summary { flex-basis: 100%; }
|
||||||
|
.vc-ev-r { text-align: right; font: 500 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||||
|
.vc-day { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); padding: 14px 0 4px 74px; }
|
||||||
|
.vc-day:first-child { padding-top: 0; }
|
||||||
|
@media (max-width: 619.98px) {
|
||||||
|
.vc-timeline::before { left: 46px; }
|
||||||
|
.vc-ev { grid-template-columns: 38px 18px minmax(0, 1fr); }
|
||||||
|
.vc-ev-r { grid-column: 3; flex-direction: row; align-items: center; margin-top: 6px; }
|
||||||
|
.vc-day { padding-left: 58px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client errors */
|
||||||
|
.vc-err-sum { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
|
||||||
|
@media (max-width: 619.98px) { .vc-err-sum { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
.vc-muted-text { color: var(--vc-faint); }
|
||||||
|
.vc-err-pills { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||||
|
.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; }
|
||||||
|
.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
|
||||||
|
.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.vc-errs { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.vc-err { background: var(--vc-panel-2); border: 1px solid var(--vc-line); border-radius: 10px; }
|
||||||
|
.vc-err[open] { border-color: var(--vc-line-strong); }
|
||||||
|
.vc-err summary { list-style: none; cursor: pointer; padding: 12px 14px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; }
|
||||||
|
.vc-err summary::-webkit-details-marker { display: none; }
|
||||||
|
.vc-err-msg { min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vc-err-msg small { display: block; color: var(--vc-muted); font: 11.5px var(--vc-mono); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.vc-err-t { font: 12px var(--vc-mono); color: var(--vc-muted); white-space: nowrap; }
|
||||||
|
.vc-err-body { padding: 0 14px 14px; font-size: 12.5px; }
|
||||||
|
.vc-kv { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 6px 12px; margin: 0; }
|
||||||
|
.vc-kv dt { color: var(--vc-faint); font-weight: 400; }
|
||||||
|
.vc-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.vc-stack { margin: 10px 0 0; background: var(--vc-ink); border: 0; border-radius: 8px; padding: 10px 12px; font: 11.5px/1.55 var(--vc-mono); color: #b9c1ef; overflow: auto; max-height: 320px; white-space: pre; }
|
||||||
|
|
||||||
|
/* Info button with tooltip, top right in every card (pure CSS: hover
|
||||||
|
or keyboard focus shows the text). */
|
||||||
|
.vc-card { position: relative; }
|
||||||
|
.vc-card-h { padding-right: 34px; }
|
||||||
|
.vc-hero-top { padding-right: 34px; }
|
||||||
|
/* Button and tooltip are placed against the card, so the tooltip never
|
||||||
|
gets wider than its card or leaves the module at the left edge. */
|
||||||
|
.vc-info-btn {
|
||||||
|
position: absolute; top: 14px; right: 14px; z-index: 21;
|
||||||
|
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
|
||||||
|
border-radius: 50%; border: 1px solid var(--vc-line-strong);
|
||||||
|
background: rgba(140, 156, 255, .1); color: var(--vc-muted);
|
||||||
|
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
|
||||||
|
transition: color .15s, border-color .15s, background-color .15s;
|
||||||
|
}
|
||||||
|
.vc-info:hover .vc-info-btn, .vc-info:focus-within .vc-info-btn {
|
||||||
|
color: #fff; border-color: var(--vc-orange); background: rgba(244, 121, 55, .22);
|
||||||
|
}
|
||||||
|
.vc-tip {
|
||||||
|
position: absolute; top: 48px; right: 8px; z-index: 20; width: min(320px, calc(100% - 16px));
|
||||||
|
padding: 12px 14px; border-radius: 10px;
|
||||||
|
background: #1a2460; border: 1px solid var(--vc-navy-hi);
|
||||||
|
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
|
||||||
|
color: var(--vc-text); font-size: 12.5px; font-weight: 400; line-height: 1.55;
|
||||||
|
letter-spacing: 0; text-transform: none; text-align: left; white-space: normal;
|
||||||
|
opacity: 0; visibility: hidden; transform: translateY(-4px);
|
||||||
|
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
|
||||||
|
}
|
||||||
|
.vc-tip::before {
|
||||||
|
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
|
||||||
|
background: #1a2460; border-left: 1px solid var(--vc-navy-hi); border-top: 1px solid var(--vc-navy-hi);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.vc-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
|
||||||
|
.vc-info:hover .vc-tip, .vc-info:focus-within .vc-tip {
|
||||||
|
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vitec-cockpit *, .vitec-cockpit *::after { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/* VITEC SSR dashboard tiles in the look of the "VITEC SSR" cockpit module
|
||||||
|
(dark VITEC navy, orange accents; decision Harry, 2026-11-11).
|
||||||
|
Loaded per widget via AdditionalCssInterface; markup from
|
||||||
|
Classes/Widgets/CockpitHtml.php. Only touches widgets that contain
|
||||||
|
.vc-widget, so the rest of the dashboard keeps the core look. */
|
||||||
|
|
||||||
|
.widget:has(.vc-widget) {
|
||||||
|
--widget-bg: #0f1640;
|
||||||
|
--widget-color: #e9ecfb;
|
||||||
|
--widget-border-color: rgba(140, 156, 255, .18);
|
||||||
|
--widget-title-bg: #121b4c;
|
||||||
|
--widget-title-color: #e9ecfb;
|
||||||
|
--widget-box-shadow: 0 6px 24px rgba(8, 12, 38, .25);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-widget {
|
||||||
|
--vcw-panel-2: #151e52;
|
||||||
|
--vcw-line: rgba(140, 156, 255, .14);
|
||||||
|
--vcw-navy: #4a5fd6;
|
||||||
|
--vcw-orange: #f47937;
|
||||||
|
--vcw-text: #e9ecfb;
|
||||||
|
--vcw-muted: #8e97c6;
|
||||||
|
--vcw-faint: #6a73a6;
|
||||||
|
--vcw-ok: #35d39a;
|
||||||
|
--vcw-warn: #ffc65c;
|
||||||
|
--vcw-bad: #ff5f73;
|
||||||
|
--vcw-sky: #6fc3ff;
|
||||||
|
--vcw-mono: var(--typo3-font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
/* Widgets based on WidgetInterface get no inner padding from the core. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px 18px;
|
||||||
|
background: radial-gradient(120% 90% at 0% 0%, rgba(74, 95, 214, .22), transparent 60%), #0f1640;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--vcw-text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.vc-widget code { font-family: var(--vcw-mono); font-size: 12px; color: #cdd4ff; background: none; padding: 0; }
|
||||||
|
|
||||||
|
.vcw-ok { color: var(--vcw-ok); } .vcw-warn { color: var(--vcw-warn); } .vcw-bad { color: var(--vcw-bad); }
|
||||||
|
.vcw-dim { color: var(--vcw-muted); font-size: 12px; margin: 0; }
|
||||||
|
.vcw-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-muted); }
|
||||||
|
.vcw-sub { font-size: 12px; color: var(--vcw-muted); }
|
||||||
|
.vcw-right { text-align: right; }
|
||||||
|
.vcw-num { font-size: 26px; font-weight: 800; line-height: 1.1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.vcw-num small, .vcw-val small { font-size: 12px; font-weight: 600; color: var(--vcw-muted); margin-left: 3px; letter-spacing: 0; }
|
||||||
|
|
||||||
|
/* Head with pulse */
|
||||||
|
.vcw-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.vcw-title { font-size: 22px; font-weight: 800; line-height: 1.15; }
|
||||||
|
.vcw-pulse { position: relative; flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--vcw-ok); color: var(--vcw-ok); }
|
||||||
|
.vcw-pulse::after { content: ""; position: absolute; inset: -5px; border-radius: 50%; border: 2px solid currentColor; opacity: 0; animation: vcw-ping 2.4s ease-out infinite; }
|
||||||
|
.vcw-pulse.is-warn { background: var(--vcw-warn); color: var(--vcw-warn); }
|
||||||
|
.vcw-pulse.is-down { background: var(--vcw-bad); color: var(--vcw-bad); }
|
||||||
|
@keyframes vcw-ping { 0% { transform: scale(.5); opacity: .8; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||||
|
|
||||||
|
/* Ring gauge */
|
||||||
|
.vcw-ring { position: relative; flex: none; }
|
||||||
|
.vcw-ring svg { display: block; transform: rotate(-90deg); }
|
||||||
|
.vcw-ring circle { fill: none; stroke-width: 9; }
|
||||||
|
.vcw-track { stroke: rgba(140, 156, 255, .12); }
|
||||||
|
.vcw-val { stroke-linecap: round; }
|
||||||
|
circle.vcw-val.is-ok { stroke: var(--vcw-ok); } circle.vcw-val.is-sky { stroke: var(--vcw-sky); } circle.vcw-val.is-orange { stroke: var(--vcw-orange); }
|
||||||
|
.vcw-ring-c { position: absolute; inset: 0; display: grid; place-items: center; font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.vcw-gauge { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.vcw-grow { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.vcw-split { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.vcw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px 16px; margin: 0; }
|
||||||
|
.vcw-facts > div { min-width: 0; }
|
||||||
|
.vcw-facts dt { font-size: 10.5px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--vcw-faint); }
|
||||||
|
.vcw-facts dd { margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Pills */
|
||||||
|
.vcw-pill { display: inline-flex; align-items: center; font-size: 11px; font-weight: 600; line-height: 1; letter-spacing: .03em; padding: 4px 8px; border-radius: 99px; white-space: nowrap; background: rgba(140, 156, 255, .12); color: #c4cbf0; }
|
||||||
|
.vcw-pill.is-ok { background: rgba(53, 211, 154, .14); color: var(--vcw-ok); }
|
||||||
|
.vcw-pill.is-warn { background: rgba(255, 198, 92, .15); color: var(--vcw-warn); }
|
||||||
|
.vcw-pill.is-bad { background: rgba(255, 95, 115, .15); color: var(--vcw-bad); }
|
||||||
|
.vcw-pill.is-sky { background: rgba(111, 195, 255, .14); color: var(--vcw-sky); }
|
||||||
|
.vcw-pill.is-navy { background: rgba(74, 95, 214, .24); color: #b8c3ff; }
|
||||||
|
.vcw-pill.is-orange { background: rgba(244, 121, 55, .16); color: var(--vcw-orange); }
|
||||||
|
.vcw-pill.is-muted { color: var(--vcw-muted); }
|
||||||
|
|
||||||
|
/* Last purge line */
|
||||||
|
.vcw-last { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 10px 12px; border-radius: 10px; background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); }
|
||||||
|
.vcw-last .vcw-label { margin-right: 4px; }
|
||||||
|
.vcw-time { font: 500 12px var(--vcw-mono); color: var(--vcw-muted); }
|
||||||
|
.vcw-path { flex-basis: 100%; font: 12px var(--vcw-mono); color: #cdd4ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Availability strip */
|
||||||
|
.vcw-strip-h { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; }
|
||||||
|
.vcw-bars { display: grid; grid-template-columns: repeat(96, minmax(0, 1fr)); gap: 1px; height: 28px; align-items: end; margin-top: -4px; }
|
||||||
|
.vcw-bars i { display: block; height: 100%; border-radius: 1px; background: var(--vcw-ok); opacity: .85; }
|
||||||
|
.vcw-bars i.is-slow { background: var(--vcw-warn); }
|
||||||
|
.vcw-bars i.is-down { background: var(--vcw-bad); }
|
||||||
|
.vcw-bars i.is-none { background: rgba(140, 156, 255, .14); height: 40%; }
|
||||||
|
.vcw-bars i:hover { opacity: 1; }
|
||||||
|
.vcw-axis { display: flex; justify-content: space-between; font: 10.5px var(--vcw-mono); color: var(--vcw-faint); margin-top: -8px; }
|
||||||
|
|
||||||
|
/* Number tiles */
|
||||||
|
.vcw-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }
|
||||||
|
.vcw-tile { background: var(--vcw-panel-2); border: 1px solid var(--vcw-line); border-radius: 10px; padding: 9px 11px; min-width: 0; }
|
||||||
|
.vcw-tile .vcw-label { font-size: 10px; }
|
||||||
|
.vcw-val { font-size: 20px; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.vcw-tile .vcw-sub { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Hit rate split bar */
|
||||||
|
.vcw-split-bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: rgba(140, 156, 255, .12); margin-top: 8px; }
|
||||||
|
.vcw-split-bar i { display: block; height: 100%; }
|
||||||
|
.vcw-split-bar i.is-navy, .vcw-legend i.is-navy { background: var(--vcw-navy); }
|
||||||
|
.vcw-split-bar i.is-sky, .vcw-legend i.is-sky { background: var(--vcw-sky); }
|
||||||
|
.vcw-split-bar i.is-warn, .vcw-legend i.is-warn { background: var(--vcw-warn); }
|
||||||
|
.vcw-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px; font-size: 11px; color: var(--vcw-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.vcw-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.vcw-legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
||||||
|
.vcw-note { margin: 0; font-size: 11px; color: var(--vcw-faint); }
|
||||||
|
|
||||||
|
.vcw-foot { margin-top: auto; padding-top: 2px; }
|
||||||
|
.vcw-link { color: var(--vcw-orange); font-weight: 700; font-size: 12.5px; text-decoration: none; }
|
||||||
|
.vcw-link:hover, .vcw-link:focus { color: var(--vcw-orange); text-decoration: underline; }
|
||||||
|
.vc-widget a:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Info button with tooltip, top right (pure CSS: hover or keyboard focus). */
|
||||||
|
.vc-widget { position: relative; }
|
||||||
|
.vc-widget > .vcw-info + * { padding-right: 32px; }
|
||||||
|
.vcw-info-btn {
|
||||||
|
position: absolute; top: 12px; right: 14px; z-index: 6;
|
||||||
|
display: grid; place-items: center; width: 24px; height: 24px; padding: 0;
|
||||||
|
border-radius: 50%; border: 1px solid rgba(140, 156, 255, .28);
|
||||||
|
background: rgba(140, 156, 255, .1); color: var(--vcw-muted);
|
||||||
|
font-size: 13px; font-weight: 800; line-height: 1; cursor: help;
|
||||||
|
transition: color .15s, border-color .15s, background-color .15s;
|
||||||
|
}
|
||||||
|
.vcw-info:hover .vcw-info-btn, .vcw-info:focus-within .vcw-info-btn {
|
||||||
|
color: #fff; border-color: var(--vcw-orange); background: rgba(244, 121, 55, .22);
|
||||||
|
}
|
||||||
|
.vcw-info-btn:focus-visible { outline: 2px solid var(--vcw-orange); outline-offset: 2px; }
|
||||||
|
.vcw-tip {
|
||||||
|
position: absolute; top: 46px; right: 8px; z-index: 5; width: min(320px, calc(100% - 16px));
|
||||||
|
padding: 12px 14px; border-radius: 10px;
|
||||||
|
background: #1a2460; border: 1px solid var(--vcw-navy);
|
||||||
|
box-shadow: 0 14px 36px rgba(0, 0, 0, .5);
|
||||||
|
color: var(--vcw-text); font-size: 12.5px; font-weight: 400; line-height: 1.55; text-align: left;
|
||||||
|
opacity: 0; visibility: hidden; transform: translateY(-4px);
|
||||||
|
transition: opacity .15s, transform .15s, visibility 0s linear .15s;
|
||||||
|
}
|
||||||
|
.vcw-tip::before {
|
||||||
|
content: ""; position: absolute; top: -6px; right: 13px; width: 10px; height: 10px;
|
||||||
|
background: #1a2460; border-left: 1px solid var(--vcw-navy); border-top: 1px solid var(--vcw-navy);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.vcw-tip::after { content: ""; position: absolute; top: -14px; left: 0; right: 0; height: 14px; }
|
||||||
|
.vcw-info:hover .vcw-tip, .vcw-info:focus-within .vcw-tip {
|
||||||
|
opacity: 1; visibility: visible; transform: none; transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vcw-pulse::after { animation: none; }
|
||||||
|
.vcw-tip, .vcw-info-btn { transition: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* VITEC SSR cockpit (module web_vitecssr).
|
||||||
|
* Draws the gauges, the availability strip, the traffic chart and the
|
||||||
|
* sparkline from the JSON the controller puts on #ssr-cockpit
|
||||||
|
* (SsrController::cockpitData), and keeps the live parts current by
|
||||||
|
* polling the vitec_ssr_health ajax route (GET /__health of the node):
|
||||||
|
* every 2 s while a warm run is active, every 15 s otherwise.
|
||||||
|
* Chart.js is the copy TYPO3 ships with EXT:dashboard.
|
||||||
|
*/
|
||||||
|
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
|
||||||
|
import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js';
|
||||||
|
|
||||||
|
const root = document.getElementById('ssr-cockpit');
|
||||||
|
if (root) {
|
||||||
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(root.dataset.json || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const C = 2 * Math.PI * 40;
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const nf = new Intl.NumberFormat('en-GB');
|
||||||
|
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
|
||||||
|
const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const css = getComputedStyle(root);
|
||||||
|
const tok = (name) => css.getPropertyValue(name).trim();
|
||||||
|
const col = {
|
||||||
|
navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'),
|
||||||
|
orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'),
|
||||||
|
text: tok('--vc-text'), muted: tok('--vc-muted'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRing = (el, frac) => {
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.style.strokeDasharray = String(C);
|
||||||
|
el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac))));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Hit rate gauge */
|
||||||
|
const hit = data.hitRate;
|
||||||
|
if (typeof hit === 'number') {
|
||||||
|
$('vc-hit').textContent = hit.toFixed(1);
|
||||||
|
requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100));
|
||||||
|
if (typeof data.hitRatePrev === 'number') {
|
||||||
|
const d = Math.round((hit - data.hitRatePrev) * 10) / 10;
|
||||||
|
const cls = d >= 0 ? 'is-up' : 'is-down';
|
||||||
|
$('vc-hit-trend').innerHTML = '<span class="' + cls + '">' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
|
||||||
|
+ ' pts</span> vs. the 24 h before';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$('vc-hit-trend').textContent = 'no traffic counters yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Response time */
|
||||||
|
if (data.msMedian !== null && data.msMedian !== undefined) {
|
||||||
|
$('vc-ms-median').textContent = data.msMedian + ' ms';
|
||||||
|
$('vc-ms-p95').textContent = data.msP95 + ' ms';
|
||||||
|
}
|
||||||
|
const drawSpark = () => {
|
||||||
|
const sp = (data.spark || []).map((p) => p[1]);
|
||||||
|
const el = $('vc-spark');
|
||||||
|
if (!el || sp.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = 300;
|
||||||
|
const h = 46;
|
||||||
|
const max = Math.max(...sp) * 1.08;
|
||||||
|
const min = Math.min(...sp) * 0.85;
|
||||||
|
const span = Math.max(1, max - min);
|
||||||
|
const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]);
|
||||||
|
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
el.innerHTML = '<defs><linearGradient id="vc-sg" x1="0" y1="0" x2="0" y2="1">'
|
||||||
|
+ '<stop offset="0" stop-color="' + col.orange + '" stop-opacity=".45"/>'
|
||||||
|
+ '<stop offset="1" stop-color="' + col.orange + '" stop-opacity="0"/></linearGradient></defs>'
|
||||||
|
+ '<path d="' + d + ' L' + w + ' ' + h + ' L0 ' + h + 'Z" fill="url(#vc-sg)"/>'
|
||||||
|
+ '<path d="' + d + '" fill="none" stroke="' + col.orange + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
|
||||||
|
+ '<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="3.5" fill="' + col.orange
|
||||||
|
+ '" stroke="' + col.panel + '" stroke-width="2"/>';
|
||||||
|
};
|
||||||
|
drawSpark();
|
||||||
|
|
||||||
|
/* Cache fill */
|
||||||
|
const renderCache = (pages, mb) => {
|
||||||
|
const limit = num(data.cacheLimitMb);
|
||||||
|
if (typeof pages === 'number') {
|
||||||
|
$('vc-cache-pages').textContent = nf.format(pages);
|
||||||
|
}
|
||||||
|
if (typeof mb === 'number') {
|
||||||
|
$('vc-cache-mb').textContent = nf.format(mb);
|
||||||
|
if (limit > 0) {
|
||||||
|
setRing($('vc-ring-cache'), mb / limit);
|
||||||
|
$('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb));
|
||||||
|
|
||||||
|
/* Client errors per hour */
|
||||||
|
const mini = $('vc-mini');
|
||||||
|
const eh = data.errHours || [];
|
||||||
|
const ehMax = Math.max(1, ...eh);
|
||||||
|
eh.forEach((v, i) => {
|
||||||
|
const b = document.createElement('i');
|
||||||
|
b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%';
|
||||||
|
if (v > 0 && v >= ehMax && v >= 3) {
|
||||||
|
b.className = 'is-hot';
|
||||||
|
}
|
||||||
|
b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's');
|
||||||
|
mini.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Availability strip */
|
||||||
|
const strip = data.strip || { w: [], end: data.now, slot: 900 };
|
||||||
|
const bars = $('vc-bars');
|
||||||
|
const stripStart = strip.end - 96 * strip.slot;
|
||||||
|
(strip.w || []).forEach((w, i) => {
|
||||||
|
const b = document.createElement('i');
|
||||||
|
const t = stripStart + i * strip.slot;
|
||||||
|
let label = 'no sample';
|
||||||
|
if (w === null) {
|
||||||
|
b.className = 'is-none';
|
||||||
|
} else if (w.s === 2) {
|
||||||
|
b.className = 'is-down';
|
||||||
|
label = 'down';
|
||||||
|
} else if (w.s === 1) {
|
||||||
|
b.className = 'is-slow';
|
||||||
|
label = 'slow, ' + w.ms + ' ms';
|
||||||
|
} else {
|
||||||
|
label = 'ok, ' + w.ms + ' ms';
|
||||||
|
b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%';
|
||||||
|
}
|
||||||
|
b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label;
|
||||||
|
bars.appendChild(b);
|
||||||
|
});
|
||||||
|
const axis = $('vc-uptime-axis');
|
||||||
|
[0, 24, 48, 72, 96].forEach((i, k) => {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot);
|
||||||
|
axis.appendChild(s);
|
||||||
|
});
|
||||||
|
if (typeof data.uptimePct === 'number') {
|
||||||
|
const slow = num(data.slowWindows);
|
||||||
|
const down = num(data.downWindows);
|
||||||
|
$('vc-uptime-sum').innerHTML = '<b>' + data.uptimePct.toFixed(1) + ' %</b> up · ' + slow + ' slow window'
|
||||||
|
+ (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's');
|
||||||
|
} else {
|
||||||
|
$('vc-uptime-sum').textContent = 'no samples yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic chart */
|
||||||
|
let chart = null;
|
||||||
|
const label = (ts, size) => {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm;
|
||||||
|
};
|
||||||
|
const draw = (key) => {
|
||||||
|
const range = (data.range || {})[key];
|
||||||
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = range.b;
|
||||||
|
const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0);
|
||||||
|
const h = sum('hit');
|
||||||
|
const st = sum('stale');
|
||||||
|
const m = sum('miss');
|
||||||
|
const total = h + st + m;
|
||||||
|
$('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–';
|
||||||
|
$('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–';
|
||||||
|
$('vc-st-err').textContent = nf.format(sum('errors'));
|
||||||
|
$('vc-st-purge').textContent = nf.format(sum('purges'));
|
||||||
|
const hasMs = b.some((x) => x.ms !== null);
|
||||||
|
$('vc-traffic-empty').hidden = total > 0 || hasMs;
|
||||||
|
|
||||||
|
const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss)));
|
||||||
|
const maxMs = Math.max(100, ...b.map((x) => num(x.ms)));
|
||||||
|
const tick = { color: col.faint, font: { size: 11 } };
|
||||||
|
const cfg = {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: b.map((x) => label(x.t, range.size)),
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 },
|
||||||
|
{ label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 },
|
||||||
|
{ label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 },
|
||||||
|
{
|
||||||
|
type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2,
|
||||||
|
pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false,
|
||||||
|
pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel,
|
||||||
|
yAxisID: 'y', order: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 600 },
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1,
|
||||||
|
titleColor: col.text, bodyColor: '#c4cbf0', padding: 10,
|
||||||
|
filter: (it) => it.raw !== null,
|
||||||
|
callbacks: {
|
||||||
|
label: (c) => {
|
||||||
|
if (c.dataset.label === 'Purge') {
|
||||||
|
const x = b[c.dataIndex];
|
||||||
|
return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : '');
|
||||||
|
}
|
||||||
|
if (c.dataset.label === 'Response time') {
|
||||||
|
return ' Response time: ' + c.raw + ' ms';
|
||||||
|
}
|
||||||
|
return ' ' + c.dataset.label + ': ' + nf.format(c.raw);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } },
|
||||||
|
y: {
|
||||||
|
stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick,
|
||||||
|
border: { display: false }, title: { display: true, text: 'requests', color: col.faint },
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false },
|
||||||
|
ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy();
|
||||||
|
}
|
||||||
|
chart = new Chart($('vc-traffic'), cfg);
|
||||||
|
};
|
||||||
|
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
|
||||||
|
// Draw once the stylesheet has laid out the grid, and follow the box
|
||||||
|
// afterwards: Chart.js alone kept the width it saw on first paint.
|
||||||
|
const box = $('vc-traffic').parentElement;
|
||||||
|
const start = () => {
|
||||||
|
draw('24h');
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
let size = box.clientWidth + 'x' + box.clientHeight;
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
const now = box.clientWidth + 'x' + box.clientHeight;
|
||||||
|
if (chart && now !== size) {
|
||||||
|
size = now;
|
||||||
|
chart.resize();
|
||||||
|
}
|
||||||
|
}).observe(box);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
requestAnimationFrame(start);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', () => requestAnimationFrame(start), { once: true });
|
||||||
|
}
|
||||||
|
root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false'));
|
||||||
|
draw(btn.dataset.range);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Warm cache + live status */
|
||||||
|
const warmBtn = $('ssr-warm-button');
|
||||||
|
const warmLabel = $('vc-warm-label');
|
||||||
|
const warmPct = $('vc-warm-pct');
|
||||||
|
const warmMeta = $('vc-warm-meta');
|
||||||
|
const warmPill = $('vc-warm-pill');
|
||||||
|
const warmRing = $('vc-ring-warm');
|
||||||
|
const pill = (cls, text) => {
|
||||||
|
warmPill.hidden = !text;
|
||||||
|
warmPill.className = 'vc-pill ' + cls;
|
||||||
|
warmPill.textContent = text || '';
|
||||||
|
};
|
||||||
|
const renderWarm = (w) => {
|
||||||
|
w = w || {};
|
||||||
|
const queued = num(w.queued);
|
||||||
|
const handled = num(w.done) + num(w.skipped) + num(w.failed);
|
||||||
|
const failed = num(w.failed);
|
||||||
|
const failedHtml = failed ? '<span class="vc-bad-text">' + failed + ' failed</span>' : '0 failed';
|
||||||
|
if (w.running) {
|
||||||
|
const f = queued > 0 ? handled / queued : 0;
|
||||||
|
setRing(warmRing, f);
|
||||||
|
warmPct.textContent = Math.round(f * 100) + ' %';
|
||||||
|
warmMeta.innerHTML = '<b>' + nf.format(handled) + '</b> of ' + nf.format(queued) + ' pages<br>'
|
||||||
|
+ num(w.skipped) + ' still fresh · ' + failedHtml + '<br>live, every 2 seconds';
|
||||||
|
pill('is-sky', 'running');
|
||||||
|
} else if (w.startedAt) {
|
||||||
|
setRing(warmRing, queued > 0 ? handled / queued : 1);
|
||||||
|
warmPct.textContent = failed || w.aborted ? '!' : '✓';
|
||||||
|
const started = new Date(w.startedAt);
|
||||||
|
const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null;
|
||||||
|
const took = secs === null ? '' : '<br>took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s');
|
||||||
|
warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
|
||||||
|
+ '<br><b>' + nf.format(num(w.done)) + '</b> of ' + nf.format(queued) + ' rendered · '
|
||||||
|
+ num(w.skipped) + ' still fresh · ' + failedHtml + took;
|
||||||
|
pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done'));
|
||||||
|
} else {
|
||||||
|
setRing(warmRing, 0);
|
||||||
|
warmPct.textContent = '–';
|
||||||
|
warmMeta.textContent = 'No run since the server started.';
|
||||||
|
pill('', '');
|
||||||
|
}
|
||||||
|
if (warmBtn) {
|
||||||
|
warmBtn.disabled = !!w.running;
|
||||||
|
warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache';
|
||||||
|
}
|
||||||
|
return !!w.running;
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => renderWarm(data.warming));
|
||||||
|
|
||||||
|
const renderOnline = (online) => {
|
||||||
|
const p = $('vc-pulse');
|
||||||
|
const t = $('vc-live-title');
|
||||||
|
if (!p || !t) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p.classList.toggle('is-down', !online);
|
||||||
|
t.innerHTML = online ? 'SSR <span class="vc-ok-text">online</span>' : 'SSR <span class="vc-bad-text">not reachable</span>';
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
const schedule = (ms) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(poll, ms);
|
||||||
|
};
|
||||||
|
const poll = () => {
|
||||||
|
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get()
|
||||||
|
.then((response) => response.resolve())
|
||||||
|
.then((res) => {
|
||||||
|
const h = res && res.health;
|
||||||
|
renderOnline(!!h);
|
||||||
|
let running = false;
|
||||||
|
if (h) {
|
||||||
|
running = renderWarm(h.warming);
|
||||||
|
renderCache(h.cachedPages, h.cacheMB);
|
||||||
|
if (typeof h.responseMs === 'number') {
|
||||||
|
$('vc-ms').textContent = h.responseMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schedule(running ? 2000 : 15000);
|
||||||
|
})
|
||||||
|
.catch(() => schedule(15000));
|
||||||
|
};
|
||||||
|
schedule(data.warming && data.warming.running ? 1000 : 15000);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Context menu action "Purge from SSR cache" (SsrPurgeItemProvider).
|
||||||
|
* Posts the page uid to the vitec_ssr_purge_page ajax route and shows
|
||||||
|
* the outcome as a backend notification.
|
||||||
|
*/
|
||||||
|
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
|
||||||
|
import Notification from '@typo3/backend/notification.js';
|
||||||
|
|
||||||
|
class SsrContextMenuActions {
|
||||||
|
ssrPurge(table, uid) {
|
||||||
|
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_purge_page)
|
||||||
|
.post({ pageUid: uid })
|
||||||
|
.then(async (response) => {
|
||||||
|
const data = await response.resolve();
|
||||||
|
if (data.success) {
|
||||||
|
Notification.success('SSR cache purged', data.path + ' (' + data.message + ')', 5);
|
||||||
|
} else {
|
||||||
|
Notification.error('SSR purge failed', data.message || 'Unknown error', 8);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Notification.error('SSR purge failed', 'Request error - see purge protocol in the VITEC SSR module', 8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SsrContextMenuActions();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* VITEC SSR module: TYPO3 modal confirmation for the destructive / heavy
|
||||||
|
* actions (Purge everything, Warm cache) instead of window.confirm.
|
||||||
|
* A form opts in with data-confirm="text" (+ data-confirm-title,
|
||||||
|
* data-confirm-ok, data-confirm-severity="danger|primary").
|
||||||
|
*/
|
||||||
|
import Modal from '@typo3/backend/modal.js';
|
||||||
|
import Severity from '@typo3/backend/severity.js';
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-confirm]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
if (form.dataset.confirmed === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const danger = form.dataset.confirmSeverity === 'danger';
|
||||||
|
Modal.confirm(
|
||||||
|
form.dataset.confirmTitle || 'Please confirm',
|
||||||
|
form.dataset.confirm,
|
||||||
|
danger ? Severity.error : Severity.warning,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
btnClass: 'btn-default',
|
||||||
|
name: 'cancel',
|
||||||
|
trigger: (e, modal) => modal.hideModal(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: form.dataset.confirmOk || 'Continue',
|
||||||
|
btnClass: danger ? 'btn-danger' : 'btn-primary',
|
||||||
|
name: 'ok',
|
||||||
|
active: true,
|
||||||
|
trigger: (e, modal) => {
|
||||||
|
modal.hideModal();
|
||||||
|
form.dataset.confirmed = '1';
|
||||||
|
form.requestSubmit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,14 @@ $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['proc
|
|||||||
|
|
||||||
// XClass BackendLayoutView so the page module always reflects pages.layout
|
// XClass BackendLayoutView so the page module always reflects pages.layout
|
||||||
// without the editor having to set backend_layout manually.
|
// without the editor having to set backend_layout manually.
|
||||||
|
// Content-block images as processed, responsive WebP instead of the raw
|
||||||
|
// upload (Lighthouse 2026-11-10: 4-13 MB hero originals). The headless
|
||||||
|
// content-blocks package instantiates its serializer via makeInstance, so
|
||||||
|
// XCLASS is the supported hook. See Classes/Headless/ProcessedFileReferenceToArray.
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\Netzbewegung\NbHeadlessContentBlocks\DataProcessing\ToArray\FileReferenceToArray::class] = [
|
||||||
|
'className' => \Evomedien\Vitec\Headless\ProcessedFileReferenceToArray::class,
|
||||||
|
];
|
||||||
|
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Backend\View\BackendLayoutView::class] = [
|
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Backend\View\BackendLayoutView::class] = [
|
||||||
'className' => \Evomedien\Vitec\View\VitecBackendLayoutView::class,
|
'className' => \Evomedien\Vitec\View\VitecBackendLayoutView::class,
|
||||||
];
|
];
|
||||||
@@ -260,3 +268,14 @@ $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
|
|||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
||||||
|
// Product content audit (/products/product-check): ?check=<product uid>
|
||||||
|
// switches the vitec_productcheck element to the detail payload.
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'check';
|
||||||
|
|
||||||
|
// SSR auto-purge: editors' changes evict the matching pages from the SSR
|
||||||
|
// node's full-page cache (queued per request, one call per save/import).
|
||||||
|
// Active only while the SSR flag is ON and vitec.ssr.autoPurge is enabled.
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['vitec_ssrpurge']
|
||||||
|
= \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class;
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['vitec_ssrpurge']
|
||||||
|
= \Evomedien\Vitec\Hooks\DataHandlerSsrPurge::class;
|
||||||
@@ -460,3 +460,65 @@ CREATE TABLE tx_vitec_domain_model_searchcard (
|
|||||||
link varchar(1024) DEFAULT '' NOT NULL,
|
link varchar(1024) DEFAULT '' NOT NULL,
|
||||||
link_label varchar(80) DEFAULT '' NOT NULL
|
link_label varchar(80) DEFAULT '' NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#
|
||||||
|
# SSR purge protocol (auto-purge webhook + backend module, 2026-11-09)
|
||||||
|
#
|
||||||
|
CREATE TABLE tx_vitec_ssrpurgelog (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
trigger_source varchar(100) DEFAULT '' NOT NULL,
|
||||||
|
paths text,
|
||||||
|
success tinyint(1) DEFAULT '0' NOT NULL,
|
||||||
|
http_code int(11) DEFAULT '0' NOT NULL,
|
||||||
|
message varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
duration_ms int(11) DEFAULT '0' NOT NULL,
|
||||||
|
PRIMARY KEY (uid),
|
||||||
|
KEY crdate (crdate)
|
||||||
|
);
|
||||||
|
|
||||||
|
#
|
||||||
|
# SSR health history (vitec:ssr-health via scheduler, dashboard widgets)
|
||||||
|
#
|
||||||
|
CREATE TABLE tx_vitec_ssrhealthlog (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
ok tinyint(1) DEFAULT '0' NOT NULL,
|
||||||
|
release_id varchar(100) DEFAULT '' NOT NULL,
|
||||||
|
cached_pages int(11) DEFAULT '0' NOT NULL,
|
||||||
|
cache_mb int(11) DEFAULT '0' NOT NULL,
|
||||||
|
response_ms int(11) DEFAULT '0' NOT NULL,
|
||||||
|
stat_hit int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
stat_miss int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
stat_stale int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
stat_bypass int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
stat_render_errors int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
stat_backend_errors int(11) DEFAULT '-1' NOT NULL,
|
||||||
|
PRIMARY KEY (uid),
|
||||||
|
KEY crdate (crdate)
|
||||||
|
);
|
||||||
|
|
||||||
|
#
|
||||||
|
# Client error reports from the React frontend
|
||||||
|
# (POST /api/vitec/client-error, ClientErrorMiddleware; 30-day retention
|
||||||
|
# via vitec:client-error-cleanup; shown in the VITEC SSR module)
|
||||||
|
#
|
||||||
|
CREATE TABLE tx_vitec_clienterrorlog (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
url varchar(1024) DEFAULT '' NOT NULL,
|
||||||
|
message text,
|
||||||
|
stack text,
|
||||||
|
release varchar(100) DEFAULT '' NOT NULL,
|
||||||
|
user_agent varchar(500) DEFAULT '' NOT NULL,
|
||||||
|
ip varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
PRIMARY KEY (uid),
|
||||||
|
KEY crdate (crdate)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tx_news_domain_model_news (
|
||||||
|
vitec_canonical_usecase int(11) unsigned DEFAULT '0' NOT NULL
|
||||||
|
);
|
||||||
+67
-4
@@ -106,6 +106,19 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Begin: Immutable processed images (SEO/Lighthouse 2026-11-10) ###
|
||||||
|
|
||||||
|
# Files below fileadmin/_processed_/ carry a content hash in their name and
|
||||||
|
# never change in place - a year of immutable caching is safe and removes the
|
||||||
|
# "uses-long-cache-ttl" findings (mod_expires below sets only one month).
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
<If "%{REQUEST_URI} =~ m#^/fileadmin/_processed_/#">
|
||||||
|
Header set Cache-Control "max-age=31536000, immutable"
|
||||||
|
</If>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
### End: Immutable processed images ###
|
||||||
|
|
||||||
### Begin: Browser caching of resource files ###
|
### Begin: Browser caching of resource files ###
|
||||||
|
|
||||||
# This affects Frontend and Backend and increases performance.
|
# This affects Frontend and Backend and increases performance.
|
||||||
@@ -289,6 +302,12 @@ AddDefaultCharset utf-8
|
|||||||
RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$
|
RewriteCond $0#%{REQUEST_URI} ([^#]*)#(.*)\1$
|
||||||
RewriteRule ^.*$ - [E=CWD:%2]
|
RewriteRule ^.*$ - [E=CWD:%2]
|
||||||
|
|
||||||
|
# robots.txt per host: the Sitemap directive must be an absolute URL on the
|
||||||
|
# crawled host, so dev serves its own file (Seobility found "0 sitemaps"
|
||||||
|
# because robots.txt pointed at www.vitec.com). SEO audit 2026-11-10.
|
||||||
|
RewriteCond %{HTTP_HOST} ^dev\.vitec\.com$ [NC]
|
||||||
|
RewriteRule ^robots\.txt$ robots-dev.txt [L]
|
||||||
|
|
||||||
# Rules to set ApplicationContext based on hostname
|
# Rules to set ApplicationContext based on hostname
|
||||||
#RewriteCond %{HTTP_HOST} ^dev\.example\.com$
|
#RewriteCond %{HTTP_HOST} ^dev\.example\.com$
|
||||||
#RewriteRule .? - [E=TYPO3_CONTEXT:Development]
|
#RewriteRule .? - [E=TYPO3_CONTEXT:Development]
|
||||||
@@ -355,7 +374,19 @@ AddDefaultCharset utf-8
|
|||||||
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
|
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
|
||||||
# Emergency off without TYPO3: delete the flag file.
|
# Emergency off without TYPO3: delete the flag file.
|
||||||
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||||
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L]
|
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L,E=SSRPROXY:1]
|
||||||
|
# mod_dir turns "/" into "/index.html" via DirectoryIndex subrequest
|
||||||
|
# before the rule above can proxy it (found 2026-11-09 evening: the
|
||||||
|
# SSR node logged GET /index.html -> /_json/index.html -> 404).
|
||||||
|
# Catch that spelling and send it to the node as the homepage:
|
||||||
|
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||||
|
RewriteRule ^index\.html$ https://vitec-ssr.evomedien.de/ [P,L,E=SSRPROXY:1]
|
||||||
|
# Flag ON: the hashed JS/CSS files come from the VPS release too
|
||||||
|
# (0054-FE 5.1 / 0055-BE question 1). No !-f guard on purpose: a
|
||||||
|
# same-named file from an older local SPA build must not win, the
|
||||||
|
# SSR HTML references the hashes of the VPS release.
|
||||||
|
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||||
|
RewriteRule ^_frontend/assets/(.*)$ https://vitec-ssr.evomedien.de/_frontend/assets/$1 [P,L,E=SSRPROXY:1]
|
||||||
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
@@ -366,7 +397,12 @@ AddDefaultCharset utf-8
|
|||||||
RewriteCond %{REQUEST_URI} !^/api/
|
RewriteCond %{REQUEST_URI} !^/api/
|
||||||
RewriteCond %{REQUEST_URI} !^/_frontend/
|
RewriteCond %{REQUEST_URI} !^/_frontend/
|
||||||
RewriteCond %{REQUEST_URI} !^/sitemap
|
RewriteCond %{REQUEST_URI} !^/sitemap
|
||||||
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L]
|
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L,E=SSRPROXY:1]
|
||||||
|
# The SSR node derives absolute URLs from X-Forwarded-Host/-Proto;
|
||||||
|
# mod_proxy sends -Host on its own, -Proto needs a hand (0054-FE 5.2):
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
RequestHeader set X-Forwarded-Proto https env=SSRPROXY
|
||||||
|
</IfModule>
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
|
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
|
||||||
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
|
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
|
||||||
@@ -439,9 +475,36 @@ Options -MultiViews
|
|||||||
Header set X-Content-Type-Options "nosniff"
|
Header set X-Content-Type-Options "nosniff"
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# ETag removal
|
# TYPO3 cache-tag/debug headers: internal ids, and on news pages >16 KB,
|
||||||
|
# which overflowed the SSR node's default header limit (0054-FE 5.4) -
|
||||||
|
# never send them to clients.
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
Header unset ETag
|
Header unset X-TYPO3-Cache-Tags
|
||||||
|
Header unset X-TYPO3-Cache-Tags-1
|
||||||
|
Header unset X-TYPO3-Cache-Tags-2
|
||||||
|
Header unset X-TYPO3-Cache-Tags-3
|
||||||
|
Header unset X-TYPO3-Debug-Cache
|
||||||
|
Header unset X-TYPO3-Cache-Lifetime
|
||||||
|
Header unset X-TYPO3-Parsetime
|
||||||
|
# Depending on the PHP SAPI these can sit in the "always" table:
|
||||||
|
Header always unset X-TYPO3-Cache-Tags
|
||||||
|
Header always unset X-TYPO3-Cache-Tags-1
|
||||||
|
Header always unset X-TYPO3-Cache-Tags-2
|
||||||
|
Header always unset X-TYPO3-Cache-Tags-3
|
||||||
|
Header always unset X-TYPO3-Debug-Cache
|
||||||
|
Header always unset X-TYPO3-Cache-Lifetime
|
||||||
|
Header always unset X-TYPO3-Parsetime
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# ETag removal - except for the /_json API: there TYPO3 (JsonCacheHeaders
|
||||||
|
# middleware) sends a content ETag so browsers can revalidate with 304
|
||||||
|
# (tickets 0011-FE / 0055-BE).
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
SetEnvIf Request_URI "^/_json($|/)" KEEP_ETAG=1
|
||||||
|
# After the mod_rewrite internal redirect to index.php the variable
|
||||||
|
# is renamed REDIRECT_KEEP_ETAG - carry it back over:
|
||||||
|
SetEnvIf REDIRECT_KEEP_ETAG 1 KEEP_ETAG=1
|
||||||
|
Header unset ETag env=!KEEP_ETAG
|
||||||
</IfModule>
|
</IfModule>
|
||||||
FileETag None
|
FileETag None
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ RewriteBase /_frontend/
|
|||||||
|
|
||||||
RewriteRule ^index\.html$ - [L]
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
|
||||||
|
# SSR flag ON: hashed assets are served from the VPS release - its hashes
|
||||||
|
# must win over any local build, and the fallback below must never answer
|
||||||
|
# an asset request with index.html (the browser would get HTML as JS).
|
||||||
|
# This directory's .htaccess overrides the root one for /_frontend/*, so
|
||||||
|
# the rule has to live HERE, not in public/.htaccess (2026-11-09).
|
||||||
|
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||||
|
RewriteRule ^assets/(.*)$ https://vitec-ssr.evomedien.de/_frontend/assets/$1 [P,L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
RewriteCond %{REQUEST_FILENAME} -d
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
RewriteRule ^ - [L]
|
RewriteRule ^ - [L]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /typo3/
|
||||||
|
Disallow: /_json/
|
||||||
|
|
||||||
|
Sitemap: https://dev.vitec.com/sitemap.xml
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /typo3/
|
||||||
|
Disallow: /_json/
|
||||||
|
|
||||||
|
Sitemap: https://www.vitec.com/sitemap.xml
|
||||||
Reference in new issue
Block a user