Files
claude-regelwerk/.claude/skills/bookstack-upload/scripts/bookstack/hochladen.py
T
Pascal MichelandClaude Opus 5 c064c729ed Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 22:38:15 +02:00

229 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())