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>
This commit is contained in:
303
src-tauri/src/db/queries.rs
Normal file
303
src-tauri/src/db/queries.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user