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:
2026-05-26 14:38:26 +02:00
parent ef411fbe23
commit b0229b6332
8 changed files with 489 additions and 0 deletions
+119
View File
@@ -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.)
+116
View File
@@ -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()
+12
View File
@@ -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
+63
View File
@@ -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
+164
View File
@@ -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