Automatischer Push: 45 Datei(en) geändert
- .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]>
This commit is contained in:
1 parent
e8472409e9
commit
eb48f8822d
45 files changed
+8645
-262
No files matched your search
@@ -0,0 +1,439 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user