Handy/src-tauri/src/managers/history.rs

572 lines
20 KiB
Rust

use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use log::{debug, error, info};
use rusqlite::{params, Connection, OptionalExtension};
use rusqlite_migration::{Migrations, M};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager};
use crate::audio_toolkit::save_wav_file;
/// Database migrations for transcription history.
/// Each migration is applied in order. The library tracks which migrations
/// have been applied using SQLite's user_version pragma.
///
/// Note: For users upgrading from tauri-plugin-sql, migrate_from_tauri_plugin_sql()
/// converts the old _sqlx_migrations table tracking to the user_version pragma,
/// ensuring migrations don't re-run on existing databases.
static MIGRATIONS: &[M] = &[
M::up(
"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
);",
),
M::up("ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;"),
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"),
];
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct HistoryEntry {
pub id: i64,
pub file_name: String,
pub timestamp: i64,
pub saved: bool,
pub title: String,
pub transcription_text: String,
pub post_processed_text: Option<String>,
pub post_process_prompt: Option<String>,
}
pub struct HistoryManager {
app_handle: AppHandle,
recordings_dir: PathBuf,
db_path: PathBuf,
}
impl HistoryManager {
pub fn new(app_handle: &AppHandle) -> Result<Self> {
// 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 and run migrations synchronously
manager.init_database()?;
Ok(manager)
}
fn init_database(&self) -> Result<()> {
info!("Initializing database at {:?}", self.db_path);
let mut conn = Connection::open(&self.db_path)?;
// Handle migration from tauri-plugin-sql to rusqlite_migration
// tauri-plugin-sql used _sqlx_migrations table, rusqlite_migration uses user_version pragma
self.migrate_from_tauri_plugin_sql(&conn)?;
// Create migrations object and run to latest version
let migrations = Migrations::new(MIGRATIONS.to_vec());
// Validate migrations in debug builds
#[cfg(debug_assertions)]
migrations.validate().expect("Invalid migrations");
// Get current version before migration
let version_before: i32 =
conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
debug!("Database version before migration: {}", version_before);
// Apply any pending migrations
migrations.to_latest(&mut conn)?;
// Get version after migration
let version_after: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
if version_after > version_before {
info!(
"Database migrated from version {} to {}",
version_before, version_after
);
} else {
debug!("Database already at latest version {}", version_after);
}
Ok(())
}
/// Migrate from tauri-plugin-sql's migration tracking to rusqlite_migration's.
/// tauri-plugin-sql used a _sqlx_migrations table, while rusqlite_migration uses
/// SQLite's user_version pragma. This function checks if the old system was in use
/// and sets the user_version accordingly so migrations don't re-run.
fn migrate_from_tauri_plugin_sql(&self, conn: &Connection) -> Result<()> {
// Check if the old _sqlx_migrations table exists
let has_sqlx_migrations: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_sqlx_migrations {
return Ok(());
}
// Check current user_version
let current_version: i32 =
conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
if current_version > 0 {
// Already migrated to rusqlite_migration system
return Ok(());
}
// Get the highest version from the old migrations table
let old_version: i32 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM _sqlx_migrations WHERE success = 1",
[],
|row| row.get(0),
)
.unwrap_or(0);
if old_version > 0 {
info!(
"Migrating from tauri-plugin-sql (version {}) to rusqlite_migration",
old_version
);
// Set user_version to match the old migration state
conn.pragma_update(None, "user_version", old_version)?;
// Optionally drop the old migrations table (keeping it doesn't hurt)
// conn.execute("DROP TABLE IF EXISTS _sqlx_migrations", [])?;
info!(
"Migration tracking converted: user_version set to {}",
old_version
);
}
Ok(())
}
fn get_connection(&self) -> Result<Connection> {
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<f32>,
transcription_text: String,
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
) -> Result<()> {
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,
post_processed_text,
post_process_prompt,
)?;
// 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,
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
) -> Result<()> {
let conn = self.get_connection()?;
conn.execute(
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
)?;
debug!("Saved transcription to database");
Ok(())
}
pub fn cleanup_old_entries(&self) -> Result<()> {
let retention_period = crate::settings::get_recording_retention_period(&self.app_handle);
match retention_period {
crate::settings::RecordingRetentionPeriod::Never => {
// Don't delete anything
return Ok(());
}
crate::settings::RecordingRetentionPeriod::PreserveLimit => {
// Use the old count-based logic with history_limit
let limit = crate::settings::get_history_limit(&self.app_handle);
return self.cleanup_by_count(limit);
}
_ => {
// Use time-based logic
return self.cleanup_by_time(retention_period);
}
}
}
fn delete_entries_and_files(&self, entries: &[(i64, String)]) -> Result<usize> {
if entries.is_empty() {
return Ok(0);
}
let conn = self.get_connection()?;
let mut deleted_count = 0;
for (id, file_name) in entries {
// 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);
deleted_count += 1;
}
}
}
Ok(deleted_count)
}
fn cleanup_by_count(&self, limit: usize) -> 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?);
}
if entries.len() > limit {
let entries_to_delete = &entries[limit..];
let deleted_count = self.delete_entries_and_files(entries_to_delete)?;
if deleted_count > 0 {
debug!("Cleaned up {} old history entries by count", deleted_count);
}
}
Ok(())
}
fn cleanup_by_time(
&self,
retention_period: crate::settings::RecordingRetentionPeriod,
) -> Result<()> {
let conn = self.get_connection()?;
// Calculate cutoff timestamp (current time minus retention period)
let now = Utc::now().timestamp();
let cutoff_timestamp = match retention_period {
crate::settings::RecordingRetentionPeriod::Days3 => now - (3 * 24 * 60 * 60), // 3 days in seconds
crate::settings::RecordingRetentionPeriod::Weeks2 => now - (2 * 7 * 24 * 60 * 60), // 2 weeks in seconds
crate::settings::RecordingRetentionPeriod::Months3 => now - (3 * 30 * 24 * 60 * 60), // 3 months in seconds (approximate)
_ => unreachable!("Should not reach here"),
};
// Get all unsaved entries older than the cutoff timestamp
let mut stmt = conn.prepare(
"SELECT id, file_name FROM transcription_history WHERE saved = 0 AND timestamp < ?1",
)?;
let rows = stmt.query_map(params![cutoff_timestamp], |row| {
Ok((row.get::<_, i64>("id")?, row.get::<_, String>("file_name")?))
})?;
let mut entries_to_delete: Vec<(i64, String)> = Vec::new();
for row in rows {
entries_to_delete.push(row?);
}
let deleted_count = self.delete_entries_and_files(&entries_to_delete)?;
if deleted_count > 0 {
debug!(
"Cleaned up {} old history entries based on retention period",
deleted_count
);
}
Ok(())
}
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt 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")?,
post_processed_text: row.get("post_processed_text")?,
post_process_prompt: row.get("post_process_prompt")?,
})
})?;
let mut entries = Vec::new();
for row in rows {
entries.push(row?);
}
Ok(entries)
}
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
let conn = self.get_connection()?;
Self::get_latest_entry_with_conn(&conn)
}
fn get_latest_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
FROM transcription_history
ORDER BY timestamp DESC
LIMIT 1",
)?;
let entry = stmt
.query_row([], |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")?,
post_processed_text: row.get("post_processed_text")?,
post_process_prompt: row.get("post_process_prompt")?,
})
})
.optional()?;
Ok(entry)
}
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<Option<HistoryEntry>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
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")?,
post_processed_text: row.get("post_processed_text")?,
post_process_prompt: row.get("post_process_prompt")?,
})
})
.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)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::{params, Connection};
fn setup_conn() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE 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,
post_processed_text TEXT,
post_process_prompt TEXT
);",
)
.expect("create transcription_history table");
conn
}
fn insert_entry(conn: &Connection, timestamp: i64, text: &str, post_processed: Option<&str>) {
conn.execute(
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
format!("handy-{}.wav", timestamp),
timestamp,
false,
format!("Recording {}", timestamp),
text,
post_processed,
Option::<String>::None
],
)
.expect("insert history entry");
}
#[test]
fn get_latest_entry_returns_none_when_empty() {
let conn = setup_conn();
let entry = HistoryManager::get_latest_entry_with_conn(&conn).expect("fetch latest entry");
assert!(entry.is_none());
}
#[test]
fn get_latest_entry_returns_newest_entry() {
let conn = setup_conn();
insert_entry(&conn, 100, "first", None);
insert_entry(&conn, 200, "second", Some("processed"));
let entry = HistoryManager::get_latest_entry_with_conn(&conn)
.expect("fetch latest entry")
.expect("entry exists");
assert_eq!(entry.timestamp, 200);
assert_eq!(entry.transcription_text, "second");
assert_eq!(entry.post_processed_text.as_deref(), Some("processed"));
}
}