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: | ||||||
|  |             # 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 | ||||||
|  |  | ||||||
|  | @ -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