"""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