#!/usr/bin/env python3 """Vault side effects: draft file, pending index, git sync, notification. Ported from the legacy shell implementation with its hard-won details preserved: * a same date+title never overwrites an existing draft (suffix -2, -3, …) * the pending index row is created BEFORE staging, so one commit carries both * the 5-minute `obsidian-sync.sh` cron may have already staged the files; that is tolerated rather than treated as an error * a failed push is reported as "local only", never as success """ from __future__ import annotations import re import subprocess import tempfile from pathlib import Path from .config import CFG from .log import log PENDING_HEADER = """--- type: index title: 待确认草稿 note: 自动维护,确认定稿后请把对应行删除 --- # 待确认草稿 (ASR 自动精修) | 日期 | 标题 | 草稿 | |---|---|---| """ def slug_parts(slug: str) -> tuple[str, str]: """`2026-07-21-125915录音` -> ('2026-07-21', '125915录音').""" m = re.match(r"^(\d{4}-\d{2}-\d{2})[-_](.+)$", slug) if m: return m.group(1), m.group(2) import time return time.strftime("%Y-%m-%d"), slug def draft_path(date: str, title: str) -> Path: CFG.drafts.mkdir(parents=True, exist_ok=True) p = CFG.drafts / f"{date}-{title}.md" if not p.exists(): return p n = 2 while (CFG.drafts / f"{date}-{title}-{n}.md").exists(): n += 1 return CFG.drafts / f"{date}-{title}-{n}.md" def write_draft_file(path: Path, body: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) if not body.endswith("\n"): body += "\n" with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False) as f: f.write(body) tmp = Path(f.name) tmp.replace(path) def find_draft_for_transcript(transcript: str) -> Path | None: """Recover an interrupted run without paying for or creating a second draft.""" if not CFG.drafts.exists(): return None needle = f"transcript: {transcript}" for p in CFG.drafts.glob("*.md"): try: if needle in p.read_text(errors="ignore")[:2000]: return p except OSError: continue return None def update_pending(date: str, title: str, path: Path) -> None: p = CFG.pending_index if not p.exists(): p.parent.mkdir(parents=True, exist_ok=True) p.write_text(PENDING_HEADER, encoding="utf-8") base = path.stem text = p.read_text(errors="ignore") if f"[[{base}]]" in text: return text += f"| {date} | {title} | [[{base}]] |\n" write_draft_file(p, text) def _git(*args, check=False) -> subprocess.CompletedProcess: return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True, text=True, timeout=180, check=check) def sync(paths: list[Path], message: str) -> str: """Commit + push. Returns 'pushed' | 'local' | 'noop' | 'disabled' | 'error'.""" if not CFG.git_sync: return "disabled" try: rel = [str(p.relative_to(CFG.vault)) for p in paths if p.exists()] if not rel: return "noop" _git("add", "--", *rel) if _git("diff", "--cached", "--quiet").returncode == 0: log("nothing staged (likely already committed by the auto-sync cron)") return "noop" # Commit only our paths. The 5-minute vault sync may already have unrelated # files staged; absorbing those into this transaction makes rollback and # diagnosis needlessly difficult. c = _git("commit", "-q", "--only", "-m", message, "--", *rel) if c.returncode != 0: log(f"git commit failed: {c.stderr.strip()[:200]}", level="ERROR") return "error" p = _git("push", "-q") if p.returncode != 0: log(f"committed but push failed (local only): {p.stderr.strip()[:200]}", level="WARN") return "local" return "pushed" except Exception as e: log(f"git sync error: {e}", level="ERROR") return "error" def notify(title: str, body: str, *, redact_log: bool = False) -> None: if not CFG.notify: return n = CFG.notifier if not n.exists(): log(f"NOTIFY (no notifier): {title} — {body}") return try: r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body], capture_output=True, text=True, timeout=30) detail = "[sensitive body redacted]" if redact_log else body log(f"NOTIFY: {title} — {detail} ({r.stdout.strip()[:80]})") except Exception as e: log(f"notify failed: {e}", level="WARN") def mark_done(slug: str) -> Path: CFG.state.mkdir(parents=True, exist_ok=True) m = CFG.state / f"{slug}.done" m.touch() return m def is_done(slug: str) -> bool: return (CFG.state / f"{slug}.done").exists() def pending_transcripts() -> list[Path]: if not CFG.inbox.exists(): return [] return sorted(p for p in CFG.inbox.glob("*.txt") if not is_done(p.stem))