commenting/cleanup

This commit is contained in:
jpt 2025-05-12 18:13:24 -05:00
parent b974b2f386
commit 913b56da99
7 changed files with 125 additions and 150 deletions

View File

@ -13,13 +13,17 @@ app = typer.Typer()
@app.command() @app.command()
def new( def new(
type: str,
name: Annotated[str, typer.Option("-n", "--name", help="name")] = "", name: Annotated[str, typer.Option("-n", "--name", help="name")] = "",
category: Annotated[str, typer.Option("-c", "--category", help="name")] = "", category: Annotated[str, typer.Option("-c", "--category", help="name")] = "",
due: Annotated[str, typer.Option("-d", "--due", help="due")] = "", due: Annotated[str, typer.Option("-d", "--due", help="due")] = "",
url: Annotated[str, typer.Option("-u", "--url", help="URL")] = "", url: Annotated[str, typer.Option("-u", "--url", help="URL")] = "",
): ):
"""
Add new thing without opening TUI.
"""
initialize_db() initialize_db()
required = ["name", "category"] required = ["name"]
if url and not name: if url and not name:
resp = httpx.get(url, follow_redirects=True) resp = httpx.get(url, follow_redirects=True)
@ -37,7 +41,7 @@ def new(
# due = typer.prompt("Due (YYYY-MM-DD):") # due = typer.prompt("Due (YYYY-MM-DD):")
# TODO: validate/allow blank # TODO: validate/allow blank
add_thing(name, category, due) add_thing(type=type, text=name, category=category, due=due)
typer.echo("Created new thing!") typer.echo("Created new thing!")
@ -45,6 +49,9 @@ def new(
def table( def table(
view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "tasks", view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "tasks",
): ):
"""
Open default table view.
"""
initialize_db() initialize_db()
things_tui(view) things_tui(view)

View File

@ -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 = { SPECIAL_DATES_PIECES = {
"future": (3000, 1, 1), "future": (3000, 1, 1),
"unclassified": (1999, 1, 1), "unclassified": (1999, 1, 1),

View File

@ -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 fn
from peewee import Case, Value from peewee import Case, Value
from ..db import db, Thing from ..db import db, Thing
@ -27,6 +44,10 @@ def update_thing(
type: str | None = None, type: str | None = None,
**kwargs, **kwargs,
) -> Thing: ) -> Thing:
"""
Updates a thing in database, treating deleted & type as special params
and everything else goes into the data JSON.
"""
with db.atomic(): with db.atomic():
thing = Thing.get_by_id(item_id) thing = Thing.get_by_id(item_id)
updates = {"data": thing.data | kwargs} updates = {"data": thing.data | kwargs}
@ -80,6 +101,13 @@ def get_things(
filters: dict[str, str] | None = None, filters: dict[str, str] | None = None,
sort: str = "", sort: str = "",
) -> list[Thing]: ) -> 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) query = Thing.select().where(~Thing.deleted)
if search_text: if search_text:

View File

@ -1,3 +1,7 @@
"""
Defines the core data models in peewee ORM
"""
import json import json
from datetime import date, timedelta, datetime from datetime import date, timedelta, datetime
from xdg_base_dirs import xdg_data_home from xdg_base_dirs import xdg_data_home
@ -11,9 +15,7 @@ from peewee import (
) )
from playhouse.sqlite_ext import JSONField from playhouse.sqlite_ext import JSONField
# This module defines the core data types. # Global DB connection
#
db = SqliteDatabase( db = SqliteDatabase(
xdg_data_home() / "tt/tt.db", xdg_data_home() / "tt/tt.db",
pragmas={ pragmas={
@ -30,6 +32,26 @@ class BaseModel(Model):
class Thing(BaseModel): 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() type = CharField()
data = JSONField() data = JSONField()
created_at = DateTimeField(default=datetime.now) created_at = DateTimeField(default=datetime.now)
@ -37,15 +59,26 @@ class Thing(BaseModel):
deleted = BooleanField(default=False) deleted = BooleanField(default=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# override of save method to ensure updated_at timestamp is always set
self.updated_at = datetime.now() self.updated_at = datetime.now()
# schema enforcement could happen here as well, if/when needed
return super(Thing, self).save(*args, **kwargs) return super(Thing, self).save(*args, **kwargs)
# @property @property
# def due_week(self): def due_week(self):
# return self.due.isocalendar()[1] - 12 # 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): class SavedSearch(BaseModel):
"""
Saved search parameters.
Possibly to be removed in favor of config.toml saved searches?
"""
name = CharField(unique=True) name = CharField(unique=True)
filters = CharField() filters = CharField()
sort_string = CharField() sort_string = CharField()
@ -55,9 +88,14 @@ class SavedSearch(BaseModel):
class ThingGenerator(BaseModel): class ThingGenerator(BaseModel):
"""
Abstract "thing generator" class, allows creation of recurring tasks
and other "things that make other things".
"""
template = CharField() template = CharField()
type = CharField() type = CharField()
config = TextField() # JSON config = TextField() # TODO: JSON
deleted = BooleanField(default=False) deleted = BooleanField(default=False)
last_generated_at = DateTimeField(null=True) last_generated_at = DateTimeField(null=True)
created_at = DateTimeField(default=datetime.now) 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: if not self.last_generated_at or self.last_generated_at < maybe_next:
return 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 # same logic as above, if we're stepping another month forward
month += 1 month += 1
if month == 13: if month == 13:

View File

@ -1,3 +1,7 @@
"""
Experimental sync code.
"""
import httpx import httpx
from collections import defaultdict from collections import defaultdict
from .controller.things import get_things from .controller.things import get_things

View File

@ -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()

View File

@ -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 re
import os import os
import datetime import datetime
@ -7,6 +14,9 @@ from .constants import SPECIAL_DATES_DISPLAY
def filter_to_string(filters, search_query): def filter_to_string(filters, search_query):
"""
Format/summarize search fields for display.
"""
pieces = [] pieces = []
project = filters.get("project") project = filters.get("project")
status = filters.get("status") status = filters.get("status")
@ -19,14 +29,16 @@ def filter_to_string(filters, search_query):
return "".join(pieces) return "".join(pieces)
def remove_rich_tag(text): def remove_rich_tag(text: str) -> str:
"""remove rich styling from a string""" """remove rich styling from a string"""
pattern = r"\[[^\]]*\](.*?)\[/\]" pattern = r"\[[^\]]*\](.*?)\[/\]"
return re.sub(pattern, r"\1", text) return re.sub(pattern, r"\1", text)
def advance_enum_val(enum_type, cur_val): 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] members = [str(e.value) for e in enum_type]
cur_idx = members.index(remove_rich_tag(cur_val)) cur_idx = members.index(remove_rich_tag(cur_val))
next_idx = (cur_idx + 1) % len(members) 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: 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"] color = enum.get(value, {"color": "#ff0000"})["color"]
return f"[{color}]{value}[/]" 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): if isinstance(date, datetime.date):
as_date = date as_date = date
as_str = date.strftime("%Y-%m-%d") 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_date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
as_str = as_date.strftime("%Y-%m-%d") as_str = as_date.strftime("%Y-%m-%d")
else: else:
return "~bad~" return "~bad~" # so *something* will show up in UI & can be edited
if as_str in SPECIAL_DATES_DISPLAY: if as_str in SPECIAL_DATES_DISPLAY:
return SPECIAL_DATES_DISPLAY[as_str] return SPECIAL_DATES_DISPLAY[as_str]
@ -58,6 +79,7 @@ def get_colored_date(date: datetime.date) -> str:
delta = as_date - today delta = as_date - today
weeks = delta.days // 7 weeks = delta.days // 7
# red,orange,yellow,white gradient
colors = [ colors = [
# "#FF4000", # "#FF4000",
"#FF8000", "#FF8000",
@ -77,6 +99,11 @@ def get_colored_date(date: datetime.date) -> str:
def get_text_from_editor(initial_text: str = "") -> str | None: 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") editor = os.environ.get("EDITOR", "vim")
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w+", delete=True) as tf: with tempfile.NamedTemporaryFile(suffix=".txt", mode="w+", delete=True) as tf: