"""Findet Bilder im Vault und lädt sie in die BookStack-Bildergalerie. Ersteller: Pascal Michel Erstellt: 2026-08-05 Doku: docs/code/bilder.md · Version und Änderungen: CHANGELOG.md """ from __future__ import annotations import os from pathlib import Path from bookstack.api import ApiFehler, BookStackApi # In D:\Claude\skills\ liegt ein grosses Fremd-Repo — laut CLAUDE.md haengt sich # ein rekursiver Lauf dort praktisch endlos auf. Diese Ordner werden uebersprungen. UEBERSPRINGEN = {"skills", ".git", ".venv", "node_modules", ".obsidian", "__pycache__", ".pytest_cache"} TYPEN = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", } # Die API-Doku nennt „max 50KB"; das ist mit hoher Wahrscheinlichkeit die # Laravel-Regel max:50000, die bei Dateien in Kilobyte rechnet (~48,8 MB). # Deshalb hier eine Grenze knapp darunter — die echte Ablehnung der API wird # ohnehin woertlich weitergereicht. MAX_BYTES = 48 * 1024 * 1024 class BildFehler(Exception): """Ein Bild konnte nicht übertragen werden.""" def finde_bilddatei(vault: Path, notiz_pfad: Path, name: str) -> Path | None: """Sucht erst neben der Notiz, dann in der Vault-Wurzel, dann im ganzen Vault.""" for kandidat in (notiz_pfad.parent / name, vault / name): if kandidat.is_file(): return kandidat for wurzel, ordner, dateien in os.walk(vault): ordner[:] = [eintrag for eintrag in ordner if eintrag not in UEBERSPRINGEN] if name in dateien: return Path(wurzel) / name return None class Bilduploader: def __init__(self, api: BookStackApi, vault: Path) -> None: self._api = api self._vault = vault def _lies_galerie(self, seiten_id: int) -> dict[str, str]: """Bereits an dieser Seite hängende Galeriebilder, Name → URL; ApiFehler geht durch.""" eintraege = self._api.liste(f"/api/image-gallery?filter[uploaded_to]={seiten_id}") return {str(eintrag.get("name", "")): str(eintrag.get("url", "")) for eintrag in eintraege} def _vorhandene(self, seiten_id: int) -> dict[str, str]: """Wie _lies_galerie, aber ein ApiFehler ergibt {}; nur für lade_hoch.""" try: return self._lies_galerie(seiten_id) except ApiFehler: return {} def vorhandene(self, seiten_id: int) -> dict[str, str]: """Galeriebilder der Seite, Name → URL; nur lesend, für die Verweisrunde. Ein ApiFehler geht durch: Ein leeres Ergebnis sähe dort aus wie ein fehlendes Bild. """ return self._lies_galerie(seiten_id) def lade_hoch( self, notiz_pfad: Path, seiten_id: int, namen: list[str] ) -> tuple[dict[str, str], list[str]]: """Liefert (Name → URL) und eine Liste von Hinweisen für den Bericht.""" tabelle: dict[str, str] = {} hinweise: list[str] = [] if not namen: return tabelle, hinweise # Ein Lesefehler der Galerie ergibt hier {}, gewollt: Das Bild wird neu hochgeladen, statt den # Upload scheitern zu lassen, und kostet höchstens ein Doppelbild. Die Verweisrunde liest über # vorhandene, dort geht der Fehler durch. vorhanden = self._vorhandene(seiten_id) for name in dict.fromkeys(namen): if name in vorhanden and vorhanden[name]: tabelle[name] = vorhanden[name] continue pfad = finde_bilddatei(self._vault, notiz_pfad, name) if pfad is None: hinweise.append(f"Bild nicht gefunden im Vault: {name}") continue mime = TYPEN.get(pfad.suffix.lower()) if mime is None: hinweise.append(f"Bildtyp nicht unterstützt: {name}") continue groesse = pfad.stat().st_size if groesse > MAX_BYTES: hinweise.append(f"Bild zu groß ({groesse // (1024 * 1024)} MB): {name}") continue try: antwort = self._api.sende_formular( "/api/image-gallery", {"type": "gallery", "uploaded_to": str(seiten_id), "name": name}, ("image", name, pfad.read_bytes(), mime), ) except ApiFehler as fehler: hinweise.append(f"Bild abgelehnt ({name}): {fehler.meldung}") continue url = str(antwort.get("url", "")) if url: tabelle[name] = url else: hinweise.append(f"Bild ohne URL zurückgekommen: {name}") return tabelle, hinweise