From b0229b63325a59e4de8c391695e205ad20f7b9e0 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 26 May 2026 14:38:26 +0200 Subject: [PATCH] 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. --- .zed/tasks.json | 15 ++ todoObsidian/README.md | 119 +++++++++++++ todoObsidian/main.py | 116 +++++++++++++ todoObsidian/todo-to-obsidian.sh | 12 ++ .../utils/__pycache__/config.cpython-314.pyc | Bin 0 -> 3601 bytes .../utils/__pycache__/scanner.cpython-314.pyc | Bin 0 -> 7227 bytes todoObsidian/utils/config.py | 63 +++++++ todoObsidian/utils/scanner.py | 164 ++++++++++++++++++ 8 files changed, 489 insertions(+) create mode 100644 .zed/tasks.json create mode 100644 todoObsidian/README.md create mode 100644 todoObsidian/main.py create mode 100755 todoObsidian/todo-to-obsidian.sh create mode 100644 todoObsidian/utils/__pycache__/config.cpython-314.pyc create mode 100644 todoObsidian/utils/__pycache__/scanner.cpython-314.pyc create mode 100644 todoObsidian/utils/config.py create mode 100644 todoObsidian/utils/scanner.py 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 0000000000000000000000000000000000000000..31687b9e61ec775e485441d241e1f8530f188a27 GIT binary patch literal 3601 zcmcH*O>Y~=b@qcxNu)?h)R*;Pt!&2uZHcrTtBoapM3OC=u`07n%%rF+c1;eYO~~EN z>{3+-BhaA-Nl78aZXq4qK!+R>AV5)|Kzj5cKY)rNh}|lvffQ(aldBX)Z+$c5QnD(f zEs8#1XWz`cc{A_(o%Pk%A%NdKXnyP0ZiK#}69{B7m}1R@IJ@SyK@Th0f;*gI>&!h=-8Un!kk zo64%mStXyfY>8kiPqdIgD5Wi!79sRjc)SGCF5uFOb9MB`oCn@lR2rdx`IlSx8tOZ~+>4#PZphPpWE zDIFfod~E(Iw@cCAT5M$xC=gQ+MJTx7gQtpnB@l`#=tZ^+Dw?1wnsgKiI~OW=jEZ&$ z99N1_euu!>Gq?m719v_Q&c1W=w4dEv>(IsFoEM$vo%?5CeiBWx5TP_jId=?r9ENz6 zgTW_w=eG1KhvckM{lDcbJ0X*$`qWsUidK;WfhrsfMngyl|A{`+I+YM;gH@JQ#L;~K z-4XMA80Gn~PK4gM7Hh7OR1t^P(cCl(qFWFu32}nI1>RSSjw8cmh?A|(p@hq^+7$Ql zo8VPoHF%3_3LOkFu;$SxegP*yBjAU}9FB1@A$U>TbzX2d&u|BJrTx`B-wpB@qKZQ9 z!gw5;dUh7e)(qayHY|K)1M-ZVA$m?89goTineLUbHY?96q*sRAl*Snv<#JX}u`FhW z3mplz!Vac&$YBtSGM-aSYf!d*w?|%!UW*P-Boo7T#$Sj0RY_KbV?O{ss*%OO8!(#s z@{kc#O)i`Y%dcTee*P%EJ#S*UuaD7(3qKjv^Vu}JEsZTqa;kQuG-3J9{xQnM{8!!} zqDpivhc%0esw(%gI2@{HiOa6Ta|R@9xSZH-QdXTUF$qn(VNA{93Edi_ufI-+PHbK` zZFhyEElgv}7HOul-735?trA;O%_RK`S*+RKcwV#A9A;-j5}`@Kb}_id?ravLQcRU%ZR5TWzC~My8}Ccrj;K(^lt~wYy{41 z270#x7d8SHHUr;VaFzT`%l8-WFTc0+-cIAm;_2bXFFbB7o{DZZzFPFY3cCy5KLr|B z>prYo^?m3oc1Jb?=jNk-{BE9}!1ATVOUqZ5u9Sjp3*+BPsHtuB;)9D1ht}J-f@kN) zOYY#dyM4pmzU}V((%o6=3T=1wZ*=v4)%DV5!^lS0OKaTcbHADUys+A{(gV{4sn{^G zZV5Q~#AFuU)klblJUTQr1$@nK%b`du?07JvT zo*)E$yGW?_eOre-t-Cx|JHo-DM}kF{^pC#+Z9OgP!=k6-$1t@4bdb9v_}-Vcs1of z7a$hS$8k=Fi~`|Bu#r_#SWe9-L`i|mn{r+=l?-OhO$Ne+$_|O6_R^KX6OqaLSI^j7 zvcQMs5KrTaowi-jH>d`55vSmB5IWkdl2y|-pPDi0Yrx~n?%rT38njLYoOC#ov#`%p zRyNQRU;F%xKlqv#_>w#M zlhl);`GxABrlA2fY4R>7;^pP5OIJ(& z=7sta%!d|-=C76NgUfdp@6M0yBR>E9jZ&Sj)Y86^SnFSVV=cO#S%0$_3@_A_n%cLT zURbCt`P;Vr-5dVyZGX>~{+?2>6VhU-#=p}4bMKOy`AvwM2zhLGGMUy>&@$?w6giR@QzSDp zbS%NUp|=M7SfE}v>DobD!~wR5gL+Y;NP+#aKf1^QyFmX~krT<&EaGDOC;zA@fw$_9 z_B)pxN>*fV3+$!Nx!>IPaH*=ebYHL;Lf%|h1QV!plIg=Tff zX<@k1>})kgc^h&zt)`gBY(aN-vXK3(5ORQ=AQ#9D@_F-&=ycFs18&QY5+BYe4r-KR!}o&8|Vd43urs2732r)0JVYIK^>sLSwZ(;d@oK5 zdPyjVnRiyRgjX*O?F8P%u{YEOyqn{)&>rA!j>|)Pf%kD-5fYl2fB$Ur$nitReOfe< zOe(5xBBlDYR7Q;|zGy0@_$J~BMGGAB3x*>W(Ie4BMAPtL8II^ve$lWTibr+BawM+l zQF9e3E^Gx`ZpTLin|qq+0ybDYbZbb$fGmc6Bo#{y z>dJH!V!?hXjY)G^EBg_K~1 zClxj!ke)Hz;V?ENrAKrn95$-VB?+uecA!hG3+#`V*^k+OI7==bUXqz0ELoYWVR_K^~kx%tQd$QH5x@~G@Zuh6wnlq6i_v=$rPb+Fl`ET#v_Orwbit#{4k*k0jZZAgJa3N!DG)yJd1=Av=g;8OQ6bTP6g6EY-2#Nr$ zSqm9!qUW_XO!HqCLc;EX!rETQ`Lp*RKD&p!0v$1&s{&xy&HeB9%Z63UOiaYj8*(D` zrlK0&@QL16j)nV9jr1KmF?jsg3By9?-EiU@t2(5WbiCS#nHUnZA=hy&KE9RE586~J zlZb^$X{l`}!}l}-kY$U`lIv|(+ZLR)bI#g%XMMJJ(dK+u((usM@Tjz6(dN25b8+VK zTNmG&x794zn&)iIcaALBc7AHx`K;{23%1~=wji<}xxA~QQCMt7t^^m2e%}9gZNsxf zc~uPD6NJhVNS_-rN8DpcuaH&~tH6A0= z#KZS|rax%*)X0YZnK2qG>XI5;7eZEeSGK){J!(X$X69pKMZIEWF<@G?5iu>*vm^f) zX9p0+1;1l97*e7cRg0ff5;NQ#^F@5IxC+l9rOt4_j5})4)bzBH)U|*i?08B$l3wcq zEyuvNqK_;aVf`(Py-~KR#YyKK5X=>ugzcr#U^bA2^&wQ;{qrmf-+QPvKX>vQ_edDC zXbA8OMYs(oh6abjhXz9@R7eFwk{IYD%K#-pM#Iz@j)Rj*QaPVidyFPh8{y*o0tXW* z*h8%+P%LsD6*bZ;UtuOWCYCxM^^X2>_dAE@+zkuv?g#GfJKH`Ceh|FhJioR3p}Tv& zcl5tB64D=6bP7M)(kUDp>3?d0Xo|FH%ZI?SY^->caIA`^4OxX?ZaQ);X4-1pMJ7-< z1}jK1q>}inkY=PAg3e7|OI}af4<;hhNR1n|xCZwO{;Nw>67ayzDPcWjI7cIi zjMAs7DbQZ--x{TtEE)kZ&5oi1VbkOR5N7AnmS|H&4x8v zix!`+UIKj~VcU0>`S`fbmzdme;NJ~(l5u6J}< zLgMwV>s{BLlmCQ?Zs*s;;0$}Jz9_A|)wfW(_0!U==yK8Sx$eH|e&?_5$n#aZKez8$ zs%Pc?oR7)Q%ZD!>o~ziEJ^awpwdnF@kNokQYUbVY4HNhvuMOlRpcRdj#Qn0~dhwHb zPj83(NxJ~frOlKo8%mJGEr<9@{7H+1Quz>)RvoQpSSNl@%DZ0<+PS=GqHW{$Neuu! z&n8_$zV-qb*(5L7B(U4taunT*@#tgyR|Eg@Bc72rO>0jGNjslg4N|iVrb(=Q>O6&D zltQ>~?ImeJI3R?DjF^$aMPrbbU^F&RI;OEmTSEM|yHKW)jTPOkQC z8la5~MzOYI#IbHX35UG)p9NTQBeg7Z6e>G$^cfp)JRQY>( zRXzeO=3{NEW*Mtk$KqlmtA|;j-;rPC4HV2+VK32s(V%sSYfV~4tcN3AG-)(z{qubMwHB1GnCIyJw+d`&`BL`3nD)LtmCw zT-6tAw=L9m&ee9_ubZ#!`Kf);TXoZY!+rbI-F^QWeOQ0;bMNR2~U-Y)iDS+7sNX9xPk=j!cre9~qi z+$j<7syO73eq$HFH`bv);!E^=9lEMD*Lw1}wr(J|s}9k|wYtQ$ZP0B1K0oOnm1Kb{ zS;#7R%=woK>Vn^VpQm+iqL9`OMp{=E%9n`UuDm_<_YFCM_YQqrnOD0HcJ_VN-p}*D zdoQ3~p|UK?DsL*t?*&kTQ2Oxvg^g|q@a_xB5><*Jz)b&kL*KusxS%dNO|O@np4n}$ z(h>4;cQlT7I?XpeE>dAH@x z)_MC23-7+e;hqon+-;h#X}|l%XEmKy ztk*MFGe7aH6O^7Bw1fAK_J&RFk z{z}6Z4&zY>&#|J4Q5K)_6-_10VA$x)CgS7y=R2!DlZJs-xmqh~Im3p#XPT}V&cRoY z9S`;Oz5*kB3e{EeT~xY!^2-Ik1#tVu4=>*smHyqr52AT3%D*)CsDFbDI+vOiBZ1iv zgh$N(2j=)aYx+H_`I6P=tW0!05iLSVj)5&p_=KUvC+twk_5IiOli6&_Ihky`eDvbc zWsl7*X6?Dk)=F{F;>byeA0w6#qb=E$vk|hx-Oo7)p`t72BIIVWJLe%(!emFTl#rLo zl@ThZautLsDYuHyCQ7R&R72%96WT(kmQWp`dO{6^8VTW=;m$P?+Ddhs32md?7YMZw z+D@pI$)22_&<;v#Bh*f59fSfjf*_$zYGo&(U4*&_?WP)g2z3+MOK2bEzDQ_4r5zx2 zkjC;CgnB6VB|Y?>!4$&_ literal 0 HcmV?d00001 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