initial Commit
This commit is contained in:
366
backend/database.py
Normal file
366
backend/database.py
Normal file
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_DB = ROOT / "data" / "streamdeck.sqlite"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path = DEFAULT_DB):
|
||||
self.path = path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute("PRAGMA foreign_keys = ON")
|
||||
self.migrate()
|
||||
self.ensure_defaults()
|
||||
|
||||
def migrate(self) -> None:
|
||||
self.conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_root INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(parent_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS buttons (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
folder_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
physical_button INTEGER NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#111827',
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
trigger_mode TEXT NOT NULL DEFAULT 'down',
|
||||
action_type TEXT NOT NULL DEFAULT 'noop',
|
||||
action_config TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(folder_id, position),
|
||||
UNIQUE(folder_id, physical_button),
|
||||
FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
args TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
if not self.get_setting("click_check"):
|
||||
self.set_setting("click_check", False)
|
||||
if not self.get_setting("serial_port"):
|
||||
self.set_setting("serial_port", None)
|
||||
profile = self.conn.execute("SELECT * FROM profiles LIMIT 1").fetchone()
|
||||
if profile is None:
|
||||
profile_id = self.create_profile("Default", make_active=False)
|
||||
self.set_setting("active_profile_id", profile_id)
|
||||
root_id = self.get_root_folder(profile_id)["id"]
|
||||
self.set_setting("active_folder_id", root_id)
|
||||
else:
|
||||
active_profile = self.get_setting("active_profile_id") or profile["id"]
|
||||
self.set_setting("active_profile_id", active_profile)
|
||||
active_folder = self.get_setting("active_folder_id") or self.get_root_folder(active_profile)["id"]
|
||||
self.set_setting("active_folder_id", active_folder)
|
||||
self.sync_physical_layouts()
|
||||
|
||||
def row_to_dict(self, row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_setting(self, key: str) -> Any:
|
||||
row = self.conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return json.loads(row["value"]) if row else None
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
self.conn.execute(
|
||||
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, json.dumps(value)),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
rows = self.conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
return {row["key"]: json.loads(row["value"]) for row in rows}
|
||||
|
||||
def create_profile(self, name: str, make_active: bool = True) -> str:
|
||||
profile_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO profiles(id, name, created_at) VALUES(?, ?, ?)",
|
||||
(profile_id, name, utc_now()),
|
||||
)
|
||||
root_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, NULL, ?, 1, ?)",
|
||||
(root_id, profile_id, "Root", utc_now()),
|
||||
)
|
||||
self._seed_buttons(profile_id, root_id)
|
||||
self.conn.commit()
|
||||
self.sync_physical_layouts()
|
||||
if make_active:
|
||||
self.set_setting("active_profile_id", profile_id)
|
||||
self.set_setting("active_folder_id", root_id)
|
||||
return profile_id
|
||||
|
||||
def update_profile(self, profile_id: str, name: str | None = None, active: bool | None = None) -> None:
|
||||
if name is not None:
|
||||
self.conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (name, profile_id))
|
||||
if active:
|
||||
self.set_setting("active_profile_id", profile_id)
|
||||
self.set_setting("active_folder_id", self.get_root_folder(profile_id)["id"])
|
||||
self.conn.commit()
|
||||
|
||||
def delete_profile(self, profile_id: str) -> None:
|
||||
if profile_id == self.first_profile()["id"]:
|
||||
raise ValueError("Cannot delete the first profile because it owns the hardware layout.")
|
||||
count = self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"]
|
||||
if count <= 1:
|
||||
raise ValueError("Cannot delete the only profile.")
|
||||
self.conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
|
||||
if self.get_setting("active_profile_id") == profile_id:
|
||||
next_profile = self.conn.execute("SELECT id FROM profiles LIMIT 1").fetchone()["id"]
|
||||
self.set_setting("active_profile_id", next_profile)
|
||||
self.set_setting("active_folder_id", self.get_root_folder(next_profile)["id"])
|
||||
self.conn.commit()
|
||||
|
||||
def create_folder(self, profile_id: str, parent_id: str | None, name: str) -> str:
|
||||
parent_id = parent_id or self.get_root_folder(profile_id)["id"]
|
||||
folder_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, ?, ?, 0, ?)",
|
||||
(folder_id, profile_id, parent_id, name, utc_now()),
|
||||
)
|
||||
self._seed_buttons(profile_id, folder_id)
|
||||
self.conn.commit()
|
||||
self.sync_physical_layouts()
|
||||
return folder_id
|
||||
|
||||
def update_folder(self, folder_id: str, name: str | None = None, parent_id: str | None = None) -> None:
|
||||
if name is not None:
|
||||
self.conn.execute("UPDATE folders SET name = ? WHERE id = ?", (name, folder_id))
|
||||
if parent_id is not None:
|
||||
self.conn.execute("UPDATE folders SET parent_id = ? WHERE id = ? AND is_root = 0", (parent_id, folder_id))
|
||||
self.conn.commit()
|
||||
|
||||
def delete_folder(self, folder_id: str) -> None:
|
||||
row = self.conn.execute("SELECT is_root, profile_id FROM folders WHERE id = ?", (folder_id,)).fetchone()
|
||||
if not row:
|
||||
return
|
||||
if row["is_root"]:
|
||||
raise ValueError("Cannot delete the root folder.")
|
||||
self.conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
|
||||
if self.get_setting("active_folder_id") == folder_id:
|
||||
self.set_setting("active_folder_id", self.get_root_folder(row["profile_id"])["id"])
|
||||
self.conn.commit()
|
||||
|
||||
def update_button(self, button_id: str, changes: dict[str, Any]) -> dict[str, Any]:
|
||||
physical_button = changes.pop("physical_button", None)
|
||||
current = self.get_button(button_id)
|
||||
if physical_button is not None and int(physical_button) != current["physical_button"]:
|
||||
if not self.can_edit_physical_layout(current["folder_id"]):
|
||||
raise ValueError("Physical button layout can only be changed in the first profile's root folder.")
|
||||
allowed = {"label", "color", "icon", "trigger_mode", "action_type"}
|
||||
sets: list[str] = []
|
||||
values: list[Any] = []
|
||||
for key in allowed:
|
||||
if key in changes and changes[key] is not None:
|
||||
sets.append(f"{key} = ?")
|
||||
values.append(changes[key])
|
||||
if "action_config" in changes and changes["action_config"] is not None:
|
||||
sets.append("action_config = ?")
|
||||
values.append(json.dumps(changes["action_config"]))
|
||||
if sets:
|
||||
sets.append("updated_at = ?")
|
||||
values.append(utc_now())
|
||||
values.append(button_id)
|
||||
self.conn.execute(f"UPDATE buttons SET {', '.join(sets)} WHERE id = ?", values)
|
||||
self.conn.commit()
|
||||
if physical_button is not None and int(physical_button) != current["physical_button"]:
|
||||
self.move_physical_button(current["folder_id"], current["position"], int(physical_button))
|
||||
return self.get_button(button_id)
|
||||
|
||||
def move_physical_button(self, folder_id: str, position: int, physical_button: int) -> None:
|
||||
if not self.can_edit_physical_layout(folder_id):
|
||||
raise ValueError("Physical button layout can only be changed in the first profile's root folder.")
|
||||
if position < 1 or position > 10 or physical_button < 1 or physical_button > 10:
|
||||
raise ValueError("Position and physical button must be between 1 and 10.")
|
||||
|
||||
mapping = self.physical_layout_mapping()
|
||||
old_physical = mapping[position]
|
||||
old_position = next((slot for slot, physical in mapping.items() if physical == physical_button), None)
|
||||
mapping[position] = physical_button
|
||||
if old_position and old_position != position:
|
||||
mapping[old_position] = old_physical
|
||||
self.sync_physical_layouts(mapping)
|
||||
|
||||
def get_button(self, button_id: str) -> dict[str, Any]:
|
||||
row = self.conn.execute("SELECT * FROM buttons WHERE id = ?", (button_id,)).fetchone()
|
||||
button = dict(row)
|
||||
button["action_config"] = json.loads(button["action_config"])
|
||||
return button
|
||||
|
||||
def find_button_for_event(self, profile_id: str, folder_id: str, physical_button: int) -> dict[str, Any] | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM buttons WHERE profile_id = ? AND folder_id = ? AND physical_button = ?",
|
||||
(profile_id, folder_id, physical_button),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
button = dict(row)
|
||||
button["action_config"] = json.loads(button["action_config"])
|
||||
return button
|
||||
|
||||
def add_manual_app(self, name: str, path: str, args: str | None = None) -> dict[str, Any]:
|
||||
app_id = str(uuid.uuid4())
|
||||
self.conn.execute(
|
||||
"INSERT INTO manual_apps(id, name, path, args, created_at) VALUES(?, ?, ?, ?, ?)",
|
||||
(app_id, name, path, args, utc_now()),
|
||||
)
|
||||
self.conn.commit()
|
||||
return {"id": app_id, "name": name, "path": path, "args": args, "source": "manual"}
|
||||
|
||||
def manual_apps(self) -> list[dict[str, Any]]:
|
||||
return [dict(row) | {"source": "manual"} for row in self.conn.execute("SELECT * FROM manual_apps ORDER BY name").fetchall()]
|
||||
|
||||
def add_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
self.conn.execute(
|
||||
"INSERT INTO event_history(created_at, event_type, payload) VALUES(?, ?, ?)",
|
||||
(utc_now(), event_type, json.dumps(payload)),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
settings = self.settings()
|
||||
profiles = [dict(row) for row in self.conn.execute("SELECT * FROM profiles ORDER BY created_at").fetchall()]
|
||||
folders = [dict(row) for row in self.conn.execute("SELECT * FROM folders ORDER BY is_root DESC, created_at").fetchall()]
|
||||
buttons = []
|
||||
for row in self.conn.execute("SELECT * FROM buttons ORDER BY folder_id, position").fetchall():
|
||||
button = dict(row)
|
||||
button["action_config"] = json.loads(button["action_config"])
|
||||
buttons.append(button)
|
||||
return {
|
||||
"settings": settings,
|
||||
"profiles": profiles,
|
||||
"folders": folders,
|
||||
"buttons": buttons,
|
||||
"layout": {
|
||||
"canonical_profile_id": self.first_profile()["id"],
|
||||
"canonical_folder_id": self.canonical_layout_folder()["id"],
|
||||
"mapping": self.physical_layout_mapping(),
|
||||
},
|
||||
}
|
||||
|
||||
def get_root_folder(self, profile_id: str) -> dict[str, Any]:
|
||||
row = self.conn.execute("SELECT * FROM folders WHERE profile_id = ? AND is_root = 1", (profile_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
def first_profile(self) -> dict[str, Any]:
|
||||
row = self.conn.execute("SELECT * FROM profiles ORDER BY created_at LIMIT 1").fetchone()
|
||||
if row is None:
|
||||
raise ValueError("No profiles exist.")
|
||||
return dict(row)
|
||||
|
||||
def canonical_layout_folder(self) -> dict[str, Any]:
|
||||
return self.get_root_folder(self.first_profile()["id"])
|
||||
|
||||
def can_edit_physical_layout(self, folder_id: str) -> bool:
|
||||
return folder_id == self.canonical_layout_folder()["id"]
|
||||
|
||||
def physical_layout_mapping(self) -> dict[int, int]:
|
||||
folder_id = self.canonical_layout_folder()["id"]
|
||||
rows = self.conn.execute(
|
||||
"SELECT position, physical_button FROM buttons WHERE folder_id = ? ORDER BY position",
|
||||
(folder_id,),
|
||||
).fetchall()
|
||||
mapping = {int(row["position"]): int(row["physical_button"]) for row in rows}
|
||||
for position in range(1, 11):
|
||||
mapping.setdefault(position, position)
|
||||
return mapping
|
||||
|
||||
def sync_physical_layouts(self, mapping: dict[int, int] | None = None) -> None:
|
||||
if self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"] == 0:
|
||||
return
|
||||
mapping = mapping or self.physical_layout_mapping()
|
||||
now = utc_now()
|
||||
folders = self.conn.execute("SELECT id FROM folders").fetchall()
|
||||
for folder in folders:
|
||||
folder_id = folder["id"]
|
||||
for position in range(1, 11):
|
||||
self.conn.execute(
|
||||
"UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?",
|
||||
(-position, now, folder_id, position),
|
||||
)
|
||||
for position, physical_button in mapping.items():
|
||||
self.conn.execute(
|
||||
"UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?",
|
||||
(physical_button, now, folder_id, position),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def next_folder_id(self, profile_id: str, current_folder_id: str, direction: str = "next") -> str | None:
|
||||
rows = self.conn.execute(
|
||||
"SELECT id FROM folders WHERE profile_id = ? ORDER BY is_root DESC, created_at, name",
|
||||
(profile_id,),
|
||||
).fetchall()
|
||||
folder_ids = [row["id"] for row in rows]
|
||||
if not folder_ids:
|
||||
return None
|
||||
if current_folder_id not in folder_ids:
|
||||
return folder_ids[0]
|
||||
offset = -1 if direction == "previous" else 1
|
||||
current_index = folder_ids.index(current_folder_id)
|
||||
return folder_ids[(current_index + offset) % len(folder_ids)]
|
||||
|
||||
def _seed_buttons(self, profile_id: str, folder_id: str) -> None:
|
||||
now = utc_now()
|
||||
for position in range(1, 11):
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO buttons(
|
||||
id, profile_id, folder_id, position, physical_button, label,
|
||||
color, icon, trigger_mode, action_type, action_config, updated_at
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 'down', 'noop', '{}', ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), profile_id, folder_id, position, position, f"Button {position}", "#111827", "", now),
|
||||
)
|
||||
Reference in New Issue
Block a user