#!/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 os import argparse import re import configparser import urllib.request import urllib.parse import urllib.error import json from datetime import datetime, date from pathlib import Path 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' 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)) 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' console = Console() # ------------------------------------------------------------------- # Task class with completion-date support # ------------------------------------------------------------------- LINE_RE = re.compile( r'^(?:x \((?P\d{4}-\d{2}-\d{2})\)\s*)?' r'(?:\[#(?P[ABC])\]\s*)?' r'\((?P\d{4}-\d{2}-\d{2})\)\s*' r'(?P.+)$' ) class Task: def __init__(self, completed, priority, created, description, tags): self.completed = completed self.priority = priority self.created = created self.description = description self.tags = tags @property def done(self): return self.completed is not None @classmethod def from_line(cls, line): m = LINE_RE.match(line) if not m: return None gd = m.groupdict() completed = None if gd.get('completed'): completed = datetime.strptime(gd['completed'], DATE_FORMAT).date() prio = gd.get('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, prio, created, desc, tags) def to_line(self): parts = [] if self.done: parts.append(f"x ({self.completed.strftime(DATE_FORMAT)}) ") parts.append(f"[#{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) def mark_done(self): return Task(date.today(), self.priority, self.created, self.description, self.tags) # ------------------------------------------------------------------- # I/O # ------------------------------------------------------------------- def read_tasks(): if not TODO_FILE_PATH.exists(): return [] lines = TODO_FILE_PATH.read_text().splitlines() tasks = [] for l in lines: t = Task.from_line(l) if t: tasks.append(t) return tasks def write_tasks(tasks): 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, show_all, sort_by): display = tasks if show_all else [t for t in tasks if not t.done] 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) cols = ['#','Created','Pri','Description','Tags'] if show_all: cols.insert(2, 'Completed') cols.insert(4, 'Done') 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(' ') 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) # ------------------------------------------------------------------- # Commands # ------------------------------------------------------------------- def cmd_ls(args): display_tasks(read_tasks(), False, args.sort) def cmd_lsa(args): display_tasks(read_tasks(), True, 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).[/]') today = date.today().strftime(DATE_FORMAT) entry = f"[#{DEFAULT_PRIORITY}] ({today}) {entry}" if '@' not in entry: console.print('[yellow]No tag; defaulting to @misc.[/]') entry += ' @misc' t = Task.from_line(entry) tasks = read_tasks() if t: tasks.append(t) write_tasks(tasks) console.print(f'[green]Added:[/] {t.description}') else: console.print(f'[red]Failed to parse task line:[/] {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(None, prio, date.today(), desc, [tag]) tasks = read_tasks() tasks.append(t) write_tasks(tasks) console.print(f'[green]Added:[/] {desc}') def cmd_done(args): all_tasks = read_tasks() active_idxs = [i for i,t in enumerate(all_tasks) if not t.done] try: 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}") cmd_ls(argparse.Namespace(sort=None)) except: console.print('[red]Invalid task number[/]') 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,t) in enumerate(active, start=1): console.print(f"{disp}: {t.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") remaining = [t for t in tasks if not t.done] write_tasks(remaining) 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, False, None) def cmd_edit(args): editor = os.getenv('EDITOR','nano') os.execvp(editor, [editor, str(TODO_FILE_PATH)]) def cmd_jira(args): jira_token = os.getenv('JIRA_TOKEN') if not jira_token: console.print('[red]Missing JIRA_TOKEN environment variable[/]') return base_url = 'https://jira.atg-corp.com' jql = ( f'project="{args.project}" ' f'AND assignee=currentUser() ' f'AND status in ("Open","In Progress") ' f'ORDER BY created DESC' ) url = f'{base_url}/rest/api/2/search?jql={urllib.parse.quote(jql)}' console.print(f'[cyan]Requesting URL:[/] {url}') req = urllib.request.Request(url) req.add_header('Authorization', f'Bearer {jira_token}') req.add_header('Accept', 'application/json') req.add_header('Content-Type', 'application/json') try: with urllib.request.urlopen(req) as resp: data = json.load(resp) except urllib.error.HTTPError as e: body = e.read().decode(errors='ignore') console.print(f'[red]HTTP Error {e.code}: {e.reason}[/]') console.print('[yellow]Response body:[/]') console.print(body) return except Exception as e: console.print(f'[red]Failed to fetch JIRA issues:[/] {e}') return issues = data.get('issues', []) if not issues: console.print('[yellow]No JIRA issues found.[/]') return tasks = read_tasks() added = 0 for issue in issues: key = issue['key'] summary = issue['fields']['summary'] date_str = issue['fields']['created'][:10] desc = f'[{key}] {summary}' # skip duplicates by key if any(t.description.startswith(f'[{key}]') for t in tasks): continue t = Task( None, 'B', datetime.strptime(date_str, DATE_FORMAT).date(), desc, ['@jira'] ) tasks.append(t) added += 1 write_tasks(tasks) console.print(f'[green]Imported {added} JIRA tasks from {args.project} assigned to you.[/]') # ------------------------------------------------------------------- # CLI dispatch # ------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(prog='todo') sub = parser.add_subparsers(dest='cmd', required=True) 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_lsa = sub.add_parser('lsa', help='List all tasks') p_lsa.add_argument('--sort', choices=['created','priority','tag:@jira','tag:@work','tag:@me']) sub.add_parser('archive', help='Archive completed tasks') p_add = sub.add_parser('add', help='Add a task') p_add.add_argument('text', nargs=argparse.REMAINDER) p_done = sub.add_parser('done', help='Mark a task done') p_done.add_argument('number') 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', default='IS', help='JIRA project key (defaults to IS)') 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 __name__ == '__main__': try: main() except KeyboardInterrupt: console.print('\n[red]^C Aborted[/]')