diff --git a/bin/preflight.php b/bin/preflight.php index e2c9cfc..48c2435 100644 --- a/bin/preflight.php +++ b/bin/preflight.php @@ -296,6 +296,36 @@ if ($cronKey === '' || $cronKey === 'CHANGE_ME') { } } +// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? --- +// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram, +// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt +// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein +// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken. +// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner. +// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen. +$szFile = DATA_PATH . '/stadionzeitung.json'; +if (is_file($szFile)) { + $szData = json_decode((string) file_get_contents($szFile), true) ?: []; + foreach ($szData['issues'] ?? [] as $ausgabe) { + if (($ausgabe['status'] ?? 'veroeffentlicht') !== 'veroeffentlicht' || !isset($ausgabe['snapshot'])) { + continue; + } + $rohSnap = (string) json_encode($ausgabe['snapshot']); + $fluechtig = []; + foreach (['img/crests/', 'img/instagram/'] as $ordner) { + // JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen. + if (str_contains($rohSnap, $ordner) || str_contains($rohSnap, str_replace('/', '\\/', $ordner))) { + $fluechtig[] = rtrim($ordner, '/'); + } + } + if ($fluechtig !== []) { + $warnings[] = "Stadionzeitung '{$ausgabe['slug']}' ist veröffentlicht, ihr Snapshot zeigt aber noch auf " + . implode(' und ', $fluechtig) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.' + . ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe['slug'] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe['slug']; + } + } +} + // --- Inline-Styles (die CSP verwirft sie stillschweigend) --- // Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der // Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf diff --git a/bin/stadionzeitung-publish.php b/bin/stadionzeitung-publish.php index b953be5..6ca8909 100644 --- a/bin/stadionzeitung-publish.php +++ b/bin/stadionzeitung-publish.php @@ -38,6 +38,112 @@ $log = static function (string $msg, string $level = 'INFO'): void { echo '[' . date('c') . "] {$level} {$msg}\n"; }; +/** + * Bilder, auf die der Snapshot zeigt, in den Ausgaben-Ordner kopieren und die Pfade + * darin umschreiben. Damit ist eine veröffentlichte Ausgabe wirklich eingefroren. + * + * Warum das nötig ist: der Snapshot friert die DATEN ein, verwies aber weiter auf + * Ordner, die die Sync-Skripte verwalten. bin/matchcenter-sync.php räumt Wappen weg, + * die der BFV nicht mehr führt, bin/instagram-sync.php ältere Posts — am 31.07.2026 + * nachgewiesen: ein Lauf löschte 18 Wappen und 3 Instagram-Bilder. Bei den Wappen + * dauert das eine Saison, bei Instagram Wochen. Danach zeigte eine archivierte + * Ausgabe Lücken, und „gedruckt = fixiert" wäre nur die halbe Wahrheit. + * + * img/news wird mit eingefroren, obwohl News-Bilder nicht maschinenverwaltet sind: + * ein Artikelbild kann im Chat ersetzt werden, und dann änderte sich nachträglich + * eine gedruckte Ausgabe. Dieselbe Begründung, derselbe Handgriff. + * + * Fehlt eine Quelldatei (schon weggeräumt), bleibt der Pfad stehen und es gibt eine + * WARN — die crest-Komponente fällt auf Initialen zurück, ein fehlendes Bild darf + * das Veröffentlichen nicht verhindern. + */ +$freezeAssets = static function (array $snapshot, string $slug, callable $log): array { + $quellen = ['img/crests/', 'img/instagram/', 'img/news/']; + // Präfix je Quellordner, damit zwei gleichnamige Dateien sich nicht überschreiben + // und man im Ausgaben-Ordner sofort sieht, woher eine Datei kommt. + $praefix = ['img/crests/' => 'fix-wappen-', 'img/instagram/' => 'fix-insta-', 'img/news/' => 'fix-news-']; + $zielDir = PUBLIC_PATH . '/assets/img/stadionzeitung/' . $slug; + if (!is_dir($zielDir)) { + mkdir($zielDir, 0755, true); + } + + $kopiert = []; + $fehlend = []; + + $walk = static function (&$node) use (&$walk, $quellen, $praefix, $zielDir, $slug, &$kopiert, &$fehlend): void { + if (is_array($node)) { + // Varianten-Set {base, widths, ext?} zuerst: `base` ist KEIN Dateipfad, + // sondern ein Stamm ohne Endung — jede Breite ist eine eigene Datei. + // Ohne diesen Zweig lief img/news/neue-website als „Quelldatei fehlt" auf. + if (isset($node['base'], $node['widths']) && is_string($node['base']) && is_array($node['widths'])) { + foreach ($quellen as $ordner) { + if (!str_starts_with($node['base'], $ordner)) { + continue; + } + $ext = strtolower((string) ($node['ext'] ?? 'jpg')); + $neuStamm = $praefix[$ordner] . basename($node['base']); + $alleDa = true; + foreach ($node['widths'] as $w) { + $von = PUBLIC_PATH . "/assets/{$node['base']}-{$w}.{$ext}"; + if (!is_file($von)) { + $fehlend["{$node['base']}-{$w}.{$ext}"] = true; + $alleDa = false; + continue; + } + copy($von, "{$zielDir}/{$neuStamm}-{$w}.{$ext}"); + $kopiert["{$node['base']}-{$w}.{$ext}"] = true; + } + // Nur umschreiben, wenn ALLE Breiten kopiert sind — ein halbes + // srcset wäre schlimmer als der alte Pfad. + if ($alleDa) { + $node['base'] = "img/stadionzeitung/{$slug}/{$neuStamm}"; + } + return; + } + } + foreach ($node as &$wert) { + $walk($wert); + } + return; + } + if (!is_string($node)) { + return; + } + foreach ($quellen as $ordner) { + if (!str_starts_with($node, $ordner)) { + continue; + } + $datei = basename($node); + $neu = $praefix[$ordner] . $datei; + $ziel = "{$zielDir}/{$neu}"; + if (!isset($kopiert[$node])) { + $von = PUBLIC_PATH . '/assets/' . $node; + if (!is_file($von)) { + $fehlend[$node] = true; + return; // Pfad unverändert lassen — besser ein Fallback als ein toter Link + } + copy($von, $ziel); + $kopiert[$node] = true; + } + $node = "img/stadionzeitung/{$slug}/{$neu}"; + return; + } + }; + $walk($snapshot); + + $log(sprintf( + 'Bilder eingefroren: %d Dateien in img/stadionzeitung/%s/ kopiert%s.', + count($kopiert), + $slug, + $fehlend === [] ? '' : ', ' . count($fehlend) . ' Quelldatei(en) fehlten schon' + )); + foreach (array_keys($fehlend) as $f) { + $log("Quelldatei für den Snapshot fehlt, Pfad unverändert: {$f}", 'WARN'); + } + + return $snapshot; +}; + $cacheFile = DATA_PATH . '/stadionzeitung.json'; $data = is_file($cacheFile) ? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []]) @@ -53,7 +159,16 @@ foreach ($data['issues'] as &$issue) { if ($unpublish) { $issue['status'] = 'entwurf'; unset($issue['snapshot']); - $log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen (Live-Seiten rechnen wieder frisch)."); + // Die eingefrorenen Bilder gehören zum Snapshot und gehen mit ihm. Sonst + // lägen sie als Waisen im Ausgaben-Ordner und ein erneutes Veröffentlichen + // legte daneben eine zweite Fassung ab. + $weg = 0; + foreach (glob(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/fix-*") ?: [] as $f) { + if (is_file($f) && unlink($f)) { + $weg++; + } + } + $log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen, {$weg} eingefrorene Bilder entfernt (Live-Seiten rechnen wieder frisch)."); break; } @@ -63,6 +178,9 @@ foreach ($data['issues'] as &$issue) { } $snapshot = stadionzeitung_build_snapshot($issue); + // Erst die Bilder sichern, dann den Snapshot ablegen: die Pfade darin zeigen + // danach in den Ausgaben-Ordner und sind vom Sync unabhängig. + $snapshot = $freezeAssets($snapshot, $slug, $log); $issue['snapshot'] = $snapshot; $issue['status'] = 'veroeffentlicht'; $issue['published_at'] = date('c');