Files
claude-werkzeuge/.claude/skills/bookstack-upload/scripts/bookstack/hochladen.py
T
Pascal MichelandClaude Opus 5.5 fa1c26b407 Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:27:59 +02:00

452 lines
17 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.
"""Kommandozeile: lädt freigegebene Notizen und Beschreibungsdateien nach BookStack, zieht Verweise nach und archiviert.
Ersteller: Pascal Michel
Erstellt: 2026-08-05
Doku: docs/code/hochladen.md · Version und Änderungen: CHANGELOG.md
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime
from pathlib import Path
from bookstack.abgleich import Aktion, berechne_pruefsumme, entscheide
from bookstack.api import ApiFehler, BookStackApi
from bookstack.archiv import ARCHIV_ORDNERNAME, archiviere, ist_archiviert
from bookstack.auswahl import (
beschreibungsart,
finde_beschreibungen,
finde_kandidaten,
ist_freigegeben,
titel_von,
)
from bookstack.beschreibung import (
UEBERSICHTSTITEL,
BeschreibungsFehler,
beschreibungs_pruefsumme,
pruefe_beschreibung_html,
pruefe_ziel_felder,
setze_beschreibung,
vorspann,
zu_beschreibungs_html,
)
from bookstack.bilder import Bilduploader
from bookstack.ergebnis import (
ARCHIVIERBAR,
Beschreibungsstand,
Ergebnis,
Seitenstand,
seiten_nutzlast,
)
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 Verweisindex, baue_index
from bookstack.verweisrunde import (
baue_endindex,
fuehre_verweisrunde,
haenge_offene_an,
plane_endindex,
)
# Fehler je Datei in Durchgang 1: Sie werden zur FEHLER-Zeile, der Lauf geht weiter.
DATEIFEHLER = (StrukturFehler, ApiFehler, OSError, BeschreibungsFehler)
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 _jetzt() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _anzeige(pfad: Path, quelle: Path) -> Path:
"""Pfad für den Bericht: relativ zur Quelle, sonst wie er ist."""
try:
return pfad.relative_to(quelle)
except ValueError:
return pfad
def _mit_art(notizen: list[Notiz]) -> list[tuple[Notiz, str]]:
"""Beschreibungsdateien samt Art ('buch' oder 'kapitel'); andere Notizen fallen heraus."""
paare: list[tuple[Notiz, str]] = []
for notiz in notizen:
art = beschreibungsart(notiz.pfad)
if art is not None:
paare.append((notiz, art))
return paare
def verarbeite(
notiz: Notiz,
aufloeser: Strukturaufloeser,
api: BookStackApi,
*,
anwenden: bool,
verweise: Verweisindex,
bilduploader: Bilduploader,
titel: str | None = None,
prioritaet: int | None = None,
) -> Ergebnis:
"""Durchgang 1 für eine Seite: anlegen, aktualisieren oder überspringen.
Das Ergebnis trägt bei jeder dieser Aktionen einen Seitenstand, auch im Probelauf.
Offene Verweise hängt erst haenge_offene_an an, nach der Verweisrunde.
"""
seitentitel = titel_von(notiz) if titel is None else titel
rumpf = ohne_titelueberschrift(notiz.rumpf, seitentitel)
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(
seitentitel, umwandlung.markdown, ziel.buch_id, ziel.kapitel_id
)
entscheidung = entscheide(notiz, pruefsumme)
def stand(seiten_id: int | None, bilder: dict[str, str] | None) -> Seitenstand:
return Seitenstand(
titel=seitentitel,
rumpf=rumpf,
ziel=ziel,
seiten_id=seiten_id,
prioritaet=prioritaet,
markdown=umwandlung.markdown,
aufgeloeste_verweise=umwandlung.aufgeloeste_verweise,
offene_verweise=umwandlung.offene_verweise,
bildnamen=umwandlung.bildnamen,
bilder=bilder,
)
text = f"{seitentitel} → Buch-ID {ziel.buch_id}"
# is not None: Die Platzhalter-ID 0 eines geplanten Kapitels erscheint im Probelauf.
if ziel.kapitel_id is not None:
text += f", Kapitel-ID {ziel.kapitel_id}"
for angelegt in ziel.angelegt:
text += f"\n Angelegt: {angelegt}"
if entscheidung.aktion is Aktion.UEBERSPRINGEN:
return Ergebnis(
notiz.pfad,
"ÜBERSPRUNGEN",
entscheidung.begruendung,
seite=stand(entscheidung.seiten_id, None),
)
if not anwenden:
# Die Bildhinweise aus Pass 1 sagen nur, dass noch keine URL vorlag; im Probelauf
# steht deshalb „würde hochgeladen“, was wirklich passiert, meldet der Bilduploader.
for name in umwandlung.bildnamen:
text += f"\n Bild würde hochgeladen: {name}"
return Ergebnis(
notiz.pfad,
entscheidung.aktion.name,
text,
seite=stand(entscheidung.seiten_id, None),
)
def nutzlast(markdown: str) -> dict[str, object]:
return seiten_nutzlast(seitentitel, ziel, markdown, prioritaet)
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))
text += "\n Hinweis: Seite war gelöscht, neu angelegt."
else:
antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown))
seiten_id = int(antwort["id"])
# Die ID steht sofort im Frontmatter: Scheitert danach der Bildteil, aktualisiert der nächste
# Lauf diese Seite, statt sie ein zweites Mal anzulegen. Der Hash folgt erst am Ende, damit
# der nächste Lauf eine halb fertige Seite nicht für fertig hält.
schreibe_felder(notiz.pfad, {"bookstack_page_id": str(seiten_id)})
# Die Tabelle geht in den Seitenstand; die Verweisrunde sendet nie ohne sie.
bilder: dict[str, str] = {}
if umwandlung.bildnamen:
bilder, bildhinweise = bilduploader.lade_hoch(
notiz.pfad, seiten_id, umwandlung.bildnamen
)
for hinweis in bildhinweise:
text += f"\n Hinweis: {hinweis}"
if bilder:
zweiter = konvertiere(rumpf, verweise=verweise, bilder=bilder)
api.anfrage("PUT", f"/api/pages/{seiten_id}", nutzlast(zweiter.markdown))
text += f"\n Bilder übertragen: {len(bilder)}"
schreibe_felder(
notiz.pfad,
{
"bookstack_page_id": str(seiten_id),
"bookstack_hash": pruefsumme,
"bookstack_synced": _jetzt(),
},
)
return Ergebnis(notiz.pfad, entscheidung.aktion.name, text, seite=stand(seiten_id, bilder))
def verarbeite_beschreibung(
notiz: Notiz,
art: str,
aufloeser: Strukturaufloeser,
api: BookStackApi,
*,
anwenden: bool,
verweise: Verweisindex,
bilduploader: Bilduploader,
) -> list[Ergebnis]:
"""Durchgang 1 für eine Beschreibungsdatei: erst die Übersichtsseite, dann die Beschreibung.
Fehler in pruefe_ziel_felder und im Seitenteil gehen an fuehre_aus. Fehler im
Beschreibungsteil werden hier zur FEHLER-Zeile; die Seitenzeile bleibt stehen.
"""
pruefe_ziel_felder(art, notiz.frontmatter)
seitenzeile = verarbeite(
notiz,
aufloeser,
api,
anwenden=anwenden,
verweise=verweise,
bilduploader=bilduploader,
titel=UEBERSICHTSTITEL[art],
prioritaet=0,
)
ergebnisse = [seitenzeile]
# verarbeite liefert bei ANLEGEN, AKTUALISIEREN und ÜBERSPRUNGEN immer einen Seitenstand.
assert seitenzeile.seite is not None
ziel = seitenzeile.seite.ziel
ziel_id = ziel.buch_id if art == "buch" else ziel.kapitel_id
kennung = f"Buch-ID {ziel.buch_id}"
if art != "buch":
kennung += f", Kapitel-ID {ziel.kapitel_id}"
try:
if ziel_id is None:
raise BeschreibungsFehler("bookstack_chapter fehlt in der Kapitelbeschreibung")
text = vorspann(notiz.rumpf)
umgewandelt = zu_beschreibungs_html(text, verweise)
pruefe_beschreibung_html(umgewandelt.html)
pruefsumme = beschreibungs_pruefsumme(ziel_id, umgewandelt.html)
stand = Beschreibungsstand(
art=art,
ziel_id=ziel_id,
vorspann=text,
html=umgewandelt.html,
aufgeloeste_verweise=umgewandelt.aufgeloeste_verweise,
)
# unverändert gilt auch im Probelauf; gesendet wird nur mit --anwenden und bei Abweichung.
if notiz.frontmatter.get("bookstack_beschreibung_hash", "").strip() == pruefsumme:
meldung = f"unverändert → {kennung}"
elif not anwenden:
meldung = f"würde gesetzt → {kennung}"
else:
setze_beschreibung(api, art, ziel_id, umgewandelt.html)
schreibe_felder(notiz.pfad, {"bookstack_beschreibung_hash": pruefsumme})
meldung = f"gesetzt → {kennung}"
except (BeschreibungsFehler, ApiFehler, OSError) as fehler:
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
return ergebnisse
ergebnisse.append(Ergebnis(notiz.pfad, "BESCHREIBUNG", meldung, beschreibung=stand))
return ergebnisse
def _archiviere(gruppen: dict[Path, list[Ergebnis]], quelle: Path, *, anwenden: bool) -> int:
"""Verschiebt je Pfad einmal ins Archiv; Rückgabe: Zahl der ARCHIV-FEHLER.
Archiviert wird ein Pfad mit einer Zeile aus ARCHIVIERBAR und ohne FEHLER-Zeile,
also nur, wenn Seite, Beschreibung und Verweisrunde gelungen sind.
"""
archiviert = 0
fehler_anzahl = 0
for pfad, zeilen in gruppen.items():
aktionen = {ergebnis.aktion for ergebnis in zeilen}
if "FEHLER" in aktionen or aktionen.isdisjoint(ARCHIVIERBAR):
continue
try:
relativ = pfad.relative_to(quelle)
except ValueError:
continue # z. B. --nur mit einem Pfad außerhalb der Quelle — nicht archivieren.
if ist_archiviert(pfad, quelle):
continue
if not anwenden:
print(f" würde archiviert: {relativ}")
continue
try:
neu = archiviere(pfad, quelle)
except OSError as fehler:
# Archivkollision oder gesperrte Datei: Die Notiz bleibt liegen, der Lauf geht weiter.
fehler_anzahl += 1
print(f" ARCHIV-FEHLER {relativ}\n {fehler}")
continue
archiviert += 1
print(f" ARCHIVIERT {relativ} → {neu.relative_to(quelle)}")
if archiviert:
print(f"{archiviert}× ins Archiv verschoben ({ARCHIV_ORDNERNAME})")
return fehler_anzahl
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)
quelle = konfiguration.quelle
anwenden = einstellungen.anwenden
if einstellungen.baum:
try:
print(aufloeser.baum())
except ApiFehler as fehler:
print(f"Fehler: {fehler}")
return 1
return 0
# Auswahl: --nur ordnet die eine Datei über ihren Namen als Beschreibung oder Seite ein.
if einstellungen.nur:
notiz = lade_notiz(einstellungen.nur)
if not ist_freigegeben(notiz):
print(f"{einstellungen.nur} trägt kein #review-done — nichts zu tun.")
return 0
beschreibungen = _mit_art([notiz])
kandidaten = [] if beschreibungen else [notiz]
else:
beschreibungen = _mit_art(finde_beschreibungen(quelle))
kandidaten = finde_kandidaten(quelle)
kopf = "BookStack-Upload" + ("" if anwenden else " (Probelauf)")
print(f"{kopf} — Quelle: {quelle}")
if not beschreibungen and not kandidaten:
print("Keine Notiz mit #review-done gefunden.")
return 0
verweise = baue_index(quelle, konfiguration.basis_url, konfiguration.vault)
# Durchgang 1: erst Beschreibungsdateien (Bücher vor Kapiteln), dann Seiten; der Index bleibt fest.
ergebnisse: list[Ergebnis] = []
for notiz, art in beschreibungen:
try:
ergebnisse.extend(
verarbeite_beschreibung(
notiz,
art,
aufloeser,
api,
anwenden=anwenden,
verweise=verweise,
bilduploader=bilduploader,
)
)
except DATEIFEHLER as fehler:
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
for notiz in kandidaten:
try:
ergebnisse.append(
verarbeite(
notiz,
aufloeser,
api,
anwenden=anwenden,
verweise=verweise,
bilduploader=bilduploader,
)
)
except DATEIFEHLER as fehler:
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
# Verweisrunde nur mit --anwenden; der Probelauf plant den Endindex nur und sendet nichts.
if anwenden:
endindex = baue_endindex(
verweise,
ergebnisse,
quelle=quelle,
vault=konfiguration.vault,
basis_url=konfiguration.basis_url,
)
ergebnisse += fuehre_verweisrunde(ergebnisse, endindex, api, bilduploader)
else:
endindex = plane_endindex(verweise, ergebnisse, quelle=quelle, vault=konfiguration.vault)
aufgeloest = haenge_offene_an(ergebnisse, endindex)
# Bericht nach Pfad gruppiert; Zeilen der Verweisrunde stehen unter den Zeilen ihrer Datei.
gruppen: dict[Path, list[Ergebnis]] = {}
for ergebnis in ergebnisse:
gruppen.setdefault(ergebnis.pfad, []).append(ergebnis)
for pfad, zeilen in gruppen.items():
anzeige = _anzeige(pfad, quelle)
for ergebnis in zeilen:
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 anwenden and aufgeloest:
print(f"{aufgeloest}× Verweis wird in der Verweisrunde aufgelöst")
archiv_fehler = _archiviere(gruppen, quelle, anwenden=anwenden)
if not anwenden:
print("Probelauf — nichts geschrieben. Mit --anwenden ausführen.")
return 1 if zaehler.get("FEHLER", 0) or archiv_fehler else 0
def main() -> int:
return fuehre_aus(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())