diff --git a/src/tt/cli.py b/src/tt/cli.py index 8f8d43e..1bd572a 100644 --- a/src/tt/cli.py +++ b/src/tt/cli.py @@ -13,13 +13,17 @@ app = typer.Typer() @app.command() def new( + type: str, name: Annotated[str, typer.Option("-n", "--name", help="name")] = "", category: Annotated[str, typer.Option("-c", "--category", help="name")] = "", due: Annotated[str, typer.Option("-d", "--due", help="due")] = "", url: Annotated[str, typer.Option("-u", "--url", help="URL")] = "", ): + """ + Add new thing without opening TUI. + """ initialize_db() - required = ["name", "category"] + required = ["name"] if url and not name: resp = httpx.get(url, follow_redirects=True) @@ -37,7 +41,7 @@ def new( # due = typer.prompt("Due (YYYY-MM-DD):") # TODO: validate/allow blank - add_thing(name, category, due) + add_thing(type=type, text=name, category=category, due=due) typer.echo("Created new thing!") @@ -45,6 +49,9 @@ def new( def table( view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "tasks", ): + """ + Open default table view. + """ initialize_db() things_tui(view) diff --git a/src/tt/constants.py b/src/tt/constants.py index e79593e..7909e36 100644 --- a/src/tt/constants.py +++ b/src/tt/constants.py @@ -1,3 +1,10 @@ +""" +Actual constants, used minimally. + +Most belong in config.py so they can be overriden. +""" + +# special purpose for certain dates SPECIAL_DATES_PIECES = { "future": (3000, 1, 1), "unclassified": (1999, 1, 1), diff --git a/src/tt/controller/things.py b/src/tt/controller/things.py index 66ec9b7..42ebd92 100644 --- a/src/tt/controller/things.py +++ b/src/tt/controller/things.py @@ -1,3 +1,20 @@ +""" +Controller layer for Things. + +This layer can rely on `peewee` but no layer above it may. + +For TUI Editor, each controller must have functions to: + +- get_one(int) -> Obj +- get_many(search_text, filters, sort) -> list[Obj] +- add(**kwargs) -> Obj +- update(id, deleted, **kwargs) -> Obj + +(names may vary!) + +These will be exposed directly to editor TUI (and web UI). +""" + from peewee import fn from peewee import Case, Value from ..db import db, Thing @@ -27,6 +44,10 @@ def update_thing( type: str | None = None, **kwargs, ) -> Thing: + """ + Updates a thing in database, treating deleted & type as special params + and everything else goes into the data JSON. + """ with db.atomic(): thing = Thing.get_by_id(item_id) updates = {"data": thing.data | kwargs} @@ -80,6 +101,13 @@ def get_things( filters: dict[str, str] | None = None, sort: str = "", ) -> list[Thing]: + """ + Main query function. + + - search_text: fuzzy match strings against text field + - filters: exact field lookups (will use attribs then data elements) + - sort: comma-separated sort string specifying sort parameters + """ query = Thing.select().where(~Thing.deleted) if search_text: diff --git a/src/tt/db.py b/src/tt/db.py index 961d86d..3a08db4 100644 --- a/src/tt/db.py +++ b/src/tt/db.py @@ -1,3 +1,7 @@ +""" +Defines the core data models in peewee ORM +""" + import json from datetime import date, timedelta, datetime from xdg_base_dirs import xdg_data_home @@ -11,9 +15,7 @@ from peewee import ( ) from playhouse.sqlite_ext import JSONField -# This module defines the core data types. -# - +# Global DB connection db = SqliteDatabase( xdg_data_home() / "tt/tt.db", pragmas={ @@ -30,6 +32,26 @@ class BaseModel(Model): class Thing(BaseModel): + """ + Thing uses sqlite as a sort of schema-less database. + + - type corresponds to a table type in config.toml + - data is the JSON data, which adheres (loosely) to the schema defined for the given type + - also contains additional system-wide metadata fields tracked by app + + Note: There are a few places in the code where an attribute of Thing is + accessed where the caller can't be sure if a field name is part of the + model or somewhere within data. + + Therefore the lookup logic is almost always: + + - check for attribute first, e.g. getattr(obj, "field") + - if the attribute does not exist, check for data["field"] + - if neither is present, raise an error + + Therefore, `data` cannot contain properties with names used by attributes here. + """ + type = CharField() data = JSONField() created_at = DateTimeField(default=datetime.now) @@ -37,15 +59,26 @@ class Thing(BaseModel): deleted = BooleanField(default=False) def save(self, *args, **kwargs): + # override of save method to ensure updated_at timestamp is always set self.updated_at = datetime.now() + # schema enforcement could happen here as well, if/when needed return super(Thing, self).save(*args, **kwargs) - # @property - # def due_week(self): - # return self.due.isocalendar()[1] - 12 + @property + def due_week(self): + # FIXME: this is an experimental hack for spring quarter + # ideally will have a way to define logic about this complex within config + return self.due.isocalendar()[1] - 12 class SavedSearch(BaseModel): + """ + Saved search parameters. + + Possibly to be removed in favor of config.toml saved searches? + + """ + name = CharField(unique=True) filters = CharField() sort_string = CharField() @@ -55,9 +88,14 @@ class SavedSearch(BaseModel): class ThingGenerator(BaseModel): + """ + Abstract "thing generator" class, allows creation of recurring tasks + and other "things that make other things". + """ + template = CharField() type = CharField() - config = TextField() # JSON + config = TextField() # TODO: JSON deleted = BooleanField(default=False) last_generated_at = DateTimeField(null=True) created_at = DateTimeField(default=datetime.now) @@ -93,7 +131,7 @@ class ThingGenerator(BaseModel): if not self.last_generated_at or self.last_generated_at < maybe_next: return maybe_next - # TODO: this doesn't handle if a month was missed somehow, just advances one + # FIXME: this doesn't handle if a month was missed somehow, just advances one # same logic as above, if we're stepping another month forward month += 1 if month == 13: diff --git a/src/tt/sync.py b/src/tt/sync.py index a21840b..42f18c5 100644 --- a/src/tt/sync.py +++ b/src/tt/sync.py @@ -1,3 +1,7 @@ +""" +Experimental sync code. +""" + import httpx from collections import defaultdict from .controller.things import get_things diff --git a/src/tt/tui/overview.py b/src/tt/tui/overview.py deleted file mode 100644 index 646f9e0..0000000 --- a/src/tt/tui/overview.py +++ /dev/null @@ -1,136 +0,0 @@ -from textual.app import App, ComposeResult -from textual.containers import ScrollableContainer, Horizontal -from textual.widgets import DataTable, Label -from textual.binding import Binding -from datetime import datetime - -from ..controller.summaries import ( - get_category_summary, - get_recently_active, - get_due_soon, -) - -#### -# Due Soon # Task Summary -# # -# # -################################## -# WIP # Recently active -# # -# # -# # - - -class CategoryTable(DataTable): - """Table showing category summaries""" - - def on_mount(self): - self.add_columns( - "Category", "Zero", "WIP", "Blocked", "Done", "Overdue", "Due Soon" - ) - self.refresh_data() - - def refresh_data(self): - self.clear() - summaries = get_category_summary(10) # Show top 10 categories - for summary in summaries: - self.add_row( - summary["category"], - str(summary["tasks"]["zero"]), - str(summary["tasks"]["wip"]), - str(summary["tasks"]["blocked"]), - str(summary["tasks"]["done"]), - str(summary["tasks"]["overdue"]), - str(summary["tasks"]["due_soon"]), - ) - - -def format_due_date(due_date: datetime | None) -> str: - if not due_date: - return "No due date" - return due_date.strftime("%Y-%m-%d") - - -class DueTaskList(DataTable): - """Table showing upcoming and overdue tasks""" - - def on_mount(self): - self.add_columns("Task", "Category", "Due Date") - self.refresh_data() - - def refresh_data(self): - self.clear() - tasks = get_due_soon(10, all_overdue=True) - for task in tasks: - self.add_row( - task["text"], - task["category"], - format_due_date(task["due"]), - key=str(task["id"]), - ) - - -class RecentTaskList(DataTable): - """Table showing recently active tasks""" - - def on_mount(self): - self.add_columns("Task", "Category", "Due Date") - self.refresh_data() - - def refresh_data(self): - self.clear() - tasks = get_recently_active(10) - for task in tasks: - self.add_row( - task["text"], - task["category"] or "-", - format_due_date(task["due"]), - key=str(task["id"]), - ) - - -class Overview(App): - """Task overview application""" - - CSS = """ - CategoryTable { - height: 40%; - margin: 1 1; - } - - Label { - align: center middle; - background: purple; - } - - #lists { - height: 60%; - } - """ - - BINDINGS = [ - Binding("q", "quit", "Quit"), - Binding("r", "refresh", "Refresh Data"), - ] - - def compose(self) -> ComposeResult: - with Horizontal(id="top"): - yield CategoryTable() - with Horizontal(id="lists"): - with ScrollableContainer(): - yield Label("Due Soon") - yield DueTaskList() - with ScrollableContainer(): - yield Label("Activity") - yield RecentTaskList() - - def action_refresh(self): - """Refresh all data in the tables""" - self.query_one(CategoryTable).refresh_data() - self.query_one(DueTaskList).refresh_data() - self.query_one(RecentTaskList).refresh_data() - - -def run(): - app = Overview() - app.run() diff --git a/src/tt/utils.py b/src/tt/utils.py index 7acaaf8..7bd968c 100644 --- a/src/tt/utils.py +++ b/src/tt/utils.py @@ -1,3 +1,10 @@ +""" +General purpose utilities. + +Most of these utilities have to do with TUI. Perhaps this becomes tui/utils +once webui is ready. +""" + import re import os import datetime @@ -7,6 +14,9 @@ from .constants import SPECIAL_DATES_DISPLAY def filter_to_string(filters, search_query): + """ + Format/summarize search fields for display. + """ pieces = [] project = filters.get("project") status = filters.get("status") @@ -19,14 +29,16 @@ def filter_to_string(filters, search_query): return "".join(pieces) -def remove_rich_tag(text): +def remove_rich_tag(text: str) -> str: """remove rich styling from a string""" pattern = r"\[[^\]]*\](.*?)\[/\]" return re.sub(pattern, r"\1", text) def advance_enum_val(enum_type, cur_val): - """advance a value in an enum by one, wrapping around""" + """ + Advance a value in an enum by one position, wrap around if end is reached. + """ members = [str(e.value) for e in enum_type] cur_idx = members.index(remove_rich_tag(cur_val)) next_idx = (cur_idx + 1) % len(members) @@ -34,11 +46,20 @@ def advance_enum_val(enum_type, cur_val): def get_color_enum(value: str, enum: dict[str, dict]) -> str: + """ + Use settings to color enumeration fields in UI. + """ color = enum.get(value, {"color": "#ff0000"})["color"] return f"[{color}]{value}[/]" -def get_colored_date(date: datetime.date) -> str: +def get_colored_date(date: datetime.date | str) -> str: + """ + Date coloring function -- heat approach. + + TODO: refactor this into a set of rules that can be defined in TOML config + """ + # incoming date can be date or str, we need both if isinstance(date, datetime.date): as_date = date as_str = date.strftime("%Y-%m-%d") @@ -46,7 +67,7 @@ def get_colored_date(date: datetime.date) -> str: as_date = datetime.datetime.strptime(date, "%Y-%m-%d").date() as_str = as_date.strftime("%Y-%m-%d") else: - return "~bad~" + return "~bad~" # so *something* will show up in UI & can be edited if as_str in SPECIAL_DATES_DISPLAY: return SPECIAL_DATES_DISPLAY[as_str] @@ -58,6 +79,7 @@ def get_colored_date(date: datetime.date) -> str: delta = as_date - today weeks = delta.days // 7 + # red,orange,yellow,white gradient colors = [ # "#FF4000", "#FF8000", @@ -77,6 +99,11 @@ def get_colored_date(date: datetime.date) -> str: def get_text_from_editor(initial_text: str = "") -> str | None: + """ + Function that launches $EDITOR with a field's text. + + When editor is closed, will read file (if modified) and ingest edited text. + """ editor = os.environ.get("EDITOR", "vim") with tempfile.NamedTemporaryFile(suffix=".txt", mode="w+", delete=True) as tf: