Compare commits

...

7 commits

Author SHA1 Message Date
jpt
fe5be1115b use THing.type field 2025-05-03 18:09:34 -05:00
jpt
2bf3d126e9 move columns to global level 2025-05-03 17:43:19 -05:00
jpt
ddf4662d78 remove tasks 2025-05-03 15:34:33 -05:00
jpt
c5eef57aeb purging tasks 2025-05-03 15:27:36 -05:00
jpt
9e3dd591d6 restore filter logic 2025-05-03 14:41:00 -05:00
jpt
968ae1d18a get things working like tasks 2025-05-03 14:06:02 -05:00
jpt
5de9914341 initial -> things migration 2025-05-03 13:39:49 -05:00
13 changed files with 259 additions and 354 deletions

View file

@ -2,11 +2,11 @@ import typer
import httpx import httpx
import lxml.html import lxml.html
from typing_extensions import Annotated from typing_extensions import Annotated
from .controller.tasks import add_task
from .db import initialize_db from .db import initialize_db
from .tui.tasks import run as tasks_tui from .tui.things import run as things_tui
from .tui.overview import run as overview_tui #from .tui.overview import run as overview_tui
from .tui.recurring import run as recurring_tui from .tui.recurring import run as recurring_tui
from .controller.things import add_thing
app = typer.Typer() app = typer.Typer()
@ -37,16 +37,15 @@ 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_task(name, category, due) add_thing(name, category, due)
typer.echo("Created new task!") typer.echo("Created new thing!")
@app.command() @app.command()
def tasks( def table(
view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "default", view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "default",
): ):
initialize_db() initialize_db()
tasks_tui(view) things_tui("tasks")
@app.command() @app.command()

View file

@ -42,6 +42,16 @@ def get_view(name):
raise ValueError(f"no such view! {name}") raise ValueError(f"no such view! {name}")
def get_column(name):
if name in ("id", "type"):
return {"field_name": name, "display_name": name, "read_only": True}
for col in _load_config().get("columns", []):
if col["field_name"] == name:
return col
raise ValueError(f"no such column! {name}")
# Valid statuses & projects are read dynamically from the user's config. # Valid statuses & projects are read dynamically from the user's config.
STATUSES = get_enum("status") STATUSES = get_enum("status")
PROJECTS = get_enum("projects") PROJECTS = get_enum("projects")

View file

@ -1,15 +1,15 @@
import json import json
from datetime import date, timedelta from datetime import date, timedelta
from ..db import db, TaskGenerator from ..db import db, ThingGenerator
from .tasks import add_task from .things import add_thing
def get_generator(item_id: int) -> TaskGenerator: def get_generator(item_id: int) -> ThingGenerator:
return TaskGenerator.get_by_id(item_id) return ThingGenerator.get_by_id(item_id)
def get_generators() -> list[TaskGenerator]: def get_generators() -> list[ThingGenerator]:
query = TaskGenerator.select().where(~TaskGenerator.deleted) query = ThingGenerator.select().where(~ThingGenerator.deleted)
return query.order_by("type", "template") return query.order_by("type", "template")
@ -17,19 +17,19 @@ def add_generator(
template: str, template: str,
type: str, type: str,
val: str, val: str,
) -> TaskGenerator: ) -> ThingGenerator:
# JSON for future expansion # JSON for future expansion
config = json.dumps({"val": val}) config = json.dumps({"val": val})
with db.atomic(): with db.atomic():
task = TaskGenerator.create( thing = ThingGenerator.create(
template=template, template=template,
type=type, type=type,
config=config, config=config,
) )
return task return thing
def generate_needed_tasks(): def generate_needed_things():
to_create = [] to_create = []
for g in get_generators(): for g in get_generators():
next = g.next_at() next = g.next_at()
@ -46,7 +46,7 @@ def generate_needed_tasks():
) )
for c in to_create: for c in to_create:
add_task(**c) add_thing(**c)
return to_create return to_create
@ -54,13 +54,13 @@ def generate_needed_tasks():
def update_generator( def update_generator(
item_id: int, item_id: int,
**kwargs, **kwargs,
) -> TaskGenerator: ) -> ThingGenerator:
# replace "val" with JSON # replace "val" with JSON
if "val" in kwargs: if "val" in kwargs:
config = {"val": kwargs.pop("val")} config = {"val": kwargs.pop("val")}
kwargs["config"] = json.dumps(config) kwargs["config"] = json.dumps(config)
with db.atomic(): with db.atomic():
query = TaskGenerator.update(kwargs).where(TaskGenerator.id == item_id) query = ThingGenerator.update(kwargs).where(ThingGenerator.id == item_id)
query.execute() query.execute()
task = TaskGenerator.get_by_id(item_id) thing = ThingGenerator.get_by_id(item_id)
return task return thing

View file

@ -1,107 +0,0 @@
from datetime import datetime, timedelta
from peewee import fn, JOIN
from ..db import Task
def get_category_summary(num: int = 5) -> list[dict]:
"""
Returns summary of top categories with task counts by status and due dates.
Args:
num: Number of categories to return, ordered by total task count
Returns:
List of dicts containing category name and task statistics
"""
return []
def get_recently_active(num: int = 5, category: str | None = None) -> list[dict]:
"""
Returns most recently active tasks, optionally filtered by category.
Args:
num: Number of tasks to return
category: Optional category name to filter by
Returns:
List of tasks ordered by last activity (updated_at)
"""
query = (
Task.select(Task, Category).join(Category, JOIN.LEFT_OUTER).where(~Task.deleted)
)
if category:
query = query.where(Category.name == category)
query = query.order_by(Task.updated_at.desc()).limit(num)
return [
{
"id": task.id,
"text": task.text,
"status": task.status,
"type": task.type,
"category": task.category.name if task.category else None,
"due": task.due,
"updated_at": task.updated_at,
}
for task in query
]
def get_due_soon(
num: int = 5, all_overdue: bool = True, category: str | None = None
) -> list[dict]:
"""
Returns tasks ordered by due date, optionally including all overdue tasks.
Args:
num: Number of non-overdue tasks to return
all_overdue: If True, returns all overdue tasks plus num more tasks
If False, includes overdue tasks in the count
category: Optional category name to filter by
Returns:
List of tasks ordered by due date
"""
now = datetime.now()
# unfinished tasks w/ due date
query = (
Task.select(Task)
.join(Category, JOIN.LEFT_OUTER)
.where(
(~Task.deleted)
& (Task.due.is_null(False))
& (Task.due != "")
& (Task.status != "done")
)
)
# filter by category
if category:
query = query.where(Category.name == category)
if all_overdue:
# grab all overdue & append a few more on
overdue_tasks = list(query.where(Task.due < now).order_by(Task.due))
upcoming_tasks = list(
query.where(Task.due >= now).order_by(Task.due).limit(num)
)
tasks = overdue_tasks + upcoming_tasks
else:
# just most due tasks
tasks = list(query.order_by(Task.due).limit(num))
return [
{
"id": task.id,
"text": task.text,
"status": task.status,
"type": task.type,
"category": task.category.name if task.category else None,
"due": task.due,
"days_until_due": (task.due - now).days if task.due else None,
}
for task in tasks
]

View file

@ -1,114 +0,0 @@
import json
from datetime import datetime
from peewee import fn
from peewee import Case, Value
from ..db import db, Task, SavedSearch
from .. import config
from ..constants import SPECIAL_DATES_PIECES
def get_task(item_id: int) -> Task:
return Task.get_by_id(item_id)
def add_task(
text: str,
status: str,
due: datetime | str = SPECIAL_DATES_PIECES["unclassified"],
type: str = "",
project: str = "",
) -> Task:
"""
Add a new task to the database.
Returns the created task instance.
"""
with db.atomic():
task = Task.create(
text=text, type=type, status=status, due=due, project=project
)
return task
def update_task(
item_id: int,
**kwargs,
) -> Task:
with db.atomic():
query = Task.update(kwargs).where(Task.id == item_id)
query.execute()
task = Task.get_by_id(item_id)
return task
def _parse_sort_string(sort_string, status_order):
"""
Convert sort string like 'field1,-field2' to peewee order_by expressions.
"""
sort_expressions = []
if not sort_string:
return sort_expressions
for field in sort_string.split(","):
is_desc = field.startswith("-")
field_name = field[1:] if is_desc else field
if field == "status":
if not status_order:
status_order = list(config.STATUSES.keys())
# CASE statement that maps each status to its position in the order
order_case = Case(
Task.status,
[(s, Value(i)) for i, s in enumerate(status_order)],
)
sort_expressions.append(order_case.desc() if is_desc else order_case.asc())
elif field_name == "due_date":
expr = fn.COALESCE(getattr(Task, field_name), datetime(3000, 12, 31))
sort_expressions.append(expr.desc() if is_desc else expr)
else:
field_expr = getattr(Task, field_name)
sort_expressions.append(field_expr.desc() if is_desc else field_expr)
return sort_expressions
def get_tasks(
search_text: str | None = None,
statuses: tuple[str] | None = None,
projects: tuple[str] | None = None,
sort: str = "",
) -> list[Task]:
query = Task.select().where(~Task.deleted)
if search_text:
query = query.where(fn.Lower(Task.text).contains(search_text.lower()))
if statuses:
query = query.where(Task.status.in_(statuses))
if projects:
query = query.where(Task.project.in_(projects))
sort_expressions = _parse_sort_string(sort, statuses)
query = query.order_by(*sort_expressions)
return list(query)
def save_view(name: str, *, filters: dict, sort_string: str) -> SavedSearch:
filters_json = json.dumps(filters)
with db.atomic():
if SavedSearch.select().where(SavedSearch.name == name).exists():
query = SavedSearch.update(
filters=filters_json, sort_string=sort_string
).where(SavedSearch.name == name)
query.execute()
else:
SavedSearch.create(name=name, filters=filters_json, sort_string=sort_string)
def get_saved_view_names() -> list[str]:
return [search.name for search in SavedSearch.select()]
def get_saved_view(name: str) -> SavedSearch:
return SavedSearch.get(SavedSearch.name == name)

100
src/tt/controller/things.py Normal file
View file

@ -0,0 +1,100 @@
from peewee import fn
from peewee import Case, Value
from ..db import db, Thing
from .. import config
def get_thing(item_id: int) -> Thing:
return Thing.get_by_id(item_id)
def add_thing(
type: str = "?",
**kwargs,
) -> Thing:
"""
Add a new Thing to the database.
Returns the created Thing instance.
"""
with db.atomic():
thing = Thing.create(
type=type, data=kwargs
)
return thing
def update_thing(
item_id: int,
**kwargs,
) -> Thing:
with db.atomic():
thing = Thing.get_by_id(item_id)
new_data = thing.data | kwargs
query = Thing.update(data=new_data).where(Thing.id == item_id)
query.execute()
thing = Thing.get_by_id(item_id)
return thing
def _parse_sort_string(sort_string):
"""
Convert sort string like 'field1,-field2' to peewee order_by expressions.
"""
sort_expressions = []
if not sort_string:
return sort_expressions
for field_name in sort_string.split(","):
is_desc = field_name.startswith("-")
field_name = field_name[1:] if is_desc else field_name
# TODO: look up field type
field_type = "text"
if field_type == "enum":
# TODO: allow dynamic ordering
order = config.get_enum(field_name)
# CASE statement that maps each status to its position in the order
order_case = Case(
Thing.data[field_name],
[(s, Value(i)) for i, s in enumerate(order)],
)
sort_expressions.append(order_case.desc() if is_desc else order_case.asc())
elif field_type == "date":
expr = fn.COALESCE(Thing.data[field_name], "3000-01-01")
sort_expressions.append(expr.desc() if is_desc else expr)
else:
field_expr = Thing.data[field_name]
sort_expressions.append(field_expr.desc() if is_desc else field_expr)
return sort_expressions
def get_things(
search_text: str | None = None,
filters: dict[str, str] | None = None,
sort: str = "",
) -> list[Thing]:
query = Thing.select().where(~Thing.deleted)
if search_text:
# TODO: which fields are searchable should by dynamic
query = query.where(fn.Lower(Thing.data['text']).contains(search_text.lower()))
for param, val in filters.items():
if val is not None:
# no _in query for JSON fields, so use OR
condition = False
for cond in val:
# handle type filtering the same way
if param == "type":
condition |= Thing.type == cond
else:
condition |= Thing.data[param] == cond
query = query.where(condition)
sort_expressions = _parse_sort_string(sort)
query = query.order_by(*sort_expressions)
return list(query)

View file

@ -9,12 +9,13 @@ from peewee import (
SqliteDatabase, SqliteDatabase,
TextField, TextField,
) )
from playhouse.sqlite_ext import JSONField
# This module defines the core data types. # This module defines the core data types.
# #
db = SqliteDatabase( db = SqliteDatabase(
xdg_data_home() / "tt/tasks.db", xdg_data_home() / "tt/tt.db",
pragmas={ pragmas={
"journal_mode": "wal", "journal_mode": "wal",
"synchronous": "normal", "synchronous": "normal",
@ -28,24 +29,20 @@ class BaseModel(Model):
database = db database = db
class Thing(BaseModel):
class Task(BaseModel):
text = TextField()
status = CharField()
due = DateTimeField(null=True)
type = CharField() type = CharField()
project = CharField() data = JSONField()
created_at = DateTimeField(default=datetime.now) created_at = DateTimeField(default=datetime.now)
updated_at = DateTimeField(default=datetime.now) updated_at = DateTimeField(default=datetime.now)
deleted = BooleanField(default=False) deleted = BooleanField(default=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.updated_at = datetime.now() self.updated_at = datetime.now()
return super(Task, 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 # return self.due.isocalendar()[1] - 12
class SavedSearch(BaseModel): class SavedSearch(BaseModel):
@ -57,7 +54,7 @@ class SavedSearch(BaseModel):
return self.name return self.name
class TaskGenerator(BaseModel): class ThingGenerator(BaseModel):
template = CharField() template = CharField()
type = CharField() type = CharField()
config = TextField() # JSON config = TextField() # JSON
@ -87,7 +84,7 @@ class TaskGenerator(BaseModel):
month = 1 month = 1
year += 1 year += 1
# recurring tasks on 29-31 in Feb will just happen on 28th # recurring on 29-31 in Feb will just happen on 28th
if day_of_month >= 29 and month == 2: if day_of_month >= 29 and month == 2:
maybe_next = date(year, month, 28) maybe_next = date(year, month, 28)
else: else:
@ -111,5 +108,5 @@ class TaskGenerator(BaseModel):
def initialize_db(): def initialize_db():
db.connect() db.connect()
db.create_tables([Task, SavedSearch, TaskGenerator]) db.create_tables([SavedSearch, ThingGenerator, Thing])
db.close() db.close()

View file

@ -69,7 +69,9 @@ class EnumColumnConfig(TableColumnConfig):
class DateColumnConfig(TableColumnConfig): class DateColumnConfig(TableColumnConfig):
def preprocess(self, val): def preprocess(self, val):
try: try:
return datetime.datetime.strptime(val, "%Y-%m-%d") # ensure it parses, return as string
datetime.datetime.strptime(val, "%Y-%m-%d")
return val
except ValueError: except ValueError:
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD") raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")

View file

@ -15,7 +15,7 @@ from ..utils import (
) )
from .keymodal import KeyModal from .keymodal import KeyModal
from .modals import ConfirmModal from .modals import ConfirmModal
from .columns import get_col_cls from .columns import get_col_cls, NotifyValidationError
@ -105,10 +105,14 @@ class TableEditor(App):
- sort_string - sort_string
""" """
self.table_config = [] self.table_config = []
view = config.get_view(name) self.view = config.get_view(name)
# FIXME: special case during things conversion
# if recurring, different column definition
# set up columns # set up columns
for col in view["columns"]: for col_name in self.view["columns"]:
col = config.get_column(col_name)
field_type = col.get("field_type", "text") field_type = col.get("field_type", "text")
field_cls = get_col_cls(field_type) field_cls = get_col_cls(field_type)
field_name = col["field_name"] field_name = col["field_name"]
@ -125,8 +129,9 @@ class TableEditor(App):
self.table_config.append(cc) self.table_config.append(cc)
# load default filters # load default filters
self.filters = view["filters"] self.filters = self.view["filters"]
self.sort_string = view["sort"] self.sort_string = self.view["sort"]
self.defaults = self.view["defaults"]
def _db_item_to_row(self, item): def _db_item_to_row(self, item):
""" """
@ -135,7 +140,12 @@ class TableEditor(App):
row = [] row = []
for col in self.table_config: for col in self.table_config:
# look up properties first (ID, type, etc.)
# & fall back to data JSON if not found
try:
val = getattr(item, col.field) val = getattr(item, col.field)
except AttributeError:
val = item.data[col.field]
display_val = col.format_for_display(val) display_val = col.format_for_display(val)
row.append(display_val) row.append(display_val)
@ -233,12 +243,20 @@ class TableEditor(App):
for fc in self.table_config: for fc in self.table_config:
if fc.read_only: if fc.read_only:
continue continue
val = self.filters.get(fc.field, fc.default) val = self.defaults.get(fc.field, fc.default)
if val is not None: if val is not None:
if "," in val: if "," in val:
val = val.split(",")[0] # TODO: fix hack for enums val = val.split(",")[0] # TODO: fix hack for enums
prepopulated[fc.field] = val prepopulated[fc.field] = val
# required fields
for required in ["type"]:
if required not in prepopulated:
try:
prepopulated[required] = self.defaults[required]
except KeyError:
raise Exception(f"must set a default for {required}")
new_item = self.add_item_callback(**prepopulated) new_item = self.add_item_callback(**prepopulated)
self.refresh_data(restore_cursor=False) self.refresh_data(restore_cursor=False)
self.move_cursor_to_item(new_item.id) # TODO: check success here self.move_cursor_to_item(new_item.id) # TODO: check success here

View file

@ -5,12 +5,12 @@ from ..controller.generators import (
get_generators, get_generators,
add_generator, add_generator,
update_generator, update_generator,
generate_needed_tasks, generate_needed_things,
) )
from .editor import ( TableEditor, ) from .editor import ( TableEditor, )
class TaskGenEditor(TableEditor): class GenEditor(TableEditor):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.update_item_callback = update_generator self.update_item_callback = update_generator
@ -19,9 +19,9 @@ class TaskGenEditor(TableEditor):
self.table_config = self._load_config("recurring") self.table_config = self._load_config("recurring")
def refresh_items(self): def refresh_items(self):
generated = generate_needed_tasks() generated = generate_needed_things()
if num := len(generated): if num := len(generated):
self.notify(f"created {num} tasks") self.notify(f"created {num} things")
items = get_generators() items = get_generators()
for item in items: for item in items:
self.table.add_row( self.table.add_row(
@ -34,5 +34,5 @@ class TaskGenEditor(TableEditor):
def run(): def run():
app = TaskGenEditor() app = GenEditor()
app.run() app.run()

View file

@ -1,78 +0,0 @@
import json
from textual.widgets import Input
from ..controller.tasks import (
get_task,
get_tasks,
add_task,
update_task,
save_view,
get_saved_view,
)
from .editor import TableEditor
class TT(TableEditor):
BINDINGS = [
# saved views
("ctrl+s", "save_view", "save current view"),
("ctrl+o", "load_view", "load saved view"),
]
def __init__(self, default_view="default"):
super().__init__("tasks")
self.update_item_callback = update_task
self.add_item_callback = add_task
self.get_item_callback = get_task
def _load_db_view(self, name):
try:
saved = get_saved_view(name)
self.filters = json.loads(saved.filters)
self.sort_string = saved.sort_string
except Exception:
self.notify(f"Could not load {name}")
def action_save_view(self):
self._show_input("save-view", "default")
def action_load_view(self):
self._show_input("load-view", "")
def on_input_submitted(self, event: Input.Submitted):
# Override to add save/load view
if self.mode == "save-view":
save_view(event.value, filters=self.filters, sort_string=self.sort_string)
self._hide_input()
event.prevent_default()
elif self.mode == "load-view":
self._load_view(event.value)
self.refresh_tasks(restore_cursor=False)
self._hide_input()
# if event isn't handled here it will bubble to parent
event.prevent_default()
def refresh_items(self):
items = get_tasks(
self.search_query,
projects=self._filters_to_list("project"),
statuses=self._filters_to_list("status"),
sort=self.sort_string,
)
for item in items:
self.table.add_row(
*self._db_item_to_row(item),
key=str(item.id),
)
def _filters_to_list(self, key):
filters = self.filters.get(key)
if filters:
return filters.split(",")
else:
return None
def run(default_view):
app = TT(default_view)
app.run()

72
src/tt/tui/things.py Normal file
View file

@ -0,0 +1,72 @@
from ..controller.things import (
get_thing,
get_things,
add_thing,
update_thing,
)
from .editor import TableEditor
class ThingTable(TableEditor):
# BINDINGS = [
# # saved views
# ("ctrl+s", "save_view", "save current view"),
# ("ctrl+o", "load_view", "load saved view"),
# ]
def __init__(self, view="default"):
super().__init__(view)
self.update_item_callback = update_thing
self.add_item_callback = add_thing
self.get_item_callback = get_thing
# def _load_db_view(self, name):
# try:
# saved = get_saved_view(name)
# self.filters = json.loads(saved.filters)
# self.sort_string = saved.sort_string
# except Exception:
# self.notify(f"Could not load {name}")
# def action_save_view(self):
# self._show_input("save-view", "default")
# def action_load_view(self):
# self._show_input("load-view", "")
# def on_input_submitted(self, event: Input.Submitted):
# # Override to add save/load view
# if self.mode == "save-view":
# save_view(event.value, filters=self.filters, sort_string=self.sort_string)
# self._hide_input()
# event.prevent_default()
# elif self.mode == "load-view":
# self._load_view(event.value)
# self.refresh_things(restore_cursor=False)
# self._hide_input()
# # if event isn't handled here it will bubble to parent
# event.prevent_default()
def refresh_items(self):
items = get_things(
self.search_query,
filters={key: self._filters_to_list(key) for key in self.filters},
sort=self.sort_string,
)
for item in items:
self.table.add_row(
*self._db_item_to_row(item),
key=str(item.id),
)
def _filters_to_list(self, key):
filters = self.filters.get(key)
if filters:
return filters.split(",")
else:
return None
def run(default_view):
app = ThingTable(default_view)
app.run()

View file

@ -39,17 +39,23 @@ def get_color_enum(value: str, enum: dict[str, dict]) -> str:
def get_colored_date(date: datetime.date) -> str: def get_colored_date(date: datetime.date) -> str:
if not isinstance(date, datetime.date): if isinstance(date, datetime.date):
return "" as_date = date
as_str = date.strftime("%Y-%m-%d") as_str = date.strftime("%Y-%m-%d")
elif isinstance(date, str):
as_date = datetime.datetime.strptime(date, "%Y-%m-%d").date()
as_str = as_date.strftime("%Y-%m-%d")
else:
return "~bad~"
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]
today = datetime.date.today() today = datetime.date.today()
if date.date() < today: if as_date < today:
return f"[#eeeeee on #dd1111]{as_str}[/]" return f"[#eeeeee on #dd1111]{as_str}[/]"
else: else:
# Calculate weeks into future # Calculate weeks into future
delta = date.date() - today delta = as_date - today
weeks = delta.days // 7 weeks = delta.days // 7
colors = [ colors = [