Add todoObsidian tool for syncing TODOs to Obsidian
Scans codebases for TODO/FIXME/HACK/XXX comments and generates Markdown notes in an Obsidian vault. Includes Zed editor integration that runs on file save, plus a shell wrapper for CLI usage. Supports customizable vault paths, project names, and output directories.
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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.)
|
||||
@@ -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()
|
||||
Executable
+12
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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*)" # comment prefix
|
||||
r"(TODO|FIXME|HACK|XXX)" # tag
|
||||
r"[\s:]*" # separator
|
||||
r"(.*?)" # text
|
||||
r"(?:\s*\*/\s*|\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
|
||||
Reference in New Issue
Block a user