"""SQLite persistence layer for characters and rehearsals. Single-file database at CONFIG_DIR/tts_creator.db. Designed for easy migration to PostgreSQL / Supabase later: - JSON columns map 1-to-1 to JSONB - TEXT PKs (characters) and INTEGER PKs (rehearsals) both survive - No SQLite-specific pragmas that would break compat """ from __future__ import annotations import json import sqlite3 from pathlib import Path from typing import Any from core.constants import CONFIG_DIR _DB_PATH = CONFIG_DIR / "tts_creator.db" _DDL = """ CREATE TABLE IF NOT EXISTS characters ( id TEXT PRIMARY KEY, book TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '', voice TEXT, image TEXT, sheet TEXT NOT NULL DEFAULT '{}', analysis TEXT, created TEXT, updated TEXT ); CREATE INDEX IF NOT EXISTS idx_characters_book ON characters(book); CREATE TABLE IF NOT EXISTS rehearsals ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', script TEXT NOT NULL DEFAULT '', cast TEXT NOT NULL DEFAULT '{}', emotions TEXT NOT NULL DEFAULT '{}', notes TEXT NOT NULL DEFAULT '{}', ignored TEXT NOT NULL DEFAULT '{}', hidden_lines TEXT NOT NULL DEFAULT '{}', backend TEXT NOT NULL DEFAULT '', narrator_voice TEXT NOT NULL DEFAULT '', line_index INTEGER NOT NULL DEFAULT 0, created TEXT, updated TEXT ); CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); """ def _open() -> sqlite3.Connection: _DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn def _init() -> None: with _open() as conn: conn.executescript(_DDL) _init() # ── Helpers ─────────────────────────────────────────────────────────────────── def _j(v: Any) -> str: return json.dumps(v, ensure_ascii=False, default=str) def _row_to_char(row: sqlite3.Row) -> dict: d = dict(row) d["sheet"] = json.loads(d.get("sheet") or "{}") d["analysis"] = json.loads(d.get("analysis") or "null") return d def _row_to_reh(row: sqlite3.Row) -> dict: d = dict(row) d["cast"] = json.loads(d.get("cast") or "{}") d["emotions"] = json.loads(d.get("emotions") or "{}") d["notes"] = json.loads(d.get("notes") or "{}") d["ignored"] = json.loads(d.get("ignored") or "{}") d["hidden"] = json.loads(d.get("hidden_lines") or "{}") d["narratorVoice"] = d.pop("narrator_voice", "") d["lineIndex"] = d.pop("line_index", 0) d.pop("hidden_lines", None) return d # ── Characters ──────────────────────────────────────────────────────────────── def char_get_all() -> list[dict]: with _open() as conn: rows = conn.execute("SELECT * FROM characters ORDER BY book, name").fetchall() return [_row_to_char(r) for r in rows] def char_get(char_id: str) -> dict | None: with _open() as conn: row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone() return _row_to_char(row) if row else None def char_put(rec: dict) -> dict: with _open() as conn: conn.execute(""" INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated) VALUES (:id,:book,:name,:tags,:voice,:image,:sheet,:analysis,:created,:updated) ON CONFLICT(id) DO UPDATE SET book=excluded.book, name=excluded.name, tags=excluded.tags, voice=excluded.voice, image=excluded.image, sheet=excluded.sheet, analysis=excluded.analysis, updated=excluded.updated """, { "id": rec.get("id", ""), "book": rec.get("book", ""), "name": rec.get("name", ""), "tags": rec.get("tags", ""), "voice": rec.get("voice"), "image": rec.get("image"), "sheet": _j(rec.get("sheet", {})), "analysis": _j(rec.get("analysis")) if rec.get("analysis") is not None else None, "created": str(rec.get("created", "")), "updated": str(rec.get("updated", "")), }) conn.commit() return rec def char_delete(char_id: str) -> bool: with _open() as conn: conn.execute("DELETE FROM characters WHERE id=?", (char_id,)) conn.commit() return True # ── Rehearsals ──────────────────────────────────────────────────────────────── def reh_get_all() -> list[dict]: with _open() as conn: rows = conn.execute( "SELECT * FROM rehearsals ORDER BY updated DESC" ).fetchall() return [_row_to_reh(r) for r in rows] def reh_get(reh_id: int) -> dict | None: with _open() as conn: row = conn.execute("SELECT * FROM rehearsals WHERE id=?", (reh_id,)).fetchone() return _row_to_reh(row) if row else None def reh_put(rec: dict) -> dict: """Insert (no id) or full replace (id present). Returns record with id.""" reh_id = rec.get("id") row = { "title": rec.get("title", ""), "script": rec.get("script", ""), "cast": _j(rec.get("cast", {})), "emotions": _j(rec.get("emotions", {})), "notes": _j(rec.get("notes", {})), "ignored": _j(rec.get("ignored", {})), "hidden_lines": _j(rec.get("hidden", {})), "backend": rec.get("backend", ""), "narrator_voice": rec.get("narratorVoice", ""), "line_index": int(rec.get("lineIndex", 0)), "created": str(rec.get("created", "")), "updated": str(rec.get("updated", "")), } with _open() as conn: if reh_id: row["id"] = int(reh_id) conn.execute(""" INSERT INTO rehearsals (id,title,script,cast,emotions,notes,ignored,hidden_lines, backend,narrator_voice,line_index,created,updated) VALUES (:id,:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, :backend,:narrator_voice,:line_index,:created,:updated) ON CONFLICT(id) DO UPDATE SET title=excluded.title, script=excluded.script, cast=excluded.cast, emotions=excluded.emotions, notes=excluded.notes, ignored=excluded.ignored, hidden_lines=excluded.hidden_lines, backend=excluded.backend, narrator_voice=excluded.narrator_voice, line_index=excluded.line_index, updated=excluded.updated """, row) conn.commit() else: cur = conn.execute(""" INSERT INTO rehearsals (title,script,cast,emotions,notes,ignored,hidden_lines, backend,narrator_voice,line_index,created,updated) VALUES (:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines, :backend,:narrator_voice,:line_index,:created,:updated) """, row) conn.commit() reh_id = cur.lastrowid rec = dict(rec) rec["id"] = reh_id return rec def reh_delete(reh_id: int) -> bool: with _open() as conn: conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,)) conn.commit() return True