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]>
This commit is contained in:
commit
fa1c26b407
137 files changed
+43926
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user