diff --git a/src-tauri/src/crypto/encryption.rs b/src-tauri/src/crypto/encryption.rs new file mode 100644 index 0000000..0898523 --- /dev/null +++ b/src-tauri/src/crypto/encryption.rs @@ -0,0 +1,127 @@ +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use hex; +use pbkdf2::pbkdf2_hmac; +use rand::RngCore; +use sha2::{Digest, Sha256, Sha512}; + +use crate::error::{AppError, AppResult}; + +const KEY_LENGTH: usize = 32; // 256 bits +const IV_LENGTH: usize = 16; // 128 bits +const SALT_LENGTH: usize = 64; +const PBKDF2_ITERATIONS: u32 = 100_000; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +/// Generate a unique encryption key for a user. +/// Key is generated once at first login and stored securely in keyring. +/// Returns Base64 encoded salt+key combination. +pub fn generate_user_encryption_key(user_id: &str) -> AppResult { + let mut salt = vec![0u8; SALT_LENGTH]; + rand::rng().fill_bytes(&mut salt); + + let mut key = vec![0u8; KEY_LENGTH]; + pbkdf2_hmac::(user_id.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut key); + + let mut combined = Vec::with_capacity(SALT_LENGTH + KEY_LENGTH); + combined.extend_from_slice(&salt); + combined.extend_from_slice(&key); + + Ok(BASE64.encode(&combined)) +} + +/// Extract the actual encryption key from the stored combined salt+key. +fn extract_key_from_stored(stored_key: &str) -> AppResult> { + let combined = BASE64 + .decode(stored_key) + .map_err(|e| AppError::Encryption(format!("Invalid base64 key: {}", e)))?; + + if combined.len() < SALT_LENGTH + KEY_LENGTH { + return Err(AppError::Encryption("Stored key too short".to_string())); + } + + Ok(combined[SALT_LENGTH..SALT_LENGTH + KEY_LENGTH].to_vec()) +} + +/// Encrypt data with user key (AES-256-CBC). +/// Returns encrypted string with format "iv_hex:encrypted_hex". +/// Compatible with the Fastify/Electron TypeScript implementation. +pub fn encrypt_data_with_user_key(data: &str, user_key: &str) -> AppResult { + let key = extract_key_from_stored(user_key)?; + + let mut iv = [0u8; IV_LENGTH]; + rand::rng().fill_bytes(&mut iv); + + let data_bytes = data.as_bytes(); + // PKCS7 padding + let block_size = 16; + let padding_len = block_size - (data_bytes.len() % block_size); + let mut buf = Vec::with_capacity(data_bytes.len() + padding_len); + buf.extend_from_slice(data_bytes); + buf.extend(std::iter::repeat(padding_len as u8).take(padding_len)); + + let key_array: [u8; 32] = key + .as_slice() + .try_into() + .map_err(|_| AppError::Encryption("Invalid key length".to_string()))?; + + let cipher = Aes256CbcEnc::new(&key_array.into(), &iv.into()); + let padded_length = buf.len(); + cipher + .encrypt_padded_mut::(&mut buf, padded_length) + .map_err(|encryption_error| AppError::Encryption(format!("Encryption failed: {}", encryption_error)))?; + + let iv_hex = hex::encode(iv); + let encrypted_hex = hex::encode(&buf); + + Ok(format!("{}:{}", iv_hex, encrypted_hex)) +} + +/// Decrypt data with user key (AES-256-CBC). +/// Expects encrypted string with format "iv_hex:encrypted_hex". +/// Compatible with the Fastify/Electron TypeScript implementation. +pub fn decrypt_data_with_user_key(encrypted_data: &str, user_key: &str) -> AppResult { + let parts: Vec<&str> = encrypted_data.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(AppError::Encryption( + "Invalid encrypted data format, expected 'iv:data'".to_string(), + )); + } + + let iv = hex::decode(parts[0]) + .map_err(|e| AppError::Encryption(format!("Invalid IV hex: {}", e)))?; + let encrypted_bytes = hex::decode(parts[1]) + .map_err(|e| AppError::Encryption(format!("Invalid encrypted hex: {}", e)))?; + + let key = extract_key_from_stored(user_key)?; + + let key_array: [u8; 32] = key + .as_slice() + .try_into() + .map_err(|_| AppError::Encryption("Invalid key length".to_string()))?; + let iv_array: [u8; 16] = iv + .as_slice() + .try_into() + .map_err(|_| AppError::Encryption("Invalid IV length".to_string()))?; + + let mut buf = encrypted_bytes.clone(); + let cipher = Aes256CbcDec::new(&key_array.into(), &iv_array.into()); + + let decrypted = cipher + .decrypt_padded_mut::(&mut buf) + .map_err(|e| AppError::Encryption(format!("Decryption failed: {}", e)))?; + + String::from_utf8(decrypted.to_vec()) + .map_err(|e| AppError::Encryption(format!("Invalid UTF-8 after decryption: {}", e))) +} + +/// Hash data using SHA-256 (for non-reversible hashing like titles). +/// Returns hex encoded hash. +pub fn hash_element(data: &str) -> String { + let normalized = data.to_lowercase().trim().to_string(); + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/src-tauri/src/crypto/key_manager.rs b/src-tauri/src/crypto/key_manager.rs new file mode 100644 index 0000000..d6a6192 --- /dev/null +++ b/src-tauri/src/crypto/key_manager.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::{AppError, AppResult}; + +const SERVICE_NAME: &str = "com.eritors.scribe.desktop"; +const IV_LENGTH: usize = 16; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +// ===== Secure vault: encrypted JSON file (like Electron's safeStorage) ===== + +#[derive(Serialize, Deserialize, Default)] +struct SecureVault { + token: Option, + encryption_keys: HashMap, + last_user_id: Option, + pin_hashes: HashMap, +} + +fn vault_path() -> PathBuf { + dirs_next::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(SERVICE_NAME) + .join("secure-config.json") +} + +fn derive_machine_key() -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(SERVICE_NAME.as_bytes()); + if let Ok(name) = hostname::get() { + hasher.update(name.as_encoded_bytes()); + } + if let Some(dir) = dirs_next::home_dir() { + hasher.update(dir.to_string_lossy().as_bytes()); + } + hasher.finalize().into() +} + +fn encrypt_vault(data: &[u8], key: &[u8; 32]) -> AppResult> { + let mut iv = [0u8; IV_LENGTH]; + rand::rng().fill_bytes(&mut iv); + let block_size = 16; + let padding_len = block_size - (data.len() % block_size); + let mut buf = Vec::with_capacity(data.len() + padding_len); + buf.extend_from_slice(data); + buf.extend(std::iter::repeat(padding_len as u8).take(padding_len)); + let padded_len = buf.len(); + let cipher = Aes256CbcEnc::new(key.into(), &iv.into()); + cipher.encrypt_padded_mut::(&mut buf, padded_len) + .map_err(|e| AppError::Encryption(format!("Vault encrypt failed: {}", e)))?; + let mut result = Vec::with_capacity(IV_LENGTH + buf.len()); + result.extend_from_slice(&iv); + result.extend_from_slice(&buf); + Ok(result) +} + +fn decrypt_vault(data: &[u8], key: &[u8; 32]) -> AppResult> { + if data.len() < IV_LENGTH + 1 { + return Err(AppError::Encryption("Vault data too short".into())); + } + let iv: [u8; 16] = data[..IV_LENGTH].try_into() + .map_err(|_| AppError::Encryption("Invalid IV".into()))?; + let mut buf = data[IV_LENGTH..].to_vec(); + let cipher = Aes256CbcDec::new(key.into(), &iv.into()); + let decrypted = cipher.decrypt_padded_mut::(&mut buf) + .map_err(|e| AppError::Encryption(format!("Vault decrypt failed: {}", e)))?; + Ok(decrypted.to_vec()) +} + +fn read_vault() -> SecureVault { + let path = vault_path(); + let key = derive_machine_key(); + match fs::read_to_string(&path) { + Ok(content) => { + if let Ok(raw) = BASE64.decode(content.trim()) { + if let Ok(decrypted) = decrypt_vault(&raw, &key) { + if let Ok(vault) = serde_json::from_slice::(&decrypted) { + return vault; + } + } + } + SecureVault::default() + } + Err(_) => SecureVault::default(), + } +} + +fn write_vault(vault: &SecureVault) -> AppResult<()> { + let path = vault_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::Internal(format!("Failed to create vault dir: {}", e)))?; + } + let json = serde_json::to_string(vault) + .map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?; + let key = derive_machine_key(); + let encrypted = encrypt_vault(json.as_bytes(), &key)?; + let encoded = BASE64.encode(&encrypted); + fs::write(&path, encoded).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e))) +} + +// ===== Public API (same signatures as before) ===== + +pub fn get_user_encryption_key(user_id: &str) -> AppResult { + let vault = read_vault(); + vault.encryption_keys.get(user_id).cloned() + .ok_or_else(|| AppError::Keyring(format!("No encryption key for user {}", user_id))) +} + +pub fn set_user_encryption_key(user_id: &str, encryption_key: &str) -> AppResult<()> { + let mut vault = read_vault(); + vault.encryption_keys.insert(user_id.to_string(), encryption_key.to_string()); + write_vault(&vault) +} + +pub fn has_user_encryption_key(user_id: &str) -> bool { + let vault = read_vault(); + vault.encryption_keys.contains_key(user_id) +} + + +pub fn get_token() -> AppResult> { + let vault = read_vault(); + Ok(vault.token) +} + +pub fn set_token(token: &str) -> AppResult<()> { + let mut vault = read_vault(); + vault.token = Some(token.to_string()); + write_vault(&vault) +} + +pub fn remove_token() -> AppResult<()> { + let mut vault = read_vault(); + vault.token = None; + write_vault(&vault) +} + +pub fn set_pin_hash(user_id: &str, pin_hash: &str) -> AppResult<()> { + let mut vault = read_vault(); + vault.pin_hashes.insert(user_id.to_string(), pin_hash.to_string()); + write_vault(&vault) +} + +pub fn get_pin_hash(user_id: &str) -> AppResult> { + let vault = read_vault(); + Ok(vault.pin_hashes.get(user_id).cloned()) +} + +pub fn set_last_user_id(user_id: &str) -> AppResult<()> { + let mut vault = read_vault(); + vault.last_user_id = Some(user_id.to_string()); + write_vault(&vault) +} + +pub fn get_last_user_id() -> AppResult> { + let vault = read_vault(); + Ok(vault.last_user_id) +} diff --git a/src-tauri/src/crypto/mod.rs b/src-tauri/src/crypto/mod.rs new file mode 100644 index 0000000..1ad6440 --- /dev/null +++ b/src-tauri/src/crypto/mod.rs @@ -0,0 +1,2 @@ +pub mod encryption; +pub mod key_manager; diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs new file mode 100644 index 0000000..f7f2f22 --- /dev/null +++ b/src-tauri/src/db/connection.rs @@ -0,0 +1,62 @@ +use rusqlite::Connection; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use crate::error::{AppError, AppResult}; + +pub struct DatabaseManager { + connections: HashMap, + base_path: PathBuf, +} + +impl DatabaseManager { + pub fn new(base_path: PathBuf) -> Self { + Self { + connections: HashMap::new(), + base_path, + } + } + + pub fn get_db_path(&self, user_id: &str) -> PathBuf { + self.base_path.join(format!("eritors-local-{}.db", user_id)) + } + + pub fn initialize(&mut self, user_id: &str) -> AppResult<()> { + if self.connections.contains_key(user_id) { + return Ok(()); + } + + let db_path = self.get_db_path(user_id); + + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(&db_path)?; + + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + conn.execute_batch("PRAGMA foreign_keys=ON;")?; + + self.connections.insert(user_id.to_string(), conn); + Ok(()) + } + + pub fn get_connection(&self, user_id: &str) -> AppResult<&Connection> { + self.connections + .get(user_id) + .ok_or_else(|| AppError::Database(rusqlite::Error::InvalidParameterName( + format!("No database connection for user: {}", user_id), + ))) + } + + pub fn close(&mut self, user_id: &str) { + self.connections.remove(user_id); + } +} + +pub type DbManager = Arc>; + +pub fn create_db_manager(base_path: PathBuf) -> DbManager { + Arc::new(Mutex::new(DatabaseManager::new(base_path))) +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..fe78e2d --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,2 @@ +pub mod connection; +pub mod schema; diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs new file mode 100644 index 0000000..477d49a --- /dev/null +++ b/src-tauri/src/db/schema.rs @@ -0,0 +1,1592 @@ +use rusqlite::Connection; + +/// Current schema version - must match the TypeScript schemaVersion +const SCHEMA_VERSION: u32 = 3; + +/// Initialize the local SQLite database with all required tables. +/// This creates the full schema from scratch (for new databases). +pub fn initialize_schema(conn: &Connection) -> Result<(), rusqlite::Error> { + // Enable foreign keys + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + + conn.execute_batch( + " + -- ========================================================================= + -- AI TABLES + -- ========================================================================= + + -- AI Conversations + 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, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- AI Messages History + 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, + FOREIGN KEY (conversation_id) REFERENCES ai_conversations(conversation_id) ON DELETE CASCADE + ); + + -- ========================================================================= + -- BOOK TABLES + -- ========================================================================= + + -- Book Acts + CREATE TABLE IF NOT EXISTS book_acts ( + act_id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + last_update INTEGER DEFAULT 0 + ); + + -- Book Act Summaries + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book AI Guide Line + 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, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (user_id, book_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Chapters + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Chapter Content + 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, + 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 + ); + + -- Book Chapter Infos + 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, + 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 + ); + + -- Book Characters + 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, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + series_character_id TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE, + FOREIGN KEY (series_character_id) REFERENCES series_characters(character_id) ON DELETE SET NULL + ); + + -- Book Character Attributes + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES book_characters(character_id) ON DELETE CASCADE + ); + + -- Book Character Relations + 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, + last_update INTEGER DEFAULT 0 + ); + + -- Book Guide Line + CREATE TABLE IF NOT EXISTS book_guide_line ( + user_id TEXT NOT NULL, + book_id TEXT NOT NULL, + tone TEXT, + atmosphere TEXT, + writing_style TEXT, + themes TEXT, + symbolism TEXT, + motifs TEXT, + narrative_voice TEXT, + pacing TEXT, + intended_audience TEXT, + key_messages TEXT, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (user_id, book_id), + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Incidents + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Issues + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Location + 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, + series_location_id TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE, + FOREIGN KEY (series_location_id) REFERENCES series_locations(loc_id) ON DELETE SET NULL + ); + + -- Book Plot Points + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book World + 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, + series_world_id TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE, + FOREIGN KEY (series_world_id) REFERENCES series_worlds(world_id) ON DELETE SET NULL + ); + + -- Book World Elements + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES book_world(world_id) ON DELETE CASCADE + ); + + -- ========================================================================= + -- CORE TABLES + -- ========================================================================= + + -- Erit Books + 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, + summary TEXT, + serie_id INTEGER, + desired_release_date TEXT, + desired_word_count INTEGER, + words_count INTEGER, + cover_image TEXT, + last_update INTEGER DEFAULT 0 + ); + + -- Erit Book Series (legacy) + 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 + 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 + ); + + -- Erit Users + 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, + erite_points INTEGER NOT NULL DEFAULT 100, + stripe_customer_id TEXT, + credits_balance REAL DEFAULT 0 + ); + + -- ========================================================================= + -- LOCATION TABLES + -- ========================================================================= + + -- Location Element + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location) REFERENCES book_location(loc_id) ON DELETE CASCADE + ); + + -- Location Sub Element + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES location_element(element_id) ON DELETE CASCADE + ); + + -- ========================================================================= + -- USER TABLES + -- ========================================================================= + + -- User Keys + 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, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + ); + + -- User Last Chapter + 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, + 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 + ); + + -- ========================================================================= + -- BOOK TOOLS & SPELLS + -- ========================================================================= + + -- Book Tools + 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, + spells_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 + ); + + -- Book Spell Tags + CREATE TABLE IF NOT EXISTS book_spell_tags ( + tag_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Book Spells + CREATE TABLE IF NOT EXISTS book_spells ( + spell_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + series_spell_id TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE, + FOREIGN KEY (series_spell_id) REFERENCES series_spells(spell_id) ON DELETE SET NULL + ); + + -- ========================================================================= + -- SERIES TABLES + -- ========================================================================= + + -- Book Series (main series table) + CREATE TABLE IF NOT EXISTS book_series ( + series_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + description TEXT, + cover_image TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + ); + + -- Series Books (link series to books with order) + CREATE TABLE IF NOT EXISTS series_books ( + series_id TEXT NOT NULL, + book_id TEXT NOT NULL, + book_order INTEGER NOT NULL DEFAULT 1, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (series_id, book_id), + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + + -- Series Characters + CREATE TABLE IF NOT EXISTS series_characters ( + character_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + + -- Series Characters Attributes + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES series_characters(character_id) ON DELETE CASCADE + ); + + -- Series Worlds + CREATE TABLE IF NOT EXISTS series_worlds ( + world_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + + -- Series World Elements + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES series_worlds(world_id) ON DELETE CASCADE + ); + + -- Series Locations + CREATE TABLE IF NOT EXISTS series_locations ( + loc_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + + -- Series Location Elements + CREATE TABLE IF NOT EXISTS series_location_elements ( + element_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location_id) REFERENCES series_locations(loc_id) ON DELETE CASCADE + ); + + -- Series Location Sub Elements + CREATE TABLE IF NOT EXISTS series_location_sub_elements ( + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES series_location_elements(element_id) ON DELETE CASCADE + ); + + -- Series Spells + CREATE TABLE IF NOT EXISTS series_spells ( + spell_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + + -- Series Spell Tags + CREATE TABLE IF NOT EXISTS series_spell_tags ( + tag_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + + -- ========================================================================= + -- SYNC DELETION TRACKING + -- ========================================================================= + + -- Removed Items (sync deletion tracking) + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ); + + -- ========================================================================= + -- INDEXES + -- ========================================================================= + + -- AI indexes + 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); + + -- Book indexes + 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_book_tools_book ON book_tools(book_id); + CREATE INDEX IF NOT EXISTS idx_book_tools_user ON book_tools(user_id); + CREATE INDEX IF NOT EXISTS idx_spell_tags_book ON book_spell_tags(book_id); + CREATE INDEX IF NOT EXISTS idx_spell_tags_user ON book_spell_tags(user_id); + CREATE INDEX IF NOT EXISTS idx_spells_book ON book_spells(book_id); + CREATE INDEX IF NOT EXISTS idx_spells_user ON book_spells(user_id); + + -- Series indexes + CREATE INDEX IF NOT EXISTS idx_book_series_user ON book_series(user_id); + CREATE INDEX IF NOT EXISTS idx_series_books_book ON series_books(book_id); + CREATE INDEX IF NOT EXISTS idx_series_characters_series ON series_characters(series_id); + CREATE INDEX IF NOT EXISTS idx_series_characters_user ON series_characters(user_id); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_character ON series_characters_attributes(character_id); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_user ON series_characters_attributes(user_id); + CREATE INDEX IF NOT EXISTS idx_series_worlds_series ON series_worlds(series_id); + CREATE INDEX IF NOT EXISTS idx_series_worlds_user ON series_worlds(user_id); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_world ON series_world_elements(world_id); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_user ON series_world_elements(user_id); + CREATE INDEX IF NOT EXISTS idx_series_locations_series ON series_locations(series_id); + CREATE INDEX IF NOT EXISTS idx_series_locations_user ON series_locations(user_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_location ON series_location_elements(location_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_user ON series_location_elements(user_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id); + CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id); + + -- Removed items indexes + CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id); + ", + )?; + + Ok(()) +} + +// ============================================================================= +// MIGRATION HELPERS +// ============================================================================= + +/// Check if a column exists in a table +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + let sql = format!("PRAGMA table_info({})", table); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + for name in rows { + if name? == column { + return Ok(true); + } + } + Ok(false) +} + +/// Add a column to a table if it does not already exist +fn add_column( + conn: &Connection, + table: &str, + column: &str, + col_type: &str, +) -> Result<(), rusqlite::Error> { + if !column_exists(conn, table, column)? { + let sql = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, col_type); + conn.execute_batch(&sql)?; + } + Ok(()) +} + +/// Get the database schema version using PRAGMA user_version +fn get_db_version(conn: &Connection) -> Result { + conn.query_row("PRAGMA user_version", [], |row| row.get(0)) +} + +/// Set the database schema version using PRAGMA user_version +fn set_db_version(conn: &Connection, version: u32) -> Result<(), rusqlite::Error> { + conn.execute_batch(&format!("PRAGMA user_version = {}", version)) +} + +/// Check if the old _schema_version table exists +fn has_old_schema_table(conn: &Connection) -> Result { + let count: u32 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_schema_version'", + [], + |row| row.get(0), + )?; + Ok(count > 0) +} + +/// Get version from the old _schema_version table +fn get_old_schema_version(conn: &Connection) -> Result { + match conn.query_row( + "SELECT version FROM _schema_version LIMIT 1", + [], + |row| row.get(0), + ) { + Ok(v) => Ok(v), + Err(_) => Ok(0), + } +} + +// ============================================================================= +// OLD SYSTEM MIGRATION +// ============================================================================= + +/// 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. +fn migrate_from_old_system(conn: &Connection) -> Result<(), rusqlite::Error> { + let old_version = get_old_schema_version(conn)?; + + // Old v3 means all previous migrations were done + // Map to new system: old v3 = new v1 + if old_version >= 3 { + set_db_version(conn, SCHEMA_VERSION)?; + } + + // Add last_update column to book_tools if missing (was added after v3) + add_column(conn, "book_tools", "last_update", "INTEGER DEFAULT 0")?; + + // Add spells_enabled column to book_tools if missing + add_column( + conn, + "book_tools", + "spells_enabled", + "INTEGER NOT NULL DEFAULT 0", + )?; + + // Add new character fields if missing + add_column(conn, "book_characters", "nickname", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "age", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "gender", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "species", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_characters", + "nationality", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_characters", "status", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_characters", + "speech_pattern", + "TEXT DEFAULT NULL", + )?; + add_column( + conn, + "book_characters", + "catchphrase", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_characters", "residence", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "notes", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "color", "TEXT DEFAULT NULL")?; + + // Create book_spell_tags table if missing + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS book_spell_tags ( + tag_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_spell_tags_book ON book_spell_tags(book_id); + CREATE INDEX IF NOT EXISTS idx_spell_tags_user ON book_spell_tags(user_id); + ", + )?; + + // Create book_spells table if missing + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS book_spells ( + spell_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_spells_book ON book_spells(book_id); + CREATE INDEX IF NOT EXISTS idx_spells_user ON book_spells(user_id); + ", + )?; + + // Create series tables (v3) + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS book_series ( + series_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + description TEXT, + cover_image TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_book_series_user ON book_series(user_id); + + CREATE TABLE IF NOT EXISTS series_books ( + series_id TEXT NOT NULL, + book_id TEXT NOT NULL, + book_order INTEGER NOT NULL DEFAULT 1, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (series_id, book_id), + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_books_book ON series_books(book_id); + + CREATE TABLE IF NOT EXISTS series_characters ( + character_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_characters_series ON series_characters(series_id); + CREATE INDEX IF NOT EXISTS idx_series_characters_user ON series_characters(user_id); + + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES series_characters(character_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_character ON series_characters_attributes(character_id); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_user ON series_characters_attributes(user_id); + + CREATE TABLE IF NOT EXISTS series_worlds ( + world_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_worlds_series ON series_worlds(series_id); + CREATE INDEX IF NOT EXISTS idx_series_worlds_user ON series_worlds(user_id); + + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES series_worlds(world_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_world ON series_world_elements(world_id); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_user ON series_world_elements(user_id); + + CREATE TABLE IF NOT EXISTS series_locations ( + loc_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_locations_series ON series_locations(series_id); + CREATE INDEX IF NOT EXISTS idx_series_locations_user ON series_locations(user_id); + + CREATE TABLE IF NOT EXISTS series_location_elements ( + element_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location_id) REFERENCES series_locations(loc_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_location ON series_location_elements(location_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_user ON series_location_elements(user_id); + + CREATE TABLE IF NOT EXISTS series_location_sub_elements ( + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES series_location_elements(element_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id); + + CREATE TABLE IF NOT EXISTS series_spells ( + spell_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id); + + CREATE TABLE IF NOT EXISTS series_spell_tags ( + tag_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id); + ", + )?; + + // Add series_*_id columns to existing book tables (v3) + add_column( + conn, + "book_characters", + "series_character_id", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_world", "series_world_id", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_location", + "series_location_id", + "TEXT DEFAULT NULL", + )?; + add_column( + conn, + "book_spells", + "series_spell_id", + "TEXT DEFAULT NULL", + )?; + + // Removed Items (sync deletion tracking) + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id); + ", + )?; + + // Drop old schema version table + conn.execute_batch("DROP TABLE IF EXISTS _schema_version;")?; + + Ok(()) +} + +// ============================================================================= +// VERSIONED MIGRATIONS +// ============================================================================= + +/// Run version-based migrations on an existing database. +/// In dev mode, call `run_dev_queries` first then return. +/// In prod, runs versioned migrations (v1, v2, v3). +pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { + // Migrate from old _schema_version system to PRAGMA user_version + if has_old_schema_table(conn)? { + migrate_from_old_system(conn)?; + return Ok(()); + } + + let current_version = get_db_version(conn)?; + if current_version >= SCHEMA_VERSION { + return Ok(()); + } + + // v1 - book_tools table + spell book tables (for fresh DBs or DBs without old system) + if current_version < 1 { + conn.execute_batch( + " + -- Book Tools + 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, + spells_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 + ); + CREATE INDEX IF NOT EXISTS idx_book_tools_book ON book_tools(book_id); + CREATE INDEX IF NOT EXISTS idx_book_tools_user ON book_tools(user_id); + + -- Book Spell Tags + CREATE TABLE IF NOT EXISTS book_spell_tags ( + tag_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_spell_tags_book ON book_spell_tags(book_id); + CREATE INDEX IF NOT EXISTS idx_spell_tags_user ON book_spell_tags(user_id); + + -- Book Spells + CREATE TABLE IF NOT EXISTS book_spells ( + spell_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_spells_book ON book_spells(book_id); + CREATE INDEX IF NOT EXISTS idx_spells_user ON book_spells(user_id); + ", + )?; + } + + // v2 - Add new character fields (nickname, age, gender, species, nationality, status, etc.) + if current_version < 2 { + add_column(conn, "book_characters", "nickname", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "age", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "gender", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "species", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_characters", + "nationality", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_characters", "status", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_characters", + "speech_pattern", + "TEXT DEFAULT NULL", + )?; + add_column( + conn, + "book_characters", + "catchphrase", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_characters", "residence", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "notes", "TEXT DEFAULT NULL")?; + add_column(conn, "book_characters", "color", "TEXT DEFAULT NULL")?; + } + + // v3 - Add series tables and series_*_id columns to existing book tables + if current_version < 3 { + // Create series tables (order matters for foreign keys) + conn.execute_batch( + " + -- Book Series (main series table) + CREATE TABLE IF NOT EXISTS book_series ( + series_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + description TEXT, + cover_image TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_book_series_user ON book_series(user_id); + + -- Series Books (link series to books with order) + CREATE TABLE IF NOT EXISTS series_books ( + series_id TEXT NOT NULL, + book_id TEXT NOT NULL, + book_order INTEGER NOT NULL DEFAULT 1, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (series_id, book_id), + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_books_book ON series_books(book_id); + + -- Series Characters + CREATE TABLE IF NOT EXISTS series_characters ( + character_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_characters_series ON series_characters(series_id); + CREATE INDEX IF NOT EXISTS idx_series_characters_user ON series_characters(user_id); + + -- Series Characters Attributes + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES series_characters(character_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_character ON series_characters_attributes(character_id); + CREATE INDEX IF NOT EXISTS idx_series_char_attrs_user ON series_characters_attributes(user_id); + + -- Series Worlds + CREATE TABLE IF NOT EXISTS series_worlds ( + world_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_worlds_series ON series_worlds(series_id); + CREATE INDEX IF NOT EXISTS idx_series_worlds_user ON series_worlds(user_id); + + -- Series World Elements + CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES series_worlds(world_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_world ON series_world_elements(world_id); + CREATE INDEX IF NOT EXISTS idx_series_world_elements_user ON series_world_elements(user_id); + + -- Series Locations + CREATE TABLE IF NOT EXISTS series_locations ( + loc_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_locations_series ON series_locations(series_id); + CREATE INDEX IF NOT EXISTS idx_series_locations_user ON series_locations(user_id); + + -- Series Location Elements + CREATE TABLE IF NOT EXISTS series_location_elements ( + element_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location_id) REFERENCES series_locations(loc_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_location ON series_location_elements(location_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_elements_user ON series_location_elements(user_id); + + -- Series Location Sub Elements + CREATE TABLE IF NOT EXISTS series_location_sub_elements ( + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES series_location_elements(element_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id); + CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id); + + -- Series Spells + CREATE TABLE IF NOT EXISTS series_spells ( + spell_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id); + + -- Series Spell Tags + CREATE TABLE IF NOT EXISTS series_spell_tags ( + tag_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id); + CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id); + + -- Removed Items (sync deletion tracking) + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id); + CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id); + ", + )?; + + // Add series_*_id columns to existing book tables + add_column( + conn, + "book_characters", + "series_character_id", + "TEXT DEFAULT NULL", + )?; + add_column(conn, "book_world", "series_world_id", "TEXT DEFAULT NULL")?; + add_column( + conn, + "book_location", + "series_location_id", + "TEXT DEFAULT NULL", + )?; + add_column( + conn, + "book_spells", + "series_spell_id", + "TEXT DEFAULT NULL", + )?; + } + + set_db_version(conn, SCHEMA_VERSION)?; + + Ok(()) +} + +// ============================================================================= +// DEV QUERIES +// ============================================================================= + +/// DEV ONLY - Runs dev queries silently (errors are ignored). +/// In the TypeScript version these run on every refresh in dev mode. +/// Call this before `run_migrations` when in dev mode. +pub fn run_dev_queries(conn: &Connection) { + let dev_queries: &[&str] = &[ + // V3 Migration: Series tables and series_*_id columns + + // Book Series + "CREATE TABLE IF NOT EXISTS book_series ( + series_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + description TEXT, + cover_image TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_book_series_user ON book_series(user_id)", + + // Series Books + "CREATE TABLE IF NOT EXISTS series_books ( + series_id TEXT NOT NULL, + book_id TEXT NOT NULL, + book_order INTEGER NOT NULL DEFAULT 1, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (series_id, book_id), + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_books_book ON series_books(book_id)", + + // Series Characters + "CREATE TABLE IF NOT EXISTS series_characters ( + character_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_characters_series ON series_characters(series_id)", + "CREATE INDEX IF NOT EXISTS idx_series_characters_user ON series_characters(user_id)", + + // Series Characters Attributes + "CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES series_characters(character_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_char_attrs_character ON series_characters_attributes(character_id)", + "CREATE INDEX IF NOT EXISTS idx_series_char_attrs_user ON series_characters_attributes(user_id)", + + // Series Worlds + "CREATE TABLE IF NOT EXISTS series_worlds ( + world_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_worlds_series ON series_worlds(series_id)", + "CREATE INDEX IF NOT EXISTS idx_series_worlds_user ON series_worlds(user_id)", + + // Series World Elements + "CREATE TABLE IF NOT EXISTS series_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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES series_worlds(world_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_world_elements_world ON series_world_elements(world_id)", + "CREATE INDEX IF NOT EXISTS idx_series_world_elements_user ON series_world_elements(user_id)", + + // Series Locations + "CREATE TABLE IF NOT EXISTS series_locations ( + loc_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_locations_series ON series_locations(series_id)", + "CREATE INDEX IF NOT EXISTS idx_series_locations_user ON series_locations(user_id)", + + // Series Location Elements + "CREATE TABLE IF NOT EXISTS series_location_elements ( + element_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location_id) REFERENCES series_locations(loc_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_loc_elements_location ON series_location_elements(location_id)", + "CREATE INDEX IF NOT EXISTS idx_series_loc_elements_user ON series_location_elements(user_id)", + + // Series Location Sub Elements + "CREATE TABLE IF NOT EXISTS series_location_sub_elements ( + 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, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES series_location_elements(element_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id)", + "CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id)", + + // Series Spells + "CREATE TABLE IF NOT EXISTS series_spells ( + spell_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id)", + "CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id)", + + // Series Spell Tags + "CREATE TABLE IF NOT EXISTS series_spell_tags ( + tag_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )", + "CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id)", + "CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id)", + + // Add series_*_id columns to existing book tables (will fail silently if already exists) + "ALTER TABLE book_characters ADD COLUMN series_character_id TEXT DEFAULT NULL", + "ALTER TABLE book_world ADD COLUMN series_world_id TEXT DEFAULT NULL", + "ALTER TABLE book_location ADD COLUMN series_location_id TEXT DEFAULT NULL", + "ALTER TABLE book_spells ADD COLUMN series_spell_id TEXT DEFAULT NULL", + + // Removed Items (sync deletion tracking) + "CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL + )", + "CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id)", + "CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id)", + "CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id)", + ]; + + for query in dev_queries { + let _ = conn.execute_batch(query); + } +} + +