"""Baut den Verweisindex hochgeladener Notizen und löst Wikilink-Ziele darüber auf. Ersteller: Pascal Michel Erstellt: 2026-08-05 Doku: docs/code/verweise.md · Version und Änderungen: CHANGELOG.md """ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping from pathlib import Path from bookstack.archiv import ARCHIV_ORDNERNAME from bookstack.auswahl import beschreibungsart from bookstack.notiz import lade_notiz def permalink(basis_url: str, seiten_id: int) -> str: """Stabiler Seitenlink. BookStack löst `/link/{id}` auf die Seite auf. Das überlebt Umbenennungen, anders als der Weg über Buch- und Seiten-Slug — und `GET /api/pages/{id}` liefert `book_slug` ohnehin nicht mit. """ return f"{basis_url.rstrip('/')}/link/{seiten_id}" def normalisiere_ziel(ziel: str) -> str: """Anker ab '#' weg, Tabellen-Escape (abschließender Backslash) weg, '\\' → '/', führende '/' weg, abschließendes '.md' weg (ohne Rücksicht auf Groß/klein), casefold, strip.""" text = ziel.split("#", 1)[0].strip() if text.endswith("\\"): text = text[:-1] text = text.replace("\\", "/").strip().lstrip("/") if text.casefold().endswith(".md"): text = text[:-3] return text.casefold().strip() def _stamm(schluessel: str) -> str: return schluessel.rsplit("/", 1)[-1] def _relativ(pfad: Path, basis: Path) -> Path | None: try: return pfad.resolve().relative_to(basis.resolve()) except ValueError: return None def schluessel_fuer(pfad: Path, quelle: Path, vault: Path | None = None) -> list[str]: """Indexschlüssel einer Notiz. Basis ist vault, wenn gesetzt und pfad darunter liegt, sonst quelle (Vergleich nach resolve()). Erster Schlüssel: pfad relativ zur Basis, normalisiert. Liegt pfad unter quelle/…/ARCHIV_ORDNERNAME/…, zweiter Schlüssel für denselben Pfad ohne das erste Vorkommen von ARCHIV_ORDNERNAME. Liegt pfad unter keiner Basis: [].""" relativ = _relativ(pfad, vault) if vault is not None else None if relativ is None: relativ = _relativ(pfad, quelle) if relativ is None: return [] schluessel = [normalisiere_ziel(relativ.as_posix())] in_quelle = _relativ(pfad, quelle) if in_quelle is not None and ARCHIV_ORDNERNAME in in_quelle.parts[:-1]: # Beide Pfade enden auf derselben Datei; gezählt wird von hinten, damit das # erste Vorkommen unterhalb der Quelle fällt, nicht ein gleichnamiger Ordner darüber. stelle = ( len(relativ.parts) - len(in_quelle.parts) + in_quelle.parts.index(ARCHIV_ORDNERNAME) ) if stelle >= 0: teile = relativ.parts[:stelle] + relativ.parts[stelle + 1 :] schluessel.append(normalisiere_ziel("/".join(teile))) return schluessel class Verweisindex(Mapping[str, str]): """Normalisiertes Wikilink-Ziel → Permalink, mit eindeutigem Rückfall über Pfadende oder Dateinamen.""" def __init__( self, eintraege: Mapping[str, str] | None = None, aliase: Iterable[str] = () ) -> None: self._eintraege: dict[str, str] = {} for schluessel, url in (eintraege or {}).items(): self._eintraege[normalisiere_ziel(schluessel)] = url # Archiv-Zusatzschlüssel: Nur sie darf ein echter Schlüssel einer anderen Notiz verdrängen. self._aliase: frozenset[str] = frozenset( eintrag for eintrag in map(normalisiere_ziel, aliase) if eintrag in self._eintraege ) self._staemme: dict[str, set[str]] = {} for schluessel, url in self._eintraege.items(): self._staemme.setdefault(_stamm(schluessel), set()).add(url) def __getitem__(self, schluessel: str) -> str: return self._eintraege[schluessel] def __iter__(self) -> Iterator[str]: return iter(self._eintraege) def __len__(self) -> int: return len(self._eintraege) def __repr__(self) -> str: return f"Verweisindex({self._eintraege!r})" def aufloesen(self, ziel: str) -> str | None: schluessel = normalisiere_ziel(ziel) if not schluessel: return None if schluessel in self._eintraege: return self._eintraege[schluessel] if "/" in schluessel: # Ein Ziel mit Pfad trifft nur ganze Pfadenden, nie den Dateinamen allein: Sonst landete # [[Y/_Kapitelbeschreibung]] still auf dem einzigen Kapitel, das der Index kennt. endung = "/" + schluessel urls = {url for eintrag, url in self._eintraege.items() if eintrag.endswith(endung)} else: # Nur eindeutig: _kapitelbeschreibung gibt es je Kapitel einmal, der Stamm allein darf dann nie treffen. urls = self._staemme.get(schluessel, set()) if len(urls) == 1: return next(iter(urls)) return None def ergaenzt(self, weitere: Mapping[str, str], aliase: Iterable[str] = ()) -> Verweisindex: """Neuer Index; bei gleichem Schlüssel gewinnt weitere. Ausnahme: Ein Schlüssel aus aliase verdrängt keinen echten Schlüssel, nur einen Alias.""" neue_aliase = {normalisiere_ziel(schluessel) for schluessel in aliase} zusammen = dict(self._eintraege) zusammen_aliase = set(self._aliase) for schluessel, url in weitere.items(): eintrag = normalisiere_ziel(schluessel) if eintrag not in neue_aliase: zusammen[eintrag] = url zusammen_aliase.discard(eintrag) elif eintrag not in zusammen or eintrag in zusammen_aliase: zusammen[eintrag] = url zusammen_aliase.add(eintrag) return Verweisindex(zusammen, zusammen_aliase) def trage_ein(eintraege: dict[str, str], aliase: set[str], schluessel: list[str], url: str) -> None: """Schlüssel einer Notiz aus schluessel_fuer: der erste echt, weitere als Alias. Ein echter Schlüssel überschreibt einen Alias; ein Alias belegt nur, was kein echter Schlüssel belegt.""" for stelle, eintrag in enumerate(schluessel): if stelle == 0: eintraege[eintrag] = url aliase.discard(eintrag) elif eintrag not in eintraege or eintrag in aliase: eintraege[eintrag] = url aliase.add(eintrag) def baue_index(quelle: Path, basis_url: str, vault: Path | None = None) -> Verweisindex: """rglob('*.md') sortiert. Überspringt Namen ab '.' oder '_', außer beschreibungsart(pfad) ist nicht None. Nimmt nur ganzzahlige bookstack_page_id. Jeder Schlüssel aus schluessel_fuer → permalink, eingetragen über trage_ein.""" if not quelle.is_dir(): return Verweisindex() eintraege: dict[str, str] = {} aliase: set[str] = set() for pfad in sorted(quelle.rglob("*.md")): if pfad.name.startswith((".", "_")) and beschreibungsart(pfad) is None: continue roh_id = lade_notiz(pfad).frontmatter.get("bookstack_page_id", "").strip() try: seiten_id = int(roh_id) except ValueError: continue # Nicht die Sortierung entscheidet: Eine archivierte Notiz verdrängt nie die neue am alten Pfad. schluessel = schluessel_fuer(pfad, quelle, vault) trage_ein(eintraege, aliase, schluessel, permalink(basis_url, seiten_id)) return Verweisindex(eintraege, aliase)