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:
181
bin/anzeige-add.php
Normal file
181
bin/anzeige-add.php
Normal 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
491
bin/chronik-add.php
Normal 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
94
bin/chronik-init.php
Normal 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
564
bin/chronik-lib.php
Normal 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, ~2016–2023). 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
83
bin/dateien-init.php
Normal 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
273
bin/filesgallery-update.php
Normal 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);
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
166
bin/logs.php
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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
78
bin/qr.php
Normal 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
606
bin/stadionzeitung-add.php
Normal 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";
|
||||
92
bin/stadionzeitung-publish.php
Normal file
92
bin/stadionzeitung-publish.php
Normal 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";
|
||||
227
bin/veranstaltung-bilder.php
Normal file
227
bin/veranstaltung-bilder.php
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user