feat: Refactor pronunciation storage from SQLite to JSON for improved simplicity and performance

This commit is contained in:
JB
2025-12-02 06:43:08 -08:00
parent b502ff9068
commit 40eb294fec
+109 -190
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import json
import shutil import shutil
import threading import threading
import time import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional from typing import Any, Dict, Iterable, List, Mapping, Optional
@@ -19,125 +20,64 @@ def _store_path() -> Path:
base_dir = Path(get_user_settings_dir()) base_dir = Path(get_user_settings_dir())
except ModuleNotFoundError: except ModuleNotFoundError:
base_dir = Path(get_internal_cache_path("pronunciations")) base_dir = Path(get_internal_cache_path("pronunciations"))
target = base_dir / "pronunciations.db" target = base_dir / "overrides.json"
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists():
try:
legacy_dir = Path(get_internal_cache_path("pronunciations"))
legacy_path = legacy_dir / "pronunciations.db"
if legacy_path.exists() and legacy_path != target:
shutil.move(str(legacy_path), target)
except Exception:
pass
return target return target
def _connect() -> sqlite3.Connection: def _load_db() -> Dict[str, Any]:
connection = sqlite3.connect(_store_path()) path = _store_path()
connection.execute("PRAGMA journal_mode=WAL") if not path.exists():
connection.execute("PRAGMA foreign_keys=ON") return {"version": _SCHEMA_VERSION, "overrides": {}}
return connection try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {"version": _SCHEMA_VERSION, "overrides": {}}
def _ensure_schema(conn: sqlite3.Connection) -> None: def _save_db(data: Dict[str, Any]) -> None:
conn.execute( path = _store_path()
""" # Atomic write
CREATE TABLE IF NOT EXISTS overrides ( temp_path = path.with_suffix(".tmp")
id INTEGER PRIMARY KEY AUTOINCREMENT, with open(temp_path, "w", encoding="utf-8") as f:
normalized TEXT NOT NULL, json.dump(data, f, indent=2, ensure_ascii=False)
token TEXT NOT NULL, shutil.move(str(temp_path), str(path))
language TEXT NOT NULL,
pronunciation TEXT,
voice TEXT,
notes TEXT,
context TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(normalized, language)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
)
"""
)
row = conn.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone()
if row is None:
conn.execute(
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
(_SCHEMA_VERSION,),
)
conn.commit()
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
return {
"id": row["id"],
"normalized": row["normalized"],
"token": row["token"],
"language": row["language"],
"pronunciation": row["pronunciation"],
"voice": row["voice"],
"notes": row["notes"],
"context": row["context"],
"usage_count": row["usage_count"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]: def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]:
normalized_tokens = {normalize_token(token) for token in tokens if token} normalized_tokens = {normalize_token(token) for token in tokens if token}
if not normalized_tokens: if not normalized_tokens:
return {} return {}
# Use parameterized queries to prevent SQL injection
placeholders = ",".join("?" for _ in normalized_tokens)
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
conn.row_factory = sqlite3.Row results: Dict[str, Dict[str, Any]] = {}
cursor = conn.execute( for normalized in normalized_tokens:
f"SELECT * FROM overrides WHERE language=? AND normalized IN ({placeholders})", if normalized in lang_overrides:
(language, *normalized_tokens), results[normalized] = lang_overrides[normalized]
) return results
results: Dict[str, Dict[str, Any]] = {}
for row in cursor.fetchall():
payload = _row_to_dict(row)
results[payload["normalized"]] = payload
return results
finally:
conn.close()
def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
if not query: if not query:
return [] return []
pattern = f"%{query.lower()}%"
query = query.lower()
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
conn.row_factory = sqlite3.Row matches = []
cursor = conn.execute( for entry in lang_overrides.values():
""" if query in entry["normalized"] or query in entry["token"].lower():
SELECT * FROM overrides matches.append(entry)
WHERE language = ? AND (normalized LIKE ? OR LOWER(token) LIKE ?)
ORDER BY usage_count DESC, updated_at DESC # Sort by usage count desc, then updated_at desc
LIMIT ? matches.sort(key=lambda x: (x.get("usage_count", 0), x.get("updated_at", 0)), reverse=True)
""", return matches[:limit]
(language, pattern, pattern, limit),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def save_override( def save_override(
@@ -152,116 +92,95 @@ def save_override(
normalized = normalize_token(token) normalized = normalize_token(token)
if not normalized: if not normalized:
raise ValueError("Provide a token to override") raise ValueError("Provide a token to override")
timestamp = time.time() timestamp = time.time()
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: overrides = db.setdefault("overrides", {})
_ensure_schema(conn) lang_overrides = overrides.setdefault(language, {})
conn.execute(
""" existing = lang_overrides.get(normalized)
INSERT INTO overrides (normalized, token, language, pronunciation, voice, notes, context, usage_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) if existing:
ON CONFLICT(normalized, language) DO UPDATE SET entry = existing
token=excluded.token, entry["token"] = token
pronunciation=excluded.pronunciation, entry["pronunciation"] = pronunciation
voice=excluded.voice, entry["voice"] = voice
notes=excluded.notes, entry["notes"] = notes
context=excluded.context, entry["context"] = context
updated_at=excluded.updated_at entry["updated_at"] = timestamp
""", else:
( entry = {
normalized, "id": str(uuid.uuid4()),
token, "normalized": normalized,
language, "token": token,
pronunciation, "language": language,
voice, "pronunciation": pronunciation,
notes, "voice": voice,
context, "notes": notes,
timestamp, "context": context,
timestamp, "usage_count": 0,
), "created_at": timestamp,
) "updated_at": timestamp,
conn.commit() }
conn.row_factory = sqlite3.Row lang_overrides[normalized] = entry
row = conn.execute(
"SELECT * FROM overrides WHERE normalized=? AND language=?", _save_db(db)
(normalized, language), return entry
).fetchone()
if row is None: # pragma: no cover - defensive guard
raise RuntimeError("Override save failed")
return _row_to_dict(row)
finally:
conn.close()
def delete_override(*, language: str, token: str) -> None: def delete_override(*, language: str, token: str) -> None:
normalized = normalize_token(token) normalized = normalize_token(token)
if not normalized: if not normalized:
return return
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
conn.execute("DELETE FROM overrides WHERE normalized=? AND language=?", (normalized, language)) if normalized in lang_overrides:
conn.commit() del lang_overrides[normalized]
finally: _save_db(db)
conn.close()
def all_overrides(language: str) -> List[Dict[str, Any]]: def all_overrides(language: str) -> List[Dict[str, Any]]:
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
conn.row_factory = sqlite3.Row results = list(lang_overrides.values())
cursor = conn.execute( results.sort(key=lambda x: x.get("updated_at", 0), reverse=True)
"SELECT * FROM overrides WHERE language=? ORDER BY updated_at DESC", return results
(language,),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def increment_usage(*, language: str, token: str, amount: int = 1) -> None: def increment_usage(*, language: str, token: str, amount: int = 1) -> None:
normalized = normalize_token(token) normalized = normalize_token(token)
if not normalized: if not normalized:
return return
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
conn.execute( if normalized in lang_overrides:
"UPDATE overrides SET usage_count = usage_count + ?, updated_at = ? WHERE normalized=? AND language=?", entry = lang_overrides[normalized]
(amount, time.time(), normalized, language), entry["usage_count"] = entry.get("usage_count", 0) + amount
) entry["updated_at"] = time.time()
conn.commit() _save_db(db)
finally:
conn.close()
def get_override_stats(language: str) -> Dict[str, int]: def get_override_stats(language: str) -> Dict[str, int]:
with _DB_LOCK: with _DB_LOCK:
conn = _connect() db = _load_db()
try: lang_overrides = db.get("overrides", {}).get(language, {})
_ensure_schema(conn)
cursor = conn.execute( total = len(lang_overrides)
""" with_pronunciation = sum(1 for x in lang_overrides.values() if x.get("pronunciation"))
SELECT with_voice = sum(1 for x in lang_overrides.values() if x.get("voice"))
COUNT(*) as total,
COUNT(CASE WHEN pronunciation IS NOT NULL AND pronunciation != '' THEN 1 END) as with_pronunciation, return {
COUNT(CASE WHEN voice IS NOT NULL AND voice != '' THEN 1 END) as with_voice "total": total,
FROM overrides "filtered": total,
WHERE language=? "with_pronunciation": with_pronunciation,
""", "with_voice": with_voice,
(language,), }
)
row = cursor.fetchone()
return {
"total": row[0],
"filtered": row[0],
"with_pronunciation": row[1],
"with_voice": row[2],
}
finally:
conn.close()