117 lines
3.7 KiB
Python
117 lines
3.7 KiB
Python
|
|
#!/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 `- [[<target>]]` 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))
|