2 Commits

Author SHA1 Message Date
jake
13456e1fb6 feat(history): add Tauri commands for transcript persistence and FTS5 search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:47:28 +00:00
jake
d79ad40b33 feat(storage): add migration v2 — task fields, FTS5 search, timer state
- Migration v2: adds priority, project, status, updated_at, sort_order, notes columns to tasks table
- FTS5 virtual table on transcripts with insert/update/delete triggers for automatic index sync
- Timer state table for persisting active countdown timers across app restarts
- New database functions: insert_task_v2, update_task_v2, reorder_tasks, list_tasks_by_status, search_transcripts, save/get/clear_timer_state
- TimerStateRow type for timer persistence
- Extended TaskRow with v2 fields
- Fixed migration runner to handle BEGIN...END blocks in CREATE TRIGGER statements
- All 11 tests passing, Tauri app compiles cleanly
2026-03-21 12:38:43 +00:00
8 changed files with 860 additions and 27 deletions

View File

@@ -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(
@@ -136,25 +194,12 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows =
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC")
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes FROM tasks ORDER BY sort_order ASC, created_at DESC")
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
})
.collect())
Ok(rows.into_iter().map(task_row_from).collect())
}
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -175,6 +220,180 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(())
}
// --- Task v2 CRUD (extended fields) ---
/// Insert a task with all v2 fields.
pub async fn insert_task_v2(
pool: &SqlitePool,
id: &str,
text: &str,
priority: &str,
project: Option<&str>,
status: &str,
bucket: &str,
effort: Option<&str>,
source_transcript_id: Option<&str>,
sort_order: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO tasks (id, text, priority, project, status, bucket, effort, source_transcript_id, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(text)
.bind(priority)
.bind(project)
.bind(status)
.bind(bucket)
.bind(effort)
.bind(source_transcript_id)
.bind(sort_order)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task v2 failed: {e}")))?;
Ok(())
}
/// Update a task's mutable fields, setting updated_at to now.
pub async fn update_task_v2(
pool: &SqlitePool,
id: &str,
text: &str,
priority: &str,
project: Option<&str>,
status: &str,
bucket: &str,
effort: Option<&str>,
notes: &str,
) -> Result<()> {
sqlx::query(
"UPDATE tasks SET text = ?, priority = ?, project = ?, status = ?, bucket = ?, effort = ?, notes = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(text)
.bind(priority)
.bind(project)
.bind(status)
.bind(bucket)
.bind(effort)
.bind(notes)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Update task v2 failed: {e}")))?;
Ok(())
}
/// Reorder tasks by setting sort_order based on position in the given ID list.
pub async fn reorder_tasks(pool: &SqlitePool, task_ids: &[String]) -> Result<()> {
for (i, id) in task_ids.iter().enumerate() {
sqlx::query("UPDATE tasks SET sort_order = ?, updated_at = datetime('now') WHERE id = ?")
.bind(i as i64)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Reorder tasks failed: {e}")))?;
}
Ok(())
}
/// List tasks filtered by status, with a limit.
pub async fn list_tasks_by_status(
pool: &SqlitePool,
status: &str,
limit: i64,
) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes
FROM tasks WHERE status = ?
ORDER BY sort_order ASC, created_at DESC
LIMIT ?",
)
.bind(status)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks by status failed: {e}")))?;
Ok(rows.into_iter().map(task_row_from).collect())
}
// --- FTS5 Search ---
/// Full-text search across transcripts using the FTS5 virtual table.
pub async fn search_transcripts(
pool: &SqlitePool,
query: &str,
limit: i64,
) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at
FROM transcripts t
JOIN transcripts_fts fts ON t.rowid = fts.rowid
WHERE transcripts_fts MATCH ?1
ORDER BY rank
LIMIT ?2",
)
.bind(query)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("FTS search failed: {e}")))?;
Ok(rows.iter().map(transcript_row_from).collect())
}
// --- Timer State Persistence ---
/// Save or replace the active timer state.
pub async fn save_timer_state(
pool: &SqlitePool,
task_id: &str,
total_seconds: i64,
remaining_seconds: i64,
paused: bool,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO timer_state (id, task_id, total_seconds, remaining_seconds, paused)
VALUES ('active', ?, ?, ?, ?)",
)
.bind(task_id)
.bind(total_seconds)
.bind(remaining_seconds)
.bind(paused)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Save timer state failed: {e}")))?;
Ok(())
}
/// Retrieve the currently active timer state, if any.
pub async fn get_timer_state(pool: &SqlitePool) -> Result<Option<TimerStateRow>> {
let row = sqlx::query(
"SELECT id, task_id, total_seconds, remaining_seconds, started_at, paused FROM timer_state WHERE id = 'active'",
)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get timer state failed: {e}")))?;
Ok(row.map(|r| TimerStateRow {
id: r.get("id"),
task_id: r.get("task_id"),
total_seconds: r.get("total_seconds"),
remaining_seconds: r.get("remaining_seconds"),
started_at: r.get("started_at"),
paused: r.get("paused"),
}))
}
/// Clear the active timer state (timer completed or cancelled).
pub async fn clear_timer_state(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM timer_state WHERE id = 'active'")
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Clear timer state failed: {e}")))?;
Ok(())
}
// --- Settings CRUD ---
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
@@ -229,6 +448,43 @@ pub struct TaskRow {
pub done_at: Option<String>,
pub created_at: String,
pub source_transcript_id: Option<String>,
// v2 fields
pub priority: String,
pub project: Option<String>,
pub status: String,
pub updated_at: String,
pub sort_order: i64,
pub notes: String,
}
#[derive(Debug, Clone)]
pub struct TimerStateRow {
pub id: String,
pub task_id: String,
pub total_seconds: i64,
pub remaining_seconds: i64,
pub started_at: String,
pub paused: bool,
}
fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
priority: r.get("priority"),
project: r.get("project"),
status: r.get("status"),
updated_at: r.get("updated_at"),
sort_order: r.get("sort_order"),
notes: r.get("notes"),
}
}
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
@@ -357,6 +613,12 @@ mod tests {
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "Buy groceries");
assert!(!tasks[0].done);
// v2 defaults should be populated
assert_eq!(tasks[0].priority, "medium");
assert_eq!(tasks[0].status, "pending");
assert_eq!(tasks[0].sort_order, 0);
assert_eq!(tasks[0].notes, "");
assert!(tasks[0].project.is_none());
complete_task(&pool, "task1").await.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
@@ -367,6 +629,182 @@ mod tests {
assert!(tasks.is_empty());
}
#[tokio::test]
async fn task_v2_crud_roundtrip() {
let pool = test_pool().await;
insert_task_v2(
&pool,
"task-v2",
"Write unit tests",
"high",
Some("kon"),
"active",
"today",
Some("small"),
None,
1,
)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "Write unit tests");
assert_eq!(tasks[0].priority, "high");
assert_eq!(tasks[0].project.as_deref(), Some("kon"));
assert_eq!(tasks[0].status, "active");
assert_eq!(tasks[0].sort_order, 1);
update_task_v2(
&pool,
"task-v2",
"Write integration tests",
"medium",
None,
"done",
"today",
Some("medium"),
"Completed all tests",
)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks[0].text, "Write integration tests");
assert_eq!(tasks[0].priority, "medium");
assert!(tasks[0].project.is_none());
assert_eq!(tasks[0].status, "done");
assert_eq!(tasks[0].notes, "Completed all tests");
}
#[tokio::test]
async fn task_list_by_status() {
let pool = test_pool().await;
insert_task_v2(&pool, "t1", "Task A", "high", None, "pending", "inbox", None, None, 0).await.unwrap();
insert_task_v2(&pool, "t2", "Task B", "low", None, "active", "today", None, None, 1).await.unwrap();
insert_task_v2(&pool, "t3", "Task C", "medium", None, "pending", "inbox", None, None, 2).await.unwrap();
let pending = list_tasks_by_status(&pool, "pending", 10).await.unwrap();
assert_eq!(pending.len(), 2);
let active = list_tasks_by_status(&pool, "active", 10).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].text, "Task B");
}
#[tokio::test]
async fn task_reorder() {
let pool = test_pool().await;
insert_task_v2(&pool, "t1", "First", "medium", None, "pending", "inbox", None, None, 0).await.unwrap();
insert_task_v2(&pool, "t2", "Second", "medium", None, "pending", "inbox", None, None, 1).await.unwrap();
insert_task_v2(&pool, "t3", "Third", "medium", None, "pending", "inbox", None, None, 2).await.unwrap();
// Reverse the order
reorder_tasks(&pool, &["t3".to_string(), "t2".to_string(), "t1".to_string()])
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks[0].id, "t3");
assert_eq!(tasks[1].id, "t2");
assert_eq!(tasks[2].id, "t1");
}
#[tokio::test]
async fn fts_search_transcripts() {
let pool = test_pool().await;
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "tr1",
text: "Meeting about the quarterly budget review",
source: "microphone",
title: Some("Budget Meeting"),
audio_path: None,
duration: 120.0,
engine: Some("whisper"),
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "tr2",
text: "Grocery list for the weekend shop",
source: "microphone",
title: Some("Shopping"),
audio_path: None,
duration: 30.0,
engine: Some("whisper"),
model_id: None,
inference_ms: None,
sample_rate: None,
audio_channels: None,
format_mode: None,
remove_fillers: false,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
let results = search_transcripts(&pool, "budget", 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "tr1");
let results = search_transcripts(&pool, "grocery", 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "tr2");
let results = search_transcripts(&pool, "nonexistent", 10).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn timer_state_persistence() {
let pool = test_pool().await;
// No timer state initially
let state = get_timer_state(&pool).await.unwrap();
assert!(state.is_none());
// Save timer state
save_timer_state(&pool, "task-1", 300, 250, false).await.unwrap();
let state = get_timer_state(&pool).await.unwrap().unwrap();
assert_eq!(state.task_id, "task-1");
assert_eq!(state.total_seconds, 300);
assert_eq!(state.remaining_seconds, 250);
assert!(!state.paused);
// Update timer state (pause)
save_timer_state(&pool, "task-1", 300, 200, true).await.unwrap();
let state = get_timer_state(&pool).await.unwrap().unwrap();
assert_eq!(state.remaining_seconds, 200);
assert!(state.paused);
// Clear timer
clear_timer_state(&pool).await.unwrap();
let state = get_timer_state(&pool).await.unwrap();
assert!(state.is_none());
}
#[tokio::test]
async fn settings_crud_roundtrip() {
let pool = test_pool().await;

View File

@@ -3,8 +3,10 @@ pub mod file_storage;
pub mod migrations;
pub use database::{
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
InsertTranscriptParams, TaskRow, 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};

View File

@@ -73,8 +73,106 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id);
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
"#),
(2, "phase 2 — task fields, FTS5, timer state", r#"
ALTER TABLE tasks ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium';
ALTER TABLE tasks ADD COLUMN project TEXT;
ALTER TABLE tasks ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE tasks ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'));
ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT '';
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
text,
title,
content='transcripts',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS transcripts_ai AFTER INSERT ON transcripts BEGIN
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, new.title);
END;
CREATE TRIGGER IF NOT EXISTS transcripts_ad AFTER DELETE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, old.title);
END;
CREATE TRIGGER IF NOT EXISTS transcripts_au AFTER UPDATE ON transcripts BEGIN
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
VALUES ('delete', old.rowid, old.text, old.title);
INSERT INTO transcripts_fts(rowid, text, title)
VALUES (new.rowid, new.text, new.title);
END;
CREATE TABLE IF NOT EXISTS timer_state (
id TEXT PRIMARY KEY DEFAULT 'active',
task_id TEXT NOT NULL,
total_seconds INTEGER NOT NULL,
remaining_seconds INTEGER NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)
"#),
];
/// Split SQL text into individual statements, respecting BEGIN...END blocks
/// used by CREATE TRIGGER statements. A naive split on ';' would break
/// triggers that contain semicolons inside their body.
fn split_sql_statements(sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
let mut depth = 0; // Track BEGIN...END nesting
for fragment in sql.split(';') {
let trimmed = fragment.trim();
if trimmed.is_empty() {
continue;
}
let upper = trimmed.to_uppercase();
// Count BEGIN/END keywords to track trigger body nesting
if upper.contains("BEGIN") {
depth += 1;
}
if depth > 0 {
// Inside a BEGIN...END block — accumulate fragments
if !current.is_empty() {
current.push(';');
}
current.push_str(fragment);
if upper.trim_end().ends_with("END") {
depth -= 1;
if depth == 0 {
let stmt = current.trim().to_string();
if !stmt.is_empty() {
statements.push(stmt);
}
current.clear();
}
}
} else {
// Normal statement — emit directly
statements.push(trimmed.to_string());
}
}
// Emit anything left over
let leftover = current.trim().to_string();
if !leftover.is_empty() {
statements.push(leftover);
}
statements
}
/// Ensure the schema_version table exists and run any pending migrations.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::query(
@@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
if *version > current {
log::info!("Running migration {}: {}", version, description);
let statements: Vec<&str> = sql.split(';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let statements = split_sql_statements(sql);
for statement in statements {
for statement in &statements {
sqlx::query(statement)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
.map_err(|e| KonError::StorageError(format!("Migration {} failed on statement: {e}\nSQL: {}", version, statement)))?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
@@ -141,7 +236,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 2);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -149,6 +244,27 @@ mod tests {
.unwrap();
}
#[test]
fn test_split_sql_handles_triggers() {
let sql = r#"
ALTER TABLE tasks ADD COLUMN priority TEXT;
CREATE TRIGGER IF NOT EXISTS test_trigger AFTER INSERT ON tasks BEGIN
INSERT INTO log(msg) VALUES (new.id);
END;
CREATE TABLE IF NOT EXISTS timer (id TEXT PRIMARY KEY)
"#;
let stmts = split_sql_statements(sql);
assert_eq!(stmts.len(), 3, "Expected 3 statements, got: {stmts:#?}");
assert!(stmts[0].contains("ALTER TABLE"));
assert!(stmts[1].contains("CREATE TRIGGER"));
assert!(stmts[1].contains("BEGIN"));
assert!(stmts[1].contains("END"));
assert!(stmts[2].contains("CREATE TABLE"));
}
#[tokio::test]
async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new()
@@ -163,6 +279,6 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 2);
}
}

View File

@@ -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"] }

View 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())
}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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,