#!/usr/bin/env python3 """echo_links.py — cross-link primitives. Parse and add `## Related` wikilinks, and create **bidirectional** links between notes (read-modify-write, idempotent). Links use path-style targets without the `.md` extension (e.g. `[[resources/people/bob-smith]]`), which are unambiguous and resolve in Obsidian. Network goes through echo.py. """ from __future__ import annotations import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import echo # noqa: E402 WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]") def first_h1(text: str) -> str: for line in text.splitlines(): if line.startswith("# "): return line[2:].strip() return "" def all_wikilinks(text: str) -> list[str]: return [m.group(1).strip() for m in WIKILINK.finditer(text)] def related_targets(text: str) -> list[str]: """Wikilink targets under the `## Related` heading only.""" out, cap = [], False for line in text.splitlines(): if line.strip().lower() == "## related": cap = True continue if cap and line.startswith("## "): break if cap: out += [m.group(1).strip() for m in WIKILINK.finditer(line)] return out def source_notes(text: str) -> list[str]: """Plain relative paths listed in the frontmatter `source_notes:` field.""" if not text.startswith("---"): return [] end = text.find("\n---", 3) fm = text[:end] if end != -1 else text m = re.search(r"(?m)^source_notes:\s*\[(.*?)\]", fm) if not m: return [] return [p.strip().strip('"').strip("'") for p in m.group(1).split(",") if p.strip()] def link_token(path: str) -> str: """Path-style wikilink target without the .md extension.""" return path[:-3] if path.endswith(".md") else path def add_related(text: str, target_path: str): """Return (new_text, changed): ensure `- [[]]` exists under ## Related.""" token = link_token(target_path) if token in related_targets(text): return text, False bullet = f"- [[{token}]]" trailing_nl = text.endswith("\n") lines = text.splitlines() idx = next((i for i, l in enumerate(lines) if l.strip().lower() == "## related"), None) if idx is None: sep = "" if trailing_nl else "\n" return text + f"{sep}\n## Related\n{bullet}\n", True # insert after the section's existing content (before the next heading / EOF) j = idx + 1 while j < len(lines) and not lines[j].startswith("## "): j += 1 k = j while k > idx + 1 and not lines[k - 1].strip(): k -= 1 lines.insert(k, bullet) return "\n".join(lines) + ("\n" if trailing_nl else ""), True def get_text(path: str) -> str | None: status, body = echo.request("GET", echo.vault_url(path)) if status == 404: return None echo.check(status, body, f"get {path}") return body.decode(errors="replace") def put_text(path: str, text: str) -> None: status, body = echo.request("PUT", echo.vault_url(path), data=text.encode("utf-8"), headers={"Content-Type": "text/markdown"}) echo.check(status, body, f"put {path}") def add_one(path: str, target_path: str) -> bool: """Add [[target]] under path's ## Related. Returns True if the file changed.""" text = get_text(path) if text is None: return False new, changed = add_related(text, target_path) if changed: put_text(path, new) return changed def link_bidirectional(a_path: str, b_path: str): """Add A<->B reciprocal links. Returns (a_changed, b_changed).""" if a_path == b_path: return (False, False) return (add_one(a_path, b_path), add_one(b_path, a_path))