diff --git a/scripts/vault-links.py b/scripts/vault-links.py new file mode 100755 index 0000000..7845210 --- /dev/null +++ b/scripts/vault-links.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +vault-links.py — resolve Obsidian wikilinks from the FILES, not from Obsidian. + +Why this exists, and why not the alternatives (measured 2026-08-23): + + * Obsidian's own index is a 43 MB LevelDB under ~/Library/Application Support/obsidian/. + It is a CACHE written by the app: at 18:04 it was 78 minutes stale, and contained ZERO + entries for a note created at 16:42 the same day (control: 29 entries for a note that + had existed for months, so the search could see one when present). It also carries a + LOCK — freshest while Obsidian runs, readable only while it does not. Undocumented and + version-dependent besides. + * The Local REST API plugin exposes the live index, but only while Obsidian is open, + which a SessionStart hook cannot assume. + +So: implement the RULES, read the FILES. Always current, works with the app shut, +version-independent, and — the point — testable. `--selftest` carries positive controls +(it must find a break that is really there) and negative controls (it must NOT report one +that isn't, and must NOT invent a near-match). + +THE REFUSAL THAT MATTERS. This tool never fuzzy-matches. On 2026-08-23 a fuzzy matcher +proposed rewriting [[2025-05-30]] to [[2025-08-30]] and [[2025-07-24]] to [[2025-05-24]]. +Those are different days, not aliases; applying them would have silently falsified the +steward's own record. Candidates are ADVISORY, are never emitted for date-shaped targets, +and are never applied by this tool. +""" +import argparse, json, os, re, sys, tempfile, shutil +from urllib.parse import unquote +from collections import defaultdict +from pathlib import Path + +DEFAULT_VAULT = os.path.expanduser( + "~/Library/Mobile Documents/iCloud~md~obsidian/Documents/David, root-and-branch") + +FENCE = re.compile(r"^```.*?^```", re.S | re.M) +TILDE = re.compile(r"^~~~.*?^~~~", re.S | re.M) +INLINE = re.compile(r"`[^`\n]*`") +WIKI = re.compile(r"!?\[\[([^\]\n]+)\]\]") +MDLINK = re.compile(r"(? Target.""" + t = raw.split("|", 1)[0] + t = t.split("#", 1)[0] + return t.strip() + + +class Index: + def __init__(self, vault): + self.vault = Path(vault) + self.by_relpath, self.by_name, self.by_alias = {}, defaultdict(list), {} + for p in self.vault.rglob("*"): + if not p.is_file() or "/.obsidian/" in str(p) or p.name.startswith("."): + continue + rel = p.relative_to(self.vault).as_posix() + self.by_relpath[rel] = p + if p.suffix == ".md": + self.by_relpath[rel[:-3]] = p + self.by_name[p.stem].append(p) + for a in parse_aliases(frontmatter(p.read_text(errors="replace"))): + self.by_alias.setdefault(a, p) + else: + self.by_name[p.name].append(p) + self.lower_relpath = {k.lower(): v for k, v in self.by_relpath.items()} + self.lower_name = {k.lower(): v for k, v in self.by_name.items()} + self.lower_alias = {k.lower(): v for k, v in self.by_alias.items()} + + def resolve(self, target): + """Obsidian's order: exact path -> exact basename -> alias -> case-insensitive. + Ambiguous basenames take the shortest path, as Obsidian does. Never fuzzy.""" + if not target or target.startswith(("http://", "https://", "mailto:")): + return ("external", None, "") + t = target.strip().lstrip("./") + if t in self.by_relpath: + return ("ok", self.by_relpath[t], "path") + hits = self.by_name.get(t) or self.by_name.get(t + ".md") + if hits: + best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) + return ("ok", best, "name" + (" [ambiguous, shortest path]" if len(hits) > 1 else "")) + if t in self.by_alias: + return ("ok", self.by_alias[t], "alias") + base = t.split("/")[-1] + hits = self.by_name.get(base) + if hits: + best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) + return ("ok", best, "basename-of-path") + low = t.lower() + for table, kind in ((self.lower_relpath, "path"), (self.lower_alias, "alias")): + if low in table: + v = table[low] + return ("case", v if isinstance(v, Path) else v, f"case-insensitive {kind}") + if low in self.lower_name: + best = min(self.lower_name[low], + key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) + return ("case", best, "case-insensitive name") + return ("dead", None, "") + + def candidates(self, target): + """ADVISORY ONLY. Never for date-shaped targets — see the module docstring.""" + base = target.split("/")[-1] + if DATEY.match(base) or WEEKY.match(base): + return [] + pref = re.compile(r"^\d{1,2}[a-z]?\.\s+") + return sorted({n for n in self.by_name if pref.match(n) and pref.sub("", n) == base}) + + +def scan(vault, include_archive=False, once_per_note=True): + idx = Index(vault) + dead, case_only, total = defaultdict(set), defaultdict(set), 0 + for p in sorted(idx.vault.rglob("*.md")): + if "/.obsidian/" in str(p): + continue + if not include_archive and "99. Archive" in str(p): + continue + text = strip_code(p.read_text(errors="replace")) + raws = [target_of(m) for m in WIKI.findall(text)] + raws += [unquote(t) for t in MDLINK.findall(text) if is_vault_mdlink(t)] + seen = set() + for t in raws: + if once_per_note: + if t in seen: + continue + seen.add(t) + total += 1 + st, _, _ = idx.resolve(t) + if st == "dead": + dead[t].add(p.stem) + elif st == "case": + case_only[t].add(p.stem) + return idx, dead, case_only, total + + +def chk(label, cond, state): + state["ok" if cond else "fail"] += 1 + print(f" {'PASS' if cond else 'FAIL'} {label}") + + +def selftest(): + state = {"ok": 0, "fail": 0} + d = tempfile.mkdtemp() + try: + w = lambda rel, body: (Path(d, rel).parent.mkdir(parents=True, exist_ok=True), + Path(d, rel).write_text(body, encoding="utf-8")) + w("Alpha.md", "---\naliases:\n - Al\n - \"Alpha Prime\"\n---\nbody\n") + w("sub/Beta.md", "plain\n") + w("sub/deep/Beta.md", "duplicate basename\n") + w("2025-08-30.md", "a real day\n") + w("00. Numbered Thing.md", "prefixed\n") + w("img.png", "") + w("Source.md", + "[[Alpha]] [[Al]] [[Alpha Prime]] [[sub/Beta]] [[Alpha|shown]] [[Alpha#Head]]\n" + "[[Alpha#^blk]] ![[img.png]] [[alpha]] [[Nonexistent Note]] [[2025-05-30]]\n" + "`[[InlineCode]]` \n```\n[[FencedCode]]\n```\n[md](sub/Beta.md)\n" + "[web](/essays/) [app](drafts://open?uuid=X) [ext](https://a.b/c)\n" + "[enc](sub/Beta.md) [gone](sub/Missing.md)\n") + idx = Index(d) + R = lambda t: idx.resolve(t)[0] + + chk("exact filename resolves", R("Alpha") == "ok", state) + chk("alias resolves (block list)", R("Al") == "ok", state) + chk("quoted alias resolves", R("Alpha Prime") == "ok", state) + chk("path-form link resolves [the 52 false positives of 2026-08-23]", + R("sub/Beta") == "ok", state) + chk("display text stripped", target_of("Alpha|shown") == "Alpha", state) + chk("heading anchor stripped", target_of("Alpha#Head") == "Alpha", state) + chk("block ref stripped", target_of("Alpha#^blk") == "Alpha", state) + chk("non-markdown attachment resolves", R("img.png") == "ok", state) + chk("ambiguous basename resolves to the shortest path [Obsidian's rule]", + idx.resolve("Beta")[1] == Path(d, "sub/Beta.md"), state) + chk("case-mismatch is reported as 'case', not silently ok and not dead", + R("alpha") == "case", state) + + # --- negative controls: it must NOT report what is not there --- + chk("a genuinely missing note IS reported dead [POSITIVE CONTROL — without this," + " every 'dead' count below proves nothing]", R("Nonexistent Note") == "dead", state) + _, dead, _, _ = scan(d) + chk("link inside an inline code span is IGNORED [negative control]", + "InlineCode" not in dead, state) + chk("link inside a fenced block is IGNORED [negative control]", + "FencedCode" not in dead, state) + chk("a markdown-style link to a .md file IS resolved", "sub/Beta.md" not in dead, state) + chk("an absolute web path is NOT a vault link [/essays/ — 7,830 false positives" + " on 2026-08-23]", not is_vault_mdlink("/essays/"), state) + chk("an app URL is NOT a vault link [drafts://]", + not is_vault_mdlink("drafts://open?uuid=X"), state) + chk("an external http link is NOT a vault link", + not is_vault_mdlink("https://a.b/c"), state) + chk("a relative .md link IS a vault link [POSITIVE CONTROL — proves the three" + " refusals above are a filter, not a blanket rejection]", + is_vault_mdlink("sub/Beta.md"), state) + chk("a BROKEN relative .md link is still caught [POSITIVE CONTROL — the filter" + " must not have silenced real markdown-link breakage]", + "sub/Missing.md" in dead, state) + + # --- THE refusal. This is why the tool exists in this form. --- + chk("a date-shaped dead link gets NO candidate [2025-05-30 must never be offered" + " 2025-08-30 — different days, not aliases]", + idx.candidates("2025-05-30") == [], state) + chk("candidate machinery still works for a real prefix case [POSITIVE CONTROL —" + " proves the empty result above is a refusal, not a broken function]", + idx.candidates("Numbered Thing") == ["00. Numbered Thing"], state) + chk("a week-shaped target also gets no candidate", idx.candidates("2026-W30") == [], state) + + # --- the clean-vault control --- + d2 = tempfile.mkdtemp() + Path(d2, "Only.md").write_text("[[Only]]\n") + _, dead2, case2, tot2 = scan(d2) + chk("a vault with no broken links reports ZERO [negative control — proves the" + " scanner is not manufacturing findings]", len(dead2) == 0 and tot2 == 1, state) + shutil.rmtree(d2, ignore_errors=True) + finally: + shutil.rmtree(d, ignore_errors=True) + print(f"\n{'SELFTEST PASS' if not state['fail'] else 'SELFTEST FAIL'}" + f" — {state['ok']} ok, {state['fail']} failed") + return 0 if not state["fail"] else 1 + + +def main(): + ap = argparse.ArgumentParser(description="Resolve Obsidian wikilinks from the files.") + ap.add_argument("--vault", default=DEFAULT_VAULT) + ap.add_argument("--selftest", action="store_true") + ap.add_argument("--include-archive", action="store_true") + ap.add_argument("--json", action="store_true") + ap.add_argument("--candidates", action="store_true", + help="show ADVISORY repair candidates (never dates; never applied)") + a = ap.parse_args() + if a.selftest: + return selftest() + if not os.path.isdir(a.vault): + print(f"STOP: no vault at {a.vault}", file=sys.stderr) + return 2 + idx, dead, case_only, total = scan(a.vault, a.include_archive) + if a.json: + print(json.dumps({"total_links": total, + "dead": {k: sorted(v) for k, v in dead.items()}, + "case_only": {k: sorted(v) for k, v in case_only.items()}}, indent=1)) + return 0 + scope = "whole vault" if a.include_archive else "live vault (99. Archive excluded)" + print(f"vault: {a.vault}\nscope: {scope} links checked: {total} " + f"(once per note per target)\n") + print(f"UNRESOLVED : {len(dead)} targets, {sum(len(v) for v in dead.values())} references") + print(f"CASE-ONLY : {len(case_only)} targets [resolve in Obsidian; a rename would break them]") + for t, srcs in sorted(dead.items(), key=lambda kv: -len(kv[1]))[:25]: + line = f" {len(srcs):3d} {t}" + if a.candidates: + c = idx.candidates(t) + if c: + line += f"\n advisory candidate: {c[0]} [NOT applied]" + print(line) + if len(dead) > 25: + print(f" … {len(dead)-25} more (use --json for all)") + print("\nNo fuzzy matching is performed. Candidates are advisory and never date-shaped.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())