398 lines
13 KiB
Python
Executable File
398 lines
13 KiB
Python
Executable File
#!/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<completed>\d{4}-\d{2}-\d{2})\)\s*)?'
|
|
r'(?:\[#(?P<priority>[ABC])\]\s*)?'
|
|
r'\((?P<created>\d{4}-\d{2}-\d{2})\)\s*'
|
|
r'(?P<description>.+)$'
|
|
)
|
|
|
|
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).[/]')
|
|
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()
|
|
if t:
|
|
tasks.append(t)
|
|
write_tasks(tasks)
|
|
console.print(f'[green]Added:[/] {t.description}')
|
|
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}")
|
|
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[/]')
|