use anyhow::Result; use chrono::{DateTime, Local, Utc}; use log::{debug, error}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_sql::{Migration, MigrationKind}; use crate::audio_toolkit::save_wav_file; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HistoryEntry { pub id: i64, pub file_name: String, pub timestamp: i64, pub saved: bool, pub title: String, pub transcription_text: String, } pub struct HistoryManager { app_handle: AppHandle, recordings_dir: PathBuf, db_path: PathBuf, } impl HistoryManager { pub fn new(app_handle: &AppHandle) -> Result { // Create recordings directory in app data dir let app_data_dir = app_handle.path().app_data_dir()?; let recordings_dir = app_data_dir.join("recordings"); let db_path = app_data_dir.join("history.db"); // Ensure recordings directory exists if !recordings_dir.exists() { fs::create_dir_all(&recordings_dir)?; debug!("Created recordings directory: {:?}", recordings_dir); } let manager = Self { app_handle: app_handle.clone(), recordings_dir, db_path, }; // Initialize database manager.init_database()?; Ok(manager) } pub fn get_migrations() -> Vec { vec![Migration { version: 1, description: "create_transcription_history_table", sql: "CREATE TABLE IF NOT EXISTS transcription_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, timestamp INTEGER NOT NULL, saved BOOLEAN NOT NULL DEFAULT 0, title TEXT NOT NULL, transcription_text TEXT NOT NULL );", kind: MigrationKind::Up, }] } fn init_database(&self) -> Result<()> { let conn = Connection::open(&self.db_path)?; conn.execute( "CREATE TABLE IF NOT EXISTS transcription_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, timestamp INTEGER NOT NULL, saved BOOLEAN NOT NULL DEFAULT 0, title TEXT NOT NULL, transcription_text TEXT NOT NULL )", [], )?; debug!("Database initialized at: {:?}", self.db_path); Ok(()) } fn get_connection(&self) -> Result { Ok(Connection::open(&self.db_path)?) } /// Save a transcription to history (both database and WAV file) pub async fn save_transcription( &self, audio_samples: Vec, transcription_text: String, ) -> Result<()> { // If history limit is 0, do not save at all. if crate::settings::get_history_limit(&self.app_handle) == 0 { return Ok(()); } let timestamp = Utc::now().timestamp(); let file_name = format!("handy-{}.wav", timestamp); let title = self.format_timestamp_title(timestamp); // Save WAV file let file_path = self.recordings_dir.join(&file_name); save_wav_file(file_path, &audio_samples).await?; // Save to database self.save_to_database(file_name, timestamp, title, transcription_text)?; // Clean up old entries self.cleanup_old_entries()?; // Emit history updated event if let Err(e) = self.app_handle.emit("history-updated", ()) { error!("Failed to emit history-updated event: {}", e); } Ok(()) } fn save_to_database( &self, file_name: String, timestamp: i64, title: String, transcription_text: String, ) -> Result<()> { let conn = self.get_connection()?; conn.execute( "INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text) VALUES (?1, ?2, ?3, ?4, ?5)", params![file_name, timestamp, false, title, transcription_text], )?; debug!("Saved transcription to database"); Ok(()) } fn cleanup_old_entries(&self) -> Result<()> { let conn = self.get_connection()?; // Get all entries that are not saved, ordered by timestamp desc let mut stmt = conn.prepare( "SELECT id, file_name FROM transcription_history WHERE saved = 0 ORDER BY timestamp DESC" )?; let rows = stmt.query_map([], |row| { Ok((row.get::<_, i64>("id")?, row.get::<_, String>("file_name")?)) })?; let mut entries: Vec<(i64, String)> = Vec::new(); for row in rows { entries.push(row?); } let limit = crate::settings::get_history_limit(&self.app_handle); if entries.len() > limit { let entries_to_delete = &entries[limit..]; for (id, file_name) in entries_to_delete { // Delete database entry conn.execute( "DELETE FROM transcription_history WHERE id = ?1", params![id], )?; // Delete WAV file let file_path = self.recordings_dir.join(file_name); if file_path.exists() { if let Err(e) = fs::remove_file(&file_path) { error!("Failed to delete WAV file {}: {}", file_name, e); } else { debug!("Deleted old WAV file: {}", file_name); } } } debug!("Cleaned up {} old history entries", entries_to_delete.len()); } Ok(()) } pub async fn get_history_entries(&self) -> Result> { let conn = self.get_connection()?; let mut stmt = conn.prepare( "SELECT id, file_name, timestamp, saved, title, transcription_text FROM transcription_history ORDER BY timestamp DESC" )?; let rows = stmt.query_map([], |row| { Ok(HistoryEntry { id: row.get("id")?, file_name: row.get("file_name")?, timestamp: row.get("timestamp")?, saved: row.get("saved")?, title: row.get("title")?, transcription_text: row.get("transcription_text")?, }) })?; let mut entries = Vec::new(); for row in rows { entries.push(row?); } Ok(entries) } pub async fn toggle_saved_status(&self, id: i64) -> Result<()> { let conn = self.get_connection()?; // Get current saved status let current_saved: bool = conn.query_row( "SELECT saved FROM transcription_history WHERE id = ?1", params![id], |row| row.get("saved"), )?; let new_saved = !current_saved; conn.execute( "UPDATE transcription_history SET saved = ?1 WHERE id = ?2", params![new_saved, id], )?; debug!("Toggled saved status for entry {}: {}", id, new_saved); // Emit history updated event if let Err(e) = self.app_handle.emit("history-updated", ()) { error!("Failed to emit history-updated event: {}", e); } Ok(()) } pub fn get_audio_file_path(&self, file_name: &str) -> PathBuf { self.recordings_dir.join(file_name) } pub async fn get_entry_by_id(&self, id: i64) -> Result> { let conn = self.get_connection()?; let mut stmt = conn.prepare( "SELECT id, file_name, timestamp, saved, title, transcription_text FROM transcription_history WHERE id = ?1", )?; let entry = stmt .query_row([id], |row| { Ok(HistoryEntry { id: row.get("id")?, file_name: row.get("file_name")?, timestamp: row.get("timestamp")?, saved: row.get("saved")?, title: row.get("title")?, transcription_text: row.get("transcription_text")?, }) }) .optional()?; Ok(entry) } pub async fn delete_entry(&self, id: i64) -> Result<()> { let conn = self.get_connection()?; // Get the entry to find the file name if let Some(entry) = self.get_entry_by_id(id).await? { // Delete the audio file first let file_path = self.get_audio_file_path(&entry.file_name); if file_path.exists() { if let Err(e) = fs::remove_file(&file_path) { error!("Failed to delete audio file {}: {}", entry.file_name, e); // Continue with database deletion even if file deletion fails } } } // Delete from database conn.execute( "DELETE FROM transcription_history WHERE id = ?1", params![id], )?; debug!("Deleted history entry with id: {}", id); // Emit history updated event if let Err(e) = self.app_handle.emit("history-updated", ()) { error!("Failed to emit history-updated event: {}", e); } Ok(()) } fn format_timestamp_title(&self, timestamp: i64) -> String { if let Some(utc_datetime) = DateTime::from_timestamp(timestamp, 0) { // Convert UTC to local timezone let local_datetime = utc_datetime.with_timezone(&Local); local_datetime.format("%B %e, %Y - %l:%M%p").to_string() } else { format!("Recording {}", timestamp) } } pub fn update_history_limit(&self) -> Result<()> { self.cleanup_old_entries()?; Ok(()) } }