diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index a94ed0a..500ff79 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -6,3 +6,15 @@ description = "SQLite persistence, BM25 search, and file storage for Kon" [dependencies] kon-core = { path = "../core" } + +# SQLite with compile-time checked queries +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } + +# Async runtime +tokio = { version = "1", features = ["rt", "sync", "macros"] } + +# UUIDs for record IDs +uuid = { version = "1", features = ["v4"] } + +# Timestamps +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs new file mode 100644 index 0000000..c077138 --- /dev/null +++ b/crates/storage/src/database.rs @@ -0,0 +1,364 @@ +use std::path::Path; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Row, SqlitePool}; + +use kon_core::error::{KonError, Result}; + +/// Initialise the SQLite database with connection pool and run migrations. +pub async fn init(db_path: &Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(options) + .await + .map_err(|e| KonError::StorageError(format!("Database connect failed: {e}")))?; + + run_migrations(&pool).await?; + + Ok(pool) +} + +/// Run schema migrations. +async fn run_migrations(pool: &SqlitePool) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS transcripts ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'microphone', + title TEXT, + audio_path TEXT, + duration REAL NOT NULL DEFAULT 0.0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE, + start_time REAL NOT NULL, + end_time REAL NOT NULL, + text TEXT NOT NULL DEFAULT '' + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + bucket TEXT NOT NULL DEFAULT 'inbox', + list_id TEXT, + effort TEXT, + done INTEGER NOT NULL DEFAULT 0, + done_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + source_transcript_id TEXT + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS task_lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + built_in INTEGER NOT NULL DEFAULT 0, + profile_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + Ok(()) +} + +// --- Transcript CRUD --- + +pub async fn insert_transcript( + pool: &SqlitePool, + id: &str, + text: &str, + source: &str, + title: Option<&str>, + audio_path: Option<&str>, + duration: f64, +) -> Result<()> { + sqlx::query( + "INSERT INTO transcripts (id, text, source, title, audio_path, duration) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(text) + .bind(source) + .bind(title) + .bind(audio_path) + .bind(duration) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?; + Ok(()) +} + +pub async fn get_transcript( + pool: &SqlitePool, + id: &str, +) -> Result> { + let row = sqlx::query( + "SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?; + + Ok(row.map(|r| TranscriptRow { + id: r.get("id"), + text: r.get("text"), + source: r.get("source"), + title: r.get("title"), + audio_path: r.get("audio_path"), + duration: r.get("duration"), + created_at: r.get("created_at"), + })) +} + +pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result> { + let rows = sqlx::query( + "SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?; + + Ok(rows + .into_iter() + .map(|r| TranscriptRow { + id: r.get("id"), + text: r.get("text"), + source: r.get("source"), + title: r.get("title"), + audio_path: r.get("audio_path"), + duration: r.get("duration"), + created_at: r.get("created_at"), + }) + .collect()) +} + +pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("DELETE FROM transcripts WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Delete transcript failed: {e}")))?; + Ok(()) +} + +// --- Task CRUD --- + +pub async fn insert_task( + pool: &SqlitePool, + id: &str, + text: &str, + bucket: &str, + source_transcript_id: Option<&str>, +) -> Result<()> { + sqlx::query( + "INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(text) + .bind(bucket) + .bind(source_transcript_id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?; + Ok(()) +} + +pub async fn list_tasks(pool: &SqlitePool) -> Result> { + let rows = + sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC") + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; + + Ok(rows + .into_iter() + .map(|r| TaskRow { + id: r.get("id"), + text: r.get("text"), + bucket: r.get("bucket"), + list_id: r.get("list_id"), + effort: r.get("effort"), + done: r.get("done"), + done_at: r.get("done_at"), + created_at: r.get("created_at"), + source_transcript_id: r.get("source_transcript_id"), + }) + .collect()) +} + +pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Complete task failed: {e}")))?; + Ok(()) +} + +pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("DELETE FROM tasks WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Delete task failed: {e}")))?; + Ok(()) +} + +// --- Settings CRUD --- + +pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> { + sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .bind(key) + .bind(value) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Set setting failed: {e}")))?; + Ok(()) +} + +pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> { + let row = sqlx::query("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get setting failed: {e}")))?; + Ok(row.map(|r| r.get("value"))) +} + +// --- Row types --- + +#[derive(Debug, Clone)] +pub struct TranscriptRow { + pub id: String, + pub text: String, + pub source: String, + pub title: Option, + pub audio_path: Option, + pub duration: f64, + pub created_at: String, +} + +#[derive(Debug, Clone)] +pub struct TaskRow { + pub id: String, + pub text: String, + pub bucket: String, + pub list_id: Option, + pub effort: Option, + pub done: bool, + pub done_at: Option, + pub created_at: String, + pub source_transcript_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + run_migrations(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn transcript_crud_roundtrip() { + let pool = test_pool().await; + + insert_transcript(&pool, "t1", "Hello world", "microphone", Some("Test"), None, 1.5) + .await + .unwrap(); + + let t = get_transcript(&pool, "t1").await.unwrap().unwrap(); + assert_eq!(t.text, "Hello world"); + assert_eq!(t.source, "microphone"); + assert_eq!(t.duration, 1.5); + + let list = list_transcripts(&pool, 10).await.unwrap(); + assert_eq!(list.len(), 1); + + delete_transcript(&pool, "t1").await.unwrap(); + let deleted = get_transcript(&pool, "t1").await.unwrap(); + assert!(deleted.is_none()); + } + + #[tokio::test] + async fn task_crud_roundtrip() { + let pool = test_pool().await; + + insert_task(&pool, "task1", "Buy groceries", "today", None) + .await + .unwrap(); + + let tasks = list_tasks(&pool).await.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].text, "Buy groceries"); + assert!(!tasks[0].done); + + complete_task(&pool, "task1").await.unwrap(); + let tasks = list_tasks(&pool).await.unwrap(); + assert!(tasks[0].done); + + delete_task(&pool, "task1").await.unwrap(); + let tasks = list_tasks(&pool).await.unwrap(); + assert!(tasks.is_empty()); + } + + #[tokio::test] + async fn settings_crud_roundtrip() { + let pool = test_pool().await; + + set_setting(&pool, "theme", "dark").await.unwrap(); + let val = get_setting(&pool, "theme").await.unwrap(); + assert_eq!(val.as_deref(), Some("dark")); + + set_setting(&pool, "theme", "light").await.unwrap(); + let val = get_setting(&pool, "theme").await.unwrap(); + assert_eq!(val.as_deref(), Some("light")); + + let missing = get_setting(&pool, "nonexistent").await.unwrap(); + assert!(missing.is_none()); + } +} diff --git a/crates/storage/src/file_storage.rs b/crates/storage/src/file_storage.rs new file mode 100644 index 0000000..6d320f8 --- /dev/null +++ b/crates/storage/src/file_storage.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; + +/// Resolve the app data directory. +/// Windows: %LOCALAPPDATA%/kon +/// Unix: ~/.kon +pub fn app_data_dir() -> PathBuf { + if cfg!(target_os = "windows") { + let local_app_data = + std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(local_app_data).join("kon") + } else { + let home = + std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".kon") + } +} + +/// Path to the SQLite database file. +pub fn database_path() -> PathBuf { + app_data_dir().join("kon.db") +} + +/// Directory for saved audio recordings. +pub fn recordings_dir() -> PathBuf { + app_data_dir().join("recordings") +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 53127ee..b50e9f3 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,2 +1,9 @@ -// kon-storage: SQLite via sqlx, BM25 full-text search via probly-search, -// and file storage path management. +pub mod database; +pub mod file_storage; + +pub use database::{ + complete_task, delete_task, delete_transcript, get_setting, get_transcript, + init, insert_task, insert_transcript, list_tasks, list_transcripts, + set_setting, TaskRow, TranscriptRow, +}; +pub use file_storage::{app_data_dir, database_path, recordings_dir};