Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77661bf0b6 | ||
|
|
9211c8a395 | ||
|
|
9fb7f0ca77 |
No files matched your search
@@ -140,3 +140,4 @@ migrations/product_categories_xlsx_in/
|
||||
|
||||
# generated internal report (php migrations/build_product_audit.php)
|
||||
public/product-audit/
|
||||
migrations/datapath_stories_in/
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Diagnose wie check_grid_447.php, aber mit Seiten-Parameter: Karten,
|
||||
* Grid-Container und Intros einer Seite mit colPos / tx_container_parent /
|
||||
* sorting / hidden / deleted.
|
||||
*
|
||||
* READ-ONLY. Usage: php migrations/check_grid_page.php 569
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
$pid = (int)($argv[1] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
exit("Usage: php migrations/check_grid_page.php <pid>\n");
|
||||
}
|
||||
|
||||
$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 "=== vitec_card / vitec_cols_* / vitec_introparagraph auf pid $pid ===\n";
|
||||
$rows = $pdo->query(
|
||||
"SELECT uid, CType, LEFT(header,32) h, colPos, tx_container_parent par, sorting, hidden, deleted
|
||||
FROM tt_content WHERE pid = $pid AND (CType IN ('vitec_card','vitec_introparagraph') OR CType LIKE 'vitec_cols_%')
|
||||
ORDER BY deleted, colPos, sorting, uid"
|
||||
)->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
printf(" #%-6d %-22s col=%-4d par=%-6d sort=%-6d hid=%d del=%d %s\n",
|
||||
$r['uid'], $r['CType'], $r['colPos'], $r['par'], $r['sorting'], $r['hidden'], $r['deleted'], $r['h']);
|
||||
}
|
||||
echo "\ncounts: " . count($rows) . " rows\n";
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0010-FE Punkt 1: /markets/broadcast-and-telecoms ist eine Mischseite
|
||||
* (alte Pilotseite): vitec_herosection/Container/text/marketlist-Reste plus
|
||||
* colPos220-Legacy NEBEN dem 27.10.-Import. Ziel: exakt das Schema der zehn
|
||||
* anderen Market-Seiten (colPos1: markethero + introparagraph; colPos0:
|
||||
* marketintro, introparagraph, 2 cards, ctabanner).
|
||||
*
|
||||
* Whitelist = die acht Import-Elemente vom 27.10.; ALLES andere auf der
|
||||
* Seite wird soft-geloescht (deleted=1, wiederherstellbar).
|
||||
*
|
||||
* Plain PDO (Muster migrations/check_bg.php).
|
||||
*
|
||||
* Usage: php migrations/cleanup_broadcast_market_page.php (inspect)
|
||||
* php migrations/cleanup_broadcast_market_page.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const PAGE_SLUG = '/markets/broadcast-and-telecoms';
|
||||
// 11342-11349 = Import 27.10. (inkl. der beiden HIDDEN-Stubs customerlogos/
|
||||
// usecaselist, die auch die anderen Market-Seiten tragen); 10293 = das alte
|
||||
// "Explore Broadcast & Telecoms"-Band - der Import hat hier KEINEN eigenen
|
||||
// Explore-Introparagraph angelegt, das Element bleibt die Ueberschrift
|
||||
// ueber den Karten (Inspect-Befund 2026-10-28).
|
||||
const KEEP = [11342, 11343, 11344, 11345, 11346, 11347, 11348, 11349, 10293];
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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]
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)');
|
||||
$stmt->execute([PAGE_SLUG]);
|
||||
$pid = (int)$stmt->fetchColumn();
|
||||
if ($pid === 0) {
|
||||
exit("Seite " . PAGE_SLUG . " nicht gefunden.\n");
|
||||
}
|
||||
echo ($apply ? 'APPLY' : 'INSPECT') . " · Seite #$pid " . PAGE_SLUG . "\n\n";
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT uid, colPos, sorting, CType, header, tx_container_parent, hidden
|
||||
FROM tt_content WHERE deleted = 0 AND pid = ? ORDER BY colPos, sorting'
|
||||
);
|
||||
$stmt->execute([$pid]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$delete = [];
|
||||
printf("%-7s %-7s %-8s %-28s %-9s %-7s %s\n", 'uid', 'colPos', 'parent', 'CType', 'hidden', 'aktion', 'header');
|
||||
foreach ($rows as $row) {
|
||||
$keep = in_array((int)$row['uid'], KEEP, true);
|
||||
if (!$keep) {
|
||||
$delete[] = (int)$row['uid'];
|
||||
}
|
||||
printf("%-7d %-7d %-8d %-28s %-9s %-7s %s\n",
|
||||
$row['uid'], $row['colPos'], $row['tx_container_parent'], $row['CType'],
|
||||
$row['hidden'] ? 'hidden' : '-', $keep ? 'KEEP' : 'DELETE', mb_substr((string)$row['header'], 0, 45));
|
||||
}
|
||||
echo "\n" . count($rows) . " Element(e), davon " . count($delete) . " zu loeschen, " . (count($rows) - count($delete)) . " bleiben.\n";
|
||||
|
||||
$missing = array_diff(KEEP, array_map(static fn(array $r): int => (int)$r['uid'], $rows));
|
||||
if ($missing !== []) {
|
||||
echo "WARNUNG: Whitelist-Elemente fehlen auf der Seite: " . implode(', ', $missing) . " - NICHT anwenden, erst klaeren.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!$apply) {
|
||||
echo "\nInspect only - mit --apply soft-loeschen.\n";
|
||||
exit(0);
|
||||
}
|
||||
if ($delete === []) {
|
||||
echo "Nichts zu tun.\n";
|
||||
exit(0);
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($delete), '?'));
|
||||
$stmt = $pdo->prepare("UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN ($in)");
|
||||
$stmt->execute($delete);
|
||||
echo "Soft-geloescht: " . $stmt->rowCount() . " Element(e). Danach: vendor/bin/typo3 cache:flush\n";
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# fe_comment Isolationstest (2026-11-03): zwei FE-User NACHEINANDER einloggen
|
||||
# und pruefen, dass /api/fe-comment/state UND das USER_INT-Feld auf
|
||||
# /_json/ticket-overview jedem User nur die EIGENEN Tickets zeigen.
|
||||
# Aufruf: bash migrations/fec_isolation_test.sh <user1> <user2>
|
||||
# (fragt beide Passwoerter unsichtbar ab)
|
||||
set -e
|
||||
cd /usr/www/users/vitecevo/live
|
||||
BASE=https://dev.vitec.com
|
||||
|
||||
U1=${1:?Aufruf: fec_isolation_test.sh <user1> <user2>}
|
||||
U2=${2:?Aufruf: fec_isolation_test.sh <user1> <user2>}
|
||||
|
||||
extract_token() {
|
||||
php -r '
|
||||
$j = json_decode(file_get_contents($argv[1]), true);
|
||||
$f = "";
|
||||
$w = function ($n) use (&$w, &$f) {
|
||||
if (is_array($n)) {
|
||||
if (($n["name"] ?? "") === "__RequestToken" && isset($n["value"])) {
|
||||
$f = (string)$n["value"];
|
||||
}
|
||||
foreach ($n as $c) { $w($c); }
|
||||
} elseif (is_string($n) && $f === "") {
|
||||
if (preg_match("/name=\"__RequestToken\" value=\"([^\"]+)\"/", $n, $m)) {
|
||||
$f = $m[1];
|
||||
}
|
||||
}
|
||||
};
|
||||
$w($j);
|
||||
echo $f;
|
||||
' "$1"
|
||||
}
|
||||
|
||||
login() { # $1=user $2=pass $3=jar
|
||||
rm -f "$3"
|
||||
curl -sS -c "$3" -o /tmp/fec_form.json "$BASE/_json/fe-user-profile"
|
||||
TOKEN=$(extract_token /tmp/fec_form.json)
|
||||
if [ -z "$TOKEN" ]; then echo "KEIN __RequestToken gefunden"; exit 1; fi
|
||||
curl -sS -b "$3" -c "$3" -o /tmp/fec_login.json \
|
||||
--data-urlencode "user=$1" \
|
||||
--data-urlencode "pass=$2" \
|
||||
--data-urlencode "logintype=login" \
|
||||
--data-urlencode "pid=520" \
|
||||
--data-urlencode "__RequestToken=$TOKEN" \
|
||||
"$BASE/_json/fe-user-profile"
|
||||
if ! grep -q fe_typo_user "$3"; then
|
||||
echo "LOGIN FEHLGESCHLAGEN fuer $1 (kein fe_typo_user-Cookie)"; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
summarize_state() { # stdin = state json, $1 = label
|
||||
php -r '
|
||||
$j = json_decode(stream_get_contents(STDIN), true);
|
||||
printf("%-28s allowed=%s user=%s summaryUid=%s items=%d\n",
|
||||
$argv[1],
|
||||
var_export($j["allowed"] ?? null, true),
|
||||
$j["user"]["name"] ?? "-",
|
||||
isset($j["summary"]["uid"]) ? (string)$j["summary"]["uid"] : "-",
|
||||
is_array($j["summary"]["items"] ?? null) ? count($j["summary"]["items"]) : 0);
|
||||
foreach (($j["summary"]["items"] ?? []) as $i) {
|
||||
printf(" item #%d %s\n", $i["uid"], mb_substr($i["url"], 0, 70));
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
summarize_overview() { # stdin = page json, $1 = label
|
||||
php -r '
|
||||
$j = json_decode(stream_get_contents(STDIN), true);
|
||||
$fo = null;
|
||||
foreach (($j["content"]["colPos0"] ?? []) as $el) {
|
||||
if (($el["type"] ?? "") === "fecomment_overview") {
|
||||
$fo = $el["content"]["feedbackOverview"] ?? null;
|
||||
}
|
||||
}
|
||||
if ($fo === null) { echo $argv[1] . " KEIN fecomment_overview-Element gefunden\n"; exit; }
|
||||
printf("%-28s allowed=%s user=%s summaryUid=%s items=%d\n",
|
||||
$argv[1],
|
||||
var_export($fo["allowed"] ?? null, true),
|
||||
$fo["user"]["name"] ?? "-",
|
||||
isset($fo["summary"]["uid"]) ? (string)$fo["summary"]["uid"] : "-",
|
||||
is_array($fo["summary"]["items"] ?? null) ? count($fo["summary"]["items"]) : 0);
|
||||
' "$1"
|
||||
}
|
||||
|
||||
check_user() { # $1=user $2=jar
|
||||
echo "=== $1 ==="
|
||||
curl -sS -b "$2" "$BASE/api/fe-comment/state" | summarize_state "state-API"
|
||||
curl -sS -b "$2" "$BASE/_json/ticket-overview" | summarize_overview "ticket-overview (USER_INT)"
|
||||
echo
|
||||
}
|
||||
|
||||
read -rsp "Passwort fuer $U1: " P1; echo
|
||||
read -rsp "Passwort fuer $U2: " P2; echo
|
||||
|
||||
login "$U1" "$P1" /tmp/fec_u1.jar
|
||||
login "$U2" "$P2" /tmp/fec_u2.jar
|
||||
|
||||
# Testticket als U1 anlegen, damit es etwas zu sehen (bzw. NICHT zu sehen) gibt
|
||||
echo "--- lege Testticket als $U1 an ---"
|
||||
TICKET=$(curl -sS -b /tmp/fec_u1.jar -H 'Content-Type: application/json' \
|
||||
-d '{"url":"/isolation-test","note":"<p>Isolationstest - bitte ignorieren</p>"}' \
|
||||
"$BASE/api/fe-comment/feedback" | php -r '
|
||||
$j = json_decode(stream_get_contents(STDIN), true);
|
||||
echo (int)($j["uid"] ?? 0);')
|
||||
echo "Testticket uid=$TICKET"
|
||||
if [ "$TICKET" = "0" ]; then echo "ANLEGEN FEHLGESCHLAGEN"; exit 1; fi
|
||||
echo
|
||||
|
||||
# beide Sessions leben gleichzeitig - genau wie zwei Browser
|
||||
check_user "$U1" /tmp/fec_u1.jar
|
||||
check_user "$U2" /tmp/fec_u2.jar
|
||||
|
||||
# Gegenprobe: darf U2 das Ticket von U1 veraendern? (erwartet: Fehler)
|
||||
echo "--- Gegenprobe: $U2 versucht Ticket #$TICKET von $U1 zu loeschen ---"
|
||||
curl -sS -b /tmp/fec_u2.jar -X POST "$BASE/api/fe-comment/feedback/$TICKET/delete"; echo
|
||||
echo "(erwartet: {\"error\":\"feedback not found or not yours\"})"
|
||||
echo
|
||||
|
||||
# aufraeumen: U1 loescht sein eigenes Testticket
|
||||
echo "--- aufraeumen: $U1 loescht Testticket #$TICKET ---"
|
||||
curl -sS -b /tmp/fec_u1.jar -X POST "$BASE/api/fe-comment/feedback/$TICKET/delete"; echo
|
||||
echo
|
||||
|
||||
echo "=== anonym (ohne Session) ==="
|
||||
curl -sS "$BASE/api/fe-comment/state" | summarize_state "state-API"
|
||||
curl -sS "$BASE/_json/ticket-overview" | summarize_overview "ticket-overview (USER_INT)"
|
||||
echo
|
||||
echo "ERWARTUNG: $U1 sieht 1 Item (das Testticket), $U2 sieht 0 Items,"
|
||||
echo "die Gegenprobe-Loeschung durch $U2 schlaegt fehl, anonym ist allowed=false."
|
||||
rm -f /tmp/fec_u1.jar /tmp/fec_u2.jar /tmp/fec_form.json /tmp/fec_login.json
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0014-FE Punkt 2: doppelt kodierte HTML-Entities in tt_content
|
||||
* (z. B. "VITEC&apos;s" im JSON). Ursache: die Produkt-Importer
|
||||
* escapten mit ENT_QUOTES|ENT_HTML5 (' -> '), die RTE-Persistenz
|
||||
* escapte das & erneut. Die Importer schreiben seit 2026-10-28 literal
|
||||
* (ENT_NOQUOTES); dieses Skript repariert den Bestand um EINE
|
||||
* Escaping-Stufe: &apos; -> ' usw.
|
||||
*
|
||||
* Plain PDO (Muster migrations/check_bg.php).
|
||||
*
|
||||
* Usage: php migrations/fix_double_entities.php (Scan/Report)
|
||||
* php migrations/fix_double_entities.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
// Stufe 1: doppelt kodierte Entities um eine Stufe reduzieren.
|
||||
// Stufe 2 (Befund 2026-10-28: Scan fand KEINE Doubles in der DB - die DB
|
||||
// traegt ' EINFACH kodiert, erst die Render-Pipeline escapet das &
|
||||
// erneut): Apostroph-Entities literal machen, dann kann nichts mehr doppeln.
|
||||
const PATTERNS = ['&apos;', '&quot;', '&#039;', '&amp;', '&nbsp;', ''', '''];
|
||||
const REPLACEMENTS = [''', '"', ''', '&', ' ', "'", "'"];
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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' : 'SCAN') . "\n";
|
||||
|
||||
$like = implode(' OR ', array_map(static fn(string $p): string => "bodytext LIKE '%" . $p . "%'", PATTERNS));
|
||||
$rows = $pdo->query(
|
||||
"SELECT c.uid, c.pid, p.slug, c.CType, c.bodytext FROM tt_content c
|
||||
LEFT JOIN pages p ON p.uid = c.pid
|
||||
WHERE c.deleted = 0 AND ($like) ORDER BY p.slug, c.uid"
|
||||
)->fetchAll();
|
||||
|
||||
$fixed = 0;
|
||||
foreach ($rows as $row) {
|
||||
$new = str_replace(PATTERNS, REPLACEMENTS, (string)$row['bodytext']);
|
||||
$hits = [];
|
||||
foreach (PATTERNS as $pattern) {
|
||||
$n = substr_count((string)$row['bodytext'], $pattern);
|
||||
if ($n > 0) {
|
||||
$hits[] = $pattern . 'x' . $n;
|
||||
}
|
||||
}
|
||||
printf("#%-6d %-28s %-45s %s\n", $row['uid'], $row['CType'], (string)$row['slug'], implode(' ', $hits));
|
||||
if ($apply) {
|
||||
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
|
||||
->execute([$new, (int)$row['uid']]);
|
||||
$fixed++;
|
||||
}
|
||||
}
|
||||
echo "\n" . count($rows) . " Element(e) betroffen" . ($apply ? ", $fixed repariert. Danach: vendor/bin/typo3 cache:flush" : ". Mit --apply reparieren.") . "\n";
|
||||
if (!$apply && $rows !== []) {
|
||||
echo "HINWEIS: Ersetzt wird genau EINE Escaping-Stufe (&apos; -> ' usw.).\n";
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0014-FE, Folge-Befund: pid 569 (/products/graphics-and-multi-display)
|
||||
* nach dem IQS4-Kategorie-Re-Import. Container und ihre Kinder sind intakt,
|
||||
* aber: 8 Duplikat-Karten top-level in colPos0 (11853-11860) und die Grids
|
||||
* stehen falsch relativ zu ihren Intros (11840 vor 11629, 11841 vor 11632).
|
||||
*
|
||||
* Reparatur: Duplikate soft-loeschen, colPos0 in Sektions-Folge neu sortieren.
|
||||
*
|
||||
* Usage: php migrations/fix_graphics_grid.php (inspect)
|
||||
* php migrations/fix_graphics_grid.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const PID = 569;
|
||||
const DUPLICATES = [11853, 11854, 11855, 11856, 11857, 11858, 11859, 11860];
|
||||
// Intro -> Grid(s): Why VITEC -> 11838 (3 Benefit-Karten) + 11839 (1),
|
||||
// Finder-Intro + Comparison-Text, Products-Intro -> 11840 (IGC + IQS4),
|
||||
// Solutions-Intro -> 11841 (2 Solution-Karten)
|
||||
const ORDER = [11620, 11621, 11622, 11838, 11839, 11627, 11628, 11629, 11840, 11632, 11841];
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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' : 'INSPECT') . " · pid " . PID . "\n\n";
|
||||
|
||||
$rows = $pdo->query('SELECT uid, CType, colPos, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID)->fetchAll();
|
||||
$byUid = [];
|
||||
foreach ($rows as $row) {
|
||||
$byUid[(int)$row['uid']] = $row;
|
||||
}
|
||||
foreach (array_merge(ORDER, DUPLICATES) as $uid) {
|
||||
if (!isset($byUid[$uid])) {
|
||||
exit("ABBRUCH: uid $uid fehlt auf der Seite - erst klaeren.\n");
|
||||
}
|
||||
}
|
||||
$tail = [];
|
||||
foreach ($pdo->query('SELECT uid, CType, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID . ' AND colPos = 0 ORDER BY sorting, uid') as $row) {
|
||||
$uid = (int)$row['uid'];
|
||||
if (!in_array($uid, ORDER, true) && !in_array($uid, DUPLICATES, true)) {
|
||||
$tail[] = $uid;
|
||||
echo "tail: #$uid {$row['CType']} \"{$row['h']}\"\n";
|
||||
}
|
||||
}
|
||||
echo "\n1) " . count(DUPLICATES) . " Duplikat-Karten soft-loeschen: " . implode(',', DUPLICATES) . "\n";
|
||||
echo "2) colPos0 neu sortieren:\n";
|
||||
$sequence = array_merge(ORDER, $tail);
|
||||
foreach ($sequence as $i => $uid) {
|
||||
$r = $byUid[$uid];
|
||||
printf(" %3d. #%-6d %-22s %s\n", $i + 1, $uid, $r['CType'], $r['h']);
|
||||
}
|
||||
if (!$apply) {
|
||||
echo "\nInspect only - mit --apply schreiben.\n";
|
||||
exit(0);
|
||||
}
|
||||
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DUPLICATES) . ')');
|
||||
$sorting = 0;
|
||||
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
|
||||
foreach ($sequence as $uid) {
|
||||
$sorting += 256;
|
||||
$stmt->execute([$sorting, $uid]);
|
||||
}
|
||||
echo "\nGeschrieben. Danach: vendor/bin/typo3 cache:flush\n";
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0014-FE Punkt 1: /products (pid 447) ohne Grid-Container im Payload.
|
||||
*
|
||||
* Befund (check_grid_447.php, 2026-10-28): der Portfolio-Re-Run (IQS4) hat
|
||||
* die 15 vitec_cols_33_33_33-Container VERSTECKT (hidden=1, uid 11794-11808;
|
||||
* ihre 38 Original-Karten stecken intakt und bebildert darin), 38 neue
|
||||
* Duplikat-Karten top-level in colPos0 angelegt (11861-11898) und die
|
||||
* Sortierung neu verteilt - die versteckten Container stehen dadurch an
|
||||
* falschen Positionen.
|
||||
*
|
||||
* Reparatur: Container einblenden, Duplikate soft-loeschen, colPos0 in der
|
||||
* P5-Reihenfolge (Intro -> Grid(s) -> naechstes Intro, danach Banner/FAQ in
|
||||
* bisheriger Folge) mit Stride 256 neu sortieren.
|
||||
*
|
||||
* Usage: php migrations/fix_products_grid.php (inspect)
|
||||
* php migrations/fix_products_grid.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const PID = 447;
|
||||
const CONTAINERS = [11794, 11795, 11796, 11797, 11798, 11799, 11800, 11801, 11802, 11803, 11804, 11805, 11806, 11807, 11808];
|
||||
const DUPLICATES = [
|
||||
11861, 11862, 11863, 11864, 11865, 11866, 11867, 11868, 11869, 11870,
|
||||
11871, 11872, 11873, 11874, 11875, 11876, 11877, 11878, 11879, 11880,
|
||||
11881, 11882, 11883, 11884, 11885, 11886, 11887, 11888, 11889, 11890,
|
||||
11891, 11892, 11893, 11894, 11895, 11896, 11897, 11898,
|
||||
];
|
||||
// Zielreihenfolge colPos0: Sektions-Intro, danach seine Grids (Zuordnung
|
||||
// ueber die Container-Kinder verifiziert, check_grid_447-Ausgabe 2026-10-28)
|
||||
const ORDER = [
|
||||
11741, 11808, 11807, // IPTV & Digital Signage (5 Karten -> 2 Grids)
|
||||
11747, 11806, 11805, 11804, // Video Encoding & Streaming (7 -> 3)
|
||||
11755, 11803, // Gateways & Transcoding (3 -> 1)
|
||||
11759, 11802, 11801, // Control Room Platforms (6 -> 2)
|
||||
11766, 11800, // AV over IP & KVM (2 -> 1)
|
||||
11769, 11799, 11798, // Video Wall & Display Processing (4 -> 2)
|
||||
11774, 11797, // Graphics & Multi-Display (2 -> 1)
|
||||
11777, 11796, 11795, // Video Capture (6 -> 2)
|
||||
11784, 11794, // Infrastructure & Accessories (3 -> 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("Could not locate project root above " . __DIR__ . "\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' : 'INSPECT') . " · pid " . PID . "\n\n";
|
||||
|
||||
// Sanity: alle ORDER-/CONTAINER-uids existieren undeleted auf der Seite
|
||||
$rows = $pdo->query('SELECT uid, CType, colPos, hidden, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID)->fetchAll();
|
||||
$byUid = [];
|
||||
foreach ($rows as $row) {
|
||||
$byUid[(int)$row['uid']] = $row;
|
||||
}
|
||||
foreach (array_merge(ORDER, DUPLICATES) as $uid) {
|
||||
if (!isset($byUid[$uid])) {
|
||||
exit("ABBRUCH: uid $uid fehlt auf der Seite (geloescht/verschoben?) - erst klaeren.\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Rest von colPos0 (Banner, FAQ, ...) hinten anhaengen, bisherige Folge
|
||||
$tail = [];
|
||||
foreach ($pdo->query('SELECT uid, CType, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID . ' AND colPos = 0 ORDER BY sorting, uid') as $row) {
|
||||
$uid = (int)$row['uid'];
|
||||
if (!in_array($uid, ORDER, true) && !in_array($uid, DUPLICATES, true)) {
|
||||
$tail[] = $uid;
|
||||
echo "tail: #$uid {$row['CType']} \"{$row['h']}\"\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n1) " . count(CONTAINERS) . " Container einblenden (hidden=0): " . implode(',', CONTAINERS) . "\n";
|
||||
echo "2) " . count(DUPLICATES) . " Duplikat-Karten soft-loeschen: " . implode(',', DUPLICATES) . "\n";
|
||||
echo "3) colPos0 neu sortieren: " . count(ORDER) . " Elemente in P5-Folge + " . count($tail) . " Tail-Element(e)\n";
|
||||
$sequence = array_merge(ORDER, $tail);
|
||||
foreach ($sequence as $i => $uid) {
|
||||
$r = $byUid[$uid];
|
||||
printf(" %3d. #%-6d %-22s %s\n", $i + 1, $uid, $r['CType'], $r['h']);
|
||||
}
|
||||
|
||||
if (!$apply) {
|
||||
echo "\nInspect only - mit --apply schreiben.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$pdo->exec('UPDATE tt_content SET hidden = 0, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', CONTAINERS) . ')');
|
||||
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DUPLICATES) . ')');
|
||||
$sorting = 0;
|
||||
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
|
||||
foreach ($sequence as $uid) {
|
||||
$sorting += 256;
|
||||
$stmt->execute([$sorting, $uid]);
|
||||
}
|
||||
echo "\nGeschrieben. Danach: vendor/bin/typo3 cache:flush\n";
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0017-FE Punkt 1 + 2 (Altbestand vitec.com-Import, gegen das
|
||||
* Original auf www.vitec.com verifiziert, 2026-10-29):
|
||||
*
|
||||
* 1) CDC (centers-for-disease-control-cdc): die Background-vitec_columns
|
||||
* (#355) traegt links den Text des Crowne Plaza Muscat OCEC und rechts
|
||||
* dessen Logo. Das Original hat dort CDC-Copy. -> linkes Text-Item auf
|
||||
* die Original-Absaetze setzen, rechtes Logo-Item soft-loeschen
|
||||
* (CDC-Bild kann redaktionell nachgelegt werden).
|
||||
*
|
||||
* 2) "Key Feature"-textmedia mit identischem Hotel-/Signage-Text in 5
|
||||
* Stories. Abgleich mit dem Original:
|
||||
* - parramatta-eels #218: Text ist DORT ECHT -> behalten
|
||||
* - belgrade #253, isr #266, meydan #206: Original hat KEIN
|
||||
* Key Feature -> Element soft-loeschen
|
||||
* - proteo #234: Original hat MIL-spec-Bullets -> bodytext ersetzen
|
||||
*
|
||||
* Usage: php migrations/fix_story_content_errors.php (inspect)
|
||||
* php migrations/fix_story_content_errors.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const CDC_COLUMNS_UID = 355;
|
||||
const DELETE_ELEMENTS = [253, 266, 206];
|
||||
const PROTEO_UID = 234;
|
||||
const KEEP_CHECK = 218;
|
||||
|
||||
const CDC_BACKGROUND = '<p>When it comes mitigating major threats to public health, it is imperative for organizations like the Centers for Disease Control (CDC) to keep their finger on the pulse of global developments that require a coordinated national or global response.</p>'
|
||||
. '<p>In addition to being ardent consumers of news and information, doctors, scientists, and other healthcare experts at the CDC produce a tremendous amount of original content for both internal and external consumption. This makes the agency one of the most video-centric departments in the federal government.</p>';
|
||||
|
||||
const PROTEO_KEYFEATURE = '<ul>'
|
||||
. '<li>MIL-spec ruggedization HEVC & H.264 Encoder</li>'
|
||||
. '<li>Low latency HD/SD Streaming</li>'
|
||||
. '<li>Low bandwidth HEVC Streaming</li>'
|
||||
. '<li>Overlay for video input identification and timestamping</li>'
|
||||
. '<li>KLV / STANAG metadata processing for ISR</li>'
|
||||
. '</ul>';
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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' : 'INSPECT') . "\n";
|
||||
|
||||
// --- 1) CDC-Spalten-Items ----------------------------------------------------
|
||||
// Collection-Tabellen generisch ueber foreign_table_parent_uid finden
|
||||
// (die Content-Blocks-Felder heissen hier unprefixed: column/text/item_type)
|
||||
$itemTables = $pdo->query(
|
||||
"SELECT DISTINCT TABLE_NAME FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'foreign_table_parent_uid'"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
echo "\n--- 1) CDC #" . CDC_COLUMNS_UID . " · Kandidaten-Tabellen: " . implode(', ', $itemTables) . "\n";
|
||||
$fixedLeft = false;
|
||||
$deletedRight = false;
|
||||
foreach ($itemTables as $table) {
|
||||
$cols = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = " . $pdo->quote($table))->fetchAll(PDO::FETCH_COLUMN);
|
||||
$textCol = in_array('vitec_text', $cols, true) ? 'vitec_text' : (in_array('text', $cols, true) ? 'text' : null);
|
||||
$colCol = in_array('vitec_column', $cols, true) ? 'vitec_column' : (in_array('column', $cols, true) ? 'column' : null);
|
||||
if ($textCol === null || $colCol === null) {
|
||||
continue;
|
||||
}
|
||||
$items = $pdo->query("SELECT * FROM `$table` WHERE deleted = 0 AND foreign_table_parent_uid = " . CDC_COLUMNS_UID . " ORDER BY sorting")->fetchAll();
|
||||
if ($items === []) {
|
||||
continue;
|
||||
}
|
||||
echo " Tabelle: $table (textCol=$textCol, colCol=$colCol)\n";
|
||||
foreach ($items as $item) {
|
||||
$text = (string)$item[$textCol];
|
||||
printf(" item #%d col=%s type=%s text=\"%s\"\n",
|
||||
$item['uid'], $item[$colCol] ?? '?',
|
||||
$item['vitec_item_type'] ?? ($item['item_type'] ?? '?'),
|
||||
mb_substr(trim(strip_tags($text)), 0, 70));
|
||||
$isHotelText = str_contains($text, 'Crowne Plaza');
|
||||
$column = (string)($item[$colCol] ?? 'left');
|
||||
if ($isHotelText && $textCol !== null) {
|
||||
echo " -> linkes Text-Item: Hotel-Text wird durch CDC-Background ersetzt\n";
|
||||
if ($apply) {
|
||||
$pdo->prepare("UPDATE `$table` SET `$textCol` = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
|
||||
->execute([CDC_BACKGROUND, (int)$item['uid']]);
|
||||
$fixedLeft = true;
|
||||
}
|
||||
} elseif ($column === 'right') {
|
||||
echo " -> rechtes Item (Hotel-Logo): wird soft-geloescht\n";
|
||||
if ($apply) {
|
||||
$pdo->prepare("UPDATE `$table` SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
|
||||
->execute([(int)$item['uid']]);
|
||||
$deletedRight = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // erste Tabelle mit Items reicht
|
||||
}
|
||||
if ($apply) {
|
||||
echo $fixedLeft ? " Text ersetzt.\n" : " WARNUNG: kein Hotel-Text-Item gefunden!\n";
|
||||
echo $deletedRight ? " Logo-Item geloescht.\n" : " Hinweis: kein rechtes Item geloescht.\n";
|
||||
}
|
||||
|
||||
// --- 2) Key-Feature-Elemente -------------------------------------------------
|
||||
echo "\n--- 2) Key-Feature-Elemente ---\n";
|
||||
$in = implode(',', array_merge(DELETE_ELEMENTS, [PROTEO_UID, KEEP_CHECK]));
|
||||
foreach ($pdo->query("SELECT uid, CType, LEFT(header,30) h, LEFT(bodytext,70) b, deleted FROM tt_content WHERE uid IN ($in)") as $row) {
|
||||
$uid = (int)$row['uid'];
|
||||
$action = in_array($uid, DELETE_ELEMENTS, true) ? 'LOESCHEN (Original hat kein Key Feature)'
|
||||
: ($uid === PROTEO_UID ? 'bodytext -> MIL-spec-Bullets (Original)' : 'BEHALTEN (Text im Original echt)');
|
||||
printf(" #%-4d %-12s \"%s\" \"%s\"%s -> %s\n", $uid, $row['CType'], $row['h'],
|
||||
trim(strip_tags((string)$row['b'])), $row['deleted'] ? ' [DELETED]' : '', $action);
|
||||
}
|
||||
if ($apply) {
|
||||
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DELETE_ELEMENTS) . ')');
|
||||
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
|
||||
->execute([PROTEO_KEYFEATURE, PROTEO_UID]);
|
||||
echo " 3 geloescht, Proteo ersetzt.\n";
|
||||
}
|
||||
|
||||
echo $apply ? "\nFertig. Danach: vendor/bin/typo3 cache:flush\n" : "\nInspect only - mit --apply schreiben.\n";
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0017-FE Punkt 4: drei Alt-Stories tragen Platzhalter in
|
||||
* quote/quote_person/quote_role (africa-hall: "Here Fu Manchu lalala.",
|
||||
* bayer-uk: "This is a testimonial", meydan-racecourse: der Feldhilfetext).
|
||||
* Auf Testimonial-Karten wuerden die erscheinen -> Felder leeren.
|
||||
*
|
||||
* Usage: php migrations/fix_story_placeholder_quotes.php (inspect)
|
||||
* php migrations/fix_story_placeholder_quotes.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const SLUGS = ['africa-hall', 'bayer-uk', 'meydan-racecourse'];
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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' : 'INSPECT') . "\n";
|
||||
$in = implode(',', array_fill(0, count(SLUGS), '?'));
|
||||
$stmt = $pdo->prepare("SELECT uid, slug, LEFT(quote,60) q, quote_person, quote_role
|
||||
FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND slug IN ($in)");
|
||||
$stmt->execute(SLUGS);
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as $row) {
|
||||
printf("#%-4d %-20s quote=\"%s\" person=\"%s\" role=\"%s\" -> leeren\n",
|
||||
$row['uid'], $row['slug'], $row['q'], $row['quote_person'], $row['quote_role']);
|
||||
if ($apply) {
|
||||
$pdo->prepare("UPDATE tx_vitec_domain_model_usecase
|
||||
SET quote = '', quote_person = '', quote_role = '', tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
|
||||
->execute([(int)$row['uid']]);
|
||||
}
|
||||
}
|
||||
echo count($rows) . " Record(s)" . ($apply ? " geleert. Danach cache:flush." : ". Mit --apply leeren.") . "\n";
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ticket 0017-FE Punkt 5b: Videos als echte vitec_video-Elemente.
|
||||
*
|
||||
* - betfred-hq: "The Result" endet mit "<p>Case Study Video</p>" + Link
|
||||
* auf youtube nw52kbYpF3A -> Absaetze strippen, vitec_video danach.
|
||||
* - kanha-shanti-vanam: "The Result" traegt "<p>Short Videos</p><ul>"
|
||||
* mit zwei YouTube-Links (WD0dHmqN4s0, C8FzHR5aUAI) -> strippen,
|
||||
* zwei vitec_video danach.
|
||||
* - atlanta-hawks: html-Element #326 traegt den escapten iframe
|
||||
* (-ds3p9jb5F0) -> Element loeschen, vitec_video an seiner Position.
|
||||
*
|
||||
* Einfuegung direkt per SQL (pid/colPos 999/tx_vitec_usecase_content/
|
||||
* sorting zwischen den Nachbarn); der Inline-Zaehler content_elements am
|
||||
* Usecase-Record wird mitgezogen.
|
||||
*
|
||||
* Usage: php migrations/fix_story_videos.php (inspect)
|
||||
* php migrations/fix_story_videos.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("Could not locate project root above " . __DIR__ . "\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' : 'INSPECT') . "\n";
|
||||
|
||||
$col = $pdo->query("SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tt_content' AND COLUMN_NAME = 'vitec_youtube_code'")->fetchColumn();
|
||||
if ((int)$col !== 1) {
|
||||
exit("ABBRUCH: Spalte tt_content.vitec_youtube_code nicht gefunden.\n");
|
||||
}
|
||||
|
||||
function storyUid(PDO $pdo, string $slug): int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT uid FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND slug = ?');
|
||||
$stmt->execute([$slug]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** sorting-Wert direkt nach $anchorUid; bei zu kleinem Abstand werden die Kinder vorher mit Stride 256 neu nummeriert. */
|
||||
function sortingAfter(PDO $pdo, int $storyUid, int $anchorUid, bool $apply): int
|
||||
{
|
||||
$children = $pdo->query("SELECT uid, sorting FROM tt_content
|
||||
WHERE deleted = 0 AND tx_vitec_usecase_content = $storyUid ORDER BY sorting, uid")->fetchAll();
|
||||
$index = array_search($anchorUid, array_map(static fn(array $r): int => (int)$r['uid'], $children), true);
|
||||
if ($index === false) {
|
||||
throw new RuntimeException("Anker #$anchorUid nicht unter den Kindern von Story #$storyUid");
|
||||
}
|
||||
$anchorSort = (int)$children[$index]['sorting'];
|
||||
$nextSort = isset($children[$index + 1]) ? (int)$children[$index + 1]['sorting'] : $anchorSort + 512;
|
||||
if ($nextSort - $anchorSort >= 4) {
|
||||
return $anchorSort + intdiv($nextSort - $anchorSort, 2);
|
||||
}
|
||||
// zu eng: komplett neu nummerieren und erneut rechnen
|
||||
if ($apply) {
|
||||
$sorting = 0;
|
||||
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ? WHERE uid = ?');
|
||||
foreach ($children as $child) {
|
||||
$sorting += 256;
|
||||
$stmt->execute([$sorting, (int)$child['uid']]);
|
||||
}
|
||||
}
|
||||
return ($index + 1) * 256 + 128;
|
||||
}
|
||||
|
||||
function insertVideo(PDO $pdo, int $storyUid, int $pid, int $sorting, string $header, string $code, bool $apply): void
|
||||
{
|
||||
printf(" + vitec_video \"%s\" code=%s sorting=%d\n", $header, $code, $sorting);
|
||||
if (!$apply) {
|
||||
return;
|
||||
}
|
||||
$pdo->prepare("INSERT INTO tt_content
|
||||
(pid, CType, colPos, tx_vitec_usecase_content, sorting, header, vitec_youtube_code, crdate, tstamp)
|
||||
VALUES (?, 'vitec_video', 999, ?, ?, ?, ?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
|
||||
->execute([$pid, $storyUid, $sorting, $header, $code]);
|
||||
$pdo->exec("UPDATE tx_vitec_domain_model_usecase
|
||||
SET content_elements = (SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = $storyUid),
|
||||
tstamp = UNIX_TIMESTAMP()
|
||||
WHERE uid = $storyUid");
|
||||
}
|
||||
|
||||
function stripFromBody(PDO $pdo, int $elementUid, string $pattern, bool $apply): bool
|
||||
{
|
||||
$body = (string)$pdo->query("SELECT bodytext FROM tt_content WHERE uid = $elementUid")->fetchColumn();
|
||||
$clean = (string)preg_replace($pattern, '', $body);
|
||||
if ($clean === $body) {
|
||||
echo " ! Strip-Muster traf NICHT in #$elementUid - nichts geaendert\n";
|
||||
return false;
|
||||
}
|
||||
echo " ~ #$elementUid: Video-Absaetze aus bodytext entfernt\n";
|
||||
if ($apply) {
|
||||
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
|
||||
->execute([trim($clean), $elementUid]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Betfred -----------------------------------------------------------------
|
||||
echo "\n--- betfred-hq ---\n";
|
||||
$uid = storyUid($pdo, 'betfred-hq');
|
||||
$pid = (int)$pdo->query("SELECT pid FROM tx_vitec_domain_model_usecase WHERE uid = $uid")->fetchColumn();
|
||||
if (stripFromBody($pdo, 11910, '#<p>\s*Case Study Video\s*</p>\s*<p>[^<]*nw52kbYpF3A[^<]*</p>#u', $apply)) {
|
||||
insertVideo($pdo, $uid, $pid, sortingAfter($pdo, $uid, 11910, $apply), 'Case Study Video', 'nw52kbYpF3A', $apply);
|
||||
}
|
||||
|
||||
// --- Kanha Shanti Vanam --------------------------------------------------------
|
||||
echo "\n--- kanha-shanti-vanam ---\n";
|
||||
$uid = storyUid($pdo, 'kanha-shanti-vanam-meditation-center');
|
||||
$pid = (int)$pdo->query("SELECT pid FROM tx_vitec_domain_model_usecase WHERE uid = $uid")->fetchColumn();
|
||||
if (stripFromBody($pdo, 11964, '#<p>\s*Short Videos\s*</p>\s*<ul>\s*<li>[^<]*WD0dHmqN4s0[^<]*</li>\s*<li>[^<]*C8FzHR5aUAI[^<]*</li>\s*</ul>#u', $apply)) {
|
||||
$sorting = sortingAfter($pdo, $uid, 11964, $apply);
|
||||
insertVideo($pdo, $uid, $pid, $sorting, 'Short Videos', 'WD0dHmqN4s0', $apply);
|
||||
insertVideo($pdo, $uid, $pid, $sorting + 1, '', 'C8FzHR5aUAI', $apply);
|
||||
}
|
||||
|
||||
// --- Atlanta Hawks --------------------------------------------------------------
|
||||
echo "\n--- atlanta-hawks ---\n";
|
||||
$uid = storyUid($pdo, 'atlanta-hawks');
|
||||
$old = $pdo->query('SELECT uid, pid, sorting, bodytext FROM tt_content WHERE uid = 326 AND deleted = 0')->fetch();
|
||||
if ($old && str_contains((string)$old['bodytext'], '-ds3p9jb5F0')) {
|
||||
echo " ~ html #326 (escapter iframe) wird geloescht, vitec_video an seiner Position\n";
|
||||
if ($apply) {
|
||||
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid = 326');
|
||||
}
|
||||
insertVideo($pdo, $uid, (int)$old['pid'], (int)$old['sorting'], '', '-ds3p9jb5F0', $apply);
|
||||
} else {
|
||||
echo " ! html #326 nicht gefunden oder ohne erwarteten iframe - uebersprungen\n";
|
||||
}
|
||||
|
||||
echo $apply ? "\nFertig. Danach: vendor/bin/typo3 cache:flush\n" : "\nInspect only - mit --apply schreiben.\n";
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zwei Quick Wins aus Arnes Tickets (2026-10-28):
|
||||
*
|
||||
* A) 0012-FE Punkt 2: die Karte "Request access" auf /support (uid 11489)
|
||||
* soll auf /support/login-to-helpdesk#form-helpdesk zeigen (Anker auf
|
||||
* das HelpDesk-Formular; Schema form-<formKey>).
|
||||
*
|
||||
* B) 0009-FE Punkt 7: vitec_ctabanner auf Market-/Solution-Seiten haben
|
||||
* Labels ("Contact our team" / "Request a demo"), aber keine Links.
|
||||
* Leere primary_cta mit Contact-Label -> /contact, leere secondary_cta
|
||||
* mit Demo-Label -> /contact/request-a-demo (als t3://page?uid=...).
|
||||
* Gefuellte Links werden NIE angefasst.
|
||||
*
|
||||
* Plain PDO (Muster migrations/check_bg.php).
|
||||
*
|
||||
* Usage: php migrations/ticket_quickwins_20261028.php (inspect)
|
||||
* php migrations/ticket_quickwins_20261028.php --apply
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
const REQUEST_ACCESS_UID = 11489;
|
||||
const ANCHOR = 'form-helpdesk';
|
||||
|
||||
$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("Could not locate project root above " . __DIR__ . "\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]
|
||||
);
|
||||
|
||||
echo ($apply ? 'APPLY' : 'INSPECT') . "\n";
|
||||
|
||||
function pageUidBySlug(PDO $pdo, string $slug): int
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)');
|
||||
$stmt->execute([$slug]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// --- A) Request-access-Karte -> Anker ---------------------------------------
|
||||
echo "\n--- A) Request access -> #" . ANCHOR . " ---\n";
|
||||
$stmt = $pdo->prepare('SELECT uid, header, vitec_primary_cta FROM tt_content WHERE deleted = 0 AND uid = ?');
|
||||
$stmt->execute([REQUEST_ACCESS_UID]);
|
||||
$card = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$card) {
|
||||
echo "Karte #" . REQUEST_ACCESS_UID . " nicht gefunden - uebersprungen.\n";
|
||||
} elseif (stripos((string)$card['header'], 'request access') === false) {
|
||||
echo "Karte #" . REQUEST_ACCESS_UID . " heisst \"" . $card['header'] . "\" (erwartet: Request access) - NICHT angefasst.\n";
|
||||
} else {
|
||||
$cta = (string)$card['vitec_primary_cta'];
|
||||
if (str_contains($cta, '#' . ANCHOR)) {
|
||||
echo "Karte #" . REQUEST_ACCESS_UID . ": Anker bereits gesetzt ($cta) - nichts zu tun.\n";
|
||||
} elseif ($cta === '') {
|
||||
echo "Karte #" . REQUEST_ACCESS_UID . ": primary_cta ist LEER - erst klaeren, nicht angefasst.\n";
|
||||
} else {
|
||||
$new = $cta . '#' . ANCHOR;
|
||||
echo "Karte #" . REQUEST_ACCESS_UID . " (\"" . $card['header'] . "\"): \"$cta\" -> \"$new\"\n";
|
||||
if ($apply) {
|
||||
$pdo->prepare('UPDATE tt_content SET vitec_primary_cta = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
|
||||
->execute([$new, REQUEST_ACCESS_UID]);
|
||||
echo " geschrieben.\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- B) CTA-Banner-Defaults auf Market-/Solution-Seiten ----------------------
|
||||
echo "\n--- B) ctabanner-Links auf /markets + /solutions ---\n";
|
||||
$contactUid = pageUidBySlug($pdo, '/contact');
|
||||
$demoUid = pageUidBySlug($pdo, '/contact/request-a-demo');
|
||||
$solutionsUid = pageUidBySlug($pdo, '/solutions');
|
||||
if ($contactUid === 0 || $demoUid === 0 || $solutionsUid === 0) {
|
||||
exit("Zielseiten nicht aufloesbar (/contact=#$contactUid, request-a-demo=#$demoUid, /solutions=#$solutionsUid) - Abbruch.\n");
|
||||
}
|
||||
echo "Ziele: /contact=#$contactUid, request-a-demo=#$demoUid, /solutions=#$solutionsUid\n";
|
||||
|
||||
$rows = $pdo->query(
|
||||
"SELECT c.uid, p.slug, c.vitec_primary_cta, c.vitec_primary_cta_label,
|
||||
c.vitec_secondary_cta, c.vitec_secondary_cta_label
|
||||
FROM tt_content c JOIN pages p ON p.uid = c.pid AND p.deleted = 0
|
||||
WHERE c.deleted = 0 AND c.CType = 'vitec_ctabanner'
|
||||
AND (p.slug = '/markets' OR p.slug LIKE '/markets/%' OR p.slug = '/solutions' OR p.slug LIKE '/solutions/%')
|
||||
ORDER BY p.slug"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$setPrimary = 0;
|
||||
$setSecondary = 0;
|
||||
foreach ($rows as $row) {
|
||||
$actions = [];
|
||||
$pLabel = (string)$row['vitec_primary_cta_label'];
|
||||
$sLabel = (string)$row['vitec_secondary_cta_label'];
|
||||
// Sub-Solution-Variante: "Contact our team" / "Request a demo";
|
||||
// Market-Variante (Fund 28.10.): "Talk to our team" / "Explore solutions"
|
||||
if ((string)$row['vitec_primary_cta'] === '' && (stripos($pLabel, 'contact') !== false || stripos($pLabel, 'talk') !== false)) {
|
||||
$actions['vitec_primary_cta'] = 't3://page?uid=' . $contactUid;
|
||||
$setPrimary++;
|
||||
}
|
||||
if ((string)$row['vitec_secondary_cta'] === '') {
|
||||
if (stripos($sLabel, 'demo') !== false) {
|
||||
$actions['vitec_secondary_cta'] = 't3://page?uid=' . $demoUid;
|
||||
$setSecondary++;
|
||||
} elseif (stripos($sLabel, 'solution') !== false) {
|
||||
$actions['vitec_secondary_cta'] = 't3://page?uid=' . $solutionsUid;
|
||||
$setSecondary++;
|
||||
}
|
||||
}
|
||||
if ($actions === []) {
|
||||
continue;
|
||||
}
|
||||
echo "#" . $row['uid'] . " " . $row['slug'] . ": " . implode(', ', array_map(
|
||||
static fn(string $field, string $value): string => $field . ' = ' . $value,
|
||||
array_keys($actions), $actions
|
||||
)) . "\n";
|
||||
if ($apply) {
|
||||
$set = implode(', ', array_map(static fn(string $f): string => "$f = ?", array_keys($actions)));
|
||||
$pdo->prepare("UPDATE tt_content SET $set, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
|
||||
->execute([...array_values($actions), (int)$row['uid']]);
|
||||
}
|
||||
}
|
||||
echo "\n" . count($rows) . " Banner geprueft · $setPrimary primary + $setSecondary secondary " . ($apply ? 'gesetzt' : 'zu setzen') . ".\n";
|
||||
if (!$apply) {
|
||||
echo "Inspect only - mit --apply schreiben.\n";
|
||||
} else {
|
||||
echo "Danach: vendor/bin/typo3 cache:flush\n";
|
||||
}
|
||||
@@ -136,6 +136,19 @@ final class MenuBuilder
|
||||
'columns' => $columns,
|
||||
];
|
||||
|
||||
// Page-tree display (2026-11-02): same columns, two presentations -
|
||||
// `columns` side by side, or `rail`: the columns as a list on the
|
||||
// left, the hovered column's items as image cards on the right
|
||||
// (`cardSize` regular|compact). Additive: a front end that does not
|
||||
// know `display` keeps rendering columns.
|
||||
if ($layout === 'pagetree') {
|
||||
$display = (string)($entry['tree_display'] ?? 'columns');
|
||||
$built['display'] = str_starts_with($display, 'rail') ? 'rail' : 'columns';
|
||||
if ($built['display'] === 'rail') {
|
||||
$built['cardSize'] = $display === 'rail-compact' ? 'compact' : 'regular';
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog panels: the teaser cards serve as the featured tiles (left
|
||||
// column), the catalog data itself comes from a listener of
|
||||
// BuildCatalogEvent - this extension does not know the data source.
|
||||
@@ -229,10 +242,20 @@ final class MenuBuilder
|
||||
foreach ($this->pageRepository->getMenu($rootUid, '*', 'sorting', self::NAV_WHERE) as $child) {
|
||||
$items = [];
|
||||
foreach ($this->pageRepository->getMenu((int)$child['uid'], '*', 'sorting', self::NAV_WHERE) as $grandchild) {
|
||||
$items[] = [
|
||||
$item = [
|
||||
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
|
||||
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
|
||||
];
|
||||
// card data for the `rail` display (2026-11-02), same rules
|
||||
// as the spotlight nav: abstract as teaser, page media image
|
||||
if ($stage >= 2) {
|
||||
$teaser = trim((string)($grandchild['abstract'] ?? ''));
|
||||
if ($teaser !== '') {
|
||||
$item['teaser'] = $teaser;
|
||||
}
|
||||
$item['image'] = $this->image('pages', 'media', (int)$grandchild['uid']);
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
$column = [
|
||||
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace Evomedien\EvoMegamenuJson\UserFunc;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
|
||||
use TYPO3\CMS\Core\Cache\CacheTag;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
@@ -24,10 +26,24 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
* TYPO3 v14 hands the ContentObjectRenderer in through the setter rather
|
||||
* than the constructor; without it $this->cObj stays null and the element
|
||||
* path cannot see which record it is rendering.
|
||||
*
|
||||
* Cache (2026-11-02): the menu is part of every cached page, but its records
|
||||
* live on a storage folder - saving them cleared only that folder, and every
|
||||
* page cached before kept the old menu until a full flush. The page is
|
||||
* therefore tagged with the menu tables; the DataHandler flushes the tags
|
||||
* `<table>` and `<table>_<uid>` of every record it writes, so any save in the
|
||||
* menu refreshes all pages carrying it. Data the panels read from the page
|
||||
* tree (pagetree, catalog, spotlight) is not covered and still needs a flush.
|
||||
*/
|
||||
final class MegamenuJsonRenderer
|
||||
{
|
||||
private const CTYPE = 'evomegamenujson_megamenu';
|
||||
private const RECORD_TABLES = [
|
||||
'tx_evomegamenujson_entry',
|
||||
'tx_evomegamenujson_column',
|
||||
'tx_evomegamenujson_item',
|
||||
'tx_evomegamenujson_teaser',
|
||||
];
|
||||
|
||||
protected ?ContentObjectRenderer $cObj = null;
|
||||
|
||||
@@ -63,6 +79,7 @@ final class MegamenuJsonRenderer
|
||||
}
|
||||
|
||||
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
|
||||
$this->tagPageCache($cObj, $uid);
|
||||
|
||||
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
@@ -71,6 +88,21 @@ final class MegamenuJsonRenderer
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags the page cache with the menu element and tables (see class comment). */
|
||||
private function tagPageCache(ContentObjectRenderer $cObj, int $contentUid): void
|
||||
{
|
||||
try {
|
||||
$collector = $cObj->getRequest()->getAttribute('frontend.cache.collector');
|
||||
if (!$collector instanceof CacheDataCollectorInterface) {
|
||||
return;
|
||||
}
|
||||
$tags = array_map(static fn(string $table): CacheTag => new CacheTag($table), self::RECORD_TABLES);
|
||||
$collector->addCacheTags(new CacheTag('tt_content_' . $contentUid), ...$tags);
|
||||
} catch (\Throwable) {
|
||||
// without tags the menu still renders, it just ages until a flush
|
||||
}
|
||||
}
|
||||
|
||||
/** uid of the first megamenu element on a storage folder, 0 when none. */
|
||||
private function firstElementOn(int $pid): int
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ return [
|
||||
'1' => [
|
||||
'showitem' => '
|
||||
--div--;' . $ll . 'tab.general,
|
||||
hidden, title, link, layout,
|
||||
hidden, title, link, layout, tree_display,
|
||||
--div--;' . $ll . 'tab.columns,
|
||||
menu_columns,
|
||||
--div--;' . $ll . 'tab.teasers,
|
||||
@@ -115,6 +115,22 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
'tree_display' => [
|
||||
// page-tree panels only: same data, two presentations
|
||||
'displayCond' => 'FIELD:layout:=:pagetree',
|
||||
'label' => $ll . 'tree_display',
|
||||
'description' => $ll . 'tree_display.description',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 'columns',
|
||||
'items' => [
|
||||
['label' => $ll . 'tree_display.columns', 'value' => 'columns'],
|
||||
['label' => $ll . 'tree_display.rail', 'value' => 'rail'],
|
||||
['label' => $ll . 'tree_display.rail-compact', 'value' => 'rail-compact'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'menu_columns' => [
|
||||
'label' => $ll . 'menu_columns',
|
||||
|
||||
@@ -125,7 +125,15 @@ page, its children as the items. Hidden or nav-hidden pages leave the menu
|
||||
with the next cache flush. Curated columns are ignored; teasers and the
|
||||
featured slot stay editorial. From stage 2 a child page with media gets its
|
||||
first image as `image` on the column - attach images in the page
|
||||
properties and the panel turns visual by itself.
|
||||
properties and the panel turns visual by itself. From stage 2 the items
|
||||
carry `image` (first page media or `null`) and, when set, the page
|
||||
`abstract` as `teaser`.
|
||||
|
||||
The entry field *Page tree display* picks the presentation and travels as
|
||||
`display`: `columns` (default, columns side by side) or `rail` (the columns
|
||||
as a list on the left, the hovered column's items as image cards on the
|
||||
right) plus `cardSize` (`regular` | `compact`) for `rail`. A front end that
|
||||
ignores `display` keeps rendering columns.
|
||||
|
||||
### Layout `spotlight`
|
||||
|
||||
@@ -163,5 +171,9 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
|
||||
entry.
|
||||
* The renderer never throws: a broken menu returns an empty value rather than
|
||||
taking the page payload down.
|
||||
* Caching: every page carrying the menu is tagged with the menu tables and
|
||||
`tt_content_<uid>` of the menu element, so saving any menu record refreshes
|
||||
all of them. Panels built from the page tree (`pagetree`, `catalog`,
|
||||
`spotlight`) pick up page changes with the next cache flush.
|
||||
* Without `friendsoftypo3/headless` the element has no JSON envelope to render
|
||||
into — the extension is built for headless installations.
|
||||
@@ -27,6 +27,11 @@
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail"><source>List on the left, cards on hover</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail-compact"><source>List on the left, small cards on hover (for many sub-pages)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
|
||||
|
||||
@@ -8,6 +8,7 @@ CREATE TABLE tx_evomegamenujson_entry (
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
layout varchar(30) DEFAULT 'columns' NOT NULL,
|
||||
tree_display varchar(30) DEFAULT 'columns' NOT NULL,
|
||||
menu_columns int(11) DEFAULT '0' NOT NULL,
|
||||
menu_teasers int(11) DEFAULT '0' NOT NULL,
|
||||
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
|
||||
|
||||
@@ -32,6 +32,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* a missing numbered file falls back to the generic grey placeholder
|
||||
* and is reported for generation
|
||||
*
|
||||
* Market pages (0010-FE): an "Explore <Market>" card whose primary_cta
|
||||
* points to a /markets/... page gets that TARGET page's first media file
|
||||
* (mirrors the marketlist cards); these cards keep their text_only
|
||||
* variant (the FE styles them as market cards). Flipcards are skipped
|
||||
* entirely - they carry icons, never photos.
|
||||
*
|
||||
* A card that already has a vitec_image reference is NEVER touched, but it
|
||||
* still advances the numbering, so re-runs stay stable. Editors swap the
|
||||
* placeholder for the real asset on the element.
|
||||
@@ -45,7 +51,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
)]
|
||||
final class AttachCardImagesCommand extends Command
|
||||
{
|
||||
private const ROOTS = ['/products', '/insights', '/support', '/contact'];
|
||||
private const ROOTS = ['/products', '/insights', '/support', '/contact', '/markets'];
|
||||
private const FIRST_SLOT = 10;
|
||||
|
||||
protected function configure(): void
|
||||
@@ -77,7 +83,7 @@ final class AttachCardImagesCommand extends Command
|
||||
|
||||
$datamap = [];
|
||||
$newIndex = 0;
|
||||
$stats = ['photo' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'missing' => []];
|
||||
$stats = ['photo' => 0, 'pagemedia' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'upgraded' => 0, 'missing' => []];
|
||||
foreach ($pageUids as $pageUid => $slug) {
|
||||
$cards = $content->fetchAllAssociative(
|
||||
"SELECT uid, header, vitec_primary_cta, vitec_layout_variant FROM tt_content
|
||||
@@ -92,17 +98,43 @@ final class AttachCardImagesCommand extends Command
|
||||
$slot = self::FIRST_SLOT - 1;
|
||||
foreach ($cards as $card) {
|
||||
$cardUid = (int)$card['uid'];
|
||||
if ((string)$card['vitec_layout_variant'] === 'flipcard') {
|
||||
continue; // flipcards carry icons (vitec_icon), never photos
|
||||
}
|
||||
$cta = (string)$card['vitec_primary_cta'];
|
||||
$isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches);
|
||||
if (!$isProduct) {
|
||||
$slot++;
|
||||
}
|
||||
$existing = (int)$refs->fetchOne(
|
||||
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content'
|
||||
AND fieldname = 'vitec_image' AND uid_foreign = ?",
|
||||
$existingRef = $refs->fetchAssociative(
|
||||
"SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r
|
||||
JOIN sys_file f ON f.uid = r.uid_local
|
||||
WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
|
||||
AND r.fieldname = 'vitec_image' AND r.uid_foreign = ?
|
||||
ORDER BY r.sorting_foreign LIMIT 1",
|
||||
[$cardUid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
if ($existingRef) {
|
||||
// upgrade pass (2026-10-28): a product card that got the
|
||||
// generic grey because its record had no image swaps to
|
||||
// the real photo once the delivery arrived
|
||||
if ($isProduct && str_ends_with((string)$existingRef['identifier'], 'placeholder-grey.png')) {
|
||||
$photo = $this->productImageIdentifier($pool, $matches[1]);
|
||||
if ($photo !== null && $storage->hasFile($photo)) {
|
||||
$output->writeln(sprintf(' ^ #%d "%s": grau -> %s', $cardUid, (string)$card['header'], basename($photo)));
|
||||
$stats['upgraded']++;
|
||||
if ($apply) {
|
||||
$datamap['sys_file_reference'][(string)(int)$existingRef['uid']] = [
|
||||
'uid_local' => $storage->getFile($photo)->getUid(),
|
||||
];
|
||||
}
|
||||
if ((string)$card['vitec_layout_variant'] === 'text_only') {
|
||||
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
|
||||
$stats['variant']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$stats['kept']++;
|
||||
// a card that carries an image must not stay on the
|
||||
// explicit no-image variant (found 2026-10-27: the FE
|
||||
@@ -115,6 +147,7 @@ final class AttachCardImagesCommand extends Command
|
||||
}
|
||||
$identifier = null;
|
||||
$note = '';
|
||||
$isMarketCard = false;
|
||||
if ($isProduct) {
|
||||
$identifier = $this->productImageIdentifier($pool, $matches[1]);
|
||||
if ($identifier !== null) {
|
||||
@@ -125,6 +158,13 @@ final class AttachCardImagesCommand extends Command
|
||||
$stats['grey']++;
|
||||
$note = 'record ' . $matches[1] . ' has NO image - generic grey';
|
||||
}
|
||||
} elseif (($pageMedia = $this->marketPageMediaIdentifier($pool, $cta)) !== null) {
|
||||
// 0010-FE: mirror the marketlist cards, which show the
|
||||
// target sub-market page's media
|
||||
$identifier = $pageMedia;
|
||||
$isMarketCard = true;
|
||||
$stats['pagemedia']++;
|
||||
$note = 'target page media';
|
||||
} else {
|
||||
$identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png';
|
||||
if ($storage->hasFile($identifier)) {
|
||||
@@ -145,7 +185,9 @@ final class AttachCardImagesCommand extends Command
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
if ((string)$card['vitec_layout_variant'] === 'text_only') {
|
||||
// market cards keep text_only - the FE keys its market-card
|
||||
// styling on that variant (0010-FE) and renders the image anyway
|
||||
if (!$isMarketCard && (string)$card['vitec_layout_variant'] === 'text_only') {
|
||||
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
|
||||
$stats['variant']++;
|
||||
}
|
||||
@@ -161,8 +203,8 @@ final class AttachCardImagesCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n%d product photo(s), %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d variant flip(s) text_only->vertical%s",
|
||||
$stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['variant'],
|
||||
$output->writeln(sprintf("\n%d product photo(s), %d target page media, %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d grey->photo upgrade(s), %d variant flip(s) text_only->vertical%s",
|
||||
$stats['photo'], $stats['pagemedia'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['upgraded'], $stats['variant'],
|
||||
$stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing'])));
|
||||
|
||||
if (!$apply) {
|
||||
@@ -184,6 +226,32 @@ final class AttachCardImagesCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* First media file of the /markets/... page the card links to (plain
|
||||
* slug or t3://page?uid=N); null when the target is no market page or
|
||||
* has no media.
|
||||
*/
|
||||
private function marketPageMediaIdentifier(ConnectionPool $pool, string $cta): ?string
|
||||
{
|
||||
$pages = $pool->getConnectionForTable('pages');
|
||||
$page = null;
|
||||
if (preg_match('#^t3://page\?uid=(\d+)#', $cta, $m)) {
|
||||
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$m[1]]);
|
||||
} elseif (preg_match('#^/markets/[a-z0-9+/-]+$#', $cta)) {
|
||||
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$cta]);
|
||||
}
|
||||
if (!$page || !str_starts_with((string)$page['slug'], '/markets/')) {
|
||||
return null;
|
||||
}
|
||||
$identifier = $pool->getConnectionForTable('sys_file_reference')->fetchOne(
|
||||
"SELECT f.identifier 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 = 'pages'
|
||||
AND r.fieldname = 'media' AND r.uid_foreign = ?
|
||||
ORDER BY r.sorting_foreign LIMIT 1",
|
||||
[(int)$page['uid']]
|
||||
);
|
||||
return $identifier ? ltrim((string)$identifier, '/') : null;
|
||||
}
|
||||
/** First product photo, falling back to the hero image; null when the record has neither. */
|
||||
private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string
|
||||
{
|
||||
|
||||
@@ -24,12 +24,20 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* Image per page: the numbered placeholder
|
||||
* `fileadmin/placeholders/pages/<pid>-08.png` when it exists (slot 08 =
|
||||
* page media / menu tile; the number in the picture doubles as the image
|
||||
* wish list towards the agency), otherwise the generic wide grey
|
||||
* placeholder. A page that already has a media reference is NEVER touched -
|
||||
* editors swap the placeholder for the real image in the page properties.
|
||||
* wish list towards the agency), else the page's own hero background
|
||||
* (`vitec_bgimage`, same file referenced - 2026-11-02, for the sub-market /
|
||||
* sub-solution cards of the megamenu `rail` display), otherwise the generic
|
||||
* wide grey placeholder. A page that already has a media reference is NEVER
|
||||
* touched - editors swap the image in the page properties.
|
||||
*
|
||||
* --depth=2 targets the grandchildren (e.g. the sub-markets below
|
||||
* /markets/<market>) instead of the children; --depth=3 reaches through two
|
||||
* levels (sysfolders pass as intermediate parents, so the product overview
|
||||
* pages below /products > category folder > group folder are covered).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/support
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/support --apply
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/markets --depth=2 --apply
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/products --depth=3 --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:attach-page-media',
|
||||
@@ -40,6 +48,7 @@ final class AttachPageMediaCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /support');
|
||||
$this->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 = children of the root, 2 = their children, 3 = one level further', '1');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
@@ -56,11 +65,18 @@ final class AttachPageMediaCommand extends Command
|
||||
$output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$depth = max(1, min(3, (int)$input->getOption('depth')));
|
||||
$parents = [(int)$root['uid']];
|
||||
for ($level = 1; $level <= $depth; $level++) {
|
||||
// intermediate levels pass through sysfolders (254); only real
|
||||
// pages (doktype 1) are targets on the final level
|
||||
$doktypes = $level < $depth ? '1, 254' : '1';
|
||||
$children = $pages->fetchAllAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
|
||||
[(int)$root['uid']]
|
||||
'SELECT uid, pid, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype IN (' . $doktypes . ') AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting'
|
||||
);
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · %d child page(s)', $rootSlug, (int)$root['uid'], count($children)));
|
||||
$parents = array_map('intval', array_column($children, 'uid'));
|
||||
}
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · depth %d · %d page(s)', $rootSlug, (int)$root['uid'], $depth, count($children)));
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$refs = $pool->getConnectionForTable('sys_file_reference');
|
||||
@@ -76,10 +92,19 @@ final class AttachPageMediaCommand extends Command
|
||||
$output->writeln(sprintf(' = #%d "%s": has media (%d) - untouched', $uid, (string)$child['title'], $existing));
|
||||
continue;
|
||||
}
|
||||
$fileUid = 0;
|
||||
$identifier = 'placeholders/pages/' . $uid . '-08.png';
|
||||
if (!$storage->hasFile($identifier)) {
|
||||
$identifier = 'placeholders/placeholder-grey-wide.png';
|
||||
if (!$storage->hasFile($identifier)) {
|
||||
// the page's own hero background, first hero on the page
|
||||
$fileUid = (int)$refs->fetchOne(
|
||||
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
|
||||
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_bgimage'
|
||||
AND c.deleted = 0 AND c.hidden = 0 AND c.pid = ?
|
||||
ORDER BY c.colPos, c.sorting, r.sorting_foreign LIMIT 1",
|
||||
[$uid]
|
||||
);
|
||||
$identifier = $fileUid > 0 ? 'hero image (sys_file #' . $fileUid . ')' : 'placeholders/placeholder-grey-wide.png';
|
||||
if ($fileUid === 0 && !$storage->hasFile($identifier)) {
|
||||
$output->writeln(sprintf(' <comment>! #%d "%s": no placeholder file - skipped</comment>', $uid, (string)$child['title']));
|
||||
continue;
|
||||
}
|
||||
@@ -88,11 +113,13 @@ final class AttachPageMediaCommand extends Command
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
$file = $storage->getFile($identifier);
|
||||
if ($fileUid === 0) {
|
||||
$fileUid = $storage->getFile($identifier)->getUid();
|
||||
}
|
||||
$datamap['sys_file_reference']['NEWPM' . (++$newIndex)] = [
|
||||
'pid' => $uid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $file->getUid(),
|
||||
'uid_local' => $fileUid,
|
||||
'tablenames' => 'pages',
|
||||
'uid_foreign' => $uid,
|
||||
'fieldname' => 'media',
|
||||
|
||||
@@ -888,13 +888,13 @@ final class BuildContentPagesCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
/** @param string[] $items */
|
||||
private function ul(array $items): string
|
||||
{
|
||||
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_QUOTES | ENT_HTML5) . '</li>', $items);
|
||||
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_NOQUOTES | ENT_HTML5) . '</li>', $items);
|
||||
return '<ul>' . implode('', $li) . '</ul>';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
<?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\Resource\StorageRepository;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Builds the product section structure of the agency's "VITEC Product
|
||||
* Sitemap" / "Product Menu Prototype" (delivery 2026-11-02) in the page tree
|
||||
* below /products:
|
||||
*
|
||||
* /products
|
||||
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
|
||||
* [folder] sub-category (B, menu label) e.g. "End-Points"
|
||||
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
|
||||
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
|
||||
*
|
||||
* The page tree is the single source of the products menu (ProvideProduct
|
||||
* Catalog reads it): editors rename, reorder or move overview pages there.
|
||||
* A and B are sysfolders - never routed, never in navigation menus. The 20
|
||||
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
|
||||
* are new or still empty, generated content: hero (page title, placeholder
|
||||
* background - 0026-FE), one intro paragraph per grouping title (or
|
||||
* "Product range"), a product card per sub-product (record teaser, link
|
||||
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
|
||||
* no image get the placeholder backfilled; any existing reference wins.
|
||||
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
|
||||
*
|
||||
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
|
||||
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
|
||||
* existing records (UHD2, SD14, HD4, ...) - very likely an Excel
|
||||
* auto-fill artefact; cards show the RECORD title
|
||||
* - products outside the sitemap (APEX, Arqa family, Avedia/EZ TV
|
||||
* End-Points, VisionSC-SDI4, ActiveSQX) stay as records, just not in
|
||||
* the new structure; the nine old category pages stay as well
|
||||
* - "Avedia Modular Chassis" exists twice in the sitemap and as two
|
||||
* records - each occurrence is pinned to one record by slug
|
||||
*
|
||||
* Idempotent: existing folders/pages/shortcuts are found by title (folders,
|
||||
* shortcuts) or slug (overview pages); content is only generated on pages
|
||||
* without any content element, so editorial work is never overwritten.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:build-product-overviews
|
||||
* vendor/bin/typo3 vitec:build-product-overviews --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:build-product-overviews',
|
||||
description: 'Build the product menu structure (folders, overview pages, shortcuts) from the 2026-11 sitemap (dry-run by default)'
|
||||
)]
|
||||
final class BuildProductOverviewsCommand extends Command
|
||||
{
|
||||
private const LAYOUT = 9;
|
||||
private const DOKTYPE_PAGE = 1;
|
||||
private const DOKTYPE_SHORTCUT = 4;
|
||||
private const DOKTYPE_FOLDER = 254;
|
||||
private const COLPOS_HERO = 1;
|
||||
private const COLPOS_MAIN = 0;
|
||||
private const COLPOS_PREFOOTER = 3;
|
||||
|
||||
/**
|
||||
* The agency sitemap (VITEC Product Sitemap.html, 2026-11-02). Items are
|
||||
* product titles; ['slug' => ...] pins a record where the title is
|
||||
* ambiguous or differs; ['group' => ...] is a grouping title (not a page).
|
||||
* Shortcuts point to a group (B) or an overview page by slug.
|
||||
*/
|
||||
private const STRUCTURE = [
|
||||
['Platforms and End-Points', [
|
||||
['IPTV Distribution & Signage Platform', [
|
||||
['page' => 'Avedia', 'slug' => 'avedia', 'items' => ['Avedia Platform']],
|
||||
['page' => 'EZ TV', 'slug' => 'ez-tv', 'items' => ['EZ TV Platform']],
|
||||
]],
|
||||
['End-Points', [
|
||||
['page' => 'X-Player End-Points', 'slug' => 'x-player-end-points', 'items' => ['X-Player End-Points', '95-Series End-Points']],
|
||||
['page' => 'X-View Integrated Displays', 'slug' => 'x-view-integrated-displays', 'items' => ['X-View Integrated Displays']],
|
||||
['shortcut' => 'Video Wall Controllers', 'toGroup' => 'Video Walls'],
|
||||
]],
|
||||
['Control Room Platform', [
|
||||
['page' => 'Aetria', 'slug' => 'aetria', 'items' => ['Aetria WallControl', 'Aetria Command Center', 'Aetria Network Manager', 'Aetria Touch']],
|
||||
['page' => 'Operator Workstations', 'slug' => 'operator-workstations', 'items' => ['Aetria Workstation', 'Aligo Workstation']],
|
||||
['shortcut' => 'Aligo KVM', 'toPage' => 'aligo'],
|
||||
]],
|
||||
]],
|
||||
['IP Video Streaming, Distribution & KVM', [
|
||||
['Encoders & Decoders', [
|
||||
['page' => 'Appliances', 'slug' => 'appliances', 'items' => [
|
||||
['MGW Diamond-H', 'HDMI encoder'], ['MGW Diamond', 'SDI encoder'], ['MGW Ace encoder', 'ultra-low latency encoder'],
|
||||
['MGW Ace decoder', 'ultra-low latency decoder'], ['MGW Pico', 'portable encoder'],
|
||||
]],
|
||||
['page' => 'Modular Systems', 'slug' => 'modular-systems', 'items' => [
|
||||
['group' => 'VITEC OG', 'items' => [
|
||||
['MGW Diamond OG', 'SDI & composite blade encoder'], ['MGW Diamond+ OG', 'SDI blade encoder'],
|
||||
['MGW Diamond-Hx OG', 'HDMI/DVI blade encoder'], ['MGW Ace Decoder OG', 'ultra-low latency blade decoder'],
|
||||
['Diamond IP-OG', 'IP to IP encoder'],
|
||||
]],
|
||||
['group' => 'Avedia', 'items' => [
|
||||
['38-Series Encoders', 'modular blade systems'], ['39-Series Encoders', 'modular blade systems'],
|
||||
['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210'],
|
||||
]],
|
||||
['group' => 'Blade System', 'items' => [
|
||||
['MGES7000', 'High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade'],
|
||||
['MGES6000', 'Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder'],
|
||||
]],
|
||||
]],
|
||||
['page' => 'Rugged Appliances', 'slug' => 'rugged-appliances', 'items' => ['MGW Diamond TOUGH', 'MGW Pico+ TOUGH', 'MGW Pico TOUGH']],
|
||||
]],
|
||||
['RF Gateway', [
|
||||
['page' => 'Avedia Modular System', 'slug' => 'avedia-modular-system', 'items' => [
|
||||
'45-series RF gateways', ['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1'],
|
||||
]],
|
||||
]],
|
||||
['IP Gateway & Transcoder', [
|
||||
['page' => 'ChannelLink IP Gateway', 'slug' => 'channellink-ip-gateway', 'items' => ['ChannelLink']],
|
||||
['page' => 'PRISM Transcoder', 'slug' => 'prism-transcoder', 'items' => [
|
||||
['PRISM', '1RU rack-mount transcoder'], ['SFF PRISM', 'Small format appliance transcoder'],
|
||||
]],
|
||||
]],
|
||||
['AVoIP & KVM', [
|
||||
['page' => 'Aligo', 'slug' => 'aligo', 'items' => ['QTX100', 'TX100', 'RX100', 'TX100 SQX']],
|
||||
]],
|
||||
]],
|
||||
['Video Wall Controllers & Management', [
|
||||
['Video Walls', [
|
||||
['page' => 'VSN-Series Video Wall Controllers', 'slug' => 'vsn-series-video-wall-controllers', 'items' => ['VSN V3', 'VSNMicro 600', 'VSNMini 300']],
|
||||
['page' => 'Video Wall Management Software', 'slug' => 'video-wall-management-software', 'items' => [
|
||||
['Aetria', null, 'aetria'], 'Wall Control 10',
|
||||
]],
|
||||
['page' => 'VMS Plugins & Integrations', 'slug' => 'vms-plugins-and-integrations', 'items' => ['Genetec', 'Milestone']],
|
||||
['page' => 'X-Series Multi-Display Processors', 'slug' => 'x-series-multi-display-processors', 'items' => ['Fx4', 'Fx4-HDR', 'Fx4-SDI', 'Hx4']],
|
||||
]],
|
||||
['Capture & Graphics', [
|
||||
['page' => 'Image Graphics Cards', 'slug' => 'image-graphics-cards', 'items' => ['Image 2K', 'Image 4K', 'IQS4']],
|
||||
['page' => 'Vision Video Capture Cards', 'slug' => 'vision-video-capture-cards', 'items' => [
|
||||
['group' => 'VisionSC', 'items' => ['VisionSC-UHD3', 'VisionSC-DP3', 'VisionSC-SD15', 'VisionSC-HD4+', 'VisionSC-S3', 'VisionSC-A3']],
|
||||
['group' => 'VisionIO', 'desc' => 'real-time capture + overlay', 'items' => ['VisionIO-SD15', 'VisionIO-XD3']],
|
||||
['group' => 'VisionAV', 'desc' => 'video + audio capture', 'items' => ['VisionAV-SDI', 'VisionAV-HD', 'VisionAV', 'VisionHD5']],
|
||||
['group' => 'Vision', 'desc' => 'DVI & SDI capture cards', 'items' => ['VisionRGB-E1S', 'VisionRGB-E2S', 'VisionSD4+1S', 'VisionSD12', 'VisionSD8']],
|
||||
['group' => 'VisionLC', 'desc' => 'low-profile capture cards', 'items' => ['VisionLC-HD', 'VisionLC-HD3', 'VisionLC-SDI']],
|
||||
]],
|
||||
['page' => 'ActiveSQX', 'slug' => 'activesqx', 'desc' => 'IP encode/decode', 'items' => ['ActiveSQX2']],
|
||||
]],
|
||||
]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Sitemap names that do not exist as records - mapped onto the existing
|
||||
* record by slug. The Vision "+1" names are almost certainly an Excel
|
||||
* auto-fill artefact (same pattern in the team tracker).
|
||||
*/
|
||||
private const NAME_TO_SLUG = [
|
||||
'VisionSC-UHD3' => 'visionsc-uhd2',
|
||||
'VisionSC-DP3' => 'visionsc-dp2',
|
||||
'VisionSC-SD15' => 'visionsc-sd14',
|
||||
'VisionSC-S3' => 'visionsc-s2',
|
||||
'VisionSC-A3' => 'visionsc-a2',
|
||||
'VisionIO-SD15' => 'visionio-sd14',
|
||||
'VisionIO-XD3' => 'visionio-xd2',
|
||||
'VisionHD5' => 'visionhd4',
|
||||
'VisionLC-HD3' => 'visionlc-hd2',
|
||||
];
|
||||
|
||||
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||
private array $datamap = [];
|
||||
/** @var array<string,string[]> parent id => ordered child page ids */
|
||||
private array $pageOrder = [];
|
||||
/** @var array<string,array<int,string[]>> page id => colPos => ordered content ids */
|
||||
private array $contentOrder = [];
|
||||
/** @var string[] */
|
||||
private array $report = [];
|
||||
private int $newCounter = 0;
|
||||
private bool $apply = false;
|
||||
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
|
||||
private ?array $products = null;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = $this->apply = (bool)$input->getOption('apply');
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)");
|
||||
if (!$root) {
|
||||
$output->writeln('<error>/products not found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$rootId = (string)(int)$root['uid'];
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $rootId);
|
||||
|
||||
$groupFolderIds = []; // B title => id
|
||||
$overviewBySlug = []; // slug => id
|
||||
$shortcuts = []; // [id, spec]
|
||||
|
||||
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
|
||||
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
|
||||
foreach ($groups as [$groupTitle, $entries]) {
|
||||
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
|
||||
$groupFolderIds[$groupTitle] = $groupId;
|
||||
foreach ($entries as $entry) {
|
||||
if (isset($entry['shortcut'])) {
|
||||
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
|
||||
continue;
|
||||
}
|
||||
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shortcut targets (a group folder or an overview page)
|
||||
foreach ($shortcuts as [$shortcutId, $spec]) {
|
||||
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
|
||||
if ($target === null) {
|
||||
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
|
||||
continue;
|
||||
}
|
||||
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
|
||||
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
|
||||
}
|
||||
|
||||
foreach ($this->report as $line) {
|
||||
$output->writeln($line);
|
||||
}
|
||||
if (!$apply) {
|
||||
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($this->datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->applyOrder($dataHandler->substNEWwithIDs);
|
||||
|
||||
$output->writeln("\n<info>Written.</info> Next, per overview page:");
|
||||
foreach ($overviewBySlug as $slug => $id) {
|
||||
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
|
||||
}
|
||||
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// ---- structure -------------------------------------------------------------------
|
||||
|
||||
private function folder(string $parentId, string $title, string $kind): string
|
||||
{
|
||||
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null;
|
||||
if ($existing !== null) {
|
||||
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
|
||||
$this->pageOrder[$parentId][] = (string)$existing;
|
||||
return (string)$existing;
|
||||
}
|
||||
$id = $this->newId();
|
||||
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
|
||||
$this->pageOrder[$parentId][] = $id;
|
||||
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function shortcutPage(string $groupId, string $title): string
|
||||
{
|
||||
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
|
||||
if ($existing !== null) {
|
||||
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
|
||||
$this->pageOrder[$groupId][] = (string)$existing;
|
||||
return (string)$existing;
|
||||
}
|
||||
$id = $this->newId();
|
||||
$this->datamap['pages'][$id] = ['pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_SHORTCUT, 'title' => $title, 'hidden' => 0];
|
||||
$this->pageOrder[$groupId][] = $id;
|
||||
$this->report[] = sprintf(' shortcut "%s" CREATE', $title);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $entry */
|
||||
private function overviewPage(string $groupId, array $entry): string
|
||||
{
|
||||
$slug = '/products/' . $entry['slug'];
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$existing = $pages->fetchAssociative(
|
||||
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
|
||||
);
|
||||
[$cards, $names] = $this->resolveItems($entry['items']);
|
||||
|
||||
if ($existing) {
|
||||
$pageId = (string)(int)$existing['uid'];
|
||||
$this->pageOrder[$groupId][] = $pageId;
|
||||
$updates = [];
|
||||
if ((string)(int)$existing['pid'] !== $groupId) {
|
||||
$updates['pid'] = $this->pid($groupId); // moved into its folder
|
||||
}
|
||||
if ($updates !== []) {
|
||||
$this->datamap['pages'][$pageId] = $updates;
|
||||
}
|
||||
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
|
||||
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [(int)$pageId]);
|
||||
$this->report[] = sprintf(' page "%s" %s #%s (exists, %d elements%s)', $entry['page'], $slug, $pageId, $contentCount,
|
||||
$contentCount > 0 ? ' - content left untouched' : ' - content generated');
|
||||
if ($contentCount === 0) {
|
||||
$this->content($pageId, $entry, $cards);
|
||||
} else {
|
||||
$this->backfillHeroImage((int)$pageId);
|
||||
}
|
||||
} else {
|
||||
$pageId = $this->newId();
|
||||
$title = (string)$entry['page'];
|
||||
$description = 'VITEC ' . $title . ': ' . implode(', ', $names) . '.';
|
||||
$this->datamap['pages'][$pageId] = [
|
||||
'pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
|
||||
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
|
||||
'seo_title' => $title . ' | VITEC', 'description' => $description,
|
||||
'og_title' => $title . ' | VITEC', 'og_description' => $description,
|
||||
'abstract' => implode(', ', $names),
|
||||
];
|
||||
$this->pageOrder[$groupId][] = $pageId;
|
||||
$this->report[] = sprintf(' page "%s" %s CREATE (%d product cards)', $entry['page'], $slug, count(array_filter($cards, fn($c) => !isset($c['group']))));
|
||||
$this->content($pageId, $entry, $cards);
|
||||
}
|
||||
foreach ($cards as $card) {
|
||||
if (isset($card['group'])) {
|
||||
$this->report[] = ' [' . $card['group'] . ']';
|
||||
} elseif ($card['record'] === null) {
|
||||
$this->report[] = ' ! "' . $card['name'] . '" - NO record, card without link';
|
||||
} elseif ($card['mapped']) {
|
||||
$this->report[] = ' "' . $card['name'] . '" -> ' . $card['record']['title'] . ' (/product/' . $card['record']['slug'] . ', name mapped)';
|
||||
}
|
||||
}
|
||||
return $pageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the sitemap items into card specs, grouping titles kept as
|
||||
* markers.
|
||||
*
|
||||
* @param array<int,mixed> $items
|
||||
* @return array{0:array<int,array<string,mixed>>,1:string[]}
|
||||
*/
|
||||
private function resolveItems(array $items): array
|
||||
{
|
||||
$cards = [];
|
||||
$names = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item) && isset($item['group'])) {
|
||||
$cards[] = ['group' => $item['group'], 'desc' => $item['desc'] ?? ''];
|
||||
[$sub, $subNames] = $this->resolveItems($item['items']);
|
||||
$cards = array_merge($cards, $sub);
|
||||
$names = array_merge($names, $subNames);
|
||||
continue;
|
||||
}
|
||||
[$name, $desc, $slug] = is_array($item) ? [$item[0], $item[1] ?? '', $item[2] ?? null] : [$item, '', null];
|
||||
$slug ??= self::NAME_TO_SLUG[$name] ?? null;
|
||||
$record = $slug !== null ? $this->productBySlug($slug) : $this->productByTitle($name);
|
||||
$cards[] = ['name' => $name, 'desc' => (string)$desc, 'record' => $record, 'mapped' => isset(self::NAME_TO_SLUG[$name])];
|
||||
$names[] = $record['title'] ?? $name;
|
||||
}
|
||||
return [$cards, $names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated content of a new or empty overview page (layout 9 zones).
|
||||
*
|
||||
* @param array<string,mixed> $entry
|
||||
* @param array<int,array<string,mixed>> $cards
|
||||
*/
|
||||
private function content(string $pageId, array $entry, array $cards): void
|
||||
{
|
||||
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [
|
||||
'header' => (string)$entry['page'], 'header_layout' => 1,
|
||||
'bodytext' => isset($entry['desc']) ? $this->p(ucfirst((string)$entry['desc'])) : '',
|
||||
]);
|
||||
// no overview page goes live with an empty fullscreen hero (0026-FE):
|
||||
// the numbered placeholder when the page already has a uid, else the
|
||||
// generic wide grey one (swap the real asset in later)
|
||||
$placeholder = $this->heroPlaceholder($pageId);
|
||||
if ($placeholder === null) {
|
||||
$this->report[] = ' ! hero: no placeholder file in fileadmin - left empty';
|
||||
} elseif ($this->apply) {
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $this->pid($pageId),
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
||||
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
||||
];
|
||||
$this->datamap['tt_content'][$heroId]['hero_bgimage'] = $refId;
|
||||
$this->report[] = ' hero: attach ' . $placeholder;
|
||||
} else {
|
||||
$this->report[] = ' hero: would attach ' . $placeholder;
|
||||
}
|
||||
if (!isset($cards[0]['group'])) {
|
||||
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
|
||||
}
|
||||
foreach ($cards as $card) {
|
||||
if (isset($card['group'])) {
|
||||
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', [
|
||||
'header' => (string)$card['group'],
|
||||
'bodytext' => $card['desc'] !== '' ? $this->p(ucfirst((string)$card['desc'])) : '',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$record = $card['record'];
|
||||
$teaser = trim(strip_tags((string)($record['teaser'] ?? '')));
|
||||
$fields = [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => $record['title'] ?? $card['name'],
|
||||
'bodytext' => $this->p($teaser !== '' ? $teaser : ucfirst((string)$card['desc'])),
|
||||
'vitec_primary_cta_label' => 'Learn more',
|
||||
];
|
||||
if ($record !== null) {
|
||||
$fields['vitec_primary_cta'] = '/product/' . $record['slug'];
|
||||
}
|
||||
$this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
|
||||
}
|
||||
$this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [
|
||||
'header' => 'Talk to a VITEC expert',
|
||||
'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'),
|
||||
'vitec_primary_cta_label' => 'Contact us',
|
||||
'vitec_primary_cta' => $this->contactLink(),
|
||||
'vitec_layout_variant' => 'centered',
|
||||
'vitec_background_variant' => 'orange',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function element(string $pageId, int $colPos, string $ctype, array $fields): string
|
||||
{
|
||||
$id = $this->newId();
|
||||
$this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields;
|
||||
$this->contentOrder[$pageId][$colPos][] = $id;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the placeholder hero background to an EXISTING overview page
|
||||
* whose hero has no image yet (0026-FE: all 20 pages rendered an empty
|
||||
* fullscreen hero). A hero that carries ANY reference is never touched -
|
||||
* editors swap images in the element.
|
||||
*/
|
||||
private function backfillHeroImage(int $pageUid): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$heroUid = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_herosection' ORDER BY colPos, sorting LIMIT 1",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($heroUid === 0) {
|
||||
return;
|
||||
}
|
||||
$existing = (int)$pool->getConnectionForTable('sys_file_reference')->fetchOne(
|
||||
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'hero_bgimage' AND uid_foreign = ?",
|
||||
[$heroUid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
return;
|
||||
}
|
||||
$placeholder = $this->heroPlaceholder((string)$pageUid);
|
||||
if ($placeholder === null) {
|
||||
$this->report[] = ' ! hero #' . $heroUid . ': empty and no placeholder file - left empty';
|
||||
return;
|
||||
}
|
||||
if (!$this->apply) {
|
||||
$this->report[] = ' hero #' . $heroUid . ': empty, would attach ' . $placeholder;
|
||||
return;
|
||||
}
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pageUid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
||||
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
||||
'tablenames' => 'tt_content',
|
||||
'uid_foreign' => $heroUid,
|
||||
'fieldname' => 'hero_bgimage',
|
||||
];
|
||||
$this->datamap['tt_content'][$heroUid]['hero_bgimage'] = 1;
|
||||
$this->report[] = ' hero #' . $heroUid . ': attach ' . $placeholder;
|
||||
}
|
||||
|
||||
/** The hero placeholder identifier for a page: numbered when available, generic wide grey otherwise, null when neither file exists. */
|
||||
private function heroPlaceholder(string $pageId): ?string
|
||||
{
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$candidates = ctype_digit($pageId) ? ['placeholders/pages/' . $pageId . '-01.png'] : [];
|
||||
$candidates[] = 'placeholders/placeholder-grey-wide.png';
|
||||
foreach ($candidates as $identifier) {
|
||||
if ($storage->hasFile($identifier)) {
|
||||
return $identifier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- lookups -----------------------------------------------------------------------
|
||||
|
||||
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
|
||||
{
|
||||
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
||||
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
|
||||
[$parentUid, $title, $doktype]
|
||||
);
|
||||
return $uid !== false ? (int)$uid : null;
|
||||
}
|
||||
|
||||
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
|
||||
private function products(): array
|
||||
{
|
||||
return $this->products ??= GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getConnectionForTable('tx_vitec_domain_model_product')
|
||||
->fetchAllAssociative('SELECT uid, title, slug, teaser FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY uid');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function productBySlug(string $slug): ?array
|
||||
{
|
||||
foreach ($this->products() as $row) {
|
||||
if ((string)$row['slug'] === $slug) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null exact (normalized) title, first visible match by uid */
|
||||
private function productByTitle(string $title): ?array
|
||||
{
|
||||
foreach ($this->products() as $row) {
|
||||
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function contactLink(): string
|
||||
{
|
||||
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')
|
||||
->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/contact'");
|
||||
return $row ? 't3://page?uid=' . (int)$row['uid'] : '';
|
||||
}
|
||||
|
||||
// ---- plumbing ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DataHandler puts every NEW record with a positive pid at the TOP of
|
||||
* its parent/column (lesson 2026-10-07) - sorting is written explicitly
|
||||
* in sitemap order for pages and content alike.
|
||||
*
|
||||
* @param array<string,int> $subst
|
||||
*/
|
||||
private function applyOrder(array $subst): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$resolve = static fn(string $id): int => ctype_digit($id) ? (int)$id : (int)($subst[$id] ?? 0);
|
||||
foreach ($this->pageOrder as $parentId => $children) {
|
||||
$sorting = 0;
|
||||
foreach ($children as $childId) {
|
||||
$uid = $childId !== '' ? $resolve($childId) : 0;
|
||||
if ($uid > 0) {
|
||||
$sorting += 256;
|
||||
$pool->getConnectionForTable('pages')->update('pages', ['sorting' => $sorting], ['uid' => $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($this->contentOrder as $pageId => $columns) {
|
||||
foreach ($columns as $ids) {
|
||||
$sorting = 0;
|
||||
foreach ($ids as $id) {
|
||||
$uid = $resolve($id);
|
||||
if ($uid > 0) {
|
||||
$sorting += 256;
|
||||
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function pid(string $id): int|string
|
||||
{
|
||||
return ctype_digit($id) ? (int)$id : $id;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWPO' . (++$this->newCounter);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,24 @@ final class BuildProductPortfolioCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$pageUid = (int)$root['uid'];
|
||||
|
||||
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
|
||||
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
|
||||
// der Slot-Cursor sie nicht sieht - ein Re-Run hat deshalb die 15
|
||||
// Container versteckt und 38 Duplikat-Karten erzeugt (Repair:
|
||||
// migrations/fix_products_grid.php). Auf einer gewickelten Seite
|
||||
// darf dieser Builder nicht mehr laufen.
|
||||
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($containerCount > 0) {
|
||||
$output->writeln('<error>/products ist grid-gewickelt (' . $containerCount
|
||||
. ' vitec_cols-Container). Ein Re-Run wuerde Karten duplizieren und die Grids verstecken - Abbruch.'
|
||||
. ' Fuer Einzelaenderungen die Karten im BE pflegen oder erst die Grids aufloesen.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
// the page sits on layout 0 (Default, main only) - the portfolio
|
||||
// needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27:
|
||||
// the page's own content was all deleted, the rendered payload came
|
||||
@@ -391,7 +409,9 @@ final class BuildProductPortfolioCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu ',
|
||||
// und die RTE-Persistenz escapte das & gleich nochmal (&apos; im JSON)
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -30,7 +30,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* the Dropbox folder names, so a vitec:import-product-images re-run attaches
|
||||
* the delivered banner/carousel assets immediately.
|
||||
*
|
||||
* IQS4 stays out on purpose: its delivery folder is empty - question to Joe.
|
||||
* 2026-10-28: IQS4 and VisionSC-SDI4 added - the fresh Dropbox dump
|
||||
* delivered their banners, and both datasheets already sit in the pool.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:create-missing-products
|
||||
* vendor/bin/typo3 vitec:create-missing-products --apply
|
||||
@@ -53,6 +54,11 @@ final class CreateMissingProductsCommand extends Command
|
||||
'Rugged portable HEVC/H.264 encoder for demanding field deployments.'],
|
||||
['X-View Integrated Displays', 'x-view-integrated-displays', 'x-player-end-points-xp23-xp25-xp26',
|
||||
'Commercial displays with an integrated IPTV end-point for TV and signage delivery.'],
|
||||
// 2026-10-28 (delivery brought banners; datasheets already in the pool)
|
||||
['IQS4', 'iqs4', 'image-4k',
|
||||
'Distribute a single 4K source across multiple displays with a compact 4K splitter.'],
|
||||
['VisionSC-SDI4', 'visionsc-sdi4', 'visionsc-s2',
|
||||
'Four-channel SDI capture card for professional video and visualization workflows.'],
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
<?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\Resource\Enum\DuplicationBehavior;
|
||||
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Imports the 22 Datapath success stories from the 2026-10-28 Dropbox
|
||||
* delivery (staged by the session into migrations/datapath_stories_in/
|
||||
* <slug>/{story.docx, story.pdf, images/*}) into the usecase model.
|
||||
*
|
||||
* The docx files share one structure (verified on Edinburgh / Woori):
|
||||
* 1st paragraph -> title
|
||||
* 2nd paragraph -> subtitle (one-liner)
|
||||
* 1st IntenseQuote -> record quote, the two short lines after it
|
||||
* quote_person / quote_role
|
||||
* known section names -> text elements (Background, The Challenge,
|
||||
* The Solution, The Result, ... - plain
|
||||
* paragraphs, no heading styles in the files)
|
||||
* ListParagraph runs -> <ul> inside the section body
|
||||
* further IntenseQuote -> vitec_quotation elements; attribution from
|
||||
* the preceding "... said:" line or the short
|
||||
* "Name, Role" line after the quote
|
||||
*
|
||||
* Per story: usecase record (pid/detail_page from the existing records),
|
||||
* hero_bgimage + card_image from the first staged image, remaining images
|
||||
* (max 4) as image elements, the staged Collateral pdf as a NEW download
|
||||
* record plus a vitec_downloadcard (layout story_download, header
|
||||
* Downloads) - the same shape the migrated vitec.com stories have after
|
||||
* the 2026-10-28 plugin conversion. Files land in
|
||||
* fileadmin/success_stories_import/datapath/<slug>/.
|
||||
*
|
||||
* Idempotent: a story whose normalised title already exists gets an
|
||||
* asset-only backfill (logo/hero/card/images where missing) instead.
|
||||
* Markets stay unassigned on purpose (editorial; reported).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-datapath-stories [--only=woori] [--apply]
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-datapath-stories',
|
||||
description: 'Import the Datapath success-story delivery (docx + images + pdf) into the usecase model (dry-run by default)'
|
||||
)]
|
||||
final class ImportDatapathStoriesCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_usecase';
|
||||
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
|
||||
private const COLPOS = 999;
|
||||
private const MAX_IMAGE_ELEMENTS = 4;
|
||||
private const FILES_TARGET = 'success_stories_import/datapath';
|
||||
|
||||
private const SECTION_NAMES = [
|
||||
'background', 'the challenge', 'challenge', 'the solution', 'solution',
|
||||
'the result', 'result', 'the results', 'results', 'future plans',
|
||||
'future expansion', 'the future', 'key features', 'benefits',
|
||||
'the benefits', 'conclusion', 'summary',
|
||||
];
|
||||
|
||||
private int $newCounter = 0;
|
||||
private bool $repairQuotes = false;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Inbox directory', 'migrations/datapath_stories_in');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Folder-name substring filter', '');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
$this->addOption('repair-quotes', null, InputOption::VALUE_NONE, 'Repair pass 0017-FE: move lead attribution into quote_person/quote_role, delete the bogus quotation elements, strip "Updated on" remnants');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$this->repairQuotes = (bool)$input->getOption('repair-quotes');
|
||||
$only = mb_strtolower((string)$input->getOption('only'));
|
||||
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
|
||||
$existing = [];
|
||||
$pidCounts = [];
|
||||
$detailCounts = [];
|
||||
foreach ($pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
|
||||
'SELECT uid, pid, title, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
) as $row) {
|
||||
$existing[$this->norm((string)$row['title'])] = (int)$row['uid'];
|
||||
$pidCounts[(int)$row['pid']] = ($pidCounts[(int)$row['pid']] ?? 0) + 1;
|
||||
if ((int)$row['detail_page'] > 0) {
|
||||
$detailCounts[(int)$row['detail_page']] = ($detailCounts[(int)$row['detail_page']] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
arsort($pidCounts);
|
||||
arsort($detailCounts);
|
||||
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
|
||||
$detailPage = (int)(array_key_first($detailCounts) ?? 0);
|
||||
|
||||
$downloadPids = array_count_values(array_map('intval', $pool->getConnectionForTable(self::DOWNLOAD_TABLE)
|
||||
->fetchFirstColumn('SELECT pid FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0')));
|
||||
arsort($downloadPids);
|
||||
$downloadPid = (int)(array_key_first($downloadPids) ?? $storagePid);
|
||||
|
||||
$folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: [], static fn(string $f): bool =>
|
||||
is_file($f . '/story.docx') && ($only === '' || str_contains(mb_strtolower(basename($f)), $only)));
|
||||
$output->writeln(sprintf('%s · %d story folder(s) · storage pid %d · detail page %d · download pid %d',
|
||||
$apply ? 'APPLY' : 'DRY-RUN', count($folders), $storagePid, $detailPage, $downloadPid));
|
||||
|
||||
$created = 0;
|
||||
foreach ($folders as $folder) {
|
||||
try {
|
||||
$created += $this->importOne($folder, $existing, $storagePid, $detailPage, $downloadPid, $apply, $output) ? 1 : 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error> ' . basename($folder) . ': ' . get_class($e) . ' - ' . $e->getMessage() . '</error>');
|
||||
}
|
||||
}
|
||||
// --- repair pass: story downloadcards whose FlexForm still carries a
|
||||
// literal NEW id (first import wrote the placeholder unsubstituted) -
|
||||
// resolve via "<story title> Success Story" and write the real uid
|
||||
$content = $pool->getConnectionForTable('tt_content');
|
||||
$downloadByNorm = [];
|
||||
foreach ($pool->getConnectionForTable(self::DOWNLOAD_TABLE)->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
|
||||
) as $download) {
|
||||
$downloadByNorm[$this->norm((string)$download['title'])] = (int)$download['uid'];
|
||||
}
|
||||
$repairMap = [];
|
||||
foreach ($content->fetchAllAssociative(
|
||||
"SELECT c.uid, u.title FROM tt_content c
|
||||
JOIN " . self::TABLE . " u ON u.uid = c.tx_vitec_usecase_content AND u.deleted = 0
|
||||
WHERE c.deleted = 0 AND c.CType = 'vitec_downloadcard' AND c.pi_flexform LIKE '%NEWDS%'"
|
||||
) as $card) {
|
||||
$target = $downloadByNorm[$this->norm((string)$card['title'] . ' success story')] ?? 0;
|
||||
if ($target > 0) {
|
||||
$repairMap['tt_content'][(string)(int)$card['uid']]['pi_flexform'] =
|
||||
['data' => ['sDEF' => ['lDEF' => ['settings.download' => ['vDEF' => (string)$target]]]]];
|
||||
$output->writeln(' ~ REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '") -> download #' . $target);
|
||||
} else {
|
||||
$output->writeln(' <comment>! REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '"): kein Download gefunden</comment>');
|
||||
}
|
||||
}
|
||||
if ($apply && $repairMap !== []) {
|
||||
$repairHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$repairHandler->start($repairMap, []);
|
||||
$repairHandler->process_datamap();
|
||||
}
|
||||
|
||||
$output->writeln($apply
|
||||
? "<info>$created story record(s) created. Flush the frontend cache; assign markets editorially; re-run the sub-solution import for the testimonials.</info>"
|
||||
: "\nDRY RUN - nothing written. Add --apply to write.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<string,int> $existing */
|
||||
private function importOne(string $folder, array $existing, int $storagePid, int $detailPage, int $downloadPid, bool $apply, OutputInterface $output): bool
|
||||
{
|
||||
$slug = basename($folder);
|
||||
$doc = $this->parseDocx($folder . '/story.docx');
|
||||
$title = $doc['title'];
|
||||
if ($title === '') {
|
||||
$output->writeln('<comment> ' . $slug . ': kein Titel im docx - uebersprungen</comment>');
|
||||
return false;
|
||||
}
|
||||
if (isset($existing[$this->norm($title)])) {
|
||||
if ($this->repairQuotes) {
|
||||
$this->repairStoryQuotes($existing[$this->norm($title)], $slug, $doc, $apply, $output);
|
||||
} else {
|
||||
$this->backfillAssets($existing[$this->norm($title)], $slug, $folder, $storagePid, $apply, $output);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
|
||||
$pdf = is_file($folder . '/story.pdf') ? $folder . '/story.pdf' : null;
|
||||
$output->writeln(sprintf('+ "%s" (%d Sektion(en), %d Zitat(e)%s, %d Bild(er), %s)',
|
||||
$title, count($doc['sections']), count($doc['quotes']),
|
||||
$doc['lead']['quote'] !== '' ? ' + Lead-Quote' : '',
|
||||
count($images), $pdf ? 'PDF' : 'KEIN PDF'));
|
||||
foreach ($doc['report'] as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
if (!$apply) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
|
||||
|
||||
$datamap = [];
|
||||
$elementIds = [];
|
||||
|
||||
// --- sections as text elements -------------------------------------------------
|
||||
foreach ($doc['sections'] as $section) {
|
||||
if ($section['type'] === 'text') {
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'text', 'colPos' => self::COLPOS,
|
||||
'header' => $section['header'], 'header_layout' => 2,
|
||||
'bodytext' => $section['body'],
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
} else { // quotation
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'vitec_quotation', 'colPos' => self::COLPOS,
|
||||
'vitec_quote' => $section['quote'],
|
||||
'vitec_quote_name' => $section['name'],
|
||||
'vitec_quote_position' => $section['position'],
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// --- image elements (beyond hero/card) ------------------------------------------
|
||||
$heroRef = null;
|
||||
$cardRef = null;
|
||||
foreach ($images as $i => $imagePath) {
|
||||
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
|
||||
if ($i === 0) {
|
||||
$heroRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
|
||||
$cardRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
|
||||
continue;
|
||||
}
|
||||
if ($i > self::MAX_IMAGE_ELEMENTS) {
|
||||
continue;
|
||||
}
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
|
||||
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
}
|
||||
|
||||
// --- download record (OWN DataHandler pass: NEW ids are NOT
|
||||
// substituted inside FlexForm values, found 2026-10-28 - the card
|
||||
// needs the REAL uid) + downloadcard --------------------------------------------
|
||||
if ($pdf !== null) {
|
||||
$pdfName = 'VITEC_' . $slug . '_USE-CASE.pdf';
|
||||
$pdfFile = $storage->addFile($pdf, $targetFolder, $pdfName, DuplicationBehavior::REPLACE, false);
|
||||
$downloadId = $this->newId();
|
||||
$downloadMap = [];
|
||||
$downloadMap[self::DOWNLOAD_TABLE][$downloadId] = [
|
||||
'pid' => $downloadPid,
|
||||
'title' => $title . ' Success Story',
|
||||
'slug' => $this->slugify($title . ' success story'),
|
||||
'file' => $this->fileRef($downloadMap, $pdfFile->getUid(), $downloadPid),
|
||||
];
|
||||
$downloadHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$downloadHandler->start($downloadMap, []);
|
||||
$downloadHandler->process_datamap();
|
||||
$downloadUid = (int)($downloadHandler->substNEWwithIDs[$downloadId] ?? 0);
|
||||
if ($downloadUid > 0) {
|
||||
$cardId = $this->newId();
|
||||
$datamap['tt_content'][$cardId] = [
|
||||
'pid' => $storagePid, 'CType' => 'vitec_downloadcard', 'colPos' => self::COLPOS,
|
||||
'header' => 'Downloads',
|
||||
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
|
||||
'settings.download' => ['vDEF' => (string)$downloadUid],
|
||||
'settings.layout' => ['vDEF' => 'story_download'],
|
||||
]]]],
|
||||
];
|
||||
$elementIds[] = $cardId;
|
||||
}
|
||||
}
|
||||
|
||||
// --- the record -------------------------------------------------------------------
|
||||
$recordId = $this->newId();
|
||||
$record = [
|
||||
'pid' => $storagePid,
|
||||
'title' => $title,
|
||||
'slug' => $this->slugify($title),
|
||||
'subtitle' => $doc['subtitle'],
|
||||
'teaser' => $doc['teaser'],
|
||||
'quote' => $doc['lead']['quote'],
|
||||
'quote_person' => $doc['lead']['name'],
|
||||
'quote_role' => $doc['lead']['position'],
|
||||
'seo_title' => $title,
|
||||
'seo_description' => $doc['subtitle'],
|
||||
'content_elements' => implode(',', $elementIds),
|
||||
];
|
||||
if ($detailPage > 0) {
|
||||
$record['detail_page'] = $detailPage;
|
||||
}
|
||||
if ($heroRef !== null) {
|
||||
$record['hero_bgimage'] = $heroRef;
|
||||
$record['card_image'] = $cardRef;
|
||||
}
|
||||
$datamap[self::TABLE][$recordId] = $record;
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$uid = (int)($dataHandler->substNEWwithIDs[$recordId] ?? 0);
|
||||
$output->writeln(' -> Record #' . $uid . ' angelegt');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset backfill for an already imported story: the 2026-10-28 delivery
|
||||
* keeps the web images AND the customer logo in the Links/ folders,
|
||||
* which the first staging pass had excluded. Content-idempotent -
|
||||
* attaches customer_logo / hero_bgimage / card_image only when the
|
||||
* field has no reference yet and adds image elements only when the
|
||||
* story has none; existing references are never touched.
|
||||
*/
|
||||
private function backfillAssets(int $uid, string $slug, string $folder, int $storagePid, bool $apply, OutputInterface $output): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$refs = $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
|
||||
"SELECT DISTINCT fieldname FROM sys_file_reference
|
||||
WHERE deleted = 0 AND tablenames = ? AND uid_foreign = ?
|
||||
AND fieldname IN ('customer_logo', 'hero_bgimage', 'card_image')",
|
||||
[self::TABLE, $uid]
|
||||
);
|
||||
$imageElements = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND CType = 'image' AND tx_vitec_usecase_content = ?",
|
||||
[$uid]
|
||||
);
|
||||
|
||||
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
|
||||
$logos = array_values(array_filter(glob($folder . '/logo.*') ?: [], 'is_file'));
|
||||
|
||||
$needLogo = $logos !== [] && !in_array('customer_logo', $refs, true);
|
||||
$needHero = $images !== [] && !in_array('hero_bgimage', $refs, true);
|
||||
$needCard = $images !== [] && !in_array('card_image', $refs, true);
|
||||
$newImageCount = ($imageElements === 0 && count($images) > 1)
|
||||
? min(self::MAX_IMAGE_ELEMENTS, count($images) - 1)
|
||||
: 0;
|
||||
if (!$needLogo && !$needHero && !$needCard && $newImageCount === 0) {
|
||||
$output->writeln(' = #' . $uid . ' ' . $slug . ': Assets vollstaendig - nichts zu tun');
|
||||
return;
|
||||
}
|
||||
$output->writeln(sprintf(' ~ #%d %s: Backfill %s%s%s%s', $uid, $slug,
|
||||
$needLogo ? '[logo] ' : '', $needHero ? '[hero] ' : '', $needCard ? '[card] ' : '',
|
||||
$newImageCount > 0 ? '[' . $newImageCount . ' Bild-Element(e)]' : ''));
|
||||
if (!$apply) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
|
||||
$datamap = [];
|
||||
$recordFields = [];
|
||||
|
||||
if ($needLogo) {
|
||||
$logoName = 'logo-' . $slug . '.' . mb_strtolower(pathinfo($logos[0], PATHINFO_EXTENSION));
|
||||
$logoFile = $storage->addFile($logos[0], $targetFolder, $logoName, DuplicationBehavior::REPLACE, false);
|
||||
$recordFields['customer_logo'] = $this->fileRefFull($datamap, $logoFile->getUid(), $storagePid, $uid, 'customer_logo');
|
||||
}
|
||||
if ($needHero || $needCard) {
|
||||
$first = $storage->addFile($images[0], $targetFolder, basename($images[0]), DuplicationBehavior::REPLACE, false);
|
||||
if ($needHero) {
|
||||
$recordFields['hero_bgimage'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'hero_bgimage');
|
||||
}
|
||||
if ($needCard) {
|
||||
$recordFields['card_image'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'card_image');
|
||||
}
|
||||
}
|
||||
if ($newImageCount > 0) {
|
||||
// append via the parent CSV (existing children in sorting order +
|
||||
// the new elements) so the inline count stays correct; new images
|
||||
// land BEFORE a trailing downloadcard
|
||||
$children = array_map('intval', $pool->getConnectionForTable('tt_content')->fetchFirstColumn(
|
||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ? ORDER BY sorting',
|
||||
[$uid]
|
||||
));
|
||||
$newIds = [];
|
||||
foreach (array_slice($images, 1, $newImageCount) as $imagePath) {
|
||||
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
|
||||
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
|
||||
];
|
||||
$newIds[] = $id;
|
||||
}
|
||||
$last = $children !== [] ? (int)end($children) : 0;
|
||||
$lastType = $last > 0 ? (string)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
'SELECT CType FROM tt_content WHERE uid = ?', [$last]) : '';
|
||||
if ($lastType === 'vitec_downloadcard') {
|
||||
array_pop($children);
|
||||
$csv = array_merge($children, $newIds, [$last]);
|
||||
} else {
|
||||
$csv = array_merge($children, $newIds);
|
||||
}
|
||||
$recordFields['content_elements'] = implode(',', array_map('strval', $csv));
|
||||
}
|
||||
|
||||
$datamap[self::TABLE][(string)$uid] = $recordFields;
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$output->writeln(' -> Backfill geschrieben');
|
||||
}
|
||||
|
||||
/** sys_file_reference NEW id for a field of an EXISTING parent record. */
|
||||
private function fileRefFull(array &$datamap, int $fileUid, int $pid, int $uidForeign, string $fieldname): string
|
||||
{
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $fileUid,
|
||||
'tablenames' => self::TABLE,
|
||||
'uid_foreign' => $uidForeign,
|
||||
'fieldname' => $fieldname,
|
||||
];
|
||||
return $refId;
|
||||
}
|
||||
/** Short, non-sentence line right after a quote = attribution (name/role). */
|
||||
private function isAttributionLine(array $paragraph, int $maxLen): bool
|
||||
{
|
||||
$text = (string)$paragraph['text'];
|
||||
if ($text === '' || mb_strlen($text) > $maxLen + 60) {
|
||||
return false;
|
||||
}
|
||||
$first = trim((string)($paragraph['lines'][0] ?? $text));
|
||||
if ($first === '' || mb_strlen($first) > $maxLen || str_ends_with($first, '.')) {
|
||||
return false;
|
||||
}
|
||||
if (count(preg_split('/\s+/u', $first) ?: []) > 8) {
|
||||
return false;
|
||||
}
|
||||
return !in_array(mb_strtolower(rtrim($text, ':')), self::SECTION_NAMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair pass (0017-FE Punkt 3 + 5a) for an already imported story:
|
||||
* writes the docx lead attribution into quote_person/quote_role (only
|
||||
* when empty), deletes the quotation elements that carry just the name
|
||||
* or role (or the glued combination) as their quote, and strips the
|
||||
* "NN | YYYY Updated on NN | YYYY" footer from text elements.
|
||||
*/
|
||||
private function repairStoryQuotes(int $uid, string $slug, array $doc, bool $apply, OutputInterface $output): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$record = $pool->getConnectionForTable(self::TABLE)->fetchAssociative(
|
||||
'SELECT uid, quote_person, quote_role FROM ' . self::TABLE . ' WHERE uid = ?', [$uid]
|
||||
);
|
||||
if (!$record) {
|
||||
return;
|
||||
}
|
||||
$name = (string)$doc['lead']['name'];
|
||||
$position = (string)$doc['lead']['position'];
|
||||
|
||||
// normalise for the record (element matching below uses the RAW
|
||||
// parsed strings): trailing commas, "Name, Role" in ONE line, and a
|
||||
// single line that is clearly a role ("FIA Head of ...") -> role only
|
||||
$person = trim($name, " \t,");
|
||||
$role = trim($position, " \t,");
|
||||
if ($role === '' && str_contains($person, ',')) {
|
||||
[$person, $role] = $this->splitAttribution($person);
|
||||
}
|
||||
if ($role === '' && $person !== ''
|
||||
&& count(preg_split('/\s+/u', $person) ?: []) >= 3
|
||||
&& preg_match('/\b(Head|Manager|Director|President|CEO|CTO|Chief|Officer|Engineer|Architect|Strategy)\b/i', $person)) {
|
||||
$role = $person;
|
||||
$person = '';
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
$cmdmap = [];
|
||||
$actions = [];
|
||||
|
||||
if ($person !== '' && (string)$record['quote_person'] === '') {
|
||||
$datamap[self::TABLE][(string)$uid]['quote_person'] = $person;
|
||||
$actions[] = 'person="' . $person . '"';
|
||||
}
|
||||
if ($role !== '' && (string)$record['quote_role'] === '') {
|
||||
$datamap[self::TABLE][(string)$uid]['quote_role'] = $role;
|
||||
$actions[] = 'role="' . $role . '"';
|
||||
}
|
||||
|
||||
// bogus quotation elements: quote == name / role / glued combination
|
||||
$targets = array_filter([
|
||||
$this->norm($name),
|
||||
$this->norm($position),
|
||||
$this->norm($name . $position),
|
||||
], static fn(string $v): bool => $v !== '');
|
||||
if ($targets !== []) {
|
||||
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
"SELECT uid, vitec_quote FROM tt_content
|
||||
WHERE deleted = 0 AND CType = 'vitec_quotation' AND tx_vitec_usecase_content = ?",
|
||||
[$uid]
|
||||
) as $element) {
|
||||
if (in_array($this->norm((string)$element['vitec_quote']), $targets, true)) {
|
||||
$cmdmap['tt_content'][(int)$element['uid']]['delete'] = 1;
|
||||
$actions[] = 'delete quotation #' . $element['uid'] . ' ("' . mb_substr((string)$element['vitec_quote'], 0, 40) . '")';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Updated on" remnants inside text elements (incl. glued into <li>)
|
||||
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
"SELECT uid, bodytext FROM tt_content
|
||||
WHERE deleted = 0 AND CType = 'text' AND tx_vitec_usecase_content = ?
|
||||
AND bodytext LIKE '%Updated on%'",
|
||||
[$uid]
|
||||
) as $element) {
|
||||
$body = (string)$element['bodytext'];
|
||||
$clean = (string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?/iu', '', $body);
|
||||
$clean = (string)preg_replace('/<(p|li)>\s*<\/\1>/u', '', $clean);
|
||||
if ($clean !== $body) {
|
||||
$datamap['tt_content'][(string)(int)$element['uid']]['bodytext'] = $clean;
|
||||
$actions[] = 'strip "Updated on" in #' . $element['uid'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($actions === []) {
|
||||
$output->writeln(' = #' . $uid . ' ' . $slug . ': nichts zu reparieren');
|
||||
return;
|
||||
}
|
||||
$output->writeln(' ~ #' . $uid . ' ' . $slug . ':');
|
||||
foreach ($actions as $action) {
|
||||
$output->writeln(' - ' . $action);
|
||||
}
|
||||
if (!$apply) {
|
||||
return;
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, $cmdmap);
|
||||
if ($datamap !== []) {
|
||||
$dataHandler->process_datamap();
|
||||
}
|
||||
if ($cmdmap !== []) {
|
||||
$dataHandler->process_cmdmap();
|
||||
}
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$output->writeln(' -> repariert');
|
||||
}
|
||||
/** sys_file_reference NEW id for use in a parent FAL field. */
|
||||
private function fileRef(array &$datamap, int $fileUid, int $pid): string
|
||||
{
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $fileUid,
|
||||
];
|
||||
return $refId;
|
||||
}
|
||||
|
||||
private function ensureFolder(\TYPO3\CMS\Core\Resource\ResourceStorage $storage, string $path): \TYPO3\CMS\Core\Resource\Folder
|
||||
{
|
||||
$folder = $storage->getRootLevelFolder();
|
||||
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||
$folder = $folder->hasFolder($segment) ? $folder->getSubfolder($segment) : $folder->createFolder($segment);
|
||||
}
|
||||
return $folder;
|
||||
}
|
||||
|
||||
// ---- docx parsing ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array{title:string,subtitle:string,teaser:string,
|
||||
* lead:array{quote:string,name:string,position:string},
|
||||
* sections:array<int,array<string,string>>,quotes:array<int,int>,report:string[]}
|
||||
*/
|
||||
private function parseDocx(string $file): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($file) !== true) {
|
||||
throw new \RuntimeException('docx laesst sich nicht oeffnen');
|
||||
}
|
||||
$xml = (string)$zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
$dom = new \DOMDocument();
|
||||
$dom->loadXML($xml);
|
||||
$xpath = new \DOMXPath($dom);
|
||||
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
$xpath->registerNamespace('w', $w);
|
||||
|
||||
$paragraphs = [];
|
||||
foreach ($xpath->query('//w:body/w:p') as $p) {
|
||||
$text = '';
|
||||
foreach ($xpath->query('.//w:t|.//w:br', $p) as $node) {
|
||||
// w:br as line break - attribution lines come as "Name<br>Role"
|
||||
// in some files and glued without it the names stick together
|
||||
// ("Shawn HanssonCEO, ...", ticket 0017-FE)
|
||||
$text .= $node->localName === 'br' ? "\n" : $node->textContent;
|
||||
}
|
||||
$text = trim((string)preg_replace('/[ \t]+/u', ' ', $text));
|
||||
$text = (string)preg_replace('/\s*\n\s*/u', "\n", $text);
|
||||
// import footer "04 | 2025 Updated on 10 | 2026" (0017-FE 5a)
|
||||
$text = trim((string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?\s*$/iu', '', $text));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$styleNode = $xpath->query('./w:pPr/w:pStyle/@w:val', $p)->item(0);
|
||||
$paragraphs[] = [
|
||||
'text' => str_replace("\n", ' ', $text),
|
||||
'lines' => explode("\n", $text),
|
||||
'style' => $styleNode ? (string)$styleNode->nodeValue : '',
|
||||
];
|
||||
}
|
||||
|
||||
$report = [];
|
||||
$title = $paragraphs[0]['text'] ?? '';
|
||||
$subtitle = $paragraphs[1]['text'] ?? '';
|
||||
$lead = ['quote' => '', 'name' => '', 'position' => ''];
|
||||
$index = 2;
|
||||
if (isset($paragraphs[$index]) && $paragraphs[$index]['style'] === 'IntenseQuote') {
|
||||
$lead['quote'] = $this->unquote($paragraphs[$index]['text']);
|
||||
$index++;
|
||||
// attribution lines are often ALSO IntenseQuote-styled in these
|
||||
// files (0017-FE: they became bogus quotation elements) and may
|
||||
// be glued into ONE paragraph via w:br
|
||||
if (isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 60)) {
|
||||
$lines = $paragraphs[$index]['lines'];
|
||||
$lead['name'] = trim((string)$lines[0]);
|
||||
if (count($lines) > 1) {
|
||||
$lead['position'] = trim(implode(', ', array_map('trim', array_slice($lines, 1))));
|
||||
}
|
||||
$index++;
|
||||
if ($lead['position'] === '' && isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 100)) {
|
||||
$lead['position'] = $paragraphs[$index]['text'];
|
||||
$index++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$report[] = 'HINWEIS: kein Lead-Quote an Position 3';
|
||||
}
|
||||
|
||||
// sections
|
||||
$sections = [];
|
||||
$quotes = [];
|
||||
$current = null;
|
||||
$body = []; // items: ['p', html] | ['ul', [li,...]]
|
||||
$flushBody = function () use (&$sections, &$current, &$body): void {
|
||||
if ($current === null && $body === []) {
|
||||
return;
|
||||
}
|
||||
$html = '';
|
||||
foreach ($body as [$kind, $value]) {
|
||||
$html .= $kind === 'ul'
|
||||
? '<ul>' . implode('', array_map(static fn(string $li): string => '<li>' . htmlspecialchars($li, ENT_NOQUOTES | ENT_HTML5) . '</li>', $value)) . '</ul>'
|
||||
: '<p>' . htmlspecialchars($value, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
if ($html !== '' || $current !== null) {
|
||||
$sections[] = ['type' => 'text', 'header' => (string)$current, 'body' => $html];
|
||||
}
|
||||
$body = [];
|
||||
};
|
||||
|
||||
for ($i = $index; $i < count($paragraphs); $i++) {
|
||||
$paragraph = $paragraphs[$i];
|
||||
$lower = mb_strtolower(rtrim($paragraph['text'], ':'));
|
||||
if ($paragraph['style'] !== 'IntenseQuote' && in_array($lower, self::SECTION_NAMES, true)) {
|
||||
$flushBody();
|
||||
$current = $paragraph['text'];
|
||||
continue;
|
||||
}
|
||||
if ($paragraph['style'] === 'IntenseQuote') {
|
||||
// attribution BEFORE the quote ("... said:" / "... continues:")
|
||||
$name = '';
|
||||
$position = '';
|
||||
$last = $body !== [] ? $body[count($body) - 1] : null;
|
||||
if ($last !== null && $last[0] === 'p' && preg_match('/^(.{3,90}?)\s+(said|says|continues|adds|explains)\s*:?\s*$/iu', $last[1], $m)) {
|
||||
[$name, $position] = $this->splitAttribution($m[1]);
|
||||
array_pop($body);
|
||||
} elseif (isset($paragraphs[$i + 1]) && $paragraphs[$i + 1]['style'] !== 'IntenseQuote') {
|
||||
$next = $paragraphs[$i + 1]['text'];
|
||||
$nextLower = mb_strtolower(rtrim($next, ':'));
|
||||
if (mb_strlen($next) <= 90 && str_contains($next, ',') && !in_array($nextLower, self::SECTION_NAMES, true)) {
|
||||
[$name, $position] = $this->splitAttribution($next);
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
$flushBody();
|
||||
$current = null;
|
||||
$sections[] = ['type' => 'quote', 'quote' => $this->unquote($paragraph['text']), 'name' => $name, 'position' => $position];
|
||||
$quotes[] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($paragraph['style'] === 'ListParagraph') {
|
||||
$last = $body !== [] ? count($body) - 1 : -1;
|
||||
if ($last >= 0 && $body[$last][0] === 'ul') {
|
||||
$body[$last][1][] = $paragraph['text'];
|
||||
} else {
|
||||
$body[] = ['ul', [$paragraph['text']]];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($current === null && $sections === []) {
|
||||
$report[] = 'HINWEIS: Absatz vor erster Sektion: "' . mb_substr($paragraph['text'], 0, 50) . '..."';
|
||||
}
|
||||
$body[] = ['p', $paragraph['text']];
|
||||
}
|
||||
$flushBody();
|
||||
|
||||
// teaser from the first text section
|
||||
$teaser = '';
|
||||
foreach ($sections as $section) {
|
||||
if ($section['type'] === 'text' && preg_match('/<p>(.*?)<\/p>/s', $section['body'], $m)) {
|
||||
$teaser = html_entity_decode($m[1], ENT_NOQUOTES | ENT_HTML5);
|
||||
if (mb_strlen($teaser) > 300) {
|
||||
$teaser = mb_substr($teaser, 0, 300);
|
||||
$teaser = mb_substr($teaser, 0, (int)mb_strrpos($teaser, ' ')) . ' ...';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['title' => $title, 'subtitle' => $subtitle, 'teaser' => $teaser,
|
||||
'lead' => $lead, 'sections' => $sections, 'quotes' => $quotes, 'report' => $report];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} name, position */
|
||||
private function splitAttribution(string $value): array
|
||||
{
|
||||
$parts = explode(',', $value, 2);
|
||||
return [trim($parts[0]), trim($parts[1] ?? '')];
|
||||
}
|
||||
|
||||
private function unquote(string $value): string
|
||||
{
|
||||
// multibyte-safe strip of straight and curly quotes at both ends
|
||||
return trim((string)preg_replace('/^[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+|[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+$/u', '', $value));
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = str_replace(['+', '&'], ['plus', 'and'], mb_strtolower(trim($value)));
|
||||
return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-');
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWDS' . (++$this->newCounter);
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ final class ImportMarketsMainPageCommand extends Command
|
||||
|
||||
private function p(string $text): string
|
||||
{
|
||||
return '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
return '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -173,6 +173,24 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
}
|
||||
$pageUid = ctype_digit($pageId) ? (int)$pageId : 0;
|
||||
|
||||
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
|
||||
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
|
||||
// der Slot-Cursor sie nicht sieht - der IQS4-Re-Run hat so auf pid
|
||||
// 569 den Kartensatz dupliziert (Repair: fix_products_grid.php).
|
||||
// Eine gewickelte Seite wird uebersprungen statt beschaedigt.
|
||||
if ($pageUid > 0) {
|
||||
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($containerCount > 0) {
|
||||
$this->report[] = 'SKIP: Seite ist grid-gewickelt (' . $containerCount
|
||||
. ' Container) - Re-Import wuerde Karten duplizieren. Inhalte im BE pflegen oder Grids erst aufloesen.';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- hero -------------------------------------------------------------------
|
||||
if ($hero !== null) {
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId);
|
||||
@@ -316,9 +334,9 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
}
|
||||
$body = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
|
||||
foreach ($rows as $row) {
|
||||
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>';
|
||||
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['body'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
|
||||
}
|
||||
$body .= '</tbody></table>';
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
|
||||
@@ -502,7 +520,9 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu ',
|
||||
// und die RTE-Persistenz escapte das & gleich nochmal (&apos; im JSON)
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -5,50 +5,69 @@ declare(strict_types=1);
|
||||
namespace Evomedien\Vitec\EventListener;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
|
||||
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Resource\FileRepository;
|
||||
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
|
||||
/**
|
||||
* Supplies the product catalog for megamenu entries with the layout
|
||||
* `catalog` (2026-10-09, modelled on the Ross Video "Products" menu):
|
||||
* main categories (middle column) -> subcategories as group headings ->
|
||||
* products with a one-line teaser (right column), plus the endpoint of the
|
||||
* product type-ahead in the panel's search bar.
|
||||
* Supplies the products panel of the megamenu (entry layout `catalog`),
|
||||
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
|
||||
* the menu ends at the product OVERVIEW pages.
|
||||
*
|
||||
* Same tree and visibility rules as the product list and the Product
|
||||
* Finder: level 1 below the root category "Product" are the main
|
||||
* categories, their direct children the subcategories; products need
|
||||
* legacy/supportproduct/hideonwebsite/hideonproducts/subproduct = 0. Within
|
||||
* a group the uid order is kept (records were created in sitemap order).
|
||||
* Links follow the Product Finder convention /products?cat=<main>&subcat=<sub>.
|
||||
* categories[] (A, menu label) = sysfolders directly below /products
|
||||
* groups[] (B, menu label) = sysfolders below those
|
||||
* items[] type "page" = overview pages (/products/<slug>)
|
||||
* type "shortcut" = shortcut pages, resolved to a target
|
||||
* group or overview page of this tree
|
||||
*
|
||||
* Three queries for the whole catalog (categories, assignments, products) -
|
||||
* the menu travels with every page.
|
||||
* The page tree built by vitec:build-product-overviews is the single
|
||||
* source: editors rename, reorder, hide (hidden/nav_hide) or move pages
|
||||
* there. An overview item lists the products of its page (the vitec_card
|
||||
* elements linking /product/<slug>, in creation order) - the sub-product
|
||||
* 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.
|
||||
*
|
||||
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The
|
||||
* boxes of the left column ("Product highlight", "Product news") are the
|
||||
* entry's editorial teaser cards, not data from here (decision Olli
|
||||
* 2026-11-02: no automatic news).
|
||||
*
|
||||
* Version 1 (2026-10-09) listed sys_category groups with their products;
|
||||
* the nine old category pages are plain pages below /products and are not
|
||||
* part of this tree. The transitional v1 fields (categories[].uid/link,
|
||||
* groups[].uid/link/products, products[].teaser) are gone since 2026-11-03 -
|
||||
* the front end reads the pure v2 shape (ticket 0024-FE).
|
||||
*/
|
||||
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
|
||||
final class ProvideProductCatalog
|
||||
{
|
||||
private const ROOT_TITLE = 'Product';
|
||||
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||
private const PRODUCTS_PAGE = '/products';
|
||||
private const PRODUCTS_SLUG = '/products';
|
||||
private const SEARCH_ENDPOINT = '/_json/?type=7385';
|
||||
private const TEASER_LENGTH = 120;
|
||||
private const DOKTYPE_PAGE = 1;
|
||||
private const DOKTYPE_SHORTCUT = 4;
|
||||
private const DOKTYPE_FOLDER = 254;
|
||||
|
||||
public function __construct(private readonly ConnectionPool $connectionPool) {}
|
||||
public function __construct(
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
private readonly FileRepository $fileRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(BuildCatalogEvent $event): void
|
||||
{
|
||||
try {
|
||||
$stage = $event->getStage();
|
||||
$event->setCatalog([
|
||||
'version' => 2,
|
||||
'search' => [
|
||||
'endpoint' => self::SEARCH_ENDPOINT,
|
||||
'param' => 'q',
|
||||
'minChars' => 2,
|
||||
'placeholder' => 'Search products',
|
||||
],
|
||||
'categories' => $this->categories(),
|
||||
'viewAll' => ['title' => 'View all products', 'link' => self::PRODUCTS_SLUG],
|
||||
'categories' => $this->categories($stage),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// fail-soft: the menu renders without the catalog rather than not at all
|
||||
@@ -56,141 +75,177 @@ final class ProvideProductCatalog
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function categories(): array
|
||||
private function categories(int $stage): array
|
||||
{
|
||||
$root = $this->rootUid();
|
||||
$root = $this->productsRootUid();
|
||||
if ($root === 0) {
|
||||
return [];
|
||||
}
|
||||
$mains = $this->childCategories([$root]);
|
||||
$subs = $this->childCategories(array_column($mains, 'uid'));
|
||||
$subsByParent = [];
|
||||
foreach ($subs as $sub) {
|
||||
$subsByParent[(int)$sub['parent']][] = $sub;
|
||||
}
|
||||
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
|
||||
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]);
|
||||
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
|
||||
$overviewUids = array_map('intval', array_column(
|
||||
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
|
||||
));
|
||||
$productsByPage = $this->productsOnPages($overviewUids);
|
||||
|
||||
$categoryUids = array_merge(array_column($mains, 'uid'), array_column($subs, 'uid'));
|
||||
$productsByCategory = $this->productsByCategory(array_map('intval', $categoryUids));
|
||||
|
||||
$catalog = [];
|
||||
foreach ($mains as $main) {
|
||||
$mainUid = (int)$main['uid'];
|
||||
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
|
||||
$groupPos = [];
|
||||
$pagePos = [];
|
||||
$categories = [];
|
||||
foreach ($categoryRows as $c => $categoryRow) {
|
||||
$groups = [];
|
||||
// products filed directly on the main category: an untitled first group
|
||||
if (!empty($productsByCategory[$mainUid])) {
|
||||
$groups[] = [
|
||||
'uid' => $mainUid,
|
||||
'title' => '',
|
||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
|
||||
'products' => $productsByCategory[$mainUid],
|
||||
];
|
||||
}
|
||||
foreach ($subsByParent[$mainUid] ?? [] as $sub) {
|
||||
$subUid = (int)$sub['uid'];
|
||||
if (empty($productsByCategory[$subUid])) {
|
||||
continue; // no empty headings in the menu
|
||||
}
|
||||
$groups[] = [
|
||||
'uid' => $subUid,
|
||||
'title' => (string)$sub['title'],
|
||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid . '&subcat=' . $subUid,
|
||||
'products' => $productsByCategory[$subUid],
|
||||
];
|
||||
}
|
||||
if ($groups === []) {
|
||||
foreach (array_values(array_filter($groupRows, fn(array $g): bool => (int)$g['pid'] === (int)$categoryRow['uid'])) as $g => $groupRow) {
|
||||
$groupPos[(int)$groupRow['uid']] = [$c, $g];
|
||||
$items = [];
|
||||
foreach (array_values(array_filter($itemRows, fn(array $i): bool => (int)$i['pid'] === (int)$groupRow['uid'])) as $p => $itemRow) {
|
||||
if ((int)$itemRow['doktype'] === self::DOKTYPE_SHORTCUT) {
|
||||
$items[] = ['type' => 'shortcut', 'title' => (string)$itemRow['title'], 'shortcut' => (int)$itemRow['shortcut']];
|
||||
continue;
|
||||
}
|
||||
$catalog[] = [
|
||||
'uid' => $mainUid,
|
||||
'title' => (string)$main['title'],
|
||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
|
||||
'groups' => $groups,
|
||||
$uid = (int)$itemRow['uid'];
|
||||
$pagePos[$uid] = [$c, $g, $p];
|
||||
$products = $productsByPage[$uid] ?? [];
|
||||
$item = [
|
||||
'type' => 'page',
|
||||
'uid' => $uid,
|
||||
'title' => (string)$itemRow['title'],
|
||||
'link' => (string)$itemRow['slug'],
|
||||
'productCount' => count($products),
|
||||
'products' => $products,
|
||||
];
|
||||
if ($stage >= 2) {
|
||||
$item['image'] = $this->pageImage($uid);
|
||||
}
|
||||
return $catalog;
|
||||
$items[] = $item;
|
||||
}
|
||||
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items];
|
||||
}
|
||||
$categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups];
|
||||
}
|
||||
|
||||
private function rootUid(): int
|
||||
// shortcuts: target = a group of this tree (opens that group) or an
|
||||
// overview page (opens its group, highlights the page); anything else
|
||||
// is dropped - a shortcut must not lead out of the menu silently
|
||||
foreach ($categories as $c => $category) {
|
||||
foreach ($category['groups'] as $g => $group) {
|
||||
foreach ($group['items'] as $i => $item) {
|
||||
if ($item['type'] !== 'shortcut') {
|
||||
continue;
|
||||
}
|
||||
$targetUid = $item['shortcut'];
|
||||
if (isset($groupPos[$targetUid])) {
|
||||
[$tc, $tg] = $groupPos[$targetUid];
|
||||
$resolved = ['category' => $tc, 'group' => $tg];
|
||||
$link = '';
|
||||
} elseif (isset($pagePos[$targetUid])) {
|
||||
[$tc, $tg, $tp] = $pagePos[$targetUid];
|
||||
$resolved = ['category' => $tc, 'group' => $tg, 'page' => $tp];
|
||||
$link = (string)$categories[$tc]['groups'][$tg]['items'][$tp]['link'];
|
||||
} else {
|
||||
unset($categories[$c]['groups'][$g]['items'][$i]);
|
||||
continue;
|
||||
}
|
||||
$categories[$c]['groups'][$g]['items'][$i] = [
|
||||
'type' => 'shortcut',
|
||||
'title' => $item['title'],
|
||||
'link' => $link,
|
||||
'target' => $resolved,
|
||||
];
|
||||
}
|
||||
$categories[$c]['groups'][$g]['items'] = array_values($categories[$c]['groups'][$g]['items']);
|
||||
}
|
||||
}
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private function productsRootUid(): int
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
|
||||
return (int)$queryBuilder->select('uid')->from('sys_category')
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
return (int)$queryBuilder->select('uid')->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('parent', 0),
|
||||
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter(self::ROOT_TITLE))
|
||||
$queryBuilder->expr()->eq('slug', $queryBuilder->createNamedParameter(self::PRODUCTS_SLUG)),
|
||||
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
|
||||
)
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
|
||||
* nav_hide respected, in page tree order.
|
||||
*
|
||||
* @param int[] $parents
|
||||
* @return array<int,array{uid:int,parent:int,title:string}>
|
||||
* @param int[] $doktypes
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function childCategories(array $parents): array
|
||||
private function children(array $parents, array $doktypes): array
|
||||
{
|
||||
if ($parents === []) {
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
|
||||
return $queryBuilder->select('uid', 'parent', 'title')->from('sys_category')
|
||||
->where($queryBuilder->expr()->in('parent', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)))
|
||||
->orderBy('parent')->addOrderBy('sorting')
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('nav_hide', 0),
|
||||
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
|
||||
)
|
||||
->orderBy('pid')->addOrderBy('sorting')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $categoryUids
|
||||
* @return array<int,array<int,array{uid:int,title:string,teaser:string,link:string}>> category uid => products
|
||||
* The products an overview page presents: its visible cards that link a
|
||||
* product detail page (container children included), creation order.
|
||||
*
|
||||
* @param int[] $pageUids
|
||||
* @return array<int,array<int,array{title:string,link:string}>> page uid => products
|
||||
*/
|
||||
private function productsByCategory(array $categoryUids): array
|
||||
private function productsOnPages(array $pageUids): array
|
||||
{
|
||||
if ($categoryUids === []) {
|
||||
if ($pageUids === []) {
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRODUCT_TABLE);
|
||||
$rows = $queryBuilder
|
||||
->select('p.uid', 'p.title', 'p.teaser', 'p.slug', 'mm.uid_local AS category')
|
||||
->from(self::PRODUCT_TABLE, 'p')
|
||||
->join(
|
||||
'p',
|
||||
'sys_category_record_mm',
|
||||
'mm',
|
||||
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $queryBuilder->createNamedParameter(self::PRODUCT_TABLE)
|
||||
. ' AND mm.fieldname = ' . $queryBuilder->createNamedParameter('categories')
|
||||
)
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
|
||||
$rows = $queryBuilder->select('pid', 'header', 'vitec_primary_cta')->from('tt_content')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('p.legacy', 0),
|
||||
$queryBuilder->expr()->eq('p.supportproduct', 0),
|
||||
$queryBuilder->expr()->eq('p.hideonwebsite', 0),
|
||||
$queryBuilder->expr()->eq('p.hideonproducts', 0),
|
||||
$queryBuilder->expr()->eq('p.subproduct', 0),
|
||||
$queryBuilder->expr()->neq('p.slug', $queryBuilder->createNamedParameter('', ParameterType::STRING))
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
|
||||
$queryBuilder->expr()->like('vitec_primary_cta', $queryBuilder->createNamedParameter('/product/%'))
|
||||
)
|
||||
->orderBy('p.uid')
|
||||
->orderBy('uid')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$byCategory = [];
|
||||
$byPage = [];
|
||||
foreach ($rows as $row) {
|
||||
$byCategory[(int)$row['category']][] = [
|
||||
'uid' => (int)$row['uid'],
|
||||
'title' => (string)$row['title'],
|
||||
'teaser' => $this->oneLiner((string)$row['teaser']),
|
||||
'link' => '/product/' . ltrim((string)$row['slug'], '/'),
|
||||
$byPage[(int)$row['pid']][] = [
|
||||
'title' => (string)$row['header'],
|
||||
'link' => (string)$row['vitec_primary_cta'],
|
||||
];
|
||||
}
|
||||
return $byCategory;
|
||||
return $byPage;
|
||||
}
|
||||
|
||||
/** Plain one-line teaser: tags and entities resolved, whitespace collapsed, cut at a word. */
|
||||
private function oneLiner(string $value): string
|
||||
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
|
||||
private function pageImage(int $pageUid): ?array
|
||||
{
|
||||
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||||
if (mb_strlen($text) <= self::TEASER_LENGTH) {
|
||||
return $text;
|
||||
try {
|
||||
$references = $this->fileRepository->findByRelation('pages', 'media', $pageUid);
|
||||
if ($references === []) {
|
||||
return null;
|
||||
}
|
||||
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
|
||||
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
|
||||
$url = $imageService->getImageUri($processed);
|
||||
return [
|
||||
'url' => str_starts_with($url, '/') || preg_match('#^https?://#', $url) ? $url : '/' . $url,
|
||||
'alternative' => (string)($references[0]->getAlternative() ?? ''),
|
||||
'width' => (int)$processed->getProperty('width'),
|
||||
'height' => (int)$processed->getProperty('height'),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
$cut = mb_substr($text, 0, self::TEASER_LENGTH);
|
||||
$space = mb_strrpos($cut, ' ');
|
||||
return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
|
||||
}
|
||||
}
|
||||
@@ -85,15 +85,57 @@ final class SubmarketWorkbookImporter
|
||||
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
|
||||
}
|
||||
|
||||
// Meta sheets can be stale template copies (2026-10-05: all 24 shared
|
||||
// record 32; 2026-10-28: "Broadcast & Media.xlsx" still shipped the
|
||||
// complete Corporate meta block). The FILENAME names the agency's
|
||||
// actual intent - when it disagrees with the sheet, trust it, drop
|
||||
// the copied record link and re-resolve the record by title.
|
||||
$fileTitle = (string)preg_replace('/\.xlsx$/i', '', basename($file));
|
||||
if ($this->norm($fileTitle) !== $this->norm($submarket)) {
|
||||
$this->report[] = 'META REPAIR: sheet says "' . $submarket . '" (group "' . $group . '", record ' . $recordUid
|
||||
. ') - stale template copy, using filename "' . $fileTitle . '"';
|
||||
$submarket = $fileTitle;
|
||||
$group = '';
|
||||
$recordUid = 0;
|
||||
foreach (GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market')
|
||||
->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_market WHERE deleted = 0') as $marketRow) {
|
||||
if ($this->norm((string)$marketRow['title']) === $this->norm($submarket)) {
|
||||
$recordUid = $recordUid === 0 ? (int)$marketRow['uid'] : -1; // -1 = ambiguous
|
||||
}
|
||||
}
|
||||
$recordUid = max(0, $recordUid);
|
||||
if ($recordUid > 0) {
|
||||
$this->report[] = 'market record re-resolved by title: #' . $recordUid;
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolve pages ---------------------------------------------------
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
||||
if (!$marketsRoot) {
|
||||
throw new \RuntimeException('Page /markets not found.');
|
||||
}
|
||||
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
|
||||
$mainPage = $group !== '' ? $this->childByTitle((int)$marketsRoot['uid'], $group) : null;
|
||||
if ($mainPage === null) {
|
||||
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
|
||||
// repaired meta (or unknown group): locate the ONE main market
|
||||
// that already has a sub-page with this title
|
||||
$matches = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
||||
[(int)$marketsRoot['uid']]
|
||||
) as $mainRow) {
|
||||
if ($this->childByTitle((int)$mainRow['uid'], $submarket) !== null) {
|
||||
$matches[] = $mainRow;
|
||||
}
|
||||
}
|
||||
if (count($matches) === 1) {
|
||||
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], (string)$matches[0]['title']);
|
||||
$this->report[] = 'group resolved via page tree: "' . $matches[0]['title'] . '"';
|
||||
}
|
||||
}
|
||||
if ($mainPage === null) {
|
||||
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets'
|
||||
. ($group === '' ? ' (and no main market has a sub-page titled "' . $submarket . '")' : '') . '.');
|
||||
}
|
||||
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
*
|
||||
* { "query": "mgw d",
|
||||
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
|
||||
* "products": [ { "title", "teaser", "image", "url" } ] }
|
||||
* "overviews": [ { "title", "link", "path": [category, group] } ],
|
||||
* "products": [ { "title", "teaser", "image", "url",
|
||||
* "overview": { "title", "link", "path" } | null } ] }
|
||||
*
|
||||
* Since menu v2 (2026-11-02) overview pages of the products tree match by
|
||||
* title (every typed term contained) and lead the suggestions; each product
|
||||
* names the overview page linking it.
|
||||
*
|
||||
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
|
||||
* product set stay identical to the site search), restricted to product
|
||||
@@ -60,12 +66,113 @@ final class ProductMenuSearchJsonRenderer
|
||||
// e.g. a typed hyphen or a complete word the prefix form misses
|
||||
$documents = $this->search($connection, $query, false);
|
||||
}
|
||||
return $this->json($query, $this->cards($documents));
|
||||
// menu v2 (2026-11-02): overview pages are hits of their own, and
|
||||
// every product names the overview page it belongs to (path)
|
||||
$overviews = $this->overviewPages();
|
||||
$products = $this->withOverview($this->cards($documents), $overviews);
|
||||
return $this->json($query, $products, $this->matchingOverviews($overviews, $query));
|
||||
} catch (\Throwable) {
|
||||
return $this->json($query, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview pages of the products menu tree (/products > folder >
|
||||
* folder > page, see ProvideProductCatalog) with their menu path.
|
||||
*
|
||||
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
|
||||
*/
|
||||
private function overviewPages(): array
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$root = (int)$pool->getConnectionForTable('pages')->fetchOne(
|
||||
"SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)"
|
||||
);
|
||||
if ($root === 0) {
|
||||
return [];
|
||||
}
|
||||
$level = function (array $parents, array $doktypes) use ($pool): array {
|
||||
if ($parents === []) {
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $pool->getQueryBuilderForTable('pages');
|
||||
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('nav_hide', 0)
|
||||
)
|
||||
->orderBy('sorting')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
};
|
||||
$categories = array_column($level([$root], [254]), null, 'uid');
|
||||
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
|
||||
$overviews = [];
|
||||
foreach ($level(array_keys($groups), [1]) as $page) {
|
||||
$group = $groups[(int)$page['pid']];
|
||||
$overviews[(int)$page['uid']] = [
|
||||
'uid' => (int)$page['uid'],
|
||||
'title' => (string)$page['title'],
|
||||
'link' => (string)$page['slug'],
|
||||
'path' => [(string)$categories[(int)$group['pid']]['title'], (string)$group['title']],
|
||||
];
|
||||
}
|
||||
return $overviews;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `overview` {title, link, path} to every product card whose detail
|
||||
* page is linked from an overview page's cards (first one wins).
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $products
|
||||
* @param array<int,array<string,mixed>> $overviews
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function withOverview(array $products, array $overviews): array
|
||||
{
|
||||
if ($products === [] || $overviews === []) {
|
||||
return $products;
|
||||
}
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
|
||||
$rows = $queryBuilder->select('pid', 'vitec_primary_cta')->from('tt_content')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_keys($overviews), ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
|
||||
$queryBuilder->expr()->in('vitec_primary_cta', $queryBuilder->createNamedParameter(array_column($products, 'url'), ArrayParameterType::STRING))
|
||||
)
|
||||
->orderBy('uid')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
$overviewByUrl = [];
|
||||
foreach ($rows as $row) {
|
||||
$overviewByUrl[(string)$row['vitec_primary_cta']] ??= $overviews[(int)$row['pid']];
|
||||
}
|
||||
foreach ($products as &$product) {
|
||||
$overview = $overviewByUrl[$product['url']] ?? null;
|
||||
$product['overview'] = $overview === null ? null
|
||||
: ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview pages whose title contains every typed term (case-insensitive).
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $overviews
|
||||
* @return array<int,array{title:string,link:string,path:string[]}>
|
||||
*/
|
||||
private function matchingOverviews(array $overviews, string $query): array
|
||||
{
|
||||
$terms = preg_split('/\s+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$hits = [];
|
||||
foreach ($overviews as $overview) {
|
||||
$haystack = mb_strtolower($overview['title'] . ' ' . implode(' ', $overview['path']));
|
||||
if ($terms !== [] && array_reduce($terms, fn(bool $all, string $t): bool => $all && str_contains($haystack, $t), true)) {
|
||||
$hits[] = ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
|
||||
}
|
||||
}
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> Solr document fields, best first */
|
||||
private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array
|
||||
{
|
||||
@@ -154,12 +261,16 @@ final class ProductMenuSearchJsonRenderer
|
||||
return $teasers;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $products */
|
||||
private function json(string $query, array $products): string
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $products
|
||||
* @param array<int,array<string,mixed>> $overviews
|
||||
*/
|
||||
private function json(string $query, array $products, array $overviews = []): string
|
||||
{
|
||||
return (string)json_encode([
|
||||
'query' => $query,
|
||||
'suggestions' => array_values(array_unique(array_column($products, 'title'))),
|
||||
'suggestions' => array_values(array_unique(array_merge(array_column($overviews, 'title'), array_column($products, 'title')))),
|
||||
'overviews' => $overviews,
|
||||
'products' => $products,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||
| **Version** | 1.26 |
|
||||
| **Version** | 1.30 |
|
||||
| **Status** | Released |
|
||||
| **Date** | 2026‑10‑26 |
|
||||
| **Date** | 2026‑11‑02 |
|
||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||
| **Owner** | evomedien — VITEC relaunch |
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
| 1.25 | 2026‑10‑23 | **Interface change (additive).** The markets branch is aligned with the agreed agency structure: `/markets` eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout **23 Market Hero Page** (zones hero = `colPos1`, main = `colPos0`): `vitec_markethero` (CTA anchors `#submarkets`) + `vitec_introparagraph` (header carries the hero H1) in the hero zone; `vitec_marketintro`, `vitec_introparagraph`, sub‑market `vitec_card`s whose `primary_cta` links the child page, `vitec_ctabanner` in main (`vitec_customerlogos` and `vitec_usecaselist` ship hidden until filled editorially). Imported by `vitec:import-market-hero-workbooks`; the tree itself by `vitec:align-market-pages` (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set. |
|
||||
| 1.26 | 2026‑10‑26 | **Interface change (additive).** Product detail resources band simplified (customer CR): the `resources` envelope gains **`sectionLayout`** — `50-50` (default) \| `33-33-33` — from the new per‑product select *Resources Section Layout* (Layout 1: Technical Highlights left, resource cards right, 50/50; Layout 2: highlights over two columns, cards in the third). Capabilities leave the band editorially; the TCA fields `capabilities`, `datasheets` and `downloads` are parked in the Misc tab — all three stay in the payload unchanged. The automatic newest‑datasheet/documentation cards remain deferred; meanwhile the manual card `restype` vocabulary gains `datasheet` and `brochure` — a pinned datasheet card links the download record and stays current because the download route always serves the newest file. |
|
||||
| 1.27 | 2026‑11‑02 | **Interface change (additive).** Product *Resources Section Layout* gained the value `hardware` (“Layout 3 - Hardware option”): `resources.sectionLayout = "hardware"` — Technical Highlights over two columns, no resource cards (`resources.items` is emitted empty even when cards are maintained, so they reappear on a switch back). |
|
||||
| 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. |
|
||||
| 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. |
|
||||
| 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). |
|
||||
|
||||
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
|
||||
@@ -1182,22 +1185,108 @@ document type and the full indexed content.
|
||||
(`ProductMenuSearchJsonRenderer`, USER_INT):
|
||||
|
||||
```json
|
||||
{ "query": "mgw d",
|
||||
"suggestions": [ "MGW Diamond+ OG", "MGW Diamond" ],
|
||||
"products": [ { "title": "MGW Diamond+ OG", "teaser": "Broadcast-grade 4K HEVC …",
|
||||
"image": "/fileadmin/_processed_/…", "url": "/product/…" } ] }
|
||||
{ "query": "vision",
|
||||
"suggestions": [ "Vision Video Capture Cards", "VisionSD8" ],
|
||||
"overviews": [ { "title": "Vision Video Capture Cards", "link": "/products/vision-video-capture-cards",
|
||||
"path": [ "Video Wall Controllers & Management", "Capture & Graphics" ] } ],
|
||||
"products": [ { "title": "VisionSD8", "teaser": "Multi-channel SDI capture …",
|
||||
"image": "/fileadmin/_processed_/…", "url": "/product/visionsd8",
|
||||
"overview": { "title": "Vision Video Capture Cards",
|
||||
"link": "/products/vision-video-capture-cards",
|
||||
"path": [ "Video Wall Controllers & Management", "Capture & Graphics" ] } } ] }
|
||||
```
|
||||
|
||||
It runs through Solr restricted to product documents; the last term is
|
||||
completed as a prefix, so cards appear while typing (inputs below two
|
||||
characters yield the empty shape). At most six products, one card per
|
||||
product; `suggestions` are their titles for the autocomplete list. `teaser`
|
||||
is the product's plain-text one-liner from the record (≤ 120 characters),
|
||||
`image` the indexed 640 px tile or `null`. The endpoint and its parameters
|
||||
travel with the megamenu catalog payload (`catalog.search`); the catalog
|
||||
itself (`layout: "catalog"`, categories → groups → products) is produced by
|
||||
the `ProvideProductCatalog` listener of EXT:evo_megamenu_json’s
|
||||
`BuildCatalogEvent` - see that extension’s README.
|
||||
product. Since v1.28 `overviews` lists the product overview pages whose
|
||||
title (or menu path) contains every typed term, and each product carries
|
||||
`overview` - the overview page that presents it, with its menu path - or
|
||||
`null` for a product outside the menu tree. `suggestions` are the overview
|
||||
titles followed by the product titles. `teaser` is the product's plain-text
|
||||
one-liner from the record (≤ 120 characters), `image` the indexed 640 px
|
||||
tile or `null`.
|
||||
|
||||
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
|
||||
The catalog payload is produced by the `ProvideProductCatalog` listener of
|
||||
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
|
||||
`/products`: sysfolders on the first level are the menu **categories**,
|
||||
sysfolders below them the **groups** (both labels, never pages), and the
|
||||
pages below the groups the **overview pages** (`/products/<slug>`, built by
|
||||
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
|
||||
**shortcut** pages. The menu ends at the overview pages; product detail
|
||||
pages are reached from there.
|
||||
|
||||
```json
|
||||
"catalog": {
|
||||
"version": 2,
|
||||
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
|
||||
"viewAll": { "title": "View all products", "link": "/products" },
|
||||
"categories": [ { "title": "IP Video Streaming, Distribution & KVM",
|
||||
"groups": [ { "title": "Encoders & Decoders",
|
||||
"items": [
|
||||
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
||||
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
||||
"image": null },
|
||||
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
|
||||
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
|
||||
`target` holds zero-based indices into `categories` / `groups` / `items`:
|
||||
with `page` it points to an overview page (and `link` is that page),
|
||||
without `page` it opens a whole group (`link` is `""`).
|
||||
- `products` are the product cards of the overview page that link a detail
|
||||
page (container children included, creation order); `productCount` is
|
||||
their number - a card without a resolvable product is not counted.
|
||||
- `image` (from content stage 2) is the overview page's first page media as a
|
||||
640 px WebP, `null` when none is set.
|
||||
- The promo boxes of the left column ("Product highlight", "Product news")
|
||||
are the entry's editorial `teasers` next to `catalog` (`kicker` = box
|
||||
label, plus `title`, `text`, `link`, `image`; designed for two, an empty
|
||||
array hides the column). There are no automatic news (`catalog.news` was
|
||||
dropped in v1.29).
|
||||
- Hidden pages and pages with *Hide in menu* drop out; order = page tree
|
||||
order. Version 1 (2026-10-09, sys_category groups with products) is no
|
||||
longer the source.
|
||||
- **Transitional v1 fields (deprecated, removed once the front end reads
|
||||
`items`).** The deployed front end still evaluated the v1 shape for the
|
||||
active state of the Products trigger and crashed the whole site without
|
||||
it. Until it has switched, v2 additionally carries: `categories[].uid`
|
||||
(folder uid) and `categories[].link` (`/products`); `groups[].uid` (`0`),
|
||||
`groups[].link` (link of the group's first overview page) and
|
||||
`groups[].products` (the products of all its overview pages, deduplicated
|
||||
by link); and a `teaser` (card text, plain) on every product. Do not build
|
||||
on these fields.
|
||||
|
||||
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
|
||||
since v1.30).** Page-tree entries build their `columns` live from the
|
||||
linked page (one column per child page, its children as `items`). The
|
||||
entry field *Page tree display* adds the presentation:
|
||||
|
||||
```json
|
||||
{ "title": "Markets", "link": "/markets", "layout": "pagetree",
|
||||
"display": "rail", "cardSize": "regular",
|
||||
"columns": [ { "title": "Transport", "link": "/markets/transport", "image": { "url": "…" },
|
||||
"items": [ { "title": "Airports", "link": "/markets/transport/airports",
|
||||
"image": { "url": "/fileadmin/_processed_/…webp", "width": 640, "height": 360, "alternative": "", "title": "" },
|
||||
"teaser": "…" } ] } ] }
|
||||
```
|
||||
|
||||
- `display: "columns"` (default; Support and every entry not switched):
|
||||
columns side by side as before. `display: "rail"`: the columns as a list
|
||||
on the left; hovering (or focusing) one shows its `items` as image cards
|
||||
on the right, the first column is preselected. `cardSize` (`regular` |
|
||||
`compact`, only with `rail`) sizes the cards - Solutions uses `compact`
|
||||
so five sub-solutions fit next to the list.
|
||||
- From content stage 2 every item carries `image` (first page media as a
|
||||
640 px WebP, `null` when none is set) and, when the page has an abstract,
|
||||
`teaser`. The sub-market pages already had page media; the 39 sub-solution
|
||||
pages got their hero background as page media
|
||||
(`vitec:attach-page-media --depth=2`); editors replace it in
|
||||
the page properties.
|
||||
- Additive: a front end that ignores `display` keeps rendering columns.
|
||||
|
||||
**Operations.** Indexing runs automatically: record and page saves enter the
|
||||
index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them
|
||||
@@ -1616,4 +1705,4 @@ remediation.
|
||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||
|
||||
*End of document EVO‑VITEC‑HL‑001 v1.27.*
|
||||
*End of document EVO‑VITEC‑HL‑001 v1.30.*
|
||||
Reference in new issue
Block a user