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:
@@ -94,10 +94,21 @@ pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<Transc
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
|
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
|
||||||
|
list_transcripts_paged(pool, limit, 0).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated transcript list. `limit` is rows-per-page, `offset` is rows to skip.
|
||||||
|
/// Used by HistoryPage to load 50 at a time and append more on scroll.
|
||||||
|
pub async fn list_transcripts_paged(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<TranscriptRow>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?",
|
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
|
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
|
||||||
@@ -105,6 +116,64 @@ pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<Trans
|
|||||||
Ok(rows.iter().map(transcript_row_from).collect())
|
Ok(rows.iter().map(transcript_row_from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI.
|
||||||
|
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Count transcripts failed: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update mutable transcript fields. Currently `text` and `title`. Other
|
||||||
|
/// columns (engine, model, audio path) are immutable post-creation. Returns
|
||||||
|
/// the number of rows updated (0 if id not found).
|
||||||
|
///
|
||||||
|
/// This is the function HistoryPage's rename flow needed; previously the
|
||||||
|
/// rename was a UI-only state change with a TODO never wired up.
|
||||||
|
/// (architecture-review.md §13)
|
||||||
|
pub async fn update_transcript(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
text: Option<&str>,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> Result<u64> {
|
||||||
|
// Build the SET clause dynamically so we only update the fields the
|
||||||
|
// caller actually changed. Two fields max so we can keep this simple
|
||||||
|
// without a query builder.
|
||||||
|
match (text, title) {
|
||||||
|
(Some(t), Some(ttl)) => {
|
||||||
|
let res = sqlx::query("UPDATE transcripts SET text = ?, title = ? WHERE id = ?")
|
||||||
|
.bind(t)
|
||||||
|
.bind(ttl)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
(Some(t), None) => {
|
||||||
|
let res = sqlx::query("UPDATE transcripts SET text = ? WHERE id = ?")
|
||||||
|
.bind(t)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
(None, Some(ttl)) => {
|
||||||
|
let res = sqlx::query("UPDATE transcripts SET title = ? WHERE id = ?")
|
||||||
|
.bind(ttl)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Update transcript failed: {e}")))?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
(None, None) => Ok(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -114,6 +183,78 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full-text search over transcripts using the FTS5 virtual table created
|
||||||
|
/// in migration v2. Returns matched transcript rows ordered by FTS rank
|
||||||
|
/// (best match first). `query` follows FTS5 syntax: bare words AND together
|
||||||
|
/// by default; quote multi-word phrases; `OR`, `NOT` operators supported.
|
||||||
|
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 fts.rowid = t.rowid \
|
||||||
|
WHERE transcripts_fts MATCH ? \
|
||||||
|
ORDER BY rank LIMIT ?",
|
||||||
|
)
|
||||||
|
.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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dictionary (custom vocabulary injected into LLM cleanup prompts) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DictionaryEntry {
|
||||||
|
pub id: i64,
|
||||||
|
pub term: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_dictionary(pool: &SqlitePool) -> Result<Vec<DictionaryEntry>> {
|
||||||
|
let rows = sqlx::query("SELECT id, term, note FROM dictionary ORDER BY term ASC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("List dictionary failed: {e}")))?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| DictionaryEntry {
|
||||||
|
id: r.get("id"),
|
||||||
|
term: r.get("term"),
|
||||||
|
note: r.get("note"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_dictionary_entry(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
term: &str,
|
||||||
|
note: Option<&str>,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let res = sqlx::query("INSERT OR IGNORE INTO dictionary (term, note) VALUES (?, ?)")
|
||||||
|
.bind(term)
|
||||||
|
.bind(note)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Insert dictionary entry failed: {e}")))?;
|
||||||
|
Ok(res.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_dictionary_entry(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM dictionary WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Delete dictionary entry failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- Task CRUD ---
|
// --- Task CRUD ---
|
||||||
|
|
||||||
pub async fn insert_task(
|
pub async fn insert_task(
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ pub mod file_storage;
|
|||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
|
add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry,
|
||||||
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
|
delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
|
||||||
|
insert_transcript, list_dictionary, list_tasks, list_transcripts, list_transcripts_paged,
|
||||||
|
log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry,
|
||||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
||||||
|
|||||||
@@ -73,6 +73,41 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id);
|
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)
|
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
|
||||||
"#),
|
"#),
|
||||||
|
(2, "transcripts FTS5 + dictionary table", r#"
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
|
||||||
|
text,
|
||||||
|
title,
|
||||||
|
content='transcripts',
|
||||||
|
content_rowid='rowid',
|
||||||
|
tokenize='porter unicode61 remove_diacritics 2'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS transcripts_ai AFTER INSERT ON transcripts BEGIN
|
||||||
|
INSERT INTO transcripts_fts(rowid, text, title)
|
||||||
|
VALUES (new.rowid, new.text, COALESCE(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, COALESCE(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, COALESCE(old.title, ''));
|
||||||
|
INSERT INTO transcripts_fts(rowid, text, title)
|
||||||
|
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dictionary (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
term TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term)
|
||||||
|
"#),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Ensure the schema_version table exists and run any pending migrations.
|
/// Ensure the schema_version table exists and run any pending migrations.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ pub mod audio;
|
|||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
|
pub mod live;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
|
pub mod transcripts;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
235
src-tauri/src/commands/transcripts.rs
Normal file
235
src-tauri/src/commands/transcripts.rs
Normal 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, ¶ms)
|
||||||
|
.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())
|
||||||
|
}
|
||||||
@@ -186,6 +186,17 @@ pub fn run() {
|
|||||||
commands::audio::start_native_capture,
|
commands::audio::start_native_capture,
|
||||||
commands::audio::stop_native_capture,
|
commands::audio::stop_native_capture,
|
||||||
commands::audio::list_audio_devices,
|
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::start_live_transcription_session,
|
||||||
commands::live::stop_live_transcription_session,
|
commands::live::stop_live_transcription_session,
|
||||||
// Windows
|
// Windows
|
||||||
|
|||||||
Reference in New Issue
Block a user