Files
voice-to-notes/src-tauri/src/db/queries.rs

304 lines
9.8 KiB
Rust
Raw Normal View History

Phase 1 foundation: Tauri shell, Python sidecar, SQLite database Tauri v2 + Svelte + TypeScript frontend: - App shell with workspace layout (waveform, transcript, speakers, AI chat) - Placeholder components for all major UI areas - Typed stores (project, transcript, playback, AI) - TypeScript interfaces matching the database schema - Tauri bridge service with typed invoke wrappers - svelte-check passes with 0 errors Rust backend: - Tauri v2 app entry point with command registration - SQLite database layer (rusqlite with bundled SQLite) - Full schema: projects, media_files, speakers, segments, words, ai_outputs, annotations (with indexes) - Model structs with serde serialization - CRUD queries for projects, speakers, segments, words - Segment text editing preserves original text - Schema versioning for future migrations - 6 tests passing - Command stubs for project, transcribe, export, AI, settings, system - App state management Python sidecar: - JSON-line IPC protocol (stdin/stdout) - Message types: IPCMessage, progress, error, ready - Handler registry with routing and error handling - Ping/pong handler for connectivity testing - Service stubs: transcribe, diarize, pipeline, AI, export - Provider stubs: local (llama-server), OpenAI, Anthropic, LiteLLM - Hardware detection stubs - 14 tests passing, ruff clean Also adds: - Testing strategy document (docs/TESTING.md) - Validation script (scripts/validate.sh) - Updated .gitignore for Svelte, Rust, Python artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:16:06 -08:00
use chrono::Utc;
use rusqlite::{params, Connection};
use uuid::Uuid;
use super::errors::DatabaseError;
use super::models::*;
// ── Projects ──────────────────────────────────────────────────────
pub fn create_project(conn: &Connection, name: &str) -> Result<Project, DatabaseError> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO projects (id, name, created_at, updated_at, status) VALUES (?1, ?2, ?3, ?4, 'active')",
params![id, name, now, now],
)?;
get_project(conn, &id)?.ok_or_else(|| DatabaseError::NotFound("project".into()))
}
pub fn get_project(conn: &Connection, id: &str) -> Result<Option<Project>, DatabaseError> {
let mut stmt = conn.prepare(
"SELECT id, name, created_at, updated_at, settings, status FROM projects WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], |row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
settings: row.get(4)?,
status: row.get(5)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn list_projects(conn: &Connection) -> Result<Vec<Project>, DatabaseError> {
let mut stmt = conn.prepare(
"SELECT id, name, created_at, updated_at, settings, status FROM projects WHERE status = 'active' ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
settings: row.get(4)?,
status: row.get(5)?,
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn update_project(
conn: &Connection,
id: &str,
name: Option<&str>,
settings: Option<&str>,
) -> Result<(), DatabaseError> {
let now = Utc::now().to_rfc3339();
if let Some(name) = name {
conn.execute(
"UPDATE projects SET name = ?1, updated_at = ?2 WHERE id = ?3",
params![name, now, id],
)?;
}
if let Some(settings) = settings {
conn.execute(
"UPDATE projects SET settings = ?1, updated_at = ?2 WHERE id = ?3",
params![settings, now, id],
)?;
}
Ok(())
}
pub fn delete_project(conn: &Connection, id: &str) -> Result<(), DatabaseError> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE projects SET status = 'deleted', updated_at = ?1 WHERE id = ?2",
params![now, id],
)?;
Ok(())
}
// ── Speakers ──────────────────────────────────────────────────────
pub fn create_speaker(
conn: &Connection,
project_id: &str,
label: &str,
color: Option<&str>,
) -> Result<Speaker, DatabaseError> {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO speakers (id, project_id, label, color) VALUES (?1, ?2, ?3, ?4)",
params![id, project_id, label, color],
)?;
Ok(Speaker {
id,
project_id: project_id.to_string(),
label: label.to_string(),
display_name: None,
color: color.map(String::from),
metadata: None,
})
}
pub fn get_speakers_for_project(
conn: &Connection,
project_id: &str,
) -> Result<Vec<Speaker>, DatabaseError> {
let mut stmt = conn.prepare(
"SELECT id, project_id, label, display_name, color, metadata FROM speakers WHERE project_id = ?1",
)?;
let rows = stmt.query_map(params![project_id], |row| {
Ok(Speaker {
id: row.get(0)?,
project_id: row.get(1)?,
label: row.get(2)?,
display_name: row.get(3)?,
color: row.get(4)?,
metadata: row.get(5)?,
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn rename_speaker(
conn: &Connection,
speaker_id: &str,
display_name: &str,
) -> Result<(), DatabaseError> {
conn.execute(
"UPDATE speakers SET display_name = ?1 WHERE id = ?2",
params![display_name, speaker_id],
)?;
Ok(())
}
// ── Segments ──────────────────────────────────────────────────────
pub fn get_segments_for_media(
conn: &Connection,
media_file_id: &str,
) -> Result<Vec<Segment>, DatabaseError> {
let mut stmt = conn.prepare(
"SELECT id, project_id, media_file_id, speaker_id, start_ms, end_ms, text, original_text, confidence, is_edited, edited_at, segment_index FROM segments WHERE media_file_id = ?1 ORDER BY segment_index",
)?;
let rows = stmt.query_map(params![media_file_id], |row| {
Ok(Segment {
id: row.get(0)?,
project_id: row.get(1)?,
media_file_id: row.get(2)?,
speaker_id: row.get(3)?,
start_ms: row.get(4)?,
end_ms: row.get(5)?,
text: row.get(6)?,
original_text: row.get(7)?,
confidence: row.get(8)?,
is_edited: row.get(9)?,
edited_at: row.get(10)?,
segment_index: row.get(11)?,
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn update_segment_text(
conn: &Connection,
segment_id: &str,
new_text: &str,
) -> Result<(), DatabaseError> {
let now = Utc::now().to_rfc3339();
// Preserve original text on first edit
conn.execute(
"UPDATE segments SET original_text = COALESCE(original_text, text), text = ?1, is_edited = 1, edited_at = ?2 WHERE id = ?3",
params![new_text, now, segment_id],
)?;
Ok(())
}
pub fn reassign_speaker(
conn: &Connection,
segment_id: &str,
new_speaker_id: &str,
) -> Result<(), DatabaseError> {
conn.execute(
"UPDATE segments SET speaker_id = ?1 WHERE id = ?2",
params![new_speaker_id, segment_id],
)?;
Ok(())
}
// ── Words ─────────────────────────────────────────────────────────
pub fn get_words_for_segment(
conn: &Connection,
segment_id: &str,
) -> Result<Vec<Word>, DatabaseError> {
let mut stmt = conn.prepare(
"SELECT id, segment_id, word, start_ms, end_ms, confidence, word_index FROM words WHERE segment_id = ?1 ORDER BY word_index",
)?;
let rows = stmt.query_map(params![segment_id], |row| {
Ok(Word {
id: row.get(0)?,
segment_id: row.get(1)?,
word: row.get(2)?,
start_ms: row.get(3)?,
end_ms: row.get(4)?,
confidence: row.get(5)?,
word_index: row.get(6)?,
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::schema;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
schema::create_tables(&conn).unwrap();
conn
}
#[test]
fn test_project_crud() {
let conn = setup_db();
// Create
let project = create_project(&conn, "Test Project").unwrap();
assert_eq!(project.name, "Test Project");
assert_eq!(project.status, "active");
// Read
let fetched = get_project(&conn, &project.id).unwrap().unwrap();
assert_eq!(fetched.name, "Test Project");
// List
let projects = list_projects(&conn).unwrap();
assert_eq!(projects.len(), 1);
// Update
update_project(&conn, &project.id, Some("Renamed"), None).unwrap();
let updated = get_project(&conn, &project.id).unwrap().unwrap();
assert_eq!(updated.name, "Renamed");
// Delete (soft)
delete_project(&conn, &project.id).unwrap();
let active = list_projects(&conn).unwrap();
assert_eq!(active.len(), 0);
}
#[test]
fn test_speaker_operations() {
let conn = setup_db();
let project = create_project(&conn, "Test").unwrap();
let speaker = create_speaker(&conn, &project.id, "Speaker 1", Some("#ff0000")).unwrap();
assert_eq!(speaker.label, "Speaker 1");
rename_speaker(&conn, &speaker.id, "Alice").unwrap();
let speakers = get_speakers_for_project(&conn, &project.id).unwrap();
assert_eq!(speakers[0].display_name.as_deref(), Some("Alice"));
}
#[test]
fn test_segment_text_editing() {
let conn = setup_db();
let project = create_project(&conn, "Test").unwrap();
// Insert a media file first
let media_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO media_files (id, project_id, file_path, created_at) VALUES (?1, ?2, ?3, ?4)",
params![media_id, project.id, "test.wav", now],
)
.unwrap();
// Insert a segment
let seg_id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO segments (id, project_id, media_file_id, start_ms, end_ms, text, segment_index) VALUES (?1, ?2, ?3, 0, 1000, 'hello world', 0)",
params![seg_id, project.id, media_id],
)
.unwrap();
// Edit the text
update_segment_text(&conn, &seg_id, "hello everyone").unwrap();
let segments = get_segments_for_media(&conn, &media_id).unwrap();
assert_eq!(segments[0].text, "hello everyone");
assert_eq!(segments[0].original_text.as_deref(), Some("hello world"));
assert!(segments[0].is_edited);
}
}