diff --git a/README.md b/README.md index 86dabed..054b20b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Ajal Todo CLI -A todo utility +A todo utility - enhanced diff --git a/todo.md b/todo.md index 27d00d1..cdd6f17 100644 --- a/todo.md +++ b/todo.md @@ -1,10 +1,11 @@ # A ToDo list -- Buy: - - Milk - - Eggs - - Coffee - - Wine -- Clean - - Gutters - +- [ ] Buy: + - [ ] Milk + - [ ] Eggs + - [ ] Coffee + - [ ] Wine +- [ ] Clean + - [ ] Gutters +- [x] Love + - [x] Myself diff --git a/todo.py b/todo.py index 03b802a..b529938 100755 --- a/todo.py +++ b/todo.py @@ -1,152 +1,149 @@ #!/usr/bin/env python3 -import os +import argparse import re -import sys +from dataclasses import dataclass, field +from pathlib import Path -base_dir = os.path.abspath(os.path.dirname(__file__)) -todo_file = os.path.join(base_dir, "todo.md") +base_dir = Path(__file__).absolute().parent -def add_task(task: str, parent=None): - todo = parse_todo() - if parent and parent not in todo: - print(f"Could not find parent task '{parent}' in root task list") - exit(1) - - print(f"Adding task '{task}' to '{parent if parent else 'root'}'") - if parent: - todo[parent]["sub_tasks"][task] = {"completed": False} - else: - todo[task] = {"completed": False, "sub_tasks": {}} - write_todo(todo) +@dataclass +class LeafTask: + completed: bool = False -def list_tasks(parent=None): - todo = parse_todo() - if parent and parent not in todo: - print(f"Could not find parent task '{parent}' in root task list") - exit(1) - - print(f"Listing tasks in '{parent if parent else 'root'}'") - if parent: - todo = {parent: todo[parent]} - print(render_todo(todo)) +@dataclass +class RootTask(LeafTask): + tasks: dict[str, LeafTask] = field(default_factory=dict) -def complete_task(task, parent=None): - todo = parse_todo() - if parent: - if parent not in todo: - print(f"Could not find parent task '{parent}' in root task list") - exit(1) - if task not in todo[parent]["sub_tasks"]: - print(f"Could not find task '{task}' in parent task '{parent}'") - exit(1) - if task not in todo: - print(f"Could not find task '{task}' in root task list") - exit(1) - - print(f"Marking task '{task}' in '{parent if parent else 'root'}' as completed") - if parent: - task = todo[parent]["sub_tasks"][task] - else: - task = todo[task] - task["completed"] = True - write_todo(todo) +@dataclass +class Command: + func: callable + read_only: bool = False + help: str | None = None -def remove_task(task, parent=None): - todo = parse_todo() - if parent: - if parent not in todo: - print(f"Could not find parent task '{parent}' in root task list") - exit(1) - if task not in todo[parent]["sub_tasks"]: - print(f"Could not find task '{task}' in parent task '{parent}'") - exit(1) - if task not in todo: - print(f"Could not find task '{task}' in root task list") - exit(1) +@dataclass +class CommandList: + commands: dict[str, Command] = field(default_factory=dict) - print(f"Removing task '{task}' from '{parent if parent else 'root'}'") - if parent: - del todo[parent]["sub_tasks"][task] - else: - del todo[task] - write_todo(todo) + def mark(self, read_only: bool = False): + def wrap(func): + self.commands[func.__name__] = Command(func=func, read_only=read_only, help=func.__doc__) + return func + return wrap + + @property + def read_list(self): + return [(command, cmd) for command, cmd in self.commands.items() if cmd.read_only] + + @property + def write_list(self): + return [(command, cmd) for command, cmd in self.commands.items() if not cmd.read_only] -def print_help(): - print("""Usage: todo.py [add|complete|remove] TASK [PARENT] - todo.py list [PARENT] +class ToDo: + args: argparse.Namespace + tasks: dict[str, RootTask] = {} + commands: CommandList = CommandList() - Manages todo.md file as a ToDo file""") + def __init__(self): + self.args = self.parse_args() + self.tasks_root = self.tasks + self.task_class = RootTask + def parse_args(self): + parser = argparse.ArgumentParser(description="Manages todo.md file as a ToDo file") + parser.add_argument("--todo-file", "-f", help="Path to the todo file.", default=base_dir / "todo.md") -def parse_todo() -> dict[str, dict]: - todo = {} - with open(todo_file, "r") as f: - lines = f.readlines() + sub_parsers = parser.add_subparsers(dest="command", help="Command to run", required=True) + for command, cmd in self.commands.write_list: + sub_parser = sub_parsers.add_parser(command, help=cmd.help.lstrip().splitlines()[0], description=cmd.help) + sub_parser.add_argument("--root", "-r", help="Root task") + sub_parser.add_argument("task", help="Task name") + for command, cmd in self.commands.read_list: + sub_parser = sub_parsers.add_parser(command, help=cmd.help.lstrip().splitlines()[0], description=cmd.help) + sub_parser.add_argument("--root", "-r", help="Root task") + + return parser.parse_args() + + @property + def command(self) -> Command: + return self.commands.commands[self.args.command] + + def run(self) -> None: + self.parse() + self.augment_root() + self.command.func(self) + if not self.command.read_only: + self.write() + self.list() + + def parse(self): root_task = None - for line in lines: + for line in self.args.todo_file.read_text().splitlines(): if matches := re.match(r"^-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): - root_task_name = matches.group("task") - root_task = { - "completed": matches.group("status") == "x", - "sub_tasks": {}, - } - todo[root_task_name] = root_task + task_name = matches.group("task") + root_task = RootTask(completed=matches.group("status") == "x") + self.tasks[task_name] = root_task elif matches := re.match(r"^\s+-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): - leaf_task = matches.group("task") - root_task["sub_tasks"][leaf_task] = { - "completed": matches.group("status") == "x", - } - return todo + root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x") + def render(self, tasks: dict | None = None): + lines = ["# A ToDo list", ""] + for root_task_name, root_task in (tasks or self.tasks).items(): # type: str, RootTask + lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}") + for leaf_task_name, task in root_task.tasks.items(): + lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}") + lines += [""] + return "\n".join(lines) -def render_todo(todo: dict[str, dict]) -> str: - lines = ["# A ToDo list", ""] - for task, task_data in todo.items(): # type: str, dict - lines.append(f"- [{'x' if task_data["completed"] else ' '}] {task}") - for leaf_task, leaf_task_data in task_data["sub_tasks"].items(): - lines.append(f" - [{'x' if leaf_task_data["completed"] else ' '}] {leaf_task}") - lines += [""] - return "\n".join(lines) + def write(self, todo: dict | None = None): + self.args.todo_file.write_text(self.render(tasks=todo)) + def augment_root(self): + if self.args.root: + if self.args.root not in self.tasks: + print(f"Root task '{self.args.root}' does not exist") + exit(1) + else: + self.tasks_root = self.tasks[self.args.root].tasks + self.task_class = LeafTask -def write_todo(todo: dict[str, dict]): - with open(todo_file, "w") as f: - f.write(render_todo(todo)) + @commands.mark(read_only=True) + def list(self): + """ + List all tasks in todo.md + Returns all tasks if no root task is passed, otherwise only the root task and subtasks + """ + print(f"Listing tasks in '{self.args.root if self.args.root else 'root'}'") + print(self.render(tasks={self.args.root: self.tasks[self.args.root]} if self.args.root else None)) -def main(): - if len(sys.argv) < 2: - print_help() - exit(1) + @commands.mark() + def add(self): + """ + Add a task to todo.md + """ + print(f"Adding task '{self.args.task}' to '{self.args.root if self.args.root else 'root'}'") + self.tasks_root[self.args.task] = self.task_class() - match sys.argv[1]: - case "help" | "--help" | "h" | "-h": - print_help() - case "list": - match sys.argv[2:]: - case [parent, *_]: - list_tasks(parent) - case _: - list_tasks() - case "add" | "complete" | "remove" as task_name: - task_func = f"{task_name}_task" - match sys.argv[2:]: - case [task]: - globals()[task_func](task) - case [parent, task, *_]: - globals()[task_func](task, parent) - case _: - print_help() - exit(1) - case _: - print_help() - exit(1) + @commands.mark() + def complete(self): + """ + Mark a task in todo.md as completed + """ + print(f"Marking task '{self.args.task}' in '{self.args.root if self.args.root else 'root'}' as completed") + self.tasks_root[self.args.task].completed = True + + @commands.mark() + def remove(self): + """ + Remove a task from todo.md + """ + del self.tasks_root[self.args.task] if __name__ == "__main__": - main() + ToDo().run()