ver 0.9
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user