"""CLI: findet freigegebene Notizen und spielt sie nach BookStack.""" from __future__ import annotations import argparse import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path from bookstack.abgleich import Aktion, berechne_pruefsumme, entscheide from bookstack.api import ApiFehler, BookStackApi from bookstack.auswahl import finde_kandidaten, ist_freigegeben, titel_von from bookstack.bilder import Bilduploader from bookstack.konfiguration import KonfigurationsFehler, lade_konfiguration from bookstack.konvertierung import konvertiere, ohne_titelueberschrift from bookstack.notiz import Notiz, lade_notiz, schreibe_felder from bookstack.struktur import StrukturFehler, Strukturaufloeser from bookstack.verweise import baue_index @dataclass class Ergebnis: pfad: Path aktion: str text: str def _stelle_ausgabe_um() -> None: """Windows-Konsolen laufen oft unter cp1252 — „→" bricht den Lauf sonst ab. Gefunden beim Lauf mit echten Daten: pytest fängt stdout in UTF-8 ab, deshalb bleibt der Fehler in der Testsuite unsichtbar und schlägt erst beim Anwender zu. """ for strom in (sys.stdout, sys.stderr): umschalten = getattr(strom, "reconfigure", None) if umschalten is None: continue try: umschalten(encoding="utf-8", errors="replace") except (ValueError, OSError): pass def _argumente(argumente: list[str]) -> argparse.Namespace: zerleger = argparse.ArgumentParser( prog="bookstack.hochladen", description="Lädt freigegebene Vault-Notizen (#review-done) nach BookStack.", ) zerleger.add_argument("--anwenden", action="store_true", help="tatsächlich schreiben") zerleger.add_argument( "--struktur-anlegen", action="store_true", help="fehlende Regale/Bücher/Kapitel anlegen" ) zerleger.add_argument("--nur", type=Path, help="nur diese eine Datei verarbeiten") zerleger.add_argument("--baum", action="store_true", help="nur die BookStack-Struktur zeigen") return zerleger.parse_args(argumente) def verarbeite( notiz: Notiz, aufloeser: Strukturaufloeser, api: BookStackApi, *, anwenden: bool, verweise: dict[str, str], bilduploader: Bilduploader, ) -> Ergebnis: titel = titel_von(notiz) rumpf = ohne_titelueberschrift(notiz.rumpf, titel) umwandlung = konvertiere(rumpf, verweise=verweise) ziel = aufloeser.aufloesen( notiz.frontmatter.get("bookstack_shelf"), notiz.frontmatter.get("bookstack_book", ""), notiz.frontmatter.get("bookstack_chapter"), ) # Bewusst ueber den Pass-1-Markdown: Bild-URLs haengen an der Seiten-ID, # nicht am Inhalt, und wuerden sonst bei jedem Lauf ein Update ausloesen. pruefsumme = berechne_pruefsumme(titel, umwandlung.markdown, ziel.buch_id, ziel.kapitel_id) entscheidung = entscheide(notiz, pruefsumme) beschreibung = f"{titel} → Buch-ID {ziel.buch_id}" if ziel.kapitel_id: beschreibung += f", Kapitel-ID {ziel.kapitel_id}" # Die Bildhinweise aus Pass 1 sagen nur, dass zu diesem Zeitpunkt noch keine URL # vorlag — nicht, dass das Bild fehlschlägt. Was wirklich passiert ist, meldet # weiter unten der Bilduploader (bzw. im Probelauf „würde hochgeladen"). for offen in umwandlung.offene_verweise: beschreibung += f"\n Verweis nicht auflösbar: {offen}" for angelegt in ziel.angelegt: beschreibung += f"\n Angelegt: {angelegt}" if entscheidung.aktion is Aktion.UEBERSPRINGEN: return Ergebnis(notiz.pfad, "ÜBERSPRUNGEN", entscheidung.begruendung) if not anwenden: for name in umwandlung.bildnamen: beschreibung += f"\n Bild würde hochgeladen: {name}" return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung) def nutzlast(markdown: str) -> dict[str, object]: daten: dict[str, object] = {"name": titel, "markdown": markdown} if ziel.kapitel_id: daten["chapter_id"] = ziel.kapitel_id else: daten["book_id"] = ziel.buch_id return daten if entscheidung.aktion is Aktion.AKTUALISIEREN: try: antwort = api.anfrage( "PUT", f"/api/pages/{entscheidung.seiten_id}", nutzlast(umwandlung.markdown) ) except ApiFehler as fehler: if fehler.status != 404: raise # Seite wurde serverseitig gelöscht — neu anlegen statt scheitern. antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown)) beschreibung += "\n Hinweis: Seite war gelöscht, neu angelegt." else: antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown)) seiten_id = int(antwort["id"]) if umwandlung.bildnamen: tabelle, bildhinweise = bilduploader.lade_hoch( notiz.pfad, seiten_id, umwandlung.bildnamen ) for hinweis in bildhinweise: beschreibung += f"\n Hinweis: {hinweis}" if tabelle: zweiter = konvertiere(rumpf, verweise=verweise, bilder=tabelle) api.anfrage("PUT", f"/api/pages/{seiten_id}", nutzlast(zweiter.markdown)) beschreibung += f"\n Bilder übertragen: {len(tabelle)}" schreibe_felder( notiz.pfad, { "bookstack_page_id": str(seiten_id), "bookstack_hash": pruefsumme, "bookstack_synced": datetime.now().astimezone().isoformat(timespec="seconds"), }, ) return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung) def fuehre_aus(argumente: list[str]) -> int: _stelle_ausgabe_um() einstellungen = _argumente(argumente) try: konfiguration = lade_konfiguration() except KonfigurationsFehler as fehler: print(f"Konfigurationsfehler: {fehler}") return 1 api = BookStackApi( konfiguration.basis_url, konfiguration.token_id, konfiguration.token_secret ) aufloeser = Strukturaufloeser( api, anlegen_erlaubt=einstellungen.struktur_anlegen, nur_planen=not einstellungen.anwenden, ) bilduploader = Bilduploader(api, konfiguration.vault) if einstellungen.baum: try: print(aufloeser.baum()) except ApiFehler as fehler: print(f"Fehler: {fehler}") return 1 return 0 if einstellungen.nur: notiz = lade_notiz(einstellungen.nur) kandidaten = [notiz] if ist_freigegeben(notiz) else [] if not kandidaten: print(f"{einstellungen.nur} trägt kein #review-done — nichts zu tun.") return 0 else: kandidaten = finde_kandidaten(konfiguration.quelle) kopf = "BookStack-Upload" + ("" if einstellungen.anwenden else " (Probelauf)") print(f"{kopf} — Quelle: {konfiguration.quelle}") if not kandidaten: print("Keine Notiz mit #review-done gefunden.") return 0 verweise = baue_index(konfiguration.quelle, konfiguration.basis_url) ergebnisse: list[Ergebnis] = [] fehler_anzahl = 0 for notiz in kandidaten: try: ergebnisse.append( verarbeite( notiz, aufloeser, api, anwenden=einstellungen.anwenden, verweise=verweise, bilduploader=bilduploader, ) ) except (StrukturFehler, ApiFehler, OSError) as fehler: fehler_anzahl += 1 ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler))) for ergebnis in ergebnisse: try: anzeige = ergebnis.pfad.relative_to(konfiguration.quelle) except ValueError: anzeige = ergebnis.pfad print(f" {ergebnis.aktion:<14} {anzeige}\n {ergebnis.text}") zaehler: dict[str, int] = {} for ergebnis in ergebnisse: zaehler[ergebnis.aktion] = zaehler.get(ergebnis.aktion, 0) + 1 print("\n" + ", ".join(f"{anzahl}× {name}" for name, anzahl in sorted(zaehler.items()))) if not einstellungen.anwenden: print("Probelauf — nichts geschrieben. Mit --anwenden ausführen.") return 1 if fehler_anzahl else 0 def main() -> int: return fuehre_aus(sys.argv[1:]) if __name__ == "__main__": raise SystemExit(main())