Datapath stories, grid repairs, menu v2 pure, hero placeholders, fec test

Batch of 2026-10-28/29 plus 2026-11-03:

- NEW vitec:import-datapath-stories: 22 story packages from the agency
  dump (docx + Links/ assets) as usecase records with content, quotes,
  images, customer logos, download cards; --repair-quotes normalizes
  attribution lines; asset backfill for existing stories
- /products grid incident repaired (fix_products_grid, fix_graphics_grid,
  check_grid_page): containers unhidden, 46 duplicate cards soft-deleted,
  section order restored; BuildProductPortfolio and
  ImportProductCategoryPages now GUARD against re-runs on pages that
  already carry vitec_cols_% containers
- ' double-encoding fixed: render-pipeline importers write literal
  apostrophes (ENT_NOQUOTES), fix_double_entities literalizes existing rows
- ticket quick wins (ticket_quickwins_20261028), broadcast market page
  cleanup, AttachCardImages market-card handling, SubmarketWorkbook
  META REPAIR, story content/video fixes (fix_story_*)
- ProvideProductCatalog: transitional v1 fields removed (categories
  uid/link, legacyGroupFields, item products teaser) - pure v2 shape
  (ticket 0024-FE); search renderer keeps its teaser
- BuildProductOverviews: heroes get the numbered placeholder background
  (placeholders/pages/<uid>-01.png, wide grey fallback) - backfill on
  existing pages with empty hero_bgimage, attached at creation for new
  pages; existing references always win (ticket 0026-FE); 40 placeholder
  PNGs shipped to fileadmin (not tracked)
- AttachPageMedia: --depth=3 with sysfolders as pass-through parents for
  the overview pages' -08 menu tiles
- NEW migrations/fec_isolation_test.sh: two-user live test proving
  fe_comment ticket isolation (backend verified tight, cross-user
  sighting traced to the FE page-JSON cache, ticket 0027-BE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-03 18:29:56 +01:00
1 parent 9211c8a395
commit 77661bf0b6
22 files changed
+2103 -63

No files matched your search

+1
View File
@@ -140,3 +140,4 @@ migrations/product_categories_xlsx_in/
# generated internal report (php migrations/build_product_audit.php) # generated internal report (php migrations/build_product_audit.php)
public/product-audit/ public/product-audit/
migrations/datapath_stories_in/
+46
View File
@@ -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";
+131
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* Ticket 0014-FE Punkt 2: doppelt kodierte HTML-Entities in tt_content
* (z. B. "VITEC&amp;apos;s" im JSON). Ursache: die Produkt-Importer
* escapten mit ENT_QUOTES|ENT_HTML5 (' -> &apos;), 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: &amp;apos; -> &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 &apos; EINFACH kodiert, erst die Render-Pipeline escapet das &
// erneut): Apostroph-Entities literal machen, dann kann nichts mehr doppeln.
const PATTERNS = ['&amp;apos;', '&amp;quot;', '&amp;#039;', '&amp;amp;', '&amp;nbsp;', '&apos;', '&#039;'];
const REPLACEMENTS = ['&apos;', '&quot;', '&#039;', '&amp;', '&nbsp;', "'", "'"];
$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 (&amp;apos; -> &apos; usw.).\n";
}
+88
View File
@@ -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";
+117
View File
@@ -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";
+141
View File
@@ -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 &amp; 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";
+155
View File
@@ -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";
+147
View File
@@ -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";
}
@@ -32,6 +32,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* a missing numbered file falls back to the generic grey placeholder * a missing numbered file falls back to the generic grey placeholder
* and is reported for generation * 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 * 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 * still advances the numbering, so re-runs stay stable. Editors swap the
* placeholder for the real asset on the element. * placeholder for the real asset on the element.
@@ -45,7 +51,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
)] )]
final class AttachCardImagesCommand extends Command 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; private const FIRST_SLOT = 10;
protected function configure(): void protected function configure(): void
@@ -77,7 +83,7 @@ final class AttachCardImagesCommand extends Command
$datamap = []; $datamap = [];
$newIndex = 0; $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) { foreach ($pageUids as $pageUid => $slug) {
$cards = $content->fetchAllAssociative( $cards = $content->fetchAllAssociative(
"SELECT uid, header, vitec_primary_cta, vitec_layout_variant FROM tt_content "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; $slot = self::FIRST_SLOT - 1;
foreach ($cards as $card) { foreach ($cards as $card) {
$cardUid = (int)$card['uid']; $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']; $cta = (string)$card['vitec_primary_cta'];
$isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches); $isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches);
if (!$isProduct) { if (!$isProduct) {
$slot++; $slot++;
} }
$existing = (int)$refs->fetchOne( $existingRef = $refs->fetchAssociative(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' "SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r
AND fieldname = 'vitec_image' AND uid_foreign = ?", 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] [$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']++; $stats['kept']++;
// a card that carries an image must not stay on the // a card that carries an image must not stay on the
// explicit no-image variant (found 2026-10-27: the FE // explicit no-image variant (found 2026-10-27: the FE
@@ -115,6 +147,7 @@ final class AttachCardImagesCommand extends Command
} }
$identifier = null; $identifier = null;
$note = ''; $note = '';
$isMarketCard = false;
if ($isProduct) { if ($isProduct) {
$identifier = $this->productImageIdentifier($pool, $matches[1]); $identifier = $this->productImageIdentifier($pool, $matches[1]);
if ($identifier !== null) { if ($identifier !== null) {
@@ -125,6 +158,13 @@ final class AttachCardImagesCommand extends Command
$stats['grey']++; $stats['grey']++;
$note = 'record ' . $matches[1] . ' has NO image - generic 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 { } else {
$identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png'; $identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png';
if ($storage->hasFile($identifier)) { if ($storage->hasFile($identifier)) {
@@ -145,7 +185,9 @@ final class AttachCardImagesCommand extends Command
if (!$apply) { if (!$apply) {
continue; 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'; $datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
$stats['variant']++; $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", $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['placeholder'], $stats['grey'], $stats['kept'], $stats['variant'], $stats['photo'], $stats['pagemedia'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['upgraded'], $stats['variant'],
$stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing']))); $stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing'])));
if (!$apply) { if (!$apply) {
@@ -184,6 +226,32 @@ final class AttachCardImagesCommand extends Command
return Command::SUCCESS; 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. */ /** First product photo, falling back to the hero image; null when the record has neither. */
private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string
{ {
@@ -31,10 +31,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* touched - editors swap the image in the page properties. * touched - editors swap the image in the page properties.
* *
* --depth=2 targets the grandchildren (e.g. the sub-markets below * --depth=2 targets the grandchildren (e.g. the sub-markets below
* /markets/<market>) instead of the children. * /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
* vendor/bin/typo3 vitec:attach-page-media --root=/markets --depth=2 --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( #[AsCommand(
name: 'vitec:attach-page-media', name: 'vitec:attach-page-media',
@@ -45,7 +48,7 @@ final class AttachPageMediaCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /support'); $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', '1'); $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)'); $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
} }
@@ -62,11 +65,14 @@ final class AttachPageMediaCommand extends Command
$output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>'); $output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>');
return Command::FAILURE; return Command::FAILURE;
} }
$depth = max(1, min(2, (int)$input->getOption('depth'))); $depth = max(1, min(3, (int)$input->getOption('depth')));
$parents = [(int)$root['uid']]; $parents = [(int)$root['uid']];
for ($level = 1; $level <= $depth; $level++) { 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( $children = $pages->fetchAllAssociative(
'SELECT uid, pid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting' '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'
); );
$parents = array_map('intval', array_column($children, 'uid')); $parents = array_map('intval', array_column($children, 'uid'));
} }
@@ -888,13 +888,13 @@ final class BuildContentPagesCommand extends Command
private function p(string $text): string private function p(string $text): string
{ {
$text = trim($text); $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 */ /** @param string[] $items */
private function ul(array $items): string 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>'; return '<ul>' . implode('', $li) . '</ul>';
} }
@@ -12,6 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler; use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
@@ -29,9 +30,11 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* Catalog reads it): editors rename, reorder or move overview pages there. * Catalog reads it): editors rename, reorder or move overview pages there.
* A and B are sysfolders - never routed, never in navigation menus. The 20 * 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 * overview pages get flat slugs (decision Olli 2026-11-02) and, when they
* are new or still empty, generated content: hero (page title), one intro * are new or still empty, generated content: hero (page title, placeholder
* paragraph per grouping title (or "Product range"), a product card per * background - 0026-FE), one intro paragraph per grouping title (or
* sub-product (record teaser, link /product/<slug>), contact pre-footer. * "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. * Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
* *
* Decisions without customer confirmation (Olli: proceed, nothing deleted): * Decisions without customer confirmation (Olli: proceed, nothing deleted):
@@ -175,6 +178,7 @@ final class BuildProductOverviewsCommand extends Command
/** @var string[] */ /** @var string[] */
private array $report = []; private array $report = [];
private int $newCounter = 0; private int $newCounter = 0;
private bool $apply = false;
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */ /** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
private ?array $products = null; private ?array $products = null;
@@ -186,7 +190,7 @@ final class BuildProductOverviewsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
Bootstrap::initializeBackendAuthentication(); Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply'); $apply = $this->apply = (bool)$input->getOption('apply');
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $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)"); $root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)");
if (!$root) { if (!$root) {
@@ -309,6 +313,8 @@ final class BuildProductOverviewsCommand extends Command
$contentCount > 0 ? ' - content left untouched' : ' - content generated'); $contentCount > 0 ? ' - content left untouched' : ' - content generated');
if ($contentCount === 0) { if ($contentCount === 0) {
$this->content($pageId, $entry, $cards); $this->content($pageId, $entry, $cards);
} else {
$this->backfillHeroImage((int)$pageId);
} }
} else { } else {
$pageId = $this->newId(); $pageId = $this->newId();
@@ -373,10 +379,29 @@ final class BuildProductOverviewsCommand extends Command
*/ */
private function content(string $pageId, array $entry, array $cards): void private function content(string $pageId, array $entry, array $cards): void
{ {
$this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [ $heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [
'header' => (string)$entry['page'], 'header_layout' => 1, 'header' => (string)$entry['page'], 'header_layout' => 1,
'bodytext' => isset($entry['desc']) ? $this->p(ucfirst((string)$entry['desc'])) : '', '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'])) { if (!isset($cards[0]['group'])) {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']); $this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
} }
@@ -412,11 +437,72 @@ final class BuildProductOverviewsCommand extends Command
} }
/** @param array<string,mixed> $fields */ /** @param array<string,mixed> $fields */
private function element(string $pageId, int $colPos, string $ctype, array $fields): void private function element(string $pageId, int $colPos, string $ctype, array $fields): string
{ {
$id = $this->newId(); $id = $this->newId();
$this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields; $this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields;
$this->contentOrder[$pageId][$colPos][] = $id; $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 ----------------------------------------------------------------------- // ---- lookups -----------------------------------------------------------------------
@@ -79,6 +79,24 @@ final class BuildProductPortfolioCommand extends Command
return Command::FAILURE; return Command::FAILURE;
} }
$pageUid = (int)$root['uid']; $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 // the page sits on layout 0 (Default, main only) - the portfolio
// needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27: // needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27:
// the page's own content was all deleted, the rendered payload came // 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 private function p(string $text): string
{ {
$text = trim($text); $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 &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;apos; im JSON)
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
} }
private function norm(string $value): string 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 Dropbox folder names, so a vitec:import-product-images re-run attaches
* the delivered banner/carousel assets immediately. * 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
* vendor/bin/typo3 vitec:create-missing-products --apply * 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.'], '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', ['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.'], '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 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 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 private function norm(string $value): string
@@ -173,6 +173,24 @@ final class ImportProductCategoryPagesCommand extends Command
} }
$pageUid = ctype_digit($pageId) ? (int)$pageId : 0; $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 ------------------------------------------------------------------- // --- hero -------------------------------------------------------------------
if ($hero !== null) { if ($hero !== null) {
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId); $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>'; $body = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
foreach ($rows as $row) { foreach ($rows as $row) {
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>' $body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>' . '<td>' . htmlspecialchars((string)$row['body'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>'; . '<td>' . htmlspecialchars((string)$row['cta'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
} }
$body .= '</tbody></table>'; $body .= '</tbody></table>';
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId); $id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
@@ -502,7 +520,9 @@ final class ImportProductCategoryPagesCommand extends Command
private function p(string $text): string private function p(string $text): string
{ {
$text = trim($text); $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 &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;apos; im JSON)
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
} }
private function norm(string $value): string private function norm(string $value): string
@@ -36,7 +36,9 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* *
* Version 1 (2026-10-09) listed sys_category groups with their products; * 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 * the nine old category pages are plain pages below /products and are not
* part of this tree. * 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')] #[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
final class ProvideProductCatalog final class ProvideProductCatalog
@@ -117,10 +119,9 @@ final class ProvideProductCatalog
} }
$items[] = $item; $items[] = $item;
} }
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items] + $this->legacyGroupFields($items); $groups[] = ['title' => (string)$groupRow['title'], 'items' => $items];
} }
// legacy (v1) fields - see legacyGroupFields() $categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups];
$categories[] = ['uid' => (int)$categoryRow['uid'], 'title' => (string)$categoryRow['title'], 'link' => self::PRODUCTS_SLUG, 'groups' => $groups];
} }
// shortcuts: target = a group of this tree (opens that group) or an // shortcuts: target = a group of this tree (opens that group) or an
@@ -158,33 +159,6 @@ final class ProvideProductCatalog
return $categories; return $categories;
} }
/**
* TRANSITIONAL (2026-11-02): the deployed front end still reads the v1
* shape (`groups[].products` with title/teaser/link, `groups[].link`)
* and crashed the WHOLE site with v2 alone (white page). Each group
* therefore also carries the products of its overview pages, deduplicated
* by link, and the link of its first overview page. Remove once the
* front end has switched to `items` (briefing 2026-11-02).
*
* @param array<int,array<string,mixed>> $items
* @return array{uid:int,link:string,products:array<int,array<string,mixed>>}
*/
private function legacyGroupFields(array $items): array
{
$products = [];
$link = '';
foreach ($items as $item) {
if ($item['type'] !== 'page') {
continue;
}
$link = $link !== '' ? $link : (string)$item['link'];
foreach ($item['products'] as $product) {
$products[$product['link']] ??= $product;
}
}
return ['uid' => 0, 'link' => $link, 'products' => array_values($products)];
}
private function productsRootUid(): int private function productsRootUid(): int
{ {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
@@ -235,7 +209,7 @@ final class ProvideProductCatalog
return []; return [];
} }
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'header', 'bodytext', 'vitec_primary_cta')->from('tt_content') $rows = $queryBuilder->select('pid', 'header', 'vitec_primary_cta')->from('tt_content')
->where( ->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')), $queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
@@ -247,7 +221,6 @@ final class ProvideProductCatalog
foreach ($rows as $row) { foreach ($rows as $row) {
$byPage[(int)$row['pid']][] = [ $byPage[(int)$row['pid']][] = [
'title' => (string)$row['header'], 'title' => (string)$row['header'],
'teaser' => trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['bodytext']), ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
'link' => (string)$row['vitec_primary_cta'], 'link' => (string)$row['vitec_primary_cta'],
]; ];
} }
@@ -85,15 +85,57 @@ final class SubmarketWorkbookImporter
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?'); 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 --------------------------------------------------- // --- resolve pages ---------------------------------------------------
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'"); $marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
if (!$marketsRoot) { if (!$marketsRoot) {
throw new \RuntimeException('Page /markets not found.'); 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) { 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); $subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);