Compare commits
	
		
			7 commits
		
	
	
		
			6ed62ac3c2
			...
			a72c6fbaab
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a72c6fbaab | |||
| 4f408d7ceb | |||
| 72588fd9c6 | |||
| d24160cfa9 | |||
| 98be0fcb2c | |||
| 0981bd4d19 | |||
| 5ca4a54ffb | 
					 11 changed files with 193 additions and 136 deletions
				
			
		
							
								
								
									
										24
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | ||||||
|  | # 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,11 +3,9 @@ import httpx | ||||||
| import lxml.html | import lxml.html | ||||||
| import sqlite3 | import sqlite3 | ||||||
| from typing_extensions import Annotated | from typing_extensions import Annotated | ||||||
| from .db import initialize_db | from .db import initialize_db, db | ||||||
|  | from .sync import full_sync | ||||||
| from .tui.things import run as things_tui | 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 | from .controller.things import add_thing | ||||||
| 
 | 
 | ||||||
| app = typer.Typer() | app = typer.Typer() | ||||||
|  | @ -45,22 +43,10 @@ def new( | ||||||
| 
 | 
 | ||||||
| @app.command() | @app.command() | ||||||
| def table( | def table( | ||||||
|     view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "default", |     view: Annotated[str, typer.Option("-v", "--view", help="saved view")] = "tasks", | ||||||
| ): | ): | ||||||
|     initialize_db() |     initialize_db() | ||||||
|     things_tui("tasks") |     things_tui(view) | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() |  | ||||||
| def generators(): |  | ||||||
|     initialize_db() |  | ||||||
|     recurring_tui() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() |  | ||||||
| def overview(): |  | ||||||
|     initialize_db() |  | ||||||
|     overview_tui() |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.command() | @app.command() | ||||||
|  | @ -68,8 +54,6 @@ def backup(backup_path: str): | ||||||
|     """ |     """ | ||||||
|     Perform a SQLite backup using the .backup dot command |     Perform a SQLite backup using the .backup dot command | ||||||
|     """ |     """ | ||||||
|     from tt.db import db |  | ||||||
| 
 |  | ||||||
|     conn = db.connection() |     conn = db.connection() | ||||||
| 
 | 
 | ||||||
|     backup_conn = None |     backup_conn = None | ||||||
|  | @ -81,5 +65,14 @@ def backup(backup_path: str): | ||||||
|             backup_conn.close() |             backup_conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @app.command() | ||||||
|  | def sync(): | ||||||
|  |     """ | ||||||
|  |     Sync with tt server. | ||||||
|  |     """ | ||||||
|  |     initialize_db() | ||||||
|  |     full_sync() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     app() |     app() | ||||||
|  |  | ||||||
|  | @ -59,3 +59,5 @@ def get_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") | ||||||
|  | SERVER_URL = _load_config()["sync"]["url"] | ||||||
|  | SERVER_KEY = _load_config()["sync"]["key"] | ||||||
|  |  | ||||||
|  | @ -1,66 +0,0 @@ | ||||||
| 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,6 +80,8 @@ def get_things( | ||||||
|         # TODO: which fields are searchable should by dynamic |         # TODO: which fields are searchable should by dynamic | ||||||
|         query = query.where(fn.Lower(Thing.data["text"]).contains(search_text.lower())) |         query = query.where(fn.Lower(Thing.data["text"]).contains(search_text.lower())) | ||||||
| 
 | 
 | ||||||
|  |     if filters is None: | ||||||
|  |         filters = {} | ||||||
|     for param, val in filters.items(): |     for param, val in filters.items(): | ||||||
|         if val is not None: |         if val is not None: | ||||||
|             # no _in query for JSON fields, so use OR |             # no _in query for JSON fields, so use OR | ||||||
|  |  | ||||||
							
								
								
									
										45
									
								
								src/tt/sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/tt/sync.py
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | 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_color_enum, | ||||||
|     get_colored_date, |     get_colored_date, | ||||||
| ) | ) | ||||||
| from .modals import ChoiceModal, DateModal | from .modals import ChoiceModal, DateModal, TagModal | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class NotifyValidationError(Exception): | class NotifyValidationError(Exception): | ||||||
|  | @ -32,6 +32,7 @@ class TableColumnConfig: | ||||||
|         self.read_only = read_only |         self.read_only = read_only | ||||||
| 
 | 
 | ||||||
|     def preprocess(self, val): |     def preprocess(self, val): | ||||||
|  |         """from UI -> DB""" | ||||||
|         return val  # no-op |         return val  # no-op | ||||||
| 
 | 
 | ||||||
|     def start_change(self, app, current_value): |     def start_change(self, app, current_value): | ||||||
|  | @ -42,6 +43,7 @@ class TableColumnConfig: | ||||||
|             app._show_input("edit", current_value) |             app._show_input("edit", current_value) | ||||||
| 
 | 
 | ||||||
|     def format_for_display(self, val): |     def format_for_display(self, val): | ||||||
|  |         """from DB -> UI""" | ||||||
|         val = str(val) |         val = str(val) | ||||||
|         if "\n" in val: |         if "\n" in val: | ||||||
|             val = val.split("\n")[0] + ELLIPSIS |             val = val.split("\n")[0] + ELLIPSIS | ||||||
|  | @ -67,6 +69,20 @@ class EnumColumnConfig(TableColumnConfig): | ||||||
|         app.push_screen(ChoiceModal(self.enum, current_value), app.apply_change) |         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): | class DateColumnConfig(TableColumnConfig): | ||||||
|     def preprocess(self, val): |     def preprocess(self, val): | ||||||
|         try: |         try: | ||||||
|  | @ -88,4 +104,5 @@ def get_col_cls(field_type): | ||||||
|         "text": TableColumnConfig, |         "text": TableColumnConfig, | ||||||
|         "enum": EnumColumnConfig, |         "enum": EnumColumnConfig, | ||||||
|         "date": DateColumnConfig, |         "date": DateColumnConfig, | ||||||
|  |         "tag": TagColumnConfig, | ||||||
|     }[field_type] |     }[field_type] | ||||||
|  |  | ||||||
|  | @ -144,7 +144,7 @@ class TableEditor(App): | ||||||
|             try: |             try: | ||||||
|                 val = getattr(item, col.field) |                 val = getattr(item, col.field) | ||||||
|             except AttributeError: |             except AttributeError: | ||||||
|                 val = item.data[col.field] |                 val = item.data.get(col.field, "~???~") | ||||||
|             display_val = col.format_for_display(val) |             display_val = col.format_for_display(val) | ||||||
|             row.append(display_val) |             row.append(display_val) | ||||||
| 
 | 
 | ||||||
|  | @ -170,8 +170,10 @@ class TableEditor(App): | ||||||
|                 yield self.right_status |                 yield self.right_status | ||||||
| 
 | 
 | ||||||
|     def on_mount(self): |     def on_mount(self): | ||||||
|         column_names = [c.display_name for c in self.table_config] |         for c in self.table_config: | ||||||
|         self.table.add_columns(*column_names) |             self.table.add_column( | ||||||
|  |                 c.display_name, width=self.view.get("widths", {}).get(c.field, None) | ||||||
|  |             ) | ||||||
|         self.refresh_data(restore_cursor=False) |         self.refresh_data(restore_cursor=False) | ||||||
| 
 | 
 | ||||||
|     def action_cursor_left(self): |     def action_cursor_left(self): | ||||||
|  | @ -245,7 +247,9 @@ 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.defaults.get(fc.field, fc.default) |             # 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)) | ||||||
|             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 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import datetime | import datetime | ||||||
| from textual.screen import ModalScreen | from textual.screen import ModalScreen | ||||||
| from textual.binding import Binding | from textual.binding import Binding | ||||||
| from textual.widgets import Label | from textual.widgets import Label, Input | ||||||
| from textual.containers import Horizontal, Vertical | from textual.containers import Horizontal, Vertical | ||||||
| from textual.reactive import reactive | from textual.reactive import reactive | ||||||
| from ..utils import get_color_enum | from ..utils import get_color_enum | ||||||
|  | @ -132,6 +132,82 @@ class ChoiceModal(ModalScreen): | ||||||
|             self.dismiss(self.enum_by_idx[idx]) |             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): | class DateModal(ModalScreen): | ||||||
|     CSS = """ |     CSS = """ | ||||||
|     DateModal { |     DateModal { | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| from textual.app import App, ComposeResult | from textual.app import App, ComposeResult | ||||||
| from textual.containers import ScrollableContainer, Horizontal | from textual.containers import ScrollableContainer, Horizontal | ||||||
| from textual.widgets import DataTable, Static, Label | from textual.widgets import DataTable, Label | ||||||
| from textual.binding import Binding | from textual.binding import Binding | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,40 +0,0 @@ | ||||||
| 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