diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index da64c86..961d10b 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -27,6 +27,7 @@ import GuideTour, {GuideStep} from "@/components/GuideTour"; import {UserProps} from "@/lib/models/User"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; +import {getOfflineDataService} from "@/lib/services/offline-data.service"; interface MinMax { min: number; @@ -122,29 +123,43 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< } setIsAddingBook(true); try { - const bookId: string = await System.authPostToServer('book/add', { - title: title, - subTitle: subtitle, - type: selectedBookType, - summary: summary, - serie: 0, - publicationDate: publicationDate, - desiredWordCount: wordCount, - }, token, lang) - if (!bookId) { - errorMessage(t('addNewBookForm.error.addingBook')); - setIsAddingBook(false); - return; - } - const book: BookProps = { - bookId: bookId, + const offlineDataService = getOfflineDataService(); + const bookData = { title, subTitle: subtitle, type: selectedBookType, - summary, serie: 0, + summary, + serie: 0, publicationDate, desiredWordCount: wordCount }; + + const bookId: string = await offlineDataService.createBook( + bookData, + session.user?.id || '', + async () => { + // Only called if online + const id = await System.authPostToServer('book/add', { + title: title, + subTitle: subtitle, + type: selectedBookType, + summary: summary, + serie: 0, + publicationDate: publicationDate, + desiredWordCount: wordCount, + }, token, lang); + if (!id) { + throw new Error(t('addNewBookForm.error.addingBook')); + } + return id; + } + ); + + const book: BookProps = { + bookId: bookId, + ...bookData + }; + setSession({ ...session, user: { diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index b306ce7..a2e1972 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -13,6 +13,7 @@ import GuideTour, {GuideStep} from "@/components/GuideTour"; import User from "@/lib/models/User"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; +import {getOfflineDataService} from "@/lib/services/offline-data.service"; export default function BookList() { const {session, setSession} = useContext(SessionContext); @@ -113,7 +114,10 @@ export default function BookList() { async function getBooks(): Promise { setIsLoadingBooks(true); try { - const bookResponse: BookListProps[] = await System.authGetQueryToServer('books', accessToken, lang); + const offlineDataService = getOfflineDataService(); + const bookResponse: BookListProps[] = await offlineDataService.getBooks( + () => System.authGetQueryToServer('books', accessToken, lang) + ); if (bookResponse) { const booksByType: Record = bookResponse.reduce((groups: Record, book: BookListProps): Record => { const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; diff --git a/electron/database/database.service.ts b/electron/database/database.service.ts new file mode 100644 index 0000000..cd35704 --- /dev/null +++ b/electron/database/database.service.ts @@ -0,0 +1,457 @@ +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; +} diff --git a/electron/database/encryption.ts b/electron/database/encryption.ts new file mode 100644 index 0000000..2f10d3d --- /dev/null +++ b/electron/database/encryption.ts @@ -0,0 +1,137 @@ +import crypto from 'crypto'; + +/** + * Encryption utilities using AES-256-GCM for local database encryption + * Each user has a unique encryption key derived from their userId and a master secret + */ + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 16; // 128 bits +const SALT_LENGTH = 64; +const TAG_LENGTH = 16; + +export interface EncryptedData { + encryptedData: string; + iv: string; + authTag: string; +} + +/** + * Generate a unique encryption key for a user + * This key is generated once at first login and stored securely in electron-store + * @param userId - The user's unique identifier + * @returns Base64 encoded encryption key + */ +export function generateUserEncryptionKey(userId: string): string { + // Generate a random salt for this user + const salt = crypto.randomBytes(SALT_LENGTH); + + // Create a deterministic key based on userId and random salt + // This ensures each user has a unique, strong key + const key = crypto.pbkdf2Sync( + userId, + salt, + 100000, // iterations + KEY_LENGTH, + 'sha512' + ); + + // Combine salt and key for storage + const combined = Buffer.concat([salt, key]); + return combined.toString('base64'); +} + +/** + * Extract the actual encryption key from the stored combined salt+key + * @param storedKey - Base64 encoded salt+key combination + * @returns Encryption key buffer + */ +function extractKeyFromStored(storedKey: string): Buffer { + const combined = Buffer.from(storedKey, 'base64'); + // Extract key (last KEY_LENGTH bytes) + return combined.subarray(SALT_LENGTH, SALT_LENGTH + KEY_LENGTH); +} + +/** + * Encrypt sensitive data using AES-256-GCM + * @param data - Plain text data to encrypt + * @param userKey - User's encryption key (base64) + * @returns Encrypted data with IV and auth tag + */ +export function encrypt(data: string, userKey: string): EncryptedData { + try { + const key = extractKeyFromStored(userKey); + const iv = crypto.randomBytes(IV_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return { + encryptedData: encrypted, + iv: iv.toString('hex'), + authTag: authTag.toString('hex') + }; + } catch (error) { + throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Decrypt data encrypted with AES-256-GCM + * @param encryptedData - Encrypted data object + * @param userKey - User's encryption key (base64) + * @returns Decrypted plain text + */ +export function decrypt(encryptedData: EncryptedData, userKey: string): string { + try { + const key = extractKeyFromStored(userKey); + const iv = Buffer.from(encryptedData.iv, 'hex'); + const authTag = Buffer.from(encryptedData.authTag, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedData.encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Encrypt an object by converting it to JSON first + * @param obj - Object to encrypt + * @param userKey - User's encryption key + * @returns Encrypted data + */ +export function encryptObject(obj: T, userKey: string): EncryptedData { + const jsonString = JSON.stringify(obj); + return encrypt(jsonString, userKey); +} + +/** + * Decrypt and parse an encrypted object + * @param encryptedData - Encrypted data object + * @param userKey - User's encryption key + * @returns Decrypted and parsed object + */ +export function decryptObject(encryptedData: EncryptedData, userKey: string): T { + const decrypted = decrypt(encryptedData, userKey); + return JSON.parse(decrypted) as T; +} + +/** + * Hash data using SHA-256 (for non-reversible hashing like titles) + * @param data - Data to hash + * @returns Hex encoded hash + */ +export function hash(data: string): string { + return crypto.createHash('sha256').update(data).digest('hex'); +} diff --git a/electron/database/mappers/ai.mapper.ts b/electron/database/mappers/ai.mapper.ts new file mode 100644 index 0000000..0a52b5c --- /dev/null +++ b/electron/database/mappers/ai.mapper.ts @@ -0,0 +1,120 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface Message { + id: number; + type: string; + message: string; + date: string; +} + +export interface Conversation { + id: string; + title?: string; + date?: string; + type?: string; + status: number; + totalPrice: number; + messages: Message[]; +} + +export interface ConversationProps { + id: string; + mode: string; + title: string; + startDate: string; + status: number; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBConversation { + conversation_id: string; + book_id: string; + mode: string; + title: string; + start_date: number; // Unix timestamp + status: number; + user_id: string; + summary?: string; + convo_meta: string; + synced?: number; +} + +export interface DBMessage { + message_id: string; + conversation_id: string; + role: string; // 'user' or 'model' + message: string; + message_date: number; // Unix timestamp + meta_message: string; + synced?: number; +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToConversation(dbConvo: DBConversation, messages: DBMessage[] = []): Conversation { + return { + id: dbConvo.conversation_id, + title: dbConvo.title, + date: new Date(dbConvo.start_date).toISOString(), + type: dbConvo.mode as any, + status: dbConvo.status, + totalPrice: 0, // Computed from messages if needed + messages: messages.map(dbToMessage) + }; +} + +export function dbToConversationProps(dbConvo: DBConversation): ConversationProps { + return { + id: dbConvo.conversation_id, + mode: dbConvo.mode, + title: dbConvo.title, + startDate: new Date(dbConvo.start_date).toISOString(), + status: dbConvo.status + }; +} + +export function dbToMessage(dbMessage: DBMessage): Message { + return { + id: parseInt(dbMessage.message_id, 10) || 0, + type: dbMessage.role as any, + message: dbMessage.message, + date: new Date(dbMessage.message_date).toISOString() + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function conversationToDb(conversation: Conversation, bookId: string, userId: string, synced: number = 0): DBConversation { + return { + conversation_id: conversation.id, + book_id: bookId, + mode: conversation.type || 'chatbot', + title: conversation.title || 'Untitled Conversation', + start_date: conversation.date ? new Date(conversation.date).getTime() : Date.now(), + status: conversation.status, + user_id: userId, + summary: '', + convo_meta: '', + synced + }; +} + +export function messageToDb(message: Message, conversationId: string, synced: number = 0): DBMessage { + return { + message_id: message.id.toString(), + conversation_id: conversationId, + role: message.type, + message: message.message, + message_date: message.date ? new Date(message.date).getTime() : Date.now(), + meta_message: '', + synced + }; +} diff --git a/electron/database/mappers/book.mapper.ts b/electron/database/mappers/book.mapper.ts new file mode 100644 index 0000000..ae090d1 --- /dev/null +++ b/electron/database/mappers/book.mapper.ts @@ -0,0 +1,406 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface Author { + id: string; + name: string; + lastName: string; + authorName?: string; +} + +export interface ActChapter { + chapterInfoId: string; + chapterId: string; + title: string; + chapterOrder: number; + actId: number; + incidentId?: string; + plotPointId?: string; + summary: string; + goal: string; +} + +export interface ChapterProps { + chapterId: string; + chapterOrder: number; + title: string; + chapterContent: ChapterContent; +} + +export interface ChapterContent { + version: number; + content: string; + wordsCount: number; +} + +export interface BookProps { + bookId: string; + type: string; + title: string; + author?: Author; + serie?: number; + subTitle?: string; + summary?: string; + publicationDate?: string; + desiredWordCount?: number; + totalWordCount?: number; + coverImage?: string; + chapters?: ChapterProps[]; +} + +export interface BookListProps { + id: string; + type: string; + authorId: string; + title: string; + subTitle?: string; + summary?: string; + serieId?: number; + desiredReleaseDate?: string; + desiredWordCount?: number; + wordCount?: number; + coverImage?: string; + bookMeta?: string; +} + +export interface GuideLine { + tone: string; + atmosphere: string; + writingStyle: string; + themes: string; + symbolism: string; + motifs: string; + narrativeVoice: string; + pacing: string; + intendedAudience: string; + keyMessages: string; +} + +export interface GuideLineAI { + narrativeType: number; + dialogueType: number; + globalResume: string; + atmosphere: string; + verbeTense: number; + langue: number; + themes: string; +} + +export interface PlotPoint { + plotPointId: string; + title: string; + summary: string; + linkedIncidentId: string; + chapters?: ActChapter[]; +} + +export interface Incident { + incidentId: string; + title: string; + summary: string; + chapters?: ActChapter[]; +} + +export interface Issue { + id: string; + name: string; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBBook { + book_id: string; + type: string; + author_id: string; + title: string; + hashed_title: string; + sub_title?: string; + hashed_sub_title: string; + summary: string; + serie_id?: number; + desired_release_date?: string; + desired_word_count?: number; + words_count?: number; + cover_image?: string; + book_meta?: string; + synced?: number; +} + +export interface DBGuideLine { + user_id: string; + book_id: string; + tone: string; + atmosphere: string; + writing_style: string; + themes: string; + symbolism: string; + motifs: string; + narrative_voice: string; + pacing: string; + intended_audience: string; + key_messages: string; + meta_guide_line: string; + synced?: number; +} + +export interface DBGuideLineAI { + user_id: string; + book_id: string; + global_resume: string; + themes: string; + verbe_tense: number; + narrative_type: number; + langue: number; + dialogue_type: number; + tone: string; + atmosphere: string; + current_resume: string; + meta: string; + synced?: number; +} + +export interface DBPlotPoint { + plot_point_id: string; + title: string; + hashed_title: string; + summary?: string; + linked_incident_id?: string; + author_id: string; + book_id: string; + meta_plot: string; + synced?: number; +} + +export interface DBIncident { + incident_id: string; + author_id: string; + book_id: string; + title: string; + hashed_title: string; + summary?: string; + meta_incident: string; + synced?: number; +} + +export interface DBIssue { + issue_id: string; + author_id: string; + book_id: string; + name: string; + hashed_issue_name: string; + meta_issue: string; + synced?: number; +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToBookList(dbBook: DBBook): BookListProps { + return { + id: dbBook.book_id, + type: dbBook.type, + authorId: dbBook.author_id, + title: dbBook.title, + subTitle: dbBook.sub_title, + summary: dbBook.summary, + serieId: dbBook.serie_id, + desiredReleaseDate: dbBook.desired_release_date, + desiredWordCount: dbBook.desired_word_count, + wordCount: dbBook.words_count, + coverImage: dbBook.cover_image, + bookMeta: dbBook.book_meta + }; +} + +export function dbToBook(dbBook: DBBook, author?: Author): BookProps { + return { + bookId: dbBook.book_id, + type: dbBook.type, + title: dbBook.title, + author, + serie: dbBook.serie_id, + subTitle: dbBook.sub_title, + summary: dbBook.summary, + publicationDate: dbBook.desired_release_date, + desiredWordCount: dbBook.desired_word_count, + totalWordCount: dbBook.words_count, + coverImage: dbBook.cover_image, + chapters: [] // Populated separately + }; +} + +export function dbToGuideLine(dbGuideLine: DBGuideLine): GuideLine { + return { + tone: dbGuideLine.tone, + atmosphere: dbGuideLine.atmosphere, + writingStyle: dbGuideLine.writing_style, + themes: dbGuideLine.themes, + symbolism: dbGuideLine.symbolism, + motifs: dbGuideLine.motifs, + narrativeVoice: dbGuideLine.narrative_voice, + pacing: dbGuideLine.pacing, + intendedAudience: dbGuideLine.intended_audience, + keyMessages: dbGuideLine.key_messages + }; +} + +export function dbToGuideLineAI(dbGuideLineAI: DBGuideLineAI): GuideLineAI { + return { + narrativeType: dbGuideLineAI.narrative_type, + dialogueType: dbGuideLineAI.dialogue_type, + globalResume: dbGuideLineAI.global_resume, + atmosphere: dbGuideLineAI.atmosphere, + verbeTense: dbGuideLineAI.verbe_tense, + langue: dbGuideLineAI.langue, + themes: dbGuideLineAI.themes + }; +} + +export function dbToPlotPoint(dbPlotPoint: DBPlotPoint): PlotPoint { + return { + plotPointId: dbPlotPoint.plot_point_id, + title: dbPlotPoint.title, + summary: dbPlotPoint.summary || '', + linkedIncidentId: dbPlotPoint.linked_incident_id || '', + chapters: [] // Populated separately + }; +} + +export function dbToIncident(dbIncident: DBIncident): Incident { + return { + incidentId: dbIncident.incident_id, + title: dbIncident.title, + summary: dbIncident.summary || '', + chapters: [] // Populated separately + }; +} + +export function dbToIssue(dbIssue: DBIssue): Issue { + return { + id: dbIssue.issue_id, + name: dbIssue.name + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function bookListToDb(book: BookListProps, synced: number = 0): DBBook { + return { + book_id: book.id, + type: book.type, + author_id: book.authorId, + title: book.title, + hashed_title: '', // Will be computed with hash function + sub_title: book.subTitle, + hashed_sub_title: '', + summary: book.summary || '', + serie_id: book.serieId, + desired_release_date: book.desiredReleaseDate, + desired_word_count: book.desiredWordCount, + words_count: book.wordCount, + cover_image: book.coverImage, + book_meta: book.bookMeta || '', + synced + }; +} + +export function bookToDb(book: BookProps, authorId: string, synced: number = 0): DBBook { + return { + book_id: book.bookId, + type: book.type, + author_id: authorId, + title: book.title, + hashed_title: '', + sub_title: book.subTitle, + hashed_sub_title: '', + summary: book.summary || '', + serie_id: book.serie, + desired_release_date: book.publicationDate, + desired_word_count: book.desiredWordCount, + words_count: book.totalWordCount, + cover_image: book.coverImage, + book_meta: '', + synced + }; +} + +export function guideLineToDb(guideLine: GuideLine, userId: string, bookId: string, synced: number = 0): DBGuideLine { + return { + user_id: userId, + book_id: bookId, + tone: guideLine.tone, + atmosphere: guideLine.atmosphere, + writing_style: guideLine.writingStyle, + themes: guideLine.themes, + symbolism: guideLine.symbolism, + motifs: guideLine.motifs, + narrative_voice: guideLine.narrativeVoice, + pacing: guideLine.pacing, + intended_audience: guideLine.intendedAudience, + key_messages: guideLine.keyMessages, + meta_guide_line: '', + synced + }; +} + +export function guideLineAIToDb(guideLineAI: GuideLineAI, userId: string, bookId: string, synced: number = 0): DBGuideLineAI { + return { + user_id: userId, + book_id: bookId, + global_resume: guideLineAI.globalResume, + themes: guideLineAI.themes, + verbe_tense: guideLineAI.verbeTense, + narrative_type: guideLineAI.narrativeType, + langue: guideLineAI.langue, + dialogue_type: guideLineAI.dialogueType, + tone: '', + atmosphere: guideLineAI.atmosphere, + current_resume: '', + meta: '', + synced + }; +} + +export function plotPointToDb(plotPoint: PlotPoint, authorId: string, bookId: string, synced: number = 0): DBPlotPoint { + return { + plot_point_id: plotPoint.plotPointId, + title: plotPoint.title, + hashed_title: '', + summary: plotPoint.summary, + linked_incident_id: plotPoint.linkedIncidentId, + author_id: authorId, + book_id: bookId, + meta_plot: '', + synced + }; +} + +export function incidentToDb(incident: Incident, authorId: string, bookId: string, synced: number = 0): DBIncident { + return { + incident_id: incident.incidentId, + author_id: authorId, + book_id: bookId, + title: incident.title, + hashed_title: '', + summary: incident.summary, + meta_incident: '', + synced + }; +} + +export function issueToDb(issue: Issue, authorId: string, bookId: string, synced: number = 0): DBIssue { + return { + issue_id: issue.id, + author_id: authorId, + book_id: bookId, + name: issue.name, + hashed_issue_name: '', + meta_issue: '', + synced + }; +} diff --git a/electron/database/mappers/chapter.mapper.ts b/electron/database/mappers/chapter.mapper.ts new file mode 100644 index 0000000..bf337ea --- /dev/null +++ b/electron/database/mappers/chapter.mapper.ts @@ -0,0 +1,174 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface ChapterContent { + version: number; + content: string; + wordsCount: number; +} + +export interface ChapterProps { + chapterId: string; + chapterOrder: number; + title: string; + chapterContent: ChapterContent; +} + +export interface ActChapter { + chapterInfoId: string; + chapterId: string; + title: string; + chapterOrder: number; + actId: number; + incidentId?: string; + plotPointId?: string; + summary: string; + goal: string; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBChapter { + chapter_id: string; + book_id: string; + author_id: string; + title: string; + hashed_title?: string; + words_count?: number; + chapter_order?: number; + meta_chapter: string; + synced?: number; +} + +export interface DBChapterContent { + content_id: string; + chapter_id: string; + author_id: string; + version: number; + content: string; + words_count: number; + meta_chapter_content: string; + time_on_it: number; + synced?: number; +} + +export interface DBChapterInfo { + chapter_info_id: string; + chapter_id?: string; + act_id?: number; + incident_id?: string; + plot_point_id?: string; + book_id?: string; + author_id?: string; + summary: string; + goal: string; + meta_chapter_info: string; + synced?: number; +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToChapter(dbChapter: DBChapter, dbContent?: DBChapterContent): ChapterProps { + const chapterContent: ChapterContent = dbContent ? { + version: dbContent.version, + content: dbContent.content, + wordsCount: dbContent.words_count + } : { + version: 2, + content: '', + wordsCount: 0 + }; + + return { + chapterId: dbChapter.chapter_id, + chapterOrder: dbChapter.chapter_order || 0, + title: dbChapter.title, + chapterContent + }; +} + +export function dbToChapterContent(dbContent: DBChapterContent): ChapterContent { + return { + version: dbContent.version, + content: dbContent.content, + wordsCount: dbContent.words_count + }; +} + +export function dbToActChapter(dbChapter: DBChapter, dbInfo: DBChapterInfo): ActChapter { + return { + chapterInfoId: dbInfo.chapter_info_id, + chapterId: dbChapter.chapter_id, + title: dbChapter.title, + chapterOrder: dbChapter.chapter_order || 0, + actId: dbInfo.act_id || 0, + incidentId: dbInfo.incident_id, + plotPointId: dbInfo.plot_point_id, + summary: dbInfo.summary, + goal: dbInfo.goal + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function chapterToDb(chapter: ChapterProps, bookId: string, authorId: string, synced: number = 0): DBChapter { + return { + chapter_id: chapter.chapterId, + book_id: bookId, + author_id: authorId, + title: chapter.title, + hashed_title: '', + words_count: chapter.chapterContent.wordsCount, + chapter_order: chapter.chapterOrder, + meta_chapter: '', + synced + }; +} + +export function chapterContentToDb( + content: ChapterContent, + contentId: string, + chapterId: string, + authorId: string, + timeOnIt: number = 0, + synced: number = 0 +): DBChapterContent { + return { + content_id: contentId, + chapter_id: chapterId, + author_id: authorId, + version: content.version, + content: content.content, + words_count: content.wordsCount, + meta_chapter_content: '', + time_on_it: timeOnIt, + synced + }; +} + +export function actChapterToDbInfo( + actChapter: ActChapter, + bookId: string, + authorId: string, + synced: number = 0 +): DBChapterInfo { + return { + chapter_info_id: actChapter.chapterInfoId, + chapter_id: actChapter.chapterId, + act_id: actChapter.actId, + incident_id: actChapter.incidentId, + plot_point_id: actChapter.plotPointId, + book_id: bookId, + author_id: authorId, + summary: actChapter.summary, + goal: actChapter.goal, + meta_chapter_info: '', + synced + }; +} diff --git a/electron/database/mappers/character.mapper.ts b/electron/database/mappers/character.mapper.ts new file mode 100644 index 0000000..85adb06 --- /dev/null +++ b/electron/database/mappers/character.mapper.ts @@ -0,0 +1,185 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface Attribute { + id?: string; + name: string; + description: string; +} + +export interface CharacterProps { + id: string | null; + name: string; + lastName: string; + category: string; + title: string; + image: string; + physical?: Attribute[]; + psychological?: Attribute[]; + relations?: Attribute[]; + skills?: Attribute[]; + weaknesses?: Attribute[]; + strengths?: Attribute[]; + goals?: Attribute[]; + motivations?: Attribute[]; + role: string; + biography?: string; + history?: string; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBCharacter { + character_id: string; + book_id: string; + user_id: string; + first_name: string; + last_name?: string; + category: string; + title?: string; + image?: string; + role?: string; + biography?: string; + history?: string; + char_meta: string; + synced?: number; +} + +export interface DBCharacterAttribute { + attr_id: string; + character_id: string; + user_id: string; + attribute_name: string; // Format: "section:attributeName" (e.g., "physical:Height") + attribute_value: string; // JSON stringified Attribute + attr_meta: string; + synced?: number; +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToCharacter(dbChar: DBCharacter, attributes: DBCharacterAttribute[] = []): CharacterProps { + // Group attributes by section + const physical: Attribute[] = []; + const psychological: Attribute[] = []; + const relations: Attribute[] = []; + const skills: Attribute[] = []; + const weaknesses: Attribute[] = []; + const strengths: Attribute[] = []; + const goals: Attribute[] = []; + const motivations: Attribute[] = []; + + for (const attr of attributes) { + try { + const parsedValue: Attribute = JSON.parse(attr.attribute_value); + const section = attr.attribute_name.split(':')[0]; + + switch (section) { + case 'physical': + physical.push(parsedValue); + break; + case 'psychological': + psychological.push(parsedValue); + break; + case 'relations': + relations.push(parsedValue); + break; + case 'skills': + skills.push(parsedValue); + break; + case 'weaknesses': + weaknesses.push(parsedValue); + break; + case 'strengths': + strengths.push(parsedValue); + break; + case 'goals': + goals.push(parsedValue); + break; + case 'motivations': + motivations.push(parsedValue); + break; + } + } catch (error) { + console.error('Failed to parse character attribute:', error); + } + } + + return { + id: dbChar.character_id, + name: dbChar.first_name, + lastName: dbChar.last_name || '', + category: dbChar.category as any, + title: dbChar.title || '', + image: dbChar.image || '', + physical, + psychological, + relations, + skills, + weaknesses, + strengths, + goals, + motivations, + role: dbChar.role || '', + biography: dbChar.biography, + history: dbChar.history + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function characterToDb(character: CharacterProps, bookId: string, userId: string, synced: number = 0): DBCharacter { + return { + character_id: character.id || crypto.randomUUID(), + book_id: bookId, + user_id: userId, + first_name: character.name, + last_name: character.lastName, + category: character.category, + title: character.title, + image: character.image, + role: character.role, + biography: character.biography, + history: character.history, + char_meta: '', + synced + }; +} + +export function characterAttributesToDb( + character: CharacterProps, + userId: string, + synced: number = 0 +): DBCharacterAttribute[] { + const attributes: DBCharacterAttribute[] = []; + + const addAttributes = (section: string, attrs: Attribute[]) => { + for (const attr of attrs) { + attributes.push({ + attr_id: attr.id || crypto.randomUUID(), + character_id: character.id || '', + user_id: userId, + attribute_name: `${section}:${attr.name}`, + attribute_value: JSON.stringify(attr), + attr_meta: '', + synced + }); + } + }; + + addAttributes('physical', character.physical || []); + addAttributes('psychological', character.psychological || []); + addAttributes('relations', character.relations || []); + addAttributes('skills', character.skills || []); + addAttributes('weaknesses', character.weaknesses || []); + addAttributes('strengths', character.strengths || []); + addAttributes('goals', character.goals || []); + addAttributes('motivations', character.motivations || []); + + return attributes; +} diff --git a/electron/database/mappers/user.mapper.ts b/electron/database/mappers/user.mapper.ts new file mode 100644 index 0000000..42004b0 --- /dev/null +++ b/electron/database/mappers/user.mapper.ts @@ -0,0 +1,153 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface Subscription { + subType: string; + subTier: number; + status: boolean; +} + +export interface UserProps { + id: string; + name: string; + lastName: string; + username: string; + authorName?: string; + email?: string; + accountVerified: boolean; + termsAccepted: boolean; + aiUsage: number; + apiKeys: { + gemini: boolean; + openai: boolean; + anthropic: boolean; + }; + books?: any[]; + guideTour?: { [key: string]: boolean }[]; + subscription?: Subscription[]; + writingLang: number; + writingLevel: number; + ritePoints: number; + creditsBalance: number; + groupId: number; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBUser { + user_id: string; + first_name: string; + last_name: string; + username: string; + email: string; + origin_email: string; + origin_username: string; + author_name?: string; + origin_author_name?: string; + plateform: string; + social_id?: string; + user_group: number; + password?: string; + term_accepted: number; + verify_code?: string; + reg_date: number; + account_verified: number; + user_meta: string; // JSON containing apiKeys, guideTour, writingLang, writingLevel, aiUsage + erite_points: number; + stripe_customer_id?: string; + credits_balance: number; + synced?: number; +} + +interface UserMeta { + apiKeys?: { + gemini: boolean; + openai: boolean; + anthropic: boolean; + }; + guideTour?: { [key: string]: boolean }[]; + subscription?: Subscription[]; + writingLang?: number; + writingLevel?: number; + aiUsage?: number; +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToUser(dbUser: DBUser): UserProps { + let meta: UserMeta = {}; + try { + meta = JSON.parse(dbUser.user_meta || '{}'); + } catch (error) { + console.error('Failed to parse user_meta:', error); + } + + return { + id: dbUser.user_id, + name: dbUser.first_name, + lastName: dbUser.last_name, + username: dbUser.username, + authorName: dbUser.author_name, + email: dbUser.email, + accountVerified: dbUser.account_verified === 1, + termsAccepted: dbUser.term_accepted === 1, + aiUsage: meta.aiUsage || 0, + apiKeys: meta.apiKeys || { + gemini: false, + openai: false, + anthropic: false + }, + books: [], // Populated separately + guideTour: meta.guideTour || [], + subscription: meta.subscription || [], + writingLang: meta.writingLang || 1, + writingLevel: meta.writingLevel || 1, + ritePoints: dbUser.erite_points, + creditsBalance: dbUser.credits_balance, + groupId: dbUser.user_group + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function userToDb(user: UserProps, synced: number = 0): DBUser { + const meta: UserMeta = { + apiKeys: user.apiKeys, + guideTour: user.guideTour, + subscription: user.subscription, + writingLang: user.writingLang, + writingLevel: user.writingLevel, + aiUsage: user.aiUsage + }; + + return { + user_id: user.id, + first_name: user.name, + last_name: user.lastName, + username: user.username, + email: user.email || '', + origin_email: user.email || '', + origin_username: user.username, + author_name: user.authorName, + origin_author_name: user.authorName, + plateform: 'electron', + social_id: undefined, + user_group: user.groupId, + password: undefined, + term_accepted: user.termsAccepted ? 1 : 0, + verify_code: undefined, + reg_date: Date.now(), + account_verified: user.accountVerified ? 1 : 0, + user_meta: JSON.stringify(meta), + erite_points: user.ritePoints, + stripe_customer_id: undefined, + credits_balance: user.creditsBalance, + synced + }; +} diff --git a/electron/database/mappers/world.mapper.ts b/electron/database/mappers/world.mapper.ts new file mode 100644 index 0000000..5002dcb --- /dev/null +++ b/electron/database/mappers/world.mapper.ts @@ -0,0 +1,222 @@ +/** + * TypeScript interfaces (copied from lib/models for type safety) + */ + +export interface WorldElement { + id: string; + name: string; + description: string; +} + +export interface WorldProps { + id: string; + name: string; + history: string; + politics: string; + economy: string; + religion: string; + languages: string; + laws?: WorldElement[]; + biomes?: WorldElement[]; + issues?: WorldElement[]; + customs?: WorldElement[]; + kingdoms?: WorldElement[]; + climate?: WorldElement[]; + resources?: WorldElement[]; + wildlife?: WorldElement[]; + arts?: WorldElement[]; + ethnicGroups?: WorldElement[]; + socialClasses?: WorldElement[]; + importantCharacters?: WorldElement[]; +} + +/** + * Database row types (snake_case from SQLite) + */ +export interface DBWorld { + world_id: string; + name: string; + hashed_name: string; + author_id: string; + book_id: string; + history?: string; + politics?: string; + economy?: string; + religion?: string; + languages?: string; + meta_world: string; + synced?: number; +} + +export interface DBWorldElement { + element_id: string; + world_id: string; + user_id: string; + element_type: number; // Type identifier for different element categories + name: string; + original_name: string; + description?: string; + meta_element: string; + synced?: number; +} + +// Element type constants +export enum WorldElementType { + LAW = 1, + BIOME = 2, + ISSUE = 3, + CUSTOM = 4, + KINGDOM = 5, + CLIMATE = 6, + RESOURCE = 7, + WILDLIFE = 8, + ART = 9, + ETHNIC_GROUP = 10, + SOCIAL_CLASS = 11, + IMPORTANT_CHARACTER = 12 +} + +/** + * MAPPERS: DB → TypeScript Interfaces + */ + +export function dbToWorld(dbWorld: DBWorld, elements: DBWorldElement[] = []): WorldProps { + // Group elements by type + const laws: WorldElement[] = []; + const biomes: WorldElement[] = []; + const issues: WorldElement[] = []; + const customs: WorldElement[] = []; + const kingdoms: WorldElement[] = []; + const climate: WorldElement[] = []; + const resources: WorldElement[] = []; + const wildlife: WorldElement[] = []; + const arts: WorldElement[] = []; + const ethnicGroups: WorldElement[] = []; + const socialClasses: WorldElement[] = []; + const importantCharacters: WorldElement[] = []; + + for (const elem of elements) { + const worldElement: WorldElement = { + id: elem.element_id, + name: elem.name, + description: elem.description || '' + }; + + switch (elem.element_type) { + case WorldElementType.LAW: + laws.push(worldElement); + break; + case WorldElementType.BIOME: + biomes.push(worldElement); + break; + case WorldElementType.ISSUE: + issues.push(worldElement); + break; + case WorldElementType.CUSTOM: + customs.push(worldElement); + break; + case WorldElementType.KINGDOM: + kingdoms.push(worldElement); + break; + case WorldElementType.CLIMATE: + climate.push(worldElement); + break; + case WorldElementType.RESOURCE: + resources.push(worldElement); + break; + case WorldElementType.WILDLIFE: + wildlife.push(worldElement); + break; + case WorldElementType.ART: + arts.push(worldElement); + break; + case WorldElementType.ETHNIC_GROUP: + ethnicGroups.push(worldElement); + break; + case WorldElementType.SOCIAL_CLASS: + socialClasses.push(worldElement); + break; + case WorldElementType.IMPORTANT_CHARACTER: + importantCharacters.push(worldElement); + break; + } + } + + return { + id: dbWorld.world_id, + name: dbWorld.name, + history: dbWorld.history || '', + politics: dbWorld.politics || '', + economy: dbWorld.economy || '', + religion: dbWorld.religion || '', + languages: dbWorld.languages || '', + laws, + biomes, + issues, + customs, + kingdoms, + climate, + resources, + wildlife, + arts, + ethnicGroups, + socialClasses, + importantCharacters + }; +} + +/** + * MAPPERS: TypeScript Interfaces → DB + */ + +export function worldToDb(world: WorldProps, authorId: string, bookId: string, synced: number = 0): DBWorld { + return { + world_id: world.id, + name: world.name, + hashed_name: '', + author_id: authorId, + book_id: bookId, + history: world.history, + politics: world.politics, + economy: world.economy, + religion: world.religion, + languages: world.languages, + meta_world: '', + synced + }; +} + +export function worldElementsToDb(world: WorldProps, userId: string, synced: number = 0): DBWorldElement[] { + const elements: DBWorldElement[] = []; + + const addElements = (type: WorldElementType, elems: WorldElement[]) => { + for (const elem of elems) { + elements.push({ + element_id: elem.id, + world_id: world.id, + user_id: userId, + element_type: type, + name: elem.name, + original_name: elem.name, + description: elem.description, + meta_element: '', + synced + }); + } + }; + + addElements(WorldElementType.LAW, world.laws || []); + addElements(WorldElementType.BIOME, world.biomes || []); + addElements(WorldElementType.ISSUE, world.issues || []); + addElements(WorldElementType.CUSTOM, world.customs || []); + addElements(WorldElementType.KINGDOM, world.kingdoms || []); + addElements(WorldElementType.CLIMATE, world.climate || []); + addElements(WorldElementType.RESOURCE, world.resources || []); + addElements(WorldElementType.WILDLIFE, world.wildlife || []); + addElements(WorldElementType.ART, world.arts || []); + addElements(WorldElementType.ETHNIC_GROUP, world.ethnicGroups || []); + addElements(WorldElementType.SOCIAL_CLASS, world.socialClasses || []); + addElements(WorldElementType.IMPORTANT_CHARACTER, world.importantCharacters || []); + + return elements; +} diff --git a/electron/database/schema.ts b/electron/database/schema.ts new file mode 100644 index 0000000..efc04b0 --- /dev/null +++ b/electron/database/schema.ts @@ -0,0 +1,525 @@ +import sqlite3 from 'node-sqlite3-wasm'; + +type Database = sqlite3.Database; + +/** + * SQLite schema based on the MySQL erit_main_db schema + * All tables use snake_case naming to match the server database + * Data is encrypted before storage and decrypted on retrieval + */ + +export const SCHEMA_VERSION = 1; + +/** + * Initialize the local SQLite database with all required tables + * @param db - SQLite database instance + */ +export function initializeSchema(db: Database): void { + // Enable foreign keys + db.exec('PRAGMA foreign_keys = ON'); + + // Create sync metadata table (tracks last sync times) + db.exec(` + CREATE TABLE IF NOT EXISTS _sync_metadata ( + table_name TEXT PRIMARY KEY, + last_sync_at INTEGER NOT NULL, + last_push_at INTEGER, + pending_changes INTEGER DEFAULT 0 + ); + `); + + // Create pending changes queue (for offline operations) + db.exec(` + CREATE TABLE IF NOT EXISTS _pending_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + operation TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' + record_id TEXT NOT NULL, + data TEXT, -- JSON data for INSERT/UPDATE + created_at INTEGER NOT NULL, + retry_count INTEGER DEFAULT 0 + ); + `); + + // AI Conversations + db.exec(` + CREATE TABLE IF NOT EXISTS ai_conversations ( + conversation_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + mode TEXT NOT NULL, + title TEXT NOT NULL, + start_date INTEGER NOT NULL, + status INTEGER NOT NULL, + user_id TEXT NOT NULL, + summary TEXT, + convo_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // AI Messages History + db.exec(` + CREATE TABLE IF NOT EXISTS ai_messages_history ( + message_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + message TEXT NOT NULL, + message_date INTEGER NOT NULL, + meta_message TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (conversation_id) REFERENCES ai_conversations(conversation_id) ON DELETE CASCADE + ); + `); + + // Book Acts + db.exec(` + CREATE TABLE IF NOT EXISTS book_acts ( + act_id INTEGER PRIMARY KEY, + title TEXT NOT NULL + ); + `); + + // Book Act Summaries + db.exec(` + CREATE TABLE IF NOT EXISTS book_act_summaries ( + act_sum_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + act_index INTEGER NOT NULL, + summary TEXT, + meta_acts TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book AI Guide Line + db.exec(` + CREATE TABLE IF NOT EXISTS book_ai_guide_line ( + user_id TEXT NOT NULL, + book_id TEXT NOT NULL, + global_resume TEXT, + themes TEXT, + verbe_tense INTEGER, + narrative_type INTEGER, + langue INTEGER, + dialogue_type INTEGER, + tone TEXT, + atmosphere TEXT, + current_resume TEXT, + meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + PRIMARY KEY (user_id, book_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Chapters + db.exec(` + CREATE TABLE IF NOT EXISTS book_chapters ( + chapter_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + author_id TEXT NOT NULL, + title TEXT NOT NULL, + hashed_title TEXT, + words_count INTEGER, + chapter_order INTEGER, + meta_chapter TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Chapter Content + db.exec(` + CREATE TABLE IF NOT EXISTS book_chapter_content ( + content_id TEXT PRIMARY KEY, + chapter_id TEXT NOT NULL, + author_id TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 2, + content TEXT NOT NULL, + words_count INTEGER NOT NULL, + meta_chapter_content TEXT NOT NULL, + time_on_it INTEGER NOT NULL DEFAULT 0, + synced INTEGER DEFAULT 0, + FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE + ); + `); + + // Book Chapter Infos + db.exec(` + CREATE TABLE IF NOT EXISTS book_chapter_infos ( + chapter_info_id TEXT PRIMARY KEY, + chapter_id TEXT, + act_id INTEGER, + incident_id TEXT, + plot_point_id TEXT, + book_id TEXT, + author_id TEXT, + summary TEXT NOT NULL, + goal TEXT NOT NULL, + meta_chapter_info TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE, + FOREIGN KEY (incident_id) REFERENCES book_incidents(incident_id) ON DELETE CASCADE, + FOREIGN KEY (plot_point_id) REFERENCES book_plot_points(plot_point_id) ON DELETE CASCADE + ); + `); + + // Book Characters + db.exec(` + CREATE TABLE IF NOT EXISTS book_characters ( + character_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + char_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Character Attributes + db.exec(` + CREATE TABLE IF NOT EXISTS book_characters_attributes ( + attr_id TEXT PRIMARY KEY, + character_id TEXT NOT NULL, + user_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + attribute_value TEXT NOT NULL, + attr_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES book_characters(character_id) ON DELETE CASCADE + ); + `); + + // Book Character Relations + db.exec(` + CREATE TABLE IF NOT EXISTS book_characters_relations ( + rel_id INTEGER PRIMARY KEY, + character_id INTEGER NOT NULL, + char_name TEXT NOT NULL, + type TEXT NOT NULL, + description TEXT NOT NULL, + history TEXT NOT NULL, + synced INTEGER DEFAULT 0 + ); + `); + + // Book Guide Line + db.exec(` + CREATE TABLE IF NOT EXISTS book_guide_line ( + user_id TEXT NOT NULL, + book_id TEXT NOT NULL, + tone TEXT NOT NULL, + atmosphere TEXT NOT NULL, + writing_style TEXT NOT NULL, + themes TEXT NOT NULL, + symbolism TEXT NOT NULL, + motifs TEXT NOT NULL, + narrative_voice TEXT NOT NULL, + pacing TEXT NOT NULL, + intended_audience TEXT NOT NULL, + key_messages TEXT NOT NULL, + meta_guide_line TEXT NOT NULL, + synced INTEGER DEFAULT 0, + PRIMARY KEY (user_id, book_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Incidents + db.exec(` + CREATE TABLE IF NOT EXISTS book_incidents ( + incident_id TEXT PRIMARY KEY, + author_id TEXT NOT NULL, + book_id TEXT NOT NULL, + title TEXT NOT NULL, + hashed_title TEXT NOT NULL, + summary TEXT, + meta_incident TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Issues + db.exec(` + CREATE TABLE IF NOT EXISTS book_issues ( + issue_id TEXT PRIMARY KEY, + author_id TEXT NOT NULL, + book_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_issue_name TEXT NOT NULL, + meta_issue TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Location + db.exec(` + CREATE TABLE IF NOT EXISTS book_location ( + loc_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + loc_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book Plot Points + db.exec(` + CREATE TABLE IF NOT EXISTS book_plot_points ( + plot_point_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + hashed_title TEXT NOT NULL, + summary TEXT, + linked_incident_id TEXT, + author_id TEXT NOT NULL, + book_id TEXT NOT NULL, + meta_plot TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book World + db.exec(` + CREATE TABLE IF NOT EXISTS book_world ( + world_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + author_id TEXT NOT NULL, + book_id TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + meta_world TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + + // Book World Elements + db.exec(` + CREATE TABLE IF NOT EXISTS book_world_elements ( + element_id TEXT PRIMARY KEY, + world_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_type INTEGER NOT NULL, + name TEXT NOT NULL, + original_name TEXT NOT NULL, + description TEXT, + meta_element TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES book_world(world_id) ON DELETE CASCADE + ); + `); + + // Erit Books + db.exec(` + CREATE TABLE IF NOT EXISTS erit_books ( + book_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + author_id TEXT NOT NULL, + title TEXT NOT NULL, + hashed_title TEXT NOT NULL, + sub_title TEXT, + hashed_sub_title TEXT NOT NULL, + summary TEXT NOT NULL, + serie_id INTEGER, + desired_release_date TEXT, + desired_word_count INTEGER, + words_count INTEGER, + cover_image TEXT, + book_meta TEXT, + synced INTEGER DEFAULT 0 + ); + `); + + // Erit Book Series + db.exec(` + CREATE TABLE IF NOT EXISTS erit_book_series ( + serie_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + author_id INTEGER NOT NULL + ); + `); + + // Erit Editor Settings + db.exec(` + CREATE TABLE IF NOT EXISTS erit_editor ( + user_id TEXT, + type TEXT NOT NULL, + text_size INTEGER NOT NULL, + text_intent INTEGER NOT NULL, + interline TEXT NOT NULL, + paper_width INTEGER NOT NULL, + theme TEXT NOT NULL, + focus INTEGER NOT NULL, + synced INTEGER DEFAULT 0 + ); + `); + + // Erit Users + db.exec(` + CREATE TABLE IF NOT EXISTS erit_users ( + user_id TEXT PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + username TEXT NOT NULL, + email TEXT NOT NULL, + origin_email TEXT NOT NULL, + origin_username TEXT NOT NULL, + author_name TEXT, + origin_author_name TEXT, + plateform TEXT NOT NULL, + social_id TEXT, + user_group INTEGER NOT NULL DEFAULT 4, + password TEXT, + term_accepted INTEGER NOT NULL DEFAULT 0, + verify_code TEXT, + reg_date INTEGER NOT NULL, + account_verified INTEGER NOT NULL DEFAULT 0, + user_meta TEXT NOT NULL, + erite_points INTEGER NOT NULL DEFAULT 100, + stripe_customer_id TEXT, + credits_balance REAL DEFAULT 0, + synced INTEGER DEFAULT 0 + ); + `); + + // Location Element + db.exec(` + CREATE TABLE IF NOT EXISTS location_element ( + element_id TEXT PRIMARY KEY, + location TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + element_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (location) REFERENCES book_location(loc_id) ON DELETE CASCADE + ); + `); + + // Location Sub Element + db.exec(` + CREATE TABLE IF NOT EXISTS location_sub_element ( + sub_element_id TEXT PRIMARY KEY, + element_id TEXT NOT NULL, + user_id TEXT NOT NULL, + sub_elem_name TEXT NOT NULL, + original_name TEXT NOT NULL, + sub_elem_description TEXT, + sub_elem_meta TEXT NOT NULL, + synced INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES location_element(element_id) ON DELETE CASCADE + ); + `); + + // User Keys + db.exec(` + CREATE TABLE IF NOT EXISTS user_keys ( + user_id TEXT NOT NULL, + brand TEXT NOT NULL, + key TEXT NOT NULL, + actif INTEGER NOT NULL DEFAULT 1, + synced INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + ); + `); + + // User Last Chapter + db.exec(` + CREATE TABLE IF NOT EXISTS user_last_chapter ( + user_id TEXT NOT NULL, + book_id TEXT NOT NULL, + chapter_id TEXT NOT NULL, + version INTEGER NOT NULL, + synced INTEGER DEFAULT 0, + PRIMARY KEY (user_id, book_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE, + FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE + ); + `); + + // Create indexes for better performance + createIndexes(db); + + // Initialize sync metadata for all tables + initializeSyncMetadata(db); +} + +/** + * Create indexes for frequently queried columns + */ +function createIndexes(db: Database): void { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_conversations_book ON ai_conversations(book_id); + CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id); + CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages_history(conversation_id); + CREATE INDEX IF NOT EXISTS idx_chapters_book ON book_chapters(book_id); + CREATE INDEX IF NOT EXISTS idx_chapter_content_chapter ON book_chapter_content(chapter_id); + CREATE INDEX IF NOT EXISTS idx_characters_book ON book_characters(book_id); + CREATE INDEX IF NOT EXISTS idx_character_attrs_character ON book_characters_attributes(character_id); + CREATE INDEX IF NOT EXISTS idx_world_book ON book_world(book_id); + CREATE INDEX IF NOT EXISTS idx_world_elements_world ON book_world_elements(world_id); + CREATE INDEX IF NOT EXISTS idx_pending_changes_table ON _pending_changes(table_name); + CREATE INDEX IF NOT EXISTS idx_pending_changes_created ON _pending_changes(created_at); + `); +} + +/** + * Initialize sync metadata for all tables + */ +function initializeSyncMetadata(db: Database): void { + const tables = [ + 'ai_conversations', 'ai_messages_history', 'book_acts', 'book_act_summaries', + 'book_ai_guide_line', 'book_chapters', 'book_chapter_content', 'book_chapter_infos', + 'book_characters', 'book_characters_attributes', 'book_guide_line', 'book_incidents', + 'book_issues', 'book_location', 'book_plot_points', 'book_world', 'book_world_elements', + 'erit_books', 'erit_editor', 'erit_users', 'location_element', 'location_sub_element', + 'user_keys', 'user_last_chapter' + ]; + + for (const table of tables) { + db.run(` + INSERT OR IGNORE INTO _sync_metadata (table_name, last_sync_at, pending_changes) + VALUES (?, 0, 0) + `, [table]); + } +} + +/** + * Drop all tables (for testing/reset) + */ +export function dropAllTables(db: Database): void { + const tables = db.all(` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + `, []) as unknown as { name: string }[]; + + db.exec('PRAGMA foreign_keys = OFF'); + + for (const { name } of tables) { + db.exec(`DROP TABLE IF EXISTS ${name}`); + } + + db.exec('PRAGMA foreign_keys = ON'); +} diff --git a/lib/services/offline-data.service.ts b/lib/services/offline-data.service.ts index 0eee834..56b87cd 100644 --- a/lib/services/offline-data.service.ts +++ b/lib/services/offline-data.service.ts @@ -2,226 +2,394 @@ import { BookProps, BookListProps } from '@/lib/models/Book'; import { CharacterProps } from '@/lib/models/Character'; import { Conversation } from '@/lib/models/QuillSense'; import { WorldProps } from '@/lib/models/World'; +import { ChapterProps } from '@/lib/models/Chapter'; /** - * Service pour gérer les données avec cache local - * Sauvegarde automatiquement dans la DB locale (Electron) quand disponible + * OfflineDataService - Manages data retrieval with offline/online mode + * Automatically caches to local DB when online, serves from local DB when offline */ +export class OfflineDataService { + private isOffline: boolean = false; -/** - * Get all books - from local DB if offline, from server otherwise - */ -export async function getBooks(fetchFromServer: () => Promise): Promise { - if (!window.electron) { - return await fetchFromServer(); + /** + * Set offline status (called by OfflineProvider) + */ + setOfflineStatus(offline: boolean): void { + this.isOffline = offline; } - // TODO: Check if offline mode is enabled - const isOffline = false; // Replace with actual offline check + /** + * Get current offline status + */ + getOfflineStatus(): boolean { + return this.isOffline; + } - if (isOffline) { - // Fetch from local DB - const result = await window.electron.dbGetBooks(); - if (result.success) { - return result.data || []; + /** + * Get all books - from local DB if offline, from server otherwise + */ + async getBooks(fetchFromServer: () => Promise): Promise { + if (!window.electron) { + return await fetchFromServer(); } - throw new Error(result.error || 'Failed to get books from local DB'); - } else { - // Fetch from server and save to local DB - const books = await fetchFromServer(); - // Save to local DB in background - for (const book of books) { + if (this.isOffline) { + // Fetch from local DB + const result = await window.electron.dbGetBooks(); + if (result.success && result.data) { + const titles = result.data.map(b => b.title).join(', '); + console.log(`📚 Books from LOCAL DB: ${titles}`); + return result.data; + } + throw new Error(result.error || 'Failed to get books from local DB'); + } else { + // Fetch from server and save to local DB + const books = await fetchFromServer(); + const titles = books.map(b => b.title).join(', '); + console.log(`📚 Books from SERVER: ${titles}`); + + // Save to local DB in background + for (const book of books) { + try { + await window.electron.dbSaveBook(book); + } catch (error) { + console.error('Failed to save book to local DB:', error); + } + } + + return books; + } + } + + /** + * Get single book - from local DB if offline, from server otherwise + */ + async getBook( + bookId: string, + fetchFromServer: () => Promise + ): Promise { + if (!window.electron) { + return await fetchFromServer(); + } + + if (this.isOffline) { + const result = await window.electron.dbGetBook(bookId); + if (result.success && result.data) { + console.log(`📖 "${result.data.title}" from LOCAL DB`); + return result.data; + } + throw new Error(result.error || 'Book not found in local DB'); + } else { + const book = await fetchFromServer(); + console.log(`📖 "${book.title}" from SERVER`); + + // Save to local DB try { await window.electron.dbSaveBook(book); } catch (error) { console.error('Failed to save book to local DB:', error); } - } - return books; - } -} - -/** - * Get single book - from local DB if offline, from server otherwise - */ -export async function getBook( - bookId: string, - fetchFromServer: () => Promise -): Promise { - if (!window.electron) { - return await fetchFromServer(); - } - - const isOffline = false; // Replace with actual offline check - - if (isOffline) { - const result = await window.electron.dbGetBook(bookId); - if (result.success && result.data) { - return result.data; - } - throw new Error(result.error || 'Book not found in local DB'); - } else { - const book = await fetchFromServer(); - - // Save to local DB - try { - await window.electron.dbSaveBook(book); - } catch (error) { - console.error('Failed to save book to local DB:', error); - } - - return book; - } -} - -/** - * Save book - save to local DB and sync to server later if offline - */ -export async function saveBook( - book: BookProps, - authorId: string | undefined, - saveToServer: () => Promise -): Promise { - if (!window.electron) { - return await saveToServer(); - } - - const isOffline = false; // Replace with actual offline check - - // Always save to local DB first - await window.electron.dbSaveBook(book, authorId); - - if (!isOffline) { - // Also save to server - try { - await saveToServer(); - } catch (error) { - console.error('Failed to save to server, will sync later:', error); - // Data is already in local DB, will be synced later + return book; } } -} -/** - * Get characters for a book - */ -export async function getCharacters( - bookId: string, - fetchFromServer: () => Promise -): Promise { - if (!window.electron) { - return await fetchFromServer(); + /** + * Create new book - creates on server if online, local UUID if offline + */ + async createBook( + bookData: Omit, + authorId: string, + createOnServer: () => Promise + ): Promise { + if (!window.electron) { + return await createOnServer(); + } + + if (this.isOffline) { + // Generate local UUID and save to local DB + const localBookId = crypto.randomUUID(); + const book: BookProps = { ...bookData, bookId: localBookId }; + await window.electron.dbSaveBook(book, authorId); + console.log(`💾 Book "${book.title}" created locally (offline mode)`); + return localBookId; + } else { + // Create on server and save to local DB + const serverBookId = await createOnServer(); + const book: BookProps = { ...bookData, bookId: serverBookId }; + await window.electron.dbSaveBook(book, authorId); + return serverBookId; + } } - const isOffline = false; // Replace with actual offline check - - if (isOffline) { - const result = await window.electron.dbGetCharacters(bookId); - if (result.success) { - return result.data || []; + /** + * Save book - save to local DB and sync to server later if offline + */ + async saveBook( + book: BookProps, + authorId: string | undefined, + saveToServer: () => Promise + ): Promise { + if (!window.electron) { + return await saveToServer(); } - throw new Error(result.error || 'Failed to get characters from local DB'); - } else { - const characters = await fetchFromServer(); - // Save to local DB - for (const character of characters) { + // Always save to local DB first + await window.electron.dbSaveBook(book, authorId); + + if (!this.isOffline) { + // Also save to server try { - await window.electron.dbSaveCharacter(character, bookId); + await saveToServer(); } catch (error) { - console.error('Failed to save character to local DB:', error); + console.error('Failed to save to server, will sync later:', error); + // Data is already in local DB, will be synced later } - } - - return characters; - } -} - -/** - * Save character - */ -export async function saveCharacter( - character: CharacterProps, - bookId: string, - saveToServer: () => Promise -): Promise { - if (!window.electron) { - return await saveToServer(); - } - - const isOffline = false; // Replace with actual offline check - - // Always save to local DB first - await window.electron.dbSaveCharacter(character, bookId); - - if (!isOffline) { - try { - await saveToServer(); - } catch (error) { - console.error('Failed to save to server, will sync later:', error); + } else { + console.log(`💾 Book queued for sync (offline mode)`); } } -} -/** - * Get conversations for a book - */ -export async function getConversations( - bookId: string, - fetchFromServer: () => Promise -): Promise { - if (!window.electron) { - return await fetchFromServer(); + /** + * Get characters for a book + */ + async getCharacters( + bookId: string, + fetchFromServer: () => Promise + ): Promise { + if (!window.electron) { + return await fetchFromServer(); + } + + if (this.isOffline) { + const result = await window.electron.dbGetCharacters(bookId); + if (result.success && result.data) { + const names = result.data.map(c => c.name).join(', '); + console.log(`👤 Characters from LOCAL DB: ${names}`); + return result.data; + } + throw new Error(result.error || 'Failed to get characters from local DB'); + } else { + const characters = await fetchFromServer(); + const names = characters.map(c => c.name).join(', '); + console.log(`👤 Characters from SERVER: ${names}`); + + // Save to local DB + for (const character of characters) { + try { + await window.electron.dbSaveCharacter(character, bookId); + } catch (error) { + console.error('Failed to save character to local DB:', error); + } + } + + return characters; + } } - const isOffline = false; // Replace with actual offline check - - if (isOffline) { - const result = await window.electron.dbGetConversations(bookId); - if (result.success) { - return result.data || []; + /** + * Create character + */ + async createCharacter( + characterData: Omit, + bookId: string, + createOnServer: () => Promise + ): Promise { + if (!window.electron) { + return await createOnServer(); } - throw new Error(result.error || 'Failed to get conversations from local DB'); - } else { - const conversations = await fetchFromServer(); - // Save to local DB - for (const conversation of conversations) { + if (this.isOffline) { + const localId = crypto.randomUUID(); + const character = { ...characterData, id: localId }; + await window.electron.dbSaveCharacter(character, bookId); + console.log(`💾 Character "${character.name}" created locally (offline mode)`); + return localId; + } else { + const serverId = await createOnServer(); + const character = { ...characterData, id: serverId }; + await window.electron.dbSaveCharacter(character, bookId); + return serverId; + } + } + + /** + * Save character + */ + async saveCharacter( + character: CharacterProps, + bookId: string, + saveToServer: () => Promise + ): Promise { + if (!window.electron) { + return await saveToServer(); + } + + // Always save to local DB first + await window.electron.dbSaveCharacter(character, bookId); + + if (!this.isOffline) { try { - await window.electron.dbSaveConversation(conversation, bookId); + await saveToServer(); } catch (error) { - console.error('Failed to save conversation to local DB:', error); + console.error('Failed to save to server, will sync later:', error); } + } else { + console.log(`💾 Character queued for sync (offline mode)`); + } + } + + /** + * Get conversations for a book + */ + async getConversations( + bookId: string, + fetchFromServer: () => Promise + ): Promise { + if (!window.electron) { + return await fetchFromServer(); } - return conversations; + if (this.isOffline) { + const result = await window.electron.dbGetConversations(bookId); + if (result.success) { + const titles = result.data?.map((c: any) => c.title).join(', ') || 'none'; + console.log(`💬 Conversations from LOCAL DB: ${titles}`); + return result.data || []; + } + throw new Error(result.error || 'Failed to get conversations from local DB'); + } else { + const conversations = await fetchFromServer(); + const titles = conversations.map(c => c.title).join(', '); + console.log(`💬 Conversations from SERVER: ${titles}`); + + // Save to local DB + for (const conversation of conversations) { + try { + await window.electron.dbSaveConversation(conversation, bookId); + } catch (error) { + console.error('Failed to save conversation to local DB:', error); + } + } + + return conversations; + } + } + + /** + * Create conversation + */ + async createConversation( + conversationData: Omit, + bookId: string, + createOnServer: () => Promise + ): Promise { + if (!window.electron) { + return await createOnServer(); + } + + if (this.isOffline) { + const localId = crypto.randomUUID(); + const conversation = { ...conversationData, id: localId }; + await window.electron.dbSaveConversation(conversation, bookId); + console.log(`💾 Conversation "${conversation.title}" created locally (offline mode)`); + return localId; + } else { + const serverId = await createOnServer(); + const conversation = { ...conversationData, id: serverId }; + await window.electron.dbSaveConversation(conversation, bookId); + return serverId; + } + } + + /** + * Save conversation + */ + async saveConversation( + conversation: Conversation, + bookId: string, + saveToServer: () => Promise + ): Promise { + if (!window.electron) { + return await saveToServer(); + } + + // Always save to local DB first + await window.electron.dbSaveConversation(conversation, bookId); + + if (!this.isOffline) { + try { + await saveToServer(); + } catch (error) { + console.error('Failed to save to server, will sync later:', error); + } + } else { + console.log(`💾 Conversation queued for sync (offline mode)`); + } + } + + /** + * Create chapter + */ + async createChapter( + chapterData: Omit, + bookId: string, + createOnServer: () => Promise + ): Promise { + if (!window.electron) { + return await createOnServer(); + } + + if (this.isOffline) { + const localId = crypto.randomUUID(); + const chapter = { ...chapterData, chapterId: localId }; + await window.electron.dbSaveChapter(chapter, bookId); + console.log(`💾 Chapter "${chapter.title}" created locally (offline mode)`); + return localId; + } else { + const serverId = await createOnServer(); + const chapter = { ...chapterData, chapterId: serverId }; + await window.electron.dbSaveChapter(chapter, bookId); + return serverId; + } + } + + /** + * Save chapter content + */ + async saveChapter( + chapter: ChapterProps, + bookId: string, + saveToServer: () => Promise + ): Promise { + if (!window.electron) { + return await saveToServer(); + } + + // Always save to local DB first + await window.electron.dbSaveChapter(chapter, bookId); + + if (!this.isOffline) { + try { + await saveToServer(); + } catch (error) { + console.error('Failed to save to server, will sync later:', error); + } + } else { + console.log(`💾 Chapter "${chapter.title}" queued for sync (offline mode)`); + } } } +// Singleton instance +let offlineDataServiceInstance: OfflineDataService | null = null; + /** - * Save conversation + * Get OfflineDataService singleton instance */ -export async function saveConversation( - conversation: Conversation, - bookId: string, - saveToServer: () => Promise -): Promise { - if (!window.electron) { - return await saveToServer(); - } - - const isOffline = false; // Replace with actual offline check - - // Always save to local DB first - await window.electron.dbSaveConversation(conversation, bookId); - - if (!isOffline) { - try { - await saveToServer(); - } catch (error) { - console.error('Failed to save to server, will sync later:', error); - } +export function getOfflineDataService(): OfflineDataService { + if (!offlineDataServiceInstance) { + offlineDataServiceInstance = new OfflineDataService(); } + return offlineDataServiceInstance; }