commenting/cleanup
This commit is contained in:
parent
b974b2f386
commit
913b56da99
@ -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)
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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:
|
||||
|
54
src/tt/db.py
54
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:
|
||||
|
@ -1,3 +1,7 @@
|
||||
"""
|
||||
Experimental sync code.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from collections import defaultdict
|
||||
from .controller.things import get_things
|
||||
|
@ -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()
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user