fixed up my todo app to be even more robust + aliases for todo

This commit is contained in:
2025-04-30 14:19:18 -07:00
parent 005589be05
commit ba2b5e286c
3 changed files with 324 additions and 144 deletions

View File

@@ -141,7 +141,13 @@ alias tbr='trans :pt-BR'
# ncmpcpp
alias ncmpcpp='ncmpcpp -b ~/.config/ncmpcpp/bindings'
# todo
alias t='todo'
alias tl='todo ls'
alias ta='todo lsa'
alias td='todo done'
alias te='todo edit'
alias tj='todo jira'
#######################################################

View File

@@ -6,3 +6,12 @@ vim.o.clipboard = "unnamedplus"
vim.cmd [[highlight Normal guibg=NONE ctermbg=NONE]]
vim.cmd [[highlight NormalNC guibg=NONE ctermbg=NONE]]
vim.cmd [[highlight EndOfBuffer guibg=NONE ctermbg=NONE]]
vim.api.nvim_create_autocmd("BufRead", {
pattern = "*",
callback = function()
if vim.fn.getline(1):match("^#!.*/python") then
vim.bo.filetype = "python"
end
end
})

View File

@@ -1,185 +1,350 @@
#!/usr/bin/env python3
"""
todo.py
A minimalist CLI todo application with priorities, tags, sorting, JIRA import,
and completion-date tracking. Uses INI config at ~/.config/todo/config.ini.
"""
import sys
import os
import argparse
import re
from datetime import date
import configparser
import urllib.request
import urllib.parse
import json
from dataclasses import dataclass
from datetime import datetime, date
from pathlib import Path
from typing import List, Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt, IntPrompt
from rich.text import Text
# -------------------------------------------------------------------
# Configuration loading via configparser
# -------------------------------------------------------------------
DEFAULT_CONFIG = {
'todo': {
'todo_file': '~/sync/todo/todo.txt',
'archive_file': '~/sync/todo/todo-archive.txt',
'default_priority': 'B'
},
'tag_colors': {
'@jira': 'blue',
'@me': 'magenta',
'@work': 'green'
},
'priority_labels': {
'A': 'High',
'B': 'Med',
'C': 'Low'
},
'priority_styles': {
'A': 'bold red',
'B': 'yellow',
'C': 'green'
}
}
def load_config():
cfg = configparser.ConfigParser()
cfg.optionxform = str
config_path = Path.home() / '.config' / 'todo' / 'config.ini'
# Initialize defaults
for section, opts in DEFAULT_CONFIG.items():
if not cfg.has_section(section):
cfg[section] = {}
for k, v in opts.items():
cfg[section].setdefault(k, str(v))
# Read user overrides
if config_path.exists():
cfg.read(config_path)
return {
'todo_file': Path(cfg['todo']['todo_file']).expanduser(),
'archive_file': Path(cfg['todo']['archive_file']).expanduser(),
'default_priority': cfg['todo']['default_priority'],
'tag_colors': dict(cfg['tag_colors']),
'priority_labels': dict(cfg['priority_labels']),
'priority_styles': dict(cfg['priority_styles']),
}
CFG = load_config()
TODO_FILE_PATH = CFG['todo_file']
ARCHIVE_FILE_PATH = CFG['archive_file']
DEFAULT_PRIORITY = CFG['default_priority']
TAG_COLOR_MAP = CFG['tag_colors']
PRIORITY_LABELS = CFG['priority_labels']
PRIORITY_STYLE_MAP= CFG['priority_styles']
DATE_FORMAT = '%Y-%m-%d'
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>.+)$")
# -------------------------------------------------------------------
# Task class with completion-date support
# -------------------------------------------------------------------
def read_tasks():
return TODO_FILE.read_text().splitlines() if TODO_FILE.exists() else []
LINE_RE = re.compile(
r'^(?:x \((?P<completed>\d{4}-\d{2}-\d{2})\)\s*)?' # optional done with date
r'(?:\[#(?P<priority>[ABC])\]\s*)?' # optional priority
r'\((?P<created>\d{4}-\d{2}-\d{2})\)\s*' # created date
r'(?P<description>.+)$' # description+tags
)
def write_tasks(tasks):
TODO_FILE.write_text("\n".join(tasks) + "\n")
@dataclass
class Task:
completed: Optional[date]
priority: str
created: date
description: str
tags: List[str]
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
@property
def done(self) -> bool:
return self.completed is not None
def extract_tags(text):
return " ".join(tok for tok in text.split() if tok.startswith("@"))
@classmethod
def from_line(cls, line: str) -> Optional['Task']:
m = LINE_RE.match(line)
if not m:
return None
gd = m.groupdict()
# Parse dates
completed = None
if gd.get('completed'):
completed = datetime.strptime(gd['completed'], DATE_FORMAT).date()
prio = gd['priority'] or DEFAULT_PRIORITY
created = datetime.strptime(gd['created'], DATE_FORMAT).date()
parts = gd['description'].split()
tags = [w for w in parts if w.startswith('@')]
desc = ' '.join(w for w in parts if not w.startswith('@'))
return cls(completed=completed,
priority=prio,
created=created,
description=desc,
tags=tags)
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")
def to_line(self) -> str:
parts = []
if self.done:
parts.append(f"x ({self.completed.strftime(DATE_FORMAT)}) ")
parts.append(f"[#%s] " % self.priority)
parts.append(f"({self.created.strftime(DATE_FORMAT)}) ")
parts.append(self.description)
if self.tags:
parts.append(' ' + ' '.join(self.tags))
return ''.join(parts)
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)
def mark_done(self) -> 'Task':
return Task(completed=date.today(),
priority=self.priority,
created=self.created,
description=self.description,
tags=self.tags)
# -------------------------------------------------------------------
# I/O
# -------------------------------------------------------------------
def read_tasks() -> List[Task]:
if not TODO_FILE_PATH.exists():
return []
lines = TODO_FILE_PATH.read_text().splitlines()
return [t for t in (Task.from_line(l) for l in lines) if t]
def write_tasks(tasks: List[Task]):
TODO_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
lines = [t.to_line() for t in tasks]
TODO_FILE_PATH.write_text("\n".join(lines) + "\n")
# -------------------------------------------------------------------
# Display & Sorting
# -------------------------------------------------------------------
def display_tasks(tasks: List[Task], show_all: bool, sort_by: Optional[str]):
# filter
display = tasks if show_all else [t for t in tasks if not t.done]
# sort
if sort_by == 'created':
display.sort(key=lambda t: t.created)
elif sort_by == 'priority':
display.sort(key=lambda t: t.priority)
elif sort_by and sort_by.startswith('tag:'):
tag = sort_by.split(':',1)[1]
display.sort(key=lambda t: tag in t.tags, reverse=True)
# columns order
if show_all:
cols = ['#','Created','Completed','Pri','Done','Description','Tags']
else:
cols = ['#','Created','Pri','Description','Tags']
table = Table(title='TODOs (all)' if show_all else 'TODOs')
for c in cols:
table.add_column(c, justify='right' if c=='#' else None)
for idx, t in enumerate(display, start=1):
pri_lbl = PRIORITY_LABELS.get(t.priority, t.priority)
pri_cell= Text(pri_lbl, style=PRIORITY_STYLE_MAP.get(t.priority,''))
tag_cell= Text()
for tag in t.tags:
tag_cell.append(tag, TAG_COLOR_MAP.get(tag,''))
tag_cell.append(' ')
# cells
row = [str(idx),
t.created.strftime(DATE_FORMAT)]
if show_all:
comp = t.completed.strftime(DATE_FORMAT) if t.completed else ''
row.append(comp)
row.append(pri_cell)
if show_all:
row.append('✔' if t.done else '')
row.append(t.description)
row.append(tag_cell)
table.add_row(*row)
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}")
# -------------------------------------------------------------------
# Commands
# -------------------------------------------------------------------
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}"
def cmd_ls(args):
display_tasks(read_tasks(), show_all=False, sort_by=args.sort)
def cmd_lsa(args):
display_tasks(read_tasks(), show_all=True, sort_by=args.sort)
def cmd_add(args):
if args.text:
entry = ' '.join(args.text)
if not entry.startswith('[#'):
console.print('[yellow]No priority; defaulting to Med (B).[/]')
entry = '[#B] ' + entry
if '@' not in entry:
console.print('[yellow]No tag; defaulting to @misc.[/]')
entry += ' @misc'
t = Task.from_line(entry)
tasks = read_tasks() + ([t] if t else [])
write_tasks(tasks)
console.print(f"[green]Marked done:[/] {text}")
except Exception:
console.print("[red]Invalid task number.[/]")
console.print(f'[green]Added:[/] {t.description if t else entry}')
else:
desc = Prompt.ask('Task description')
prio = Prompt.ask('Priority (A=High,B=Med,C=Low)', choices=['A','B','C'], default=DEFAULT_PRIORITY)
tag = Prompt.ask('Tag (e.g. @work,@me,@jira)', default='@misc')
t = Task(completed=None, priority=prio, created=date.today(), description=desc, tags=[tag])
tasks = read_tasks() + [t]
write_tasks(tasks)
console.print(f'[green]Added:[/] {desc}')
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)
def cmd_done(args):
all_tasks = read_tasks()
active_idxs = [i for i,t in enumerate(all_tasks) if not t.done]
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.[/]")
n = int(args.number)
if not (1 <= n <= len(active_idxs)):
raise IndexError
idx = active_idxs[n-1]
all_tasks[idx] = all_tasks[idx].mark_done()
write_tasks(all_tasks)
console.print(f"[green]Completed:[/] {all_tasks[idx].description}")
except:
console.print('[red]Invalid task number[/]')
def edit_file():
editor = os.getenv("EDITOR", "nano")
os.execvp(editor, [editor, str(TODO_FILE)])
def cmd_donei(args):
all_tasks = read_tasks()
active = [(i,t) for i,t in enumerate(all_tasks) if not t.done]
if not active:
console.print('[yellow]No active tasks.[/]')
return
for disp,i in enumerate(active, start=1):
console.print(f"{disp}: {i[1].description}")
choice = IntPrompt.ask('Complete task #')
cmd_done(argparse.Namespace(number=str(choice)))
def cmd_archive(args):
tasks = read_tasks()
done = [t for t in tasks if t.done]
if not done:
console.print('[yellow]No tasks to archive[/]')
return
ARCHIVE_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
with ARCHIVE_FILE_PATH.open('a') as f:
for t in done:
f.write(t.to_line() + '\n')
write_tasks([t for t in tasks if not t.done])
console.print(f'[green]Archived {len(done)}[/]')
def cmd_tags(args):
tasks = [t for t in read_tasks() if args.tag in t.tags and not t.done]
if not tasks:
console.print(f'[yellow]No active tasks with tag {args.tag}[/]')
else:
display_tasks(tasks, show_all=False, sort_by=None)
def cmd_edit(args):
editor = os.getenv('EDITOR','nano')
os.execvp(editor, [editor, str(TODO_FILE_PATH)])
def cmd_jira(args):
console.print('[italic]JIRA import not yet implemented[/]')
# -------------------------------------------------------------------
# CLI dispatch
# -------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(prog="todo")
sub = parser.add_subparsers(dest="cmd", required=True)
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_ls = sub.add_parser('ls', help='List active tasks')
p_ls.add_argument('--sort', choices=['created','priority','tag:@jira','tag:@work','tag:@me'])
p_done = sub.add_parser("done", help="Mark a task as done")
p_done.add_argument("n", type=int, help="Task number")
p_lsa = sub.add_parser('lsa', help='List all tasks')
p_lsa.add_argument('--sort', choices=p_ls.get_default('sort') and p_ls.choices or None)
sub.add_parser("donei", help="Interactive task picker")
sub.add_parser('archive', help='Archive completed tasks')
p_tags = sub.add_parser("tags", help="Show tasks by tag")
p_tags.add_argument("tag", help="+tag or @context")
p_add = sub.add_parser('add', help='Add a task')
p_add.add_argument('text', nargs=argparse.REMAINDER)
sub.add_parser("edit", help="Edit the todo file")
p_done = sub.add_parser('done', help='Mark a task done')
p_done.add_argument('number')
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)")
sub.add_parser('donei', help='Interactive completion')
p_tags = sub.add_parser('tags', help='Filter by tag')
p_tags.add_argument('tag')
sub.add_parser('edit', help='Open todo file in editor')
p_jira = sub.add_parser('jira', help='Import JIRA issues')
p_jira.add_argument('--project', required=True)
p_jira.add_argument('--status','-s',action='append',default=['Open','In Progress'])
p_jira.add_argument('--assignee','-a',default='currentUser()')
p_jira.add_argument('--board','-b',type=int)
args = parser.parse_args()
{
'ls': cmd_ls,
'lsa': cmd_lsa,
'add': cmd_add,
'done': cmd_done,
'donei': cmd_donei,
'archive': cmd_archive,
'tags': cmd_tags,
'edit': cmd_edit,
'jira': cmd_jira,
}[args.cmd](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__":
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
console.print("\n[red]^C Aborted.[/]")
sys.exit(130)
console.print('\n[red]^C Aborted[/]')