81 lines
2.9 KiB
Python
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()
|