186 lines
6.0 KiB
Python
Executable File
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)
|