Files
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

207 lines
8.0 KiB
Python

"""Zieht nach Durchgang 1 die Verweise auf Seiten desselben Laufs nach und meldet, was offen bleibt.
Ersteller: Pascal Michel
Erstellt: 2026-09-30
Doku: docs/code/verweisrunde.md · Version und Änderungen: CHANGELOG.md
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from bookstack.abgleich import berechne_pruefsumme
from bookstack.api import ApiFehler, BookStackApi
from bookstack.beschreibung import (
BeschreibungsFehler,
beschreibungs_pruefsumme,
pruefe_beschreibung_html,
setze_beschreibung,
zu_beschreibungs_html,
)
from bookstack.bilder import Bilduploader
from bookstack.ergebnis import (
ARCHIVIERBAR,
Beschreibungsstand,
Ergebnis,
Seitenstand,
seiten_nutzlast,
)
from bookstack.konvertierung import konvertiere
from bookstack.notiz import schreibe_felder
from bookstack.verweise import Verweisindex, permalink, schluessel_fuer, trage_ein
PLATZHALTER_PRAEFIX = "geplant:"
def _jetzt() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _neu_oder_geaendert(vorher: dict[str, str], nachher: dict[str, str]) -> int:
"""Zahl der Verweisziele, deren URL neu dazukam oder sich geändert hat."""
return sum(1 for ziel, url in nachher.items() if vorher.get(ziel) != url)
def baue_endindex(
verweise: Verweisindex, ergebnisse: list[Ergebnis], *, quelle: Path, vault: Path, basis_url: str
) -> Verweisindex:
"""verweise.ergaenzt(...) um jede Zeile mit Aktion aus ARCHIVIERBAR und seite.seiten_id
nicht None: alle schluessel_fuer(pfad) → permalink(basis_url, seiten_id)."""
weitere: dict[str, str] = {}
aliase: set[str] = set()
for ergebnis in ergebnisse:
seite = ergebnis.seite
if ergebnis.aktion not in ARCHIVIERBAR or seite is None or seite.seiten_id is None:
continue
# Über trage_ein, nicht per Zuweisung: Sonst entschiede die Reihenfolge der Zeilen, ob der
# Alias einer archivierten Notiz die neue Notiz am alten Pfad verdrängt.
trage_ein(
weitere,
aliase,
schluessel_fuer(ergebnis.pfad, quelle, vault),
permalink(basis_url, seite.seiten_id),
)
return verweise.ergaenzt(weitere, aliase)
def plane_endindex(
verweise: Verweisindex, ergebnisse: list[Ergebnis], *, quelle: Path, vault: Path
) -> Verweisindex:
"""Probelauf: jede Zeile mit Aktion 'ANLEGEN' und seite → PLATZHALTER_PRAEFIX + pfad.as_posix()."""
weitere: dict[str, str] = {}
aliase: set[str] = set()
for ergebnis in ergebnisse:
if ergebnis.aktion != "ANLEGEN" or ergebnis.seite is None:
continue
trage_ein(
weitere,
aliase,
schluessel_fuer(ergebnis.pfad, quelle, vault),
PLATZHALTER_PRAEFIX + ergebnis.pfad.as_posix(),
)
return verweise.ergaenzt(weitere, aliase)
def _bildtabelle(
seite: Seitenstand, seiten_id: int, bilduploader: Bilduploader
) -> tuple[dict[str, str], list[str]]:
"""(Tabelle für den PUT, fehlende Bildnamen). Lädt nie hoch, liest höchstens die Galerie."""
if seite.bilder is not None:
# Tabelle aus Durchgang 1; deckt sie nicht alle Bildnamen ab, steht das unter fehlend.
tabelle = seite.bilder
elif not seite.bildnamen:
return {}, []
else:
# Durchgang 1 hat nichts gesendet (ÜBERSPRUNGEN): Die Tabelle kommt lesend aus der Galerie.
vorhanden = bilduploader.vorhandene(seiten_id)
tabelle = {name: vorhanden[name] for name in seite.bildnamen if vorhanden.get(name)}
fehlend = [name for name in dict.fromkeys(seite.bildnamen) if name not in tabelle]
return tabelle, fehlend
def _ziehe_seite_nach(
pfad: Path,
seite: Seitenstand,
seiten_id: int,
endindex: Verweisindex,
api: BookStackApi,
bilduploader: Bilduploader,
) -> Ergebnis | None:
neu = konvertiere(seite.rumpf, verweise=endindex)
if neu.markdown == seite.markdown:
return None
tabelle, fehlend = _bildtabelle(seite, seiten_id, bilduploader)
# Nie ein PUT mit Platzhaltertext: Fehlt ein Bildname in der Tabelle, gibt es nur die Zeile.
if fehlend:
namen = ", ".join(fehlend)
return Ergebnis(
pfad,
"FEHLER",
f"Verweise nicht nachgezogen: Bild fehlt in der Galerie der Seite ({namen}). "
"Der nächste Lauf aktualisiert die Seite.",
)
markdown = neu.markdown
if seite.bildnamen:
markdown = konvertiere(seite.rumpf, verweise=endindex, bilder=tabelle).markdown
api.anfrage(
"PUT",
f"/api/pages/{seiten_id}",
seiten_nutzlast(seite.titel, seite.ziel, markdown, seite.prioritaet),
)
# Gespeichert wird die Summe des bildfreien Markdowns mit den Verweisen dieses PUT.
pruefsumme = berechne_pruefsumme(
seite.titel, neu.markdown, seite.ziel.buch_id, seite.ziel.kapitel_id
)
schreibe_felder(pfad, {"bookstack_hash": pruefsumme, "bookstack_synced": _jetzt()})
anzahl = _neu_oder_geaendert(seite.aufgeloeste_verweise, neu.aufgeloeste_verweise)
return Ergebnis(pfad, "VERLINKT", f"{anzahl} Verweise nachgezogen")
def _ziehe_beschreibung_nach(
pfad: Path, stand: Beschreibungsstand, endindex: Verweisindex, api: BookStackApi
) -> Ergebnis | None:
neu = zu_beschreibungs_html(stand.vorspann, endindex)
if neu.html == stand.html:
return None
pruefe_beschreibung_html(neu.html)
setze_beschreibung(api, stand.art, stand.ziel_id, neu.html)
schreibe_felder(
pfad, {"bookstack_beschreibung_hash": beschreibungs_pruefsumme(stand.ziel_id, neu.html)}
)
anzahl = _neu_oder_geaendert(stand.aufgeloeste_verweise, neu.aufgeloeste_verweise)
return Ergebnis(pfad, "VERLINKT", f"{anzahl} Verweise in der Beschreibung nachgezogen")
def fuehre_verweisrunde(
ergebnisse: list[Ergebnis],
endindex: Verweisindex,
api: BookStackApi,
bilduploader: Bilduploader,
) -> list[Ergebnis]:
"""Zweiter Durchgang, nur mit --anwenden. Liefert neue VERLINKT- und FEHLER-Zeilen."""
neue: list[Ergebnis] = []
for ergebnis in ergebnisse:
seite = ergebnis.seite
beschreibung = ergebnis.beschreibung
try:
if (
seite is not None
and seite.seiten_id is not None
and ergebnis.aktion in ARCHIVIERBAR
):
zeile = _ziehe_seite_nach(
ergebnis.pfad, seite, seite.seiten_id, endindex, api, bilduploader
)
elif beschreibung is not None and ergebnis.aktion == "BESCHREIBUNG":
zeile = _ziehe_beschreibung_nach(ergebnis.pfad, beschreibung, endindex, api)
else:
continue
except (ApiFehler, OSError, BeschreibungsFehler) as fehler:
zeile = Ergebnis(ergebnis.pfad, "FEHLER", str(fehler))
if zeile is not None:
neue.append(zeile)
return neue
def haenge_offene_an(ergebnisse: list[Ergebnis], endindex: Verweisindex) -> int:
"""Hängt an jede Zeile mit seite „Verweis nicht auflösbar“ für zwei Gruppen von Zielen an,
soweit endindex sie nicht auflöst: die offenen Verweise aus Durchgang 1 (seite.offene_verweise)
und die Ziele aus seite.aufgeloeste_verweise. Rückgabe: Zahl der offenen Verweise, die erst
der Endindex auflöst; die Ziele aus aufgeloeste_verweise werden gemeldet, nicht gezählt."""
aufgeloest = 0
for ergebnis in ergebnisse:
if ergebnis.seite is None:
continue
for ziel in ergebnis.seite.offene_verweise:
if endindex.aufloesen(ziel) is None:
ergebnis.text += f"\n Verweis nicht auflösbar: {ziel}"
else:
aufgeloest += 1
# Ein Stamm, der in Durchgang 1 eindeutig war, kann im Endindex mehrdeutig werden;
# der PUT der Runde entfernt den Link dann, und das darf nicht still passieren.
for ziel in ergebnis.seite.aufgeloeste_verweise:
if endindex.aufloesen(ziel) is None:
ergebnis.text += f"\n Verweis nicht auflösbar: {ziel}"
return aufgeloest