From 2e6b30c6326c9243833ce2f202d2584b9b3e4d6f Mon Sep 17 00:00:00 2001 From: natreex Date: Thu, 15 Jan 2026 18:35:48 -0500 Subject: [PATCH] Add support for syncing tool settings with `lastUpdate` and improve consistency - Introduced `lastUpdate` field in `book_tools` for tracking changes. - Refactored tool enablement logic in `CharacterComponent`, `WorldSetting`, and `LocationComponent` for consistency. - Updated database schema and migration scripts for `book_tools` table. - Enhanced synchronization workflows to support new `lastUpdate` logic. - Adjusted related models, repositories, and IPC handlers for streamlined management. - Improved type safety and robustness in tool-related methods with additional checks. --- app/login/LoginWrapper.tsx | 2 + components/book/AddNewBookForm.tsx | 6 +- .../characters/CharacterComponent.tsx | 12 +- .../settings/locations/LocationComponent.tsx | 12 +- .../book/settings/world/WorldSetting.tsx | 12 +- electron/database/models/Book.ts | 14 +- electron/database/models/Download.ts | 2 +- electron/database/models/Sync.ts | 28 +- .../database/repositories/book.repository.ts | 56 ++-- electron/database/schema.ts | 309 ++++++++---------- lib/models/Book.ts | 6 +- lib/models/SyncedBook.ts | 19 +- 12 files changed, 252 insertions(+), 226 deletions(-) diff --git a/app/login/LoginWrapper.tsx b/app/login/LoginWrapper.tsx index 1898e75..eaec043 100644 --- a/app/login/LoginWrapper.tsx +++ b/app/login/LoginWrapper.tsx @@ -20,6 +20,8 @@ export default function LoginWrapper({children}: { children: React.ReactNode }) const [locale, setLocale] = useState<'fr' | 'en'>('fr'); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); + const [infoMessage, setInfoMessage] = useState(''); + const [warningMessage, setWarningMessage] = useState(''); const messages = messagesMap[locale]; const [session, setSession] = useState({ diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index 78128fe..332fb83 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -169,7 +169,8 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< issues: [], actSummaries: [], guideLine: null, - aiGuideLine: null + aiGuideLine: null, + bookTools: null }]); } else { @@ -188,7 +189,8 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< issues: [], actSummaries: [], guideLine: null, - aiGuideLine: null + aiGuideLine: null, + bookTools: null }]); } diff --git a/components/book/settings/characters/CharacterComponent.tsx b/components/book/settings/characters/CharacterComponent.tsx index 3b69b6a..ae09060 100644 --- a/components/book/settings/characters/CharacterComponent.tsx +++ b/components/book/settings/characters/CharacterComponent.tsx @@ -96,7 +96,11 @@ export function CharacterComponent({showToggle = true}: {showToggle?: boolean}, } if (response && setBook && book) { setToolEnabled(enabled); - setBook({...book, tools: {...book.tools, characters: enabled}}); + setBook({...book, tools: { + characters: enabled, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false + }}); } } catch (e: unknown) { if (e instanceof Error) { @@ -123,7 +127,11 @@ export function CharacterComponent({showToggle = true}: {showToggle?: boolean}, setCharacters(response.characters); setToolEnabled(response.enabled); if (setBook && book) { - setBook({...book, tools: {...book.tools, characters: response.enabled}}); + setBook({...book, tools: { + characters: response.enabled, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false + }}); } } } catch (e: unknown) { diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx index 4ac3b45..f49a5a4 100644 --- a/components/book/settings/locations/LocationComponent.tsx +++ b/components/book/settings/locations/LocationComponent.tsx @@ -95,7 +95,11 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } if (response && setBook && book) { setToolEnabled(enabled); - setBook({...book, tools: {...book.tools, locations: enabled}}); + setBook({...book, tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: enabled + }}); } } catch (e: unknown) { if (e instanceof Error) { @@ -122,7 +126,11 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r setSections(response.locations); setToolEnabled(response.enabled); if (setBook && book) { - setBook({...book, tools: {...book.tools, locations: response.enabled}}); + setBook({...book, tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: response.enabled + }}); } } } catch (e: unknown) { diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx index 45cbffb..b2989ee 100644 --- a/components/book/settings/world/WorldSetting.tsx +++ b/components/book/settings/world/WorldSetting.tsx @@ -81,7 +81,11 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } if (response && setBook && book) { setToolEnabled(enabled); - setBook({...book, tools: {...book.tools, worlds: enabled}}); + setBook({...book, tools: { + characters: book.tools?.characters ?? false, + worlds: enabled, + locations: book.tools?.locations ?? false + }}); } } catch (e: unknown) { if (e instanceof Error) { @@ -108,7 +112,11 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a setWorlds(response.worlds); setToolEnabled(response.enabled); if (setBook && book) { - setBook({...book, tools: {...book.tools, worlds: response.enabled}}); + setBook({...book, tools: { + characters: book.tools?.characters ?? false, + worlds: response.enabled, + locations: book.tools?.locations ?? false + }}); } const formattedWorlds: SelectBoxProps[] = response.worlds.map( (world: WorldProps): SelectBoxProps => ({ diff --git a/electron/database/models/Book.ts b/electron/database/models/Book.ts index cd1be96..f2070d4 100644 --- a/electron/database/models/Book.ts +++ b/electron/database/models/Book.ts @@ -1,6 +1,6 @@ import System from '../System.js'; import { getUserEncryptionKey } from '../keyManager.js'; -import BookRepo, { BookQuery, BookToolsTable, BookToolsSettings, EritBooksTable } from "../repositories/book.repository.js"; +import BookRepo, { BookQuery, BookToolsTable, EritBooksTable } from "../repositories/book.repository.js"; import { BookActSummariesTable } from "../repositories/act.repository.js"; import { BookAIGuideLineTable, BookGuideLineTable } from "../repositories/guideline.repository.js"; import ChapterRepo, { @@ -35,9 +35,13 @@ import Cover from "./Cover.js"; import UserRepo from "../repositories/user.repository.js"; export interface SyncedBookTools { - charactersEnabled: boolean; - worldsEnabled: boolean; - locationsEnabled: boolean; + lastUpdate: number; +} + +export interface BookToolsSettings { + characters: boolean; + worlds: boolean; + locations: boolean; } export interface BookProps { @@ -308,7 +312,7 @@ export default class Book { public static updateBookToolSetting(userId: string, bookId: string, toolName: 'characters' | 'worlds' | 'locations', enabled: boolean, lang: 'fr' | 'en' = 'fr'): boolean { const columnName: 'characters_enabled' | 'worlds_enabled' | 'locations_enabled' = `${toolName}_enabled` as 'characters_enabled' | 'worlds_enabled' | 'locations_enabled'; - return BookRepo.updateBookToolSetting(userId, bookId, columnName, enabled, lang); + return BookRepo.updateBookToolSetting(userId, bookId, columnName, enabled, System.timeStampInSeconds(), lang); } /** diff --git a/electron/database/models/Download.ts b/electron/database/models/Download.ts index c7a054c..24910f9 100644 --- a/electron/database/models/Download.ts +++ b/electron/database/models/Download.ts @@ -199,7 +199,7 @@ export default class Download { if (!issuesInserted) return false; return data.bookTools.every((bookTool: BookToolsTable): boolean => { - return BookRepo.insertSyncBookTools(bookTool.book_id, userId, bookTool.characters_enabled, bookTool.worlds_enabled, bookTool.locations_enabled, lang); + return BookRepo.insertSyncBookTools(bookTool.book_id, userId, bookTool.characters_enabled, bookTool.worlds_enabled, bookTool.locations_enabled, bookTool.last_update, lang); }); } } diff --git a/electron/database/models/Sync.ts b/electron/database/models/Sync.ts index ec44cab..020b08f 100644 --- a/electron/database/models/Sync.ts +++ b/electron/database/models/Sync.ts @@ -1,7 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import { BookSyncCompare, CompleteBook, SyncedBook, SyncedBookTools } from "./Book.js"; -import BookRepo, { EritBooksTable, SyncedBookResult, BookToolsTable } from "../repositories/book.repository.js"; +import BookRepo, { EritBooksTable, SyncedBookResult, BookToolsTable, SyncedBookToolsResult } from "../repositories/book.repository.js"; import ChapterRepo, { BookChapterInfosTable, BookChaptersTable, @@ -728,19 +728,11 @@ export default class Sync { } } - const serverBookTools: BookToolsTable[] = completeBook.bookTools; - if (serverBookTools && serverBookTools.length > 0) { - for (const serverBookTool of serverBookTools) { - const bookToolsExists: BookToolsTable | null = BookRepo.fetchBookTools(userId, bookId, lang); - if (bookToolsExists) { - BookRepo.updateBookToolSetting(userId, bookId, 'characters_enabled', serverBookTool.characters_enabled === 1, lang); - BookRepo.updateBookToolSetting(userId, bookId, 'worlds_enabled', serverBookTool.worlds_enabled === 1, lang); - BookRepo.updateBookToolSetting(userId, bookId, 'locations_enabled', serverBookTool.locations_enabled === 1, lang); - } else { - const insertSuccessful: boolean = BookRepo.insertSyncBookTools(bookId, userId, serverBookTool.characters_enabled, serverBookTool.worlds_enabled, serverBookTool.locations_enabled, lang); - if (!insertSuccessful) { - return false; - } + if (completeBook.bookTools && completeBook.bookTools.length > 0) { + for (const serverBookTool of completeBook.bookTools) { + const success: boolean = BookRepo.insertSyncBookTools(serverBookTool.book_id, userId, serverBookTool.characters_enabled, serverBookTool.worlds_enabled, serverBookTool.locations_enabled, serverBookTool.last_update, lang); + if (!success) { + return false; } } } @@ -961,11 +953,9 @@ export default class Sync { lastUpdate: aiGuidelineRecord.last_update } : null; - const bookToolsRecord: BookToolsTable | null = BookRepo.fetchBookTools(userId, currentBookId, lang); - const bookTools: SyncedBookTools | null = bookToolsRecord ? { - charactersEnabled: bookToolsRecord.characters_enabled === 1, - worldsEnabled: bookToolsRecord.worlds_enabled === 1, - locationsEnabled: bookToolsRecord.locations_enabled === 1 + const bookToolsQuery: SyncedBookToolsResult | null = BookRepo.fetchSyncedBookTools(userId, currentBookId, lang); + const bookTools: SyncedBookTools | null = bookToolsQuery ? { + lastUpdate: bookToolsQuery.last_update } : null; return { diff --git a/electron/database/repositories/book.repository.ts b/electron/database/repositories/book.repository.ts index 2892559..04d512d 100644 --- a/electron/database/repositories/book.repository.ts +++ b/electron/database/repositories/book.repository.ts @@ -52,12 +52,11 @@ export interface BookToolsTable extends Record { characters_enabled: number; worlds_enabled: number; locations_enabled: number; + last_update: number; } -export interface BookToolsSettings { - characters: boolean; - worlds: boolean; - locations: boolean; +export interface SyncedBookToolsResult extends Record { + last_update: number; } export default class BookRepo { @@ -380,7 +379,7 @@ export default class BookRepo { static fetchBookTools(userId: string, bookId: string, lang: 'fr' | 'en'): BookToolsTable | null { try { const db: Database = System.getDb(); - const query: string = 'SELECT book_id, user_id, characters_enabled, worlds_enabled, locations_enabled FROM book_tools WHERE user_id=? AND book_id=?'; + const query: string = 'SELECT book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, last_update FROM book_tools WHERE user_id=? AND book_id=?'; const params: SQLiteValue[] = [userId, bookId]; const result = db.get(query, params) as BookToolsTable | undefined; return result ?? null; @@ -393,20 +392,20 @@ export default class BookRepo { } } - static updateBookToolSetting(userId: string, bookId: string, toolName: 'characters_enabled' | 'worlds_enabled' | 'locations_enabled', enabled: boolean, lang: 'fr' | 'en'): boolean { + static updateBookToolSetting(userId: string, bookId: string, toolName: 'characters_enabled' | 'worlds_enabled' | 'locations_enabled', enabled: boolean, lastUpdate: number, lang: 'fr' | 'en'): boolean { const enabledValue: number = enabled ? 1 : 0; try { const db: Database = System.getDb(); - const updateQuery: string = `UPDATE book_tools SET ${toolName}=? WHERE user_id=? AND book_id=?`; - const updateResult: RunResult = db.run(updateQuery, [enabledValue, userId, bookId]); + const updateQuery: string = `UPDATE book_tools SET ${toolName}=?, last_update=? WHERE user_id=? AND book_id=?`; + const updateResult: RunResult = db.run(updateQuery, [enabledValue, lastUpdate, userId, bookId]); if (updateResult.changes > 0) { return true; } const charactersValue: number = toolName === 'characters_enabled' ? enabledValue : 0; const worldsValue: number = toolName === 'worlds_enabled' ? enabledValue : 0; const locationsValue: number = toolName === 'locations_enabled' ? enabledValue : 0; - const insertQuery: string = 'INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled) VALUES (?, ?, ?, ?, ?)'; - const insertResult: RunResult = db.run(insertQuery, [bookId, userId, charactersValue, worldsValue, locationsValue]); + const insertQuery: string = 'INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, last_update) VALUES (?, ?, ?, ?, ?, ?)'; + const insertResult: RunResult = db.run(insertQuery, [bookId, userId, charactersValue, worldsValue, locationsValue, lastUpdate]); return insertResult.changes > 0; } catch (error: unknown) { if (error instanceof Error) { @@ -418,26 +417,35 @@ export default class BookRepo { } /** - * Inserts book tools settings during sync. - * @param bookId - The book identifier - * @param userId - The user identifier - * @param charactersEnabled - Whether characters tool is enabled - * @param worldsEnabled - Whether worlds tool is enabled - * @param locationsEnabled - Whether locations tool is enabled - * @param lang - The language for error messages - * @returns true if the insertion was successful + * Upserts book tools settings during sync. + * Inserts if not exists, updates if exists. */ - static insertSyncBookTools(bookId: string, userId: string, charactersEnabled: number, worldsEnabled: number, locationsEnabled: number, lang: 'fr' | 'en'): boolean { + static insertSyncBookTools(bookId: string, userId: string, charactersEnabled: number, worldsEnabled: number, locationsEnabled: number, lastUpdate: number, lang: 'fr' | 'en'): boolean { try { const db: Database = System.getDb(); - const query: string = 'INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled) VALUES (?, ?, ?, ?, ?)'; - const params: SQLiteValue[] = [bookId, userId, charactersEnabled, worldsEnabled, locationsEnabled]; - const insertResult: RunResult = db.run(query, params); - return insertResult.changes > 0; + const query: string = 'INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, last_update) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (book_id, user_id) DO UPDATE SET characters_enabled = excluded.characters_enabled, worlds_enabled = excluded.worlds_enabled, locations_enabled = excluded.locations_enabled, last_update = excluded.last_update'; + const params: SQLiteValue[] = [bookId, userId, charactersEnabled, worldsEnabled, locationsEnabled, lastUpdate]; + db.run(query, params); + return true; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[BookRepository] DB Error: ${error.message}`); + } + return false; + } + } + + static fetchSyncedBookTools(userId: string, bookId: string, lang: 'fr' | 'en'): SyncedBookToolsResult | null { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT last_update FROM book_tools WHERE user_id = ? AND book_id = ?'; + const params: SQLiteValue[] = [userId, bookId]; + const result = db.get(query, params) as SyncedBookToolsResult | undefined; + return result ?? null; } catch (error: unknown) { if (error instanceof Error) { console.error(`DB Error: ${error.message}`); - throw new Error(lang === 'fr' ? "Impossible d'insérer les paramètres des outils." : 'Unable to insert tools settings.'); + throw new Error(lang === 'fr' ? 'Impossible de récupérer les paramètres des outils.' : 'Unable to fetch tools settings.'); } throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); } diff --git a/electron/database/schema.ts b/electron/database/schema.ts index 37675b2..b22c04b 100644 --- a/electron/database/schema.ts +++ b/electron/database/schema.ts @@ -8,7 +8,128 @@ type Database = sqlite3.Database; * Data is encrypted before storage and decrypted on retrieval */ -export const SCHEMA_VERSION = 3; +// ============================================================================= +// MIGRATIONS +// ============================================================================= + +const schemaVersion = 1; + +/** + * DEV ONLY - S'exécute à chaque refresh, pas besoin de version + * Mets ta query, test, efface après + */ +const devQueries: string[] = [ + // "ALTER TABLE book_tools ADD COLUMN last_update INTEGER DEFAULT 0", +]; + +const isDev = process.env.NODE_ENV === 'development'; + +function columnExists(db: Database, table: string, column: string): boolean { + const result = db.all(`PRAGMA table_info(${table})`) as { name: string }[]; + return result?.some((row) => row.name === column) ?? false; +} + +function addColumn(db: Database, table: string, column: string, type: string): void { + if (!columnExists(db, table, column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); + } +} + +function getDbVersion(db: Database): number { + const result = db.get('PRAGMA user_version') as { user_version: number } | undefined; + return result?.user_version ?? 0; +} + +function setDbVersion(db: Database, version: number): void { + db.exec(`PRAGMA user_version = ${version}`); +} + +/** + * Check if old _schema_version table exists + */ +function hasOldSchemaTable(db: Database): boolean { + const result = db.get(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='_schema_version' + `) as { name: string } | undefined; + return !!result; +} + +/** + * Get version from old _schema_version table + */ +function getOldSchemaVersion(db: Database): number { + try { + const result = db.get('SELECT version FROM _schema_version LIMIT 1') as { version: number } | undefined; + return result?.version ?? 0; + } catch { + return 0; + } +} + +/** + * Migrate from old _schema_version table to PRAGMA user_version + * Old system: v3 = all migrations done (book_tools created, NOT NULL fixes applied) + * New system: v1 = equivalent starting point + */ +function migrateFromOldSystem(db: Database): void { + const oldVersion = getOldSchemaVersion(db); + + // Old v3 means all previous migrations were done + // Map to new system: old v3 = new v1 + if (oldVersion >= 3) { + setDbVersion(db, schemaVersion); + } + + // Add last_update column to book_tools if missing (was added after v3) + addColumn(db, 'book_tools', 'last_update', 'INTEGER DEFAULT 0'); + + // Drop old schema version table + db.exec('DROP TABLE IF EXISTS _schema_version'); +} + +export function runMigrations(db: Database): void { + // DEV: run test queries (skip errors silently) + if (isDev && devQueries.length > 0) { + for (const query of devQueries) { + try { + db.exec(query); + } catch (_) { /* ignore */ } + } + } + + // PROD only: versioned migrations + if (isDev) return; + + // Migrate from old _schema_version system to PRAGMA user_version + if (hasOldSchemaTable(db)) { + migrateFromOldSystem(db); + return; // Migration done, no need to continue + } + + const currentVersion = getDbVersion(db); + if (currentVersion >= schemaVersion) return; + + // v1 - book_tools table with last_update (for fresh DBs or DBs without old system) + if (currentVersion < 1) { + db.exec(` + CREATE TABLE IF NOT EXISTS book_tools ( + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + characters_enabled INTEGER NOT NULL DEFAULT 0, + worlds_enabled INTEGER NOT NULL DEFAULT 0, + locations_enabled INTEGER NOT NULL DEFAULT 0, + last_update INTEGER DEFAULT 0, + UNIQUE (book_id, user_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_book_tools_book ON book_tools(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_book_tools_user ON book_tools(user_id)`); + } + + setDbVersion(db, schemaVersion); +} /** * Initialize the local SQLite database with all required tables @@ -420,187 +541,47 @@ export function initializeSchema(db: Database): void { characters_enabled INTEGER NOT NULL DEFAULT 0, worlds_enabled INTEGER NOT NULL DEFAULT 0, locations_enabled INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (book_id, user_id), + last_update INTEGER DEFAULT 0, + UNIQUE (book_id, user_id), FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE ); `); // Create indexes for better performance createIndexes(db); - - // Set schema version for new databases (prevents unnecessary migrations) - initializeSchemaVersion(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); - `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_conversations_book ON ai_conversations(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages_history(conversation_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_chapters_book ON book_chapters(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_chapter_content_chapter ON book_chapter_content(chapter_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_characters_book ON book_characters(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_character_attrs_character ON book_characters_attributes(character_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_world_book ON book_world(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_world_elements_world ON book_world_elements(world_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_book_tools_book ON book_tools(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_book_tools_user ON book_tools(user_id)`); } /** - * Get current schema version from database + * Drop all tables (for testing/reset) */ -function getDbSchemaVersion(db: Database): number { - try { - const result = db.get('SELECT version FROM _schema_version LIMIT 1') as { version: number } | undefined; - return result?.version ?? 0; - } catch { - return 0; - } -} +export function dropAllTables(db: Database): void { + const tables = db.all(` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + `) as { name: string }[]; -/** - * Set schema version in database - */ -function setDbSchemaVersion(db: Database, version: number): void { - db.exec('CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)'); - db.run('DELETE FROM _schema_version'); - db.run('INSERT INTO _schema_version (version) VALUES (?)', [version]); -} + db.exec('PRAGMA foreign_keys = OFF'); -/** - * Initialize schema version for new databases - * Only sets version if table doesn't exist yet (new DB) - */ -function initializeSchemaVersion(db: Database): void { - const currentVersion = getDbSchemaVersion(db); - if (currentVersion === 0) { - setDbSchemaVersion(db, SCHEMA_VERSION); - } -} - -/** - * Check if a column exists in a table - */ -function columnExists(db: Database, tableName: string, columnName: string): boolean { - const columns = db.all(`PRAGMA table_info(${tableName})`) as { name: string }[]; - return columns.some(col => col.name === columnName); -} - -/** - * Safely drop a column if it exists - */ -function dropColumnIfExists(db: Database, tableName: string, columnName: string): void { - if (columnExists(db, tableName, columnName)) { - try { - db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`); - } catch (e) { - console.error(`[Migration] Failed to drop column ${columnName} from ${tableName}:`, e); - } - } -} - -/** - * Recreate a table with a new schema while preserving data - */ -function recreateTable(db: Database, tableName: string, newSchema: string, columnsToKeep: string): void { - try { - db.exec('PRAGMA foreign_keys = OFF'); - db.exec(`CREATE TABLE ${tableName}_backup AS SELECT ${columnsToKeep} FROM ${tableName}`); - db.exec(`DROP TABLE ${tableName}`); - db.exec(newSchema); - db.exec(`INSERT INTO ${tableName} (${columnsToKeep}) SELECT ${columnsToKeep} FROM ${tableName}_backup`); - db.exec(`DROP TABLE ${tableName}_backup`); - db.exec('PRAGMA foreign_keys = ON'); - } catch (e) { - console.error(`[Migration] Failed to recreate table ${tableName}:`, e); - db.exec('PRAGMA foreign_keys = ON'); - } -} - -/** - * Run migrations to update schema from one version to another - */ -export function runMigrations(db: Database): void { - const currentVersion = getDbSchemaVersion(db); - - if (currentVersion >= SCHEMA_VERSION) { - return; + for (const row of tables) { + db.exec(`DROP TABLE IF EXISTS ${row.name}`); } - // Migration v2: Remove NOT NULL constraints to allow null values from server sync - if (currentVersion < 2) { - // Recreate erit_books with nullable hashed_sub_title and summary - recreateTable(db, 'erit_books', ` - CREATE TABLE 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, - summary TEXT, - serie_id INTEGER, - desired_release_date TEXT, - desired_word_count INTEGER, - words_count INTEGER, - cover_image TEXT, - last_update INTEGER DEFAULT 0 - ) - `, '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, last_update'); - - // Recreate book_chapter_content with nullable content - recreateTable(db, 'book_chapter_content', ` - CREATE TABLE 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, - words_count INTEGER NOT NULL, - time_on_it INTEGER NOT NULL DEFAULT 0, - last_update INTEGER DEFAULT 0, - FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE - ) - `, 'content_id, chapter_id, author_id, version, content, words_count, time_on_it, last_update'); - - // Recreate book_chapter_infos with nullable summary and goal - recreateTable(db, 'book_chapter_infos', ` - CREATE TABLE 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, - goal TEXT, - last_update 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 - ) - `, 'chapter_info_id, chapter_id, act_id, incident_id, plot_point_id, book_id, author_id, summary, goal, last_update'); - } - - if (currentVersion < 3) { - db.exec(` - CREATE TABLE IF NOT EXISTS book_tools ( - book_id TEXT NOT NULL, - user_id TEXT NOT NULL, - characters_enabled INTEGER NOT NULL DEFAULT 0, - worlds_enabled INTEGER NOT NULL DEFAULT 0, - locations_enabled INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (book_id, user_id), - FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE - ); - `); - } - - // Update schema version - setDbSchemaVersion(db, SCHEMA_VERSION); + db.exec('PRAGMA foreign_keys = ON'); } diff --git a/lib/models/Book.ts b/lib/models/Book.ts index f40d1a4..0eadcf1 100644 --- a/lib/models/Book.ts +++ b/lib/models/Book.ts @@ -156,16 +156,16 @@ export const bookTypes: SelectBoxProps[] = [ export default class Book { constructor() { } - + static booksToSelectBox(books: SyncedBook[]): SelectBoxProps[] { - return books.map((book: SyncedBook):SelectBoxProps => { + return books.map((book: SyncedBook): SelectBoxProps => { return { label: book.title, value: book.id, } }); } - + static getBookTypeLabel(value: string): string { switch (value) { case 'short': diff --git a/lib/models/SyncedBook.ts b/lib/models/SyncedBook.ts index 3063a55..73931fe 100644 --- a/lib/models/SyncedBook.ts +++ b/lib/models/SyncedBook.ts @@ -1,3 +1,7 @@ +export interface SyncedBookTools { + lastUpdate: number; +} + export interface SyncedBook { id: string; type: string; @@ -14,6 +18,7 @@ export interface SyncedBook { actSummaries: SyncedActSummary[]; guideLine: SyncedGuideLine | null; aiGuideLine: SyncedAIGuideLine | null; + bookTools: SyncedBookTools | null; } export interface SyncedChapter { @@ -129,6 +134,7 @@ export interface BookSyncCompare { actSummaries: string[]; guideLine: boolean; aiGuideLine: boolean; + bookTools: boolean; } export function compareBookSyncs(newerBook: SyncedBook, olderBook: SyncedBook): BookSyncCompare | null { @@ -148,6 +154,7 @@ export function compareBookSyncs(newerBook: SyncedBook, olderBook: SyncedBook): const changedActSummaryIds: string[] = []; let guideLineChanged: boolean = false; let aiGuideLineChanged: boolean = false; + let bookToolsChanged: boolean = false; newerBook.chapters.forEach((newerChapter: SyncedChapter): void => { const olderChapter: SyncedChapter | undefined = olderBook.chapters.find((chapter: SyncedChapter): boolean => chapter.id === newerChapter.id); @@ -296,6 +303,12 @@ export function compareBookSyncs(newerBook: SyncedBook, olderBook: SyncedBook): aiGuideLineChanged = true; } + if (newerBook.bookTools && olderBook.bookTools) { + bookToolsChanged = newerBook.bookTools.lastUpdate > olderBook.bookTools.lastUpdate; + } else if (newerBook.bookTools && !olderBook.bookTools) { + bookToolsChanged = true; + } + const hasChanges: boolean = changedChapterIds.length > 0 || changedChapterContentIds.length > 0 || @@ -312,7 +325,8 @@ export function compareBookSyncs(newerBook: SyncedBook, olderBook: SyncedBook): changedIssueIds.length > 0 || changedActSummaryIds.length > 0 || guideLineChanged || - aiGuideLineChanged; + aiGuideLineChanged || + bookToolsChanged; if (!hasChanges) { return null; @@ -335,6 +349,7 @@ export function compareBookSyncs(newerBook: SyncedBook, olderBook: SyncedBook): issues: changedIssueIds, actSummaries: changedActSummaryIds, guideLine: guideLineChanged, - aiGuideLine: aiGuideLineChanged + aiGuideLine: aiGuideLineChanged, + bookTools: bookToolsChanged }; } \ No newline at end of file