Compare commits
7 commits
076f43b082
...
fe5be1115b
Author | SHA1 | Date | |
---|---|---|---|
fe5be1115b | |||
2bf3d126e9 | |||
ddf4662d78 | |||
c5eef57aeb | |||
9e3dd591d6 | |||
968ae1d18a | |||
5de9914341 |
13 changed files with 259 additions and 354 deletions
|
@ -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()
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
]
|
|
|
@ -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
100
src/tt/controller/things.py
Normal 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)
|
25
src/tt/db.py
25
src/tt/db.py
|
@ -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()
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
val = getattr(item, col.field)
|
# look up properties first (ID, type, etc.)
|
||||||
|
# & fall back to data JSON if not found
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
72
src/tt/tui/things.py
Normal 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()
|
|
@ -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 = [
|
||||||
|
|
Loading…
Reference in a new issue