This commit is contained in:
2025-04-29 22:36:07 -07:00
parent 25ad40f68b
commit 4818c27b93
12 changed files with 722 additions and 56 deletions

View File

@@ -1,45 +1,185 @@
#!/bin/sh
#!/usr/bin/env python3
TODO_FILE="$HOME/sync/todo/todo.txt"
ARCHIVE_FILE="$HOME/sync/todo/todo-archive.txt"
TODAY="$(date +%Y-%m-%d)"
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
case "$1" in
add)
shift
echo "($TODAY) $*" >> "$TODO_FILE"
echo "Added: ($TODAY) $*"
;;
lsa)
nl -w2 -s'. ' "$TODO_FILE"
;;
ls)
grep -v '^x ' "$TODO_FILE" | nl -w2 -s'. '
;;
done)
if [ -z "$2" ]; then
echo "Usage: $0 done <task-number>"
exit 1
fi
TASK=$(sed -n "${2}p" "$TODO_FILE")
[ -z "$TASK" ] && { echo "Invalid task number."; exit 1; }
sed -i "${2}s/^/x ($TODAY) /" "$TODO_FILE"
echo "Marked done: $TASK"
;;
archive)
grep '^x ' "$TODO_FILE" >> "$ARCHIVE_FILE"
sed -i '/^x /d' "$TODO_FILE"
echo "Archived completed tasks to $ARCHIVE_FILE"
;;
tags)
if [ -z "$2" ]; then
echo "Usage: $0 tags <+tag|@context>"
exit 1
fi
grep "$2" "$TODO_FILE" | grep -v '^x ' | nl -w2 -s'. '
;;
help|*)
echo "Usage: $0 {add <task> | list | done <number> | archive}"
;;
esac
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)