Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
237 lines
9.4 KiB
Python
237 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Findet tote Wikilinks im Vault - ohne die Fehlalarme aus Codebloecken.
|
|
|
|
WARUM ES DIESES WERKZEUG GIBT
|
|
-----------------------------
|
|
`vault_doctor.py` aus dem mitgelieferten Navigator-Toolkit meldete am
|
|
2026-09-03 92 tote Wikilinks. Die Stichprobe bestand fast ausschliesslich
|
|
aus Rauschen: `[[ $EUID -eq 0 ]]` (Bash-Test), `[[:space:]]` (POSIX-
|
|
Zeichenklasse), `[[bild.png]]` und `[[Ziel]]` (Platzhalter in der Doku des
|
|
Bookstack-Upload-Skills). Ursache ist `_WIKILINK_RE` in `vaultlib.py`
|
|
Zeile 61: der Ausdruck laeuft ueber den gesamten Text, das Wort "fence"
|
|
kommt in der Datei kein einziges Mal vor.
|
|
|
|
Das Navigator-Toolkit ist schreibgeschuetzt und stammt aus einem fremden
|
|
Skill (siehe AI/Regelwerk/navigation.md). Ein Patch dort waere beim
|
|
naechsten Update verloren. Deshalb liegt die Korrektur hier daneben.
|
|
|
|
HERKUNFT DER REGELN
|
|
-------------------
|
|
- Wikilink-Syntax: QUELLE - `_WIKILINK_RE`, vaultlib.py Zeile 61.
|
|
- Zielaufloesung (lookup-Menge und Treffer-Bedingung): QUELLE - woertlich
|
|
uebernommen aus vault_doctor.py Zeilen 66-74 und 102-107.
|
|
- Codebloecke und Code-Spans zaehlen nicht: QUELLE - CommonMark; Obsidian
|
|
rendert dort keine Links.
|
|
- Frontmatter zaehlt nicht mit: QUELLE - vaultlib.py Zeile 117 trennt es ab.
|
|
- Ungeschlossener Zaun gilt bis Dateiende: PROJEKTVORGABE - die
|
|
konservative Wahl, lieber einen echten Link uebersehen als Rauschen
|
|
erzeugen.
|
|
- Schliesszaun: QUELLE - dasselbe Zeichen wie der Oeffner, mindestens so
|
|
lang, danach nur Leerzeichen oder Tab. Abgeschrieben aus markdown-it-py
|
|
4.2.0, rules_block/fence.py Zeilen 42-43, 106 und 119-123; Referenz ist
|
|
CommonMark 0.31.2, Abschnitt 4.5 Fenced code blocks. Fundstellen und der
|
|
offene Abgleich mit dem Spezifikationstext: ENTWURF-codeblock-erkennung.md.
|
|
- Einrueckung vor dem Schliesszaun: wie beim Oeffner hoechstens drei
|
|
Leerraumzeichen, ein Tab eingeschlossen. Die Regel ist bewusst symmetrisch
|
|
(Entscheidung nach Pruefrunde 2): Eine strengere Schliessregel liess einen
|
|
in einer Liste mit Tab eingerueckten Block bis zum Dateiende offen, dort
|
|
schliesst markdown-it-py ihn aber.
|
|
|
|
BEWUSSTE LUECKEN - nicht fuer erledigt halten
|
|
---------------------------------------------
|
|
- Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie
|
|
wuerden eingerueckte Listenpunkte mit echten Links verschlucken.
|
|
- Ein Tab vor dem Schliesszaun auf oberster Ebene schliesst hier den Block.
|
|
markdown-it-py zaehlt den Tab dort als vier Spalten und liest die Zeile als
|
|
eingerueckten Code (state_block.py Zeilen 79-80 und 257-261). In Listen
|
|
schliesst er auch dort. Listen-Einrueckung bildet dieses Werkzeug nicht nach.
|
|
- Ein Oeffner mit Backtick im Info-String gilt hier als Zaun. CommonMark
|
|
liest so eine Zeile als Inline-Code. Nicht behandelt.
|
|
- `extract_headings()` im Toolkit hat denselben Fehler: `# Kommentar` aus
|
|
einem Bash-Block zaehlt dort als Ueberschrift. Hier nicht behandelt.
|
|
- Dieses Werkzeug ist ein Fangnetz gegen offensichtliche Fehler. Es belegt
|
|
nicht, dass alle Wikilinks stimmen - nur, dass die gemeldeten nicht aus
|
|
Codebloecken stammen.
|
|
|
|
Entwurf und Angriffsliste: ENTWURF-codeblock-erkennung.md im selben Ordner.
|
|
|
|
Aufruf:
|
|
python .vault-tools/linkcheck/linkcheck.py --vault .
|
|
python .vault-tools/linkcheck/linkcheck.py --vault . --alle
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import traceback
|
|
|
|
SKRIPT = os.path.dirname(os.path.abspath(__file__))
|
|
TOOLKIT = os.path.join(os.path.dirname(SKRIPT), "obsidian-vault-navigator", "scripts")
|
|
|
|
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
# Schliesszaun: dieselbe Einrueckung wie beim Oeffner (symmetrisch, siehe
|
|
# Kopfkommentar), danach nur Leerzeichen, Tab und ein CR. Eine Zeile mit
|
|
# Info-String im Block ist deshalb Inhalt, kein Ende.
|
|
_SCHLUSS_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})[ \t]*\r?$")
|
|
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1")
|
|
|
|
|
|
def strip_code(body):
|
|
"""Ersetzt Codebloecke und Code-Spans durch Leerzeichen.
|
|
|
|
Laenge und Zeilenzahl bleiben erhalten (Ersetzen statt Loeschen), damit
|
|
nichts ueber eine entfernte Stelle hinweg zusammenwaechst.
|
|
"""
|
|
if not body:
|
|
return ""
|
|
zaun = None
|
|
aus = []
|
|
for zeile in body.split("\n"):
|
|
m = _FENCE_RE.match(zeile)
|
|
if zaun is None:
|
|
if m:
|
|
zaun = m.group(1)
|
|
aus.append(" " * len(zeile))
|
|
continue
|
|
aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile))
|
|
else:
|
|
s = _SCHLUSS_RE.match(zeile)
|
|
# Gleiches Zeichen und mindestens so lang wie der Oeffner: ein
|
|
# Dreier-Zaun schliesst keinen Vierer-Zaun.
|
|
if s and s.group(1)[0] == zaun[0] and len(s.group(1)) >= len(zaun):
|
|
zaun = None
|
|
aus.append(" " * len(zeile))
|
|
return "\n".join(aus)
|
|
|
|
|
|
def lade_toolkit():
|
|
"""Importiert vaultlib aus dem schreibgeschuetzten Toolkit.
|
|
|
|
Fehlt es, ist das ein BEFUND und kein Grund zu schweigen.
|
|
"""
|
|
if TOOLKIT not in sys.path:
|
|
sys.path.insert(0, TOOLKIT)
|
|
try:
|
|
import vaultlib as V
|
|
return V
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"vaultlib.py nicht gefunden oder nicht importierbar unter %s (%s). "
|
|
"Ohne das Toolkit kann diese Pruefung nicht arbeiten." % (TOOLKIT, exc))
|
|
|
|
|
|
def sammle(V, vault):
|
|
"""Notizen und Anhaenge getrennt einsammeln.
|
|
|
|
Anhaenge braucht es, weil `![[Pasted image 123.png]]` ein gueltiger
|
|
Obsidian-Link auf eine Nicht-Markdown-Datei ist. vault_doctor.py baut
|
|
seine lookup-Menge nur aus .md-Dateien und meldet solche Einbettungen
|
|
deshalb als tot.
|
|
"""
|
|
notizen, anhaenge = [], set()
|
|
for ap, rp in V.walk_vault(vault):
|
|
if not rp.endswith(".md"):
|
|
anhaenge |= {rp, os.path.basename(rp)}
|
|
continue
|
|
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
|
|
text = fh.read()
|
|
fm, body = V.parse_frontmatter(text)
|
|
notizen.append({"abs": ap, "rel": rp, "fm": fm, "body": body})
|
|
return notizen, anhaenge
|
|
|
|
|
|
def baue_lookup(V, notizen):
|
|
"""Woertlich aus vault_doctor.py Zeilen 66-74."""
|
|
lookup = set()
|
|
for n in notizen:
|
|
rel = n["rel"]
|
|
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
|
|
os.path.splitext(os.path.basename(rel))[0]}
|
|
if n["fm"].get("title"):
|
|
lookup.add(str(n["fm"]["title"]))
|
|
if n["fm"].get("uid"):
|
|
lookup.add(str(n["fm"]["uid"]))
|
|
return lookup
|
|
|
|
|
|
def wikilinks(V, body):
|
|
"""Wie V.extract_links, aber mit ausgeblendetem Code.
|
|
|
|
Zusaetzlich wird ein abschliessender Backslash entfernt. In Obsidian-
|
|
Tabellenzellen muss das Pipe eines Alias als `\\|` geschrieben werden,
|
|
sonst bricht die Tabelle. `_WIKILINK_RE` schneidet am Pipe, laesst den
|
|
Backslash aber stehen - das Ziel hiesse dann `Pfad\\` und faende nichts.
|
|
Ein Dateiname kann unter Windows nicht auf Backslash enden, das
|
|
Abschneiden ist also gefahrlos.
|
|
"""
|
|
sauber = strip_code(body)
|
|
treffer = []
|
|
for m in V._WIKILINK_RE.finditer(sauber):
|
|
t = m.group(1).strip().rstrip("\\").strip()
|
|
if t and t not in treffer:
|
|
treffer.append(t)
|
|
return treffer
|
|
|
|
|
|
def lauf(vault, alle):
|
|
V = lade_toolkit()
|
|
notizen, anhaenge = sammle(V, vault)
|
|
if not notizen:
|
|
raise RuntimeError("Keine Markdown-Dateien unter %s gefunden." % vault)
|
|
|
|
lookup = baue_lookup(V, notizen)
|
|
kaputt = []
|
|
roh_gesamt = 0
|
|
for n in notizen:
|
|
roh_gesamt += len(V.extract_links(n["body"]))
|
|
for raw in wikilinks(V, n["body"]):
|
|
t = raw.strip().lstrip("./")
|
|
# Treffer-Bedingung woertlich aus vault_doctor.py Zeile 104,
|
|
# erweitert um Anhaenge (Bilder, PDF) - siehe sammle().
|
|
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
|
|
continue
|
|
if t in anhaenge or os.path.basename(t) in anhaenge:
|
|
continue
|
|
kaputt.append((n["rel"], raw))
|
|
return notizen, kaputt, roh_gesamt
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description="Tote Wikilinks ohne Codeblock-Fehlalarme")
|
|
p.add_argument("--vault", default=".")
|
|
p.add_argument("--alle", action="store_true", help="alle Befunde statt der ersten 40")
|
|
args = p.parse_args()
|
|
vault = os.path.abspath(os.path.expanduser(args.vault))
|
|
|
|
befunde = []
|
|
try:
|
|
notizen, befunde, roh = lauf(vault, args.alle)
|
|
except Exception:
|
|
# Absturz ist selbst ein Befund - und die bis dahin gesammelten
|
|
# Befunde werden trotzdem ausgegeben.
|
|
sys.stdout.write("# Linkcheck - ABGEBROCHEN\n\n")
|
|
sys.stdout.write(traceback.format_exc())
|
|
for rel, raw in befunde:
|
|
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
|
|
return 2
|
|
|
|
sys.stdout.write("# Linkcheck - %d Notizen in %s\n" % (len(notizen), vault))
|
|
sys.stdout.write("\nWikilink-Vorkommen ohne Codeblock-Filter: %d\n" % roh)
|
|
if not befunde:
|
|
sys.stdout.write("\nKeine toten Wikilinks ausserhalb von Codebloecken.\n")
|
|
return 0
|
|
|
|
grenze = len(befunde) if args.alle else 40
|
|
sys.stdout.write("\n## Tote Wikilinks (%d)\n" % len(befunde))
|
|
for rel, raw in befunde[:grenze]:
|
|
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
|
|
if len(befunde) > grenze:
|
|
sys.stdout.write(" ... %d weitere (--alle)\n" % (len(befunde) - grenze))
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|