import sqlite3 from 'node-sqlite3-wasm'; import path from 'path'; import { app } from 'electron'; import { initializeSchema } from './schema.js'; import { encrypt, decrypt, encryptObject, decryptObject, hash } from './encryption.js'; // Type alias for compatibility type Database = sqlite3.Database; // Mappers import * as BookMapper from './mappers/book.mapper.js'; import * as ChapterMapper from './mappers/chapter.mapper.js'; import * as CharacterMapper from './mappers/character.mapper.js'; import * as AIMapper from './mappers/ai.mapper.js'; import * as UserMapper from './mappers/user.mapper.js'; import * as WorldMapper from './mappers/world.mapper.js'; // Types from mappers (which contain all necessary interfaces) import type { BookProps, BookListProps } from './mappers/book.mapper.js'; import type { ChapterProps } from './mappers/chapter.mapper.js'; import type { CharacterProps } from './mappers/character.mapper.js'; import type { Conversation, Message } from './mappers/ai.mapper.js'; import type { UserProps } from './mappers/user.mapper.js'; import type { WorldProps } from './mappers/world.mapper.js'; /** * DatabaseService - Handles all local database operations * Provides CRUD operations with automatic encryption/decryption * Maps between DB snake_case and TypeScript camelCase interfaces */ export class DatabaseService { private db: Database | null = null; private userEncryptionKey: string | null = null; private userId: string | null = null; constructor() {} /** * Initialize the database for a specific user * @param userId - User ID for encryption key * @param encryptionKey - User's encryption key (generated at first login) */ initialize(userId: string, encryptionKey: string): void { if (this.db) { this.close(); } // Get user data directory const userDataPath = app.getPath('userData'); const dbPath = path.join(userDataPath, `eritors-local-${userId}.db`); this.db = new sqlite3.Database(dbPath); this.userEncryptionKey = encryptionKey; this.userId = userId; // Initialize schema initializeSchema(this.db); console.log(`Database initialized for user ${userId} at ${dbPath}`); } /** * Close the database connection */ close(): void { if (this.db) { this.db.close(); this.db = null; this.userEncryptionKey = null; this.userId = null; } } /** * Check if database is initialized */ isInitialized(): boolean { return this.db !== null && this.userEncryptionKey !== null; } /** * Encrypt sensitive field */ private encryptField(data: string): string { if (!this.userEncryptionKey) throw new Error('Encryption key not set'); const encrypted = encrypt(data, this.userEncryptionKey); return JSON.stringify(encrypted); } /** * Decrypt sensitive field */ private decryptField(encryptedData: string): string { if (!this.userEncryptionKey) throw new Error('Encryption key not set'); try { const parsed = JSON.parse(encryptedData); return decrypt(parsed, this.userEncryptionKey); } catch { // If not encrypted (for migration), return as-is return encryptedData; } } // ========== BOOK OPERATIONS ========== /** * Get all books for the current user */ getBooks(): BookListProps[] { if (!this.db || !this.userId) throw new Error('Database not initialized'); const rows = this.db.all(` SELECT * FROM erit_books WHERE author_id = ? ORDER BY book_id DESC `, [this.userId]) as unknown as BookMapper.DBBook[]; return rows.map(row => BookMapper.dbToBookList(row)); } /** * Get a single book by ID with all related data */ getBook(bookId: string): BookProps | null { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.all('SELECT * FROM erit_books WHERE book_id = ?', [bookId]) as unknown as BookMapper.DBBook[]; const row = rows[0]; if (!row) return null; const book = BookMapper.dbToBook(row); // Load chapters const chapterRows = this.db.all(` SELECT * FROM book_chapters WHERE book_id = ? ORDER BY chapter_order ASC `, [bookId]) as unknown as ChapterMapper.DBChapter[]; book.chapters = chapterRows.map(chapterRow => { // Load chapter content const contentRows = this.db!.all(` SELECT * FROM book_chapter_content WHERE chapter_id = ? ORDER BY version DESC LIMIT 1 `, [chapterRow.chapter_id]) as unknown as ChapterMapper.DBChapterContent[]; const contentRow = contentRows[0]; // Decrypt content if encrypted if (contentRow && contentRow.content) { try { contentRow.content = this.decryptField(contentRow.content); } catch (error) { console.warn('Failed to decrypt chapter content:', error); } } return ChapterMapper.dbToChapter(chapterRow, contentRow); }); return book; } /** * Save or update a book */ saveBook(book: BookProps | BookListProps, authorId?: string): void { if (!this.db || !this.userId) throw new Error('Database not initialized'); const dbBook = 'bookId' in book ? BookMapper.bookToDb(book, authorId || this.userId, 0) : BookMapper.bookListToDb(book, 0); // Hash the title dbBook.hashed_title = hash(dbBook.title); if (dbBook.sub_title) { dbBook.hashed_sub_title = hash(dbBook.sub_title); } this.db.run(` INSERT OR REPLACE INTO erit_books ( book_id, type, author_id, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count, words_count, cover_image, book_meta, synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ dbBook.book_id, dbBook.type, dbBook.author_id, dbBook.title, dbBook.hashed_title, dbBook.sub_title ?? null, dbBook.hashed_sub_title, dbBook.summary, dbBook.serie_id ?? null, dbBook.desired_release_date ?? null, dbBook.desired_word_count ?? null, dbBook.words_count ?? null, dbBook.cover_image ?? null, dbBook.book_meta ?? null, 0 ]); // Add to pending changes for sync this.addPendingChange('erit_books', 'INSERT', dbBook.book_id, dbBook); } /** * Delete a book */ deleteBook(bookId: string): void { if (!this.db) throw new Error('Database not initialized'); this.db.run('DELETE FROM erit_books WHERE book_id = ?', [bookId]); this.addPendingChange('erit_books', 'DELETE', bookId); } // ========== CHAPTER OPERATIONS ========== /** * Save or update a chapter */ saveChapter(chapter: ChapterProps, bookId: string, contentId?: string): void { if (!this.db || !this.userId) throw new Error('Database not initialized'); const dbChapter = ChapterMapper.chapterToDb(chapter, bookId, this.userId, 0); dbChapter.hashed_title = hash(dbChapter.title); this.db.run(` INSERT OR REPLACE INTO book_chapters ( chapter_id, book_id, author_id, title, hashed_title, words_count, chapter_order, meta_chapter, synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ dbChapter.chapter_id, dbChapter.book_id, dbChapter.author_id, dbChapter.title, dbChapter.hashed_title, dbChapter.words_count ?? null, dbChapter.chapter_order ?? null, dbChapter.meta_chapter, 0 ]); // Save encrypted content const dbContent = ChapterMapper.chapterContentToDb( chapter.chapterContent, contentId || crypto.randomUUID(), chapter.chapterId, this.userId, 0, 0 ); // Encrypt the content dbContent.content = this.encryptField(dbContent.content); this.db.run(` INSERT OR REPLACE INTO book_chapter_content ( content_id, chapter_id, author_id, version, content, words_count, meta_chapter_content, time_on_it, synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ dbContent.content_id, dbContent.chapter_id, dbContent.author_id, dbContent.version, dbContent.content, dbContent.words_count, dbContent.meta_chapter_content, dbContent.time_on_it, 0 ]); this.addPendingChange('book_chapters', 'INSERT', chapter.chapterId, dbChapter); } // ========== CHARACTER OPERATIONS ========== /** * Get all characters for a book */ getCharacters(bookId: string): CharacterProps[] { if (!this.db) throw new Error('Database not initialized'); const characterRows = this.db.all('SELECT * FROM book_characters WHERE book_id = ?', [bookId]) as unknown as CharacterMapper.DBCharacter[]; return characterRows.map(charRow => { const attrRows = this.db!.all('SELECT * FROM book_characters_attributes WHERE character_id = ?', [charRow.character_id]) as unknown as CharacterMapper.DBCharacterAttribute[]; return CharacterMapper.dbToCharacter(charRow, attrRows); }); } /** * Save or update a character */ saveCharacter(character: CharacterProps, bookId: string): void { if (!this.db || !this.userId) throw new Error('Database not initialized'); const dbCharacter = CharacterMapper.characterToDb(character, bookId, this.userId, 0); this.db.run(` INSERT OR REPLACE INTO book_characters ( character_id, book_id, user_id, first_name, last_name, category, title, image, role, biography, history, char_meta, synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ dbCharacter.character_id, dbCharacter.book_id, dbCharacter.user_id, dbCharacter.first_name, dbCharacter.last_name ?? null, dbCharacter.category, dbCharacter.title ?? null, dbCharacter.image ?? null, dbCharacter.role ?? null, dbCharacter.biography ?? null, dbCharacter.history ?? null, dbCharacter.char_meta, 0 ]); // Delete old attributes and insert new ones this.db.run('DELETE FROM book_characters_attributes WHERE character_id = ?', [dbCharacter.character_id]); const attributes = CharacterMapper.characterAttributesToDb(character, this.userId, 0); for (const attr of attributes) { this.db.run(` INSERT INTO book_characters_attributes ( attr_id, character_id, user_id, attribute_name, attribute_value, attr_meta, synced ) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ attr.attr_id, attr.character_id, attr.user_id, attr.attribute_name, attr.attribute_value, attr.attr_meta, 0 ]); } this.addPendingChange('book_characters', 'INSERT', dbCharacter.character_id, dbCharacter); } // ========== AI CONVERSATION OPERATIONS ========== /** * Get all conversations for a book */ getConversations(bookId: string): Conversation[] { if (!this.db) throw new Error('Database not initialized'); const convoRows = this.db.all('SELECT * FROM ai_conversations WHERE book_id = ? ORDER BY start_date DESC', [bookId]) as unknown as AIMapper.DBConversation[]; return convoRows.map(convoRow => { const messageRows = this.db!.all('SELECT * FROM ai_messages_history WHERE conversation_id = ? ORDER BY message_date ASC', [convoRow.conversation_id]) as unknown as AIMapper.DBMessage[]; // Decrypt messages messageRows.forEach(msg => { try { msg.message = this.decryptField(msg.message); } catch (error) { console.warn('Failed to decrypt AI message:', error); } }); return AIMapper.dbToConversation(convoRow, messageRows); }); } /** * Save a conversation with messages */ saveConversation(conversation: Conversation, bookId: string): void { if (!this.db || !this.userId) throw new Error('Database not initialized'); const dbConvo = AIMapper.conversationToDb(conversation, bookId, this.userId, 0); this.db.run(` INSERT OR REPLACE INTO ai_conversations ( conversation_id, book_id, mode, title, start_date, status, user_id, summary, convo_meta, synced ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ dbConvo.conversation_id, dbConvo.book_id, dbConvo.mode, dbConvo.title, dbConvo.start_date, dbConvo.status, dbConvo.user_id, dbConvo.summary ?? null, dbConvo.convo_meta, 0 ]); // Save encrypted messages for (const message of conversation.messages) { const dbMessage = AIMapper.messageToDb(message, conversation.id, 0); // Encrypt the message content dbMessage.message = this.encryptField(dbMessage.message); this.db.run(` INSERT OR REPLACE INTO ai_messages_history ( message_id, conversation_id, role, message, message_date, meta_message, synced ) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ dbMessage.message_id, dbMessage.conversation_id, dbMessage.role, dbMessage.message, dbMessage.message_date, dbMessage.meta_message, 0 ]); } this.addPendingChange('ai_conversations', 'INSERT', dbConvo.conversation_id, dbConvo); } // ========== SYNC OPERATIONS ========== /** * Add a pending change for sync */ private addPendingChange(tableName: string, operation: string, recordId: string, data?: any): void { if (!this.db) return; this.db.run(` INSERT INTO _pending_changes (table_name, operation, record_id, data, created_at) VALUES (?, ?, ?, ?, ?) `, [tableName, operation, recordId, data ? JSON.stringify(data) : null, Date.now()]); // Update sync metadata this.db.run(` UPDATE _sync_metadata SET pending_changes = pending_changes + 1 WHERE table_name = ? `, [tableName]); } /** * Get pending changes for sync */ getPendingChanges(limit: number = 100): any[] { if (!this.db) throw new Error('Database not initialized'); return this.db.all(` SELECT * FROM _pending_changes ORDER BY created_at ASC LIMIT ? `, [limit]) as any[]; } /** * Mark changes as synced */ markChangesSynced(changeIds: number[]): void { if (!this.db || changeIds.length === 0) return; const placeholders = changeIds.map(() => '?').join(','); this.db.run(`DELETE FROM _pending_changes WHERE id IN (${placeholders})`, changeIds); } /** * Update last sync time for a table */ updateLastSync(tableName: string): void { if (!this.db) return; this.db.run(` UPDATE _sync_metadata SET last_sync_at = ?, pending_changes = 0 WHERE table_name = ? `, [Date.now(), tableName]); } /** * Get sync status */ getSyncStatus(): { table: string; lastSync: number; pending: number }[] { if (!this.db) throw new Error('Database not initialized'); const rows = this.db.all('SELECT * FROM _sync_metadata', []) as unknown as any[]; return rows.map(row => ({ table: row.table_name as string, lastSync: row.last_sync_at as number, pending: row.pending_changes as number })); } } // Singleton instance let dbServiceInstance: DatabaseService | null = null; export function getDatabaseService(): DatabaseService { if (!dbServiceInstance) { dbServiceInstance = new DatabaseService(); } return dbServiceInstance; }