Files
vault-cli/scripts/one-off/find_unresolved_links.py

81 lines
2.9 KiB
Python

#!/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()