[-v] * * Erwarteter Ordnerinhalt (beides optional, mindestens eines nötig): * /plakate/*.{jpg|jpeg|png|webp} Plakate, natsort = Reihenfolge. * Das ERSTE ist das Hauptplakat und * landet auf Karten und im OG-Bild. * /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 [-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"; }