devtodo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devtodo/__init__.py +3 -0
- devtodo/commands/__init__.py +0 -0
- devtodo/commands/add.py +44 -0
- devtodo/commands/done.py +22 -0
- devtodo/commands/edit.py +56 -0
- devtodo/commands/github_cmd.py +120 -0
- devtodo/commands/list.py +21 -0
- devtodo/commands/remove.py +27 -0
- devtodo/db.py +39 -0
- devtodo/github_client.py +50 -0
- devtodo/main.py +230 -0
- devtodo/models.py +17 -0
- devtodo/services/__init__.py +0 -0
- devtodo/services/todo_service.py +118 -0
- devtodo/tui.py +238 -0
- devtodo/utils/__init__.py +0 -0
- devtodo/utils/dates.py +13 -0
- devtodo/utils/output.py +80 -0
- devtodo-0.1.0.dist-info/METADATA +71 -0
- devtodo-0.1.0.dist-info/RECORD +23 -0
- devtodo-0.1.0.dist-info/WHEEL +5 -0
- devtodo-0.1.0.dist-info/entry_points.txt +3 -0
- devtodo-0.1.0.dist-info/top_level.txt +1 -0
devtodo/__init__.py
ADDED
|
File without changes
|
devtodo/commands/add.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import typer
|
|
3
|
+
from devtodo.db import init_db, get_session
|
|
4
|
+
from devtodo.services.todo_service import TodoService
|
|
5
|
+
from devtodo.utils.output import success, error, print_todo
|
|
6
|
+
from devtodo.utils.dates import parse_date
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(help="Add a new todo.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.callback(invoke_without_command=True)
|
|
12
|
+
def add(
|
|
13
|
+
title: str = typer.Argument(..., help="Title of the todo"),
|
|
14
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="Optional description"),
|
|
15
|
+
priority: str = typer.Option("medium", "--priority", "-p", help="Priority: low, medium, high"),
|
|
16
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="Project name"),
|
|
17
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
|
|
18
|
+
due: Optional[str] = typer.Option(None, "--due", help="Due date (YYYY-MM-DD)"),
|
|
19
|
+
) -> None:
|
|
20
|
+
if priority not in ("low", "medium", "high"):
|
|
21
|
+
error(f"Invalid priority '{priority}'. Use: low, medium, high")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
due_date = None
|
|
25
|
+
if due:
|
|
26
|
+
try:
|
|
27
|
+
due_date = parse_date(due)
|
|
28
|
+
except ValueError as e:
|
|
29
|
+
error(str(e))
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
engine = init_db()
|
|
33
|
+
with get_session(engine) as session:
|
|
34
|
+
service = TodoService(session)
|
|
35
|
+
todo = service.add(
|
|
36
|
+
title=title,
|
|
37
|
+
description=description,
|
|
38
|
+
priority=priority,
|
|
39
|
+
project=project,
|
|
40
|
+
tags=tags,
|
|
41
|
+
due_date=due_date,
|
|
42
|
+
)
|
|
43
|
+
success(f"Added todo [bold]#{todo.id}[/bold]: {todo.title}")
|
|
44
|
+
print_todo(todo)
|
devtodo/commands/done.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from devtodo.db import init_db, get_session
|
|
3
|
+
from devtodo.services.todo_service import TodoService
|
|
4
|
+
from devtodo.utils.output import success, error
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Mark a todo as done.")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.callback(invoke_without_command=True)
|
|
10
|
+
def done(
|
|
11
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to mark as done"),
|
|
12
|
+
) -> None:
|
|
13
|
+
engine = init_db()
|
|
14
|
+
with get_session(engine) as session:
|
|
15
|
+
service = TodoService(session)
|
|
16
|
+
todo = service.mark_done(todo_id)
|
|
17
|
+
|
|
18
|
+
if todo:
|
|
19
|
+
success(f"Todo [bold]#{todo.id}[/bold] marked as done: {todo.title}")
|
|
20
|
+
else:
|
|
21
|
+
error(f"Todo #{todo_id} not found.")
|
|
22
|
+
raise typer.Exit(1)
|
devtodo/commands/edit.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import typer
|
|
3
|
+
from devtodo.db import init_db, get_session
|
|
4
|
+
from devtodo.services.todo_service import TodoService
|
|
5
|
+
from devtodo.utils.output import success, error, print_todo
|
|
6
|
+
from devtodo.utils.dates import parse_date
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(help="Edit a todo.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.callback(invoke_without_command=True)
|
|
12
|
+
def edit(
|
|
13
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to edit"),
|
|
14
|
+
title: Optional[str] = typer.Option(None, "--title", help="New title"),
|
|
15
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="New description"),
|
|
16
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="New status: open, done, in_progress"),
|
|
17
|
+
priority: Optional[str] = typer.Option(None, "--priority", "-p", help="New priority: low, medium, high"),
|
|
18
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="New project"),
|
|
19
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="New tags (comma-separated)"),
|
|
20
|
+
due: Optional[str] = typer.Option(None, "--due", help="New due date (YYYY-MM-DD)"),
|
|
21
|
+
) -> None:
|
|
22
|
+
if priority and priority not in ("low", "medium", "high"):
|
|
23
|
+
error(f"Invalid priority '{priority}'. Use: low, medium, high")
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
if status and status not in ("open", "done", "in_progress"):
|
|
26
|
+
error(f"Invalid status '{status}'. Use: open, done, in_progress")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
|
|
29
|
+
due_date = None
|
|
30
|
+
if due:
|
|
31
|
+
try:
|
|
32
|
+
due_date = parse_date(due)
|
|
33
|
+
except ValueError as e:
|
|
34
|
+
error(str(e))
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
engine = init_db()
|
|
38
|
+
with get_session(engine) as session:
|
|
39
|
+
service = TodoService(session)
|
|
40
|
+
todo = service.edit(
|
|
41
|
+
todo_id=todo_id,
|
|
42
|
+
title=title,
|
|
43
|
+
description=description,
|
|
44
|
+
status=status,
|
|
45
|
+
priority=priority,
|
|
46
|
+
project=project,
|
|
47
|
+
tags=tags,
|
|
48
|
+
due_date=due_date,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if todo:
|
|
52
|
+
success(f"Todo [bold]#{todo.id}[/bold] updated.")
|
|
53
|
+
print_todo(todo)
|
|
54
|
+
else:
|
|
55
|
+
error(f"Todo #{todo_id} not found.")
|
|
56
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""devtodo github – GitHub Issue integration commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from devtodo.db import init_db, get_session
|
|
8
|
+
from devtodo.services.todo_service import TodoService
|
|
9
|
+
from devtodo.utils.output import success, error, print_todos
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="GitHub Issue integration.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require_repo(repo: Optional[str]) -> str:
|
|
16
|
+
if not repo:
|
|
17
|
+
error("Provide --repo owner/reponame e.g. --repo torvalds/linux")
|
|
18
|
+
raise typer.Exit(1)
|
|
19
|
+
if "/" not in repo:
|
|
20
|
+
error("--repo must be in format owner/reponame")
|
|
21
|
+
raise typer.Exit(1)
|
|
22
|
+
return repo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("sync")
|
|
26
|
+
def sync(
|
|
27
|
+
repo: Optional[str] = typer.Option(None, "--repo", "-r", help="GitHub repo (owner/name)"),
|
|
28
|
+
state: str = typer.Option("open", "--state", "-s", help="Issue state: open / closed / all"),
|
|
29
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max issues to fetch"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Import GitHub Issues as todos."""
|
|
32
|
+
from devtodo.github_client import fetch_issues
|
|
33
|
+
repo = _require_repo(repo)
|
|
34
|
+
|
|
35
|
+
console.print(f"[dim]Fetching {state} issues from [bold]{repo}[/bold]...[/dim]")
|
|
36
|
+
try:
|
|
37
|
+
issues = fetch_issues(repo, state=state, limit=limit)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
error(f"GitHub API error: {e}")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
if not issues:
|
|
43
|
+
console.print("[dim]No issues found.[/dim]")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
engine = init_db()
|
|
47
|
+
added = 0
|
|
48
|
+
skipped = 0
|
|
49
|
+
with get_session(engine) as session:
|
|
50
|
+
service = TodoService(session)
|
|
51
|
+
existing_urls = {t.github_url for t in service.list_todos() if t.github_url}
|
|
52
|
+
|
|
53
|
+
for issue in issues:
|
|
54
|
+
url = issue["html_url"]
|
|
55
|
+
if url in existing_urls:
|
|
56
|
+
skipped += 1
|
|
57
|
+
continue
|
|
58
|
+
service.add(
|
|
59
|
+
title=issue["title"],
|
|
60
|
+
description=issue.get("body") or None,
|
|
61
|
+
github_url=url,
|
|
62
|
+
github_issue_number=issue["number"],
|
|
63
|
+
)
|
|
64
|
+
added += 1
|
|
65
|
+
|
|
66
|
+
success(f"Imported {added} issue(s) from [bold]{repo}[/bold] ({skipped} already existed).")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("push")
|
|
70
|
+
def push(
|
|
71
|
+
todo_id: int = typer.Argument(..., help="Todo ID to push as GitHub Issue"),
|
|
72
|
+
repo: Optional[str] = typer.Option(None, "--repo", "-r", help="GitHub repo (owner/name)"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Push a todo as a new GitHub Issue."""
|
|
75
|
+
from devtodo.github_client import create_issue
|
|
76
|
+
repo = _require_repo(repo)
|
|
77
|
+
|
|
78
|
+
engine = init_db()
|
|
79
|
+
with get_session(engine) as session:
|
|
80
|
+
service = TodoService(session)
|
|
81
|
+
todo = service.get(todo_id)
|
|
82
|
+
if not todo:
|
|
83
|
+
error(f"Todo #{todo_id} not found.")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
if todo.github_url:
|
|
87
|
+
error(f"Todo #{todo_id} is already linked to: {todo.github_url}")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
|
|
90
|
+
console.print(f"[dim]Creating issue in [bold]{repo}[/bold]...[/dim]")
|
|
91
|
+
try:
|
|
92
|
+
issue = create_issue(repo, title=todo.title, body=todo.description)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
error(f"GitHub API error: {e}")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
service.edit(
|
|
98
|
+
todo_id=todo_id,
|
|
99
|
+
github_url=issue["html_url"],
|
|
100
|
+
github_issue_number=issue["number"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
success(f"Issue created: [link={issue['html_url']}]{issue['html_url']}[/link]")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command("list")
|
|
107
|
+
def list_linked() -> None:
|
|
108
|
+
"""List todos that are linked to GitHub Issues."""
|
|
109
|
+
engine = init_db()
|
|
110
|
+
with get_session(engine) as session:
|
|
111
|
+
service = TodoService(session)
|
|
112
|
+
todos = [t for t in service.list_todos() if t.github_url]
|
|
113
|
+
|
|
114
|
+
if not todos:
|
|
115
|
+
console.print("[dim]No todos linked to GitHub Issues.[/dim]")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
print_todos(todos)
|
|
119
|
+
for t in todos:
|
|
120
|
+
console.print(f" #{t.id} → [dim]{t.github_url}[/dim]")
|
devtodo/commands/list.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import typer
|
|
3
|
+
from devtodo.db import init_db, get_session
|
|
4
|
+
from devtodo.services.todo_service import TodoService
|
|
5
|
+
from devtodo.utils.output import print_todos
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="List todos.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.callback(invoke_without_command=True)
|
|
11
|
+
def list_todos(
|
|
12
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, done, in_progress)"),
|
|
13
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="Filter by project"),
|
|
14
|
+
priority: Optional[str] = typer.Option(None, "--priority", "-p", help="Filter by priority"),
|
|
15
|
+
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
|
|
16
|
+
) -> None:
|
|
17
|
+
engine = init_db()
|
|
18
|
+
with get_session(engine) as session:
|
|
19
|
+
service = TodoService(session)
|
|
20
|
+
todos = service.list_todos(status=status, project=project, priority=priority, tag=tag)
|
|
21
|
+
print_todos(todos)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from devtodo.db import init_db, get_session
|
|
3
|
+
from devtodo.services.todo_service import TodoService
|
|
4
|
+
from devtodo.utils.output import success, error
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Remove a todo.")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.callback(invoke_without_command=True)
|
|
10
|
+
def remove(
|
|
11
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to remove"),
|
|
12
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
13
|
+
) -> None:
|
|
14
|
+
engine = init_db()
|
|
15
|
+
with get_session(engine) as session:
|
|
16
|
+
service = TodoService(session)
|
|
17
|
+
todo = service.get(todo_id)
|
|
18
|
+
if not todo:
|
|
19
|
+
error(f"Todo #{todo_id} not found.")
|
|
20
|
+
raise typer.Exit(1)
|
|
21
|
+
|
|
22
|
+
if not force:
|
|
23
|
+
typer.confirm(f"Remove todo #{todo_id}: '{todo.title}'?", abort=True)
|
|
24
|
+
|
|
25
|
+
service.remove(todo_id)
|
|
26
|
+
|
|
27
|
+
success(f"Todo #{todo_id} removed.")
|
devtodo/db.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from sqlmodel import SQLModel, create_engine, Session
|
|
3
|
+
|
|
4
|
+
DB_PATH = Path.home() / ".devtodo" / "devtodo.db"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_engine():
|
|
8
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
9
|
+
return create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init_db(engine=None):
|
|
13
|
+
from devtodo.models import Todo # noqa: F401 – registers the table
|
|
14
|
+
if engine is None:
|
|
15
|
+
engine = get_engine()
|
|
16
|
+
SQLModel.metadata.create_all(engine)
|
|
17
|
+
_migrate(engine)
|
|
18
|
+
return engine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _migrate(engine) -> None:
|
|
22
|
+
"""Add new columns to existing databases without Alembic."""
|
|
23
|
+
new_columns = [
|
|
24
|
+
("github_url", "TEXT"),
|
|
25
|
+
("github_issue_number", "INTEGER"),
|
|
26
|
+
]
|
|
27
|
+
with engine.connect() as conn:
|
|
28
|
+
for col, col_type in new_columns:
|
|
29
|
+
try:
|
|
30
|
+
conn.exec_driver_sql(f"ALTER TABLE todo ADD COLUMN {col} {col_type}")
|
|
31
|
+
conn.commit()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass # column already exists
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_session(engine=None):
|
|
37
|
+
if engine is None:
|
|
38
|
+
engine = get_engine()
|
|
39
|
+
return Session(engine)
|
devtodo/github_client.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""GitHub API client for devtodo."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
GITHUB_API = "https://api.github.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_token() -> Optional[str]:
|
|
11
|
+
return os.environ.get("GITHUB_TOKEN")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _headers() -> dict:
|
|
15
|
+
token = _get_token()
|
|
16
|
+
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
|
17
|
+
if token:
|
|
18
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
19
|
+
return headers
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_issues(repo: str, state: str = "open", limit: int = 50) -> list[dict]:
|
|
23
|
+
"""Fetch issues from a GitHub repo. repo = 'owner/name'"""
|
|
24
|
+
url = f"{GITHUB_API}/repos/{repo}/issues"
|
|
25
|
+
params = {"state": state, "per_page": min(limit, 100), "page": 1}
|
|
26
|
+
resp = httpx.get(url, headers=_headers(), params=params, timeout=10)
|
|
27
|
+
resp.raise_for_status()
|
|
28
|
+
# filter out pull requests (GitHub returns PRs as issues too)
|
|
29
|
+
return [i for i in resp.json() if "pull_request" not in i]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_issue(repo: str, title: str, body: Optional[str] = None, labels: list[str] | None = None) -> dict:
|
|
33
|
+
"""Create a GitHub issue. Returns the created issue dict."""
|
|
34
|
+
url = f"{GITHUB_API}/repos/{repo}/issues"
|
|
35
|
+
payload: dict = {"title": title}
|
|
36
|
+
if body:
|
|
37
|
+
payload["body"] = body
|
|
38
|
+
if labels:
|
|
39
|
+
payload["labels"] = labels
|
|
40
|
+
resp = httpx.post(url, headers=_headers(), json=payload, timeout=10)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
return resp.json()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def close_issue(repo: str, issue_number: int) -> dict:
|
|
46
|
+
"""Close a GitHub issue."""
|
|
47
|
+
url = f"{GITHUB_API}/repos/{repo}/issues/{issue_number}"
|
|
48
|
+
resp = httpx.patch(url, headers=_headers(), json={"state": "closed"}, timeout=10)
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
return resp.json()
|
devtodo/main.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from devtodo.db import init_db, get_session
|
|
7
|
+
from devtodo.services.todo_service import TodoService
|
|
8
|
+
from devtodo.utils.output import success, error, print_todos, print_todo
|
|
9
|
+
from devtodo.utils.dates import parse_date
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="devtodo",
|
|
13
|
+
help="[bold]devtodo[/bold] – a developer-focused CLI todo tool.",
|
|
14
|
+
rich_markup_mode="rich",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("add")
|
|
22
|
+
def cmd_add(
|
|
23
|
+
title: str = typer.Argument(..., help="Title of the todo"),
|
|
24
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="Optional description"),
|
|
25
|
+
priority: str = typer.Option("medium", "--priority", "-p", help="Priority: low, medium, high"),
|
|
26
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="Project name"),
|
|
27
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
|
|
28
|
+
due: Optional[str] = typer.Option(None, "--due", help="Due date (YYYY-MM-DD)"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Add a new todo."""
|
|
31
|
+
if priority not in ("low", "medium", "high"):
|
|
32
|
+
error(f"Invalid priority '{priority}'. Use: low, medium, high")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
due_date = None
|
|
36
|
+
if due:
|
|
37
|
+
try:
|
|
38
|
+
due_date = parse_date(due)
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
error(str(e))
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
engine = init_db()
|
|
44
|
+
with get_session(engine) as session:
|
|
45
|
+
service = TodoService(session)
|
|
46
|
+
todo = service.add(
|
|
47
|
+
title=title,
|
|
48
|
+
description=description,
|
|
49
|
+
priority=priority,
|
|
50
|
+
project=project,
|
|
51
|
+
tags=tags,
|
|
52
|
+
due_date=due_date,
|
|
53
|
+
)
|
|
54
|
+
success(f"Added todo [bold]#{todo.id}[/bold]: {todo.title}")
|
|
55
|
+
print_todo(todo)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("list")
|
|
59
|
+
def cmd_list(
|
|
60
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter: open, done, in_progress"),
|
|
61
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="Filter by project"),
|
|
62
|
+
priority: Optional[str] = typer.Option(None, "--priority", "-p", help="Filter by priority"),
|
|
63
|
+
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""List todos."""
|
|
66
|
+
engine = init_db()
|
|
67
|
+
with get_session(engine) as session:
|
|
68
|
+
service = TodoService(session)
|
|
69
|
+
todos = service.list_todos(status=status, project=project, priority=priority, tag=tag)
|
|
70
|
+
print_todos(todos)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("done")
|
|
74
|
+
def cmd_done(
|
|
75
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to mark as done"),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Mark a todo as done."""
|
|
78
|
+
engine = init_db()
|
|
79
|
+
with get_session(engine) as session:
|
|
80
|
+
service = TodoService(session)
|
|
81
|
+
todo = service.mark_done(todo_id)
|
|
82
|
+
|
|
83
|
+
if todo:
|
|
84
|
+
success(f"Todo [bold]#{todo.id}[/bold] marked as done: {todo.title}")
|
|
85
|
+
else:
|
|
86
|
+
error(f"Todo #{todo_id} not found.")
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("remove")
|
|
91
|
+
def cmd_remove(
|
|
92
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to remove"),
|
|
93
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Remove a todo."""
|
|
96
|
+
engine = init_db()
|
|
97
|
+
with get_session(engine) as session:
|
|
98
|
+
service = TodoService(session)
|
|
99
|
+
todo = service.get(todo_id)
|
|
100
|
+
if not todo:
|
|
101
|
+
error(f"Todo #{todo_id} not found.")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
if not force:
|
|
105
|
+
typer.confirm(f"Remove todo #{todo_id}: '{todo.title}'?", abort=True)
|
|
106
|
+
|
|
107
|
+
service.remove(todo_id)
|
|
108
|
+
|
|
109
|
+
success(f"Todo #{todo_id} removed.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("edit")
|
|
113
|
+
def cmd_edit(
|
|
114
|
+
todo_id: int = typer.Argument(..., help="ID of the todo to edit"),
|
|
115
|
+
title: Optional[str] = typer.Option(None, "--title", help="New title"),
|
|
116
|
+
description: Optional[str] = typer.Option(None, "--desc", "-d", help="New description"),
|
|
117
|
+
status: Optional[str] = typer.Option(None, "--status", "-s", help="New status: open, done, in_progress"),
|
|
118
|
+
priority: Optional[str] = typer.Option(None, "--priority", "-p", help="New priority: low, medium, high"),
|
|
119
|
+
project: Optional[str] = typer.Option(None, "--project", "-P", help="New project"),
|
|
120
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="New tags (comma-separated)"),
|
|
121
|
+
due: Optional[str] = typer.Option(None, "--due", help="New due date (YYYY-MM-DD)"),
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Edit a todo."""
|
|
124
|
+
if priority and priority not in ("low", "medium", "high"):
|
|
125
|
+
error(f"Invalid priority '{priority}'. Use: low, medium, high")
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
if status and status not in ("open", "done", "in_progress"):
|
|
128
|
+
error(f"Invalid status '{status}'. Use: open, done, in_progress")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
due_date = None
|
|
132
|
+
if due:
|
|
133
|
+
try:
|
|
134
|
+
due_date = parse_date(due)
|
|
135
|
+
except ValueError as e:
|
|
136
|
+
error(str(e))
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
engine = init_db()
|
|
140
|
+
with get_session(engine) as session:
|
|
141
|
+
service = TodoService(session)
|
|
142
|
+
todo = service.edit(
|
|
143
|
+
todo_id=todo_id,
|
|
144
|
+
title=title,
|
|
145
|
+
description=description,
|
|
146
|
+
status=status,
|
|
147
|
+
priority=priority,
|
|
148
|
+
project=project,
|
|
149
|
+
tags=tags,
|
|
150
|
+
due_date=due_date,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if todo:
|
|
154
|
+
success(f"Todo [bold]#{todo.id}[/bold] updated.")
|
|
155
|
+
print_todo(todo)
|
|
156
|
+
else:
|
|
157
|
+
error(f"Todo #{todo_id} not found.")
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("clear")
|
|
162
|
+
def cmd_clear(
|
|
163
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Delete ALL todos. Cannot be undone!"""
|
|
166
|
+
engine = init_db()
|
|
167
|
+
with get_session(engine) as session:
|
|
168
|
+
service = TodoService(session)
|
|
169
|
+
count = service.list_todos().__len__()
|
|
170
|
+
|
|
171
|
+
if count == 0:
|
|
172
|
+
console.print("[dim]No todos to delete.[/dim]")
|
|
173
|
+
raise typer.Exit()
|
|
174
|
+
|
|
175
|
+
if not force:
|
|
176
|
+
console.print(f"[bold red]⚠ This will delete ALL {count} todo(s). This cannot be undone![/bold red]")
|
|
177
|
+
typer.confirm("Are you sure?", abort=True)
|
|
178
|
+
|
|
179
|
+
engine = init_db()
|
|
180
|
+
with get_session(engine) as session:
|
|
181
|
+
service = TodoService(session)
|
|
182
|
+
service.clear_all()
|
|
183
|
+
|
|
184
|
+
success(f"Deleted all {count} todo(s). Database is now empty.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_clear_completed(
|
|
188
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Remove all completed todos."""
|
|
191
|
+
if not force:
|
|
192
|
+
typer.confirm("Remove all completed todos?", abort=True)
|
|
193
|
+
|
|
194
|
+
engine = init_db()
|
|
195
|
+
with get_session(engine) as session:
|
|
196
|
+
service = TodoService(session)
|
|
197
|
+
count = service.clear_completed()
|
|
198
|
+
|
|
199
|
+
success(f"Removed {count} completed todo(s).")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("start")
|
|
203
|
+
def cmd_start() -> None:
|
|
204
|
+
"""Launch the interactive TUI."""
|
|
205
|
+
from devtodo.tui import run_tui
|
|
206
|
+
run_tui()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_today() -> None:
|
|
210
|
+
"""Show high-priority todos and todos due today."""
|
|
211
|
+
today_date = date.today()
|
|
212
|
+
|
|
213
|
+
engine = init_db()
|
|
214
|
+
with get_session(engine) as session:
|
|
215
|
+
service = TodoService(session)
|
|
216
|
+
all_todos = service.list_todos(status="open")
|
|
217
|
+
|
|
218
|
+
focused = [
|
|
219
|
+
t for t in all_todos
|
|
220
|
+
if t.priority == "high"
|
|
221
|
+
or (t.due_date and t.due_date.date() <= today_date)
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
console.print("[bold]📅 Today's focus[/bold]")
|
|
225
|
+
print_todos(focused)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
app()
|
|
230
|
+
|
devtodo/models.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from sqlmodel import SQLModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Todo(SQLModel, table=True):
|
|
7
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
8
|
+
title: str
|
|
9
|
+
description: Optional[str] = None
|
|
10
|
+
status: str = "open"
|
|
11
|
+
priority: str = "medium"
|
|
12
|
+
project: Optional[str] = None
|
|
13
|
+
tags: Optional[str] = None
|
|
14
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
15
|
+
due_date: Optional[datetime] = None
|
|
16
|
+
github_url: Optional[str] = None
|
|
17
|
+
github_issue_number: Optional[int] = None
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from sqlmodel import select, Session
|
|
4
|
+
|
|
5
|
+
from devtodo.models import Todo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TodoService:
|
|
9
|
+
def __init__(self, session: Session):
|
|
10
|
+
self.session = session
|
|
11
|
+
|
|
12
|
+
def add(
|
|
13
|
+
self,
|
|
14
|
+
title: str,
|
|
15
|
+
description: Optional[str] = None,
|
|
16
|
+
priority: str = "medium",
|
|
17
|
+
project: Optional[str] = None,
|
|
18
|
+
tags: Optional[str] = None,
|
|
19
|
+
due_date: Optional[datetime] = None,
|
|
20
|
+
) -> Todo:
|
|
21
|
+
todo = Todo(
|
|
22
|
+
title=title,
|
|
23
|
+
description=description,
|
|
24
|
+
priority=priority,
|
|
25
|
+
project=project,
|
|
26
|
+
tags=tags,
|
|
27
|
+
due_date=due_date,
|
|
28
|
+
)
|
|
29
|
+
self.session.add(todo)
|
|
30
|
+
self.session.commit()
|
|
31
|
+
self.session.refresh(todo)
|
|
32
|
+
return todo
|
|
33
|
+
|
|
34
|
+
def list_todos(
|
|
35
|
+
self,
|
|
36
|
+
status: Optional[str] = None,
|
|
37
|
+
project: Optional[str] = None,
|
|
38
|
+
priority: Optional[str] = None,
|
|
39
|
+
tag: Optional[str] = None,
|
|
40
|
+
) -> list[Todo]:
|
|
41
|
+
statement = select(Todo)
|
|
42
|
+
if status:
|
|
43
|
+
statement = statement.where(Todo.status == status)
|
|
44
|
+
if project:
|
|
45
|
+
statement = statement.where(Todo.project == project)
|
|
46
|
+
if priority:
|
|
47
|
+
statement = statement.where(Todo.priority == priority)
|
|
48
|
+
if tag:
|
|
49
|
+
statement = statement.where(Todo.tags.contains(tag))
|
|
50
|
+
statement = statement.order_by(Todo.id)
|
|
51
|
+
return list(self.session.exec(statement).all())
|
|
52
|
+
|
|
53
|
+
def get(self, todo_id: int) -> Optional[Todo]:
|
|
54
|
+
return self.session.get(Todo, todo_id)
|
|
55
|
+
|
|
56
|
+
def mark_done(self, todo_id: int) -> Optional[Todo]:
|
|
57
|
+
todo = self.get(todo_id)
|
|
58
|
+
if todo:
|
|
59
|
+
todo.status = "done"
|
|
60
|
+
self.session.add(todo)
|
|
61
|
+
self.session.commit()
|
|
62
|
+
self.session.refresh(todo)
|
|
63
|
+
return todo
|
|
64
|
+
|
|
65
|
+
def remove(self, todo_id: int) -> bool:
|
|
66
|
+
todo = self.get(todo_id)
|
|
67
|
+
if todo:
|
|
68
|
+
self.session.delete(todo)
|
|
69
|
+
self.session.commit()
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def edit(
|
|
74
|
+
self,
|
|
75
|
+
todo_id: int,
|
|
76
|
+
title: Optional[str] = None,
|
|
77
|
+
description: Optional[str] = None,
|
|
78
|
+
status: Optional[str] = None,
|
|
79
|
+
priority: Optional[str] = None,
|
|
80
|
+
project: Optional[str] = None,
|
|
81
|
+
tags: Optional[str] = None,
|
|
82
|
+
due_date: Optional[datetime] = None,
|
|
83
|
+
) -> Optional[Todo]:
|
|
84
|
+
todo = self.get(todo_id)
|
|
85
|
+
if not todo:
|
|
86
|
+
return None
|
|
87
|
+
if title is not None:
|
|
88
|
+
todo.title = title
|
|
89
|
+
if description is not None:
|
|
90
|
+
todo.description = description
|
|
91
|
+
if status is not None:
|
|
92
|
+
todo.status = status
|
|
93
|
+
if priority is not None:
|
|
94
|
+
todo.priority = priority
|
|
95
|
+
if project is not None:
|
|
96
|
+
todo.project = project
|
|
97
|
+
if tags is not None:
|
|
98
|
+
todo.tags = tags
|
|
99
|
+
if due_date is not None:
|
|
100
|
+
todo.due_date = due_date
|
|
101
|
+
self.session.add(todo)
|
|
102
|
+
self.session.commit()
|
|
103
|
+
self.session.refresh(todo)
|
|
104
|
+
return todo
|
|
105
|
+
|
|
106
|
+
def clear_completed(self) -> int:
|
|
107
|
+
todos = self.list_todos(status="done")
|
|
108
|
+
for todo in todos:
|
|
109
|
+
self.session.delete(todo)
|
|
110
|
+
self.session.commit()
|
|
111
|
+
return len(todos)
|
|
112
|
+
|
|
113
|
+
def clear_all(self) -> int:
|
|
114
|
+
todos = self.list_todos()
|
|
115
|
+
for todo in todos:
|
|
116
|
+
self.session.delete(todo)
|
|
117
|
+
self.session.commit()
|
|
118
|
+
return len(todos)
|
devtodo/tui.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.widgets import (
|
|
3
|
+
Header, Footer, DataTable, Input, Label, Button, Static
|
|
4
|
+
)
|
|
5
|
+
from textual.containers import Vertical, Horizontal, Container
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual import events
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from devtodo.db import init_db, get_session
|
|
12
|
+
from devtodo.services.todo_service import TodoService
|
|
13
|
+
from devtodo.models import Todo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PRIORITY_STYLE = {
|
|
17
|
+
"high": ("red", "●"),
|
|
18
|
+
"medium": ("yellow", "●"),
|
|
19
|
+
"low": ("green", "●"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
STATUS_STYLE = {
|
|
23
|
+
"open": ("cyan", "○"),
|
|
24
|
+
"in_progress": ("blue", "◑"),
|
|
25
|
+
"done": ("dim green", "✔"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AddTodoModal(ModalScreen):
|
|
30
|
+
"""Modal dialog to add a new todo."""
|
|
31
|
+
|
|
32
|
+
BINDINGS = [("escape", "dismiss", "Cancel")]
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
with Vertical(id="modal-box"):
|
|
36
|
+
yield Label("➕ Add new todo", id="modal-title")
|
|
37
|
+
yield Input(placeholder="Title *", id="input-title")
|
|
38
|
+
yield Input(placeholder="Description (optional)", id="input-desc")
|
|
39
|
+
yield Input(placeholder="Priority: low / medium / high (default: medium)", id="input-priority")
|
|
40
|
+
yield Input(placeholder="Project (optional)", id="input-project")
|
|
41
|
+
yield Input(placeholder="Tags, comma-separated (optional)", id="input-tags")
|
|
42
|
+
with Horizontal(id="modal-buttons"):
|
|
43
|
+
yield Button("Add", variant="success", id="btn-add")
|
|
44
|
+
yield Button("Cancel", variant="default", id="btn-cancel")
|
|
45
|
+
|
|
46
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
47
|
+
if event.button.id == "btn-cancel":
|
|
48
|
+
self.dismiss(None)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
title = self.query_one("#input-title", Input).value.strip()
|
|
52
|
+
if not title:
|
|
53
|
+
self.query_one("#input-title", Input).focus()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
priority = self.query_one("#input-priority", Input).value.strip() or "medium"
|
|
57
|
+
if priority not in ("low", "medium", "high"):
|
|
58
|
+
priority = "medium"
|
|
59
|
+
|
|
60
|
+
self.dismiss({
|
|
61
|
+
"title": title,
|
|
62
|
+
"description": self.query_one("#input-desc", Input).value.strip() or None,
|
|
63
|
+
"priority": priority,
|
|
64
|
+
"project": self.query_one("#input-project", Input).value.strip() or None,
|
|
65
|
+
"tags": self.query_one("#input-tags", Input).value.strip() or None,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
69
|
+
# pressing Enter in any field triggers add
|
|
70
|
+
self.query_one("#btn-add", Button).press()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DevtodoTUI(App):
|
|
74
|
+
"""devtodo – Terminal UI"""
|
|
75
|
+
|
|
76
|
+
CSS = """
|
|
77
|
+
Screen {
|
|
78
|
+
background: $surface;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#main-table {
|
|
82
|
+
height: 1fr;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#status-bar {
|
|
86
|
+
height: 1;
|
|
87
|
+
padding: 0 1;
|
|
88
|
+
background: $panel;
|
|
89
|
+
color: $text-muted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Modal */
|
|
93
|
+
AddTodoModal {
|
|
94
|
+
align: center middle;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#modal-box {
|
|
98
|
+
width: 60;
|
|
99
|
+
height: auto;
|
|
100
|
+
border: round $primary;
|
|
101
|
+
background: $surface;
|
|
102
|
+
padding: 1 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#modal-box Input {
|
|
106
|
+
margin-bottom: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#modal-title {
|
|
110
|
+
text-style: bold;
|
|
111
|
+
color: $primary;
|
|
112
|
+
margin-bottom: 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#modal-buttons {
|
|
116
|
+
height: auto;
|
|
117
|
+
margin-top: 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#btn-add {
|
|
121
|
+
margin-right: 2;
|
|
122
|
+
}
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
BINDINGS = [
|
|
126
|
+
Binding("a", "add_todo", "Add", show=True),
|
|
127
|
+
Binding("d", "mark_done", "Done", show=True),
|
|
128
|
+
Binding("delete", "remove_todo", "Remove", show=True),
|
|
129
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
130
|
+
Binding("q", "quit", "Quit", show=True),
|
|
131
|
+
Binding("f", "filter_open", "Open only",show=True),
|
|
132
|
+
Binding("F", "filter_all", "All", show=True),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
def __init__(self):
|
|
136
|
+
super().__init__()
|
|
137
|
+
self.engine = init_db()
|
|
138
|
+
self._filter_status = None # None = all
|
|
139
|
+
self._todos: list[Todo] = []
|
|
140
|
+
|
|
141
|
+
def compose(self) -> ComposeResult:
|
|
142
|
+
yield Header(show_clock=True)
|
|
143
|
+
yield DataTable(id="main-table", cursor_type="row", zebra_stripes=True)
|
|
144
|
+
yield Static("", id="status-bar")
|
|
145
|
+
yield Footer()
|
|
146
|
+
|
|
147
|
+
def on_mount(self) -> None:
|
|
148
|
+
self.title = "devtodo"
|
|
149
|
+
self.sub_title = "press [a] add · [d] done · [del] remove · [q] quit"
|
|
150
|
+
table = self.query_one(DataTable)
|
|
151
|
+
table.add_columns("ID", "●", "Title", "Status", "Priority", "Project", "Tags")
|
|
152
|
+
self._load_todos()
|
|
153
|
+
|
|
154
|
+
def _load_todos(self) -> None:
|
|
155
|
+
with get_session(self.engine) as session:
|
|
156
|
+
service = TodoService(session)
|
|
157
|
+
self._todos = service.list_todos(status=self._filter_status)
|
|
158
|
+
|
|
159
|
+
table = self.query_one(DataTable)
|
|
160
|
+
table.clear()
|
|
161
|
+
|
|
162
|
+
for todo in self._todos:
|
|
163
|
+
p_color, p_dot = PRIORITY_STYLE.get(todo.priority, ("white", "●"))
|
|
164
|
+
s_color, s_icon = STATUS_STYLE.get(todo.status, ("white", "?"))
|
|
165
|
+
|
|
166
|
+
table.add_row(
|
|
167
|
+
Text(str(todo.id), style="dim"),
|
|
168
|
+
Text(p_dot, style=p_color),
|
|
169
|
+
Text(todo.title),
|
|
170
|
+
Text(f"{s_icon} {todo.status}", style=s_color),
|
|
171
|
+
Text(todo.priority, style=p_color),
|
|
172
|
+
Text(todo.project or "", style="blue"),
|
|
173
|
+
Text(todo.tags or "", style="magenta"),
|
|
174
|
+
key=str(todo.id),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self._update_status_bar()
|
|
178
|
+
|
|
179
|
+
def _update_status_bar(self) -> None:
|
|
180
|
+
total = len(self._todos)
|
|
181
|
+
done = sum(1 for t in self._todos if t.status == "done")
|
|
182
|
+
open_ = sum(1 for t in self._todos if t.status == "open")
|
|
183
|
+
filt = f" [filter: open only]" if self._filter_status else ""
|
|
184
|
+
self.query_one("#status-bar", Static).update(
|
|
185
|
+
f" {total} todos · {open_} open · {done} done{filt}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _current_todo(self) -> Todo | None:
|
|
189
|
+
table = self.query_one(DataTable)
|
|
190
|
+
if table.cursor_row < 0 or not self._todos:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
return self._todos[table.cursor_row]
|
|
194
|
+
except IndexError:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# ── Actions ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def action_add_todo(self) -> None:
|
|
200
|
+
def handle_result(data: dict | None) -> None:
|
|
201
|
+
if not data:
|
|
202
|
+
return
|
|
203
|
+
with get_session(self.engine) as session:
|
|
204
|
+
TodoService(session).add(**data)
|
|
205
|
+
self._load_todos()
|
|
206
|
+
|
|
207
|
+
self.push_screen(AddTodoModal(), handle_result)
|
|
208
|
+
|
|
209
|
+
def action_mark_done(self) -> None:
|
|
210
|
+
todo = self._current_todo()
|
|
211
|
+
if not todo:
|
|
212
|
+
return
|
|
213
|
+
with get_session(self.engine) as session:
|
|
214
|
+
TodoService(session).mark_done(todo.id)
|
|
215
|
+
self._load_todos()
|
|
216
|
+
|
|
217
|
+
def action_remove_todo(self) -> None:
|
|
218
|
+
todo = self._current_todo()
|
|
219
|
+
if not todo:
|
|
220
|
+
return
|
|
221
|
+
with get_session(self.engine) as session:
|
|
222
|
+
TodoService(session).remove(todo.id)
|
|
223
|
+
self._load_todos()
|
|
224
|
+
|
|
225
|
+
def action_refresh(self) -> None:
|
|
226
|
+
self._load_todos()
|
|
227
|
+
|
|
228
|
+
def action_filter_open(self) -> None:
|
|
229
|
+
self._filter_status = "open"
|
|
230
|
+
self._load_todos()
|
|
231
|
+
|
|
232
|
+
def action_filter_all(self) -> None:
|
|
233
|
+
self._filter_status = None
|
|
234
|
+
self._load_todos()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_tui() -> None:
|
|
238
|
+
DevtodoTUI().run()
|
|
File without changes
|
devtodo/utils/dates.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def parse_date(value: Optional[str]) -> Optional[datetime]:
|
|
6
|
+
if not value:
|
|
7
|
+
return None
|
|
8
|
+
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
|
9
|
+
try:
|
|
10
|
+
return datetime.strptime(value, fmt)
|
|
11
|
+
except ValueError:
|
|
12
|
+
continue
|
|
13
|
+
raise ValueError(f"Cannot parse date: '{value}'. Use YYYY-MM-DD format.")
|
devtodo/utils/output.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich import box
|
|
5
|
+
from devtodo.models import Todo
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
PRIORITY_COLORS = {
|
|
10
|
+
"high": "red",
|
|
11
|
+
"medium": "yellow",
|
|
12
|
+
"low": "green",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
STATUS_COLORS = {
|
|
16
|
+
"open": "cyan",
|
|
17
|
+
"done": "dim green",
|
|
18
|
+
"in_progress": "blue",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _priority_badge(priority: str) -> str:
|
|
23
|
+
color = PRIORITY_COLORS.get(priority, "white")
|
|
24
|
+
return f"[{color}]{priority}[/{color}]"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _status_badge(status: str) -> str:
|
|
28
|
+
color = STATUS_COLORS.get(status, "white")
|
|
29
|
+
return f"[{color}]{status}[/{color}]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_todos(todos: list[Todo]) -> None:
|
|
33
|
+
if not todos:
|
|
34
|
+
console.print("[dim]No todos found.[/dim]")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold magenta")
|
|
38
|
+
table.add_column("ID", style="bold", justify="right", width=4)
|
|
39
|
+
table.add_column("Title", min_width=20)
|
|
40
|
+
table.add_column("Status", width=12)
|
|
41
|
+
table.add_column("Priority", width=10)
|
|
42
|
+
table.add_column("Project", width=12)
|
|
43
|
+
table.add_column("Tags", width=14)
|
|
44
|
+
table.add_column("Due", width=11)
|
|
45
|
+
|
|
46
|
+
for todo in todos:
|
|
47
|
+
due = todo.due_date.strftime("%Y-%m-%d") if todo.due_date else ""
|
|
48
|
+
table.add_row(
|
|
49
|
+
str(todo.id),
|
|
50
|
+
todo.title,
|
|
51
|
+
_status_badge(todo.status),
|
|
52
|
+
_priority_badge(todo.priority),
|
|
53
|
+
todo.project or "",
|
|
54
|
+
todo.tags or "",
|
|
55
|
+
due,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.print(table)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_todo(todo: Todo) -> None:
|
|
62
|
+
console.print(f"[bold]#{todo.id}[/bold] {todo.title}")
|
|
63
|
+
if todo.description:
|
|
64
|
+
console.print(f" [dim]{todo.description}[/dim]")
|
|
65
|
+
console.print(f" Status: {_status_badge(todo.status)}")
|
|
66
|
+
console.print(f" Priority: {_priority_badge(todo.priority)}")
|
|
67
|
+
if todo.project:
|
|
68
|
+
console.print(f" Project: [blue]{todo.project}[/blue]")
|
|
69
|
+
if todo.tags:
|
|
70
|
+
console.print(f" Tags: [magenta]{todo.tags}[/magenta]")
|
|
71
|
+
if todo.due_date:
|
|
72
|
+
console.print(f" Due: {todo.due_date.strftime('%Y-%m-%d')}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def success(msg: str) -> None:
|
|
76
|
+
console.print(f"[bold green]✔[/bold green] {msg}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def error(msg: str) -> None:
|
|
80
|
+
console.print(f"[bold red]✘[/bold red] {msg}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devtodo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A developer-focused CLI todo tool
|
|
5
|
+
Author: jakok
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jakok/devtodo
|
|
8
|
+
Project-URL: Repository, https://github.com/jakok/devtodo
|
|
9
|
+
Keywords: todo,cli,developer,terminal,productivity
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: typer>=0.12
|
|
17
|
+
Requires-Dist: rich>=13
|
|
18
|
+
Requires-Dist: sqlmodel>=0.0.19
|
|
19
|
+
Requires-Dist: textual>=0.80
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
|
|
22
|
+
# devtodo 📝
|
|
23
|
+
|
|
24
|
+
A fast, developer-focused CLI todo tool with a beautiful TUI.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install devtodo
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> Requires Python 3.12+. Install `pipx` first: `pip install pipx`
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dt add "Fix login bug" --priority high --project backend
|
|
38
|
+
dt list
|
|
39
|
+
dt list --project backend
|
|
40
|
+
dt done 1
|
|
41
|
+
dt edit 2 --priority low --tags "bug,ui"
|
|
42
|
+
dt remove 3
|
|
43
|
+
dt clear-completed
|
|
44
|
+
dt today # high priority + due today
|
|
45
|
+
dt start # interactive TUI
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## GitHub Integration
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export GITHUB_TOKEN=ghp_yourtoken
|
|
52
|
+
|
|
53
|
+
dt github sync --repo owner/repo # import open issues as todos
|
|
54
|
+
dt github push 5 --repo owner/repo # push todo #5 as a GitHub issue
|
|
55
|
+
dt github list # show todos linked to GitHub
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Keyboard shortcuts (TUI)
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| `a` | Add todo |
|
|
63
|
+
| `e` | Edit selected |
|
|
64
|
+
| `d` | Mark done |
|
|
65
|
+
| `Del` | Remove |
|
|
66
|
+
| `f` / `F` | Filter open / show all |
|
|
67
|
+
| `q` | Quit |
|
|
68
|
+
|
|
69
|
+
## Data
|
|
70
|
+
|
|
71
|
+
Todos are stored locally at `~/.devtodo/devtodo.db` (SQLite).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
devtodo/__init__.py,sha256=1Oeb1VkZTRqRrNw2Xp78L41PI3boM_O-oxv3uNUzFhk,76
|
|
2
|
+
devtodo/db.py,sha256=i0bWvXfDx09vYhIMHiZd6e2IhBBAW5e_WWwXvprY0a0,1128
|
|
3
|
+
devtodo/github_client.py,sha256=TBs4v_GhDwoYuSbzeu0Lxut_oc8BMzWc5uLF5uPkPJ8,1695
|
|
4
|
+
devtodo/main.py,sha256=tbIsoJ3xgXSEM2aKIh-p0QHmUMJxiRJ3SWjJZHfzpD4,7421
|
|
5
|
+
devtodo/models.py,sha256=CTqVhFNncpfdj5auG4WmEjdqjE3JkjXIAe-mY9V0-ws,554
|
|
6
|
+
devtodo/tui.py,sha256=cHII3LQKOg8TDNYL7oE4corlpt1hOQk9k4WOCJHrfOE,7599
|
|
7
|
+
devtodo/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
devtodo/commands/add.py,sha256=UVTS7t2yvivr5dAZ5-67Zj7_P4EyavTq6dz2x8Wromo,1605
|
|
9
|
+
devtodo/commands/done.py,sha256=qpv-LlDw2uHW2safeFPXKZ9L5fzahmQ653nk-Dl1L4c,672
|
|
10
|
+
devtodo/commands/edit.py,sha256=10ehEKJkTk1eibZItLYYzonTlU4x6EKrfza1ICPI2Bs,2141
|
|
11
|
+
devtodo/commands/github_cmd.py,sha256=zUbd6Ff3zP_0z_7uN5omoXuFZBvn-XEiXyf8hYPUkQg,3888
|
|
12
|
+
devtodo/commands/list.py,sha256=pFVjZwJ7z6vDSVL59CYf6Cpgrxl0Cuaxd7CXwTyl2fY,906
|
|
13
|
+
devtodo/commands/remove.py,sha256=t47HFEQyTcERS5ks1TiXCMsYwnQL1DQNAqiYP5Eq4oo,845
|
|
14
|
+
devtodo/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
devtodo/services/todo_service.py,sha256=my17-ax2IfEEd0QBOVpH7Q_x2cHJerHzIGMxfN6stR4,3478
|
|
16
|
+
devtodo/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
devtodo/utils/dates.py,sha256=CYfhGHASwgXm2Qxp8o-VOwAEAeUcERS0z9wBgMvY-Bk,399
|
|
18
|
+
devtodo/utils/output.py,sha256=zfjN4zZ05iqSpv-4ZgWH6Mp8dmhOS7Cj72Sx-FyxdcA,2267
|
|
19
|
+
devtodo-0.1.0.dist-info/METADATA,sha256=v8hdfsaVfpOmyrpnBsjIufm1yT9WyzddPpXqEZcsEzw,1670
|
|
20
|
+
devtodo-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
21
|
+
devtodo-0.1.0.dist-info/entry_points.txt,sha256=Zph7i34gUVm4WIvG3IybJupznzQXLYQjj9QltEXDmRw,67
|
|
22
|
+
devtodo-0.1.0.dist-info/top_level.txt,sha256=iedrhl7lx7pyAMlH0aM03NALgkjAAGHPZRAyXwpcdzc,8
|
|
23
|
+
devtodo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devtodo
|