Compare commits
	
		
			No commits in common. "a72c6fbaabf484af0600803bb505b29c472f7477" and "6ed62ac3c2bb90e7beece44384d2c83feb5a6be0" have entirely different histories.
		
	
	
		
			a72c6fbaab
			...
			6ed62ac3c2
		
	
		
					 11 changed files with 136 additions and 193 deletions
				
			
		|  | @ -1,24 +0,0 @@ | |||
| # updated 2025-04-16 | ||||
| repos: | ||||
| - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|   rev: v0.11.5 | ||||
|   hooks: | ||||
|     - id: ruff | ||||
|     - id: ruff-format | ||||
| 
 | ||||
| - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|   rev: v5.0.0  # Use the ref you want to point at | ||||
|   hooks: | ||||
|     - id: trailing-whitespace | ||||
|     - id: check-added-large-files | ||||
|     - id: check-case-conflict | ||||
|     - id: check-executables-have-shebangs | ||||
|     - id: check-json | ||||
|     - id: check-merge-conflict | ||||
|     - id: check-symlinks | ||||
|     - id: check-toml | ||||
|     - id: check-yaml | ||||
|     - id: debug-statements | ||||
|     - id: forbid-submodules | ||||
|     - id: mixed-line-ending | ||||
|     #- id: no-commit-to-branch | ||||
|  | @ -3,9 +3,11 @@ import httpx | |||
| import lxml.html | ||||
| import sqlite3 | ||||
| from typing_extensions import Annotated | ||||
| from .db import initialize_db, db | ||||
| from .sync import full_sync | ||||
| from .db import initialize_db | ||||
| from .tui.things import run as things_tui | ||||
| 
 | ||||
| # from .tui.overview import run as overview_tui | ||||
| from .tui.recurring import run as recurring_tui | ||||
| from .controller.things import add_thing | ||||
| 
 | ||||
| app = typer.Typer() | ||||
|  | @ -43,10 +45,22 @@ def new( | |||
| 
 | ||||
| @app.command() | ||||
| def table( | ||||
|     view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "tasks", | ||||
|     view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "default", | ||||
| ): | ||||
|     initialize_db() | ||||
|     things_tui(view) | ||||
|     things_tui("tasks") | ||||
| 
 | ||||
| 
 | ||||
| @app.command() | ||||
| def generators(): | ||||
|     initialize_db() | ||||
|     recurring_tui() | ||||
| 
 | ||||
| 
 | ||||
| @app.command() | ||||
| def overview(): | ||||
|     initialize_db() | ||||
|     overview_tui() | ||||
| 
 | ||||
| 
 | ||||
| @app.command() | ||||
|  | @ -54,6 +68,8 @@ def backup(backup_path: str): | |||
|     """ | ||||
|     Perform a SQLite backup using the .backup dot command | ||||
|     """ | ||||
|     from tt.db import db | ||||
| 
 | ||||
|     conn = db.connection() | ||||
| 
 | ||||
|     backup_conn = None | ||||
|  | @ -65,14 +81,5 @@ def backup(backup_path: str): | |||
|             backup_conn.close() | ||||
| 
 | ||||
| 
 | ||||
| @app.command() | ||||
| def sync(): | ||||
|     """ | ||||
|     Sync with tt server. | ||||
|     """ | ||||
|     initialize_db() | ||||
|     full_sync() | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     app() | ||||
|  |  | |||
|  | @ -59,5 +59,3 @@ def get_column(name): | |||
| # Valid statuses & projects are read dynamically from the user's config. | ||||
| STATUSES = get_enum("status") | ||||
| PROJECTS = get_enum("projects") | ||||
| SERVER_URL = _load_config()["sync"]["url"] | ||||
| SERVER_KEY = _load_config()["sync"]["key"] | ||||
|  |  | |||
							
								
								
									
										66
									
								
								src/tt/controller/generators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/tt/controller/generators.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| import json | ||||
| from datetime import date, timedelta | ||||
| from ..db import db, ThingGenerator | ||||
| from .things import add_thing | ||||
| 
 | ||||
| 
 | ||||
| def get_generator(item_id: int) -> ThingGenerator: | ||||
|     return ThingGenerator.get_by_id(item_id) | ||||
| 
 | ||||
| 
 | ||||
| def get_generators() -> list[ThingGenerator]: | ||||
|     query = ThingGenerator.select().where(~ThingGenerator.deleted) | ||||
|     return query.order_by("type", "template") | ||||
| 
 | ||||
| 
 | ||||
| def add_generator( | ||||
|     template: str, | ||||
|     type: str, | ||||
|     val: str, | ||||
| ) -> ThingGenerator: | ||||
|     # JSON for future expansion | ||||
|     config = json.dumps({"val": val}) | ||||
|     with db.atomic(): | ||||
|         thing = ThingGenerator.create( | ||||
|             template=template, | ||||
|             type=type, | ||||
|             config=config, | ||||
|         ) | ||||
|     return thing | ||||
| 
 | ||||
| 
 | ||||
| def generate_needed_things(): | ||||
|     to_create = [] | ||||
|     for g in get_generators(): | ||||
|         next = g.next_at() | ||||
|         if not next: | ||||
|             continue | ||||
|         # TODO: make configurable | ||||
|         if date.today() - next > timedelta(days=14): | ||||
|             to_create.append( | ||||
|                 { | ||||
|                     "text": g.template.format(next=next), | ||||
|                     "project": "recurring", | ||||
|                     "due": next, | ||||
|                 } | ||||
|             ) | ||||
| 
 | ||||
|     for c in to_create: | ||||
|         add_thing(**c) | ||||
| 
 | ||||
|     return to_create | ||||
| 
 | ||||
| 
 | ||||
| def update_generator( | ||||
|     item_id: int, | ||||
|     **kwargs, | ||||
| ) -> ThingGenerator: | ||||
|     # replace "val" with JSON | ||||
|     if "val" in kwargs: | ||||
|         config = {"val": kwargs.pop("val")} | ||||
|         kwargs["config"] = json.dumps(config) | ||||
|     with db.atomic(): | ||||
|         query = ThingGenerator.update(kwargs).where(ThingGenerator.id == item_id) | ||||
|         query.execute() | ||||
|         thing = ThingGenerator.get_by_id(item_id) | ||||
|     return thing | ||||
|  | @ -80,8 +80,6 @@ def get_things( | |||
|         # TODO: which fields are searchable should by dynamic | ||||
|         query = query.where(fn.Lower(Thing.data["text"]).contains(search_text.lower())) | ||||
| 
 | ||||
|     if filters is None: | ||||
|         filters = {} | ||||
|     for param, val in filters.items(): | ||||
|         if val is not None: | ||||
|             # no _in query for JSON fields, so use OR | ||||
|  |  | |||
|  | @ -1,45 +0,0 @@ | |||
| import httpx | ||||
| from collections import defaultdict | ||||
| from .controller.things import get_things | ||||
| from .config import get_view, get_column, SERVER_KEY, SERVER_URL | ||||
| 
 | ||||
| 
 | ||||
| def sync_view(view_name): | ||||
|     view = get_view(view_name) | ||||
|     columns = {c: get_column(c) for c in view["columns"]} | ||||
|     filters = view["filters"] | ||||
|     resp = httpx.post( | ||||
|         SERVER_URL + "view/", | ||||
|         params={"api_key": SERVER_KEY}, | ||||
|         json={ | ||||
|             "name": view["name"], | ||||
|             "columns": columns, | ||||
|             "filters": filters, | ||||
|             "sort": view["sort"], | ||||
|         }, | ||||
|     ) | ||||
|     resp.raise_for_status() | ||||
| 
 | ||||
| 
 | ||||
| def sync_thing(thing): | ||||
|     resp = httpx.post( | ||||
|         SERVER_URL + "thing/", | ||||
|         params={"api_key": SERVER_KEY}, | ||||
|         json={ | ||||
|             "id": thing.id, | ||||
|             "data": thing.data, | ||||
|             "type": thing.type, | ||||
|         }, | ||||
|     ) | ||||
|     resp.raise_for_status() | ||||
|     return resp.json() | ||||
| 
 | ||||
| 
 | ||||
| def full_sync(): | ||||
|     sync_view("tasks") | ||||
|     actions = defaultdict(int) | ||||
|     # all things for now | ||||
|     for thing in get_things(None, None): | ||||
|         action = sync_thing(thing)["action"] | ||||
|         actions[action] += 1 | ||||
|     print(actions) | ||||
|  | @ -3,7 +3,7 @@ from ..utils import ( | |||
|     get_color_enum, | ||||
|     get_colored_date, | ||||
| ) | ||||
| from .modals import ChoiceModal, DateModal, TagModal | ||||
| from .modals import ChoiceModal, DateModal | ||||
| 
 | ||||
| 
 | ||||
| class NotifyValidationError(Exception): | ||||
|  | @ -32,7 +32,6 @@ class TableColumnConfig: | |||
|         self.read_only = read_only | ||||
| 
 | ||||
|     def preprocess(self, val): | ||||
|         """from UI -> DB""" | ||||
|         return val  # no-op | ||||
| 
 | ||||
|     def start_change(self, app, current_value): | ||||
|  | @ -43,7 +42,6 @@ class TableColumnConfig: | |||
|             app._show_input("edit", current_value) | ||||
| 
 | ||||
|     def format_for_display(self, val): | ||||
|         """from DB -> UI""" | ||||
|         val = str(val) | ||||
|         if "\n" in val: | ||||
|             val = val.split("\n")[0] + ELLIPSIS | ||||
|  | @ -69,20 +67,6 @@ class EnumColumnConfig(TableColumnConfig): | |||
|         app.push_screen(ChoiceModal(self.enum, current_value), app.apply_change) | ||||
| 
 | ||||
| 
 | ||||
| class TagColumnConfig(TableColumnConfig): | ||||
|     def __init__(self, field: str, display_name: str, **kwargs): | ||||
|         super().__init__(field, display_name, **kwargs) | ||||
| 
 | ||||
|     def preprocess(self, val): | ||||
|         return val | ||||
| 
 | ||||
|     def format_for_display(self, val): | ||||
|         return ", ".join(val) | ||||
| 
 | ||||
|     def start_change(self, app, current_value): | ||||
|         app.push_screen(TagModal(current_value), app.apply_change) | ||||
| 
 | ||||
| 
 | ||||
| class DateColumnConfig(TableColumnConfig): | ||||
|     def preprocess(self, val): | ||||
|         try: | ||||
|  | @ -104,5 +88,4 @@ def get_col_cls(field_type): | |||
|         "text": TableColumnConfig, | ||||
|         "enum": EnumColumnConfig, | ||||
|         "date": DateColumnConfig, | ||||
|         "tag": TagColumnConfig, | ||||
|     }[field_type] | ||||
|  |  | |||
|  | @ -144,7 +144,7 @@ class TableEditor(App): | |||
|             try: | ||||
|                 val = getattr(item, col.field) | ||||
|             except AttributeError: | ||||
|                 val = item.data.get(col.field, "~???~") | ||||
|                 val = item.data[col.field] | ||||
|             display_val = col.format_for_display(val) | ||||
|             row.append(display_val) | ||||
| 
 | ||||
|  | @ -170,10 +170,8 @@ class TableEditor(App): | |||
|                 yield self.right_status | ||||
| 
 | ||||
|     def on_mount(self): | ||||
|         for c in self.table_config: | ||||
|             self.table.add_column( | ||||
|                 c.display_name, width=self.view.get("widths", {}).get(c.field, None) | ||||
|             ) | ||||
|         column_names = [c.display_name for c in self.table_config] | ||||
|         self.table.add_columns(*column_names) | ||||
|         self.refresh_data(restore_cursor=False) | ||||
| 
 | ||||
|     def action_cursor_left(self): | ||||
|  | @ -247,9 +245,7 @@ class TableEditor(App): | |||
|         for fc in self.table_config: | ||||
|             if fc.read_only: | ||||
|                 continue | ||||
|             # when adding, prefer this order | ||||
|             # filter value -> view default -> field config default | ||||
|             val = self.filters.get(fc.field, self.defaults.get(fc.field, fc.default)) | ||||
|             val = self.defaults.get(fc.field, fc.default) | ||||
|             if val is not None: | ||||
|                 if "," in val: | ||||
|                     val = val.split(",")[0]  # TODO: fix hack for enums | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import datetime | ||||
| from textual.screen import ModalScreen | ||||
| from textual.binding import Binding | ||||
| from textual.widgets import Label, Input | ||||
| from textual.widgets import Label | ||||
| from textual.containers import Horizontal, Vertical | ||||
| from textual.reactive import reactive | ||||
| from ..utils import get_color_enum | ||||
|  | @ -132,82 +132,6 @@ class ChoiceModal(ModalScreen): | |||
|             self.dismiss(self.enum_by_idx[idx]) | ||||
| 
 | ||||
| 
 | ||||
| class TagModal(ModalScreen): | ||||
|     CSS = """ | ||||
|     TagModal { | ||||
|         align: center middle; | ||||
|         background: $primary 30%; | ||||
|     } | ||||
|     TagModal Vertical { | ||||
|         border: double teal; | ||||
|         width: 38; | ||||
|     } | ||||
|     TagModal Label.hints { | ||||
|         border: solid grey; | ||||
|         height: 4; | ||||
|     } | ||||
|     TagModal Label { | ||||
|         height: 1; | ||||
|     } | ||||
|     TagEditor #tageditor { | ||||
|         width: 100%; | ||||
|     } | ||||
|     TagModal Label#selected { | ||||
|         background: white; | ||||
|     } | ||||
|     """ | ||||
|     # TODO: float hints to bottom | ||||
| 
 | ||||
|     BINDINGS = [ | ||||
|         # ("j,tab", "cursor_down", "Down"), | ||||
|         # ("k,shift+tab", "cursor_up", "Up"), | ||||
|         # TODO: add clear | ||||
|         Binding("enter", "select", "Select", priority=True), | ||||
|         ("escape", "cancel", "cancel"), | ||||
|     ] | ||||
| 
 | ||||
|     def __init__(self, current_val): | ||||
|         if isinstance(current_val, str): | ||||
|             # FIXME: shouldn't happen | ||||
|             current_val = current_val.split(", ") | ||||
|         self._tags = current_val | ||||
|         self.sel_idx = 0 | ||||
|         super().__init__() | ||||
| 
 | ||||
|     def compose(self): | ||||
|         self.input = Input() | ||||
|         with Vertical(): | ||||
|             yield self.input | ||||
|             for tag in self._tags: | ||||
|                 yield Label(" - " + tag) | ||||
|             yield Label( | ||||
|                 """(h/j/k/l) move | ||||
| (enter) confirm (esc) quit""", | ||||
|                 classes="hints", | ||||
|             ) | ||||
| 
 | ||||
|     def action_cursor_down(self): | ||||
|         self._move_cursor(1) | ||||
| 
 | ||||
|     def action_cursor_up(self): | ||||
|         self._move_cursor(-1) | ||||
| 
 | ||||
|     async def action_select(self): | ||||
|         # on first submit: add, second: submit | ||||
|         if tag := self.input.value: | ||||
|             if tag in self._tags: | ||||
|                 self._tags.remove(tag) | ||||
|             else: | ||||
|                 self._tags.append(self.input.value) | ||||
|             await self.recompose() | ||||
|             self.input.focus() | ||||
|         else: | ||||
|             self.dismiss(self._tags) | ||||
| 
 | ||||
|     def action_cancel(self): | ||||
|         self.app.pop_screen() | ||||
| 
 | ||||
| 
 | ||||
| class DateModal(ModalScreen): | ||||
|     CSS = """ | ||||
|     DateModal { | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| from textual.app import App, ComposeResult | ||||
| from textual.containers import ScrollableContainer, Horizontal | ||||
| from textual.widgets import DataTable, Label | ||||
| from textual.widgets import DataTable, Static, Label | ||||
| from textual.binding import Binding | ||||
| from datetime import datetime | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										40
									
								
								src/tt/tui/recurring.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/tt/tui/recurring.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | |||
| import json | ||||
| 
 | ||||
| from ..controller.generators import ( | ||||
|     get_generator, | ||||
|     get_generators, | ||||
|     add_generator, | ||||
|     update_generator, | ||||
|     generate_needed_things, | ||||
| ) | ||||
| from .editor import ( | ||||
|     TableEditor, | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| class GenEditor(TableEditor): | ||||
|     def __init__(self): | ||||
|         super().__init__() | ||||
|         self.update_item_callback = update_generator | ||||
|         self.add_item_callback = add_generator | ||||
|         self.get_item_callback = get_generator | ||||
|         self.table_config = self._load_config("recurring") | ||||
| 
 | ||||
|     def refresh_items(self): | ||||
|         generated = generate_needed_things() | ||||
|         if num := len(generated): | ||||
|             self.notify(f"created {num} things") | ||||
|         items = get_generators() | ||||
|         for item in items: | ||||
|             self.table.add_row( | ||||
|                 str(item.id), | ||||
|                 item.template, | ||||
|                 item.type, | ||||
|                 json.loads(item.config)["val"], | ||||
|                 str(item.next_at()), | ||||
|             ) | ||||
| 
 | ||||
| 
 | ||||
| def run(): | ||||
|     app = GenEditor() | ||||
|     app.run() | ||||
		Loading…
	
		Reference in a new issue