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>
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5385
src-tauri/Cargo.lock
generated
Normal file
24
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "voice-to-notes"
|
||||
version = "0.1.0"
|
||||
description = "Voice to Notes — desktop transcription with speaker identification"
|
||||
authors = ["Voice to Notes Contributors"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "voice_to_notes_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
2
src-tauri/src/commands/ai.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// AI provider commands — chat, summarize via Python sidecar
|
||||
// TODO: Implement when AI provider service is built
|
||||
2
src-tauri/src/commands/export.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export commands — trigger caption/text export via Python sidecar
|
||||
// TODO: Implement when export service is built
|
||||
6
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod ai;
|
||||
pub mod export;
|
||||
pub mod project;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod transcribe;
|
||||
27
src-tauri/src/commands/project.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::db::models::Project;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_project(name: String) -> Result<Project, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
Ok(Project {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
settings: None,
|
||||
status: "active".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_project(id: String) -> Result<Option<Project>, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
let _ = id;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_projects() -> Result<Vec<Project>, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
Ok(vec![])
|
||||
}
|
||||
2
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings commands — app preferences, model selection, AI provider config
|
||||
// TODO: Implement when settings UI is built
|
||||
2
src-tauri/src/commands/system.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// System commands — hardware detection, llama-server lifecycle
|
||||
// TODO: Implement hardware detection and llama-server management
|
||||
2
src-tauri/src/commands/transcribe.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Transcription commands — start/stop/monitor transcription via Python sidecar
|
||||
// TODO: Implement when sidecar IPC is connected
|
||||
22
src-tauri/src/db/errors.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Record not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
}
|
||||
|
||||
impl serde::Serialize for DatabaseError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
49
src-tauri/src/db/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use errors::DatabaseError;
|
||||
|
||||
/// Open a SQLite database at the given path, creating tables if needed.
|
||||
pub fn open_database(path: &Path) -> Result<Connection, DatabaseError> {
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Enable WAL mode for concurrent reads
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
// Set busy timeout to 5 seconds
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
// Enable foreign key constraints
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
schema::create_tables(&conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_open_in_memory() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
schema::create_tables(&conn).unwrap();
|
||||
|
||||
// Verify tables exist by querying sqlite_master
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// 8 tables: schema_version, projects, media_files, speakers, segments, words, ai_outputs, annotations
|
||||
assert_eq!(count, 8);
|
||||
}
|
||||
}
|
||||
84
src-tauri/src/db/models.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub settings: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaFile {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub file_path: String,
|
||||
pub file_hash: Option<String>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub channels: Option<i32>,
|
||||
pub format: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Speaker {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub label: String,
|
||||
pub display_name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub media_file_id: String,
|
||||
pub speaker_id: Option<String>,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub text: String,
|
||||
pub original_text: Option<String>,
|
||||
pub confidence: Option<f64>,
|
||||
pub is_edited: bool,
|
||||
pub edited_at: Option<String>,
|
||||
pub segment_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Word {
|
||||
pub id: String,
|
||||
pub segment_id: String,
|
||||
pub word: String,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub confidence: Option<f64>,
|
||||
pub word_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiOutput {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub output_type: String,
|
||||
pub prompt: Option<String>,
|
||||
pub content: String,
|
||||
pub provider: Option<String>,
|
||||
pub created_at: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: Option<i64>,
|
||||
pub text: String,
|
||||
pub annotation_type: String,
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
136
src-tauri/src/db/schema.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::errors::DatabaseError;
|
||||
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
pub fn create_tables(conn: &Connection) -> Result<(), DatabaseError> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
settings TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
file_path TEXT NOT NULL,
|
||||
file_hash TEXT,
|
||||
duration_ms INTEGER,
|
||||
sample_rate INTEGER,
|
||||
channels INTEGER,
|
||||
format TEXT,
|
||||
file_size INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speakers (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
label TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
color TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id),
|
||||
speaker_id TEXT REFERENCES speakers(id),
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
original_text TEXT,
|
||||
confidence REAL,
|
||||
is_edited INTEGER NOT NULL DEFAULT 0,
|
||||
edited_at TEXT,
|
||||
segment_index INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id TEXT PRIMARY KEY,
|
||||
segment_id TEXT NOT NULL REFERENCES segments(id),
|
||||
word TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
confidence REAL,
|
||||
word_index INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_outputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
output_type TEXT NOT NULL,
|
||||
prompt TEXT,
|
||||
content TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'bookmark'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_project ON segments(project_id, segment_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_time ON segments(media_file_id, start_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_words_segment ON words(segment_id, word_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_words_time ON words(start_ms, end_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_outputs_project ON ai_outputs(project_id, output_type);
|
||||
",
|
||||
)?;
|
||||
|
||||
// Initialize schema version if empty
|
||||
let count: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if count == 0 {
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?1)",
|
||||
[CURRENT_SCHEMA_VERSION],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_tables_idempotent() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
// Running again should be fine (IF NOT EXISTS)
|
||||
create_tables(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_version() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
let version: i32 = conn
|
||||
.query_row("SELECT version FROM schema_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
18
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod commands;
|
||||
pub mod db;
|
||||
pub mod state;
|
||||
|
||||
use commands::project::{create_project, get_project, list_projects};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_project,
|
||||
get_project,
|
||||
list_projects,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
voice_to_notes_lib::run()
|
||||
}
|
||||
19
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Shared application state managed by Tauri.
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Option<Connection>>,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
db: Mutex::new(None),
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voice to Notes",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.voicetonotes.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Voice to Notes",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||