import initSqlJs from 'sql.js'; import localforage from 'localforage'; import { v4 as uuidv4 } from 'uuid'; const DB_NAME = 'ai_translate_db_v1'; const STORE_KEY = 'sqlite_binary'; let db = null; let SQL = null; export const dbService = { init: async () => { if (db) return db; try { // Load WASM SQL = await initSqlJs({ // Locate the WASM file in public folder locateFile: file => `/${file}` }); // Try to load saved binary from IndexedDB const savedData = await localforage.getItem(STORE_KEY); if (savedData) { // Load existing DB db = new SQL.Database(new Uint8Array(savedData)); console.log("[DB] Loaded existing database."); } else { // Create new DB db = new SQL.Database(); console.log("[DB] Created new database."); dbService._createTables(); await dbService.save(); // Initial save } return db; } catch (e) { console.error("[DB] Initialization Failed:", e); throw e; } }, _createTables: () => { // Stories Table db.run(` CREATE TABLE IF NOT EXISTS stories ( id TEXT PRIMARY KEY, title TEXT, author TEXT, summary TEXT, cover_image TEXT, -- Base64 or URL created_at INTEGER, last_accessed INTEGER, metadata TEXT -- JSON: { genres, languages, personality, world... } ); `); // Files Table db.run(` CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, -- Use existing file IDs if possible, or new UUIDs story_id TEXT, name TEXT, content TEXT, translated_content TEXT, status TEXT, size INTEGER, last_modified INTEGER, FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE ); `); // Dictionary Table (Global or per story? Let's make it simple first: Per Story stored in metadata or separate?) // For now, Dictionary is just text in App.jsx. We can store it in metadata if needed. }, save: async () => { if (!db) return; try { const data = db.export(); await localforage.setItem(STORE_KEY, data); // console.log("[DB] Saved to IndexedDB"); } catch (e) { console.error("[DB] Save Failed:", e); } }, // --- Story Operations --- createStory: async (title = "Truyện Mới") => { if (!db) { console.error("[DB] Database is null!"); throw new Error("Database not initialized"); } console.log("[DB] Executing createStory for:", title); const id = uuidv4(); const now = Date.now(); const metadata = JSON.stringify({ genres: [], languages: ['Tiếng Trung'], mcPersonality: [], worldSetting: [], contextNotes: '', additionalDictionary: '', customPrompt: '' }); try { db.run(`INSERT INTO stories (id, title, created_at, last_accessed, metadata) VALUES (?, ?, ?, ?, ?)`, [id, title, now, now, metadata]); await dbService.save(); console.log("[DB] Insert Successful, ID:", id); return id; } catch (e) { console.error("[DB] Insert Failed:", e); throw e; } }, getStories: () => { if (!db) return []; const result = db.exec("SELECT id, title, author, cover_image, last_accessed FROM stories ORDER BY created_at DESC"); if (result.length === 0) return []; // Transform result [columns, values] to objects const columns = result[0].columns; return result[0].values.map(row => { const obj = {}; columns.forEach((col, i) => obj[col] = row[i]); return obj; }); }, getStory: (id) => { if (!db) return null; const result = db.exec("SELECT * FROM stories WHERE id = ?", [id]); if (result.length === 0) return null; const columns = result[0].columns; const row = result[0].values[0]; const story = {}; columns.forEach((col, i) => story[col] = row[i]); // Parse metadata try { if (story.metadata) Object.assign(story, JSON.parse(story.metadata)); } catch (e) { console.error("Failed to parse metadata", e); } return story; }, updateStory: async (id, data) => { if (!db) return; const now = Date.now(); // Separate columns vs metadata const directCols = ['title', 'author', 'summary', 'cover_image']; const metadataKeys = ['genres', 'languages', 'mcPersonality', 'worldSetting', 'contextNotes', 'image_prompt', 'additionalDictionary', 'customPrompt']; // 1. Get current metadata const current = dbService.getStory(id); if (!current) return; // 2. Prepare Updates const updates = []; const values = []; directCols.forEach(col => { if (data[col] !== undefined) { updates.push(`${col} = ?`); values.push(data[col]); } }); // 3. Update Metadata const newMetadata = {}; metadataKeys.forEach(k => { if (data[k] !== undefined) newMetadata[k] = data[k]; else if (current[k] !== undefined) newMetadata[k] = current[k]; }); updates.push(`metadata = ?`); values.push(JSON.stringify(newMetadata)); updates.push(`last_accessed = ?`); values.push(now); // Where ID values.push(id); const query = `UPDATE stories SET ${updates.join(', ')} WHERE id = ?`; db.run(query, values); await dbService.save(); }, deleteStory: async (id) => { db.run("DELETE FROM stories WHERE id = ?", [id]); // Files cascade delete handled by schema? sql.js usually doesn't enforce FK by default unless enabled // Manually delete files to be safe db.run("DELETE FROM files WHERE story_id = ?", [id]); await dbService.save(); }, // --- File Operations --- getFiles: (storyId) => { const res = db.exec("SELECT * FROM files WHERE story_id = ?", [storyId]); if (res.length === 0) return []; const columns = res[0].columns; return res[0].values.map(row => { const f = {}; columns.forEach((col, i) => { // Map snake_case to camelCase if (col === 'translated_content') f['translatedContent'] = row[i]; else if (col === 'last_modified') f['lastModified'] = row[i]; else f[col] = row[i]; }); return f; }); }, saveFilesBatch: async (storyId, files) => { // Use transaction db.run("BEGIN TRANSACTION"); const stmt = db.prepare("INSERT OR REPLACE INTO files (id, story_id, name, content, translated_content, status, size, last_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); files.forEach(f => { stmt.run([f.id, storyId, f.name, f.content, f.translatedContent || null, f.status || 'IDLE', f.content.length, Date.now()]); }); stmt.free(); db.run("COMMIT"); await dbService.save(); }, deleteFile: async (id) => { db.run("DELETE FROM files WHERE id = ?", [id]); await dbService.save(); }, deleteAllFiles: async (storyId) => { db.run("DELETE FROM files WHERE story_id = ?", [storyId]); await dbService.save(); } };