Files
obsidian_utils/dailySummary/utils/zed_tracker.py
T
fegger 4b7ddcb1c1 Add daily coding summary tool for Obsidian
Integrates with Zed editor history and Git commits to track daily
coding activity. Writes idempotent `## Coding Work` sections into
Obsidian daily notes with project breakdowns and commit stats.
2026-05-26 14:52:09 +02:00

141 lines
4.2 KiB
Python

"""Read recent editor activity from Zed's SQLite database."""
import sqlite3
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@dataclass
class EditorSession:
path: str
mtime: int
language: Optional[str]
@dataclass
class ProjectSummary:
project: str
files: List[Tuple[str, int]] # (relative_path, mtime)
languages: set
def _find_zed_db() -> Optional[Path]:
"""Locate the most recent Zed stable database."""
zed_db_dir = Path.home() / ".local" / "share" / "zed" / "db"
if not zed_db_dir.exists():
return None
candidates = []
for sub in zed_db_dir.iterdir():
db_file = sub / "db.sqlite"
if db_file.exists():
# Prefer stable, then preview, then anything else
priority = 0
if "stable" in sub.name:
priority = 2
elif "preview" in sub.name:
priority = 1
candidates.append((priority, db_file.stat().st_mtime, db_file))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
return candidates[0][2]
def _resolve_project(file_path: Path, code_dirs: List[Path]) -> Optional[str]:
"""Return the project name for a file path based on code directories."""
for code_dir in code_dirs:
try:
rel = file_path.relative_to(code_dir)
parts = rel.parts
if parts:
return parts[0]
except ValueError:
continue
# Fallback: use parent directory name
return file_path.parent.name if file_path.parent != Path("/") else None
def fetch_zed_activity(
start_ts: int,
end_ts: int,
code_dirs: Optional[List[Path]] = None,
) -> List[ProjectSummary]:
"""Fetch editor activity from Zed for the given time range."""
db_path = _find_zed_db()
if not db_path:
return []
if code_dirs is None:
code_dirs = [Path.home() / "Code"]
code_dirs = [d.expanduser().resolve() for d in code_dirs if d.exists()]
sessions: List[EditorSession] = []
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"""
SELECT path, mtime_seconds, language
FROM editors
WHERE mtime_seconds >= ? AND mtime_seconds < ?
ORDER BY mtime_seconds DESC
""",
(start_ts, end_ts),
)
for row in cursor.fetchall():
path_blob, mtime, language = row
if not path_blob:
continue
try:
path_str = (
path_blob.decode("utf-8")
if isinstance(path_blob, bytes)
else str(path_blob)
)
except (UnicodeDecodeError, AttributeError):
continue
sessions.append(
EditorSession(path=path_str, mtime=mtime, language=language)
)
except sqlite3.Error as e:
print(f"Warning: could not read Zed database: {e}")
return []
# Group by project
by_project: Dict[str, List[EditorSession]] = defaultdict(list)
for s in sessions:
fp = Path(s.path)
project = _resolve_project(fp, code_dirs)
if not project:
project = fp.parent.name
by_project[project].append(s)
summaries = []
for project, items in by_project.items():
files = []
seen = set()
languages = set()
for item in items:
if item.language:
languages.add(item.language)
rel = item.path
# Store unique files with their latest mtime
key = rel
if key not in seen:
seen.add(key)
files.append((rel, item.mtime))
# Sort by mtime descending
files.sort(key=lambda x: x[1], reverse=True)
summaries.append(
ProjectSummary(project=project, files=files, languages=languages)
)
# Sort projects by number of files, then by latest mtime
summaries.sort(key=lambda s: (-len(s.files), -s.files[0][1] if s.files else 0))
return summaries