fixed up my todo app to be even more robust + aliases for todo
This commit is contained in:
6
.bashrc
6
.bashrc
@@ -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'
|
||||
|
||||
|
||||
#######################################################
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
453
.local/bin/todo
453
.local/bin/todo
@@ -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[/]')
|
||||
|
||||
Reference in New Issue
Block a user