Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung

Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
2026-07-31 14:55:27 +02:00
parent 3fb480dd78
commit a101b0c3ec
861 changed files with 75244 additions and 487 deletions

181
bin/anzeige-add.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* Sponsoren-Anzeige in den Bestand aufnehmen oder ihr Motiv erneuern (nur CLI).
*
* php bin/anzeige-add.php <bild-oder-ordner> [-v]
*
* Beispiele:
* php bin/anzeige-add.php ~/Desktop/anzeigen/ alle Bilder im Ordner
* php bin/anzeige-add.php ~/Desktop/eldorado.jpg nur diese eine
*
* DER DATEINAME IST DER SLUG: `eldorado.jpg` wird zu `img/anzeigen/eldorado-*.jpg`
* und findet damit den bestehenden Eintrag wieder. Ein neues Motiv für einen
* bekannten Sponsor heißt also einfach wieder `eldorado.jpg`.
*
* Anzeigen sind ein DAUERHAFTER Bestand (data/anzeigen.json), kein Zubehör einer
* einzelnen Ausgabe: Sponsorenmotive ändern sich selten, die Stadionzeitung
* erscheint oft. bin/stadionzeitung-add.php holt sie sich von hier, pro Heft ist
* nichts abzuliefern. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe und
* ihr Alt-Text musste jedes Mal neu geschrieben werden.
*
* Das Skript schreibt ausschließlich `base` und `widths`. `sponsor`, `alt`,
* `aktiv` und die REIHENFOLGE der Liste (= Reihenfolge im Heft) sind
* chat-gepflegt und überleben jeden Lauf unangetastet — dasselbe Prinzip wie bei
* bin/veranstaltung-bilder.php. Bei einem neuen Sponsor wird `sponsor` aus dem
* Dateinamen vorbelegt und muss im Chat richtiggestellt werden; das Skript sagt,
* bei welchen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('stadionzeitung', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
exit(1);
};
if ($args === []) {
fwrite(STDERR, "Nutzung: php bin/anzeige-add.php <bild-oder-ordner> [-v]\n");
exit(1);
}
// --- Quelldateien einsammeln ---
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$quelle = $args[0];
if (is_dir($quelle)) {
$sources = glob(rtrim($quelle, '/') . "/*.{{$exts}}", GLOB_BRACE) ?: [];
natsort($sources);
$sources = array_values($sources);
} elseif (is_file($quelle)) {
$sources = [$quelle];
} else {
$fail("Nicht gefunden: {$quelle}");
}
if ($sources === []) {
$fail("Keine Bilder in {$quelle} gefunden.");
}
// Selbst-Import verhindern: zeigt jemand auf den Zielordner, wären die Quellen
// die erzeugten Varianten (`eldorado-1440.jpg`), und aus jeder Breite entstünde
// ein eigener Sponsor („eldorado 1440"). Einmal reingelaufen, deshalb der Riegel.
$zielReal = realpath(PUBLIC_PATH . '/assets/img/anzeigen');
foreach ($sources as $src) {
if ($zielReal !== false && realpath(dirname($src)) === $zielReal) {
$fail('Quelle liegt im Zielordner public/assets/img/anzeigen/ — das sind die erzeugten Varianten, nicht die Originale.');
}
}
// --- Lock gegen parallele Läufe ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/anzeigen.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
$dataFile = DATA_PATH . '/anzeigen.json';
$data = is_file($dataFile)
? (json_decode((string) file_get_contents($dataFile), true) ?: ['anzeigen' => []])
: ['anzeigen' => []];
$destDir = PUBLIC_PATH . '/assets/img/anzeigen';
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
$widths = [480, 960, 1440];
/** Dateiname zu Slug: Kleinschreibung, Umlaute weg, alles andere zu Bindestrichen. */
$slugify = static function (string $text): string {
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
$text = strtolower($text);
return trim(preg_replace('/[^a-z0-9]+/', '-', $text) ?? '', '-');
};
$neu = [];
$erneuert = [];
foreach ($sources as $src) {
$slug = $slugify(pathinfo($src, PATHINFO_FILENAME));
if ($slug === '') {
$log('Dateiname ergibt keinen Slug, übersprungen: ' . basename($src), 'WARN');
continue;
}
// Alte Varianten dieses Slugs weg, sonst bleiben bei einem kleineren Motiv
// Breiten von vorher liegen und die JSON verspricht Dateien, die zum Bild
// nicht mehr passen.
foreach (glob("{$destDir}/{$slug}-*.jpg") ?: [] as $alt) {
unlink($alt);
}
try {
// Qualität 82 wie bei den Plakaten: Anzeigen tragen Text, und Ringing um
// Buchstaben sieht man.
$variants = resize_image_variants($src, "{$destDir}/{$slug}", $widths, 82);
} catch (RuntimeException $e) {
$fail('Anzeige nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
// Bestehenden Eintrag suchen und NUR base/widths ersetzen.
$index = null;
foreach ($data['anzeigen'] as $i => $eintrag) {
if (($eintrag['slug'] ?? '') === $slug) {
$index = $i;
break;
}
}
if ($index === null) {
// Neuer Sponsor: Platzhalter-Name aus dem Slug, im Chat zu ersetzen.
$data['anzeigen'][] = [
'slug' => $slug,
'sponsor' => str_replace('-', ' ', $slug),
'aktiv' => true,
'base' => "img/anzeigen/{$slug}",
'widths' => $generated,
];
$neu[] = $slug;
} else {
$data['anzeigen'][$index]['base'] = "img/anzeigen/{$slug}";
$data['anzeigen'][$index]['widths'] = $generated;
$erneuert[] = $slug;
}
$log("Anzeige {$slug}: " . implode('/', $generated) . 'px');
}
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $dataFile . '.tmp';
file_put_contents($tmp, $payload . "\n");
if (!rename($tmp, $dataFile)) {
$fail('Konnte data/anzeigen.json nicht schreiben.');
}
$log(sprintf('Anzeigen-Bestand aktualisiert: %d neu, %d erneuert.', count($neu), count($erneuert)));
echo 'Anzeigen-Bestand: ' . count($data['anzeigen']) . ' Motive, ' . count($neu) . ' neu, ' . count($erneuert) . " erneuert.\n";
if ($neu !== []) {
echo "Sponsorenname noch Platzhalter bei: " . implode(', ', $neu) . "\n";
echo "In data/anzeigen.json richtigstellen (daraus entsteht der Alt-Text „Anzeige: …\").\n";
}

491
bin/chronik-add.php Normal file
View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
/**
* Fundstücke für die Vereinschronik 2028 einsammeln und ablegen.
*
* Holt zu einer Web-Adresse Überschrift, Datum, Teaser, Bildnachweis und das Aufmacherbild,
* legt beides im richtigen Jahrzehnt-Ordner unter Vereinshistorie/ ab, schreibt einen
* Quellen-Beleg daneben und eine Zeile ins Register (docs/chronik/register.csv).
*
* Warum überhaupt ein Skript und kein „Rechtsklick, Bild speichern": das Bild allein ist
* in fünf Jahren wertlos. Woher es stammt, wann es entstand und wem es gehört, steht nirgends
* im JPEG — und genau das braucht man, wenn die Chronik in den Druck geht. Das Skript hält
* den Zusammenhang fest, in dem Moment, in dem er noch bekannt ist.
*
* Es wird NICHTS massenhaft abgegriffen: pro Aufruf eine Seite, mit Pause zwischen den
* Abrufen. Die Presseportale haben keine Übersichtsseite mehr, über die man den Verein
* vollständig abfragen könnte — die Fundstellen kommen aus gezielter Suche, nicht aus
* einem Crawler.
*
* NUR CLI, nie im Request-Pfad (wie alle bin/-Skripte).
*
* Aufruf:
* php bin/chronik-add.php <URL> [--datum=1972-07-15] [--titel="…"] [--art=Zeitungsartikel]
* [--notiz="…"] [--urheber="…"] [--ohne-bild] [--nur-register]
* php bin/chronik-add.php --liste=urls.txt Zeilen: URL | Datum | Titel (| optional)
* php bin/chronik-add.php --datei=<pfad> --datum=1983-06-11 --titel="Festzug 75 Jahre"
* [--quelle="Album Familie Rausch"] [--urheber="…"] [--art=Foto]
* [--rechte-klaeren]
* php bin/chronik-add.php --rechte nur 00_RECHTE-ZU-KLAEREN.txt neu schreiben
* php bin/chronik-add.php --bericht Registerübersicht auf stdout
*
* Optionen --datum/--titel gewinnen immer gegen das, was die Seite selbst angibt.
* --datei benennt die Datei um und verschiebt sie in den passenden Jahrzehnt-Ordner.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$opts = [];
$positional = [];
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--([a-z-]+)(?:=(.*))?$/s', $arg, $m)) {
$opts[$m[1]] = $m[2] ?? true;
} else {
$positional[] = $arg;
}
}
$root = chronik_root();
if (!is_dir($root)) {
fwrite(STDERR, "Vereinshistorie/ fehlt — erst 'php bin/chronik-init.php' laufen lassen.\n");
exit(1);
}
// --- Nebenmodi -------------------------------------------------------------
if (isset($opts['rechte'])) {
$n = chronik_write_rights_todo();
echo "00_RECHTE-ZU-KLAEREN.txt geschrieben — {$n} offene Posten.\n";
exit(0);
}
if (isset($opts['bericht'])) {
chronik_report();
exit(0);
}
// --- HTTP ------------------------------------------------------------------
/**
* Browser-typische Header. Ohne sie antworten mehrere der Quellen mit 403 —
* dieselbe Erfahrung wie beim BFV-Widget (dort HTTP 418), siehe matchcenter-sync.php.
*/
function chronik_fetch(string $url, ?string $referer = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 25,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
CURLOPT_HTTPHEADER => array_filter([
'Accept: text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8',
'Accept-Language: de-DE,de;q=0.9',
$referer !== null ? 'Referer: ' . $referer : null,
]),
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$final = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
return [
'ok' => $body !== false && $status === 200,
'status' => $status,
'body' => $body === false ? '' : $body,
'type' => $type,
'url' => $final !== '' ? $final : $url,
];
}
/** Ersten Treffer eines Musters liefern, HTML-Entities aufgelöst. */
function chronik_match(string $html, string $pattern): ?string
{
if (!preg_match($pattern, $html, $m)) {
return null;
}
$val = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $val !== '' ? $val : null;
}
/**
* Fließtext einer Seite herausschälen.
*
* Grob und absichtlich: Skripte und Styles raus, Tags raus, alles behalten, was länger als
* ein Navigationspunkt ist. Für saubere Extraktion bräuchte es pro Portal eine eigene Regel —
* das lohnt hier nicht, weil der Text als Beleg dient und nicht als Layout.
*/
function chronik_body_text(string $html): string
{
$html = (string) preg_replace('#<(script|style|nav|header|footer|form)\b.*?</\1>#is', ' ', $html);
$html = (string) preg_replace('#<(br|/p|/div|/h[1-6]|/li|/td|/tr)[^>]*>#i', "\n", $html);
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$keep = [];
$seen = [];
foreach (explode("\n", $text) as $line) {
$line = trim((string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $line));
// 45 Zeichen trennt Fließtext zuverlässig von Menüpunkten und Buttons.
if (mb_strlen($line) < 45 || isset($seen[$line])) {
continue;
}
$seen[$line] = true;
$keep[] = $line;
}
return implode("\n\n", $keep);
}
/** Seite auswerten: was an Metadaten drinsteht, ohne Layout-Annahmen. */
function chronik_parse(string $html): array
{
$strip = static fn(string $s): string => trim((string) preg_replace('/\s+/', ' ', strip_tags($s)));
$titel = chronik_match($html, '/<meta property="og:title" content="([^"]+)"/i')
?? chronik_match($html, '/<h1[^>]*>(.*?)<\/h1>/is')
?? chronik_match($html, '/<title[^>]*>(.*?)<\/title>/is');
if ($titel !== null) {
$titel = $strip($titel);
// Seitentitel tragen oft " | Portalname" mit — für den Dateinamen stört das nur.
$titel = (string) preg_replace('/\s*[|-]\s*(inFranken\.de|BFV|FUSSBALL\.DE|Deutsche Turnliga)\s*$/iu', '', $titel);
}
$datum = chronik_match($html, '/<meta property="article:published_time" content="(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/"datePublished"\s*:\s*"(\d{4}-\d{2}-\d{2})/i')
?? chronik_match($html, '/datetime="(\d{4}-\d{2}-\d{2})/i');
$teaser = chronik_match($html, '/<meta property="og:description" content="([^"]+)"/i')
?? chronik_match($html, '/<meta name="description" content="([^"]+)"/i');
$bild = chronik_match($html, '/<meta property="og:image" content="([^"]+)"/i');
// Bildnachweis steht bei Presseseiten fast immer in der Bildunterschrift
// („Foto: Max Mustermann") — genau die Angabe, die später fehlt.
$urheber = null;
if (preg_match_all('/<figcaption[^>]*>(.*?)<\/figcaption>/is', $html, $caps)) {
foreach ($caps[1] as $cap) {
$cap = $strip(html_entity_decode($cap, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (preg_match('/\b(Foto|Bild|Quelle|©)\s*:?\s*(.{2,80})$/ui', $cap, $m)) {
$urheber = trim($m[2], " .;");
break;
}
}
}
$urheber ??= chronik_match($html, '/<meta name="author" content="([^"]+)"/i');
return compact('titel', 'datum', 'teaser', 'bild', 'urheber');
}
// --- Ein Fundstück verarbeiten ---------------------------------------------
function chronik_add(string $url, array $opts, array $override = []): bool
{
$root = chronik_root();
$heute = date('Y-m-d');
if (chronik_register_has_url($url) && !isset($opts['erneut'])) {
echo "· schon im Register: {$url}\n";
return true;
}
// Bei Wayback-Adressen zählt für Rechte und Quellenkürzel die ARCHIVIERTE Seite,
// nicht das Archiv — abgerufen wird trotzdem der Schnappschuss.
$quellUrl = chronik_origin_url($url);
$host = (string) (parse_url($quellUrl, PHP_URL_HOST) ?: '');
if ($host === '') {
fwrite(STDERR, "✗ Keine gültige Adresse: {$url}\n");
return false;
}
$rights = chronik_rights($host);
$meta = ['titel' => null, 'datum' => null, 'teaser' => null, 'bild' => null, 'urheber' => null];
$volltext = '';
if (!isset($opts['nur-register'])) {
$res = chronik_fetch($url);
if (!$res['ok']) {
fwrite(STDERR, "✗ HTTP {$res['status']}: {$url}\n");
log_write('chronik', 'WARN', "Abruf fehlgeschlagen (HTTP {$res['status']}): {$url}");
return false;
}
$meta = chronik_parse($res['body']);
// Die Rechtelage entscheidet, wie viel Text bleibt: eigenes Vereinsmaterial sichern
// wir vollständig (sonst ist es weg — die alten Vereinsseiten sind offline), fremde
// Presseartikel nur als Nachweis mit Überschrift und Anriss.
if (!$rights['klaeren']) {
$volltext = chronik_body_text($res['body']);
}
}
$titel = (string) ($override['titel'] ?? $opts['titel'] ?? $meta['titel'] ?? 'Ohne Titel');
$datum = $override['datum'] ?? $opts['datum'] ?? $meta['datum'] ?? null;
$urheber = (string) ($opts['urheber'] ?? $meta['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Zeitungsartikel');
$notiz = (string) ($opts['notiz'] ?? '');
if ($quellUrl !== $url) {
$notiz = trim($notiz . ' Archivfassung (Internet Archive), Original nicht mehr online: ' . $quellUrl);
}
$tag = chronik_source_tag($host);
$bucket = chronik_bucket(is_string($datum) ? $datum : null);
$dnorm = chronik_date_norm(is_string($datum) ? $datum : null);
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
// Namenskollision auflösen. Passiert wirklich: zwei Seiten „Bundesliga" und
// „Bundesliga-1" mit gleicher Überschrift und gleichem Jahr ergeben denselben Namen —
// ohne Suffix überschreibt der zweite Fund den ersten stillschweigend.
// Anker ist die .quelle.txt, die zu jedem Fundstück entsteht.
if (is_file($dir . '/' . $base . '.quelle.txt')) {
$n = 2;
while (is_file($dir . '/' . $base . '-' . $n . '.quelle.txt')) {
$n++;
}
$base .= '-' . $n;
}
// --- Aufmacherbild ---
$bildDatei = '';
if (!isset($opts['ohne-bild']) && !isset($opts['nur-register']) && is_string($meta['bild'])) {
$img = chronik_fetch($meta['bild'], $url);
$ext = match (true) {
str_contains($img['type'], 'png') => 'png',
str_contains($img['type'], 'webp') => 'webp',
str_contains($img['type'], 'gif') => 'gif',
default => 'jpg',
};
// 6 KB Untergrenze: Portale liefern bei fehlendem Aufmacher ein Platzhalter-Pixel aus.
if ($img['ok'] && strlen($img['body']) > 6144) {
$bildDatei = $base . '.' . $ext;
file_put_contents($dir . '/' . $bildDatei, $img['body']);
}
}
// --- Textbeleg: das Fundstück bleibt auffindbar, auch wenn die Seite verschwindet ---
$belegDatei = $base . '.artikel.txt';
$beleg = implode("\n", array_filter([
$titel,
str_repeat('=', min(72, max(8, strlen($titel)))),
'',
$dnorm !== '0000-00-00' ? 'Erschienen: ' . $dnorm : null,
'Quelle: ' . $host,
'URL: ' . $url,
'Abgerufen: ' . $heute,
'',
// Bei Volltext ist der Teaser nur dessen erster Absatz — nicht doppelt hinschreiben.
($meta['teaser'] !== null && $volltext === '') ? wordwrap((string) $meta['teaser'], 88, "\n") : null,
$volltext === '' ? '' : null,
'--',
$volltext !== ''
? "Eigenes Vereinsmaterial — vollständig gesichert, weil die Quellseite offline ist."
: 'Nur Überschrift, Datum und Anrisstext festgehalten — der vollständige Artikeltext',
$volltext !== ''
? ''
: 'gehört dem Verlag und wird hier nicht gespeichert. Für die Chronik über die URL',
$volltext !== '' ? '' : 'oder das Zeitungsarchiv im Original nachlesen.',
$volltext !== '' ? "\n" . wordwrap($volltext, 88, "\n") : null,
], static fn($l): bool => $l !== null));
file_put_contents($dir . '/' . $belegDatei, $beleg . "\n");
// --- Register + Beleg-Sidecar ---
$row = [
'datei' => $bucket . '/' . ($bildDatei !== '' ? $bildDatei : $belegDatei),
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $host,
'url' => $url,
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
$flag = $rights['klaeren'] ? ' [Rechte klären]' : '';
echo "{$bucket}/{$base}" . ($bildDatei !== '' ? ' (+Bild)' : ' (nur Text)') . $flag . "\n";
log_write('chronik', 'INFO', "Fundstück aufgenommen: {$bucket}/{$base} ({$host})");
return true;
}
/**
* Eine Datei aufnehmen, die schon da ist — Scan aus einer Festschrift, Foto aus einem
* privaten Album, per Hand gespeichertes Pressebild.
*
* Genau derselbe Weg wie bei einer Web-Adresse: umbenennen nach Konvention, ins richtige
* Jahrzehnt einsortieren, Beleg daneben, Zeile ins Register. Ohne das landen die Scans aus
* dem Sammelaufruf als IMG_4711.jpg im Ordner und sind in einem Jahr nicht mehr zuzuordnen.
*/
function chronik_add_file(string $path, array $opts): bool
{
if (!is_file($path)) {
fwrite(STDERR, "✗ Datei nicht gefunden: {$path}\n");
return false;
}
$root = chronik_root();
$heute = date('Y-m-d');
$datum = is_string($opts['datum'] ?? null) ? $opts['datum'] : null;
$titel = (string) ($opts['titel'] ?? pathinfo($path, PATHINFO_FILENAME));
$quelle = (string) ($opts['quelle'] ?? 'Vereinsbestand');
$urheber = (string) ($opts['urheber'] ?? '');
$art = (string) ($opts['art'] ?? 'Foto');
$notiz = (string) ($opts['notiz'] ?? '');
// Ohne --quelle gilt: es kommt aus dem Verein und ist nutzbar. Mit --quelle=<Host>
// greift dieselbe Einschätzung wie bei einem Abruf aus dem Netz.
$rights = str_contains($quelle, '.')
? chronik_rights($quelle)
: ['status' => 'Vereinsbestand — Herkunft ' . $quelle, 'klaeren' => false,
'hinweis' => 'Aus dem Bestand des Vereins bzw. von Mitgliedern. Bei erkennbaren Personen '
. 'die Einwilligung zur Veröffentlichung einholen, bei Kindern die Eltern fragen.'];
if (isset($opts['rechte-klaeren'])) {
$rights['klaeren'] = true;
}
$tag = str_contains($quelle, '.') ? chronik_source_tag($quelle) : strtoupper(chronik_slug($quelle, 12));
$bucket = chronik_bucket($datum);
$dnorm = chronik_date_norm($datum);
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)) ?: 'bin';
$base = $dnorm . '_' . chronik_slug($titel) . '_' . $tag;
$dir = $root . '/' . $bucket;
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "✗ Zielordner nicht anlegbar: {$bucket}\n");
return false;
}
$ziel = $dir . '/' . $base . '.' . $ext;
if (realpath($path) !== realpath($ziel) && !rename($path, $ziel)) {
fwrite(STDERR, "✗ Verschieben fehlgeschlagen: {$path}\n");
return false;
}
$row = [
'datei' => $bucket . '/' . $base . '.' . $ext,
'datum' => $dnorm,
'titel' => $titel,
'art' => $art,
'quelle' => $quelle,
'url' => (string) ($opts['url'] ?? ''),
'urheber' => $urheber,
'rechte' => $rights['status'],
'klaeren' => $rights['klaeren'] ? 'ja' : 'nein',
'abgerufen' => $heute,
'notiz' => $notiz,
];
chronik_register_append($row);
file_put_contents($dir . '/' . $base . '.quelle.txt', chronik_sidecar_text($row, $rights['hinweis']));
echo "{$bucket}/{$base}.{$ext}" . ($rights['klaeren'] ? ' [Rechte klären]' : '') . "\n";
return true;
}
/** Registerübersicht — was haben wir, wo klafft die Lücke. */
function chronik_report(): void
{
$path = chronik_register_path();
if (!is_file($path)) {
echo "Register ist noch leer.\n";
return;
}
$fh = fopen($path, 'r');
fgetcsv($fh, 0, ';', '"', '\\');
$perBucket = array_fill_keys(array_merge(array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]), 0);
$perQuelle = [];
$offen = 0;
$gesamt = 0;
while (($r = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
$gesamt++;
$bucket = explode('/', (string) ($r[0] ?? ''))[0];
if (isset($perBucket[$bucket])) {
$perBucket[$bucket]++;
}
$perQuelle[$r[4] ?? '?'] = ($perQuelle[$r[4] ?? '?'] ?? 0) + 1;
if (($r[8] ?? '') === 'ja') {
$offen++;
}
}
fclose($fh);
echo "Chronik-Register — {$gesamt} Fundstücke, {$offen} mit offenen Rechten\n\n";
echo "Nach Zeitraum:\n";
foreach ($perBucket as $b => $n) {
printf(" %-28s %3d %s\n", $b, $n, $n === 0 ? '← Lücke' : str_repeat('▌', min(40, $n)));
}
echo "\nNach Quelle:\n";
arsort($perQuelle);
foreach ($perQuelle as $q => $n) {
printf(" %-28s %3d\n", $q, $n);
}
}
// --- Hauptlauf -------------------------------------------------------------
if (isset($opts['datei'])) {
$ok = chronik_add_file((string) $opts['datei'], $opts);
$offen = chronik_write_rights_todo();
echo "\n{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($ok ? 0 : 1);
}
$jobs = [];
if (isset($opts['liste'])) {
$listFile = (string) $opts['liste'];
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: {$listFile}\n");
exit(1);
}
foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$parts = array_map('trim', explode('|', $line));
$jobs[] = [
'url' => $parts[0],
'datum' => ($parts[1] ?? '') !== '' ? $parts[1] : null,
'titel' => ($parts[2] ?? '') !== '' ? $parts[2] : null,
];
}
} elseif ($positional !== []) {
$jobs[] = ['url' => $positional[0], 'datum' => null, 'titel' => null];
} else {
fwrite(STDERR, "Aufruf: php bin/chronik-add.php <URL> | --liste=urls.txt | --rechte | --bericht\n");
exit(1);
}
$ok = 0;
$fehler = 0;
foreach ($jobs as $i => $job) {
if ($i > 0) {
// Pause zwischen den Abrufen: das hier ist Recherche, kein Crawl.
usleep(1_500_000);
}
$override = array_filter(['datum' => $job['datum'], 'titel' => $job['titel']], static fn($v): bool => $v !== null);
chronik_add($job['url'], $opts, $override) ? $ok++ : $fehler++;
}
$offen = chronik_write_rights_todo();
echo "\n{$ok} aufgenommen" . ($fehler > 0 ? ", {$fehler} fehlgeschlagen" : '') . ".\n";
echo "{$offen} Fundstücke mit offenen Rechten — siehe Vereinshistorie/" . CHRONIK_RECHTE . "\n";
exit($fehler > 0 ? 1 : 0);

94
bin/chronik-init.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur für die Jubiläums-Chronik 2028 anlegen (unter Vereinshistorie/).
*
* Vereinshistorie/
* 00_LIESMICH.txt ← Benennung + Ablageregeln, direkt im Ordner lesbar
* 00_RECHTE-ZU-KLAEREN.txt ← von chronik-add.php gepflegt, siehe unten
* 1908-1929/ … 2010-2028/ ← Jahrzehnt-Bündel, chronologisch
* Festschriften-und-Dokumente/ ← Jubiläumsschriften, Satzungen, Urkunden, Protokolle
* Undatiert/ ← Fundstücke ohne Jahr; wandern nach der Zuordnung um
*
* Warum Zeit als oberste Ebene und nicht Abteilung: eine Chronik wird chronologisch
* gelesen und geschrieben. Fußball/Turnen steht im Dateinamen, danach kann man filtern —
* ein Jahrzehnt dagegen ist die Einheit, in der Kapitel entstehen.
*
* Warum Jahrzehnte und nicht Einzeljahre: für 1908-1970 gibt es pro Jahr oft null bis drei
* Fundstücke. 60 fast leere Ordner verstecken den Bestand, statt ihn zu zeigen. Die frühen
* Bündel sind bewusst 20 Jahre breit, die jüngeren enger — dort wird das Material dichter.
*
* Die Ablage ist bewusst dieselbe wie in dateien-init.php: flach, ohne Vorgaben in der
* Tiefe. Wer ein Ereignis bündeln will, legt darin „1972-07-15 Einweihung Sportheim" an.
*
* Idempotent — vorhandene Ordner und Dateien bleiben unberührt.
*
* NUR CLI, und die Ordner müssen auf dem SERVER entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC).
*
* Aufruf: php bin/chronik-init.php
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
require __DIR__ . '/chronik-lib.php';
$root = chronik_root();
$parent = dirname($root);
if (!is_dir($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($parent)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$created = 0;
$failed = 0;
$dirs = array_merge([''], array_values(CHRONIK_BUCKETS), [CHRONIK_DOKUMENTE, CHRONIK_UNDATIERT]);
foreach (array_unique($dirs) as $rel) {
$dir = $rel === '' ? $root : $root . '/' . $rel;
$label = 'Vereinshistorie' . ($rel === '' ? '' : '/' . $rel);
if (is_dir($dir)) {
echo "· {$label} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$label} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$label}\n";
$created++;
}
// LIESMICH: die Ablageregeln gehören in den Ordner, nicht nur ins Repo — gelesen wird
// dort, wo hochgeladen wird. Wird nicht überschrieben, falls jemand sie ergänzt hat.
$readme = $root . '/00_LIESMICH.txt';
if (!is_file($readme)) {
file_put_contents($readme, chronik_readme_text());
echo "✓ Vereinshistorie/00_LIESMICH.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_LIESMICH.txt (gibt es schon)\n";
}
// Entwurf der Verlagsanfrage: gehört neben die Fundstücke, auf die er sich bezieht.
$anfrage = $root . '/00_RECHTE-Anfrage-Verlag-Entwurf.txt';
if (!is_file($anfrage)) {
file_put_contents($anfrage, chronik_verlagsanfrage_text());
echo "✓ Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt\n";
$created++;
} else {
echo "· Vereinshistorie/00_RECHTE-Anfrage-Verlag-Entwurf.txt (gibt es schon)\n";
}
echo "\n{$created} Einträge angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

564
bin/chronik-lib.php Normal file
View File

@@ -0,0 +1,564 @@
<?php
declare(strict_types=1);
/**
* Gemeinsame Logik der Chronik-Skripte (chronik-init.php, chronik-add.php).
*
* Bewusst hier unter bin/ und NICHT in app/helpers.php: das ist Recherche-Werkzeug für
* die Jubiläums-Chronik 2028, kein Website-Code. Nichts davon darf je im Request-Pfad
* landen — die Seite kennt die Chronik nicht.
*
* Zwei Dinge trägt diese Datei, und beide sind der eigentliche Punkt der Übung:
*
* 1. Der DATEINAME ist der Metadatenträger. Die Files Gallery unter /dateien kennt keine
* Felder für Datum, Quelle oder Rechte — sie listet Dateien. Also muss alles, was ein
* Fundstück später brauchbar macht, in den Namen: `1972-07-15_Einweihung-Sportheim_BR.jpg`.
* Datum vorn, damit die alphabetische Sortierung der Gallery zufällig die chronologische ist.
*
* 2. RECHTE werden beim Einsammeln festgehalten, nicht 2028 rekonstruiert. Zu jedem Fundstück
* entsteht eine `.quelle.txt` daneben und eine Zeile im Register. Was geklärt werden muss,
* landet zusätzlich in 00_RECHTE-ZU-KLAEREN.txt — im Ordner, wo es jemand sieht.
*/
/** Jahrzehnt-Bündel: [von, bis] => Ordnername. Früh breit, später enger — dort wird das Material dichter. */
const CHRONIK_BUCKETS = [
'1908-1929' => '1908-1929',
'1930-1949' => '1930-1949',
'1950-1969' => '1950-1969',
'1970-1989' => '1970-1989',
'1990-2009' => '1990-2009',
'2010-2028' => '2010-2028',
];
const CHRONIK_DOKUMENTE = 'Festschriften-und-Dokumente';
const CHRONIK_UNDATIERT = 'Undatiert';
const CHRONIK_README = '00_LIESMICH.txt';
const CHRONIK_RECHTE = '00_RECHTE-ZU-KLAEREN.txt';
/** Register liegt im REPO, nicht im Uploads-Ordner: uploads/ ist gitignored, das Register braucht Versionsgeschichte. */
function chronik_register_path(): string
{
return ROOT_PATH . '/docs/chronik/register.csv';
}
function chronik_root(): string
{
return STORAGE_PATH . '/dateien/uploads/Vereinshistorie';
}
/**
* Wayback-Adressen auf die ursprüngliche Adresse zurückführen.
*
* Das Internet Archive ist nur der Aufbewahrungsort — Urheber und Rechte richten sich nach
* der Seite, die dort eingefroren wurde. Ein Schnappschuss der eigenen alten Vereinsseite
* gehört dem Verein, nicht dem Archiv.
*/
function chronik_origin_url(string $url): string
{
if (preg_match('#^https?://web\.archive\.org/web/[^/]+/(https?://.+)$#i', $url, $m)) {
return $m[1];
}
return $url;
}
/**
* Rechtelage aus dem Host ableiten.
*
* Die Einschätzung ist eine ARBEITSHILFE, keine Rechtsauskunft — sie sagt nur, wo geklärt
* werden muss, bevor etwas gedruckt oder auf die Website gestellt wird. Sammeln und Sichten
* ist das eine, Veröffentlichen das andere; genau diese Grenze markiert das Feld.
*
* @return array{status:string, klaeren:bool, hinweis:string}
*/
function chronik_rights(string $host): array
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$map = [
'infranken.de' => [
'Presse © Mediengruppe Oberfranken (inFranken/Bayerische Rundschau)',
true,
'Text UND Foto sind geschützt. Für Abdruck in der Chronik bzw. Nutzung auf der Website '
. 'Nachdruckrechte beim Verlag anfragen (Mediengruppe Oberfranken, Bamberg). Für '
. 'Vereinsjubiläen wird das üblicherweise gegen Quellenangabe eingeräumt — aber schriftlich, und früh.',
],
'bayerische-rundschau.de' => [
'Presse © Mediengruppe Oberfranken (Bayerische Rundschau)',
true,
'Wie inFranken — gleicher Verlag. Nachdruckrechte schriftlich einholen.',
],
'frankenpost.de' => [
'Presse © Mediengruppe Oberfranken (Frankenpost)',
true,
'Gleiches Haus wie Bayerische Rundschau/inFranken — eine gemeinsame Anfrage an die '
. 'Mediengruppe Oberfranken deckt alle drei Titel ab.',
],
'fraenkischertag.de' => [
'Presse © Mediengruppe Oberfranken (Fränkischer Tag)',
true,
'Vierter Titel des Hauses, dieselbe Anfrage deckt ihn mit ab. Achtung beim Lokalsport '
. 'Kulmbach: dort steht oft „Foto: privat" — das Bild kommt dann aus dem Verein oder von '
. 'einer Privatperson, die Bildrechte liegen bei ihr und nicht beim Verlag. Wer das ist, '
. 'weiß die Redaktion oder die Mannschaft; danach ist es ein Anruf statt einer Anfrage.',
],
'kicker.de' => [
'Presse © kicker / Olympia-Verlag',
true,
'Reine Stammdaten (Liga, Spielort) sind Fakten und frei; Texte und Fotos nicht.',
],
'europlan-online.de' => [
'© Europlan-Online (Groundhopping-Portal)',
true,
'Stadiondaten als Fakten frei verwendbar; die Stadionfotos gehören den einzelnen '
. 'Fotografen und sind einzeln anzufragen.',
],
'turneninkulmbach.info' => [
'Turnabteilung TSV 08 Kulmbach — eigenes Material',
false,
'Seite der eigenen Turnabteilung. Intern klären, wer sie betreut, dann sind die '
. 'Inhalte für die Chronik verfügbar.',
],
'anpfiff.info' => [
'© anpfiff.info',
true,
'Amateurfußball-Portal. Ergebnisse frei, Berichte und Fotos nicht.',
],
'bfv.de' => [
'© Bayerischer Fußball-Verband',
true,
'Reine Ergebnis- und Tabellendaten (Fakten) sind frei verwendbar; Texte, Fotos und Wappen nicht. '
. 'Wappen fremder Vereine nur mit deren Zustimmung abbilden.',
],
'fussball.de' => [
'© DFB / Bayerischer Fußball-Verband',
true,
'Ergebnisse und Tabellenstände als Fakten frei; Seitenaufbau, Texte und Fotos nicht.',
],
'deutsche-turnliga.de' => [
'© Deutsche Turnliga',
true,
'Ergebnisdaten frei, Texte/Fotos nicht.',
],
'tsv08kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach)',
false,
'Aus dem Bestand des Vereins — nutzbar. Bei Personenfotos trotzdem das Recht am eigenen Bild beachten.',
],
// Frühere Vereinsdomain (Jimdo, ~20162023). DNS läuft nicht mehr, erreichbar nur
// noch über das Internet Archive — inhaltlich aber unser eigenes Material.
'tsv1908kulmbach.de' => [
'Eigenes Material (TSV 08 Kulmbach, frühere Vereinsseite)',
false,
'Alte Vereinsseite, nur noch im Internet Archive. Inhalte stammen vom Verein selbst. '
. 'Bei Personenfotos das Recht am eigenen Bild beachten.',
],
'commons.wikimedia.org' => [
'Wikimedia Commons — freie Lizenz, Bedingung prüfen',
true,
'Frei nutzbar, aber fast immer MIT Namensnennung und Lizenzangabe. Die konkrete Lizenz '
. '(CC BY-SA, CC0, PD) auf der Dateiseite ablesen und im Register eintragen.',
],
'de.wikipedia.org' => [
'Wikipedia — CC BY-SA',
true,
'Text nur mit Namensnennung und unter gleicher Lizenz weiterverwendbar. Für eine Chronik '
. 'besser als Faktenquelle nutzen und selbst formulieren.',
],
'facebook.com' => [
'Social Media — Urheber unklar',
true,
'Wer das Foto gemacht hat, steht dort meist nicht. Vor einer Veröffentlichung Urheber '
. 'ermitteln (oft der Verein selbst oder ein Mitglied) und Einwilligung abgeborgener Personen prüfen.',
],
'instagram.com' => [
'Social Media — Urheber unklar',
true,
'Wie Facebook. Eigene Vereinsposts sind unkritisch, fremde nicht.',
],
'digipress.digitale-sammlungen.de' => [
'Bayerische Staatsbibliothek — meist gemeinfrei',
false,
'Zeitungen bis ~1920er sind in der Regel gemeinfrei. Quellenangabe (Blatt, Datum, Seite, BSB) trotzdem mitführen.',
],
];
foreach ($map as $needle => [$status, $klaeren, $hinweis]) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return ['status' => $status, 'klaeren' => $klaeren, 'hinweis' => $hinweis];
}
}
return [
'status' => 'Ungeklärt — Urheber unbekannt',
'klaeren' => true,
'hinweis' => 'Herkunft im Register nachtragen und vor jeder Veröffentlichung klären.',
];
}
/**
* Datum (YYYY, YYYY-MM oder YYYY-MM-DD) → Zielordner.
* Unbekanntes oder unplausibles Datum landet in Undatiert/ statt still im falschen Jahrzehnt.
*/
function chronik_bucket(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})/', $date, $m)) {
return CHRONIK_UNDATIERT;
}
$year = (int) $m[1];
foreach (CHRONIK_BUCKETS as $range => $dir) {
[$from, $to] = array_map('intval', explode('-', $range));
if ($year >= $from && $year <= $to) {
return $dir;
}
}
return CHRONIK_UNDATIERT;
}
/** Datum auf YYYY-MM-DD normalisieren; unbekannte Teile werden 00 (sortiert vor dem 1. des Monats). */
function chronik_date_norm(?string $date): string
{
if ($date === null || !preg_match('/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/', $date, $m)) {
return '0000-00-00';
}
return sprintf('%s-%s-%s', $m[1], $m[2] ?? '00', $m[3] ?? '00');
}
/** Titel → dateinamensicherer Slug. Umlaute ausgeschrieben, damit NFD/NFC nie zum Thema wird. */
function chronik_slug(string $text, int $maxLen = 60): string
{
$text = strtr($text, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a', 'ç' => 'c',
]);
$text = (string) preg_replace('/[^A-Za-z0-9]+/', '-', $text);
$text = trim((string) preg_replace('/-+/', '-', $text), '-');
if (strlen($text) > $maxLen) {
$text = substr($text, 0, $maxLen);
$text = (string) preg_replace('/-[^-]*$/', '', $text) ?: $text;
}
return $text !== '' ? $text : 'ohne-titel';
}
/** Kurzkürzel der Quelle für den Dateinamen (BR, BFV, …). */
function chronik_source_tag(string $host): string
{
$host = strtolower(preg_replace('/^www\./', '', $host));
$tags = [
'infranken.de' => 'BR',
'bayerische-rundschau.de' => 'BR',
'frankenpost.de' => 'FP',
'fraenkischertag.de' => 'FT',
'kicker.de' => 'KICKER',
'europlan-online.de' => 'EUROPLAN',
'anpfiff.info' => 'ANPFIFF',
'turneninkulmbach.info' => 'TURNEN08',
'tsv1908kulmbach.de' => 'TSV08',
'bfv.de' => 'BFV',
'fussball.de' => 'FUSSBALLDE',
'deutsche-turnliga.de' => 'DTL',
'tsv08kulmbach.de' => 'TSV08',
'facebook.com' => 'FB',
'instagram.com' => 'IG',
'de.wikipedia.org' => 'WIKIPEDIA',
'commons.wikimedia.org' => 'COMMONS',
'digipress.digitale-sammlungen.de' => 'BSB',
'web.archive.org' => 'WAYBACK',
];
foreach ($tags as $needle => $tag) {
if ($host === $needle || str_ends_with($host, '.' . $needle)) {
return $tag;
}
}
return strtoupper(chronik_slug(explode('.', $host)[0] ?? 'quelle', 12));
}
/** Registerzeile anhängen (CSV, Semikolon — öffnet sich in Excel/LibreOffice ohne Import-Dialog). */
function chronik_register_append(array $row): void
{
$path = chronik_register_path();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$cols = ['datei', 'datum', 'titel', 'art', 'quelle', 'url', 'urheber', 'rechte', 'klaeren', 'abgerufen', 'notiz'];
$new = !is_file($path);
$fh = fopen($path, 'a');
if (!$fh) {
return;
}
flock($fh, LOCK_EX);
if ($new) {
fputcsv($fh, $cols, ';', '"', '\\');
}
fputcsv($fh, array_map(static fn(string $c): string => (string) ($row[$c] ?? ''), $cols), ';', '"', '\\');
flock($fh, LOCK_UN);
fclose($fh);
}
/** Schon im Register? Verhindert Dubletten bei wiederholten Läufen. */
function chronik_register_has_url(string $url): bool
{
$path = chronik_register_path();
if (!is_file($path)) {
return false;
}
$fh = fopen($path, 'r');
if (!$fh) {
return false;
}
$found = false;
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[5] ?? '') === $url) {
$found = true;
break;
}
}
fclose($fh);
return $found;
}
/** Beleg-Sidecar neben dem Fundstück: alles, was der Dateiname nicht fasst. */
function chronik_sidecar_text(array $row, string $hinweis): string
{
$lines = [
'FUNDSTÜCK FÜR DIE VEREINSCHRONIK — TSV 08 Kulmbach',
str_repeat('=', 52),
'',
'Titel: ' . ($row['titel'] ?? ''),
'Datum: ' . ($row['datum'] ?? '') . ' (00 = unbekannt)',
'Art: ' . ($row['art'] ?? ''),
'Quelle: ' . ($row['quelle'] ?? ''),
'URL: ' . ($row['url'] ?? ''),
'Urheber: ' . (($row['urheber'] ?? '') !== '' ? $row['urheber'] : 'nicht angegeben'),
'Abgerufen am: ' . ($row['abgerufen'] ?? ''),
'',
'RECHTE: ' . ($row['rechte'] ?? ''),
' ' . (($row['klaeren'] ?? '') === 'ja' ? '>>> VOR VERÖFFENTLICHUNG KLÄREN <<<' : 'nutzbar'),
'',
wordwrap($hinweis, 88, "\n"),
];
if (($row['notiz'] ?? '') !== '') {
$lines[] = '';
$lines[] = 'Notiz:';
$lines[] = wordwrap((string) $row['notiz'], 88, "\n");
}
return implode("\n", $lines) . "\n";
}
/**
* 00_RECHTE-ZU-KLAEREN.txt aus dem Register neu schreiben.
* Bewusst generiert statt fortgeschrieben: die Datei soll immer den aktuellen Stand zeigen,
* auch wenn im Register etwas auf „geklärt" gesetzt wurde.
*/
function chronik_write_rights_todo(): int
{
$path = chronik_register_path();
if (!is_file($path)) {
return 0;
}
$byRights = [];
$count = 0;
$fh = fopen($path, 'r');
if (!$fh) {
return 0;
}
fgetcsv($fh, 0, ';', '"', '\\'); // Kopfzeile
while (($row = fgetcsv($fh, 0, ';', '"', '\\')) !== false) {
if (($row[8] ?? '') !== 'ja') {
continue;
}
$byRights[$row[7] ?? 'Ungeklärt'][] = [
'datei' => $row[0] ?? '',
'titel' => $row[2] ?? '',
'datum' => $row[1] ?? '',
'url' => $row[5] ?? '',
// Der Verlag verlangt den Bildnachweis namentlich — wo er bekannt ist,
// gehört er in die Anfrage-Anlage, nicht nur ins Register.
'urheber' => $row[6] ?? '',
];
$count++;
}
fclose($fh);
$out = [
'RECHTE, DIE VOR DER VERÖFFENTLICHUNG ZU KLÄREN SIND',
str_repeat('=', 52),
'',
'Diese Datei wird von bin/chronik-add.php erzeugt — nicht von Hand pflegen,',
'sondern die Spalte „klaeren" in docs/chronik/register.csv auf „nein" setzen,',
'sobald eine Freigabe vorliegt, und das Skript erneut laufen lassen.',
'',
'Sammeln und intern sichten ist unproblematisch. Erst der Abdruck in der Chronik',
'oder die Veröffentlichung auf der Website braucht eine Erlaubnis.',
'',
"Offen: {$count} Fundstücke",
'',
];
foreach ($byRights as $rechte => $items) {
$out[] = str_repeat('-', 52);
$out[] = $rechte . ' (' . count($items) . ')';
$out[] = str_repeat('-', 52);
foreach ($items as $i) {
$out[] = ' · ' . $i['datum'] . ' ' . $i['titel'];
$out[] = ' ' . $i['datei'];
if ($i['urheber'] !== '') {
$out[] = ' ' . $i['urheber'];
}
if ($i['url'] !== '') {
$out[] = ' ' . $i['url'];
}
}
$out[] = '';
}
$out[] = 'Stand: ' . date('d.m.Y H:i');
file_put_contents(chronik_root() . '/' . CHRONIK_RECHTE, implode("\n", $out) . "\n");
return $count;
}
/**
* Entwurf der Nachdruck-Anfrage an den Verlag, als Datei im Ordner.
*
* Steht hier und nicht im Repo, weil der Vorstand sie braucht, nicht die Entwicklung — und
* weil sie neben den Fundstücken liegen soll, auf die sie sich bezieht. Der wichtigste Satz
* darin ist die Bitte um EINE Freigabe für alle drei Titel: Bayerische Rundschau, inFranken
* und Frankenpost gehören zur selben Mediengruppe, drei Einzelanfragen wären unnötig.
*/
function chronik_verlagsanfrage_text(): string
{
return <<<TXT
ENTWURF — ANFRAGE NACHDRUCKRECHTE
=================================
Für die Vereinschronik zum 120-jährigen Bestehen (2028) wollen wir Zeitungsberichte und
Pressefotos verwenden. Die gehören dem Verlag, nicht uns — auch die, in denen wir vorkommen.
Bitte früh abschicken. Eine Absage im Jahr 2027 wirft das Chronik-Konzept um, eine Absage
2026 kostet nichts.
Empfänger: Mediengruppe Oberfranken Zeitungsverlage GmbH & Co. KG
(deckt Bayerische Rundschau, inFranken.de UND Frankenpost gemeinsam ab)
Vor dem Versand prüfen: aktuelle Anschrift und zuständige Ansprechperson auf der
Verlagsseite nachsehen, die Liste im Anhang aus docs/chronik/register.csv erzeugen.
---------------------------------------------------------------------------
Betreff: Nachdruckrechte für die Vereinschronik zum 120-jährigen Jubiläum des
TSV 08 Kulmbach (2028)
Sehr geehrte Damen und Herren,
der TSV 1908 Kulmbach e. V. begeht im Jahr 2028 sein 120-jähriges Bestehen. Aus diesem
Anlass erarbeiten wir eine Vereinschronik.
Über die Jahrzehnte hat die Bayerische Rundschau das Vereinsleben in Kulmbach begleitet.
Ihre Berichte sind für unsere Vereinsgeschichte häufig die einzige erhaltene Quelle.
Wir möchten Sie deshalb um die Erlaubnis bitten, einzelne Artikel und Pressefotos in der
Chronik abzudrucken — jeweils mit vollständiger Quellenangabe (Titel, Datum, Autorin
oder Autor, Bildnachweis).
Konkret geht es um:
· Umfang: voraussichtlich [ANZAHL] Artikel und [ANZAHL] Fotos
(Aufstellung im Anhang)
· Verwendung: gedruckte Festschrift, Auflage ca. [ANZAHL] Exemplare,
Abgabe an Mitglieder und Gäste, nicht gewerblich
· zusätzlich: Darstellung einzelner Ausschnitte im Historie-Bereich unserer
Vereinswebsite tsv08kulmbach.de
· Zeitraum: Erscheinen der Chronik im Jubiläumsjahr 2028
Sollten für die Nutzung Gebühren anfallen, bitten wir um eine Auskunft über die Konditionen.
Als gemeinnütziger Verein sind wir für ein Entgegenkommen dankbar.
Für Rückfragen stehen wir gerne zur Verfügung.
Mit freundlichen Grüßen
[NAME], [FUNKTION]
TSV 1908 Kulmbach e. V.
[ANSCHRIFT / TELEFON / E-MAIL]
Anlage: Aufstellung der gewünschten Artikel und Fotos
---------------------------------------------------------------------------
NOCH ZU KLÄREN, UNABHÄNGIG VOM VERLAG
-------------------------------------
· Private Fotos von Mitgliedern: Einverständnis der abgebildeten Personen einholen.
Bei Kindern die Eltern fragen. Am einfachsten gleich beim Einsammeln miterledigen.
· Wappen anderer Vereine: nur mit deren Zustimmung abbilden.
· Fotos ohne bekannten Urheber: Herkunft ermitteln, solange es noch jemand weiß.
In zwei Jahren weiß es niemand mehr.
TXT;
}
function chronik_readme_text(): string
{
return <<<TXT
VEREINSHISTORIE — MATERIALSAMMLUNG FÜR DAS JUBILÄUM 2028
========================================================
Hier sammeln wir alles, was in die Vereinschronik zum 120-jährigen Bestehen
einfließen kann: Fotos, Zeitungsausschnitte, Urkunden, Festschriften, Programmhefte,
Mannschaftsbilder, Wimpel, Protokolle.
Lieber zu viel als zu wenig. Aussortiert wird später.
WO KOMMT ES HIN?
----------------
In den Ordner des Jahrzehnts, aus dem es stammt:
1908-1929 1930-1949 1950-1969 1970-1989 1990-2009 2010-2028
Weißt du das Jahr nicht? -> Undatiert/ (wir ordnen es später zu)
Festschrift, Satzung, Urkunde, Protokoll? -> Festschriften-und-Dokumente/
WIE SOLL ES HEISSEN?
--------------------
Datum zuerst, dann worum es geht:
1972-07-15_Einweihung-Sportheim.jpg
1985-00-00_Mannschaftsfoto-Erste.jpg (Monat/Tag unbekannt -> 00)
1962-00-00_Aufstieg-Bericht_BR.jpg (Quelle hinten, hier: Bayerische Rundschau)
Das Datum vorn sorgt dafür, dass die Liste sich von selbst richtig sortiert.
Wenn du den Namen nicht hinbekommst: trotzdem hochladen. Ein falsch benanntes
Foto ist unendlich viel besser als ein Foto, das im Schuhkarton bleibt.
WAS UNS BEI JEDEM STÜCK HILFT
-----------------------------
Wenn du es weißt, schreib es dazu (in den Dateinamen oder als kurze Textdatei daneben):
· Wann ungefähr?
· Wer ist zu sehen?
· Wo aufgenommen?
· Wer hat fotografiert / wo stand der Artikel?
Der letzte Punkt ist der wichtigste und der, der am häufigsten fehlt. Ohne ihn
dürfen wir das Bild später womöglich nicht drucken.
RECHTE
------
Zeitungsartikel und Pressefotos gehören dem Verlag, nicht uns — auch wenn wir
darin vorkommen. Was noch freigegeben werden muss, steht in
00_RECHTE-ZU-KLAEREN.txt
Private Aufnahmen von Vereinsmitgliedern sind unkritisch, solange die abgebildeten
Personen mit einer Veröffentlichung einverstanden sind.
Fragen? Melde dich bei der Vorstandschaft.
TXT;
}

83
bin/dateien-init.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* Ordnerstruktur der Dateiablage /dateien anlegen: Jahr → Abteilung.
*
* 2026/
* Fußball/
* Jugend/
* Turnen/
* Feste und Aktionen/
*
* Warum diese Reihenfolge: das Jahr außen hält die oberste Ebene für immer kurz
* (ein Eintrag pro Jahr) und macht die Grenze zum Archiv sichtbar. Die Abteilungen
* innen sind bewusst dieselben wie die Bereiche der Website — dann ist beim Hochladen
* schon klar, auf welche Seite das Material später gehört. Wer ein Ereignis bündeln
* will, legt darin einen Ordner mit Datum voran an („2026-08-15 Sommerfest"); der
* sortiert sich dann von selbst richtig ein.
*
* Tiefer als diese zwei Ebenen wird nichts vorgegeben: die hochladenden Personen
* stehen meist mit dem Handy am Spielfeldrand, und jede zusätzliche Ebene wird
* ignoriert oder falsch getroffen.
*
* Idempotent — vorhandene Ordner bleiben unberührt. Im Januar für das neue Jahr
* einmal neu aufrufen (oder das Vereins-Konto legt den Jahresordner selbst an).
*
* NUR CLI. Die Ordner müssen auf dem Server entstehen, nicht per FTP hochgeladen
* werden: macOS und Linux normalisieren Umlaute unterschiedlich (NFD/NFC), sonst
* steht „Fußball" am Ende zweimal da.
*
* Aufruf: php bin/dateien-init.php [Jahr] (ohne Argument: laufendes Jahr)
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const DATEIEN_ABTEILUNGEN = ['Fußball', 'Jugend', 'Turnen', 'Feste und Aktionen'];
// Europe/Berlin, weil PHP global auf UTC läuft — sonst kippt das Jahr am 31.12. zu früh.
$year = (string) ($argv[1] ?? (new DateTimeImmutable('now', new DateTimeZone('Europe/Berlin')))->format('Y'));
if (!preg_match('/^20\d{2}$/', $year)) {
fwrite(STDERR, "Ungültiges Jahr: {$year}\nAufruf: php bin/dateien-init.php [Jahr]\n");
exit(1);
}
$root = STORAGE_PATH . '/dateien/uploads';
if (!is_dir($root)) {
fwrite(STDERR, "storage/dateien/uploads/ fehlt — erst die Dateiablage einrichten (siehe CLAUDE.md).\n");
exit(1);
}
if (!is_writable($root)) {
fwrite(STDERR, "storage/dateien/uploads/ ist nicht beschreibbar.\n");
exit(1);
}
$paths = [$year];
foreach (DATEIEN_ABTEILUNGEN as $abteilung) {
$paths[] = $year . '/' . $abteilung;
}
$created = 0;
$failed = 0;
foreach ($paths as $rel) {
$dir = $root . '/' . $rel;
if (is_dir($dir)) {
echo "· {$rel} (gibt es schon)\n";
continue;
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
fwrite(STDERR, "{$rel} konnte nicht angelegt werden\n");
$failed++;
continue;
}
echo "{$rel}\n";
$created++;
}
echo "\n{$created} Ordner angelegt" . ($failed > 0 ? ", {$failed} fehlgeschlagen" : '') . ".\n";
exit($failed > 0 ? 1 : 0);

273
bin/filesgallery-update.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* Files Gallery (Dateiablage /dateien) aktualisieren — App-Datei und self-hosted Assets.
*
* Warum ein Skript: die App lädt ihre Assets sonst vom jsDelivr-CDN (index.php:965), was
* gegen Regel 1 verstößt. Self-Hosting ist offiziell vorgesehen, muss aber bei JEDEM
* Versionswechsel wiederholt werden, weil die Asset-Pfade die Version enthalten
* (files.photo.gallery@<version>/...). Dazu kommt der TSV-PATCH in index.php, den ein
* Update sonst wegwirft. Beides von Hand ist genau der Schritt, der einmal vergessen wird.
*
* NUR CLI/Build — nie im Request-Pfad. Die einzige ausgehende Verbindung ist jsDelivr,
* und die passiert hier, nicht im Browser des Besuchers.
*
* Aufruf:
* php bin/filesgallery-update.php --check nur prüfen (Patch da? Assets zur Version da?)
* php bin/filesgallery-update.php --assets Assets zur vendorten Version neu holen
* php bin/filesgallery-update.php neue index.php holen, patchen, Assets holen
*
* Nach einem echten Versionssprung: Login, Upload und Vorschau einmal durchklicken und
* die alten Asset-Verzeichnisse löschen (das Skript sagt, welche das sind).
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
const FG_APP = PUBLIC_PATH . '/dateien/index.php';
const FG_ASSETS_DIR = PUBLIC_PATH . '/assets/filesgallery';
const FG_APP_URL = 'https://cdn.jsdelivr.net/npm/files.photo.gallery/index.php';
const FG_LOG = STORAGE_PATH . '/logs/filesgallery.log';
// Der Patch, ohne den die App bei abweichendem Arbeitsverzeichnis ohne Login startet.
const FG_PATCH_NEEDLE = "public static \$localconfigpath = '_filesconfig.php';";
const FG_PATCH_MARKER = "public static \$localconfigpath = __DIR__ . '/_filesconfig.php';";
const FG_PATCH_REPLACEMENT = <<<'PHP'
// TSV-PATCH (einzige Änderung an dieser Datei): absolut statt relativ.
// Der Original-Pfad wird gegen das aktuelle Arbeitsverzeichnis aufgelöst. Weicht das ab
// (z. B. unter `php -S` mit Router-Script), findet die App _filesconfig.php nicht und
// startet mit ihrer Default-Config — also OHNE Login und mit falschen Pfaden.
// Nebeneffekt: der Selbst-Ausschluss in Path::is_exclude() vergleicht diesen Wert mit
// einem Basename und greift dadurch nicht mehr. Belanglos, weil unser `root`
// (storage/dateien/uploads) außerhalb dieses Verzeichnisses liegt.
// Nach jedem Update neu setzen: `php bin/filesgallery-update.php` erledigt und prüft das.
public static $localconfigpath = __DIR__ . '/_filesconfig.php';
PHP;
$mode = $argv[1] ?? '';
if (!in_array($mode, ['', '--check', '--assets'], true)) {
fwrite(STDERR, "Unbekannte Option: {$mode}\nAufruf: php bin/filesgallery-update.php [--check|--assets]\n");
exit(1);
}
$errors = 0;
function fg_log(string $line): void
{
@file_put_contents(FG_LOG, date('c') . ' ' . $line . "\n", FILE_APPEND);
}
function fg_fail(string $message): void
{
global $errors;
$errors++;
fwrite(STDERR, "{$message}\n");
fg_log('FEHLER ' . $message);
}
/** Datei von jsDelivr holen; gibt den Rohinhalt zurück oder null. */
function fg_fetch(string $url): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120, // die Assets sind ein paar MB
CURLOPT_USERAGENT => 'tsv08kulmbach-filesgallery-update/1.0',
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
if (!is_string($body) || $body === '' || $status !== 200) {
fg_fail("Download fehlgeschlagen ({$status}" . ($error !== '' ? ", {$error}" : '') . "): {$url}");
return null;
}
return $body;
}
/** Version aus dem Quelltext der App lesen. */
function fg_version(string $source): ?string
{
return preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : null;
}
/**
* Den TSV-PATCH anwenden. Idempotent: ist er schon drin, bleibt die Datei unverändert.
* Fehlt beides (Patch und Original-Zeile), bricht das Skript ab — dann hat die App ihre
* Struktur geändert und der Patch muss von Hand nachgezogen werden.
*/
function fg_patch(string $source): ?string
{
if (str_contains($source, FG_PATCH_MARKER)) {
return $source;
}
if (!str_contains($source, FG_PATCH_NEEDLE)) {
fg_fail('Patch-Stelle nicht gefunden ($localconfigpath) — index.php von Hand prüfen, NICHT ungepatcht ausliefern.');
return null;
}
// Die Original-Zeile trägt einen Kommentar hinter dem Semikolon; den behalten wir.
return (string) preg_replace(
'/^\h*' . preg_quote(FG_PATCH_NEEDLE, '/') . '/m',
FG_PATCH_REPLACEMENT,
$source,
1
);
}
/** assets.zip zur Version holen und nach public/assets/filesgallery/ entpacken. */
function fg_assets(string $version): void
{
$url = "https://cdn.jsdelivr.net/npm/files.photo.gallery@{$version}/_files/assets/assets.zip";
$zipData = fg_fetch($url);
if ($zipData === null) {
return;
}
$tmp = tempnam(sys_get_temp_dir(), 'fg-assets-');
if ($tmp === false || file_put_contents($tmp, $zipData) === false) {
fg_fail('Konnte assets.zip nicht zwischenspeichern.');
return;
}
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) {
fg_fail('assets.zip ist kein lesbares ZIP-Archiv.');
@unlink($tmp);
return;
}
if (!is_dir(FG_ASSETS_DIR) && !mkdir(FG_ASSETS_DIR, 0775, true) && !is_dir(FG_ASSETS_DIR)) {
fg_fail('Kann ' . FG_ASSETS_DIR . ' nicht anlegen.');
$zip->close();
@unlink($tmp);
return;
}
$written = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
// Manche Archive tragen den _files/assets-Pfad noch mit sich; wir wollen den Inhalt flach.
$rel = (string) preg_replace('#^(\./)?(_files/)?assets/#', '', $name);
$rel = ltrim(str_replace('\\', '/', $rel), '/');
// Zip-Slip: alles mit Parent-Traversal oder absolutem Pfad wird verworfen.
if ($rel === '' || str_contains($rel, '../')) {
continue;
}
$target = FG_ASSETS_DIR . '/' . $rel;
if (str_ends_with($name, '/')) {
if (!is_dir($target)) {
@mkdir($target, 0775, true);
}
continue;
}
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
fg_fail("Kann Verzeichnis nicht anlegen: {$dir}");
continue;
}
$stream = $zip->getStream($name);
if ($stream === false) {
fg_fail("Konnte Eintrag nicht lesen: {$name}");
continue;
}
if (@file_put_contents($target, $stream) === false) {
fg_fail("Konnte Datei nicht schreiben: {$target}");
} else {
$written++;
}
fclose($stream);
}
$zip->close();
@unlink($tmp);
echo "✓ Assets entpackt ({$written} Dateien) → " . str_replace(ROOT_PATH . '/', '', FG_ASSETS_DIR) . "\n";
fg_log("Assets {$version} entpackt ({$written} Dateien)");
}
/** Prüfen, ob Patch und Assets zur vendorten Version passen. */
function fg_check(string $version, string $source): void
{
if (str_contains($source, FG_PATCH_MARKER)) {
echo "✓ TSV-PATCH in public/dateien/index.php vorhanden\n";
} else {
fg_fail('TSV-PATCH fehlt in public/dateien/index.php — die App würde ohne Login starten.');
}
$css = FG_ASSETS_DIR . "/files.photo.gallery@{$version}/css/files.css";
if (is_file($css)) {
echo "✓ Assets für Version {$version} vorhanden\n";
} else {
fg_fail("Assets für Version {$version} fehlen (erwartet: " . str_replace(ROOT_PATH . '/', '', $css) . ") — php bin/filesgallery-update.php --assets");
}
// Verirrte Assets anderer Versionen melden, damit sie nicht mitgeschleppt werden.
foreach (glob(FG_ASSETS_DIR . '/files.photo.gallery@*') ?: [] as $dir) {
if (basename($dir) !== "files.photo.gallery@{$version}") {
echo "· Alte Assets, können weg: " . str_replace(ROOT_PATH . '/', '', $dir) . "\n";
}
}
}
// --- Ablauf ---------------------------------------------------------------------------
if (!is_file(FG_APP)) {
fwrite(STDERR, '✗ ' . FG_APP . " fehlt.\n");
exit(1);
}
$source = (string) file_get_contents(FG_APP);
if ($mode === '' ) {
// Vollupdate: neue App-Datei holen, patchen, dann Assets zur neuen Version.
$fresh = fg_fetch(FG_APP_URL);
if ($fresh !== null) {
$newVersion = fg_version($fresh);
if ($newVersion === null) {
fg_fail('Version im Download nicht gefunden — Datei nicht übernommen.');
} else {
$patched = fg_patch($fresh);
if ($patched !== null) {
$old = fg_version($source) ?? '?';
if ($patched === $source) {
echo "· Version {$newVersion} ist schon aktuell und gepatcht\n";
} elseif (file_put_contents(FG_APP, $patched) === false) {
fg_fail('Konnte ' . FG_APP . ' nicht schreiben.');
} else {
echo "✓ index.php aktualisiert: {$old}{$newVersion} (gepatcht)\n";
fg_log("index.php {$old}{$newVersion}");
$source = $patched;
}
}
}
}
}
$version = fg_version($source);
if ($version === null) {
fwrite(STDERR, "✗ Version in public/dateien/index.php nicht lesbar.\n");
exit(1);
}
if ($mode !== '--check') {
fg_assets($version);
}
fg_check($version, $source);
if ($errors > 0) {
fwrite(STDERR, "\n{$errors} Problem(e) — nicht ausliefern, bis sie behoben sind.\n");
exit(1);
}
echo "\nFiles Gallery {$version}: alles an seinem Platz.\n";
exit(0);

View File

@@ -16,6 +16,8 @@ if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_slice($argv, 1);
$crop = false;
if (($args[0] ?? '') === '--crop') {
@@ -31,21 +33,21 @@ if (count($args) < 3) {
[$src, $dest, $spec] = $args;
$quality = (int) ($args[3] ?? 78);
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
'webp' => imagecreatefromwebp($src),
default => null,
};
if (!$image) {
fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n");
exit(1);
}
$srcW = imagesx($image);
$srcH = imagesy($image);
if ($crop) {
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => imagecreatefromjpeg($src),
'png' => imagecreatefrompng($src),
'webp' => imagecreatefromwebp($src),
default => null,
};
if (!$image) {
fwrite(STDERR, "Kann Quelle nicht lesen: {$src}\n");
exit(1);
}
$srcW = imagesx($image);
$srcH = imagesy($image);
[$w, $h] = array_map('intval', explode('x', $spec));
$srcRatio = $srcW / $srcH;
$dstRatio = $w / $h;
@@ -65,12 +67,13 @@ if ($crop) {
exit(0);
}
foreach (explode(',', $spec) as $width) {
$w = min((int) $width, $srcW); // nie hochskalieren
$h = (int) round($srcH * $w / $srcW);
$out = imagecreatetruecolor($w, $h);
imagecopyresampled($out, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
$file = "{$dest}-{$w}.jpg";
imagejpeg($out, $file, $quality);
echo "{$file} ({$w}x{$h}, " . round(filesize($file) / 1024) . " KB)\n";
try {
$variants = resize_image_variants($src, $dest, array_map('intval', explode(',', $spec)), $quality);
} catch (RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
foreach ($variants as $variant) {
echo "{$variant['file']} ({$variant['width']}x{$variant['height']}, " . round($variant['bytes'] / 1024) . " KB)\n";
}

View File

@@ -16,35 +16,42 @@ declare(strict_types=1);
* 2. web_profile_info-API (JSON, edges[])
* 3. Profil-HTML nach eingebettetem JSON parsen
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$verbose = in_array('-v', $argv ?? [], true);
$username = (string) config('instagram.username', 'tsv08kulmbach');
$maxPosts = (int) config('instagram.max_posts', 9);
$imgDir = PUBLIC_PATH . '/assets/img/instagram';
$cacheFile = DATA_PATH . '/instagram.json';
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/instagram.log', $line, FILE_APPEND);
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/instagram.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('instagram', $level, $msg);
if ($verbose) {
echo $line;
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/instagram.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern ---
@@ -203,12 +210,12 @@ foreach ($posts as $post) {
if (!is_file($file)) {
$img = $fetch($post['display_url']);
if ($img === false) {
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen");
$log("Bild-Download fehlgeschlagen: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$src = imagecreatefromstring($img);
if ($src === false) {
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen");
$log("Bild unlesbar: {$post['shortcode']} — Post wird übersprungen", 'WARN');
continue;
}
$w = imagesx($src);

View File

@@ -3,14 +3,18 @@
declare(strict_types=1);
/**
* Logrotation für storage/logs/*.log — NUR CLI, per Cron 1×/Woche.
* Logpflege für storage/logs/ — NUR CLI, per Cron 1×/Woche.
*
* Wird eine Datei größer als das Limit, wandert sie nach <name>.log.1 (eine
* vorhandene .1 wird ersetzt) und das aktive Log startet leer. Damit bleiben
* höchstens zwei Generationen pro Log liegen — genug zum Nachsehen, ohne dass
* spam.log oder php-errors.log auf dem Shared Hosting unbegrenzt wachsen.
* Zwei Aufgaben:
* 1. Rotation nach Größe: wird eine Datei größer als das Limit, wandert sie nach
* <name>.log.1 (eine vorhandene .1 wird ersetzt) und das aktive Log startet leer.
* Es bleiben also höchstens zwei Generationen pro Log liegen.
* 2. Aufbewahrungsfrist: Einträge, die älter als --keep-days sind, werden aus den
* aktiven Logs entfernt; rotierte .1-Dateien jenseits der Frist gelöscht. Damit
* gibt es eine echte Löschfrist statt nur einer Größengrenze — auf einer ruhigen
* Vereinsseite läge ein Eintrag sonst jahrelang herum.
*
* Aufruf: php bin/log-rotate.php [--max-mb=2]
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
*/
if (PHP_SAPI !== 'cli') {
exit(1);
@@ -19,30 +23,108 @@ if (PHP_SAPI !== 'cli') {
require dirname(__DIR__) . '/app/bootstrap.php';
$maxMb = 2.0;
$keepDays = 90;
$verbose = in_array('-v', $argv, true);
foreach ($argv as $arg) {
if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) {
$maxMb = (float) $m[1];
} elseif (preg_match('/^--keep-days=(\d+)$/', $arg, $m) === 1) {
$keepDays = max(1, (int) $m[1]);
}
}
$limit = (int) ($maxMb * 1024 * 1024);
$cutoff = time() - $keepDays * 86400;
$logDir = STORAGE_PATH . '/logs';
/**
* Zeitstempel am Zeilenanfang: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel. Null = keiner.
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/**
* Einträge vor $cutoff entfernen. Zeilen ohne eigenen Zeitstempel (z. B. PHP-Stacktraces)
* gehören zum vorherigen Eintrag und teilen dessen Schicksal. Geschrieben wird über eine
* temporäre Datei + rename; ein Append genau in diesem Moment ginge verloren — bei einem
* wöchentlichen Lauf nachts ein akzeptables Risiko, dafür bleibt die Datei immer konsistent.
*/
$prune = static function (string $file) use ($cutoff, $stampOf): int {
$raw = (string) @file_get_contents($file);
if ($raw === '') {
return 0;
}
$keep = [];
$dropping = false;
$removed = 0;
foreach (explode("\n", $raw) as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$dropping = $ts < $cutoff;
}
if ($dropping) {
if (trim($line) !== '') {
$removed++;
}
continue;
}
$keep[] = $line;
}
if ($removed === 0) {
return 0;
}
$tmp = $file . '.tmp';
if (@file_put_contents($tmp, implode("\n", $keep)) === false || !@rename($tmp, $file)) {
@unlink($tmp);
return 0;
}
@chmod($file, 0664);
return $removed;
};
$rotated = 0;
foreach (glob(STORAGE_PATH . '/logs/*.log') ?: [] as $log) {
$pruned = 0;
$deleted = 0;
foreach (glob($logDir . '/*.log') ?: [] as $log) {
$name = basename($log);
// 1. Alte Einträge raus (vor der Größenprüfung — spart oft die Rotation).
$removed = $prune($log);
if ($removed > 0) {
$pruned += $removed;
if ($verbose) {
echo "gekürzt: {$name} ({$removed} Einträge älter als {$keepDays} Tage)\n";
}
}
// 2. Rotation nach Größe.
$size = (int) @filesize($log);
if ($size <= $limit) {
continue;
if ($size > $limit) {
if (@rename($log, $log . '.1')) {
@touch($log);
@chmod($log, 0664);
echo "rotiert: {$name} (" . round($size / 1048576, 1) . " MB)\n";
$rotated++;
} else {
fwrite(STDERR, "Rotation fehlgeschlagen: {$name}\n");
}
}
if (!@rename($log, $log . '.1')) {
fwrite(STDERR, 'Rotation fehlgeschlagen: ' . basename($log) . "\n");
continue;
}
// Neue, leere Datei mit denselben Rechten anlegen, damit PHP/Cron weiterschreiben können.
@touch($log);
@chmod($log, 0664);
echo 'rotiert: ' . basename($log) . ' (' . round($size / 1048576, 1) . " MB)\n";
$rotated++;
}
echo $rotated === 0
? "Nichts zu rotieren (alle Logs unter {$maxMb} MB).\n"
: "{$rotated} Log(s) rotiert.\n";
// 3. Rotierte Generationen jenseits der Frist löschen.
foreach (glob($logDir . '/*.log.1') ?: [] as $old) {
if ((int) @filemtime($old) < $cutoff && @unlink($old)) {
echo 'gelöscht: ' . basename($old) . " (älter als {$keepDays} Tage)\n";
$deleted++;
}
}
echo $rotated === 0 && $pruned === 0 && $deleted === 0
? "Nichts zu tun (alle Logs unter {$maxMb} MB, keine Einträge älter als {$keepDays} Tage).\n"
: "Fertig: {$rotated} rotiert, {$pruned} alte Einträge entfernt, {$deleted} Altdatei(en) gelöscht.\n";

166
bin/logs.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
/**
* Logübersicht — NUR CLI. Beantwortet in einem Aufruf: läuft alles, und wenn
* nicht, was ist zuletzt schiefgegangen? Ersetzt das Durchklicken von fünf
* Dateien per FTP.
*
* Aufruf:
* php bin/logs.php Übersicht + letzte Fehler
* php bin/logs.php --lines=20 mehr Zeilen je Kanal
* php bin/logs.php --errors nur die gesammelte error.log
* php bin/logs.php --channel=mail nur einen Kanal, dafür ausführlich
* php bin/logs.php --days=30 anderer Auswertungszeitraum (Default 7)
*
* Format der eigenen Logs (log_write() in helpers.php):
* [2026-07-27T08:11:45+00:00] ERROR Meldung
* php-errors.log kommt von PHP selbst und hat ein eigenes Format — es wird
* mitgezählt, aber nicht nach Level aufgeschlüsselt.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$lines = 5;
$days = 7;
$only = '';
$errorsOnly = false;
foreach ($argv as $arg) {
if (preg_match('/^--lines=(\d+)$/', $arg, $m) === 1) {
$lines = max(1, (int) $m[1]);
} elseif (preg_match('/^--days=(\d+)$/', $arg, $m) === 1) {
$days = max(1, (int) $m[1]);
} elseif (preg_match('/^--channel=([a-z0-9-]+)$/', $arg, $m) === 1) {
$only = $m[1];
$lines = max($lines, 20);
} elseif ($arg === '--errors') {
$errorsOnly = true;
$lines = max($lines, 20);
}
}
$logDir = STORAGE_PATH . '/logs';
$cutoff = time() - $days * 86400;
/**
* Zeitstempel am Zeilenanfang lesen: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel ohne Klammern. Null = kein Zeitstempel,
* die Zeile gehört dann zum vorherigen Eintrag (z. B. Stacktrace-Fortsetzung).
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/** @return string[] */
$readLines = static function (string $file): array {
if (!is_file($file) || filesize($file) === 0) {
return [];
}
return array_values(array_filter(explode("\n", (string) file_get_contents($file)), static fn ($l) => trim($l) !== ''));
};
$ago = static function (?int $ts): string {
if ($ts === null) {
return '—';
}
$d = time() - $ts;
if ($d < 3600) {
return 'vor ' . max(1, (int) round($d / 60)) . ' Min.';
}
if ($d < 86400) {
return 'vor ' . (int) round($d / 3600) . ' Std.';
}
return 'vor ' . (int) round($d / 86400) . ' Tagen';
};
$size = static function (string $file): string {
$b = is_file($file) ? (int) filesize($file) : 0;
return $b < 1024 ? "{$b} B" : ($b < 1048576 ? round($b / 1024) . ' KB' : round($b / 1048576, 1) . ' MB');
};
$tail = static function (array $all, int $n): array {
return array_slice($all, -$n);
};
echo "TSV 08 — Logübersicht (" . date('d.m.Y H:i') . ", Auswertung der letzten {$days} Tage)\n\n";
// --- Gesammelte Fehler zuerst: die eine Datei, die zählt ---
$errorFile = $logDir . '/error.log';
$errorLines = $readLines($errorFile);
$recentErrors = array_values(array_filter($errorLines, static fn ($l) => ($stampOf($l) ?? 0) >= $cutoff));
if ($recentErrors === []) {
echo "Keine Fehler in den letzten {$days} Tagen.\n";
} else {
echo count($recentErrors) . " Fehler in den letzten {$days} Tagen (storage/logs/error.log):\n";
foreach ($tail($recentErrors, $lines) as $line) {
echo ' ' . $line . "\n";
}
if (count($recentErrors) > $lines) {
echo ' … ' . (count($recentErrors) - $lines) . " weitere\n";
}
}
if ($errorsOnly) {
exit($recentErrors === [] ? 0 : 1);
}
// --- Kanäle ---
$files = glob($logDir . '/*.log') ?: [];
$channels = [];
foreach ($files as $file) {
$name = basename($file, '.log');
if ($name === 'error' || ($only !== '' && $name !== $only)) {
continue;
}
$channels[$name] = $file;
}
ksort($channels);
if ($channels === []) {
echo "\nKeine Kanal-Logs gefunden" . ($only !== '' ? " (--channel={$only})" : '') . ".\n";
exit(0);
}
echo "\n";
printf("%-14s %8s %-14s %s\n", 'KANAL', 'GRÖSSE', 'LETZTER EINTR.', "INFO/WARN/ERROR ({$days}d)");
echo str_repeat('-', 68) . "\n";
$body = [];
foreach ($channels as $name => $file) {
$all = $readLines($file);
$last = null;
$counts = ['INFO' => 0, 'WARN' => 0, 'ERROR' => 0];
foreach ($all as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$last = $ts;
}
if (($ts ?? 0) >= $cutoff && preg_match('/^\[[^\]]+\]\s+(INFO|WARN|ERROR)\s/', $line, $m) === 1) {
$counts[$m[1]]++;
}
}
$level = $name === 'php-errors'
? '(eigenes Format)'
: "{$counts['INFO']}/{$counts['WARN']}/{$counts['ERROR']}";
printf("%-14s %8s %-14s %s\n", $name, $size($file), $ago($last), $level);
$body[$name] = $tail($all, $lines);
}
foreach ($body as $name => $tailLines) {
echo "\n--- {$name}.log (letzte " . count($tailLines) . ") ---\n";
foreach ($tailLines as $line) {
echo $line . "\n";
}
}
echo "\nRotation: php bin/log-rotate.php · Deploy-Check: php bin/preflight.php\n";
exit($recentErrors === [] ? 0 : 1);

View File

@@ -21,14 +21,18 @@ declare(strict_types=1);
* Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
* setzt ihn (wie bin/instagram-sync.php).
*/
if (PHP_SAPI !== 'cli') {
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$verbose = in_array('-v', $argv, true);
$dump = in_array('--dump', $argv, true);
$verbose = in_array('-v', $argv ?? [], true);
$dump = in_array('--dump', $argv ?? [], true);
$apiHost = 'https://widget-prod.bfv.de';
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
@@ -61,16 +65,17 @@ $competitionLabels = [
'Hallenrunde' => 'Halle',
];
$log = static function (string $msg) use ($verbose): void {
$line = '[' . date('c') . "] {$msg}\n";
file_put_contents(STORAGE_PATH . '/logs/matchcenter.log', $line, FILE_APPEND);
// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/matchcenter.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('matchcenter', $level, $msg);
if ($verbose) {
echo $line;
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("FEHLER: {$msg} — bestehender Cache bleibt unangetastet.");
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
@@ -81,7 +86,9 @@ if ($teamsCfg === []) {
// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$fail('Läuft bereits (Lock belegt)');
// Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
@@ -115,7 +122,7 @@ $fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
return null;
}
if ($dump) {
$log("DUMP {$url}\n" . substr($body, 0, 4000));
echo "DUMP {$url}\n" . substr($body, 0, 4000) . "\n";
}
$data = json_decode($body, true);
return is_array($data) ? $data : null;
@@ -139,12 +146,12 @@ $downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $cre
}
$bin = $fetch($crestBase . $clubId, false);
if ($bin === false) {
$log("Wappen-Download fehlgeschlagen: {$clubId}");
$log("Wappen-Download fehlgeschlagen: {$clubId}", 'WARN');
return null;
}
$src = @imagecreatefromstring($bin);
if ($src === false) {
$log("Wappen unlesbar: {$clubId}");
$log("Wappen unlesbar: {$clubId}", 'WARN');
return null;
}
$w = imagesx($src);
@@ -277,7 +284,7 @@ foreach ($teamsCfg as $cfg) {
$teamCfgId = (string) ($cfg['team_id'] ?? '');
$cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
if ($key === '' || $teamCfgId === '') {
$log("Team-Eintrag ohne key/team_id übersprungen");
$log("Team-Eintrag ohne key/team_id übersprungen", 'WARN');
continue;
}
$prevTeam = $prevByKey[$key] ?? null;
@@ -325,6 +332,13 @@ foreach ($teamsCfg as $cfg) {
$upcoming = [];
$results = [];
foreach ($matches as $m) {
// Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
// "SPIELFREI" — kein echtes Spiel, gehört weder in "Nächste
// Spiele" noch in Ergebnisse (tauchte sonst bis in den
// Startseiten-Hero und die Stadionzeitung durch).
if (($m['homeTeamName'] ?? '') === 'SPIELFREI' || ($m['guestTeamName'] ?? '') === 'SPIELFREI') {
continue;
}
$hasResult = trim((string) ($m['result'] ?? '')) !== '';
$row = $mapMatch($m, $teamCfgId);
if ($hasResult) {
@@ -360,7 +374,7 @@ foreach ($teamsCfg as $cfg) {
$cacheFallbackUsed = true;
$keepCrests($upcoming);
$keepCrests($results);
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen");
$log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen", 'WARN');
}
// --- Tabellen-Teil ---
@@ -406,7 +420,7 @@ foreach ($teamsCfg as $cfg) {
$table = $prevTeam['table'] ?? [];
$cacheFallbackUsed = true;
$keepCrests($table);
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen");
$log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen", 'WARN');
}
// Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
@@ -416,7 +430,7 @@ foreach ($teamsCfg as $cfg) {
$hasPrevious = $previous !== null
&& (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen");
$log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen", 'WARN');
continue;
}
@@ -448,7 +462,7 @@ foreach ($teamsCfg as $cfg) {
. ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
} catch (\Throwable $e) {
// Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
$log("Team {$key} übersprungen: " . $e->getMessage());
$log("Team {$key} übersprungen: " . $e->getMessage(), 'WARN');
if ($prevTeam !== null) {
$resultTeams[] = $prevTeam;
$cacheFallbackUsed = true;

View File

@@ -37,6 +37,21 @@ if (str_ends_with($baseUrl, '/')) {
$warnings[] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.';
}
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
// (base_url ohne www, .htaccess mit).
$htaccess = PUBLIC_PATH . '/.htaccess';
if (is_file($htaccess)
&& preg_match('~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i', (string) file_get_contents($htaccess), $forced) === 1) {
$forcedHost = strtolower($forced[1]);
$configHost = strtolower((string) parse_url($baseUrl, PHP_URL_HOST));
if ($configHost !== '' && $configHost !== $forcedHost && !str_contains($configHost, 'localhost')) {
$errors[] = "base_url nutzt '{$configHost}', public/.htaccess leitet aber alles auf '{$forcedHost}' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen.";
}
}
// --- Abhängigkeiten ---
if (!is_file(ROOT_PATH . '/vendor/autoload.php')) {
$errors[] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.';
@@ -64,6 +79,86 @@ foreach (['img/instagram', 'img/crests'] as $dir) {
}
}
// --- Dateiablage /dateien (Files Gallery) ---
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
foreach (['uploads', 'system'] as $dir) {
$path = STORAGE_PATH . '/dateien/' . $dir;
if (!is_dir($path)) {
$errors[] = "storage/dateien/{$dir}/ fehlt — anlegen und beschreibbar machen.";
} elseif (!is_writable($path)) {
$errors[] = "storage/dateien/{$dir}/ ist nicht beschreibbar — Uploads und Thumbnails scheitern.";
}
}
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
// jemand vor dem Login-Formular steht und sich wundert.
if (config('dateien') === null) {
$errors[] = "Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php).";
} else {
foreach (['verein', 'admin'] as $user) {
$hash = (string) config("dateien.users.{$user}.password_hash", '');
if ($hash === '' || $hash === 'CHANGE_ME') {
$errors[] = "dateien.users.{$user}.password_hash fehlt — dieser Zugang ist gesperrt.";
} elseif (password_get_info($hash)['algoName'] === 'unknown') {
$warnings[] = "dateien.users.{$user}.password_hash sieht wie Klartext aus — besser hashen: php -r \"echo password_hash('…', PASSWORD_DEFAULT);\"";
}
}
if (in_array((string) config('dateien.license_key', ''), ['', 'CHANGE_ME'], true)) {
$warnings[] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.';
}
}
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
$galleryApp = PUBLIC_PATH . '/dateien/index.php';
if (!is_file($galleryApp)) {
$errors[] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.';
} else {
$source = (string) file_get_contents($galleryApp);
if (!str_contains($source, "\$localconfigpath = __DIR__ . '/_filesconfig.php'")) {
$errors[] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php';
}
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
// selbst davor (index.php:33), also fangen wir es hier ab.
if (preg_match('/^\s*\'(license_key|username|password)\' => \'.+\',/m', $source, $edited) === 1) {
$errors[] = "public/dateien/index.php wurde direkt editiert ('{$edited[1]}') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg.";
}
$version = preg_match('/public static \$version = \'([0-9][^\']*)\'/', $source, $m) === 1 ? $m[1] : '?';
if (!is_file(PUBLIC_PATH . "/assets/filesgallery/files.photo.gallery@{$version}/css/files.css")) {
$errors[] = "Assets der Dateiablage fehlen (Version {$version}) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets";
}
}
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
$phpUpload = ini_parse_quantity((string) ini_get('upload_max_filesize'));
$phpPost = ini_parse_quantity((string) ini_get('post_max_size'));
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
$ourMax = 0;
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php';
if (is_file($galleryConfigFile)
&& preg_match("/'upload_max_filesize'\s*=>\s*(\d+)/", (string) file_get_contents($galleryConfigFile), $um) === 1) {
$ourMax = (int) $um[1];
}
$mb = static fn (int|float $bytes): string => round($bytes / 1048576) . ' MB';
echo 'INFO Uploadgrenze: ' . ($ourMax > 0 ? $mb($ourMax) : 'unbekannt')
. " laut _filesconfig.php, diese PHP-CLI erlaubt {$mb($phpUpload)} (post_max_size {$mb($phpPost)})\n";
if ($ourMax > 0 && $phpUpload < $ourMax) {
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
$warnings[] = "Diese PHP-CLI deckelt Uploads auf {$mb($phpUpload)}, unsere Config erlaubt {$mb($ourMax)}. Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt {$mb($ourMax)}. Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser).";
}
if ($phpPost <= $phpUpload) {
$warnings[] = "post_max_size ({$mb($phpPost)}) ist nicht größer als upload_max_filesize ({$mb($phpUpload)}) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung.";
}
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchcenter-sync.php'] as $name => $script) {
$file = DATA_PATH . '/' . $name . '.json';
@@ -74,6 +169,133 @@ foreach (['instagram' => 'bin/instagram-sync.php', 'matchcenter' => 'bin/matchce
}
}
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
// deshalb prüft es die Maschine.
$eventSlugs = array_filter(array_column(json_load('events')['events'] ?? [], 'slug'));
$veranstaltungRows = json_load('veranstaltungen')['veranstaltungen'] ?? [];
$veranstaltungSlugs = array_column($veranstaltungRows, 'slug');
$doppelt = array_intersect($eventSlugs, $veranstaltungSlugs);
if ($doppelt !== []) {
$errors[] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
. implode(', ', $doppelt) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.';
}
if (count($veranstaltungSlugs) !== count(array_unique($veranstaltungSlugs))) {
$errors[] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
foreach ($veranstaltungRows as $i => $row) {
if (empty($row['slug']) || empty($row['title']) || empty($row['date_start'])) {
$errors[] = "data/veranstaltungen.json, Eintrag #{$i}: slug, title und date_start sind Pflicht.";
}
}
// --- Formular-Empfänger auf eigener Domain ---
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
// Datenschutzerklärung mit — und das fällt von allein nie auf.
$clubMails = array_filter(
json_load('club'),
static fn ($v, string $k): bool => str_starts_with($k, 'email') && is_string($v) && $v !== '',
ARRAY_FILTER_USE_BOTH
);
foreach ($clubMails as $key => $address) {
if (!str_ends_with(strtolower((string) $address), '@tsv08kulmbach.de')) {
$warnings[] = "club.json → {$key} ({$address}) liegt nicht auf tsv08kulmbach.de."
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.';
}
}
// --- News (data/news.json) ---
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
$newsRows = json_load('news')['news'] ?? [];
$newsSlugs = array_values(array_filter(array_column($newsRows, 'slug')));
$newsBlockTypen = ['text', 'zwischentitel', 'bild', 'zitat'];
if (count($newsSlugs) !== count(array_unique($newsSlugs))) {
$errors[] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.';
}
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
// sieht man an der weißen Seite nicht.
$routeSlugs = array_keys(require APP_PATH . '/routes.php');
$verdeckt = array_intersect(array_map(static fn (string $s): string => 'news/' . $s, $newsSlugs), $routeSlugs);
if ($verdeckt !== []) {
$errors[] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode(', ', $verdeckt)
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.';
}
$gleichnamig = array_intersect($newsSlugs, $veranstaltungSlugs);
if ($gleichnamig !== []) {
$warnings[] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
. implode(', ', $gleichnamig) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
. ' wahrscheinlich zwei Seiten für dieselbe Sache.';
}
foreach ($newsRows as $i => $row) {
if (empty($row['title']) || empty($row['date'])) {
$errors[] = "data/news.json, Eintrag #{$i}: title und date sind Pflicht.";
}
if (!empty($row['date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $row['date'])) {
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
// falsch, ohne dass etwas kaputt aussieht.
$errors[] = "data/news.json, Eintrag #{$i}: date muss YYYY-MM-DD sein.";
}
if (empty($row['artikel'])) {
continue; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
}
if (empty($row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar.";
} elseif (!preg_match('/^[a-z0-9-]+$/', (string) $row['slug'])) {
$errors[] = "data/news.json, Eintrag #{$i}: slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.";
}
foreach ($row['artikel'] as $b => $block) {
$typ = (string) ($block['typ'] ?? '');
if (!in_array($typ, $newsBlockTypen, true)) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: unbekannter typ '{$typ}' — erlaubt sind "
. implode(', ', $newsBlockTypen) . '. Der Block würde beim Rendern verschluckt.';
continue;
}
if ($typ === 'bild' && empty($block['alt'])) {
$errors[] = "data/news.json, Eintrag #{$i}, Block #{$b}: Bild ohne alt-Text.";
}
}
}
// --- Cron-Endpoint (public/cron.php) ---
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
$cronKey = (string) config('cron.key', '');
$cronInterval = (int) config('cron.min_interval', 240);
if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
$warnings[] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.';
} else {
if (strlen($cronKey) < 24) {
$errors[] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'';
}
if ($cronKey === (string) config('app_secret')) {
$errors[] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.';
}
if ($cronInterval >= 1800) {
$warnings[] = "cron.min_interval ist {$cronInterval} s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen.";
}
}
// --- Ausgabe ---
foreach ($warnings as $w) {
echo "WARNUNG {$w}\n";

78
bin/qr.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* QR-Code für DRUCKSACHEN erzeugen (nur CLI).
*
* php bin/qr.php <pfad|url> [ziel.png] [--scale=24]
*
* Beispiele:
* php bin/qr.php stadionzeitung/aktuell
* php bin/qr.php stadionzeitung/aktuell ~/Desktop/qr-plakat.png --scale=30
* php bin/qr.php https://tsv08kulmbach.de/stadionzeitung/aktuell
*
* Abgrenzung zu den QR-Codes der Stadionzeitung: die erzeugt
* bin/stadionzeitung-add.php selbst, klein und für die A5-Seiten
* (generate_qr_png() Default-Skalierung, gut 200px). Dieses Skript ist für
* Plakate und Aushänge, die in Canva zusammengebaut werden — dort braucht es
* ein Vielfaches an Pixeln, sonst franst der Code im Druck aus.
*
* DIE EIGENTLICHE AUFGABE DIESES SKRIPTS IST DIE WARNUNG: ein QR-Code enthält
* die URL fest eingebacken. Wird er gegen die Entwicklungs-Config erzeugt, steht
* `http://localhost:8000/…` darin und das gedruckte Plakat ist Altpapier. Ein
* interner Pfad läuft deshalb durch abs_url() (Single Source: config base_url),
* und wenn die nicht auf https zeigt, sagt das Skript es laut.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => !str_starts_with($a, '--')));
$scale = 24;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--scale=')) {
$scale = max(1, (int) substr($arg, 8));
}
}
if ($args === []) {
fwrite(STDERR, "Nutzung: php bin/qr.php <pfad|url> [ziel.png] [--scale=24]\n");
exit(1);
}
$target = $args[0];
$isAbsolute = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
$url = $isAbsolute ? $target : abs_url(trim($target, '/'));
// Zielpfad: ohne Angabe neben das Skript-Verzeichnis in storage/qr/ ablegen —
// Druckmaterial ist kein Website-Asset und hat unter public/ nichts zu suchen.
$dest = $args[1] ?? null;
if ($dest === null) {
$dir = STORAGE_PATH . '/qr';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$name = preg_replace('/[^a-z0-9]+/', '-', strtolower($isAbsolute ? (string) parse_url($url, PHP_URL_PATH) : $target)) ?? 'qr';
$dest = $dir . '/qr-' . trim($name, '-') . '.png';
}
if (!$isAbsolute && !str_starts_with((string) config('base_url'), 'https://')) {
fwrite(STDERR, "WARNUNG base_url ist '" . (string) config('base_url') . "' — dieser QR-Code führt NICHT auf die echte Seite\n");
fwrite(STDERR, " und ist als Druckvorlage unbrauchbar. Entweder die Produktions-Config verwenden\n");
fwrite(STDERR, " oder die vollständige URL direkt übergeben:\n");
fwrite(STDERR, " php bin/qr.php https://tsv08kulmbach.de/{$target}\n\n");
}
if (!generate_qr_png($url, $dest, $scale)) {
fwrite(STDERR, "FEHLER QR-Code konnte nicht erzeugt werden (vendor-manual/phpqrcode vorhanden?)\n");
exit(1);
}
[$w, $h] = getimagesize($dest) ?: [0, 0];
echo "QR-Code erzeugt\n";
echo " Ziel-URL : {$url}\n";
echo " Datei : {$dest}\n";
echo " Größe : {$w}x{$h} px (bei 300 dpi etwa " . round($w / 300 * 2.54, 1) . " cm)\n";

606
bin/stadionzeitung-add.php Normal file
View File

@@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
/**
* Stadionzeitung-Ausgabe hinzufügen/aktualisieren: baut eine Ausgabe nach der
* festen Vorlage (siehe CLAUDE.md → Stadionzeitung) aus Canva-Bildern +
* drei automatischen Live-Seiten (Tabelle/Termine/News). Manuell im Chat
* ausgelöst, kein Cron.
*
* Aufruf: php bin/stadionzeitung-add.php <ordner> "<Gegner>" <YYYY-MM-DD> <team-key> [slug]
* <team-key> Team-Key aus data/matchcenter.json (z. B. erste-mannschaft, damen) —
* bestimmt, wessen Tabelle auf der Live-Seite „Tabelle" erscheint.
*
* Erwarteter Ordnerinhalt:
* <ordner>/vorwort.* optional, Fallback-Bild — nur genutzt, solange für diese
* Ausgabe noch kein Vorwort-Text hinterlegt ist (siehe unten)
* <ordner>/rueckseite.* optional
* <ordner>/spieler.* optional, freigestelltes Spielerfoto (PNG mit Transparenz)
* fürs Cover — wird unverändert kopiert, nie über GD neu kodiert
* <ordner>/verfasser.* optional, Porträtfoto für die Vorwort-Signatur
* <ordner>/anzeigen/* Sponsoren-Seiten (fertige Vollseiten — ob im Sponsoring
* halbe oder ganze Seite verkauft wurde, ist hier egal,
* geliefert wird immer eine fertige Seite), natürlich sortiert
*
* Feste Vorlage (Interview-Beschluss 29.07.2026): Cover (live) → Vorwort →
* Ergebnis-Rückblick (live) → Anzeigen A → je Mannschaft eine Tabelle (live,
* Reihenfolge = data/matchcenter.json) → Vorschau (live) → Anzeigen B →
* Termine (live) → Anzeigen C → News (live) → Ansprechpartner (live) →
* Anzeigen D → Rückseite. Das Cover wird automatisch generiert (siehe
* app/components/stadionzeitung-cover.php) — kein Canva-Bild mehr nötig. Der
* gesamte Anzeigen-Pool (in gelieferter Reihenfolge) wird zu möglichst
* gleichen Teilen auf die vier Gruppen verteilt.
*
* Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in
* Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst
* bin/stadionzeitung-publish.php friert Tabellen/News/Ergebnisse/Vorschau als
* snapshot ein und schaltet die Ausgabe sichtbar. Fürs gefaltete A5-Heft
* (Druckfassung ?druck=1, Broschüren-Reihenfolge) sollte die Seitenzahl ein
* Vielfaches von 4 sein — das Skript meldet die Lücke, aufgefüllt wird beim
* Erzeugen gemeinsam (weiße Füllseiten in der Druckfassung sind der Notnagel).
*
* Vorwort-Text ist KEIN Skript-Input: er wird (wie data/events.json/news.json) im
* Chat gepflegt — Felix schickt den Text, Claude trägt ihn manuell ins `vorwort`-Feld
* der Ausgabe in data/stadionzeitung.json ein (heading/paragraphs/closing/signee_name/
* signee_role). Damit ein erneuter Skript-Lauf (z. B. um eine Anzeige zu korrigieren)
* dieses Feld nicht wegwirft, liest das Skript den bisherigen Wert vor dem Ersetzen
* aus und schreibt ihn unverändert zurück. Ist ein `vorwort`-Feld vorhanden, rendert
* die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
* Fallback-Bilds `vorwort.*`.
*
* Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
* Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter($argv, static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
array_shift($args); // Skriptname
if (count($args) < 4) {
fwrite(STDERR, "Nutzung: php bin/stadionzeitung-add.php <ordner> \"<Gegner>\" <YYYY-MM-DD> <team-key> [slug]\n");
exit(1);
}
[$sourceDir, $opponent, $date, $teamKey] = $args;
$slugArg = $args[4] ?? '';
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('stadionzeitung', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
exit(1);
};
// --- Eingaben validieren ---
if (!is_dir($sourceDir)) {
$fail("Ordner nicht gefunden: {$sourceDir}");
}
$parsedDate = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($parsedDate === false || $parsedDate->format('Y-m-d') !== $date) {
$fail("Ungültiges Datum (erwartet YYYY-MM-DD): {$date}");
}
$opponent = trim($opponent);
if ($opponent === '') {
$fail('Gegner darf nicht leer sein.');
}
$mc = json_load('matchcenter');
$knownTeamKeys = array_column($mc['teams'] ?? [], 'key');
if (!in_array($teamKey, $knownTeamKeys, true)) {
$fail("Unbekannter team-key '{$teamKey}'. Bekannt: " . implode(', ', $knownTeamKeys));
}
/**
* Kleine, lokale Slugify-Funktion (nur dieser Aufrufer) — Umlaute
* transliterieren, Kleinschreibung, alles außer a-z0-9 zu Bindestrichen.
*/
$slugify = static function (string $text): string {
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
return trim($text, '-');
};
$slug = $slugArg !== '' ? $slugify($slugArg) : $date . '-' . $slugify($opponent);
if ($slug === '') {
$fail('Konnte keinen gültigen Slug bilden.');
}
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$findOne = static function (string $baseName) use ($sourceDir, $exts): ?string {
$matches = glob(rtrim($sourceDir, '/') . "/{$baseName}.{{$exts}}", GLOB_BRACE) ?: [];
return $matches[0] ?? null;
};
$findAll = static function (string $subdir) use ($sourceDir, $exts): array {
$dir = rtrim($sourceDir, '/') . "/{$subdir}";
if (!is_dir($dir)) {
return [];
}
$files = glob("{$dir}/*.{{$exts}}", GLOB_BRACE) ?: [];
natsort($files);
return array_values($files);
};
$vorwort = $findOne('vorwort');
$rueckseite = $findOne('rueckseite');
$spieler = $findOne('spieler');
$verfasser = $findOne('verfasser');
// Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften
// Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch
// vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen,
// wo sie jetzt hingehören, als ihn stillschweigend zu ignorieren.
if ($findAll('anzeigen') !== []) {
$log('Der Ordner enthält noch anzeigen/ — die werden NICHT mehr von hier gelesen. '
. 'Anzeigen liegen im Bestand: php bin/anzeige-add.php <ordner>', 'WARN');
}
if ($rueckseite === null) {
$log('Keine rueckseite.* im Ordner — das Heft endet mit dem Impressum.', 'WARN');
}
// --- Lock gegen parallele Läufe (billige Absicherung, kein echtes Cron-Risiko hier) ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/stadionzeitung.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- Bisherigen Eintrag lesen (falls vorhanden) — trägt u. a. das chat-gepflegte
// vorwort-Feld über einen erneuten Lauf hinweg weiter, siehe Kommentar oben. ---
$cacheFile = DATA_PATH . '/stadionzeitung.json';
$existing = is_file($cacheFile)
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
: ['issues' => []];
$oldIssue = null;
foreach ($existing['issues'] ?? [] as $entry) {
if (($entry['slug'] ?? '') === $slug) {
$oldIssue = $entry;
break;
}
}
$oldVorwort = $oldIssue['vorwort'] ?? null;
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
$oldInterview = $oldIssue['interview'] ?? null;
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
} else {
// Der Ordner wird KOMPLETT geleert, nicht nach Mustern aufgeräumt: alles
// darin entsteht in diesem Lauf neu (Vorwort- und Rückseiten-Bild, Spieler,
// Verfasser, QR). Ein Muster-Aufräumen hinterlässt sonst Waisen, sobald sich
// Dateinamen ändern — genau das ist passiert: neben den `page-NN`-Resten des
// alten Positions-Schemas lagen auch die `anzeige-*`-Kopien, die jetzt im
// Bestand (public/assets/img/anzeigen/) leben. Die geteilten CTA-QR-Codes
// liegen eine Ebene höher und sind davon nicht betroffen.
foreach (glob("{$destDir}/*") ?: [] as $old) {
if (is_file($old)) {
unlink($old);
}
}
}
$widths = [480, 960, 1440];
// --- Anzeigen kommen aus dem DAUERHAFTEN Bestand (data/anzeigen.json), nicht
// mehr aus dem Ablieferordner: Sponsorenmotive ändern sich selten, das Heft
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
// Bilder liegen schon. ---
$anzeigenSeiten = array_values(array_map(
static function (array $a): array {
$sponsor = trim((string) ($a['sponsor'] ?? ''));
return [
'type' => 'image',
'base' => (string) $a['base'],
'widths' => $a['widths'] ?? [],
// Alt-Text aus dem Sponsorennamen abgeleitet, damit er nur an EINER
// Stelle gepflegt wird. `alt` im Bestand überschreibt ihn (z. B.
// „Eigenanzeige: …").
'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . $sponsor)),
];
},
array_filter(
json_load('anzeigen')['anzeigen'] ?? [],
// `bis` gegen das DATUM DER AUSGABE prüfen, nicht gegen heute: ein Heft
// zum Spiel am 1. August darf für ein Fest am 6. September werben, ein
// später angelegtes Heft nicht mehr. Damit retirieren zeitgebundene
// Eigenanzeigen (Sportfest-Plakat, Programm) von selbst, statt bis zum
// nächsten Handgriff im Heft zu bleiben.
static fn (array $a): bool => ($a['aktiv'] ?? true)
&& !empty($a['base'])
&& (empty($a['bis']) || (string) $a['bis'] >= $date)
)
));
// --- Seiten-Bausteine: Live-Seiten brauchen kein Bild, die zwei verbliebenen
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
// bei jeder Umverteilung. ---
$live = static function (string $page, ?string $team = null): array {
$entry = ['type' => 'live', 'page' => $page];
if ($team !== null) {
$entry['team'] = $team;
}
return $entry;
};
$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
try {
$variants = resize_image_variants($src, "{$destDir}/{$name}", $widths, 82);
} catch (RuntimeException $e) {
throw new RuntimeException("Seite '{$name}' ({$src}) fehlgeschlagen: " . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
return [
'type' => 'image',
'base' => "img/stadionzeitung/{$slug}/{$name}",
'widths' => $generated,
'alt' => $alt,
];
};
/*
* Inhalt als Liste von BLÖCKEN, Anzeigen kommen anschließend in die Lücken
* dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS;
* `anzeigen_danach` sagt, ob nach dem Block eine Lücke für Anzeigen entsteht.
*
* Drei Stellen sind bewusst anzeigenfrei:
* - nach dem Cover: der Leser schlägt auf Inhalt auf, nicht auf Werbung,
* - innerhalb des Aufschlags Vorwort+Interview (deshalb EIN Block),
* - innerhalb der Tabellen (deshalb EIN Block): die Tabellenseiten müssen im
* gefalteten Heft nebeneinander liegen, eine Anzeige dazwischen zerstört
* genau die Doppelseite, die die Paritätsrechnung unten herstellt,
* - zwischen Impressum und Rückseite: die Rückseite ist die Heft-Außenseite.
*/
$bloecke = [];
try {
$bloecke[] = ['name' => 'cover', 'seiten' => [$live('cover')], 'anzeigen_danach' => false];
$aufschlag = [];
if ($oldVorwort !== null) {
$aufschlag[] = $live('vorwort');
} elseif ($vorwort !== null) {
$aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
}
// Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
// Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
if ($oldInterview !== null) {
$aufschlag[] = $live('interview');
}
if ($aufschlag !== []) {
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
}
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
// Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
// (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
// SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
$tabellen = [];
foreach ($mc['teams'] ?? [] as $team) {
if (!empty($team['key'])) {
$tabellen[] = $live('tabelle', (string) $team['key']);
}
}
if ($tabellen !== []) {
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
}
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) {
$bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true];
}
$bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];
if ($rueckseite !== null) {
$bloecke[] = [
'name' => 'rueckseite',
'seiten' => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")],
'anzeigen_danach' => false,
];
}
} catch (RuntimeException $e) {
$fail($e->getMessage());
}
// --- Anzeigen gleichmäßig auf die Lücken verteilen ---
$luecken = [];
foreach ($bloecke as $i => $b) {
if ($b['anzeigen_danach']) {
$luecken[] = $i;
}
}
$luckCount = count($luecken);
$adCount = count($anzeigenSeiten);
if ($luckCount === 0) {
$fail('Keine Lücke für Anzeigen — die Vorlage hat keine Inhaltsblöcke.');
}
// Grundmenge plus Restverteilung. Der Rest wird GESTREUT (nicht vorne gebündelt),
// sonst stünden alle Doppel-Lücken am Heftanfang.
$basis = intdiv($adCount, $luckCount);
$rest = $adCount % $luckCount;
$verteilung = [];
for ($g = 0; $g < $luckCount; $g++) {
$verteilung[$g] = $basis + (intdiv(($g + 1) * $rest, $luckCount) - intdiv($g * $rest, $luckCount));
}
if ($adCount > 2 * $luckCount) {
$log(sprintf(
'%d Anzeigen auf %d Lücken — mehr als zwei hintereinander ist damit unvermeidbar (höchstens %d). Weniger Anzeigen oder eine Inhaltsseite mehr.',
$adCount,
$luckCount,
max($verteilung)
), 'WARN');
}
/** 1-basierte Seitenzahl, auf der ein Block beginnt (null, wenn er fehlt). */
$blockSeite = static function (string $name) use ($bloecke, $verteilung): ?int {
$seite = 0;
$g = 0;
foreach ($bloecke as $b) {
if ($b['name'] === $name) {
return $seite + 1;
}
$seite += count($b['seiten']);
if ($b['anzeigen_danach']) {
$seite += $verteilung[$g];
$g++;
}
}
return null;
};
/*
* Doppelseiten-Regel: aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander,
* die GERADE Seite links. Die erste Tabellenseite muss deshalb auf einer geraden
* Seitenzahl liegen, damit die Tabellen im gefalteten Heft eine Doppelseite bilden.
* Nur Anzeigen in Lücken VOR dem Tabellenblock verschieben dessen Parität — eine
* Anzeige zwischen einer solchen Lücke und einer dahinter zu tauschen kippt sie
* also, ohne die Gesamtseitenzahl zu ändern. Früher war das Handarbeit
* („notfalls eine Anzeige verschieben"), jetzt rechnet es das Skript.
*/
$tabellenIndex = null;
foreach ($bloecke as $i => $b) {
if ($b['name'] === 'tabellen') {
$tabellenIndex = $i;
break;
}
}
if ($tabellenIndex !== null && ($seite = $blockSeite('tabellen')) !== null && $seite % 2 === 1) {
$vor = [];
$nach = [];
foreach ($luecken as $g => $blockIdx) {
if ($blockIdx < $tabellenIndex) {
$vor[] = $g;
} else {
$nach[] = $g;
}
}
$getauscht = false;
// Erst versuchen: eine Anzeige nach vorne holen (Lücke vorne darf auf 2).
foreach ($vor as $g) {
if ($verteilung[$g] >= 2) {
continue;
}
foreach (array_reverse($nach) as $h) {
if ($verteilung[$h] > 0) {
$verteilung[$g]++;
$verteilung[$h]--;
$getauscht = true;
$log("Doppelseiten-Regel: eine Anzeige aus Lücke {$h} nach Lücke {$g} verschoben, damit die Tabellen nebeneinander liegen.");
break 2;
}
}
}
// Sonst umgekehrt: eine Anzeige nach hinten abgeben.
if (!$getauscht) {
foreach ($vor as $g) {
if ($verteilung[$g] < 1) {
continue;
}
foreach ($nach as $h) {
if ($verteilung[$h] < 2) {
$verteilung[$g]--;
$verteilung[$h]++;
$getauscht = true;
$log("Doppelseiten-Regel: eine Anzeige aus Lücke {$g} nach Lücke {$h} verschoben, damit die Tabellen nebeneinander liegen.");
break 2;
}
}
}
}
if (!$getauscht) {
$log('Doppelseiten-Regel NICHT herstellbar (keine Anzeige verschiebbar) — die Tabellen liegen im Heft nicht nebeneinander.', 'WARN');
}
}
// --- Zusammensetzen: Block, dann die Anzeigen seiner Lücke ---
$images = [];
$g = 0;
$zeiger = 0;
foreach ($bloecke as $b) {
foreach ($b['seiten'] as $seiteEintrag) {
$images[] = $seiteEintrag;
}
if ($b['anzeigen_danach']) {
for ($k = 0; $k < $verteilung[$g]; $k++) {
$images[] = $anzeigenSeiten[$zeiger++];
}
$g++;
}
}
$total = count($images);
// Layout einmal kompakt protokollieren (statt einer Zeile pro Seite) — daran
// sieht man auf einen Blick, ob die Verteilung stimmt.
$layout = implode(' ', array_map(
static fn (array $e): string => ($e['type'] === 'live' ? strtoupper(substr($e['page'], 0, 3)) : '·'),
$images
));
$log("Layout ({$total} Seiten, · = Anzeige): {$layout}");
$log(sprintf(
'%d Anzeigen auf %d Lücken verteilt (%s), längster Block: %d.',
$adCount,
$luckCount,
implode('/', $verteilung),
max($verteilung ?: [0])
));
// Fürs gefaltete Heft (Sattelheftung) muss die Seitenzahl durch 4 teilbar
// sein — die Druckfassung füllt fehlende Seiten weiß auf, aber sauberer ist
// eine gemeinsame Lösung beim Erzeugen (weitere Anzeige, Füllseite mit
// Inhalt). Deshalb hier nur eine deutliche Meldung, kein Abbruch.
if ($total % 4 !== 0) {
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
}
// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
$playerImage = null;
if ($spieler !== null) {
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
$playerDest = "{$destDir}/spieler.{$ext}";
// PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
// leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
$copied = $ext === 'png'
? crop_transparent_png_to_content($spieler, $playerDest)
: copy($spieler, $playerDest);
if ($copied) {
$playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
$log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
} else {
$log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
}
}
// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie.
// QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. ---
$qrImage = null;
$qrDest = "{$destDir}/qr.png";
if (generate_qr_png(abs_url('stadionzeitung/' . $slug), $qrDest)) {
$qrImage = "img/stadionzeitung/{$slug}/qr.png";
$log('QR-Code erzeugt.');
} else {
$log('QR-Code konnte nicht erzeugt werden — Cover rendert ohne QR.', 'WARN');
}
// Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine →
// /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — werden bei
// jedem Lauf mit der aktuellen base_url neu geschrieben, damit sie nach dem
// Produktivgang automatisch auf die echte Domain zeigen.
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) {
if (generate_qr_png(abs_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) {
$log("QR-Code für /{$ctaTarget} erzeugt.");
} else {
$log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN');
}
}
// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
// keine Einbindung, Regel 1 bleibt unberührt).
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
if ($instagramUrl !== '' && generate_qr_png($instagramUrl, PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')) {
$log('QR-Code fürs Instagram-Profil erzeugt.');
} else {
$log('QR-Code fürs Instagram-Profil konnte nicht erzeugt werden — Momente-Seite rendert ohne QR.', 'WARN');
}
// --- Termine-Snapshot für die Live-Seite "termine": die 5 zum Spieltag
// nächsten Termine, EINMALIG eingefroren (termine_build_lists($date)) —
// nicht bei jedem Seitenaufruf neu berechnet. Eine Ausgabe ist eine gedruckte
// Publikation: wer sie Monate später liest, soll weiter sehen, was am
// Spieltag anstand, nicht was von heute aus gesehen ansteht. Das heutige
// Spiel selbst (das Cover zeigt es schon) fliegt raus — sonst listet
// "Kommende Termine" die eigene Ausgabe. ---
$termineUpcoming = array_filter(
termine_build_lists($date)['upcoming'],
static fn (array $item): bool => ($item['date'] ?? '') !== $date || ($item['type'] ?? '') !== 'spiel'
);
$termineSnapshot = array_slice(array_values($termineUpcoming), 0, 5);
$log(count($termineSnapshot) . ' Termine für die Live-Seite eingefroren (Stand ' . $date . ').');
// --- Porträtfoto für die Vorwort-Signatur: normales Foto (kein Alpha), daher
// über resize_image_variants() wie eine Bild-Seite — kleine Breiten reichen,
// es wird nur als kleiner Kreis neben der Signatur gezeigt. Optional. ---
$verfasserPhoto = null;
if ($verfasser !== null) {
$verfasserDest = "{$destDir}/verfasser";
try {
$variants = resize_image_variants($verfasser, $verfasserDest, [120, 240], 82);
$generatedWidths = array_values(array_unique(array_column($variants, 'width')));
sort($generatedWidths);
$verfasserPhoto = [
'base' => "img/stadionzeitung/{$slug}/verfasser",
'widths' => $generatedWidths,
'alt' => '',
];
$log('Verfasserfoto übernommen.');
} catch (RuntimeException $e) {
$log("Verfasserfoto konnte nicht verarbeitet werden: {$e->getMessage()}", 'WARN');
}
}
// --- data/stadionzeitung.json atomar schreiben (bestehenden Slug ersetzen) ---
$issues = array_values(array_filter(
$existing['issues'] ?? [],
static fn (array $issue): bool => ($issue['slug'] ?? '') !== $slug
));
// Immer als Entwurf — auch beim erneuten Lauf über eine schon veröffentlichte
// Ausgabe: der Lauf ersetzt die Seiten, also muss der Stand neu geprüft und
// neu veröffentlicht werden (der alte snapshot passt nicht mehr und fliegt mit).
if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') {
$log("Ausgabe '{$slug}' war veröffentlicht — steht jetzt wieder auf Entwurf, nach Prüfung neu veröffentlichen (bin/stadionzeitung-publish.php).", 'WARN');
}
$issues[] = [
'slug' => $slug,
'opponent' => $opponent,
'date' => $date,
'team_key' => $teamKey,
'status' => 'entwurf',
'pages' => $total,
'player_image' => $playerImage,
'qr_image' => $qrImage,
'vorwort' => $oldVorwort,
'interview' => $oldInterview,
'verfasser_photo' => $verfasserPhoto,
'termine_snapshot' => $termineSnapshot,
'images' => $images,
];
usort($issues, static fn (array $a, array $b): int => strcmp($b['date'], $a['date']));
$payload = json_encode(['issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);
$log("OK: Ausgabe '{$slug}' ({$opponent}, {$date}, Team {$teamKey}) mit {$total} Seiten als Entwurf gespeichert.");
echo "Fertig (ENTWURF): /stadionzeitung/{$slug} ({$total} Seiten, davon {$adCount} Anzeigen aus dem Bestand)\n";
echo 'Anzeigen verteilt: ' . implode('/', $verteilung) . ' je Lücke, längster Block ' . max($verteilung ?: [0]) . ".\n";
echo "Veröffentlichen nach Prüfung: php bin/stadionzeitung-publish.php {$slug}\n";

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* Stadionzeitung-Ausgabe veröffentlichen: friert die Live-Daten (Tabellen,
* Ergebnisse, Vorschau, News) als `snapshot`-Feld in der Ausgabe ein und
* setzt status auf 'veroeffentlicht' — ab jetzt taucht die Ausgabe in
* Archiv/Sitemap auf und ändert sich nie mehr (Interview-Beschluss
* 29.07.2026: „gedruckt = fixiert"). Die Berechnung ist dieselbe wie in der
* Entwurfs-Vorschau (stadionzeitung_build_snapshot(), app/helpers.php) —
* was Felix beim Prüfen gesehen hat, wird exakt so eingefroren.
*
* Aufruf: php bin/stadionzeitung-publish.php <slug>
* php bin/stadionzeitung-publish.php <slug> --entwurf (zurückziehen:
* Status wieder auf Entwurf, Snapshot wird verworfen — die Seiten
* rechnen wieder live)
*
* Sinnvoller Zeitpunkt: am Spieltagmorgen NACH dem letzten Matchcenter-Sync,
* dann steht der aktuelle Tabellenstand im Heft.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$slug = trim((string) ($argv[1] ?? ''));
$unpublish = in_array('--entwurf', $argv, true);
if ($slug === '' || str_starts_with($slug, '--')) {
fwrite(STDERR, "Nutzung: php bin/stadionzeitung-publish.php <slug> [--entwurf]\n");
exit(1);
}
$log = static function (string $msg, string $level = 'INFO'): void {
log_write('stadionzeitung', $level, $msg);
echo '[' . date('c') . "] {$level} {$msg}\n";
};
$cacheFile = DATA_PATH . '/stadionzeitung.json';
$data = is_file($cacheFile)
? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
: ['issues' => []];
$found = false;
foreach ($data['issues'] as &$issue) {
if (($issue['slug'] ?? '') !== $slug) {
continue;
}
$found = true;
if ($unpublish) {
$issue['status'] = 'entwurf';
unset($issue['snapshot']);
$log("Ausgabe '{$slug}' zurückgezogen — Status Entwurf, Snapshot verworfen (Live-Seiten rechnen wieder frisch).");
break;
}
if (($issue['status'] ?? 'veroeffentlicht') === 'veroeffentlicht' && isset($issue['snapshot'])) {
$log("Ausgabe '{$slug}' ist bereits veröffentlicht — nichts zu tun. Zum Neu-Einfrieren erst mit --entwurf zurückziehen.", 'WARN');
exit(0);
}
$snapshot = stadionzeitung_build_snapshot($issue);
$issue['snapshot'] = $snapshot;
$issue['status'] = 'veroeffentlicht';
$issue['published_at'] = date('c');
$tables = implode(', ', array_keys($snapshot['tabellen'] ?? []));
$log("Ausgabe '{$slug}' veröffentlicht — eingefroren: Tabellen [{$tables}], "
. count($snapshot['ergebnisse'] ?? []) . ' Ergebnis-Blöcke, '
. count($snapshot['vorschau'] ?? []) . ' Vorschau-Spiele, '
. count($snapshot['news'] ?? []) . ' News.');
break;
}
unset($issue);
if (!$found) {
$known = implode(', ', array_column($data['issues'], 'slug'));
fwrite(STDERR, "Unbekannter Slug '{$slug}'. Bekannt: {$known}\n");
exit(1);
}
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);
echo $unpublish
? "Fertig: /stadionzeitung/{$slug} ist wieder ein Entwurf.\n"
: "Fertig: /stadionzeitung/{$slug} ist veröffentlicht und eingefroren.\n";

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
/**
* Bilder einer Veranstaltung einarbeiten (nur CLI).
*
* php bin/veranstaltung-bilder.php <slug> <ordner> [-v]
*
* Erwarteter Ordnerinhalt (beides optional, mindestens eines nötig):
* <ordner>/plakate/*.{jpg|jpeg|png|webp} Plakate, natsort = Reihenfolge.
* Das ERSTE ist das Hauptplakat und
* landet auf Karten und im OG-Bild.
* <ordner>/bilder/*.{jpg|…} Fotos vom Fest, natsort = Reihenfolge
*
* DER SLUG MUSS SCHON IN data/veranstaltungen.json STEHEN. Umgekehrt zur
* Stadionzeitung (dort legt das Skript die Ausgabe an und der Chat reichert sie
* an) gilt hier: der Eintrag entsteht im Chat, das Skript reichert ihn an. Genau
* daraus folgt die Garantie, dass chat-gepflegte Felder nie verloren gehen —
* das Skript ändert am Eintrag ausschließlich `plakate` und `galerie` und lässt
* alles andere unangetastet (auch `status`: nachgelieferte Fotos sind kein Grund,
* eine veröffentlichte Seite offline zu nehmen — anders als bei
* bin/stadionzeitung-add.php, wo ersetzte Seiten den Stand neu prüfbar machen).
*
* DER ORDNER IST IMMER DIE VOLLSTÄNDIGE GALERIE. Ein erneuter Lauf ersetzt sie
* komplett (alte Dateien werden vorher gelöscht). Wer nur drei Fotos nachliefert,
* verliert die anderen zwanzig — der Ordner muss also alles enthalten. Diese
* Wahl ist Absicht: nur so lassen sich Fotos auch entfernen und umsortieren.
*
* Alt-Texte schreibt Claude im Chat, nicht dieses Skript. Damit sie einen
* erneuten Lauf überleben, trägt jeder Galerie-Eintrag `source` (den
* Originaldateinamen) — daran werden bestehende Alt-Texte wiedererkannt, auch
* wenn sich die Reihenfolge durch ein neues Foto verschiebt. Neue Fotos bekommen
* den Veranstaltungstitel als Platzhalter; das Skript nennt sie am Ende
* namentlich, damit die Texte in derselben Chat-Runde entstehen.
*/
if (PHP_SAPI !== 'cli') {
exit(1);
}
require dirname(__DIR__) . '/app/bootstrap.php';
$args = array_values(array_filter($argv, static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
array_shift($args); // Skriptname
if (count($args) < 2) {
fwrite(STDERR, "Nutzung: php bin/veranstaltung-bilder.php <slug> <ordner> [-v]\n");
exit(1);
}
[$slug, $sourceDir] = $args;
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
log_write('veranstaltungen', $level, $msg);
if ($verbose || $level === 'ERROR' || $level === 'WARN') {
echo '[' . date('c') . "] {$level} {$msg}\n";
}
};
$fail = static function (string $msg) use ($log): never {
$log("{$msg} — bestehende Daten bleiben unangetastet.", 'ERROR');
exit(1);
};
// --- Eingaben validieren ---
if (!is_dir($sourceDir)) {
$fail("Ordner nicht gefunden: {$sourceDir}");
}
$dataFile = DATA_PATH . '/veranstaltungen.json';
$data = is_file($dataFile)
? (json_decode((string) file_get_contents($dataFile), true) ?: ['veranstaltungen' => []])
: ['veranstaltungen' => []];
$index = null;
foreach ($data['veranstaltungen'] ?? [] as $i => $entry) {
if (($entry['slug'] ?? '') === $slug) {
$index = $i;
break;
}
}
if ($index === null) {
$known = implode(', ', array_column($data['veranstaltungen'] ?? [], 'slug'));
$fail("Unbekannter Slug '{$slug}'. Der Eintrag muss zuerst in data/veranstaltungen.json angelegt werden. Bekannt: {$known}");
}
$veranstaltung = $data['veranstaltungen'][$index];
$titel = (string) ($veranstaltung['title'] ?? $slug);
// --- Quelldateien einsammeln ---
$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$findAll = static function (string $subdir) use ($sourceDir, $exts): array {
$dir = rtrim($sourceDir, '/') . "/{$subdir}";
if (!is_dir($dir)) {
return [];
}
$files = glob("{$dir}/*.{{$exts}}", GLOB_BRACE) ?: [];
// natsort, damit IMG_9 vor IMG_10 kommt (strcmp würde umgekehrt sortieren).
natsort($files);
return array_values($files);
};
$plakatSources = $findAll('plakate');
$fotoSources = $findAll('bilder');
if ($plakatSources === [] && $fotoSources === []) {
$fail("Weder plakate/* noch bilder/* in {$sourceDir} gefunden.");
}
// --- Lock gegen parallele Läufe ---
if (!is_dir(STORAGE_PATH . '/cache')) {
mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/veranstaltungen.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
$log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
exit(1);
}
// --- Bestehende Alt-Texte sichern, bevor irgendetwas ersetzt wird ---
$altBySource = [];
foreach (array_merge($veranstaltung['plakate'] ?? [], $veranstaltung['galerie'] ?? []) as $bild) {
if (!empty($bild['source']) && !empty($bild['alt'])) {
$altBySource[(string) $bild['source']] = (string) $bild['alt'];
}
}
// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Dateien wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/veranstaltungen/{$slug}";
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
} else {
foreach (array_merge(
glob("{$destDir}/plakat-*.jpg") ?: [],
glob("{$destDir}/bild-*.jpg") ?: []
) as $old) {
unlink($old);
}
}
// Plakate brauchen die große Fassung: sie tragen Text, und in der Großansicht
// will man Uhrzeiten lesen können. Fotos nicht — dort ist die Großansicht nur
// „größer ansehen", und die dritte Stufe kostet bei 40 Fotos mehr Platz als sie
// bringt (gemessen: über die Hälfte des gesamten Bildvolumens).
$plakatWidths = [640, 1000, 1600];
$fotoWidths = [640, 1200];
/**
* Eine Quelldatei in die responsiven Breiten rendern und den Bild-Eintrag für
* die JSON bauen. Die `widths` im Eintrag sind die TATSÄCHLICH erzeugten —
* resize_image_variants() skaliert nie hoch, eine kleine Quelle liefert also
* weniger Varianten und img.php bleibt ehrlich.
*/
$render = static function (string $src, string $destBase, string $relBase, string $alt, int $quality, array $widths) use ($fail): array {
try {
$variants = resize_image_variants($src, $destBase, $widths, $quality);
} catch (RuntimeException $e) {
$fail('Bild nicht verarbeitbar (' . basename($src) . '): ' . $e->getMessage());
}
$generated = array_values(array_unique(array_column($variants, 'width')));
sort($generated);
return ['base' => $relBase, 'widths' => $generated, 'alt' => $alt];
};
$ohneAlt = [];
/**
* Eine Liste von Quelldateien zu Bild-Einträgen rendern. $prefix bestimmt den
* Dateinamen (plakat-01…, bild-01…), $quality die JPEG-Qualität: Plakate tragen
* Text, da ist Ringing um Buchstaben sichtbar — Fotos brauchen das nicht.
*/
$renderAlle = static function (array $sources, string $prefix, int $quality, array $widths, string $fallbackAlt)
use ($render, $destDir, $slug, $altBySource, &$ohneAlt): array {
$eintraege = [];
foreach ($sources as $i => $src) {
$source = basename($src);
$nummer = str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT);
$alt = $altBySource[$source] ?? '';
if ($alt === '') {
$ohneAlt[] = $source;
}
$eintrag = $render(
$src,
"{$destDir}/{$prefix}-{$nummer}",
"img/veranstaltungen/{$slug}/{$prefix}-{$nummer}",
$alt !== '' ? $alt : $fallbackAlt,
$quality,
$widths
);
$eintrag['source'] = $source;
$eintraege[] = $eintrag;
}
return $eintraege;
};
$plakate = $renderAlle($plakatSources, 'plakat', 82, $plakatWidths, "Plakat zum {$titel}");
$galerie = $renderAlle($fotoSources, 'bild', 78, $fotoWidths, $titel);
// --- Nur plakate + galerie ersetzen, alles andere bleibt, wie es ist ---
$data['veranstaltungen'][$index]['plakate'] = $plakate;
$data['veranstaltungen'][$index]['galerie'] = $galerie;
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $dataFile . '.tmp';
file_put_contents($tmp, $payload);
if (!rename($tmp, $dataFile)) {
$fail('Konnte data/veranstaltungen.json nicht schreiben.');
}
$log(sprintf(
'%s: Bilder komplett ersetzt (%d Plakate, %d Fotos).',
$slug,
count($plakate),
count($galerie)
));
echo "Veranstaltung {$slug}: Bilder komplett ersetzt, jetzt "
. count($plakate) . " Plakat(e) und " . count($galerie) . " Foto(s).\n";
if ($ohneAlt !== []) {
echo count($ohneAlt) . " Bild(er) noch ohne Alt-Text: " . implode(', ', $ohneAlt) . "\n";
echo "Alt-Texte in data/veranstaltungen.json nachtragen (Wir-Form, siehe CLAUDE.md).\n";
}