Files
notes-qpid-pro/app/database.py
T

415 lines
16 KiB
Python

import sqlite3
import contextlib
import hashlib
from pathlib import Path
from typing import Generator
from app import config
def get_db_connection() -> sqlite3.Connection:
conn = sqlite3.connect(config.DB_PATH, timeout=15.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA synchronous = NORMAL;")
conn.execute("PRAGMA mmap_size = 268435456;") # 256MB memory-mapped I/O
conn.execute("PRAGMA cache_size = -32000;") # 32MB RAM page cache
conn.execute("PRAGMA temp_store = MEMORY;") # In-memory temporary tables & sorts
conn.execute("PRAGMA foreign_keys = ON;")
return conn
@contextlib.contextmanager
def get_db() -> Generator[sqlite3.Connection, None, None]:
conn = get_db_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _migrate(conn: sqlite3.Connection):
"""Safe schema migrations: add new columns/tables/indexes without touching existing data."""
conn.execute("PRAGMA journal_mode=WAL;")
# --- vocabulary_cards new columns ---
existing_cols = {
row[1] for row in conn.execute("PRAGMA table_info(vocabulary_cards)").fetchall()
}
card_migrations = [
("collection", "TEXT NOT NULL DEFAULT 'General'"),
("srs_reps", "INTEGER DEFAULT 0"),
("srs_interval", "INTEGER DEFAULT 0"),
("srs_ease", "REAL DEFAULT 2.5"),
("srs_next_review", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
("srs_last_review", "TIMESTAMP"),
("srs_state", "TEXT DEFAULT 'new'"),
("error_count", "INTEGER DEFAULT 0"),
]
for col, definition in card_migrations:
if col not in existing_cols:
conn.execute(f"ALTER TABLE vocabulary_cards ADD COLUMN {col} {definition}")
# Ensure srs_logs table exists
conn.execute("""
CREATE TABLE IF NOT EXISTS srs_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
interval INTEGER NOT NULL,
ease REAL NOT NULL,
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES vocabulary_cards(id) ON DELETE CASCADE
);
""")
# Ensure books table exists
conn.execute("""
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
author TEXT DEFAULT '',
source_type TEXT NOT NULL,
chapters_json TEXT NOT NULL,
current_chapter INTEGER DEFAULT 0,
current_position REAL DEFAULT 0,
total_words INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
# --- Deduplicate vocabulary_cards (case-insensitive) and enforce UNIQUE index ---
dup_rows = conn.execute("""
SELECT user_id, lower(trim(term)) as clean_term, COUNT(*) as cnt
FROM vocabulary_cards
GROUP BY user_id, lower(trim(term))
HAVING cnt > 1
""").fetchall()
for d in dup_rows:
u_id = d["user_id"]
c_term = d["clean_term"]
cards = conn.execute(
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ? ORDER BY id ASC",
(u_id, c_term),
).fetchall()
if not cards:
continue
primary = cards[0]
primary_id = primary["id"]
merged_contexts = [primary["context_sentence"]] if primary["context_sentence"] else []
merged_tags = set([t.strip() for t in (primary["tags"] or "").split(",") if t.strip()])
max_reps = primary["srs_reps"] or 0
max_interval = primary["srs_interval"] or 0
max_ease = primary["srs_ease"] or 2.5
latest_review = primary["srs_last_review"]
srs_state = primary["srs_state"]
for dup in cards[1:]:
dup_id = dup["id"]
if dup["context_sentence"] and dup["context_sentence"] not in merged_contexts:
merged_contexts.append(dup["context_sentence"])
for t in (dup["tags"] or "").split(","):
if t.strip():
merged_tags.add(t.strip())
if (dup["srs_reps"] or 0) > max_reps:
max_reps = dup["srs_reps"]
max_interval = dup["srs_interval"]
max_ease = dup["srs_ease"]
latest_review = dup["srs_last_review"]
srs_state = dup["srs_state"]
# Reassign SRS logs to primary card
conn.execute("UPDATE srs_logs SET card_id = ? WHERE card_id = ?", (primary_id, dup_id))
# Delete duplicate row
conn.execute("DELETE FROM vocabulary_cards WHERE id = ?", (dup_id,))
# Update primary card with merged data
conn.execute(
"""
UPDATE vocabulary_cards
SET context_sentence = ?, tags = ?, srs_reps = ?, srs_interval = ?, srs_ease = ?,
srs_last_review = ?, srs_state = ?
WHERE id = ?
""",
(
"\n\n".join(merged_contexts),
", ".join(sorted(merged_tags)),
max_reps,
max_interval,
max_ease,
latest_review,
srs_state,
primary_id,
),
)
# Composite and single column indexes for high-speed queries
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vocab_user_created ON vocabulary_cards(user_id, created_at DESC);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vocab_user_srs_state ON vocabulary_cards(user_id, srs_state, srs_next_review);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vocab_user_srs_created ON vocabulary_cards(user_id, srs_state, created_at DESC);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vocab_collection ON vocabulary_cards(user_id, collection);"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_user ON srs_logs(user_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_srs_card ON srs_logs(card_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_books_user_updated ON books(user_id, updated_at DESC);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);")
# Ensure offline_dictionary table exists
conn.execute("""
CREATE TABLE IF NOT EXISTS offline_dictionary (
term TEXT PRIMARY KEY COLLATE NOCASE,
translation TEXT NOT NULL,
phonetic TEXT DEFAULT '',
pos TEXT DEFAULT ''
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_offline_dict_term ON offline_dictionary(term COLLATE NOCASE);")
# Ensure sentence_translations table exists
conn.execute("""
CREATE TABLE IF NOT EXISTS sentence_translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence_hash TEXT NOT NULL,
original_text TEXT NOT NULL,
translated_text TEXT NOT NULL,
target_word TEXT DEFAULT '',
word_meaning TEXT DEFAULT '',
source TEXT DEFAULT 'local_llm',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);")
# Auto-seed offline dictionary if empty
_seed_offline_dictionary(conn)
# Seed curated phrasal verbs
try:
from app.phrasal import seed_curated_phrasal_verbs
seed_curated_phrasal_verbs(conn)
except Exception:
pass
def _seed_offline_dictionary(conn: sqlite3.Connection):
"""Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty."""
try:
count = conn.execute("SELECT COUNT(*) FROM offline_dictionary").fetchone()[0]
if count > 0:
return
except Exception:
return
archive_candidates = [
config.BASE_DIR / "data" / "offline_dict.tsv.gz",
config.BASE_DIR / "app" / "data" / "offline_dict.tsv.gz",
Path("/app/data/offline_dict.tsv.gz"),
]
archive_path = next((p for p in archive_candidates if p.is_file()), None)
if not archive_path:
return
import gzip
batch = []
with gzip.open(archive_path, "rt", encoding="utf-8") as f:
for line in f:
parts = line.rstrip("\r\n").split("\t")
if len(parts) == 4:
batch.append(parts)
if len(batch) >= 10000:
conn.executemany(
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, ?, ?)",
batch,
)
batch.clear()
if batch:
conn.executemany(
"INSERT OR IGNORE INTO offline_dictionary (term, translation, phonetic, pos) VALUES (?, ?, ?, ?)",
batch,
)
def init_db():
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with get_db() as conn:
conn.execute("PRAGMA journal_mode=WAL;")
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
user_agent TEXT,
ip TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS vocabulary_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
term TEXT NOT NULL,
translation TEXT NOT NULL,
context_sentence TEXT DEFAULT '',
phonetic TEXT DEFAULT '',
audio_url TEXT DEFAULT '',
part_of_speech TEXT DEFAULT '',
definition_en TEXT DEFAULT '',
tags TEXT DEFAULT 'general',
notes TEXT DEFAULT '',
collection TEXT NOT NULL DEFAULT 'General',
srs_reps INTEGER DEFAULT 0,
srs_interval INTEGER DEFAULT 0,
srs_ease REAL DEFAULT 2.5,
srs_next_review TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
srs_last_review TIMESTAMP,
srs_state TEXT DEFAULT 'new',
error_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vocab_user ON vocabulary_cards(user_id);
CREATE INDEX IF NOT EXISTS idx_vocab_term ON vocabulary_cards(term);
CREATE INDEX IF NOT EXISTS idx_vocab_user_created ON vocabulary_cards(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_vocab_collection ON vocabulary_cards(user_id, collection);
CREATE INDEX IF NOT EXISTS idx_vocab_srs_queue ON vocabulary_cards(user_id, srs_next_review, srs_state);
CREATE INDEX IF NOT EXISTS idx_vocab_user_srs_state ON vocabulary_cards(user_id, srs_state, srs_next_review);
CREATE INDEX IF NOT EXISTS idx_vocab_user_srs_created ON vocabulary_cards(user_id, srs_state, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vocab_user_term_nocase ON vocabulary_cards(user_id, term COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS srs_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
card_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
interval INTEGER NOT NULL,
ease REAL NOT NULL,
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES vocabulary_cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_srs_user ON srs_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_srs_reviewed ON srs_logs(user_id, reviewed_at);
CREATE INDEX IF NOT EXISTS idx_srs_card ON srs_logs(card_id);
CREATE TABLE IF NOT EXISTS dictionary_cache (
query TEXT PRIMARY KEY COLLATE NOCASE,
response_json TEXT NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
author TEXT DEFAULT '',
source_type TEXT NOT NULL, -- 'epub', 'txt', 'md', 'text', 'pdf'
chapters_json TEXT NOT NULL, -- JSON array: [{"title": "Chapter 1", "content": "..."}]
current_chapter INTEGER DEFAULT 0,
current_position REAL DEFAULT 0,
total_words INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_books_user ON books(user_id);
CREATE INDEX IF NOT EXISTS idx_books_user_updated ON books(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS offline_dictionary (
term TEXT PRIMARY KEY COLLATE NOCASE,
translation TEXT NOT NULL,
phonetic TEXT DEFAULT '',
pos TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_offline_dict_term ON offline_dictionary(term COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS sentence_translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence_hash TEXT NOT NULL,
original_text TEXT NOT NULL,
translated_text TEXT NOT NULL,
target_word TEXT DEFAULT '',
word_meaning TEXT DEFAULT '',
source TEXT DEFAULT 'local_llm',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);
""")
# Apply migrations for pre-existing databases
_migrate(conn)
def compute_sentence_hash(sentence: str, target_word: str = "") -> str:
norm = f"{sentence.strip().lower()}||{target_word.strip().lower()}"
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
def get_cached_sentence_translation(sentence: str, target_word: str = "") -> dict | None:
s_hash = compute_sentence_hash(sentence, target_word)
with get_db() as conn:
row = conn.execute(
"""
SELECT original_text, translated_text, target_word, word_meaning, source, created_at
FROM sentence_translations
WHERE sentence_hash = ?
ORDER BY id DESC LIMIT 1
""",
(s_hash,),
).fetchone()
if row:
return dict(row)
return None
def save_sentence_translation(
sentence: str,
translated_text: str,
target_word: str = "",
word_meaning: str = "",
source: str = "local_llm",
) -> None:
s_hash = compute_sentence_hash(sentence, target_word)
with get_db() as conn:
conn.execute(
"""
INSERT INTO sentence_translations (sentence_hash, original_text, translated_text, target_word, word_meaning, source)
VALUES (?, ?, ?, ?, ?, ?)
""",
(s_hash, sentence.strip(), translated_text.strip(), target_word.strip(), word_meaning.strip(), source),
)