diff --git a/dailySummary/README.md b/dailySummary/README.md new file mode 100644 index 0000000..040523e --- /dev/null +++ b/dailySummary/README.md @@ -0,0 +1,43 @@ +# Daily Coding Summary + +Track your coding activity from **Zed** editors and **Git** commits, then write a summary into your Obsidian daily note. + +## Features + +- **Zed Integration** – reads Zed's SQLite database to find which files you edited today and groups them by project. +- **Git Tracking** – scans `~/Code` (or custom directories) for git repositories and collects today's commits with stats (insertions, deletions, files changed). +- **Obsidian Daily Notes** – writes or appends a `## Coding Work` section to your daily note (`YYYY-MM-DD.md`). +- **Idempotent** – running multiple times on the same day replaces the existing `## Coding Work` section rather than duplicating it. + +## Usage + +```bash +# Use default vault (from OBSIDIAN_VAULT env var or ~/Obsidian) +python main.py + +# Specify vault and code directories +python main.py --vault ~/Documents/obsidian_vault --code-dir ~/Code --code-dir ~/Work + +# Dry-run to preview without writing +python main.py --dry-run + +# Summarize a specific day +python main.py --date 2026-05-20 + +# Skip Zed or Git tracking +python main.py --no-zed +python main.py --no-git +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `OBSIDIAN_VAULT` | Path to your Obsidian vault | auto-detected from `~/Obsidian` | +| `OBSIDIAN_DAILY_FOLDER` | Folder inside the vault for daily notes | `Daily Notes` | + +## Requirements + +- Python 3.9+ +- Git (for commit tracking) +- Zed editor (optional, for editor tracking) diff --git a/dailySummary/__pycache__/main.cpython-314.pyc b/dailySummary/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..1ec1bd1 Binary files /dev/null and b/dailySummary/__pycache__/main.cpython-314.pyc differ diff --git a/dailySummary/daily-summary.sh b/dailySummary/daily-summary.sh new file mode 100755 index 0000000..c8e7bb6 --- /dev/null +++ b/dailySummary/daily-summary.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$SCRIPT_DIR" +python3 main.py "$@" diff --git a/dailySummary/main.py b/dailySummary/main.py new file mode 100644 index 0000000..a91ae77 --- /dev/null +++ b/dailySummary/main.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Daily Coding Summary for Obsidian + +Track coding activity from Zed editors and Git commits, then write +a summary into your Obsidian daily note. + +Usage: + python main.py + python main.py --vault ~/Obsidian/MyVault + python main.py --code-dir ~/Code --code-dir ~/Work + python main.py --date 2026-05-20 +""" + +import argparse +import sys +from datetime import datetime, timezone +from pathlib import Path + +from utils.config import date_range, get_vault_path +from utils.git_tracker import fetch_git_activity +from utils.obsidian_writer import write_daily_summary +from utils.zed_tracker import fetch_zed_activity + + +def parse_date(date_str: str) -> datetime: + """Parse YYYY-MM-DD string into a datetime object.""" + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + return dt.replace(tzinfo=timezone.utc).astimezone() + except ValueError: + raise argparse.ArgumentTypeError( + f"Invalid date format: '{date_str}'. Expected YYYY-MM-DD." + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Track daily coding activity and write it to Obsidian." + ) + parser.add_argument( + "--vault", + dest="vault", + default=None, + help="Path to the Obsidian vault. Overrides OBSIDIAN_VAULT env var.", + ) + parser.add_argument( + "--code-dir", + dest="code_dirs", + action="append", + default=None, + help="Directory to scan for git repos (default: ~/Code). Can be given multiple times.", + ) + parser.add_argument( + "--daily-folder", + dest="daily_folder", + default=None, + help="Folder inside the vault for daily notes (default: Daily Notes). Overrides OBSIDIAN_DAILY_FOLDER env var.", + ) + parser.add_argument( + "--date", + dest="date", + type=parse_date, + default=None, + help="Date to summarize (YYYY-MM-DD). Defaults to today.", + ) + parser.add_argument( + "--no-zed", + dest="no_zed", + action="store_true", + help="Skip Zed editor tracking.", + ) + parser.add_argument( + "--no-git", + dest="no_git", + action="store_true", + help="Skip Git commit tracking.", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Print the summary instead of writing to Obsidian.", + ) + + args = parser.parse_args() + + try: + vault_path = get_vault_path(args.vault) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if args.daily_folder: + daily_folder = args.daily_folder + else: + import os + + daily_folder = os.environ.get("OBSIDIAN_DAILY_FOLDER", "Daily Notes") + + code_dirs = args.code_dirs + if code_dirs: + code_dirs = [Path(d).expanduser().resolve() for d in code_dirs] + else: + code_dirs = [Path.home() / "Code"] + + target_date = args.date or datetime.now(timezone.utc).astimezone() + start_ts, end_ts = date_range(target_date) + + zed_projects = [] + git_repos = [] + + if not args.no_zed: + print("🔍 Reading Zed editor history…") + zed_projects = fetch_zed_activity(start_ts, end_ts, code_dirs=code_dirs) + print(f" Found {len(zed_projects)} project(s) with editor activity.") + + if not args.no_git: + print("🔍 Scanning git repositories…") + git_repos = fetch_git_activity(start_ts, end_ts, code_dirs=code_dirs) + print(f" Found {len(git_repos)} repo(s) with commits today.") + + if args.dry_run: + from utils.obsidian_writer import generate_markdown + + print("\n--- Daily Summary ---\n") + print(generate_markdown(zed_projects, git_repos, target_date)) + print("\n---------------------") + return + + note_path = write_daily_summary( + vault_path=vault_path, + zed_projects=zed_projects, + git_repos=git_repos, + daily_folder=daily_folder, + date=target_date, + ) + print(f"✅ Daily note updated: {note_path}") + + +if __name__ == "__main__": + main() diff --git a/dailySummary/utils/__init__.py b/dailySummary/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dailySummary/utils/__pycache__/__init__.cpython-314.pyc b/dailySummary/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..24f6fcf Binary files /dev/null and b/dailySummary/utils/__pycache__/__init__.cpython-314.pyc differ diff --git a/dailySummary/utils/__pycache__/config.cpython-314.pyc b/dailySummary/utils/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..3106831 Binary files /dev/null and b/dailySummary/utils/__pycache__/config.cpython-314.pyc differ diff --git a/dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc b/dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc new file mode 100644 index 0000000..2c0386a Binary files /dev/null and b/dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc differ diff --git a/dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc b/dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc new file mode 100644 index 0000000..a7cf0e6 Binary files /dev/null and b/dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc differ diff --git a/dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc b/dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc new file mode 100644 index 0000000..a94e2a9 Binary files /dev/null and b/dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc differ diff --git a/dailySummary/utils/config.py b/dailySummary/utils/config.py new file mode 100644 index 0000000..0929e3f --- /dev/null +++ b/dailySummary/utils/config.py @@ -0,0 +1,61 @@ +"""Configuration and common utilities for daily summary.""" + +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + + +def get_vault_path(cli_vault: Optional[str] = None) -> Path: + """Resolve the Obsidian vault path from CLI arg, env var, or default locations.""" + if cli_vault: + vault = Path(cli_vault).expanduser().resolve() + if not vault.exists(): + raise FileNotFoundError(f"Vault path does not exist: {vault}") + return vault + + env_vault = os.environ.get("OBSIDIAN_VAULT") + if env_vault: + vault = Path(env_vault).expanduser().resolve() + if vault.exists(): + return vault + + obsidian_dir = Path.home() / "Obsidian" + if obsidian_dir.exists(): + vaults = [d for d in obsidian_dir.iterdir() if d.is_dir()] + if len(vaults) == 1: + return vaults[0] + elif len(vaults) > 1: + raise RuntimeError( + f"Multiple vaults found in {obsidian_dir}. " + "Set OBSIDIAN_VAULT or use --vault." + ) + + raise RuntimeError( + "Could not determine Obsidian vault path. " + "Use --vault or set OBSIDIAN_VAULT environment variable." + ) + + +def get_daily_notes_folder(vault_path: Path) -> Path: + """Return the daily notes folder, defaulting to 'Daily Notes'.""" + folder = os.environ.get("OBSIDIAN_DAILY_FOLDER", "Daily Notes") + return vault_path / folder + + +def date_range(dt: datetime) -> tuple[int, int]: + """Return Unix timestamp range for the given datetime's date (start, end).""" + start = datetime(dt.year, dt.month, dt.day, tzinfo=dt.tzinfo or timezone.utc) + end = start + timedelta(days=1) + return int(start.timestamp()), int(end.timestamp()) + + +def format_duration(seconds: int) -> str: + """Format seconds as 'Xh Ym' or 'Ym'.""" + if seconds < 60: + return f"{seconds}s" + hours, remainder = divmod(seconds, 3600) + minutes = remainder // 60 + if hours: + return f"{hours}h {minutes}m" + return f"{minutes}m" diff --git a/dailySummary/utils/git_tracker.py b/dailySummary/utils/git_tracker.py new file mode 100644 index 0000000..2a7782d --- /dev/null +++ b/dailySummary/utils/git_tracker.py @@ -0,0 +1,225 @@ +"""Scan git repositories for today's commits.""" + +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + + +@dataclass +class CommitInfo: + hash: str + message: str + author: str + timestamp: int + files_changed: int + insertions: int + deletions: int + + +@dataclass +class RepoSummary: + name: str + path: Path + commits: List[CommitInfo] + total_insertions: int + total_deletions: int + total_files: int + + +def _run_git(args: List[str], cwd: Path, check: bool = True) -> str: + """Run a git command and return stdout.""" + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + check=check, + ) + if result.returncode != 0 and check: + raise subprocess.CalledProcessError( + result.returncode, + ["git"] + args, + output=result.stdout, + stderr=result.stderr, + ) + return result.stdout + + +def _parse_git_date(date_str: str) -> int: + """Parse a git date string into Unix timestamp. + + Handles both ISO ('2026-05-26 14:30:00 +0200') and + RFC ('Tue, 26 May 2026 14:30:00 +0200') formats. + """ + date_str = date_str.strip() + # Try ISO format first + for fmt in ("%Y-%m-%d %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S %z"): + try: + dt = datetime.strptime(date_str, fmt) + return int(dt.timestamp()) + except ValueError: + continue + return 0 + + +def _get_commits_for_date( + repo_path: Path, start_ts: int, end_ts: int +) -> List[CommitInfo]: + """Get commits in the given timestamp range.""" + # Use --since and --until with ISO format which git understands reliably + since = datetime.fromtimestamp(start_ts, tz=timezone.utc).isoformat() + until = datetime.fromtimestamp(end_ts, tz=timezone.utc).isoformat() + + log_format = "%H%x00%s%x00%an%x00%ad%x00" + try: + output = _run_git( + [ + "log", + f"--since={since}", + f"--until={until}", + f"--format={log_format}", + "--date=iso", + "--no-merges", + ], + cwd=repo_path, + check=False, + ) + except subprocess.CalledProcessError: + return [] + + if not output.strip(): + return [] + + commits = [] + entries = output.strip().split("\n") + for entry in entries: + parts = entry.split("\x00") + if len(parts) < 4: + continue + commit_hash, message, author, date_str = parts[0], parts[1], parts[2], parts[3] + ts = _parse_git_date(date_str) + + # Get stats for this commit + try: + stat_output = _run_git( + ["show", "--stat", "--format=", commit_hash], + cwd=repo_path, + check=False, + ) + except subprocess.CalledProcessError: + stat_output = "" + + files_changed, insertions, deletions = 0, 0, 0 + for line in stat_output.splitlines(): + line = line.strip() + if "file changed" in line or "files changed" in line: + # Parse line like "3 files changed, 10 insertions(+), 2 deletions(-)" + parts_stat = line.split(",") + for part in parts_stat: + part = part.strip() + if "changed" in part: + try: + files_changed = int(part.split()[0]) + except ValueError: + pass + elif "insertion" in part: + try: + insertions = int(part.split()[0]) + except ValueError: + pass + elif "deletion" in part: + try: + deletions = int(part.split()[0]) + except ValueError: + pass + + commits.append( + CommitInfo( + hash=commit_hash[:8], + message=message.strip(), + author=author.strip(), + timestamp=ts, + files_changed=files_changed, + insertions=insertions, + deletions=deletions, + ) + ) + + return commits + + +def _is_git_repo(path: Path) -> bool: + """Check if a directory is a git repository.""" + return (path / ".git").is_dir() + + +def find_git_repos(base_dir: Path, max_depth: int = 3) -> List[Path]: + """Find git repositories under base_dir up to max_depth levels.""" + repos = [] + base_dir = base_dir.expanduser().resolve() + if not base_dir.exists(): + return repos + + for depth in range(max_depth + 1): + pattern = "/".join(["*"] * depth) if depth > 0 else "." + if depth == 0: + if _is_git_repo(base_dir): + repos.append(base_dir) + continue + + for candidate in base_dir.glob(pattern): + if candidate.is_dir() and _is_git_repo(candidate): + repos.append(candidate) + + return repos + + +def fetch_git_activity( + start_ts: int, + end_ts: int, + code_dirs: Optional[List[Path]] = None, + max_depth: int = 3, +) -> List[RepoSummary]: + """Scan code directories and return git activity summaries.""" + if code_dirs is None: + code_dirs = [Path.home() / "Code"] + + all_repos = [] + for d in code_dirs: + all_repos.extend(find_git_repos(d, max_depth=max_depth)) + + # Deduplicate + seen = set() + unique_repos = [] + for r in all_repos: + resolved = r.resolve() + if resolved not in seen: + seen.add(resolved) + unique_repos.append(resolved) + + summaries = [] + for repo_path in unique_repos: + commits = _get_commits_for_date(repo_path, start_ts, end_ts) + if not commits: + continue + + total_insertions = sum(c.insertions for c in commits) + total_deletions = sum(c.deletions for c in commits) + total_files = sum(c.files_changed for c in commits) + + summaries.append( + RepoSummary( + name=repo_path.name, + path=repo_path, + commits=commits, + total_insertions=total_insertions, + total_deletions=total_deletions, + total_files=total_files, + ) + ) + + # Sort by number of commits descending + summaries.sort(key=lambda r: -len(r.commits)) + return summaries diff --git a/dailySummary/utils/obsidian_writer.py b/dailySummary/utils/obsidian_writer.py new file mode 100644 index 0000000..b775875 --- /dev/null +++ b/dailySummary/utils/obsidian_writer.py @@ -0,0 +1,112 @@ +"""Write or append daily summaries to Obsidian notes.""" + +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from .git_tracker import RepoSummary +from .zed_tracker import ProjectSummary + +DAILY_NOTE_FORMAT = "%Y-%m-%d" +SECTION_HEADER = "## Coding Work" + + +def _format_time(ts: int) -> str: + """Format a Unix timestamp as HH:MM.""" + dt = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() + return dt.strftime("%H:%M") + + +def generate_markdown( + zed_projects: List[ProjectSummary], + git_repos: List[RepoSummary], + date: datetime, +) -> str: + """Generate the markdown content for the daily summary.""" + lines = [] + lines.append(SECTION_HEADER) + lines.append("") + + if not zed_projects and not git_repos: + lines.append("No coding activity tracked today. 🌴") + return "\n".join(lines) + + if zed_projects: + lines.append("### Zed Editors") + lines.append("") + for project in zed_projects: + langs = ( + f" ({', '.join(sorted(project.languages))})" + if project.languages + else "" + ) + lines.append( + f"- **{project.project}**{langs} — {len(project.files)} file(s)" + ) + # Show up to 5 most recent files + for path, mtime in project.files[:5]: + time_str = _format_time(mtime) + lines.append(f" - `{Path(path).name}` at {time_str}") + if len(project.files) > 5: + lines.append(f" - *…and {len(project.files) - 5} more*") + lines.append("") + + if git_repos: + lines.append("### Git Commits") + lines.append("") + for repo in git_repos: + lines.append( + f"- **{repo.name}** — {len(repo.commits)} commit(s), " + f"+{repo.total_insertions}/-{repo.total_deletions} lines, " + f"{repo.total_files} file(s)" + ) + for commit in repo.commits: + time_str = _format_time(commit.timestamp) + lines.append(f" - `{commit.hash}` {commit.message} — {time_str}") + lines.append("") + + return "\n".join(lines) + + +# Backward compatibility alias +_generate_markdown = generate_markdown + + +def write_daily_summary( + vault_path: Path, + zed_projects: List[ProjectSummary], + git_repos: List[RepoSummary], + daily_folder: str = "Daily Notes", + date: datetime = None, +) -> Path: + """Write or append a coding summary to the Obsidian daily note.""" + if date is None: + date = datetime.now(timezone.utc).astimezone() + + note_dir = vault_path / daily_folder + note_dir.mkdir(parents=True, exist_ok=True) + + note_name = date.strftime(DAILY_NOTE_FORMAT) + note_path = note_dir / f"{note_name}.md" + + content = _generate_markdown(zed_projects, git_repos, date) + + if note_path.exists(): + existing = note_path.read_text(encoding="utf-8") + # If section already exists, replace it; otherwise append + if SECTION_HEADER in existing: + before, _, after = existing.partition(SECTION_HEADER) + # Find the next section header or end of file + next_section_idx = after.find("\n## ") + if next_section_idx != -1: + after = after[next_section_idx:] + else: + after = "" + new_content = before.rstrip() + "\n\n" + content + "\n" + after.lstrip() + else: + new_content = existing.rstrip() + "\n\n" + content + "\n" + else: + new_content = f"# {note_name}\n\n" + content + "\n" + + note_path.write_text(new_content, encoding="utf-8") + return note_path diff --git a/dailySummary/utils/zed_tracker.py b/dailySummary/utils/zed_tracker.py new file mode 100644 index 0000000..36c9cac --- /dev/null +++ b/dailySummary/utils/zed_tracker.py @@ -0,0 +1,140 @@ +"""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