#!/usr/bin/env python3 """Scan /vault for [[wikilinks]] and report which targets don't resolve to an actual note. Obsidian resolves links by basename match anywhere in the vault (unless the link includes a path), so we build an index of all note basenames (and their full relative paths) first. """ import os, re, json from collections import defaultdict VAULT = "/vault" LINK_RE = re.compile(r'\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]') def all_notes(): idx = defaultdict(list) # basename (no ext, lowercase) -> [full paths] all_paths = [] for dp, dirnames, fns in os.walk(VAULT): if ".git" in dp.split(os.sep): continue for fn in fns: if fn.lower().endswith(".md"): p = os.path.join(dp, fn) base = fn[:-3] idx[base.lower()].append(p) all_paths.append(p) return idx, all_paths def resolve(link_target, index, current_dir): # Obsidian: link may be a bare name, or a relative/vault path (with /) target = link_target.strip() if "/" in target: # path-style link; try resolving relative to vault root or current dir candidates = [ os.path.join(VAULT, target + ".md"), os.path.join(VAULT, target), os.path.join(current_dir, target + ".md"), os.path.join(current_dir, target), ] for c in candidates: if os.path.isfile(c): return True # also try basename match as fallback base = os.path.basename(target) return base.lower() in index else: return target.lower() in index def main(): index, all_paths = all_notes() unresolved = defaultdict(list) # target -> [source files] total_links = 0 for p in all_paths: try: with open(p, "r", encoding="utf-8", errors="ignore") as f: text = f.read() except Exception: continue for m in LINK_RE.finditer(text): target = m.group(1) total_links += 1 if not resolve(target, index, os.path.dirname(p)): unresolved[target].append(os.path.relpath(p, VAULT)) print(f"Total wikilinks scanned: {total_links}") print(f"Unique unresolved targets: {len(unresolved)}") print(f"Total unresolved link occurrences: {sum(len(v) for v in unresolved.values())}") # dump full detail to json for further processing out = {t: srcs for t, srcs in unresolved.items()} with open("/tmp/unresolved_links.json", "w") as f: json.dump(out, f, indent=2) print("Full detail written to /tmp/unresolved_links.json") # sorted by occurrence count desc by_count = sorted(unresolved.items(), key=lambda kv: -len(kv[1])) print("\n=== TOP 40 unresolved targets by occurrence count ===") for target, srcs in by_count[:40]: print(f" {len(srcs):4d}x [[{target}]]") if __name__ == "__main__": main()