""" Module quản lý cơ sở dữ liệu SQLite - Lưu trữ tập trung """ import sqlite3 import json import os import logging from datetime import datetime from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) DB_DIR = "data" DB_FILE = os.path.join(DB_DIR, "tinix_story.db") os.makedirs(DB_DIR, exist_ok=True) _connection: Optional[sqlite3.Connection] = None def get_db() -> sqlite3.Connection: """Lấy kết nối DB singleton (WAL mode, foreign keys ON)""" global _connection if _connection is None: _connection = sqlite3.connect(DB_FILE, check_same_thread=False) _connection.execute("PRAGMA journal_mode=WAL") _connection.execute("PRAGMA foreign_keys=ON") _connection.row_factory = sqlite3.Row init_db(_connection) logger.info(f"Database connected: {DB_FILE}") return _connection def init_db(conn: Optional[sqlite3.Connection] = None) -> None: """Tạo tất cả bảng nếu chưa có""" if conn is None: conn = get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS backends ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL, base_url TEXT NOT NULL, api_key TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, timeout INTEGER NOT NULL DEFAULT 30, retry_times INTEGER NOT NULL DEFAULT 3, is_default INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS config_backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS response_cache ( key TEXT PRIMARY KEY, value TEXT NOT NULL, timestamp TEXT NOT NULL, ttl INTEGER NOT NULL DEFAULT 3600 ); CREATE TABLE IF NOT EXISTS generation_cache ( project_id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS chapter_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, chapter_num INTEGER NOT NULL, summary TEXT NOT NULL, generated_at TEXT NOT NULL, UNIQUE(project_id, chapter_num) ); CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, title TEXT NOT NULL, genre TEXT NOT NULL DEFAULT '', sub_genres TEXT NOT NULL DEFAULT '[]', character_setting TEXT NOT NULL DEFAULT '', world_setting TEXT NOT NULL DEFAULT '', plot_idea TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, num INTEGER NOT NULL, title TEXT NOT NULL DEFAULT '', desc TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', word_count INTEGER NOT NULL DEFAULT 0, generated_at TEXT, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, UNIQUE(project_id, num) ); """) # Đảm bảo schema cũ được cập nhật try: conn.execute("ALTER TABLE projects ADD COLUMN sub_genres TEXT NOT NULL DEFAULT '[]'") except sqlite3.OperationalError: pass # Đã có cột conn.commit() logger.info("Database tables initialized") def migrate_from_files() -> str: """ Đọc dữ liệu cũ từ file JSON → insert vào DB. Không xóa file cũ (giữ lại để phòng lỗi). Returns: Báo cáo migration """ conn = get_db() report = [] now = datetime.now().isoformat() # 1. Migrate config config_file = os.path.join("config", "novel_tool_config.json") if os.path.exists(config_file): try: with open(config_file, "r", encoding="utf-8") as f: data = json.load(f) # Migrate backends backends = data.get("backends", []) migrated_backends = 0 for b in backends: try: conn.execute(""" INSERT OR IGNORE INTO backends (name, type, base_url, api_key, model, enabled, timeout, retry_times, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( b.get("name", ""), b.get("type", "openai"), b.get("base_url", ""), b.get("api_key", ""), b.get("model", ""), 1 if b.get("enabled", True) else 0, b.get("timeout", 30), b.get("retry_times", 3), 1 if b.get("is_default", False) else 0, now, now )) migrated_backends += 1 except Exception as e: logger.warning(f"Migrate backend failed: {e}") # Migrate generation config gen = data.get("generation", {}) if gen: conn.execute( "INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)", ("generation", json.dumps(gen, ensure_ascii=False), now) ) # Migrate version version = data.get("version", "4.0.0") conn.execute( "INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)", ("version", version, now) ) conn.commit() report.append(f"✅ Config: {migrated_backends} backends migrated") except Exception as e: report.append(f"❌ Config migration failed: {e}") else: report.append("⏭ Config file not found, skipped") # 2. Migrate config backups backup_dir = os.path.join("config", "backups") if os.path.exists(backup_dir): migrated_backups = 0 for fname in os.listdir(backup_dir): fpath = os.path.join(backup_dir, fname) if fname.endswith(".json") and os.path.isfile(fpath): try: with open(fpath, "r", encoding="utf-8") as f: backup_data = f.read() # Extract timestamp from filename if possible created = now if fname.startswith("backup_"): parts = fname.replace("backup_", "").replace(".json", "") try: created = datetime.strptime(parts, "%Y%m%d_%H%M%S").isoformat() except ValueError: pass conn.execute( "INSERT INTO config_backups (data, created_at) VALUES (?, ?)", (backup_data, created) ) migrated_backups += 1 except Exception as e: logger.warning(f"Migrate backup {fname} failed: {e}") conn.commit() report.append(f"✅ Config backups: {migrated_backups} backups migrated") # 3. Migrate response cache cache_file = os.path.join("cache", "response_cache.json") if os.path.exists(cache_file): try: with open(cache_file, "r", encoding="utf-8") as f: cache_data = json.load(f) migrated_cache = 0 for k, v in cache_data.items(): try: conn.execute( "INSERT OR IGNORE INTO response_cache (key, value, timestamp, ttl) VALUES (?, ?, ?, ?)", (k, v.get("value", ""), v.get("timestamp", now), int(v.get("ttl", 3600))) ) migrated_cache += 1 except Exception as e: logger.warning(f"Migrate cache entry failed: {e}") conn.commit() report.append(f"✅ Response cache: {migrated_cache} entries migrated") except Exception as e: report.append(f"❌ Response cache migration failed: {e}") else: report.append("⏭ Response cache not found, skipped") # 4. Migrate generation cache gen_cache_dir = Path("cache/generation") if gen_cache_dir.exists(): migrated_gen = 0 for cache_file in gen_cache_dir.glob("*.json"): try: with open(cache_file, "r", encoding="utf-8") as f: gen_data = f.read() conn.execute( "INSERT OR IGNORE INTO generation_cache (project_id, data, updated_at) VALUES (?, ?, ?)", (cache_file.stem, gen_data, now) ) migrated_gen += 1 except Exception as e: logger.warning(f"Migrate generation cache {cache_file.name} failed: {e}") conn.commit() report.append(f"✅ Generation cache: {migrated_gen} entries migrated") # 5. Migrate chapter summaries summary_dir = Path("cache/summaries") if summary_dir.exists(): migrated_summaries = 0 for project_dir in summary_dir.iterdir(): if not project_dir.is_dir(): continue for summary_file in project_dir.glob("*.json"): try: with open(summary_file, "r", encoding="utf-8") as f: summary_data = json.load(f) conn.execute(""" INSERT OR IGNORE INTO chapter_summaries (project_id, chapter_num, summary, generated_at) VALUES (?, ?, ?, ?) """, ( project_dir.name, summary_data.get("chapter_num", int(summary_file.stem)), summary_data.get("summary", ""), summary_data.get("generated_at", now) )) migrated_summaries += 1 except Exception as e: logger.warning(f"Migrate summary {summary_file} failed: {e}") conn.commit() report.append(f"✅ Chapter summaries: {migrated_summaries} entries migrated") # 6. Migrate projects projects_dir = "projects" if os.path.exists(projects_dir): migrated_projects = 0 for project_id in os.listdir(projects_dir): project_path = os.path.join(projects_dir, project_id) if not os.path.isdir(project_path): continue metadata_file = os.path.join(project_path, "metadata.json") if not os.path.exists(metadata_file): continue try: with open(metadata_file, "r", encoding="utf-8") as f: metadata = json.load(f) conn.execute(""" INSERT OR IGNORE INTO projects (id, title, genre, character_setting, world_setting, plot_idea, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( metadata.get("id", project_id), metadata.get("title", ""), metadata.get("genre", ""), metadata.get("character_setting", ""), metadata.get("world_setting", ""), metadata.get("plot_idea", ""), metadata.get("created_at", now), metadata.get("updated_at", now) )) # Migrate chapters for ch in metadata.get("chapters", []): try: conn.execute(""" INSERT OR IGNORE INTO chapters (project_id, num, title, desc, content, word_count, generated_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( metadata.get("id", project_id), ch.get("num", 0), ch.get("title", ""), ch.get("desc", ""), ch.get("content", ""), ch.get("word_count", 0), ch.get("generated_at") )) except Exception as e: logger.warning(f"Migrate chapter {ch.get('num')} failed: {e}") migrated_projects += 1 except Exception as e: logger.warning(f"Migrate project {project_id} failed: {e}") conn.commit() report.append(f"✅ Projects: {migrated_projects} projects migrated") else: report.append("⏭ Projects directory not found, skipped") result = "\n".join(report) logger.info(f"Migration complete:\n{result}") return result