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]>
This commit is contained in:
commit
fa1c26b407
137 files changed
+43926
No files matched your search
@@ -0,0 +1,451 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user