"""Gemeinsame Helfer für den obsidian-vault-navigator. Bewusst ohne externe Abhängigkeiten (kein PyYAML), damit die Skripte überall laufen: lokal, in Claude Code, in CI. Der YAML-Parser deckt genau die Teilmenge ab, die Obsidian-Properties benutzen: Skalare, Inline-Listen und Block-Listen. """ from __future__ import annotations import datetime as _dt import json import os import re import unicodedata # --- Konfiguration ----------------------------------------------------------- INDEX_DIR = "_index" INDEX_JSON = "vault-index.json" INDEX_MD = "INDEX.md" SKIP_DIRS = { ".obsidian", ".git", ".trash", ".claude", ".agents", ".venv", "node_modules", "__pycache__", ".svelte-kit", "dist", "build", INDEX_DIR, } ASSET_EXTS = { ".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".csv", ".json", ".yaml", ".yml", ".toml", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".canvas", ".base", ".sql", ".sh", ".py", ".ts", ".js", ".go", ".rs", } # Pfadpräfix -> Typ. Frontmatter `type` gewinnt immer gegen diese Ableitung. FOLDER_TYPES = [ ("40_Templates/note-templates", "note-template"), ("40_Templates/project-templates", "template-project"), ("40_Templates", "note-template"), ("00_Inbox", "inbox"), ("10_Projects", "project-code"), ("20_Areas", "area"), ("30_Research", "research"), ("50_Resources", "resource"), ("90_Archive", "archive"), ] KNOWN_TYPES = { "project-code", "template-project", "note-template", "research", "area", "resource", "moc", "decision", "inbox", "archive", "asset", "note", } KNOWN_STATUS = {"idea", "active", "paused", "done", "archived", "reference"} SUMMARY_MAX = 160 MAX_HEADINGS = 12 MAX_LINKS = 40 # --- Kleiner YAML-Parser ----------------------------------------------------- _FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", re.DOTALL) _WIKILINK_RE = re.compile(r"\[\[([^\]\|#\^]+)") _MDLINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)") _HEADING_RE = re.compile(r"^(#{1,3})\s+(.+?)\s*$", re.MULTILINE) def _scalar(raw: str): v = raw.strip() if not v: return "" if v[0] in "\"'" and len(v) > 1 and v[-1] == v[0]: return v[1:-1] if v.startswith("[") and v.endswith("]"): inner = v[1:-1].strip() if not inner: return [] return [_scalar(x) for x in _split_inline(inner)] low = v.lower() if low in ("true", "yes"): return True if low in ("false", "no"): return False if low in ("null", "~", "none"): return None if re.fullmatch(r"-?\d+", v): try: return int(v) except ValueError: return v return v def _split_inline(s: str): out, buf, quote = [], "", None for ch in s: if quote: if ch == quote: quote = None buf += ch elif ch in "\"'": quote = ch buf += ch elif ch == ",": out.append(buf) buf = "" else: buf += ch if buf.strip(): out.append(buf) return [x for x in (i.strip() for i in out) if x] def parse_frontmatter(text: str): """Gibt (dict, body) zurück. Ohne Frontmatter: ({}, text).""" m = _FM_RE.match(text) if not m: return {}, text block, body = m.group(1), text[m.end():] data, key, pending = {}, None, None for line in block.split("\n"): if not line.strip() or line.lstrip().startswith("#"): continue stripped = line.strip() if stripped.startswith("- ") and key is not None: if pending is None: pending = [] data[key] = pending pending.append(_scalar(stripped[2:])) continue if ":" in line and not line.startswith(" "): k, _, v = line.partition(":") key = k.strip() pending = None val = _scalar(v) data[key] = val if val == "": pending = None return data, body def dump_frontmatter(data: dict) -> str: lines = ["---"] for k, v in data.items(): if isinstance(v, list): inner = ", ".join(str(x) for x in v) lines.append(f"{k}: [{inner}]") elif isinstance(v, bool): lines.append(f"{k}: {'true' if v else 'false'}") elif v is None: lines.append(f"{k}:") else: s = str(v) if ":" in s or s.strip() != s: s = '"' + s.replace('"', "'") + '"' lines.append(f"{k}: {s}") lines.append("---") return "\n".join(lines) + "\n" # --- Ableitungen ------------------------------------------------------------- def slugify(text: str) -> str: text = unicodedata.normalize("NFKD", str(text)) text = text.replace("ß", "ss") text = "".join(c for c in text if not unicodedata.combining(c)) text = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower() return text or "note" def infer_type(rel_path: str) -> str: p = rel_path.replace(os.sep, "/") for prefix, t in FOLDER_TYPES: if p.startswith(prefix + "/") or p == prefix: return t return "note" def clean_inline(text: str) -> str: text = re.sub(r"!?\[\[([^\]\|]*\|)?([^\]]+)\]\]", r"\2", text) text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text) text = re.sub(r"[*_`>]+", "", text) return re.sub(r"\s+", " ", text).strip() def derive_summary(body: str) -> str: for para in re.split(r"\n\s*\n", body): para = para.strip() if not para or para.startswith("#") or para.startswith("---"): continue if para.startswith(("- ", "* ", "|", "```", ">")): continue s = clean_inline(para) if not s: continue if len(s) > SUMMARY_MAX: s = s[: SUMMARY_MAX - 1].rsplit(" ", 1)[0] + "…" return s return "" def derive_title(fm: dict, body: str, rel_path: str) -> str: if fm.get("title"): return str(fm["title"]) m = re.search(r"^#\s+(.+)$", body, re.MULTILINE) if m: return clean_inline(m.group(1)) return os.path.splitext(os.path.basename(rel_path))[0] def extract_headings(body: str): out = [] for m in _HEADING_RE.finditer(body): h = clean_inline(m.group(2)) if h and h not in out: out.append(h[:60]) if len(out) >= MAX_HEADINGS: break return out def extract_links(body: str): out = [] for m in _WIKILINK_RE.finditer(body): t = m.group(1).strip() if t and t not in out: out.append(t) for m in _MDLINK_RE.finditer(body): t = m.group(1).strip() if t and t not in out: out.append(t) return out[:MAX_LINKS] def as_list(v): if v is None or v == "": return [] if isinstance(v, list): return [str(x) for x in v if str(x).strip()] return [x.strip() for x in str(v).replace(";", ",").split(",") if x.strip()] def today() -> str: return _dt.date.today().isoformat() def iso_now() -> str: return _dt.datetime.now().replace(microsecond=0).isoformat() # --- Vault-Scan -------------------------------------------------------------- def walk_vault(vault: str, extra_excludes=()): """Liefert (abs_path, rel_path) für alle Dateien außerhalb der Skip-Ordner.""" excludes = set(SKIP_DIRS) | set(extra_excludes) for root, dirs, files in os.walk(vault): dirs[:] = [ d for d in dirs if d not in excludes and not d.startswith(".") ] for f in sorted(files): if f.startswith("."): continue ap = os.path.join(root, f) rp = os.path.relpath(ap, vault).replace(os.sep, "/") yield ap, rp def read_note(abs_path: str, rel_path: str) -> dict: with open(abs_path, "r", encoding="utf-8", errors="replace") as fh: text = fh.read() fm, body = parse_frontmatter(text) ntype = str(fm.get("type") or "").strip() or infer_type(rel_path) summary = str(fm.get("summary") or "").strip() or derive_summary(body) if len(summary) > SUMMARY_MAX: summary = summary[: SUMMARY_MAX - 1] + "…" mtime = _dt.date.fromtimestamp(os.path.getmtime(abs_path)).isoformat() project = str(fm.get("project") or "").strip() if not project and rel_path.startswith("10_Projects/"): parts = rel_path.split("/") if len(parts) > 2: project = parts[1] note = { "p": rel_path, "u": str(fm.get("uid") or "").strip(), "t": ntype, "s": str(fm.get("status") or "").strip(), "n": derive_title(fm, body, rel_path), "m": summary, "g": as_list(fm.get("tags")), "r": project, "c": str(fm.get("created") or "").strip(), "d": str(fm.get("updated") or "").strip() or mtime, "h": extract_headings(body), "l": extract_links(body), "w": len(body.split()), } return {k: v for k, v in note.items() if v not in ("", [], None)} def load_index(vault: str) -> dict: path = os.path.join(vault, INDEX_DIR, INDEX_JSON) if not os.path.exists(path): raise SystemExit( f"Kein Index gefunden unter {path}.\n" "Zuerst bauen: python3 scripts/build_index.py --vault " f"{vault}" ) with open(path, "r", encoding="utf-8") as fh: return json.load(fh) def count_newer_than(vault: str, iso_ts: str) -> int: """Wie viele .md-Dateien wurden nach dem Indexlauf geändert?""" try: ts = _dt.datetime.fromisoformat(iso_ts).timestamp() except (ValueError, TypeError): return 0 n = 0 for ap, rp in walk_vault(vault): if rp.endswith(".md") and os.path.getmtime(ap) > ts + 1: n += 1 return n