Compare commits
2 Commits
103585d7ea
...
13456e1fb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13456e1fb6 | ||
|
|
d79ad40b33 |
@@ -114,6 +114,64 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 ---
|
// --- Task CRUD ---
|
||||||
|
|
||||||
pub async fn insert_task(
|
pub async fn insert_task(
|
||||||
@@ -136,25 +194,12 @@ pub async fn insert_task(
|
|||||||
|
|
||||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||||
let rows =
|
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)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows.into_iter().map(task_row_from).collect())
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
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(())
|
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 ---
|
// --- Settings CRUD ---
|
||||||
|
|
||||||
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
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 done_at: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub source_transcript_id: Option<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 {
|
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||||
@@ -357,6 +613,12 @@ mod tests {
|
|||||||
assert_eq!(tasks.len(), 1);
|
assert_eq!(tasks.len(), 1);
|
||||||
assert_eq!(tasks[0].text, "Buy groceries");
|
assert_eq!(tasks[0].text, "Buy groceries");
|
||||||
assert!(!tasks[0].done);
|
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();
|
complete_task(&pool, "task1").await.unwrap();
|
||||||
let tasks = list_tasks(&pool).await.unwrap();
|
let tasks = list_tasks(&pool).await.unwrap();
|
||||||
@@ -367,6 +629,182 @@ mod tests {
|
|||||||
assert!(tasks.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn settings_crud_roundtrip() {
|
async fn settings_crud_roundtrip() {
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|||||||
@@ -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,
|
clear_timer_state, complete_task, delete_task, delete_transcript, get_segments,
|
||||||
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
|
get_setting, get_timer_state, get_transcript, init, insert_segments, insert_task,
|
||||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
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};
|
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
||||||
|
|||||||
@@ -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_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, "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.
|
/// Ensure the schema_version table exists and run any pending migrations.
|
||||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
|||||||
if *version > current {
|
if *version > current {
|
||||||
log::info!("Running migration {}: {}", version, description);
|
log::info!("Running migration {}: {}", version, description);
|
||||||
|
|
||||||
let statements: Vec<&str> = sql.split(';')
|
let statements = split_sql_statements(sql);
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for statement in statements {
|
for statement in &statements {
|
||||||
sqlx::query(statement)
|
sqlx::query(statement)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.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 (?, ?)")
|
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
|
||||||
@@ -141,7 +236,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 2);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -149,6 +244,27 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
async fn test_migrations_idempotent() {
|
async fn test_migrations_idempotent() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
@@ -163,6 +279,6 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ tokio = { version = "1", features = ["rt", "sync"] }
|
|||||||
arboard = "3.6.1"
|
arboard = "3.6.1"
|
||||||
tauri-plugin-mcp = "0.7.1"
|
tauri-plugin-mcp = "0.7.1"
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
|
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 audio;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
|
pub mod history;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use tauri::Emitter;
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||||
use kon_core::types::{Segment, TranscriptionOptions};
|
use kon_core::types::{Segment, TranscriptionOptions};
|
||||||
|
use kon_storage::InsertTranscriptParams;
|
||||||
|
|
||||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -82,8 +83,9 @@ pub async fn transcribe_file(
|
|||||||
initial_prompt: Some(initial_prompt),
|
initial_prompt: Some(initial_prompt),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let path_for_blocking = path.clone();
|
||||||
let timed = tokio::task::spawn_blocking(move || {
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let resampled =
|
let resampled =
|
||||||
kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
|
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!({
|
Ok(serde_json::json!({
|
||||||
|
"id": transcript_id,
|
||||||
"segments": segments,
|
"segments": segments,
|
||||||
"language": timed.transcript.language(),
|
"language": timed.transcript.language(),
|
||||||
"duration": timed.transcript.duration(),
|
"duration": timed.transcript.duration(),
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ pub fn run() {
|
|||||||
commands::models::list_parakeet_models,
|
commands::models::list_parakeet_models,
|
||||||
commands::models::load_parakeet_model,
|
commands::models::load_parakeet_model,
|
||||||
commands::models::check_parakeet_engine,
|
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
|
// Transcription
|
||||||
commands::transcription::transcribe_pcm,
|
commands::transcription::transcribe_pcm,
|
||||||
commands::transcription::transcribe_file,
|
commands::transcription::transcribe_file,
|
||||||
|
|||||||
Reference in New Issue
Block a user