Files
opalfiles/.local/bin/todo
2025-04-29 22:36:07 -07:00

186 lines
6.0 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import os
import argparse
import re
from datetime import date
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt, IntPrompt
TODAY = date.today().isoformat()
TODO_FILE = Path.home() / "sync/todo/todo.txt"
ARCHIVE_FILE = Path.home() / "sync/todo/todo-archive.txt"
console = Console()
RE_DONE = re.compile(r"^x \((?P<done>[^)]+)\) \((?P<created>[^)]+)\) (?P<text>.+)$")
RE_ACTIVE = re.compile(r"^\((?P<created>[^)]+)\) (?P<text>.+)$")
def read_tasks():
return TODO_FILE.read_text().splitlines() if TODO_FILE.exists() else []
def write_tasks(tasks):
TODO_FILE.write_text("\n".join(tasks) + "\n")
def parse_task(line):
m = RE_DONE.match(line)
if m:
return m.group("created"), m.group("done"), m.group("text")
m = RE_ACTIVE.match(line)
if m:
return m.group("created"), None, m.group("text")
return None, None, line
def extract_tags(text):
return " ".join(tok for tok in text.split() if tok.startswith("@"))
def show_tasks(all=False):
table = Table(title="TODOs")
table.add_column("#", style="bold", justify="right")
table.add_column("Created", style="green")
table.add_column("Done", style="magenta")
table.add_column("Tags", style="yellow")
table.add_column("Task", style="cyan")
for i, line in enumerate(read_tasks(), start=1):
created, done, text = parse_task(line)
if not all and done is not None:
continue
tags = extract_tags(text)
table.add_row(str(i), created or "", done or "", tags, text)
console.print(table)
def add_task(text):
line = f"({TODAY}) {text}"
with TODO_FILE.open("a") as f:
f.write(line + "\n")
console.print(f"[green]Added:[/] {line}")
def mark_done(index):
tasks = read_tasks()
try:
i = index - 1
if i < 0 or i >= len(tasks):
raise IndexError
created, done, text = parse_task(tasks[i])
if done is not None:
console.print("[yellow]Task already marked as done.[/]")
return
tasks[i] = f"x ({TODAY}) ({created}) {text}"
write_tasks(tasks)
console.print(f"[green]Marked done:[/] {text}")
except Exception:
console.print("[red]Invalid task number.[/]")
def archive_tasks():
tasks = read_tasks()
done_tasks = [t for t in tasks if RE_DONE.match(t)]
remaining = [t for t in tasks if not RE_DONE.match(t)]
if not done_tasks:
console.print("[yellow]No tasks to archive.[/]")
return
with ARCHIVE_FILE.open("a") as f:
for t in done_tasks:
f.write(t + "\n")
write_tasks(remaining)
console.print(f"[green]Archived {len(done_tasks)} completed tasks.[/]")
def filter_tags(tag):
table = Table(title=f"Tasks tagged {tag}")
table.add_column("#", justify="right")
table.add_column("Created", style="green")
table.add_column("Done", style="magenta")
table.add_column("Tags", style="yellow")
table.add_column("Task", style="cyan")
for i, line in enumerate(read_tasks(), start=1):
created, done, text = parse_task(line)
if done is None and tag in text:
tags = extract_tags(text)
table.add_row(str(i), created or "", "", tags, text)
console.print(table)
def done_interactive():
choices = [(i, l) for i, l in enumerate(read_tasks(), start=1) if RE_ACTIVE.match(l)]
if not choices:
console.print("[yellow]No active tasks.[/]")
return
table = Table(title="Select a task to mark as done")
table.add_column("#", justify="right")
table.add_column("Created", style="green")
table.add_column("Tags", style="yellow")
table.add_column("Task", style="cyan")
for i, line in choices:
created, _, text = parse_task(line)
tags = extract_tags(text)
table.add_row(str(i), created or "", tags, text)
console.print(table)
try:
choice = IntPrompt.ask("Task # to mark done")
mark_done(choice)
except KeyboardInterrupt:
console.print("\n[red]^C Aborted.[/]")
sys.exit(130)
except Exception:
console.print("[red]Invalid input or cancelled.[/]")
def edit_file():
editor = os.getenv("EDITOR", "nano")
os.execvp(editor, [editor, str(TODO_FILE)])
def main():
parser = argparse.ArgumentParser(prog="todo")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("ls", help="List active tasks")
sub.add_parser("lsa", help="List all tasks")
sub.add_parser("archive", help="Archive completed tasks")
p_done = sub.add_parser("done", help="Mark a task as done")
p_done.add_argument("n", type=int, help="Task number")
sub.add_parser("donei", help="Interactive task picker")
p_tags = sub.add_parser("tags", help="Show tasks by tag")
p_tags.add_argument("tag", help="+tag or @context")
sub.add_parser("edit", help="Edit the todo file")
p_add = sub.add_parser("add", help="Add a new task")
p_add.add_argument("text", nargs=argparse.REMAINDER, help="Task description (falls back to prompt)")
args = parser.parse_args()
if args.cmd == "ls":
show_tasks()
elif args.cmd == "lsa":
show_tasks(all=True)
elif args.cmd == "add":
if args.text:
desc = " ".join(args.text).strip()
else:
desc = Prompt.ask("Task description")
tags = Prompt.ask("Tags (e.g. @home @work)", default="")
desc = f"{desc} {tags}".strip() if tags else desc
if desc:
add_task(desc)
elif args.cmd == "done":
mark_done(args.n)
elif args.cmd == "donei":
done_interactive()
elif args.cmd == "archive":
archive_tasks()
elif args.cmd == "tags":
filter_tags(args.tag)
elif args.cmd == "edit":
edit_file()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
console.print("\n[red]^C Aborted.[/]")
sys.exit(130)