use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{sqlite::SqlitePool, Row}; use std::collections::HashMap; use std::env; use std::fs; use std::path::Path; use uuid::Uuid; use crate::push_log; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AuthState { pub is_authenticated: bool, pub access_token: Option, pub user_info: Option>, } #[derive(Debug, Serialize, Deserialize, Clone)] #[allow(dead_code)] pub struct SessionRecord { pub id: String, pub is_authenticated: bool, pub access_token: Option, pub user_info: Option, pub created_at: DateTime, pub updated_at: DateTime, pub last_accessed_at: DateTime, } pub struct Database { pool: SqlitePool, } impl Database { pub async fn new() -> Result { let db_path = get_hackatime_db_path()?; if let Some(parent) = db_path.parent() { if !parent.exists() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create hackatime directory: {}", e))?; push_log("info", "backend", format!("Created directory: {}", parent.display())); } } if let Some(parent) = db_path.parent() { if !parent.exists() { return Err(format!( "Parent directory does not exist: {}", parent.display() )); } let test_file = parent.join(".write_test"); if let Err(e) = fs::write(&test_file, "test") { return Err(format!( "Cannot write to directory {}: {}", parent.display(), e )); } let _ = fs::remove_file(&test_file); push_log("debug", "backend", format!("Directory is writable: {}", parent.display())); } if !db_path.exists() { push_log("info", "backend", format!( "Database file doesn't exist, creating: {}", db_path.display() )); if let Err(e) = fs::write(&db_path, "") { return Err(format!( "Cannot create database file {}: {}", db_path.display(), e )); } } else { push_log("debug", "backend", format!("Database file already exists: {}", db_path.display())); } if let Ok(metadata) = fs::metadata(&db_path) { push_log("debug", "backend", format!("Database file metadata: {:?}", metadata)); } let database_url = format!("sqlite:{}", db_path.display()); push_log("info", "backend", format!("Connecting to database at: {}", database_url)); let pool_result = SqlitePool::connect(&database_url).await; let pool = match pool_result { Ok(pool) => pool, Err(e) => { push_log("warn", "backend", format!("Standard connect failed: {}, trying connect_with", e)); let options = sqlx::sqlite::SqliteConnectOptions::new() .filename(&db_path) .create_if_missing(true); SqlitePool::connect_with(options).await.map_err(|e| { format!( "Failed to connect to database at {} with connect_with: {}", db_path.display(), e ) })? } }; let db = Database { pool }; db.migrate().await?; push_log("info", "backend", "Database initialized successfully".to_string()); Ok(db) } async fn migrate(&self) -> Result<(), String> { sqlx::query( r#" CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, is_authenticated INTEGER NOT NULL DEFAULT 0, access_token TEXT, user_info TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_accessed_at TEXT NOT NULL ) "#, ) .execute(&self.pool) .await .map_err(|e| format!("Failed to create sessions table: {}", e))?; sqlx::query( r#" CREATE TABLE IF NOT EXISTS statistics_cache ( cache_key TEXT PRIMARY KEY, data TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ) "#, ) .execute(&self.pool) .await .map_err(|e| format!("Failed to create statistics_cache table: {}", e))?; Ok(()) } pub async fn save_session(&self, auth_state: &AuthState) -> Result { let session_id = Uuid::new_v4().to_string(); let now = Utc::now(); let user_info_json = match &auth_state.user_info { Some(info) => Some( serde_json::to_string(info) .map_err(|e| format!("Failed to serialize user info: {}", e))?, ), None => None, }; sqlx::query( r#" INSERT INTO sessions (id, is_authenticated, access_token, user_info, created_at, updated_at, last_accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&session_id) .bind(auth_state.is_authenticated as i32) .bind(&auth_state.access_token) .bind(&user_info_json) .bind(now.to_rfc3339()) .bind(now.to_rfc3339()) .bind(now.to_rfc3339()) .execute(&self.pool) .await .map_err(|e| format!("Failed to save session: {}", e))?; Ok(session_id) } #[allow(dead_code)] pub async fn update_session( &self, session_id: &str, auth_state: &AuthState, ) -> Result<(), String> { let now = Utc::now(); let user_info_json = match &auth_state.user_info { Some(info) => Some( serde_json::to_string(info) .map_err(|e| format!("Failed to serialize user info: {}", e))?, ), None => None, }; sqlx::query( r#" UPDATE sessions SET is_authenticated = ?, access_token = ?, user_info = ?, updated_at = ?, last_accessed_at = ? WHERE id = ? "#, ) .bind(auth_state.is_authenticated as i32) .bind(&auth_state.access_token) .bind(&user_info_json) .bind(now.to_rfc3339()) .bind(now.to_rfc3339()) .bind(session_id) .execute(&self.pool) .await .map_err(|e| format!("Failed to update session: {}", e))?; Ok(()) } pub async fn load_latest_session(&self) -> Result, String> { let row = sqlx::query( r#" SELECT id, is_authenticated, access_token, user_info, last_accessed_at FROM sessions ORDER BY last_accessed_at DESC LIMIT 1 "#, ) .fetch_optional(&self.pool) .await .map_err(|e| format!("Failed to load latest session: {}", e))?; match row { Some(row) => { let session_id: String = row.get("id"); let is_authenticated: i32 = row.get("is_authenticated"); let access_token: Option = row.get("access_token"); let user_info_json: Option = row.get("user_info"); let user_info = match user_info_json { Some(json) => { match serde_json::from_str::>(&json) { Ok(info) => Some(info), Err(_) => None, } } None => None, }; self.update_last_accessed(&session_id).await?; Ok(Some(AuthState { is_authenticated: is_authenticated != 0, access_token, user_info, })) } None => Ok(None), } } async fn update_last_accessed(&self, session_id: &str) -> Result<(), String> { let now = Utc::now(); sqlx::query("UPDATE sessions SET last_accessed_at = ? WHERE id = ?") .bind(now.to_rfc3339()) .bind(session_id) .execute(&self.pool) .await .map_err(|e| format!("Failed to update last accessed time: {}", e))?; Ok(()) } pub async fn clear_sessions(&self) -> Result<(), String> { sqlx::query("DELETE FROM sessions") .execute(&self.pool) .await .map_err(|e| format!("Failed to clear sessions: {}", e))?; Ok(()) } pub async fn cleanup_old_sessions(&self, days_old: i64) -> Result<(), String> { let cutoff = Utc::now() - chrono::Duration::days(days_old); sqlx::query("DELETE FROM sessions WHERE last_accessed_at < ?") .bind(cutoff.to_rfc3339()) .execute(&self.pool) .await .map_err(|e| format!("Failed to cleanup old sessions: {}", e))?; Ok(()) } pub async fn get_cached_data(&self, cache_key: &str) -> Result, String> { let now = Utc::now(); let row = sqlx::query( r#" SELECT data, expires_at FROM statistics_cache WHERE cache_key = ? "#, ) .bind(cache_key) .fetch_optional(&self.pool) .await .map_err(|e| format!("Failed to fetch cached data: {}", e))?; match row { Some(row) => { let expires_at: String = row.get("expires_at"); let expires_at_dt = DateTime::parse_from_rfc3339(&expires_at) .map_err(|e| format!("Failed to parse expiration date: {}", e))?; if expires_at_dt > now { let data: String = row.get("data"); Ok(Some(data)) } else { sqlx::query("DELETE FROM statistics_cache WHERE cache_key = ?") .bind(cache_key) .execute(&self.pool) .await .ok(); Ok(None) } } None => Ok(None), } } pub async fn set_cached_data(&self, cache_key: &str, data: &str, ttl_days: i64) -> Result<(), String> { let now = Utc::now(); let expires_at = now + chrono::Duration::days(ttl_days); sqlx::query( r#" INSERT OR REPLACE INTO statistics_cache (cache_key, data, created_at, expires_at) VALUES (?, ?, ?, ?) "#, ) .bind(cache_key) .bind(data) .bind(now.to_rfc3339()) .bind(expires_at.to_rfc3339()) .execute(&self.pool) .await .map_err(|e| format!("Failed to cache data: {}", e))?; Ok(()) } pub async fn clear_all_cache(&self) -> Result<(), String> { sqlx::query("DELETE FROM statistics_cache") .execute(&self.pool) .await .map_err(|e| format!("Failed to clear cache: {}", e))?; Ok(()) } pub async fn cleanup_expired_cache(&self) -> Result<(), String> { let now = Utc::now(); sqlx::query("DELETE FROM statistics_cache WHERE expires_at < ?") .bind(now.to_rfc3339()) .execute(&self.pool) .await .map_err(|e| format!("Failed to cleanup expired cache: {}", e))?; Ok(()) } } fn get_hackatime_db_path() -> Result { let app_data_dir = get_app_data_dir()?; let db_path = app_data_dir.join("sessions.db"); push_log("debug", "backend", format!("Database path: {}", db_path.display())); push_log("debug", "backend", format!( "Parent directory exists: {}", db_path.parent().map_or(false, |p| p.exists()) )); Ok(db_path) } fn get_app_data_dir() -> Result { if cfg!(target_os = "windows") { let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?; Ok(Path::new(&appdata).join(".hackatime")) } else if cfg!(target_os = "macos") { let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?; Ok(Path::new(&home) .join("Library") .join("Application Support") .join(".hackatime")) } else { let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?; Ok(Path::new(&home) .join(".local") .join("share") .join(".hackatime")) } } pub fn get_hackatime_config_dir() -> Result { let app_data_dir = get_app_data_dir()?; if !app_data_dir.exists() { fs::create_dir_all(&app_data_dir) .map_err(|e| format!("Failed to create hackatime directory: {}", e))?; } Ok(app_data_dir) } pub fn get_hackatime_logs_dir() -> Result { let config_dir = get_hackatime_config_dir()?; let logs_dir = config_dir.join("logs"); if !logs_dir.exists() { fs::create_dir_all(&logs_dir) .map_err(|e| format!("Failed to create logs directory: {}", e))?; } Ok(logs_dir) } pub fn get_hackatime_data_dir() -> Result { let config_dir = get_hackatime_config_dir()?; let data_dir = config_dir.join("data"); if !data_dir.exists() { fs::create_dir_all(&data_dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; } Ok(data_dir) } #[tauri::command] pub fn get_platform_info() -> Result { let app_data_dir = get_app_data_dir()?; let platform_info = serde_json::json!({ "platform": std::env::consts::OS, "app_data_dir": app_data_dir.to_string_lossy(), "description": if cfg!(target_os = "windows") { "Windows AppData directory" } else if cfg!(target_os = "macos") { "macOS Application Support directory" } else { "Linux XDG data directory" } }); Ok(platform_info) }