stuff
This commit is contained in:
224
.local/bin/todo
224
.local/bin/todo
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user