Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

292 lines
10 KiB
Python

"""Pure filesystem operations over a connected Obsidian vault.
This module is the *only* place that knows Obsidian's on-disk conventions:
* notes are ``.md`` files anywhere under the vault root;
* metadata is YAML frontmatter fenced by ``---`` at the top of a note;
* links are ``[[wikilinks]]`` (optionally ``[[Note#heading|alias]]``);
* ``.obsidian`` / ``.trash`` / ``.git`` are vault internals to ignore.
Every function takes the vault root explicitly and returns plain data — no
dependency on the chat loop, tools, or capability machinery, so it is trivial
to unit-test and reuse. Writes are additive only (create / append / set a
property); nothing here edits-in-place or deletes existing prose.
"""
from __future__ import annotations
from pathlib import Path
import re
from typing import Any
import yaml
# Vault-internal folders that hold app state, not user notes.
IGNORED_DIRS: frozenset[str] = frozenset({".obsidian", ".trash", ".git"})
# Target of a wikilink, up to the first of ``#`` (heading), ``^`` (block), or
# ``|`` (display alias). ``![[...]]`` embeds share the same target syntax.
_WIKILINK_RE = re.compile(r"!?\[\[([^\]\n|#^]+)")
# Inline ``#tag`` (allowing nested ``#a/b``), not a Markdown heading (``# h``):
# requires a non-space immediately after ``#`` and not at line start after space.
_INLINE_TAG_RE = re.compile(r"(?:^|(?<=\s))#([A-Za-z0-9_][A-Za-z0-9_/-]*)")
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
class VaultError(Exception):
"""A vault operation could not be completed (missing note, bad path, …)."""
# --- internals --------------------------------------------------------------
def _is_ignored(rel: Path) -> bool:
return any(part in IGNORED_DIRS for part in rel.parts)
def _iter_markdown(root: Path):
"""Yield every user-facing ``.md`` file under ``root`` (ignored dirs skipped)."""
for path in sorted(root.rglob("*.md")):
if _is_ignored(path.relative_to(root)):
continue
yield path
def _safe_join(root: Path, rel: str) -> Path:
"""Resolve ``rel`` under ``root``, refusing anything that escapes the vault."""
root_resolved = root.resolve()
candidate = (root_resolved / rel.lstrip("/")).resolve()
if candidate != root_resolved and root_resolved not in candidate.parents:
raise VaultError(f"Path {rel!r} escapes the vault.")
return candidate
def _with_md_suffix(rel: str) -> str:
return rel if rel.lower().endswith(".md") else f"{rel}.md"
def resolve_note(root: Path, ref: str) -> Path | None:
"""Resolve a note reference to a file path the way Obsidian links do.
``ref`` may be a vault-relative path (``folder/Note.md``) or a bare note
name (``Note``) matched by basename anywhere in the vault. Returns ``None``
when nothing matches.
"""
ref = (ref or "").strip().split("#", 1)[0].split("|", 1)[0].strip()
if not ref:
return None
# Explicit path (has a separator or .md suffix) → resolve directly.
if "/" in ref or ref.lower().endswith(".md"):
candidate = _safe_join(root, _with_md_suffix(ref))
return candidate if candidate.is_file() else None
# Bare name → first .md whose stem matches (case-insensitive fallback).
wanted = ref.lower()
fallback: Path | None = None
for path in _iter_markdown(root):
if path.stem == ref:
return path
if fallback is None and path.stem.lower() == wanted:
fallback = path
return fallback
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Split a note into (frontmatter dict, body). Tolerates malformed YAML."""
match = _FRONTMATTER_RE.match(text)
if not match:
return {}, text
try:
data = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
return {}, text
if not isinstance(data, dict):
return {}, text
return data, text[match.end() :]
def _compose_note(frontmatter: dict[str, Any], body: str) -> str:
if not frontmatter:
return body
fm = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False).strip()
return f"---\n{fm}\n---\n{body}"
def _rel(root: Path, path: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def _snippet(body: str, query: str, width: int = 160) -> str:
lowered = body.lower()
idx = lowered.find(query.lower())
if idx < 0:
return body[:width].strip().replace("\n", " ")
start = max(0, idx - width // 3)
return ("…" if start else "") + body[start : start + width].strip().replace("\n", " ") + "…"
# --- read operations --------------------------------------------------------
def read_note(root: Path, ref: str) -> dict[str, Any]:
"""Return ``{path, frontmatter, body}`` for a note, or raise ``VaultError``."""
path = resolve_note(root, ref)
if path is None:
raise VaultError(f"Note {ref!r} not found in the vault.")
text = path.read_text(encoding="utf-8")
frontmatter, body = split_frontmatter(text)
return {"path": _rel(root, path), "frontmatter": frontmatter, "body": body}
def search_notes(root: Path, query: str, limit: int = 20) -> list[dict[str, str]]:
"""Case-insensitive substring search over note titles and bodies."""
query = (query or "").strip()
if not query:
return []
needle = query.lower()
hits: list[dict[str, str]] = []
for path in _iter_markdown(root):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
if needle in path.stem.lower() or needle in text.lower():
hits.append({"path": _rel(root, path), "snippet": _snippet(text, query)})
if len(hits) >= limit:
break
return hits
def list_notes(root: Path, folder: str = "", limit: int = 200) -> list[str]:
"""List note paths (vault-relative), optionally under ``folder``."""
base = _safe_join(root, folder) if folder.strip() else root.resolve()
if not base.exists():
return []
out: list[str] = []
for path in _iter_markdown(base if base.is_dir() else root):
out.append(_rel(root, path))
if len(out) >= limit:
break
return out
def outgoing_links(root: Path, ref: str) -> list[str]:
"""The wikilink targets a note points to, in order, de-duplicated."""
note = read_note(root, ref)
seen: dict[str, None] = {}
for target in _WIKILINK_RE.findall(note["body"]):
name = target.strip()
if name:
seen.setdefault(name, None)
return list(seen)
def backlinks(root: Path, ref: str, limit: int = 50) -> list[dict[str, str]]:
"""Notes that link to ``ref`` via ``[[name]]`` (matched by basename)."""
target = resolve_note(root, ref)
if target is None:
raise VaultError(f"Note {ref!r} not found in the vault.")
stem = target.stem.lower()
out: list[dict[str, str]] = []
for path in _iter_markdown(root):
if path == target:
continue
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
if any(t.strip().lower() == stem for t in _WIKILINK_RE.findall(text)):
out.append({"path": _rel(root, path), "snippet": _snippet(text, target.stem)})
if len(out) >= limit:
break
return out
def collect_tags(root: Path, limit: int = 200) -> list[dict[str, Any]]:
"""All tags across the vault (inline ``#tag`` + frontmatter ``tags``), by count."""
counts: dict[str, int] = {}
def bump(tag: str) -> None:
tag = tag.strip().lstrip("#")
if tag:
counts[tag] = counts.get(tag, 0) + 1
for path in _iter_markdown(root):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
frontmatter, body = split_frontmatter(text)
fm_tags = frontmatter.get("tags")
if isinstance(fm_tags, str):
fm_tags = [fm_tags]
if isinstance(fm_tags, list):
for tag in fm_tags:
bump(str(tag))
for tag in _INLINE_TAG_RE.findall(body):
bump(tag)
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
return [{"tag": tag, "count": count} for tag, count in ranked[:limit]]
# --- write operations (additive only) ---------------------------------------
def create_note(
root: Path,
rel_path: str,
content: str,
frontmatter: dict[str, Any] | None = None,
) -> str:
"""Create a new ``.md`` note. Refuses to overwrite an existing file."""
rel_path = (rel_path or "").strip()
if not rel_path:
raise VaultError("create_note needs a non-empty path.")
path = _safe_join(root, _with_md_suffix(rel_path))
if path.exists():
raise VaultError(f"Note {_with_md_suffix(rel_path)!r} already exists; use append instead.")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_compose_note(frontmatter or {}, content or ""), encoding="utf-8")
return _rel(root, path)
def append_note(root: Path, ref: str, content: str) -> str:
"""Append text to the end of an existing note."""
path = resolve_note(root, ref)
if path is None:
raise VaultError(f"Note {ref!r} not found; create it first.")
existing = path.read_text(encoding="utf-8")
separator = "" if existing.endswith("\n") or not existing else "\n"
path.write_text(existing + separator + (content or ""), encoding="utf-8")
return _rel(root, path)
def set_property(root: Path, ref: str, key: str, value: Any) -> str:
"""Set a single frontmatter property on an existing note."""
key = (key or "").strip()
if not key:
raise VaultError("set_property needs a non-empty key.")
path = resolve_note(root, ref)
if path is None:
raise VaultError(f"Note {ref!r} not found; create it first.")
frontmatter, body = split_frontmatter(path.read_text(encoding="utf-8"))
frontmatter[key] = value
path.write_text(_compose_note(frontmatter, body), encoding="utf-8")
return _rel(root, path)
__all__ = [
"VaultError",
"IGNORED_DIRS",
"resolve_note",
"read_note",
"search_notes",
"list_notes",
"outgoing_links",
"backlinks",
"collect_tags",
"create_note",
"append_note",
"set_property",
"split_frontmatter",
]