- .claude/agents/planer.md - .claude/agents/quorum-stimme.md - .claude/skills/bookstack-upload/SKILL.md - .claude/skills/bookstack-upload/scripts/bookstack/archiv.py - .claude/skills/bookstack-upload/scripts/bookstack/auswahl.py - .claude/skills/bookstack-upload/scripts/bookstack/beschreibung.py - .claude/skills/bookstack-upload/scripts/bookstack/bilder.py - .claude/skills/bookstack-upload/scripts/bookstack/ergebnis.py - .claude/skills/bookstack-upload/scripts/bookstack/hochladen.py - .claude/skills/bookstack-upload/scripts/bookstack/konvertierung.py - .claude/skills/bookstack-upload/scripts/bookstack/struktur.py - .claude/skills/bookstack-upload/scripts/bookstack/verweise.py - … und 33 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""Zählt die Waisen im Obsidian-Graphen des Vaults, ohne die Ausnahmen.
|
|
|
|
Ersteller: Pascal Michel
|
|
Erstellt: 2026-09-29
|
|
Entwurf: ENTWURF-waisen.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
|
|
|
|
GELTUNGSBEREICH
|
|
Eine Waise ist eine Notiz ohne Kante: keine ausgehende Verknüpfung auf eine
|
|
andere Notiz und keine eingehende. Notiz ist jede Datei mit Endung .md
|
|
außerhalb von Punktordnern, die selbst keine Punktdatei ist und die kein
|
|
Eintrag aus userIgnoreFilters in .obsidian/app.json ausblendet. Kanten
|
|
entstehen aus Wikilinks und Einbettungen im Text und im Frontmatter und aus
|
|
Markdown-Links im Text, sofern das Ziel eine Notiz ist. Code (Zäune und
|
|
Code-Spans) blendet strip_code() aus linkcheck.py aus. Notizen, auf die ein
|
|
Muster aus waisen-ausnahmen.txt passt, zählen nicht als Waisen, bleiben aber
|
|
Knoten und liefern Kanten.
|
|
|
|
BENANNTE LÜCKEN
|
|
* Die Auflösung eines Linkziels folgt getLinkpathDest aus Obsidian 1.13.7,
|
|
nachgebaut, nicht abgeschrieben. Abweichungen: Das Namensverzeichnis kennt
|
|
nur Notizen, die Knoten sind; Obsidian führt dort auch Anhänge und
|
|
ausgeblendete Dateien. Bei gleich langen Kandidaten entscheidet hier die
|
|
Reihenfolge des Durchlaufs (alphabetisch je Ordner), in Obsidian die
|
|
Reihenfolge seines Verzeichnisses. Ob Obsidian Backslashes im Linkziel vor
|
|
der Auflösung umwandelt, ist nicht belegt; hier bleiben sie stehen.
|
|
* Dass Obsidian relative und URL-kodierte Markdown-Links, Markdown-Links
|
|
ohne .md und Wikilinks im Frontmatter im Graphen zählt, ist nicht aus einer
|
|
Quelle belegt, sondern in der App zu prüfen (Plan 2026-09-29-waisen,
|
|
Prüfschritte P1 bis P3).
|
|
* userIgnoreFilters folgen updateUserIgnoreFilters und isUserIgnored aus
|
|
Obsidian 1.13.7, werden aber mit Python-re ausgewertet, nicht mit
|
|
JavaScript. Ein Ausdruck, den nur einer der beiden Dialekte versteht,
|
|
wirkt verschieden. Ein Ordner wird übersprungen, wenn ein Filter auf seinen
|
|
Pfad mit abschließendem Schrägstrich passt. Für einen Ausdruck mit
|
|
Endanker ist das nicht dasselbe wie die Prüfung je Datei.
|
|
* Strenger als Obsidian: Ist userIgnoreFilters null oder keine Liste oder
|
|
ein Eintrag kein Text, bricht das Skript ab. Obsidian nimmt null als
|
|
keinen Filter.
|
|
* Nicht als Link erkannt: Referenz-Links ([Text][Marke]), HTML-Links, Ziele
|
|
mit Klammern, Windows-Pfade mit Laufwerk.
|
|
* Kommentare (%% und <!-- -->) werden nicht ausgeblendet.
|
|
* Codeblöcke erkennt strip_code() mit dessen Lücken, siehe
|
|
ENTWURF-codeblock-erkennung.md.
|
|
|
|
DIESES SKRIPT IST EIN FANGNETZ, KEIN NACHWEIS. Wie der Graph aussieht, zeigt
|
|
nur Obsidian selbst.
|
|
|
|
Exit 0: keine Waise. Exit 1: mindestens eine Waise. Exit 2: abgebrochen, es
|
|
gilt keine Zahl; die Ursache steht in der Ausgabe.
|
|
|
|
Aufruf:
|
|
python .vault-tools/linkcheck/waisen.py --vault .
|
|
python .vault-tools/linkcheck/waisen.py --vault . --liste
|
|
python .vault-tools/linkcheck/waisen.py --vault . --zahl
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import traceback
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from urllib.parse import unquote
|
|
|
|
HIER = Path(__file__).resolve().parent
|
|
AUSNAHMEN = HIER / "waisen-ausnahmen.txt"
|
|
ZEILE = "Waisen außerhalb der Ausnahmen: "
|
|
|
|
# Wie _WIKILINK_RE in vaultlib.py Zeile 61: das Ziel endet vor |, #, ^ und ].
|
|
_WIKILINK = re.compile(r"\[\[([^\]\|#\^]+)")
|
|
# ](ziel) oder ](<ziel mit leerzeichen>), optional mit Titel in Anfuehrungszeichen.
|
|
_MDLINK = re.compile(r"\]\(\s*(<[^>\n]*>|[^\s)]+)(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'))?\s*\)")
|
|
# https:, mailto:, obsidian:, aber auch ein Laufwerk wie D:
|
|
_SCHEMA = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:")
|
|
|
|
|
|
class Abbruch(Exception):
|
|
"""Eine Voraussetzung fehlt oder ist kaputt; eine Zahl gälte nicht."""
|
|
|
|
|
|
@dataclass
|
|
class Muster:
|
|
text: str
|
|
regex: re.Pattern
|
|
|
|
|
|
@dataclass
|
|
class Index:
|
|
# Dateiname mit Endung, klein geschrieben -> Notizen in der Reihenfolge des Durchlaufs.
|
|
namen: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class Ergebnis:
|
|
notizen: list
|
|
waisen: list
|
|
ausgenommen: list
|
|
ausgehend: dict
|
|
muster: list
|
|
ohne_treffer: list
|
|
filter_anzahl: int
|
|
filter_uebersprungen: list
|
|
|
|
|
|
def lade_strip_code():
|
|
if str(HIER) not in sys.path:
|
|
sys.path.insert(0, str(HIER))
|
|
try:
|
|
from linkcheck import strip_code
|
|
except Exception as fehler:
|
|
raise Abbruch(
|
|
f"linkcheck.py nicht importierbar unter {HIER} ({type(fehler).__name__}: {fehler})"
|
|
) from fehler
|
|
return strip_code
|
|
|
|
|
|
def glob_regex(muster: str) -> re.Pattern:
|
|
teile = []
|
|
i = 0
|
|
while i < len(muster):
|
|
if muster.startswith("**/", i):
|
|
teile.append("(?:[^/]*/)*")
|
|
i += 3
|
|
elif muster.startswith("**", i):
|
|
teile.append(".*")
|
|
i += 2
|
|
elif muster[i] == "*":
|
|
teile.append("[^/]*")
|
|
i += 1
|
|
elif muster[i] == "?":
|
|
teile.append("[^/]")
|
|
i += 1
|
|
else:
|
|
teile.append(re.escape(muster[i]))
|
|
i += 1
|
|
return re.compile("".join(teile) + r"\Z", re.IGNORECASE | re.DOTALL)
|
|
|
|
|
|
def lade_ausnahmen(datei: Path) -> list:
|
|
if not datei.is_file():
|
|
raise Abbruch(f"Ausnahmedatei fehlt: {datei}")
|
|
try:
|
|
text = datei.read_text(encoding="utf-8-sig")
|
|
except (OSError, UnicodeDecodeError) as fehler:
|
|
raise Abbruch(f"Ausnahmedatei nicht lesbar: {datei} ({fehler})") from fehler
|
|
muster = []
|
|
gesehen = set()
|
|
# split statt splitlines: splitlines trennt auch an Unicode-Zeilentrennern.
|
|
for nummer, roh in enumerate(text.split("\n"), 1):
|
|
zeile = roh.strip()
|
|
if not zeile or zeile.startswith("#"):
|
|
continue
|
|
teile = zeile.split("/")
|
|
if ("\\" in zeile or zeile.startswith("/") or ":" in zeile
|
|
or ".." in teile or "." in teile or "" in teile[:-1]):
|
|
raise Abbruch(
|
|
f"Ausnahmedatei Zeile {nummer}: ungültiges Muster {zeile!r}, erlaubt ist ein Pfad "
|
|
"relativ zur Vault-Wurzel mit / als Trenner, ohne Laufwerk, ohne . und .."
|
|
)
|
|
schluessel = zeile.lower()
|
|
if schluessel in gesehen:
|
|
raise Abbruch(f"Ausnahmedatei Zeile {nummer}: Muster {zeile!r} steht doppelt")
|
|
gesehen.add(schluessel)
|
|
muster.append(Muster(zeile, glob_regex(zeile)))
|
|
return muster
|
|
|
|
|
|
def lade_filter(vault: Path) -> tuple:
|
|
"""Liest userIgnoreFilters nach den Regeln von Obsidian 1.13.7.
|
|
|
|
Rückgabe: die wirksamen Filter als kompilierte Ausdrücke und je
|
|
übersprungenem ungültigem Ausdruck ein Text für die Ausgabe.
|
|
"""
|
|
if not (vault / ".obsidian").is_dir():
|
|
raise Abbruch(f"kein Obsidian-Vault: Ordner .obsidian fehlt unter {vault}")
|
|
app = vault / ".obsidian" / "app.json"
|
|
if not app.is_file():
|
|
return [], []
|
|
try:
|
|
daten = json.loads(app.read_text(encoding="utf-8-sig"))
|
|
except (OSError, ValueError) as fehler:
|
|
raise Abbruch(f".obsidian/app.json nicht lesbar: {fehler}") from fehler
|
|
if not isinstance(daten, dict):
|
|
raise Abbruch(".obsidian/app.json ist kein JSON-Objekt")
|
|
if "userIgnoreFilters" not in daten:
|
|
return [], []
|
|
roh = daten["userIgnoreFilters"]
|
|
if not isinstance(roh, list):
|
|
raise Abbruch(f"userIgnoreFilters ist keine Liste, sondern {type(roh).__name__}")
|
|
liste = []
|
|
uebersprungen = []
|
|
for eintrag in roh:
|
|
if not isinstance(eintrag, str):
|
|
raise Abbruch(f"userIgnoreFilters: ein Eintrag ist kein Text: {eintrag!r}")
|
|
# Getrimmt, leer übersprungen, Ausdruck erst ab drei Zeichen, Schreibung egal.
|
|
text = eintrag.strip()
|
|
if not text:
|
|
continue
|
|
if len(text) > 2 and text.startswith("/") and text.endswith("/"):
|
|
try:
|
|
liste.append(re.compile(text[1:-1], re.IGNORECASE))
|
|
except re.error as fehler:
|
|
uebersprungen.append(f"{eintrag!r} ({fehler})")
|
|
else:
|
|
liste.append(re.compile("^" + re.escape(text), re.IGNORECASE))
|
|
return liste, uebersprungen
|
|
|
|
|
|
def ausgeblendet(rel: str, filter_liste: list) -> bool:
|
|
# Wie isUserIgnored: ausgeblendet, sobald ein Filter im relativen Pfad trifft.
|
|
return any(muster.search(rel) for muster in filter_liste)
|
|
|
|
|
|
def sammle(vault: Path, filter_liste: list) -> list:
|
|
fehler = []
|
|
|
|
def bei_fehler(exc: OSError) -> None:
|
|
fehler.append(str(exc.filename or exc))
|
|
|
|
start = str(vault)
|
|
notizen = []
|
|
for ordner, unterordner, dateien in os.walk(start, onerror=bei_fehler):
|
|
rel = os.path.relpath(ordner, start).replace(os.sep, "/")
|
|
praefix = "" if rel == "." else rel + "/"
|
|
unterordner[:] = sorted(
|
|
name for name in unterordner
|
|
if not name.startswith(".") and not ausgeblendet(praefix + name + "/", filter_liste)
|
|
)
|
|
for name in sorted(dateien):
|
|
if name.startswith(".") or not name.lower().endswith(".md"):
|
|
continue
|
|
if not ausgeblendet(praefix + name, filter_liste):
|
|
notizen.append(praefix + name)
|
|
if fehler:
|
|
raise Abbruch(f"{len(fehler)} Ordner nicht lesbar (erster: {fehler[0]})")
|
|
return notizen
|
|
|
|
|
|
def lies(pfad: Path) -> str:
|
|
# utf-8-sig schluckt die BOM, errors=replace hält ungültige Bytes aus,
|
|
# der Textmodus macht aus CRLF ein LF.
|
|
with open(pfad, "r", encoding="utf-8-sig", errors="replace") as datei:
|
|
return datei.read()
|
|
|
|
|
|
def teile(text: str) -> tuple:
|
|
zeilen = text.split("\n")
|
|
if zeilen and zeilen[0].strip() == "---":
|
|
for i in range(1, len(zeilen)):
|
|
if zeilen[i].strip() == "---":
|
|
return "\n".join(zeilen[1:i]), "\n".join(zeilen[i + 1:])
|
|
# Ohne Schlusszeile ist es kein Frontmatter, sondern Text.
|
|
return "", text
|
|
|
|
|
|
def wikilink_ziele(text: str) -> list:
|
|
ziele = []
|
|
for treffer in _WIKILINK.finditer(text):
|
|
# Ein Backslash am Ende stammt aus einem escapten Pipe in einer Tabelle.
|
|
ziel = treffer.group(1).strip().rstrip("\\").strip()
|
|
if ziel:
|
|
ziele.append(ziel)
|
|
return ziele
|
|
|
|
|
|
def markdown_ziele(text: str) -> list:
|
|
ziele = []
|
|
for treffer in _MDLINK.finditer(text):
|
|
ziel = treffer.group(1).strip()
|
|
if ziel.startswith("<") and ziel.endswith(">"):
|
|
ziel = ziel[1:-1].strip()
|
|
if not ziel or ziel.startswith("#") or _SCHEMA.match(ziel):
|
|
continue
|
|
ziel = ziel.split("#", 1)[0].split("?", 1)[0]
|
|
if ziel:
|
|
ziele.append(unquote(ziel))
|
|
return ziele
|
|
|
|
|
|
def link_ziele(text: str, strip_code) -> list:
|
|
kopf, rumpf = teile(text)
|
|
rumpf = strip_code(rumpf)
|
|
ziele = wikilink_ziele(kopf)
|
|
ziele += wikilink_ziele(rumpf)
|
|
ziele += markdown_ziele(rumpf)
|
|
return ziele
|
|
|
|
|
|
def _name(pfad: str) -> str:
|
|
return pfad[pfad.rfind("/") + 1:]
|
|
|
|
|
|
def _eltern(pfad: str) -> str:
|
|
stelle = pfad.rfind("/")
|
|
return "" if stelle == -1 else pfad[:stelle]
|
|
|
|
|
|
def baue_index(notizen: list) -> Index:
|
|
index = Index()
|
|
for rel in notizen:
|
|
index.namen.setdefault(_name(rel).lower(), []).append(rel)
|
|
return index
|
|
|
|
|
|
def aufloesen(ziel: str, quelle: str, index: Index):
|
|
# Nachgebaut nach getLinkpathDest aus Obsidian 1.13.7; gezählt wird der erste Treffer.
|
|
ziel = ziel.strip()
|
|
if not ziel:
|
|
return None
|
|
pfad = ziel.lower()
|
|
name = _name(pfad)
|
|
kandidaten = index.namen.get(name) if "." in name else None
|
|
if not kandidaten:
|
|
pfad = (ziel + ".md").lower()
|
|
name = _name(pfad)
|
|
kandidaten = index.namen.get(name)
|
|
if not kandidaten:
|
|
return None
|
|
if name == pfad and len(kandidaten) == 1:
|
|
return kandidaten[0]
|
|
ordner = _eltern(quelle).lower()
|
|
if pfad.startswith("./") or pfad.startswith("../"):
|
|
if pfad.startswith("./../"):
|
|
pfad = pfad[2:]
|
|
if pfad.startswith("./"):
|
|
if ordner:
|
|
ordner += "/"
|
|
pfad = ordner + pfad[2:]
|
|
else:
|
|
while pfad.startswith("../"):
|
|
pfad = pfad[3:]
|
|
ordner = _eltern(ordner)
|
|
if ordner:
|
|
ordner += "/"
|
|
pfad = ordner + pfad
|
|
for rel in kandidaten:
|
|
if rel.lower() == pfad:
|
|
return rel
|
|
if pfad.startswith("/"):
|
|
pfad = pfad[1:]
|
|
for rel in kandidaten:
|
|
if rel.lower() == pfad:
|
|
return rel
|
|
if ziel.startswith("/"):
|
|
return None
|
|
# Pfadende ohne Ordnergrenze; erst der Teilbaum der Quelle, je Gruppe der kürzere Pfad.
|
|
passend = [rel for rel in kandidaten if rel.lower().endswith(pfad)]
|
|
nah = sorted((rel for rel in passend if rel.lower().startswith(ordner)), key=len)
|
|
fern = sorted((rel for rel in passend if not rel.lower().startswith(ordner)), key=len)
|
|
treffer = nah + fern
|
|
return treffer[0] if treffer else None
|
|
|
|
|
|
def analysiere(vault: Path, ausnahmen: Path = AUSNAHMEN) -> Ergebnis:
|
|
if not vault.is_dir():
|
|
raise Abbruch(f"Vault-Ordner fehlt: {vault}")
|
|
strip_code = lade_strip_code()
|
|
muster = lade_ausnahmen(ausnahmen)
|
|
filter_liste, filter_uebersprungen = lade_filter(vault)
|
|
notizen = sammle(vault, filter_liste)
|
|
if not notizen:
|
|
raise Abbruch(f"keine Notiz unter {vault} gefunden")
|
|
index = baue_index(notizen)
|
|
verbunden = set()
|
|
ausgehend = {}
|
|
unlesbar = []
|
|
for rel in notizen:
|
|
try:
|
|
text = lies(vault / rel)
|
|
except OSError:
|
|
unlesbar.append(rel)
|
|
continue
|
|
for roh in link_ziele(text, strip_code):
|
|
ziel = aufloesen(roh, rel, index)
|
|
if ziel is None or ziel == rel:
|
|
continue
|
|
ausgehend.setdefault(rel, set()).add(ziel)
|
|
verbunden.add(rel)
|
|
verbunden.add(ziel)
|
|
if unlesbar:
|
|
raise Abbruch(f"{len(unlesbar)} Notiz(en) nicht lesbar (erste: {unlesbar[0]})")
|
|
alle = [rel for rel in notizen if rel not in verbunden]
|
|
ausgenommen = [rel for rel in alle if any(m.regex.match(rel) for m in muster)]
|
|
waisen = [rel for rel in alle if not any(m.regex.match(rel) for m in muster)]
|
|
ohne_treffer = [m.text for m in muster if not any(m.regex.match(rel) for rel in notizen)]
|
|
return Ergebnis(notizen, waisen, ausgenommen, ausgehend, muster, ohne_treffer, len(filter_liste),
|
|
filter_uebersprungen)
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
zerleger = argparse.ArgumentParser(description="Zählt die Waisen im Obsidian-Graphen")
|
|
zerleger.add_argument("--vault", default=".")
|
|
zerleger.add_argument("--ausnahmen", default=str(AUSNAHMEN))
|
|
art = zerleger.add_mutually_exclusive_group()
|
|
art.add_argument("--liste", action="store_true", help="die Waisen einzeln ausgeben")
|
|
art.add_argument("--zahl", action="store_true", help="nur die Zahl ausgeben, für den Sitzungsstart")
|
|
args = zerleger.parse_args(argv)
|
|
vault = Path(os.path.abspath(os.path.expanduser(args.vault)))
|
|
try:
|
|
ergebnis = analysiere(vault, Path(args.ausnahmen))
|
|
except Abbruch as fehler:
|
|
print(f"Waisen - ABGEBROCHEN: {fehler}")
|
|
return 2
|
|
except Exception as fehler:
|
|
print(f"Waisen - ABGEBROCHEN: {type(fehler).__name__}: {fehler}")
|
|
if not args.zahl:
|
|
print(traceback.format_exc())
|
|
return 2
|
|
anzahl = len(ergebnis.waisen)
|
|
if args.zahl:
|
|
# Auf stderr, damit stdout nur die Zahl trägt; der Sitzungsstart liest stdout.
|
|
for text in ergebnis.filter_uebersprungen:
|
|
print(f"Filter übersprungen, ungültiger regulärer Ausdruck: {text}", file=sys.stderr)
|
|
print(anzahl)
|
|
return 1 if anzahl else 0
|
|
print(f"# Waisen - {vault}")
|
|
print(f"Notizen im Graphen: {len(ergebnis.notizen)}")
|
|
print(f"Einträge in userIgnoreFilters: {ergebnis.filter_anzahl}")
|
|
for text in ergebnis.filter_uebersprungen:
|
|
print(f"Filter übersprungen, ungültiger regulärer Ausdruck: {text}")
|
|
print(f"Ausgenommene Waisen: {len(ergebnis.ausgenommen)} über {len(ergebnis.muster)} Muster aus {args.ausnahmen}")
|
|
for text in ergebnis.ohne_treffer:
|
|
print(f"Muster ohne Treffer: {text}")
|
|
print(f"{ZEILE}{anzahl}")
|
|
if args.liste:
|
|
for rel in ergebnis.waisen:
|
|
print(f" - {rel}")
|
|
return 1 if anzahl else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
except AttributeError:
|
|
pass
|
|
sys.exit(main())
|