Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben Quelle wie die Website, nicht als Kopie ins Heft: - Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026) - vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer Abschlusstabelle zum Eintragen am Platz - Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden: stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet, Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite - Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf /partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank als leere Fläche unten an einem Anzeigen-Bild - Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen), Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf dem Server, dort liegt der frische Matchcenter-Stand. Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und `emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
763 lines
34 KiB
PHP
763 lines
34 KiB
PHP
<?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
|
|
*
|
|
* KEINE Anzeigen im Ordner (seit 31.07.2026): die kommen aus dem dauerhaften
|
|
* Bestand data/anzeigen.json, gepflegt mit bin/anzeige-add.php. Sponsorenmotive
|
|
* ändern sich selten, das Heft erscheint oft — vorher lag jede Anzeige als Kopie
|
|
* unter jeder Ausgabe und ihr Alt-Text musste jedes Mal neu geschrieben werden.
|
|
*
|
|
* Feste Vorlage (Interview-Beschluss 29.07.2026, Anzeigen 31.07.2026 überarbeitet):
|
|
* Der Inhalt ist eine Liste von BLÖCKEN, die Anzeigen werden gleichmäßig in die
|
|
* Lücken dazwischen verteilt — nie mehr als zwei hintereinander (vorher vier
|
|
* Blöcke à fünf, das las sich wie „Heft zu Ende"). Anzeigenfrei bleiben: nach dem
|
|
* Cover, im Aufschlag Vorwort+Interview, zwischen den Tabellenseiten und zwischen
|
|
* Impressum und Rückseite. Die Doppelseiten-Regel (erste Tabelle auf gerader
|
|
* Seite) rechnet das Skript selbst und verschiebt dafür eine Anzeige.
|
|
* Das Cover wird automatisch generiert (app/components/stadionzeitung-cover.php).
|
|
*
|
|
* 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.*`.
|
|
*
|
|
* Interview (`interview`-Feld) und die generische Aufruf-Seite (`aufruf`-Feld,
|
|
* z. B. Sportfest-Helferaufruf) sind wie das Vorwort chat-gepflegt: das Skript
|
|
* trägt sie über erneute Läufe weiter und plant die Live-Seite nur ein, wenn
|
|
* das Feld existiert. Reihenfolge im anzeigenfreien Aufschlag: Vorwort →
|
|
* Interview → Aufruf. Für die Aufruf-Seite erzeugt das Skript zusätzlich den
|
|
* QR (qr-aufruf.png im Ausgaben-Ordner) aus `aufruf.link`.
|
|
*
|
|
* Sportfest-Ausgabe (03.09.2026): trägt die Ausgabe ein `veranstaltung`-Feld
|
|
* (Slug aus data/veranstaltungen.json, chat-gepflegt), kommen dazu: die
|
|
* Programm-Seite im Aufschlag (aus programm[] der Veranstaltung, dieselbe Liste
|
|
* wie die Website), ein Block Turnierplan-Seiten (je Eintrag in turniere[] eine
|
|
* Seite) direkt danach, und eine Gegnerseite je Mannschaft mit HEIMspiel im
|
|
* Zeitraum der Veranstaltung statt nur für die tragende Mannschaft — welche das
|
|
* sind, sagt stadionzeitung_gegner_fixtures() (helpers.php), dieselbe Funktion,
|
|
* aus der der Snapshot die Daten baut. `cover` ({edition, teaser}) kennzeichnet
|
|
* das Cover als Sonderausgabe. Beide Felder überleben erneute Läufe wie `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;
|
|
// Aufruf-Seite (z. B. Sportfest-Helferaufruf) ebenso chat-gepflegt.
|
|
$oldAufruf = $oldIssue['aufruf'] ?? null;
|
|
// Sportfest-Ausgabe: Slug-Verweis auf data/veranstaltungen.json (Programm,
|
|
// Turnierpläne, Gegnerseiten je Heimspiel) und Cover-Kennung — chat-gepflegt.
|
|
$oldVeranstaltung = $oldIssue['veranstaltung'] ?? null;
|
|
$oldCover = $oldIssue['cover'] ?? null;
|
|
$veranstaltung = null;
|
|
if (is_string($oldVeranstaltung) && $oldVeranstaltung !== '') {
|
|
$veranstaltung = veranstaltung_find($oldVeranstaltung);
|
|
if ($veranstaltung === null) {
|
|
$log("veranstaltung '{$oldVeranstaltung}' steht nicht in data/veranstaltungen.json — Programm-, Turnier- und Zusatz-Gegnerseiten entfallen.", 'WARN');
|
|
} else {
|
|
$log("Sportfest-Ausgabe: Veranstaltung '{$oldVeranstaltung}' (" . count($veranstaltung['programm'] ?? []) . ' Programmtage, ' . count($veranstaltung['turniere'] ?? []) . ' Turniere).');
|
|
}
|
|
}
|
|
|
|
// --- 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. ---
|
|
// Anzeigen mit `platzierung` haben einen FESTEN Platz auf einer Live-Seite
|
|
// (derzeit `danke`: die Emons-Anzeige über der Partner-Logo-Wand) und laufen
|
|
// deshalb nicht im Streu-Pool mit — sonst stünden sie zweimal im Heft.
|
|
$platzierteAnzeigen = [];
|
|
foreach (json_load('anzeigen')['anzeigen'] ?? [] as $a) {
|
|
$platz = trim((string) ($a['platzierung'] ?? ''));
|
|
if ($platz === '' || ($a['aktiv'] ?? true) === false || empty($a['base'])) {
|
|
continue;
|
|
}
|
|
if (!empty($a['bis']) && (string) $a['bis'] < $date) {
|
|
continue;
|
|
}
|
|
if (isset($platzierteAnzeigen[$platz])) {
|
|
continue;
|
|
}
|
|
$platzierteAnzeigen[$platz] = [
|
|
'base' => (string) $a['base'],
|
|
'widths' => $a['widths'] ?? [],
|
|
'alt' => (string) ($a['alt'] ?? ('Anzeige: ' . trim((string) ($a['sponsor'] ?? '')))),
|
|
];
|
|
}
|
|
|
|
$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['platzierung'])
|
|
&& (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, array $extra = []): array {
|
|
return ['type' => 'live', 'page' => $page] + $extra;
|
|
};
|
|
|
|
$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');
|
|
}
|
|
// Programm-Seite der Sportfest-Ausgabe direkt hinter dem Vorwort: aufgeschlagen
|
|
// liegen Vorwort (links) und Programm (rechts) als Doppelseite 2/3 nebeneinander.
|
|
if ($veranstaltung !== null && !empty($veranstaltung['programm'])) {
|
|
$aufschlag[] = $live('programm');
|
|
}
|
|
// 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');
|
|
}
|
|
// Aufruf-Seite (Helferaufruf o. Ä.) reiht sich in denselben anzeigenfreien
|
|
// Aufschlag ein, direkt hinter Vorwort/Interview — ein redaktioneller
|
|
// Einstieg ohne Werbung dazwischen (chat-gepflegtes `aufruf`-Feld).
|
|
if ($oldAufruf !== null) {
|
|
$aufschlag[] = $live('aufruf');
|
|
}
|
|
if ($aufschlag !== []) {
|
|
$bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
|
|
}
|
|
|
|
// Turnierplan-Seiten der Sportfest-Ausgabe: je Turnier eine Seite, EIN Block
|
|
// (zusammenhängender Sportfest-Teil, keine Anzeige zwischen zwei Turnieren),
|
|
// gleich nach dem Aufschlag und vor dem Fußball-Teil.
|
|
$turniere = [];
|
|
foreach ($veranstaltung['turniere'] ?? [] as $turnier) {
|
|
if (is_array($turnier) && !empty($turnier['key'])) {
|
|
$turniere[] = $live('turnier', ['turnier' => (string) $turnier['key']]);
|
|
}
|
|
}
|
|
if ($turniere !== []) {
|
|
$bloecke[] = ['name' => 'turniere', 'seiten' => $turniere, 'anzeigen_danach' => true];
|
|
}
|
|
|
|
$bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];
|
|
|
|
// Gegnerseite(n): normal nur die tragende Mannschaft; in der Sportfest-Ausgabe
|
|
// je Mannschaft mit Heimspiel am Fest eine (drei Heimspiele, drei Seiten), als
|
|
// EIN Block. Welche das sind, entscheidet stadionzeitung_gegner_fixtures() —
|
|
// dieselbe Funktion, aus der stadionzeitung_build_snapshot() die Daten baut,
|
|
// damit nie eine Seite ohne Daten oder Daten ohne Seite entstehen.
|
|
$gegnerSeiten = [];
|
|
foreach (stadionzeitung_gegner_fixtures([
|
|
'date' => $date, 'team_key' => $teamKey, 'opponent' => $opponent,
|
|
'veranstaltung' => $oldVeranstaltung,
|
|
]) as $gegnerTeam => $fixture) {
|
|
$gegnerSeiten[] = $live('gegner', ['team' => (string) $gegnerTeam]);
|
|
$log("Gegnerseite {$gegnerTeam}: {$fixture['opponent']} ({$fixture['date']}).");
|
|
}
|
|
if ($gegnerSeiten === []) {
|
|
// Ohne Treffer (Gegner leer) bleibt die Seite der tragenden Mannschaft —
|
|
// sie meldet dann selbst, dass keine Vergleichsdaten vorliegen.
|
|
$gegnerSeiten[] = $live('gegner', ['team' => $teamKey]);
|
|
}
|
|
$bloecke[] = ['name' => 'gegner', 'seiten' => $gegnerSeiten, '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', ['team' => (string) $team['key']]);
|
|
}
|
|
}
|
|
if ($tabellen !== []) {
|
|
$bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
|
|
}
|
|
|
|
foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'danke', 'momente'] as $seite) {
|
|
// Die Danke-Seite trägt ihr Anzeigenmotiv im Seiteneintrag (siehe oben),
|
|
// direkt hinter der Partner-werden-Seite: aufgeschlagen liegen damit
|
|
// Akquise und Dank als Partner-Doppelseite nebeneinander.
|
|
$extra = ($seite === 'danke' && isset($platzierteAnzeigen['danke']))
|
|
? ['anzeige' => $platzierteAnzeigen['danke']]
|
|
: [];
|
|
// Hinter der Partner-werden-Seite bleibt die Lücke leer, damit die
|
|
// Danke-Seite direkt folgt: Akquise und Dank gehören zusammen und
|
|
// liegen aufgeschlagen im günstigen Fall als Doppelseite nebeneinander.
|
|
$bloecke[] = [
|
|
'name' => $seite,
|
|
'seiten' => [$live($seite, $extra)],
|
|
'anzeigen_danach' => $seite !== 'partner',
|
|
];
|
|
}
|
|
|
|
$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: zuschneiden UND verkleinern. ---
|
|
// Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches
|
|
// Foto" — die Annahme stimmte nicht. Das Hutschdorf-Foto hatte 2584x5093 Pixel und
|
|
// 12 MB; iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt
|
|
// (hier gut 53 MB), und auf einer Ausgabe liegen rund 86 Bilder. Ergebnis: das Cover
|
|
// blieb am iPhone leer, während Chrome am Desktop es zeigte (31.07.2026).
|
|
// Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
|
|
// 238x470 CSS-Pixel — die 1000er Variante deckt Druck und dreifache Pixeldichte ab.
|
|
// resize_image_variants() erhält bei $alpha = true die Freistellung (PNG statt JPG).
|
|
// Optional, kein Fehlschlag bei Fehlen.
|
|
$playerImage = null;
|
|
if ($spieler !== null) {
|
|
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
|
|
$istPng = $ext === 'png';
|
|
// PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
|
|
// leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine
|
|
// Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet.
|
|
$quelle = $spieler;
|
|
$tmpCrop = "{$destDir}/spieler-zuschnitt.png";
|
|
if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) {
|
|
$quelle = $tmpCrop;
|
|
}
|
|
try {
|
|
$varianten = resize_image_variants($quelle, "{$destDir}/spieler", [320, 640, 1000], 82, $istPng);
|
|
$playerImage = [
|
|
'base' => "img/stadionzeitung/{$slug}/spieler",
|
|
'widths' => array_map(static fn (array $v): int => $v['width'], $varianten),
|
|
'ext' => $istPng ? 'png' : 'jpg',
|
|
'alt' => '',
|
|
];
|
|
$groesse = array_sum(array_map(static fn (array $v): int => $v['bytes'], $varianten));
|
|
$log(sprintf(
|
|
'Spielerfoto übernommen: %d Varianten%s, zusammen %.1f MB (Original %.1f MB).',
|
|
count($varianten),
|
|
$istPng ? ' mit Transparenz, auf Inhalt zugeschnitten' : '',
|
|
$groesse / 1048576,
|
|
filesize($spieler) / 1048576
|
|
));
|
|
} catch (Throwable $e) {
|
|
$log("Spielerfoto konnte nicht verarbeitet werden: {$spieler}", 'WARN');
|
|
}
|
|
if (is_file($tmpCrop)) {
|
|
unlink($tmpCrop);
|
|
}
|
|
}
|
|
|
|
// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie.
|
|
// club_url() statt abs_url(): der Code landet auf Papier und muss auf die echte
|
|
// Domain zeigen, auch wenn er hier lokal erzeugt wird (vorher stand
|
|
// http://localhost:8000 darin).
|
|
// 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(club_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) — ebenfalls über
|
|
// club_url(), damit sie unabhängig von der Umgebung stimmen und nach dem
|
|
// Produktivgang nicht neu erzeugt werden müssen.
|
|
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) {
|
|
if (generate_qr_png(club_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');
|
|
}
|
|
}
|
|
|
|
// Aufruf-Seite: QR auf das interne Ziel (z. B. /veranstaltungen/sportfest-2026)
|
|
// aus dem chat-gepflegten `aufruf.link`-Feld. Anders als die geteilten CTA-QRs
|
|
// ist das Ziel ausgabenspezifisch, deshalb liegt der Code im Ausgaben-Ordner
|
|
// (qr-aufruf.png) — der wird bei jedem Lauf ohnehin neu befüllt. club_url()
|
|
// wie überall, damit der gedruckte Code auf die echte Domain zeigt.
|
|
$aufrufLink = trim((string) ($oldAufruf['link'] ?? ''));
|
|
if ($aufrufLink !== '') {
|
|
if (generate_qr_png(club_url($aufrufLink), "{$destDir}/qr-aufruf.png")) {
|
|
$log("QR-Code für die Aufruf-Seite (/{$aufrufLink}) erzeugt.");
|
|
} else {
|
|
$log('QR-Code für die Aufruf-Seite 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,
|
|
'aufruf' => $oldAufruf,
|
|
'veranstaltung' => $oldVeranstaltung,
|
|
'cover' => $oldCover,
|
|
'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";
|