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 { 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, 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, 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::, _>>()?) } 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(()) } // ── Media Files ────────────────────────────────────────────────── pub fn create_media_file( conn: &Connection, project_id: &str, file_path: &str, ) -> Result { let 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![id, project_id, file_path, now], )?; Ok(MediaFile { id, project_id: project_id.to_string(), file_path: file_path.to_string(), file_hash: None, duration_ms: None, sample_rate: None, channels: None, format: None, file_size: None, created_at: now, }) } pub fn get_media_files_for_project( conn: &Connection, project_id: &str, ) -> Result, DatabaseError> { let mut stmt = conn.prepare( "SELECT id, project_id, file_path, file_hash, duration_ms, sample_rate, channels, format, file_size, created_at FROM media_files WHERE project_id = ?1 ORDER BY created_at", )?; let rows = stmt.query_map(params![project_id], |row| { Ok(MediaFile { id: row.get(0)?, project_id: row.get(1)?, file_path: row.get(2)?, file_hash: row.get(3)?, duration_ms: row.get(4)?, sample_rate: row.get(5)?, channels: row.get(6)?, format: row.get(7)?, file_size: row.get(8)?, created_at: row.get(9)?, }) })?; Ok(rows.collect::, _>>()?) } // ── Speakers ────────────────────────────────────────────────────── pub fn create_speaker( conn: &Connection, project_id: &str, label: &str, color: Option<&str>, ) -> Result { 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, 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::, _>>()?) } 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, 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::, _>>()?) } 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(()) } // ── Segments (create) ──────────────────────────────────────────── pub fn create_segment( conn: &Connection, project_id: &str, media_file_id: &str, speaker_id: Option<&str>, start_ms: i64, end_ms: i64, text: &str, segment_index: i32, ) -> Result { let id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO segments (id, project_id, media_file_id, speaker_id, start_ms, end_ms, text, is_edited, segment_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, ?8)", params![id, project_id, media_file_id, speaker_id, start_ms, end_ms, text, segment_index], )?; Ok(Segment { id, project_id: project_id.to_string(), media_file_id: media_file_id.to_string(), speaker_id: speaker_id.map(String::from), start_ms, end_ms, text: text.to_string(), original_text: None, confidence: None, is_edited: false, edited_at: None, segment_index, }) } // ── Words ───────────────────────────────────────────────────────── pub fn get_words_for_segment( conn: &Connection, segment_id: &str, ) -> Result, 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::, _>>()?) } pub fn create_word( conn: &Connection, segment_id: &str, word: &str, start_ms: i64, end_ms: i64, confidence: Option, word_index: i32, ) -> Result { let id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO words (id, segment_id, word, start_ms, end_ms, confidence, word_index) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![id, segment_id, word, start_ms, end_ms, confidence, word_index], )?; Ok(Word { id, segment_id: segment_id.to_string(), word: word.to_string(), start_ms, end_ms, confidence, word_index, }) } #[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); } }