304 lines
9.8 KiB
Rust
304 lines
9.8 KiB
Rust
|
|
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);
|
||
|
|
}
|
||
|
|
}
|