storage: Day 4 — FTS5 search + update_transcript + dictionary + paginated list + Tauri command surface

Finally exposes the canonical SQLite store to the frontend. Previously
the transcript/task tables existed but no Tauri command read or wrote
them — the UI lived entirely in localStorage. Closes that gap and adds
the missing commands flagged by both Codex and architecture-review.md
§13.

MIGRATIONS:
- v2 adds:
  - transcripts_fts virtual table (FTS5, porter+unicode61 tokeniser,
    diacritics-folded) backed by transcripts via content_rowid
  - INSERT/UPDATE/DELETE triggers keep FTS in sync automatically
  - dictionary table for custom vocabulary (term, note, created_at)
- Append-only as required; never modifies v1

STORAGE FUNCTIONS (crates/storage/src/database.rs):
- list_transcripts_paged(limit, offset) — pagination
- count_transcripts() — for "showing X of N" in UI
- update_transcript(id, text?, title?) — closes the historic
  architecture-review.md §13 rename-never-persists TODO
- search_transcripts(query, limit) — FTS5 wrapper
- list_dictionary / add_dictionary_entry / delete_dictionary_entry
- DictionaryEntry struct exported

TAURI COMMANDS (src-tauri/src/commands/transcripts.rs new file):
- add_transcript, list_transcripts, count_transcripts_command,
  get_transcript, update_transcript, delete_transcript,
  search_transcripts
- list_dictionary_command, add_dictionary_entry_command,
  delete_dictionary_entry_command
- TranscriptDto + DictionaryDto for camelCase frontend serialisation

REGISTRATION (src-tauri/src/lib.rs):
- All 10 new commands wired into the invoke_handler

cargo check -p kon-storage passes clean. The Tauri crate cargo check
still requires `sudo dnf install cmake clang-devel` for whisper-rs-sys
(pre-existing infra dep, unrelated to this work).

NEXT (still in this sprint):
- Wire DictationPage / FilesPage to call add_transcript on every save
  (dual-write: keep localStorage for now, add SQLite alongside)
- Wire HistoryPage rename to call update_transcript (fixes the TODO)
- Add a History search input that calls search_transcripts
- Add Settings → Dictionary panel
- Inject dictionary terms into the LLM cleanup prompt
This commit is contained in:
2026-04-17 13:10:26 +01:00
parent 69d768e803
commit 1cce5670af
6 changed files with 429 additions and 3 deletions

View File

@@ -2,6 +2,8 @@ pub mod audio;
pub mod clipboard;
pub mod hardware;
pub mod hotkey;
pub mod live;
pub mod models;
pub mod transcription;
pub mod transcripts;
pub mod windows;

View File

@@ -0,0 +1,235 @@
// Tauri commands wrapping the kon_storage transcript and dictionary CRUD.
// These are the bridge that lets the Svelte frontend treat SQLite as the
// canonical store rather than localStorage.
//
// Day 4 of the upgrade plan. The frontend HistoryPage rename flow has had
// a `// TODO: persist to SQLite when update_transcript exists` for some
// time; that command now exists.
use serde::{Deserialize, Serialize};
use kon_storage::{
add_dictionary_entry, count_transcripts, delete_dictionary_entry,
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
insert_transcript as db_insert_transcript, list_dictionary,
list_transcripts_paged, search_transcripts as db_search_transcripts,
update_transcript as db_update_transcript, DictionaryEntry, InsertTranscriptParams,
TranscriptRow,
};
use crate::AppState;
/// Frontend-facing shape of a stored transcript. Mirrors `TranscriptRow`
/// from the storage crate but drops fields the UI does not need yet
/// (sample_rate, audio_channels, format flags) and renames to camelCase.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptDto {
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 created_at: String,
}
impl From<TranscriptRow> for TranscriptDto {
fn from(r: TranscriptRow) -> Self {
Self {
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,
created_at: r.created_at,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTranscriptRequest {
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<i64>,
pub audio_channels: Option<i64>,
pub format_mode: Option<String>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
}
/// Insert a transcript into the canonical SQLite store. Called from the
/// frontend after a recording or file transcription completes. The FTS5
/// index is updated automatically by trigger.
#[tauri::command]
pub async fn add_transcript(
state: tauri::State<'_, AppState>,
transcript: CreateTranscriptRequest,
) -> Result<(), String> {
let params = InsertTranscriptParams {
id: &transcript.id,
text: &transcript.text,
source: &transcript.source,
title: transcript.title.as_deref(),
audio_path: transcript.audio_path.as_deref(),
duration: transcript.duration,
engine: transcript.engine.as_deref(),
model_id: transcript.model_id.as_deref(),
inference_ms: transcript.inference_ms,
sample_rate: transcript.sample_rate,
audio_channels: transcript.audio_channels,
format_mode: transcript.format_mode.as_deref(),
remove_fillers: transcript.remove_fillers,
british_english: transcript.british_english,
anti_hallucination: transcript.anti_hallucination,
};
db_insert_transcript(&state.db, &params)
.await
.map_err(|e| e.to_string())
}
/// Paginated history list. Defaults to 50 rows from the most recent.
#[tauri::command]
pub async fn list_transcripts(
state: tauri::State<'_, AppState>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<TranscriptDto>, String> {
let limit = limit.unwrap_or(50).clamp(1, 500);
let offset = offset.unwrap_or(0).max(0);
list_transcripts_paged(&state.db, limit, offset)
.await
.map(|rows| rows.into_iter().map(TranscriptDto::from).collect())
.map_err(|e| e.to_string())
}
/// Total count of transcripts (for "showing X of N" UI).
#[tauri::command]
pub async fn count_transcripts_command(
state: tauri::State<'_, AppState>,
) -> Result<i64, String> {
count_transcripts(&state.db).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_transcript(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<Option<TranscriptDto>, String> {
db_get_transcript(&state.db, &id)
.await
.map(|opt| opt.map(TranscriptDto::from))
.map_err(|e| e.to_string())
}
/// Update mutable fields of an existing transcript. `text` and/or `title`.
/// Returns the row count affected (0 if id not found). Fixes the long-
/// standing "rename in History never persists" bug per
/// architecture-review.md §13.
#[tauri::command]
pub async fn update_transcript(
state: tauri::State<'_, AppState>,
id: String,
text: Option<String>,
title: Option<String>,
) -> Result<u64, String> {
db_update_transcript(&state.db, &id, text.as_deref(), title.as_deref())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_transcript(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_delete_transcript(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
/// FTS5 search. Query syntax: bare words AND together; quote phrases;
/// supports `OR`, `NOT`, prefix `*`. Returns up to 50 best-rank matches.
#[tauri::command]
pub async fn search_transcripts(
state: tauri::State<'_, AppState>,
query: String,
) -> Result<Vec<TranscriptDto>, String> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
db_search_transcripts(&state.db, q, 50)
.await
.map(|rows| rows.into_iter().map(TranscriptDto::from).collect())
.map_err(|e| e.to_string())
}
// --- Dictionary commands (custom vocabulary) ---
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryDto {
pub id: i64,
pub term: String,
pub note: Option<String>,
}
impl From<DictionaryEntry> for DictionaryDto {
fn from(e: DictionaryEntry) -> Self {
Self {
id: e.id,
term: e.term,
note: e.note,
}
}
}
#[tauri::command]
pub async fn list_dictionary_command(
state: tauri::State<'_, AppState>,
) -> Result<Vec<DictionaryDto>, String> {
list_dictionary(&state.db)
.await
.map(|rows| rows.into_iter().map(DictionaryDto::from).collect())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn add_dictionary_entry_command(
state: tauri::State<'_, AppState>,
term: String,
note: Option<String>,
) -> Result<i64, String> {
let term = term.trim();
if term.is_empty() {
return Err("Dictionary term cannot be empty".into());
}
add_dictionary_entry(&state.db, term, note.as_deref())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_dictionary_entry_command(
state: tauri::State<'_, AppState>,
id: i64,
) -> Result<(), String> {
delete_dictionary_entry(&state.db, id)
.await
.map_err(|e| e.to_string())
}

View File

@@ -186,6 +186,17 @@ pub fn run() {
commands::audio::start_native_capture,
commands::audio::stop_native_capture,
commands::audio::list_audio_devices,
// Transcripts (canonical SQLite-backed history) — Day 4
commands::transcripts::add_transcript,
commands::transcripts::list_transcripts,
commands::transcripts::count_transcripts_command,
commands::transcripts::get_transcript,
commands::transcripts::update_transcript,
commands::transcripts::delete_transcript,
commands::transcripts::search_transcripts,
commands::transcripts::list_dictionary_command,
commands::transcripts::add_dictionary_entry_command,
commands::transcripts::delete_dictionary_entry_command,
commands::live::start_live_transcription_session,
commands::live::stop_live_transcription_session,
// Windows