Compare commits
7 Commits
6ed62ac3c2
...
a72c6fbaab
Author | SHA1 | Date | |
---|---|---|---|
a72c6fbaab | |||
4f408d7ceb | |||
72588fd9c6 | |||
d24160cfa9 | |||
98be0fcb2c | |||
0981bd4d19 | |||
5ca4a54ffb |
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 sqlite3
|
||||
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.overview import run as overview_tui
|
||||
from .tui.recurring import run as recurring_tui
|
||||
from .controller.things import add_thing
|
||||
|
||||
app = typer.Typer()
|
||||
@ -45,22 +43,10 @@ def new(
|
||||
|
||||
@app.command()
|
||||
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()
|
||||
things_tui("tasks")
|
||||
|
||||
|
||||
@app.command()
|
||||
def generators():
|
||||
initialize_db()
|
||||
recurring_tui()
|
||||
|
||||
|
||||
@app.command()
|
||||
def overview():
|
||||
initialize_db()
|
||||
overview_tui()
|
||||
things_tui(view)
|
||||
|
||||
|
||||
@app.command()
|
||||
@ -68,8 +54,6 @@ 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
|
||||
@ -81,5 +65,14 @@ 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,3 +59,5 @@ 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"]
|
||||
|
@ -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
|
||||
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
|
||||
|
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_colored_date,
|
||||
)
|
||||
from .modals import ChoiceModal, DateModal
|
||||
from .modals import ChoiceModal, DateModal, TagModal
|
||||
|
||||
|
||||
class NotifyValidationError(Exception):
|
||||
@ -32,6 +32,7 @@ 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):
|
||||
@ -42,6 +43,7 @@ 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
|
||||
@ -67,6 +69,20 @@ 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:
|
||||
@ -88,4 +104,5 @@ 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[col.field]
|
||||
val = item.data.get(col.field, "~???~")
|
||||
display_val = col.format_for_display(val)
|
||||
row.append(display_val)
|
||||
|
||||
@ -170,8 +170,10 @@ class TableEditor(App):
|
||||
yield self.right_status
|
||||
|
||||
def on_mount(self):
|
||||
column_names = [c.display_name for c in self.table_config]
|
||||
self.table.add_columns(*column_names)
|
||||
for c in self.table_config:
|
||||
self.table.add_column(
|
||||
c.display_name, width=self.view.get("widths", {}).get(c.field, None)
|
||||
)
|
||||
self.refresh_data(restore_cursor=False)
|
||||
|
||||
def action_cursor_left(self):
|
||||
@ -245,7 +247,9 @@ class TableEditor(App):
|
||||
for fc in self.table_config:
|
||||
if fc.read_only:
|
||||
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 "," 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
|
||||
from textual.widgets import Label, Input
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from ..utils import get_color_enum
|
||||
@ -132,6 +132,82 @@ 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, Static, Label
|
||||
from textual.widgets import DataTable, Label
|
||||
from textual.binding import Binding
|
||||
from datetime import datetime
|
||||
|
||||
@ -98,11 +98,11 @@ class Overview(App):
|
||||
margin: 1 1;
|
||||
}
|
||||
|
||||
Label {
|
||||
align: center middle;
|
||||
Label {
|
||||
align: center middle;
|
||||
background: purple;
|
||||
}
|
||||
|
||||
|
||||
#lists {
|
||||
height: 60%;
|
||||
}
|
||||
|
@ -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 New Issue
Block a user