Compare commits

..

No commits in common. "92cc3c5b40900c97851dd0b7d804687bbe9eb5d2" and "2c5310fe0d36f3eec9e9151ed8c1ed047d585357" have entirely different histories.

13 changed files with 107 additions and 345 deletions

View File

@ -9,7 +9,6 @@ dependencies = [
"lxml>=5.3.0",
"peewee>=3.17.8",
"textual>=1.0.0",
"tomlkit>=0.13.2",
"typer>=0.15.1",
]

View File

@ -1,14 +0,0 @@
import tomlkit
def get_enum(name):
with open("tt.toml", "r") as f:
config = tomlkit.load(f)
for enum in config.get("enums", []):
if enum["name"] == name:
return {v["value"]: v for v in enum["values"]}
raise ValueError(f"no such enum! {name}")
STATUSES = get_enum("status")
PROJECTS = get_enum("projects")

View File

@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from peewee import fn, JOIN
from ..db import Task, Category
from ..db import Task, Category, TaskStatus
def get_category_summary(num: int = 5) -> list[dict]:
@ -20,7 +20,7 @@ def get_category_summary(num: int = 5) -> list[dict]:
overdue_count = (
Task.select(Task.category, fn.COUNT(Task.id).alias("overdue"))
.where(
(~Task.deleted) & (Task.due < now) & (Task.status != "done")
(~Task.deleted) & (Task.due < now) & (Task.status != TaskStatus.DONE.value)
)
.group_by(Task.category)
)
@ -32,7 +32,7 @@ def get_category_summary(num: int = 5) -> list[dict]:
(~Task.deleted)
& (Task.due >= now)
& (Task.due <= week_from_now)
& (Task.status != "done")
& (Task.status != TaskStatus.DONE.value)
)
.group_by(Task.category)
)
@ -41,16 +41,16 @@ def get_category_summary(num: int = 5) -> list[dict]:
query = (
Category.select(
Category.name,
fn.COALESCE(fn.SUM(Task.status == "zero"), 0).alias(
fn.COALESCE(fn.SUM(Task.status == TaskStatus.ZERO.value), 0).alias(
"zero_count"
),
fn.COALESCE(fn.SUM(Task.status == "wip"), 0).alias(
fn.COALESCE(fn.SUM(Task.status == TaskStatus.WIP.value), 0).alias(
"wip_count"
),
fn.COALESCE(fn.SUM(Task.status == "blocked"), 0).alias(
fn.COALESCE(fn.SUM(Task.status == TaskStatus.BLOCKED.value), 0).alias(
"blocked_count"
),
fn.COALESCE(fn.SUM(Task.status == "done"), 0).alias(
fn.COALESCE(fn.SUM(Task.status == TaskStatus.DONE.value), 0).alias(
"done_count"
),
fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"),
@ -148,7 +148,7 @@ def get_due_soon(
(~Task.deleted)
& (Task.due.is_null(False))
& (Task.due != "")
& (Task.status != "done")
& (Task.status != TaskStatus.DONE.value)
)
)

View File

@ -2,8 +2,7 @@ import json
from datetime import datetime
from peewee import fn
from peewee import Case, Value
from ..db import db, Task, Category, SavedSearch
from .. import config
from ..db import db, Task, Category, TaskStatus, SavedSearch
def category_lookup(category):
@ -19,7 +18,7 @@ def get_task(item_id: int) -> Task:
def add_task(
text: str,
category: str,
status: str,
status: str = TaskStatus.ZERO.value,
due: datetime | None = None,
type: str = "",
) -> Task:
@ -63,7 +62,7 @@ def _parse_sort_string(sort_string, status_order):
if field == "status":
if not status_order:
status_order = list(config.STATUSES.keys())
status_order = [s.value for s in TaskStatus]
# CASE statement that maps each status to its position in the order
order_case = Case(
Task.status,

View File

@ -20,6 +20,15 @@ db = SqliteDatabase(
},
)
class TaskStatus(Enum):
# order is used for progression in toggle
ZERO = "zero"
WIP = "wip"
BLOCKED = "blocked"
DONE = "done"
class GeneratorType(Enum):
DAYS_BETWEEN = "days-btwn"
MONTHLY = "monthly"
@ -39,7 +48,10 @@ class Category(BaseModel):
class Task(BaseModel):
text = TextField()
status = CharField()
status = CharField(
choices=[(status.value, status.name) for status in TaskStatus],
default=TaskStatus.ZERO.value,
)
due = DateTimeField(null=True)
category = ForeignKeyField(Category, backref="tasks", null=True)
type = CharField()

View File

@ -1,7 +1,6 @@
import csv
from datetime import datetime
from tt.db import initialize_db, Task, Category
from tt.config import STATUSES
from tt.db import initialize_db, Task, Category, TaskStatus
def import_tasks_from_csv(filename: str):
@ -21,7 +20,9 @@ def import_tasks_from_csv(filename: str):
# Validate status
status = row["status"].lower() if row["status"] else "zero"
if status not in STATUSES:
try:
TaskStatus(status)
except ValueError:
print(f"Warning: Invalid status '{status}', defaulting to 'zero'")
status = "zero"

View File

@ -1,4 +1,3 @@
import datetime
from textual.app import App
from textual.widgets import (
DataTable,
@ -11,15 +10,30 @@ from textual.containers import Container
from ..utils import (
remove_rich_tag,
filter_to_string,
advance_enum_val,
get_text_from_editor,
)
from .keymodal import KeyModal
from .modals import ChoiceModal, DateModal, ConfirmModal
ELLIPSIS = ""
class NotifyValidationError(Exception):
"""will notify and continue if raised"""
def _enum_preprocessor(enumCls):
"""generate a default preprocessor to enforce enums"""
def preprocessor(val):
try:
enumCls(val)
return val
except ValueError:
raise NotifyValidationError(
f"Invalid value. Use: {[s.value for s in enumCls]}"
)
class TableColumnConfig:
def __init__(
@ -28,6 +42,8 @@ class TableColumnConfig:
display_name: str,
*,
default=None,
enum=None,
preprocessor=None,
enable_editor=False,
filterable=True,
read_only=False,
@ -35,57 +51,16 @@ class TableColumnConfig:
self.field = field
self.display_name = display_name
self.default = default
self.enum = enum
self.enable_editor = enable_editor
self.filterable = filterable
self.read_only = read_only
def preprocess(self, val):
return val # no-op
def start_change(self, app, current_value):
if current_value.endswith(ELLIPSIS):
app.action_start_edit()
if preprocessor:
self.preprocessor = preprocessor
elif self.enum:
self.preprocessor = _enum_preprocessor(self.enum)
else:
# default edit mode
app._show_input("edit", current_value)
class EnumColumnConfig(TableColumnConfig):
def __init__(
self,
field: str,
display_name: str,
enum,
**kwargs
):
super().__init__(field, display_name, **kwargs)
self.enum = enum
def preprocess(self, val):
if val in self.enum:
return val
else:
raise NotifyValidationError(
f"Invalid value {val}. Use: {list(self.enum)}"
)
def start_change(self, app, current_value):
# a weird hack? pass app here and correct modal gets pushed
app.push_screen(ChoiceModal(self.enum, current_value), app.apply_change)
class DateColumnConfig(TableColumnConfig):
def preprocess(self, val):
try:
return datetime.strptime(val, "%Y-%m-%d")
except ValueError:
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
def start_change(self, app, current_value):
app.push_screen(DateModal(current_value), app.apply_change)
ELLIPSIS = ""
self.preprocessor = lambda x: x
class TableEditor(App):
@ -159,7 +134,7 @@ class TableEditor(App):
("?", "show_keys", "show keybindings"),
]
def __init__(self):
def __init__(self, default_view="default"):
super().__init__()
self.filters = {}
self.sort_string = "" # TODO: default sort
@ -237,10 +212,7 @@ class TableEditor(App):
self.table.move_cursor(row=0, column=1)
def action_delete_item(self):
self.push_screen(ConfirmModal(f"delete ?"), self._delete_item_callback)
def _delete_item_callback(self, confirm):
if confirm and self.table.cursor_column == 0:
if self.table.cursor_column == 0:
cur_row = self.table.cursor_row
item_id = int(self.table.get_cell_at((cur_row, 0)))
# deletable items need a delete
@ -257,8 +229,10 @@ class TableEditor(App):
continue
val = self.filters.get(fc.field, fc.default)
if val is not None:
if "," in val:
val = val.split(",")[0] # TODO: fix hack for enums
# enums use comma separated filters
if fc.enum:
prepopulated[fc.field] = val.split(",")[0]
else:
prepopulated[fc.field] = val
new_item = self.add_item_callback(**prepopulated)
@ -273,21 +247,21 @@ class TableEditor(App):
def _active_item_id(self):
return int(self.table.get_cell_at((self.table.cursor_row, 0)))
# def action_toggle_cell(self):
# cur_row = self.table.cursor_row
# cur_col = self.table.cursor_column
# cconf = self._active_column_config()
def action_toggle_cell(self):
cur_row = self.table.cursor_row
cur_col = self.table.cursor_column
cconf = self._active_column_config()
# if cconf.enum:
# item_id = self._active_item_id()
# current_val = self.table.get_cell_at((cur_row, cur_col))
# next_val = advance_enum_val(cconf.enum, current_val)
# self.table.update_cell_at((cur_row, cur_col), next_val)
# # trigger item_id to be saved on the next cursor move
# # this avoids filtered columns disappearing right away
# # and tons of DB writes
# update = {cconf.field: next_val}
# self._register_save_on_move(item_id, **update)
if cconf.enum:
item_id = self._active_item_id()
current_val = self.table.get_cell_at((cur_row, cur_col))
next_val = advance_enum_val(cconf.enum, current_val)
self.table.update_cell_at((cur_row, cur_col), next_val)
# trigger item_id to be saved on the next cursor move
# this avoids filtered columns disappearing right away
# and tons of DB writes
update = {cconf.field: next_val}
self._register_save_on_move(item_id, **update)
def _register_save_on_move(self, item_id, **kwargs):
if self.save_on_move and self.save_on_move["item_id"] != item_id:
@ -359,15 +333,15 @@ class TableEditor(App):
if cconf.read_only:
return
# save cursor before callback, so correct position updates
self._save_cursor()
current_value = self.table.get_cell_at(
(self.table.cursor_row, self.table.cursor_column)
)
if current_value.endswith(ELLIPSIS):
self.notify("multi-line text, use (e)dit")
return
current_value = remove_rich_tag(current_value)
# delegate to start_change, which will call back to self
cconf.start_change(self, current_value)
self._show_input("edit", current_value)
def action_start_edit(self):
cconf = self._active_column_config()
@ -418,7 +392,7 @@ class TableEditor(App):
# preprocess/validate the field being saved
try:
update_data[field] = cconf.preprocess(new_value)
update_data[field] = cconf.preprocessor(new_value)
self.update_item_callback(item_id, **update_data)
self.refresh_data()
except NotifyValidationError as e:

View File

@ -1,186 +0,0 @@
from textual.screen import ModalScreen
from textual.binding import Binding
from textual.widgets import RadioSet, RadioButton, Label
from .. import config
from ..utils import get_color_enum
class ConfirmModal(ModalScreen):
CSS = """
ConfirmModal {
align: center middle;
background: $primary 30%;
}
"""
BINDINGS = [
("y", "confirm", "Down"),
("n,escape", "cancel", "cancel"),
]
def __init__(self, message):
self.message = message
super().__init__()
def compose(self):
yield Label(self.message)
yield Label("(y)es")
yield Label("(n)o")
def action_confirm(self):
self.dismiss(True)
def action_cancel(self):
self.app.pop_screen()
class ChoiceModal(ModalScreen):
CSS = """
ChoiceModal {
align: center middle;
background: $primary 30%;
}
ChoiceModal Label {
height: 1;
}
"""
BINDINGS = [
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
Binding("enter", "select", "Select", priority=True),
("c", "select", "Select"),
("escape", "cancel", "cancel"),
]
def __init__(self, enum, selected):
self._enum = enum
self.selected = selected
super().__init__()
def compose(self):
yield RadioSet(
*[
RadioButton(
get_color_enum(e.value, config.STATUSES, "red"), value=self.selected == str(e.value)
)
for e in self._enum
]
)
def action_cursor_down(self):
self.query_one(RadioSet).action_next_button()
def action_cursor_up(self):
self.query_one(RadioSet).action_previous_button()
def action_select(self):
rs = self.query_one(RadioSet)
rs.action_toggle_button()
pressed = rs.pressed_button
self.dismiss(str(pressed.label))
def action_cancel(self):
self.app.pop_screen()
class DateModal(ModalScreen):
CSS = """
DateModal {
layout: horizontal;
align: center middle;
background: $primary 30%;
}
DateModal Label {
border: solid grey;
}
DateModal Label.selected-date {
border: solid green;
}
"""
BINDINGS = [
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("h", "cursor_left", "Left"),
("l", "cursor_right", "Right"),
# ("0,1,2,3,4,5,6,7,8,9", "num_entry", "#"),
Binding("enter", "select", "Select", priority=True),
("escape", "cancel", "cancel"),
]
def __init__(self, date):
self.pieces = [int(p) for p in date.split("-")]
self.selected = 1 # start on month
super().__init__()
def compose(self):
for idx, piece in enumerate(self.pieces):
yield Label(
str(piece), classes="selected-date" if idx == self.selected else ""
)
def action_cursor_left(self):
# cycle Y/M/D
self.selected = (self.selected - 1) % 3
self._update_highlight()
def action_cursor_right(self):
self.selected = (self.selected + 1) % 3
self._update_highlight()
def _update_highlight(self):
for idx, lbl in enumerate(self.query("Label")):
if idx == self.selected:
lbl.add_class("selected-date")
else:
lbl.remove_class("selected-date")
def max_for(self, piece):
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if piece == 0:
return 3000
elif piece == 1:
return 12
else:
# -1 for offset array
return days_in_month[self.pieces[1] - 1]
def _move_piece(self, by):
cur_value = self.pieces[self.selected]
cur_value += by
if cur_value == 0:
cur_value = self.max_for(self.selected)
if cur_value > self.max_for(self.selected):
cur_value = 1
self.pieces[self.selected] = cur_value
cur_label = self.query("Label")[self.selected]
cur_label.update(str(cur_value))
def action_cursor_down(self):
self._move_piece(-1)
def action_cursor_up(self):
self._move_piece(1)
def on_key(self, event) -> None:
key = event.key
if key in "0123456789":
cur_value = self.pieces[self.selected]
# If we can append the values like 1+2 = 12
# then do that, otherwise reset the value
# so (for months) 1+3 = 3
appended = int(str(cur_value) + key)
if appended <= self.max_for(self.selected):
cur_value = appended
else:
cur_value = int(key)
self.pieces[self.selected] = cur_value
self._move_piece(0)
event.prevent_default()
def action_select(self):
date = "-".join(str(p) for p in self.pieces)
self.dismiss(date)
def action_cancel(self):
self.app.pop_screen()

View File

@ -11,7 +11,6 @@ from ..db import GeneratorType
from .editor import (
TableEditor,
TableColumnConfig,
EnumColumnConfig,
)
@ -19,14 +18,14 @@ class TaskGenEditor(TableEditor):
TABLE_CONFIG = (
TableColumnConfig("id", "ID"),
TableColumnConfig("template", "Template", default="recur {val}"),
EnumColumnConfig(
TableColumnConfig(
"type",
"Type",
default=GeneratorType.DAYS_BETWEEN.value,
enum=GeneratorType,
),
TableColumnConfig("val", "Value", default="1"),
TableColumnConfig("next_at", "Next @", read_only=True),
TableColumnConfig("next_at", "Next @", default="", read_only=True),
)
def __init__(self):

View File

@ -2,40 +2,47 @@ import json
from textual.widgets import Input
from datetime import datetime
from .. import config
from ..controller.tasks import (
get_task,
get_tasks,
add_task,
update_task,
TaskStatus,
save_view,
get_saved_view,
)
from ..utils import (
get_color_enum,
get_colored_category,
get_colored_status,
get_colored_date,
)
from .editor import (
TableEditor,
TableColumnConfig,
EnumColumnConfig,
DateColumnConfig,
NotifyValidationError,
ELLIPSIS,
)
def due_preprocessor(val):
try:
return datetime.strptime(val, "%Y-%m-%d")
except ValueError:
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
class TT(TableEditor):
TABLE_CONFIG = (
TableColumnConfig("id", "ID"),
TableColumnConfig("text", "Task", default="new task", enable_editor=True),
EnumColumnConfig(
TableColumnConfig(
"status",
"Status",
enum=config.STATUSES,
default="zero",
enum=TaskStatus,
),
TableColumnConfig("type", "Type", default=""),
DateColumnConfig("due", "Due", default=""),
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
TableColumnConfig("category", "Category", default="main"),
)
@ -89,12 +96,10 @@ class TT(TableEditor):
sort=self.sort_string,
)
for item in items:
category = get_color_enum(
item.category.name if item.category else " - ",
config.PROJECTS,
"grey"
category = get_colored_category(
item.category.name if item.category else " - "
)
status = get_color_enum(item.status, config.STATUSES, "red")
status = get_colored_status(item.status)
due = get_colored_date(item.due)
if "\n" in item.text:

View File

@ -32,9 +32,15 @@ def advance_enum_val(enum_type, cur_val):
return members[next_idx]
def get_color_enum(value: str, enum: dict[str, dict], default: str) -> str:
color = enum.get(value, {"color": default})["color"]
return f"[{color}]{value}[/]"
def get_colored_status(status: str) -> str:
colors = {
"zero": "#666666",
"wip": "#33aa99",
"blocked": "#cc9900",
"done": "#009900",
}
color = colors.get(status, "#666666")
return f"[{color}]{status}[/]"
def get_colored_category(category: str) -> str:

21
tt.toml
View File

@ -1,21 +0,0 @@
[[enums]]
name = "status"
values = [
{ value = "zero", color = "#666666" },
{ value = "blocked", color = "#33a99" },
{ value = "wip", color = "#cc9900" },
{ value = "done", color = "#009900" },
]
[[enums]]
name = "projects"
values = [
{ value = "SECT", color = "purple" },
{ value = "life", color = "#00cc00" },
{ value = "CAPP", color = "#cc0000" },
{ value = "ilikethis", color = "#cccc00" },
{ value = "krang", color = "#ff00ff"},
{ value = "artworld", color = "#0000cc"},
{ value = "TT", color = "#00ff00"},
]

14
uv.lock generated
View File

@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
@ -161,7 +160,7 @@ name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
@ -902,15 +901,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
]
[[package]]
name = "tomlkit"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
]
[[package]]
name = "tt"
version = "0.1.0"
@ -920,7 +910,6 @@ dependencies = [
{ name = "lxml" },
{ name = "peewee" },
{ name = "textual" },
{ name = "tomlkit" },
{ name = "typer" },
]
@ -937,7 +926,6 @@ requires-dist = [
{ name = "lxml", specifier = ">=5.3.0" },
{ name = "peewee", specifier = ">=3.17.8" },
{ name = "textual", specifier = ">=1.0.0" },
{ name = "tomlkit", specifier = ">=0.13.2" },
{ name = "typer", specifier = ">=0.15.1" },
]