diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 586d914..b61bb7d 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -114,6 +114,64 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { Ok(()) } +// --- Segment CRUD --- + +/// Batch insert segments for a transcript. +pub async fn insert_segments( + pool: &SqlitePool, + transcript_id: &str, + segments: &[(f64, f64, &str)], +) -> Result<()> { + for (start, end, text) in segments { + sqlx::query( + "INSERT INTO segments (transcript_id, start_time, end_time, text) VALUES (?, ?, ?, ?)", + ) + .bind(transcript_id) + .bind(start) + .bind(end) + .bind(text) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert segment failed: {e}")))?; + } + Ok(()) +} + +/// Row type for segment query results. +#[derive(Debug, Clone)] +pub struct SegmentRow { + pub id: i64, + pub transcript_id: String, + pub start_time: f64, + pub end_time: f64, + pub text: String, +} + +/// Retrieve all segments for a transcript, ordered by start time. +pub async fn get_segments( + pool: &SqlitePool, + transcript_id: &str, +) -> Result> { + let rows = sqlx::query( + "SELECT id, transcript_id, start_time, end_time, text FROM segments WHERE transcript_id = ? ORDER BY start_time ASC", + ) + .bind(transcript_id) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get segments failed: {e}")))?; + + Ok(rows + .into_iter() + .map(|r| SegmentRow { + id: r.get("id"), + transcript_id: r.get("transcript_id"), + start_time: r.get("start_time"), + end_time: r.get("end_time"), + text: r.get("text"), + }) + .collect()) +} + // --- Task CRUD --- pub async fn insert_task( diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index f025a75..b73b59e 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -3,10 +3,10 @@ pub mod file_storage; pub mod migrations; pub use database::{ - clear_timer_state, complete_task, delete_task, delete_transcript, get_setting, - get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript, - list_tasks, list_tasks_by_status, list_transcripts, log_error, reorder_tasks, - save_timer_state, search_transcripts, set_setting, update_task_v2, - InsertTranscriptParams, TaskRow, TimerStateRow, TranscriptRow, + clear_timer_state, complete_task, delete_task, delete_transcript, get_segments, + get_setting, get_timer_state, get_transcript, init, insert_segments, insert_task, + insert_task_v2, insert_transcript, list_tasks, list_tasks_by_status, list_transcripts, + log_error, reorder_tasks, save_timer_state, search_transcripts, set_setting, + update_task_v2, InsertTranscriptParams, SegmentRow, TaskRow, TimerStateRow, TranscriptRow, }; pub use file_storage::{app_data_dir, database_path, recordings_dir}; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71b30a8..80d3798 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,3 +36,4 @@ tokio = { version = "1", features = ["rt", "sync"] } arboard = "3.6.1" tauri-plugin-mcp = "0.7.1" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } +uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..14e6519 --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -0,0 +1,226 @@ +use serde::Serialize; + +use crate::AppState; +use kon_storage::{ + delete_transcript, get_segments, get_transcript, insert_segments, insert_transcript, + list_transcripts, search_transcripts, InsertTranscriptParams, +}; + +/// Serialisable transcript response for the frontend. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptResponse { + pub id: String, + pub text: String, + pub source: String, + pub title: Option, + pub audio_path: Option, + pub duration: f64, + pub engine: Option, + pub model_id: Option, + pub inference_ms: Option, + pub sample_rate: Option, + pub audio_channels: Option, + pub format_mode: Option, + pub remove_fillers: bool, + pub british_english: bool, + pub anti_hallucination: bool, + pub created_at: String, +} + +/// Serialisable segment response. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SegmentResponse { + pub id: i64, + pub transcript_id: String, + pub start_time: f64, + pub end_time: f64, + pub text: String, +} + +/// Persist a completed transcription to SQLite. +#[tauri::command] +pub async fn save_transcript( + state: tauri::State<'_, AppState>, + id: String, + text: String, + source: String, + title: Option, + audio_path: Option, + duration: f64, + engine: Option, + model_id: Option, + inference_ms: Option, + sample_rate: Option, + audio_channels: Option, + format_mode: Option, + remove_fillers: bool, + british_english: bool, + anti_hallucination: bool, +) -> Result<(), String> { + insert_transcript( + &state.db, + &InsertTranscriptParams { + id: &id, + text: &text, + source: &source, + title: title.as_deref(), + audio_path: audio_path.as_deref(), + duration, + engine: engine.as_deref(), + model_id: model_id.as_deref(), + inference_ms, + sample_rate, + audio_channels, + format_mode: format_mode.as_deref(), + remove_fillers, + british_english, + anti_hallucination, + }, + ) + .await + .map_err(|e| e.to_string()) +} + +/// Fetch a single transcript by ID. +#[tauri::command] +pub async fn get_transcript_by_id( + state: tauri::State<'_, AppState>, + id: String, +) -> Result, String> { + let row = get_transcript(&state.db, &id) + .await + .map_err(|e| e.to_string())?; + Ok(row.map(|r| TranscriptResponse { + id: r.id, + text: r.text, + source: r.source, + title: r.title, + audio_path: r.audio_path, + duration: r.duration, + engine: r.engine, + model_id: r.model_id, + inference_ms: r.inference_ms, + sample_rate: r.sample_rate, + audio_channels: r.audio_channels, + format_mode: r.format_mode, + remove_fillers: r.remove_fillers, + british_english: r.british_english, + anti_hallucination: r.anti_hallucination, + created_at: r.created_at, + })) +} + +/// List transcripts, newest first. +#[tauri::command] +pub async fn list_all_transcripts( + state: tauri::State<'_, AppState>, + limit: i64, +) -> Result, String> { + let rows = list_transcripts(&state.db, limit) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| TranscriptResponse { + id: r.id, + text: r.text, + source: r.source, + title: r.title, + audio_path: r.audio_path, + duration: r.duration, + engine: r.engine, + model_id: r.model_id, + inference_ms: r.inference_ms, + sample_rate: r.sample_rate, + audio_channels: r.audio_channels, + format_mode: r.format_mode, + remove_fillers: r.remove_fillers, + british_english: r.british_english, + anti_hallucination: r.anti_hallucination, + created_at: r.created_at, + }) + .collect()) +} + +/// Delete a transcript by ID (cascades to segments). +#[tauri::command] +pub async fn delete_transcript_by_id( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + delete_transcript(&state.db, &id) + .await + .map_err(|e| e.to_string()) +} + +/// Full-text search across transcripts via FTS5. +#[tauri::command] +pub async fn search_all_transcripts( + state: tauri::State<'_, AppState>, + query: String, + limit: i64, +) -> Result, String> { + let rows = search_transcripts(&state.db, &query, limit) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| TranscriptResponse { + id: r.id, + text: r.text, + source: r.source, + title: r.title, + audio_path: r.audio_path, + duration: r.duration, + engine: r.engine, + model_id: r.model_id, + inference_ms: r.inference_ms, + sample_rate: r.sample_rate, + audio_channels: r.audio_channels, + format_mode: r.format_mode, + remove_fillers: r.remove_fillers, + british_english: r.british_english, + anti_hallucination: r.anti_hallucination, + created_at: r.created_at, + }) + .collect()) +} + +/// Batch insert segments for a transcript. +#[tauri::command] +pub async fn save_segments( + state: tauri::State<'_, AppState>, + transcript_id: String, + segments: Vec<(f64, f64, String)>, +) -> Result<(), String> { + let refs: Vec<(f64, f64, &str)> = segments + .iter() + .map(|(s, e, t)| (*s, *e, t.as_str())) + .collect(); + insert_segments(&state.db, &transcript_id, &refs) + .await + .map_err(|e| e.to_string()) +} + +/// Retrieve all segments for a transcript, ordered by start time. +#[tauri::command] +pub async fn get_transcript_segments( + state: tauri::State<'_, AppState>, + transcript_id: String, +) -> Result, String> { + let rows = get_segments(&state.db, &transcript_id) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| SegmentResponse { + id: r.id, + transcript_id: r.transcript_id, + start_time: r.start_time, + end_time: r.end_time, + text: r.text, + }) + .collect()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d8b9d73..22ee2db 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod audio; pub mod clipboard; pub mod hardware; +pub mod history; pub mod models; pub mod transcription; pub mod windows; diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs index fabd1c7..99e8937 100644 --- a/src-tauri/src/commands/transcription.rs +++ b/src-tauri/src/commands/transcription.rs @@ -9,6 +9,7 @@ use tauri::Emitter; use crate::AppState; use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; use kon_core::types::{Segment, TranscriptionOptions}; +use kon_storage::InsertTranscriptParams; /// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event. #[tauri::command] @@ -82,8 +83,9 @@ pub async fn transcribe_file( initial_prompt: Some(initial_prompt), }; + let path_for_blocking = path.clone(); let timed = tokio::task::spawn_blocking(move || { - let audio = kon_audio::decode_audio_file(Path::new(&path)) + let audio = kon_audio::decode_audio_file(Path::new(&path_for_blocking)) .map_err(|e| e.to_string())?; let resampled = kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?; @@ -105,7 +107,46 @@ pub async fn transcribe_file( }, ); + // Auto-persist file transcription to SQLite + let transcript_id = uuid::Uuid::new_v4().to_string(); + let full_text: String = segments.iter().map(|s| s.text.as_str()).collect::>().join(" "); + let file_name = Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + // Best-effort persistence — don't fail the command if storage errors + let _ = kon_storage::insert_transcript( + &state.db, + &InsertTranscriptParams { + id: &transcript_id, + text: &full_text, + source: file_name, + title: Some(file_name), + audio_path: Some(&path), + duration: timed.transcript.duration(), + engine: Some("whisper"), + model_id: None, + inference_ms: Some(timed.inference_ms as i64), + sample_rate: Some(16000), + audio_channels: Some(1), + format_mode: Some(&format_mode), + remove_fillers, + british_english, + anti_hallucination, + }, + ) + .await; + + // Persist segments + let seg_tuples: Vec<(f64, f64, &str)> = segments + .iter() + .map(|s| (s.start, s.end, s.text.as_str())) + .collect(); + let _ = kon_storage::insert_segments(&state.db, &transcript_id, &seg_tuples).await; + Ok(serde_json::json!({ + "id": transcript_id, "segments": segments, "language": timed.transcript.language(), "duration": timed.transcript.duration(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df6af98..96493ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -138,6 +138,14 @@ pub fn run() { commands::models::list_parakeet_models, commands::models::load_parakeet_model, commands::models::check_parakeet_engine, + // History / transcript persistence + commands::history::save_transcript, + commands::history::get_transcript_by_id, + commands::history::list_all_transcripts, + commands::history::delete_transcript_by_id, + commands::history::search_all_transcripts, + commands::history::save_segments, + commands::history::get_transcript_segments, // Transcription commands::transcription::transcribe_pcm, commands::transcription::transcribe_file,