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