deleted, 301 to the original. * B. News duplicates. Where the "-1/-2" copy carries the real text and the * base record is an empty shell or an import placeholder, the shell is * deleted and the copy takes over the clean slug (301 from the "-1" URL). * Records without any content anywhere (#9/#229 global events, #42 DSEI * 2023 pointer) are deleted and their URLs sent to the news overview. * C. Records that are NOT duplicates but carry a "-1/-2" slug from the * import: slug rebuilt from the title, 301 from the old URL. * D. /about (page 7): only an office list, superseded by /insights/who-we-are * and /about/locations -> hidden, 301, two links re-pointed. * E. Stories with copied teaser / SEO description (#11 UAE University, * #30 Kansas City Royals, #35 Minnesota United): texts rebuilt from the * story's own first paragraph. * * Dry run: php migrations/dedupe_20261110.php * Write: php migrations/dedupe_20261110.php --apply * Sections: --only=A,B or --skip=D,E * * Deletions are TYPO3 soft deletes (deleted = 1, recoverable via the recycler). */ error_reporting(E_ALL); ini_set('display_errors', '1'); $apply = in_array('--apply', $argv, true); $only = []; $skip = []; foreach ($argv as $arg) { if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); } if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); } } $runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, true); $root = null; $dir = __DIR__; for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); } if ($root === null) { exit("no project root\n"); } $settings = require $root . '/config/system/settings.php'; $db = $settings['DB']['Connections']['Default']; $pdo = new PDO( sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n"; $now = time(); $purgePaths = []; // --- helpers ----------------------------------------------------------------- $hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1"); $insertRedirect = $pdo->prepare( "INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode) VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)" ); $updateRedirectBySource = $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?"); $addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $updateRedirectBySource, $apply, &$purgePaths): void { $purgePaths[$source] = true; $hasRedirect->execute([$source]); $existing = $hasRedirect->fetch(); if ($existing) { if ($existing['target'] === $target) { return; } echo " redirect $source: Ziel {$existing['target']} -> $target\n"; if ($apply) { $updateRedirectBySource->execute([$target, (int)$existing['uid']]); } return; } echo " redirect $source -> $target\n"; if ($apply) { $insertRedirect->execute([$source, $target]); } }; // Redirects whose TARGET is the URL that goes away get the new target (no 301 chains). $retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void { $st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?"); $st->execute([$oldTarget]); foreach ($st->fetchAll() as $r) { echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n"; if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]); } } }; $softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void { echo " loeschen $table #$uid\n"; if ($apply) { $pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]); $pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]); } }; $newsRow = static function (int $uid) use ($pdo): ?array { $st = $pdo->prepare("SELECT uid, title, path_segment, type, hidden, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?"); $st->execute([$uid]); $r = $st->fetch(); return $r ?: null; }; $setSegment = static function (int $uid, string $segment) use ($pdo, $apply, $now): void { echo " news #$uid slug -> $segment\n"; if ($apply) { $pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$segment, $now, $uid]); } }; $slugify = static function (string $t): string { $t = html_entity_decode($t, ENT_QUOTES, 'UTF-8'); $t = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT', $t) ?: $t), '-')); return $t; }; $plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8'))); $cut = static function (string $s, int $max): string { if (mb_strlen($s) <= $max) { return $s; } $s = mb_substr($s, 0, $max); $s = preg_replace('/\s+\S*$/u', '', $s) ?? $s; return rtrim($s, ' ,;:-') . '…'; }; $newsOverview = '/insights/news'; // --- A. product copy ----------------------------------------------------------- if ($runs('A')) { echo "\nA. Produkt-Kopie\n"; $st = $pdo->query("SELECT uid, slug, LENGTH(description) dl FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug IN ('avedia-modular-chassis-c1101-c1103-c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1')"); $p = []; foreach ($st as $r) { $p[$r['slug']] = $r; } $copy = $p['avedia-modular-chassis-c1101-c1103-c1210-1'] ?? null; $base = $p['avedia-modular-chassis-c1101-c1103-c1210'] ?? null; if ($copy && $base && (int)$copy['dl'] === 0) { echo " Produkt #{$copy['uid']} (leer) -> #{$base['uid']}\n"; $softDelete('tx_vitec_domain_model_product', (int)$copy['uid']); $addRedirect('/product/' . $copy['slug'], '/product/' . $base['slug']); $purgePaths['/product/' . $base['slug']] = true; } else { echo " nichts zu tun\n"; } } // --- B. news duplicates ---------------------------------------------------------- if ($runs('B')) { echo "\nB. News-Dubletten\n"; // [keep uid, clean segment, [uids to delete], [old segments to redirect]] $takeovers = [ [12, 'vitec-to-showcase-expanding-enterprise-iptv-and-digital-signage-innovations-at-infocomm-2026', [1]], [13, 'vitec-and-lg-electronics-to-deliver-large-scale-iptv-and-digital-signage-over-next-generation-private-5g-networks', [2]], [14, 'vitec-to-launch-prism-sff-its-most-compact-transcoder-at-eurosatory-2026', [3]], [258, 'ise-2026-a-milestone-for-vitec-and-datapath', [10, 230]], [21, 'vitec-acquires-datapath-ltd-to-expand-video-capabilities', [263]], [231, 'the-impact-of-remote-work-on-security', [344]], ]; foreach ($takeovers as [$keepUid, $clean, $deleteUids]) { $keep = $newsRow($keepUid); if ($keep === null) { echo " #$keepUid fehlt, uebersprungen\n"; continue; } echo " behalten #$keepUid ({$keep['bl']} Zeichen) als /news/article/$clean | {$keep['title']}\n"; foreach ($deleteUids as $d) { $row = $newsRow($d); if ($row === null) { echo " #$d schon weg\n"; continue; } echo " weg: #$d /news/article/{$row['path_segment']} (type {$row['type']}, {$row['bl']} Zeichen)\n"; $softDelete('tx_news_domain_model_news', $d); if ($row['path_segment'] !== $clean) { $addRedirect('/news/article/' . $row['path_segment'], '/news/article/' . $clean); $retarget('/news/article/' . $row['path_segment'], '/news/article/' . $clean); } } if ($keep['path_segment'] !== $clean) { $old = $keep['path_segment']; $setSegment($keepUid, $clean); $addRedirect('/news/article/' . $old, '/news/article/' . $clean); $retarget('/news/article/' . $old, '/news/article/' . $clean); } $purgePaths['/news/article/' . $clean] = true; } // Records with no content anywhere. echo " ohne Inhalt (Record + alte Seite leer):\n"; foreach ([9, 229] as $d) { $row = $newsRow($d); if ($row === null) { continue; } echo " weg: #$d /news/article/{$row['path_segment']} | {$row['title']}\n"; $softDelete('tx_news_domain_model_news', $d); $addRedirect('/news/article/' . $row['path_segment'], $newsOverview); $retarget('/news/article/' . $row['path_segment'], $newsOverview); } $row = $newsRow(42); if ($row !== null) { $alt = $pdo->query("SELECT path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND uid <> 42 AND type = 0 AND path_segment LIKE '%dsei-2023%' LIMIT 1")->fetchColumn(); $target = $alt ? '/news/article/' . $alt : $newsOverview; echo " weg: #42 /news/article/{$row['path_segment']} (Zeiger auf leere Seite 321) -> $target\n"; $softDelete('tx_news_domain_model_news', 42); $addRedirect('/news/article/' . $row['path_segment'], $target); $retarget('/news/article/' . $row['path_segment'], $target); } } // --- C. wrong "-N" slugs on distinct articles -------------------------------------- if ($runs('C')) { echo "\nC. Eigene Artikel mit Import-Suffix im Slug\n"; foreach ([ 435 => 'wallcontrol-10-scoops-installation-product-awards-crown', 334 => '2023-end-of-year-message-from-ceo-bjorn-krylander', 376 => '2022-end-of-year-message-from-ceo-bjorn-krylander', ] as $uid => $segment) { $row = $newsRow($uid); if ($row === null || $row['path_segment'] === $segment) { continue; } echo " #$uid | {$row['title']}\n"; $old = $row['path_segment']; $setSegment($uid, $segment); $addRedirect('/news/article/' . $old, '/news/article/' . $segment); $retarget('/news/article/' . $old, '/news/article/' . $segment); $purgePaths['/news/article/' . $segment] = true; } } // --- D. /about ------------------------------------------------------------------------ if ($runs('D')) { echo "\nD. /about (Seite 7) -> /insights/who-we-are\n"; $page = $pdo->query("SELECT uid, hidden, slug FROM pages WHERE uid = 7 AND deleted = 0")->fetch(); if ($page && (int)$page['hidden'] === 0) { echo " Seite 7 verstecken\n"; if ($apply) { $pdo->exec("UPDATE pages SET hidden = 1, tstamp = $now WHERE uid = 7"); } } $addRedirect('/about', '/insights/who-we-are'); $addRedirect('/about/', '/insights/who-we-are'); foreach ($pdo->query("SELECT uid, pid, bodytext, vitec_button_link, header_link FROM tt_content WHERE deleted = 0 AND uid IN (117, 10291)") as $c) { $changes = []; foreach (['bodytext', 'vitec_button_link', 'header_link'] as $f) { $v = (string)($c[$f] ?? ''); $n = (string)preg_replace('/t3:\/\/page\?uid=7(?![0-9])/', 't3://page?uid=550', $v); $n = str_replace(['"/about"', '"/about/"'], '"/insights/who-we-are"', $n); if (in_array($v, ['/about', '/about/', '7'], true)) { $n = $v === '7' ? '550' : 't3://page?uid=550'; } if ($n !== $v) { $changes[$f] = $n; } elseif ($f !== 'bodytext' && $v !== '') { echo " CE #{$c['uid']} $f = $v\n"; } } if ($changes) { echo " CE #{$c['uid']} (Seite {$c['pid']}): " . implode(', ', array_keys($changes)) . " -> who-we-are\n"; if ($apply) { $set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($changes))); $pdo->prepare("UPDATE tt_content SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($changes), (int)$c['uid']]); } } else { echo " CE #{$c['uid']}: kein /about-Link gefunden (manuell pruefen)\n"; } } $purgePaths['/'] = true; } // --- E. story teasers / descriptions copied from the wrong story ----------------------- if ($runs('E')) { echo "\nE. Story-Teaser/-Beschreibungen aus dem eigenen Text\n"; $firstPara = static function (int $usecase) use ($pdo, $plain): string { $st = $pdo->prepare("SELECT bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_vitec_usecase_content = ? AND bodytext <> '' ORDER BY sorting"); $st->execute([$usecase]); $fallback = ''; foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $html) { $html = (string)preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', (string)$html); $parts = preg_match_all('/
]*>(.*?)<\/p>/is', $html, $m) ? $m[1] : [$html]; foreach ($parts as $p) { $t = $plain($p); if (mb_strlen($t) < 80 || str_contains($t, 'document.') || str_contains($t, 'function (')) { continue; } // Prefer a real sentence over a title-case sub-headline. if (mb_strlen($t) >= 120 && preg_match('/[.!?]$/u', $t) === 1) { return $t; } if ($fallback === '') { $fallback = $t; } } } return $fallback; }; // Texts derived from each story's own (correct) teaser or headline; the // automatic first-paragraph pick was tried and read badly. $texts = [ 11 => ['seo_description' => 'VITEC IPTV and digital signage power streaming and next-generation learning at United Arab Emirates University. Read the success story.'], 30 => ['teaser' => 'Reliable, high-quality, low-latency streams and digital signage tools: the Kansas City Royals improve fan engagement with VITEC’s IPTV platform.'], 35 => [ 'teaser' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.', 'seo_description' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.', ], ]; foreach ($texts as $uid => $values) { $st = $pdo->prepare("SELECT uid, title, teaser, seo_description FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?"); $st->execute([$uid]); $row = $st->fetch(); if (!$row) { continue; } echo " #$uid {$row['title']} (erster Absatz der Story: " . $cut($firstPara($uid), 80) . ")\n"; foreach ($values as $f => $v) { echo " $f alt: " . $cut($plain($row[$f]), 90) . "\n $f neu: $v\n"; } if ($apply) { $set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($values))); $pdo->prepare("UPDATE tx_vitec_domain_model_usecase SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($values), $uid]); } } } echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n"; echo $apply ? "\nFERTIG. Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 referenceindex:update\n" : "\n(Trockenlauf, nichts geschrieben)\n";