Compare commits
No commits in common. "a72c6fbaabf484af0600803bb505b29c472f7477" and "6ed62ac3c2bb90e7beece44384d2c83feb5a6be0" have entirely different histories.
a72c6fbaab
...
6ed62ac3c2
@ -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 lxml.html
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing_extensions import Annotated
|
from typing_extensions import Annotated
|
||||||
from .db import initialize_db, db
|
from .db import initialize_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()
|
||||||
@ -43,10 +45,22 @@ def new(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def table(
|
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()
|
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()
|
@app.command()
|
||||||
@ -54,6 +68,8 @@ 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
|
||||||
@ -65,14 +81,5 @@ 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,5 +59,3 @@ 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"]
|
|
||||||
|
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
|
# 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
|
||||||
|
@ -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_color_enum,
|
||||||
get_colored_date,
|
get_colored_date,
|
||||||
)
|
)
|
||||||
from .modals import ChoiceModal, DateModal, TagModal
|
from .modals import ChoiceModal, DateModal
|
||||||
|
|
||||||
|
|
||||||
class NotifyValidationError(Exception):
|
class NotifyValidationError(Exception):
|
||||||
@ -32,7 +32,6 @@ 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):
|
||||||
@ -43,7 +42,6 @@ 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
|
||||||
@ -69,20 +67,6 @@ 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:
|
||||||
@ -104,5 +88,4 @@ 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.get(col.field, "~???~")
|
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)
|
||||||
|
|
||||||
@ -170,10 +170,8 @@ class TableEditor(App):
|
|||||||
yield self.right_status
|
yield self.right_status
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
for c in self.table_config:
|
column_names = [c.display_name for c in self.table_config]
|
||||||
self.table.add_column(
|
self.table.add_columns(*column_names)
|
||||||
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):
|
||||||
@ -247,9 +245,7 @@ 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
|
||||||
# when adding, prefer this order
|
val = self.defaults.get(fc.field, fc.default)
|
||||||
# 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, Input
|
from textual.widgets import Label
|
||||||
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,82 +132,6 @@ 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, Label
|
from textual.widgets import DataTable, Static, 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%;
|
||||||
}
|
}
|
||||||
|
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 New Issue
Block a user