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 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
|
||||||
|
|
||||||
@ -98,11 +98,11 @@ class Overview(App):
|
|||||||
margin: 1 1;
|
margin: 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
background: purple;
|
background: purple;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lists {
|
#lists {
|
||||||
height: 60%;
|
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