feat(history): add Tauri commands for transcript persistence and FTS5 search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Vec<SegmentRow>> {
|
||||
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(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
226
src-tauri/src/commands/history.rs
Normal file
226
src-tauri/src/commands/history.rs
Normal file
@@ -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<String>,
|
||||
pub audio_path: Option<String>,
|
||||
pub duration: f64,
|
||||
pub engine: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub inference_ms: Option<i64>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub audio_channels: Option<i32>,
|
||||
pub format_mode: Option<String>,
|
||||
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<String>,
|
||||
audio_path: Option<String>,
|
||||
duration: f64,
|
||||
engine: Option<String>,
|
||||
model_id: Option<String>,
|
||||
inference_ms: Option<i64>,
|
||||
sample_rate: Option<i32>,
|
||||
audio_channels: Option<i32>,
|
||||
format_mode: Option<String>,
|
||||
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<Option<TranscriptResponse>, 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<Vec<TranscriptResponse>, 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<Vec<TranscriptResponse>, 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<Vec<SegmentResponse>, 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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<Vec<_>>().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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user