.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]>
172 lines
7.3 KiB
Python
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)
|