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

172 lines
7.3 KiB
Python

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