199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
#!/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 os
|
|
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:
|
|
env = dict(os.environ)
|
|
for key_candidate in (
|
|
CFG.state_dir / "ssh" / "id_ed25519_gitea",
|
|
Path("/state/ssh/id_ed25519_gitea"),
|
|
Path("/root/.ssh/id_ed25519_gitea"),
|
|
Path.home() / ".ssh" / "id_ed25519_gitea",
|
|
):
|
|
if key_candidate.exists():
|
|
env.setdefault(
|
|
"GIT_SSH_COMMAND",
|
|
f"ssh -i {key_candidate} -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes",
|
|
)
|
|
break
|
|
return subprocess.run(
|
|
["git", "-c", f"safe.directory={CFG.vault}", *args],
|
|
cwd=CFG.vault, capture_output=True,
|
|
text=True, timeout=180, check=check, env=env,
|
|
)
|
|
|
|
|
|
def pull() -> str:
|
|
"""Pull latest remote changes into CFG.vault when git_sync is enabled."""
|
|
if not CFG.git_sync or not (CFG.vault / ".git").exists():
|
|
return "disabled"
|
|
try:
|
|
r = _git("pull", "--rebase", "--autostash", "-q")
|
|
if r.returncode != 0:
|
|
log(f"git pull failed: {r.stderr.strip()[:200]}", level="WARN")
|
|
return "error"
|
|
return "pulled"
|
|
except Exception as e:
|
|
log(f"git pull error: {e}", level="WARN")
|
|
return "error"
|
|
|
|
|
|
def sync(paths: list[Path], message: str) -> str:
|
|
"""Commit + push. Returns 'pushed' | 'local' | 'noop' | 'disabled' | 'error'."""
|
|
if not CFG.git_sync:
|
|
return "disabled"
|
|
try:
|
|
vault_root = CFG.vault.resolve()
|
|
rel = []
|
|
for p in paths:
|
|
if not p.exists():
|
|
continue
|
|
try:
|
|
rel.append(str(p.resolve().relative_to(vault_root)))
|
|
except ValueError:
|
|
continue
|
|
if not rel:
|
|
return "noop"
|
|
_git("pull", "--rebase", "--autostash", "-q")
|
|
_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))
|