diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..4c84544 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,15 @@ +[ + { + "label": "Scan TODOs to Obsidian", + "command": "python3", + "args": [ + "todoObsidian/main.py", + "{ZED_WORKTREE_ROOT}" + ], + "tags": ["file-save"], + "cwd": "{ZED_WORKTREE_ROOT}", + "reveal": "no", + "hide": "on_success", + "allow_concurrent_runs": false + } +] diff --git a/todoObsidian/README.md b/todoObsidian/README.md new file mode 100644 index 0000000..a118ad3 --- /dev/null +++ b/todoObsidian/README.md @@ -0,0 +1,119 @@ +# todoObsidian + +Scan a codebase for `TODO`, `FIXME`, `HACK`, and `XXX` comments and create a Markdown note per project inside your Obsidian vault. + +## Installation + +No external dependencies (uses the Python 3 standard library). + +Make the wrapper executable: + +```bash +chmod +x todo-to-obsidian.sh +``` + +## Usage + +### Basic + +```bash +python3 main.py /path/to/your/project +``` + +### Specify vault path + +```bash +python3 main.py /path/to/your/project --vault ~/Obsidian/MyVault +``` + +### Custom project name + +```bash +python3 main.py /path/to/your/project --name "My Awesome App" +``` + +### Custom output folder inside vault + +```bash +python3 main.py /path/to/your/project --output-dir "Project TODOs" +``` + +### Using the shell wrapper + +```bash +./todo-to-obsidian.sh /path/to/your/project +``` + +### Zed integration (run on file-save) + +A Zed task is included in `.zed/tasks.json` that automatically scans the workspace and syncs TODOs to Obsidian every time you save a file. + +1. Open the `obsidianUtils` folder as a workspace in Zed. +2. Save any file — the task runs in the background. +3. Check your Obsidian vault under `Todos/TODOs obsidianUtils.md`. + +If the task does not run automatically on save, you can also trigger it manually via **cmd-shift-p → tasks: run task → "Scan TODOs to Obsidian"** (or bind it to a key in your `keymap.json`). + +#### Customizing the Zed task + +You can edit `.zed/tasks.json` to: +- Change `--output-dir` +- Set a custom `--name` +- Add `--vault` if `OBSIDIAN_VAULT` is not set + +Example with explicit vault: +```json +{ + "label": "Scan TODOs to Obsidian", + "command": "python3", + "args": [ + "todoObsidian/main.py", + "{ZED_WORKTREE_ROOT}", + "--vault", + "/Users/you/Obsidian/MyVault" + ], + "tags": ["file-save"], + "cwd": "{ZED_WORKTREE_ROOT}", + "reveal": "no", + "hide": "on_success", + "allow_concurrent_runs": false +} +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `OBSIDIAN_VAULT` | Path to your Obsidian vault (used if `--vault` is omitted) | + +## Supported File Types + +- Python, JavaScript, TypeScript, Java, C, C++, Rust, Go, Ruby, Shell, Swift, Kotlin, Scala, R, Lua, PHP, C#, F#, Markdown, YAML, JSON, TOML, INI, and Makefiles/Dockerfiles. + +## Output Format + +A note is created under `Todos/` in your vault: + +```markdown +# TODOs – ProjectName + +_Scanning on 2026-05-26 14:30_ + +## Summary +- **TODO**: 3 +- **FIXME**: 1 + +## By File + +### `src/main.py` +- [ ] **TODO** (line 42) – refactor this function +- [ ] **FIXME** (line 55) – handle edge case + +### `README.md` +- [ ] **TODO** (line 10) – add installation instructions +``` + +## What Gets Skipped + +- Hidden directories (`.git`, `.vscode`, etc.) +- Common build / dependency folders (`node_modules`, `venv`, `target`, `build`, etc.) diff --git a/todoObsidian/main.py b/todoObsidian/main.py new file mode 100644 index 0000000..08b0190 --- /dev/null +++ b/todoObsidian/main.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Obsidian TODO Scanner + +Scan a project directory for TODO / FIXME / HACK / XXX comments and +emit a Markdown note into an Obsidian vault. + +Usage: + python main.py /path/to/project + python main.py /path/to/project --vault ~/Obsidian/MyVault + python main.py /path/to/project --name "My Project" +""" + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import List + +from utils.config import ensure_unique_path, get_vault_path, sanitize_filename +from utils.scanner import TodoItem, count_tags, group_by_file, scan_directory + + +def generate_markdown(project_name: str, todos: List[TodoItem]) -> str: + lines = [] + lines.append(f"# TODOs – {project_name}") + lines.append("") + lines.append(f"_Scanned on {datetime.now().strftime('%Y-%m-%d %H:%M')}_") + lines.append("") + + if not todos: + lines.append("No TODO comments found. 🎉") + return "\n".join(lines) + + tag_counts = count_tags(todos) + lines.append("## Summary") + for tag, count in sorted(tag_counts.items()): + lines.append(f"- **{tag}**: {count}") + lines.append("") + + groups = group_by_file(todos) + lines.append("## By File") + lines.append("") + + for file_path, items in sorted(groups.items(), key=lambda x: str(x[0])): + lines.append(f"### `{file_path}`") + for item in items: + # Obsidian checkbox + link to line in backticks + lines.append(f"- [ ] **{item.tag}** (line {item.line}) – {item.text}") + lines.append("") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Scan a project for TODOs and write them to an Obsidian vault." + ) + parser.add_argument("project", help="Path to the project directory to scan.") + parser.add_argument( + "--vault", + dest="vault", + default=None, + help="Path to the Obsidian vault. Overrides OBSIDIAN_VAULT env var.", + ) + parser.add_argument( + "--name", + dest="name", + default=None, + help="Project name for the note title. Defaults to the directory name.", + ) + parser.add_argument( + "--output-dir", + dest="output_dir", + default="Todos", + help="Subdirectory inside the vault where the note is created (default: Todos).", + ) + args = parser.parse_args() + + project_path = Path(args.project).expanduser().resolve() + if not project_path.exists(): + print(f"Error: project path does not exist: {project_path}", file=sys.stderr) + sys.exit(1) + if not project_path.is_dir(): + print(f"Error: not a directory: {project_path}", file=sys.stderr) + sys.exit(1) + + try: + vault_path = get_vault_path(args.vault) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + project_name = args.name or project_path.name + print(f"🔍 Scanning {project_path} ...") + todos = scan_directory(project_path) + print(f" Found {len(todos)} item(s).") + + md_content = generate_markdown(project_name, todos) + + note_dir = vault_path / args.output_dir + note_dir.mkdir(parents=True, exist_ok=True) + + safe_name = sanitize_filename(f"TODOs {project_name}") + note_path = note_dir / f"{safe_name}.md" + note_path = ensure_unique_path(note_path) + + try: + note_path.write_text(md_content, encoding="utf-8") + print(f"✅ Note written to: {note_path}") + except OSError as e: + print(f"Error writing note: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/todoObsidian/todo-to-obsidian.sh b/todoObsidian/todo-to-obsidian.sh new file mode 100755 index 0000000..f1a317d --- /dev/null +++ b/todoObsidian/todo-to-obsidian.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Wrapper script for importing TODOs into Obsidian. +# Adjust VAULT_PATH below or leave empty to rely on ~/.bashrc / env / auto-detection. + +VAULT_PATH="" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -n "$VAULT_PATH" ]; then + python3 "$SCRIPT_DIR/main.py" --vault "$VAULT_PATH" "$@" +else + python3 "$SCRIPT_DIR/main.py" "$@" +fi diff --git a/todoObsidian/utils/__pycache__/config.cpython-314.pyc b/todoObsidian/utils/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..31687b9 Binary files /dev/null and b/todoObsidian/utils/__pycache__/config.cpython-314.pyc differ diff --git a/todoObsidian/utils/__pycache__/scanner.cpython-314.pyc b/todoObsidian/utils/__pycache__/scanner.cpython-314.pyc new file mode 100644 index 0000000..6495842 Binary files /dev/null and b/todoObsidian/utils/__pycache__/scanner.cpython-314.pyc differ diff --git a/todoObsidian/utils/config.py b/todoObsidian/utils/config.py new file mode 100644 index 0000000..f8cd549 --- /dev/null +++ b/todoObsidian/utils/config.py @@ -0,0 +1,63 @@ +"""Configuration and common utilities for Obsidian import.""" + +import os +from pathlib import Path +from typing import Optional + +DEFAULT_CONFIG_PATH = Path.home() / ".config" / "obsidian-import" / "config.ini" + + +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 + + # Try common default locations + 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 sanitize_filename(name: str) -> str: + """Remove or replace characters unsafe for filenames.""" + invalid = '<>:"/\\|?*' + for ch in invalid: + name = name.replace(ch, "_") + return name.strip(". ") + + +def ensure_unique_path(target: Path) -> Path: + """If target exists, append a counter to make it unique.""" + if not target.exists(): + return target + stem = target.stem + suffix = target.suffix + parent = target.parent + counter = 1 + while True: + new_name = f"{stem}_{counter}{suffix}" + candidate = parent / new_name + if not candidate.exists(): + return candidate + counter += 1 diff --git a/todoObsidian/utils/scanner.py b/todoObsidian/utils/scanner.py new file mode 100644 index 0000000..2ac6cfa --- /dev/null +++ b/todoObsidian/utils/scanner.py @@ -0,0 +1,164 @@ +"""TODO scanner for source code files.""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List + + +@dataclass +class TodoItem: + file: Path + line: int + tag: str + text: str + + +# Regex for common comment prefixes followed by TODO/FIXME/HACK/XXX +_TODO_RE = re.compile( + r"(?:^\s*(?:#|//|/\*|\*|\s*)?$", # optional block-comment / html close + re.IGNORECASE, +) + +# File extensions to scan +_SCAN_EXTENSIONS = { + ".py", + ".js", + ".jsx", + ".ts", + ".tsx", + ".java", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".rs", + ".go", + ".rb", + ".sh", + ".bash", + ".zsh", + ".pl", + ".pm", + ".swift", + ".kt", + ".scala", + ".r", + ".lua", + ".php", + ".cs", + ".fs", + ".fsx", + ".md", + ".yaml", + ".yml", + ".json", + ".toml", + ".ini", + ".cfg", + ".conf", + ".dockerfile", + ".makefile", + ".mk", +} + +_SKIP_DIRS = { + ".git", + ".hg", + ".svn", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + "node_modules", + "vendor", + "target", + "build", + "dist", + ".next", + ".nuxt", + ".terraform", + ".venv", + "venv", + "env", + ".idea", + ".vscode", + ".vs", + "out", + "coverage", + "site-packages", + "egg-info", +} + + +def _should_scan(path: Path) -> bool: + ext = path.suffix.lower() + if ext in _SCAN_EXTENSIONS: + return True + if path.name.lower().startswith("dockerfile"): + return True + if path.name.lower().startswith("makefile"): + return True + return False + + +def scan_directory(project_path: Path) -> List[TodoItem]: + """Recursively scan a directory for TODO comments.""" + todos: List[TodoItem] = [] + for entry in project_path.rglob("*"): + if not entry.is_file(): + continue + # Skip hidden dirs + try: + relative = entry.relative_to(project_path) + except ValueError: + relative = entry + if any( + part in _SKIP_DIRS or part.startswith(".") for part in relative.parts[:-1] + ): + continue + if not _should_scan(entry): + continue + todos.extend(_scan_file(entry, project_path)) + # Sort by file path then line number + todos.sort(key=lambda t: (str(t.file), t.line)) + return todos + + +def _scan_file(file_path: Path, project_path: Path) -> List[TodoItem]: + todos: List[TodoItem] = [] + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + except (OSError, UnicodeDecodeError): + return todos + + for lineno, raw_line in enumerate(lines, start=1): + match = _TODO_RE.search(raw_line) + if match: + tag = match.group(1).upper() + text = match.group(2).strip() + if text: + rel_file = file_path.relative_to(project_path) + todos.append(TodoItem(file=rel_file, line=lineno, tag=tag, text=text)) + return todos + + +def group_by_file(todos: List[TodoItem]) -> Dict[Path, List[TodoItem]]: + """Group TODO items by their file path.""" + groups: Dict[Path, List[TodoItem]] = {} + for t in todos: + groups.setdefault(t.file, []).append(t) + return groups + + +def count_tags(todos: List[TodoItem]) -> Dict[str, int]: + """Count occurrences of each tag.""" + counts: Dict[str, int] = {} + for t in todos: + counts[t.tag] = counts.get(t.tag, 0) + 1 + return counts