"""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 ](), 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())