diff --git a/.gitignore b/.gitignore index c01bd8c..5420d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ */__pycache__/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index ba6819f..1dce8e0 100644 --- a/README.md +++ b/README.md @@ -15,25 +15,3 @@ todo.py is my attempt to organize myself and get things straight. I disliked the ### Sub-Section - [ ] here's another task with a due date |Oct 13 2021 ``` - -## TODO -kinda funny how a todo tool has a todo list, anyways here's what I've got to do to finish this thing: - -```md -# todo.md - -## Features -- [ ] add recurring tasks - - thinking about having the following section for spec -- [ ] add subtasks -- [ ] add notes - -## RECURRING -- some recurring task |how often it should repeat |the last time completed - - possible values for repeat time: - - daily - - weekly - mon - - biweekly - wed - - monthly - 15 - - yearly - jan - 17 -``` diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..0e349be --- /dev/null +++ b/cli.py @@ -0,0 +1,84 @@ +#!/bin/python3 +from pathlib import Path + +import argparse +import re + +from todo.TodoObject import TodoObject + +from todo import Todo, Category, Task + +parser = argparse.ArgumentParser("Todo.md CLI Application", description="Access your todos via the command line!") + +parser.add_argument("-md", "--markdown", action="store_true", help="Show markdown.") +parser.add_argument("-cat", "--category", action="store", help="Category the command should target.") +parser.add_argument("-t", "--task", action="store", help="Target a specific task in a category.") +parser.add_argument("-i", "--info", action="store_true", help="Print information about the given object.") +parser.add_argument("-x", "--complete", action="store_true", help="Toggle a task's completed status.") # x = done, right? +parser.add_argument("-f", "--file", action="store", help="Specify the file to load into a Todo.") + +args = parser.parse_args() + +if args.file is not None: + todo = Todo(Path(args.file)) +else: + todo = Todo() + +def print_incomplete_tasks(data: TodoObject): + if len(data.get_tasks(complete=False)) > 0: + print("\n--- Incomplete Task(s) ---") + for category in [data, *data.get_categories()]: # allows us to catch tasks in subcategories and the main data object. + if len(category.get_tasks(immediate=False, complete=False)) > 0: + print(f"\n{category}") + for task in category.get_tasks(immediate=True, complete=False): + print(task) + if task.has_children: + print(task.get_md()) + +data = todo # start with base todo object as input data. + +# --category handler +if args.category is not None: + args.category = f".*{args.category}" + data = data.get_category(args.category) + +# --task handler +if args.task is not None: + args.task = f".*{args.task}" + data = data.get_task(args.task) + +# --done handler +if args.complete: + if isinstance(data, Task): + data.toggle_complete() + print(f"Set complete status for '{data.text}' to '{data.complete}'.") + todo.write_data() + else: + print("-d is only applicable to tasks.") + +## --markdown handler +if args.markdown: + print(data) + print(data.get_md()) + +## --info handler +if args.info: + task_count = len(data.get_tasks()) + incomplete_count = len(data.get_tasks(complete=False)) + if isinstance(data, Todo): + print(data) + print("Categories: ", ", ".join([x.text for x in data.get_children(obj_type=Category)])) + print(f"Tasks: \n\tTotal: {task_count}\n\tIncomplete: {incomplete_count}") + print_incomplete_tasks(data) + elif isinstance(data, Category): + print("Category:", data.text) + print(f"Tasks: \n\tTotal: {task_count}\n\tIncomplete: {incomplete_count}") + print_incomplete_tasks(data) + elif isinstance(data, Task): + print("Category: ", data.task_category().text) + print("Task:", data.text) + if data.date is not None: + print("Due Date:", data.date.strftime("%b %d %Y")) + print("Complete:", data.complete) + else: + print("Don't know how we ended up here. :/") diff --git a/todo.md b/todo.md index 520907a..55e1f53 100644 --- a/todo.md +++ b/todo.md @@ -8,9 +8,8 @@ this is the to-do list for the `todo.md` project. *woah*. - category headers can go as many levels as necessary. - tasks can have due-dates, and they can either be complete, or incomplete. - [ ] this is an incomplete task. - - [x] this is a complete task. - - [ ] tasks can be nested on other tasks or notes. - + - [x] this is a complete task with a due date |Sep 30 2021 + - [x] tasks can be nested on other tasks or notes. - [ ] or they can be on the root of the category. - all tasks must belong under a category though. - so no line 1 tasks. @@ -32,4 +31,4 @@ this is the to-do list for the `todo.md` project. *woah*. - weekly - mon - biweekly - wed - monthly - 15 - - yearly - jan - 17 \ No newline at end of file + - yearly - jan - 17 diff --git a/todo/Category.py b/todo/Category.py index 4d9f294..b604e3b 100644 --- a/todo/Category.py +++ b/todo/Category.py @@ -1,28 +1,10 @@ from __future__ import annotations -from .Task import Task from .TodoObject import TodoObject class Category(TodoObject): def __init__(self, name : str, level: int, parent = None): super().__init__(level, name, parent) - - def get_subcategories(self) -> list[Category]: - categories = [] - for child in self.children: - if isinstance(child, Category): - categories.append(child) - return categories - - def get_tasks(self) -> list[Task]: - tasks = [] - for child in self.children: - if isinstance(child, Task): - tasks.append(child) - return tasks def __str__(self): return "#"*(self.level+1) + f" {self.text}" - - def __add__(self, other): - return other + self.__str__() diff --git a/todo/Note.py b/todo/Note.py index d7e0b1c..91e8c8a 100644 --- a/todo/Note.py +++ b/todo/Note.py @@ -12,7 +12,3 @@ class Note(TodoObject): return f"- {self.text}" else: return self.text - - def __add__(self, other: str): - return other + self.__str__() - diff --git a/todo/Task.py b/todo/Task.py index 7f15c14..d89dc98 100644 --- a/todo/Task.py +++ b/todo/Task.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Union from datetime import datetime -from .Note import Note from .TodoObject import TodoObject class Task(TodoObject): @@ -12,19 +11,18 @@ class Task(TodoObject): self.complete = complete super().__init__(level, name, parent) - def get_subtasks(self) -> list[Task]: - tasks = [] - for child in self.children: - if isinstance(child, Task): - tasks.append(child) - return tasks + def task_category(self) -> Category: + """Get the category this task belongs to. - def get_notes(self): - notes = [] - for child in self.children: - if isinstance(child, Note): - notes.append(child) + Returns: + Category: The category this task belongs to. + """ + return self.get_parents(Category)[-1] + def toggle_complete(self): + """Toggle this task's complete value.""" + self.complete = not self.complete + def __str__(self): output = "" if self.complete: @@ -38,5 +36,4 @@ class Task(TodoObject): output += f" {self.text}" return output - def __add__(self, other): - return other + self.__str__() +from .Category import Category diff --git a/todo/Todo.py b/todo/Todo.py index 6f6af30..104ea5a 100644 --- a/todo/Todo.py +++ b/todo/Todo.py @@ -4,7 +4,7 @@ import logging from pathlib import Path from datetime import datetime -from typing import Optional +from typing import Optional, Type from .TodoObject import TodoObject from .Category import Category @@ -15,21 +15,23 @@ logger = logging.getLogger("Todo") logger.addHandler(logging.StreamHandler()) class Todo(TodoObject): - def __init__(self, data_file : Path = Path.home().joinpath("todo.md")): + def __init__(self, data_file : Path = Path.home().joinpath("todo.md"), tab_spacing = 2): """Todo class, reads and parses a todo.md from a file. Default file is 'todo.md' in the home directory. Args: data_file (Path, optional): Location of the markdown file to load from. Defaults to ~/todo.md. + tab_spacing (int, optional): How many levels a tab character should represent. Defaults to 2. """ self.data_file = data_file + self.tab_spacing = tab_spacing super().__init__(-1, "File") self._load_data() - def __str__(self): - return self.get_md() + def __str__(self) -> str: + return f"File: {self.data_file.name}" def _get_level(self, line: str) -> int: - """Calculates the level at which this element lies. + """Calculates the level at which this element lies by parsing the given markdown line. Args: line (str): The string to parse. @@ -55,23 +57,27 @@ class Todo(TodoObject): else: # catch-all for unlisted notes return 0 - def _get_parent(self, level, object: TodoObject): - """Gets the parent of an object at level X. + def _get_parent(self, level: int, obj: TodoObject, obj_type: Optional[Type[TodoObject]] = None): + """Gets the parent for an object at level X. + If an object with a lower level cannot be found within the obj, it will return the obj. Args: level (int): The level to find the parent for. - object (TodoObject): Object to grab parents from. + obj (TodoObject): Object to grab parents from. + obj_type (Type[TodoObject], optional): The type of the object the parent should be. Default is None. Returns: TodoObject: The object found. """ if level > 0: - for child in object.children: - if child.level < level: + for child in obj.children[::-1]: + if obj_type is not None and isinstance(child, obj_type) and child.level < level: return child - return object + if obj_type is None and child.level < level: + return child + return obj else: - return object + return obj def _parse_note(self, line: str, parent: TodoObject): """Parses a markdown line representing a note. @@ -117,7 +123,7 @@ class Todo(TodoObject): line (str): The line to parse. """ level = self._get_level(line) - parent = self._get_parent(level, self) + parent = self._get_parent(level, self, obj_type=Category) line = line[level+2:] Category(line, level, parent) @@ -125,54 +131,17 @@ class Todo(TodoObject): """Load categories and tasks from self.data_file.""" with self.data_file.open("r") as f: for line in f: + line = line.replace("\t", self.tab_spacing*" ") line = line.rstrip() if len(line.strip()) == 0: # skip empty lines continue elif line[0] == "#": self._parse_category(line) elif re.match(" *- \\[[x ]\\]", line): - self._parse_task(line, self.children[-1]) + self._parse_task(line, self.get_children(obj_type=Category)[-1]) else: - self._parse_note(line, self.children[-1]) + self._parse_note(line, self.get_children(obj_type=Category)[-1]) - def get_md(self, category_spacing: int = 1, task_spacing: int = 0, note_spacing: int = 0) -> str: - """Gets the markdown text of the current data loaded into the object. - - Args: - category_spacing (int, optional): Amount of newlines between categories. Defaults to 1. - task_spacing (int, optional): Amount of newlines between tasks. Defaults to 0. - note_spacing (int, optional): Amount of newlines between category notes. Defaults to 0. - - Returns: - str: the markdown text generated - """ - output = "" - for i in self.get_children(immediate=True): - if isinstance(i, Category): - output += f"{i}\n" - elif isinstance(i, Task): - output += f"{i.level*2*' '}{i}\n" - elif isinstance(i, Note): - output += f"{i.level*2*' '}{i}\n" - return output - - def get_category(self, name: str) -> Optional[Category]: - """Gets the requested category by name. - - Args: - name (str): The name of the category to return. - - Returns: - Optional[Category]: Returns the category if found. - """ - for i in self.children: - if isinstance(i, Category) and i.text == name: - return i - -def main(): - """Generates a markdown of my todos""" - todo = Todo() - print(todo) - -if __name__ == "__main__": - main() + def write_data(self): + """Write the Todo data to the datafile.""" + self.data_file.write_text(self.get_md()) diff --git a/todo/TodoObject.py b/todo/TodoObject.py index 4dbab1d..585107f 100644 --- a/todo/TodoObject.py +++ b/todo/TodoObject.py @@ -1,12 +1,15 @@ +from __future__ import annotations import logging +import re -from typing import Optional +from typing import Optional, Type, TypeVar logger = logging.getLogger("TodoObject") logger.setLevel(logging.INFO) logger.addHandler(logging.StreamHandler()) class TodoObject: + """Base TodoObject for all classes in the todo library.""" def __init__(self, level, text, parent = None): self.text: str = text self.level: int = level @@ -14,18 +17,76 @@ class TodoObject: self.children: list[TodoObject] = [] self.parent: Optional[TodoObject] = parent if parent is not None: - self.set_parents(parent) + self._set_parents(parent) - def get_children(self, immediate = False): + def get_children(self, immediate = False, obj_type: Optional[Type[T]] = None) -> list[T]: + """Get all children of an object. Optionally specify a class of TodoObject to return. + + Args: + immediate (bool, optional): Whether it should return immediate children only. Defaults to False. + obj_type (Type[Note | Category | Task], optional): Specify the types of children to return. Defaults to None. + + Returns: + list[Note | Category | Task]: Returns all children that match the criteria. + """ output = [] for child in self.children: - if immediate and child.parent is self: + if obj_type is not None and isinstance(child, obj_type) and immediate and child.parent is self: output.append(child) - elif not immediate: + elif obj_type is None and immediate and child.parent is self: output.append(child) + elif obj_type is not None and isinstance(child, obj_type) and not immediate: + output.append(child) + elif obj_type is None and not immediate: + output.append(child) + return output - def set_parents(self, parent): - parent: TodoObject + def get_parents(self, obj_type: Optional[Type[T]] = None) -> list[T]: + """Get all parents of an object. Optionally specify a class of TodoObject to return. + + Args: + obj_type (Type[Note | Category | Task], optional): Specify the types of parents to return. Defaults to None. + + Returns: + list[Note | Category | Task]: List of parents found. + """ + output = [] + for parent in self.parents: + if obj_type is not None and isinstance(parent, obj_type): + output.append(parent) + if obj_type is None: + output.append(parent) + return output + + def get_md(self, category_spacing: int = 1) -> str: + """Gets the markdown text of the current data loaded into the object. + + Args: + category_spacing (int, optional): Amount of newlines between categories. Defaults to 1. + + Returns: + str: the markdown text generated + """ + output = "" + for i in self.get_children(): + if len(output) > 0: + output += "\n" + if isinstance(i, Category): + if i.level > 0: + output += "\n"*category_spacing + output += f"{i}" + elif isinstance(i, Task): + output += f"{i.level*2*' '}{i}" + elif isinstance(i, Note): + output += f"{i.level*2*' '}{i}" + return output + + def _set_parents(self, parent: TodoObject): + """Set the parents of an object by supplying the first immediate parent. + + Args: + parent (TodoObject): The immediate parent of the object. + """ for p in parent.parents: logger.debug(f"adding parent '{p.text}' to '{self.text}'") self.parents.append(p) @@ -36,3 +97,96 @@ class TodoObject: logger.debug(f"adding child '{self.text}' to '{parent.text}'") parent.children.append(self) self.parent = parent + + def get_tasks(self, immediate: bool = False, complete: Optional[bool] = None) -> list[Task]: + """Get all tasks from the children of the TodoObject. + + Args: + immediate (bool, optional): Whether it should return immediate children only. Defaults to False. + complete (Optional[bool], optional): If true, return completed tasks only, inverse if False. Defaults to None. + + Returns: + list[Task]: List of tasks found. + """ + if complete is None: + return self.get_children(immediate, Task) + else: + output = [] + for child in self.get_children(immediate, Task): + if child.complete is complete: + output.append(child) + return output + + def get_task(self, regex: str, immediate: bool = False, complete: Optional[bool] = None) -> Task: + """Get a specific task from a supplied regular expression. + + Args: + regex (str): The regular expression to match for the text of the task. + immediate (bool, optional): Whether or not it should return immediate children only. Defaults to False. + complete (Optional[bool], optional): If true, return completed tasks only, inverse if False. Defaults to None. + + Returns: + Task: Returns the task found + """ + for task in self.get_tasks(immediate, complete): + if re.match(regex, task.text): + return task + + def get_categories(self, immediate: bool = False) -> list[Category]: + """Gets all categories from children of the TodoObject. + + Returns: + list[Category]: Returns all categories found. + """ + return self.get_children(immediate, obj_type=Category) + + def get_category(self, regex: str, immediate: bool = False) -> Category: + """Get a specific task from a supplied regular expression. + + Args: + regex (str): The regular expression to match for the text of the category. + immediate (bool, optional): Whether or not it should return immediate children only. Defaults to False. + + Returns: + Category: Returns the category found. + """ + for category in self.get_categories(immediate): + if re.match(regex, category.text): + return category + + def get_notes(self, immediate: bool = False) -> list[Note]: + """Get all notes from children of the TodoObject. + + Args: + immediate (bool, optional): Whether or not it should return immeduate children only. Defaults to False. + + Returns: + Note: Returns all notes found. + """ + return self.get_children(immediate, obj_type=Note) + + def get_note(self, regex: str, immediate: bool = False) -> Note: + """Get a specific note from a supplied regular expression. + + Args: + regex (str): The regular expression to match for the text of the note. + immediate (bool, optional): Whether or not it should return immediate children only. Defaults to False. + + Returns: + Note: Returns the note found. + """ + for note in self.get_notes(immediate): + if re.match(regex, note.text): + return note + + @property + def has_children(self) -> bool: + if len(self.children) > 0: + return True + else: + return False + +from .Note import Note +from .Category import Category +from .Task import Task +T = TypeVar("T", Note, Category, Task)