From d79ad40b333ede73a4577c72d7a496846c358e82 Mon Sep 17 00:00:00 2001 From: jake Date: Sat, 21 Mar 2026 12:38:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(storage):=20add=20migration=20v2=20?= =?UTF-8?q?=E2=80=94=20task=20fields,=20FTS5=20search,=20timer=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration v2: adds priority, project, status, updated_at, sort_order, notes columns to tasks table - FTS5 virtual table on transcripts with insert/update/delete triggers for automatic index sync - Timer state table for persisting active countdown timers across app restarts - New database functions: insert_task_v2, update_task_v2, reorder_tasks, list_tasks_by_status, search_transcripts, save/get/clear_timer_state - TimerStateRow type for timer persistence - Extended TaskRow with v2 fields - Fixed migration runner to handle BEGIN...END blocks in CREATE TRIGGER statements - All 11 tests passing, Tauri app compiles cleanly --- crates/storage/src/database.rs | 410 +++++++++++++++++++++++++++++-- crates/storage/src/lib.rs | 8 +- crates/storage/src/migrations.rs | 132 +++++++++- 3 files changed, 524 insertions(+), 26 deletions(-) diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 2bb5f87..586d914 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -136,25 +136,12 @@ pub async fn insert_task( 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") + sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes FROM tasks ORDER BY sort_order ASC, 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()) + Ok(rows.into_iter().map(task_row_from).collect()) } pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { @@ -175,6 +162,180 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> { Ok(()) } +// --- Task v2 CRUD (extended fields) --- + +/// Insert a task with all v2 fields. +pub async fn insert_task_v2( + pool: &SqlitePool, + id: &str, + text: &str, + priority: &str, + project: Option<&str>, + status: &str, + bucket: &str, + effort: Option<&str>, + source_transcript_id: Option<&str>, + sort_order: i64, +) -> Result<()> { + sqlx::query( + "INSERT INTO tasks (id, text, priority, project, status, bucket, effort, source_transcript_id, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(text) + .bind(priority) + .bind(project) + .bind(status) + .bind(bucket) + .bind(effort) + .bind(source_transcript_id) + .bind(sort_order) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert task v2 failed: {e}")))?; + Ok(()) +} + +/// Update a task's mutable fields, setting updated_at to now. +pub async fn update_task_v2( + pool: &SqlitePool, + id: &str, + text: &str, + priority: &str, + project: Option<&str>, + status: &str, + bucket: &str, + effort: Option<&str>, + notes: &str, +) -> Result<()> { + sqlx::query( + "UPDATE tasks SET text = ?, priority = ?, project = ?, status = ?, bucket = ?, effort = ?, notes = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(text) + .bind(priority) + .bind(project) + .bind(status) + .bind(bucket) + .bind(effort) + .bind(notes) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Update task v2 failed: {e}")))?; + Ok(()) +} + +/// Reorder tasks by setting sort_order based on position in the given ID list. +pub async fn reorder_tasks(pool: &SqlitePool, task_ids: &[String]) -> Result<()> { + for (i, id) in task_ids.iter().enumerate() { + sqlx::query("UPDATE tasks SET sort_order = ?, updated_at = datetime('now') WHERE id = ?") + .bind(i as i64) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Reorder tasks failed: {e}")))?; + } + Ok(()) +} + +/// List tasks filtered by status, with a limit. +pub async fn list_tasks_by_status( + pool: &SqlitePool, + status: &str, + limit: i64, +) -> Result> { + let rows = sqlx::query( + "SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, priority, project, status, updated_at, sort_order, notes + FROM tasks WHERE status = ? + ORDER BY sort_order ASC, created_at DESC + LIMIT ?", + ) + .bind(status) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List tasks by status failed: {e}")))?; + + Ok(rows.into_iter().map(task_row_from).collect()) +} + +// --- FTS5 Search --- + +/// Full-text search across transcripts using the FTS5 virtual table. +pub async fn search_transcripts( + pool: &SqlitePool, + query: &str, + limit: i64, +) -> Result> { + let rows = sqlx::query( + "SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at + FROM transcripts t + JOIN transcripts_fts fts ON t.rowid = fts.rowid + WHERE transcripts_fts MATCH ?1 + ORDER BY rank + LIMIT ?2", + ) + .bind(query) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("FTS search failed: {e}")))?; + + Ok(rows.iter().map(transcript_row_from).collect()) +} + +// --- Timer State Persistence --- + +/// Save or replace the active timer state. +pub async fn save_timer_state( + pool: &SqlitePool, + task_id: &str, + total_seconds: i64, + remaining_seconds: i64, + paused: bool, +) -> Result<()> { + sqlx::query( + "INSERT OR REPLACE INTO timer_state (id, task_id, total_seconds, remaining_seconds, paused) + VALUES ('active', ?, ?, ?, ?)", + ) + .bind(task_id) + .bind(total_seconds) + .bind(remaining_seconds) + .bind(paused) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Save timer state failed: {e}")))?; + Ok(()) +} + +/// Retrieve the currently active timer state, if any. +pub async fn get_timer_state(pool: &SqlitePool) -> Result> { + let row = sqlx::query( + "SELECT id, task_id, total_seconds, remaining_seconds, started_at, paused FROM timer_state WHERE id = 'active'", + ) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get timer state failed: {e}")))?; + + Ok(row.map(|r| TimerStateRow { + id: r.get("id"), + task_id: r.get("task_id"), + total_seconds: r.get("total_seconds"), + remaining_seconds: r.get("remaining_seconds"), + started_at: r.get("started_at"), + paused: r.get("paused"), + })) +} + +/// Clear the active timer state (timer completed or cancelled). +pub async fn clear_timer_state(pool: &SqlitePool) -> Result<()> { + sqlx::query("DELETE FROM timer_state WHERE id = 'active'") + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Clear timer state failed: {e}")))?; + Ok(()) +} + // --- Settings CRUD --- pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> { @@ -229,6 +390,43 @@ pub struct TaskRow { pub done_at: Option, pub created_at: String, pub source_transcript_id: Option, + // v2 fields + pub priority: String, + pub project: Option, + pub status: String, + pub updated_at: String, + pub sort_order: i64, + pub notes: String, +} + +#[derive(Debug, Clone)] +pub struct TimerStateRow { + pub id: String, + pub task_id: String, + pub total_seconds: i64, + pub remaining_seconds: i64, + pub started_at: String, + pub paused: bool, +} + +fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { + 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"), + priority: r.get("priority"), + project: r.get("project"), + status: r.get("status"), + updated_at: r.get("updated_at"), + sort_order: r.get("sort_order"), + notes: r.get("notes"), + } } fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { @@ -357,6 +555,12 @@ mod tests { assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].text, "Buy groceries"); assert!(!tasks[0].done); + // v2 defaults should be populated + assert_eq!(tasks[0].priority, "medium"); + assert_eq!(tasks[0].status, "pending"); + assert_eq!(tasks[0].sort_order, 0); + assert_eq!(tasks[0].notes, ""); + assert!(tasks[0].project.is_none()); complete_task(&pool, "task1").await.unwrap(); let tasks = list_tasks(&pool).await.unwrap(); @@ -367,6 +571,182 @@ mod tests { assert!(tasks.is_empty()); } + #[tokio::test] + async fn task_v2_crud_roundtrip() { + let pool = test_pool().await; + + insert_task_v2( + &pool, + "task-v2", + "Write unit tests", + "high", + Some("kon"), + "active", + "today", + Some("small"), + None, + 1, + ) + .await + .unwrap(); + + let tasks = list_tasks(&pool).await.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].text, "Write unit tests"); + assert_eq!(tasks[0].priority, "high"); + assert_eq!(tasks[0].project.as_deref(), Some("kon")); + assert_eq!(tasks[0].status, "active"); + assert_eq!(tasks[0].sort_order, 1); + + update_task_v2( + &pool, + "task-v2", + "Write integration tests", + "medium", + None, + "done", + "today", + Some("medium"), + "Completed all tests", + ) + .await + .unwrap(); + + let tasks = list_tasks(&pool).await.unwrap(); + assert_eq!(tasks[0].text, "Write integration tests"); + assert_eq!(tasks[0].priority, "medium"); + assert!(tasks[0].project.is_none()); + assert_eq!(tasks[0].status, "done"); + assert_eq!(tasks[0].notes, "Completed all tests"); + } + + #[tokio::test] + async fn task_list_by_status() { + let pool = test_pool().await; + + insert_task_v2(&pool, "t1", "Task A", "high", None, "pending", "inbox", None, None, 0).await.unwrap(); + insert_task_v2(&pool, "t2", "Task B", "low", None, "active", "today", None, None, 1).await.unwrap(); + insert_task_v2(&pool, "t3", "Task C", "medium", None, "pending", "inbox", None, None, 2).await.unwrap(); + + let pending = list_tasks_by_status(&pool, "pending", 10).await.unwrap(); + assert_eq!(pending.len(), 2); + + let active = list_tasks_by_status(&pool, "active", 10).await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].text, "Task B"); + } + + #[tokio::test] + async fn task_reorder() { + let pool = test_pool().await; + + insert_task_v2(&pool, "t1", "First", "medium", None, "pending", "inbox", None, None, 0).await.unwrap(); + insert_task_v2(&pool, "t2", "Second", "medium", None, "pending", "inbox", None, None, 1).await.unwrap(); + insert_task_v2(&pool, "t3", "Third", "medium", None, "pending", "inbox", None, None, 2).await.unwrap(); + + // Reverse the order + reorder_tasks(&pool, &["t3".to_string(), "t2".to_string(), "t1".to_string()]) + .await + .unwrap(); + + let tasks = list_tasks(&pool).await.unwrap(); + assert_eq!(tasks[0].id, "t3"); + assert_eq!(tasks[1].id, "t2"); + assert_eq!(tasks[2].id, "t1"); + } + + #[tokio::test] + async fn fts_search_transcripts() { + let pool = test_pool().await; + + insert_transcript( + &pool, + &InsertTranscriptParams { + id: "tr1", + text: "Meeting about the quarterly budget review", + source: "microphone", + title: Some("Budget Meeting"), + audio_path: None, + duration: 120.0, + engine: Some("whisper"), + model_id: None, + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: true, + anti_hallucination: false, + }, + ) + .await + .unwrap(); + + insert_transcript( + &pool, + &InsertTranscriptParams { + id: "tr2", + text: "Grocery list for the weekend shop", + source: "microphone", + title: Some("Shopping"), + audio_path: None, + duration: 30.0, + engine: Some("whisper"), + model_id: None, + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: true, + anti_hallucination: false, + }, + ) + .await + .unwrap(); + + let results = search_transcripts(&pool, "budget", 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "tr1"); + + let results = search_transcripts(&pool, "grocery", 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "tr2"); + + let results = search_transcripts(&pool, "nonexistent", 10).await.unwrap(); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn timer_state_persistence() { + let pool = test_pool().await; + + // No timer state initially + let state = get_timer_state(&pool).await.unwrap(); + assert!(state.is_none()); + + // Save timer state + save_timer_state(&pool, "task-1", 300, 250, false).await.unwrap(); + + let state = get_timer_state(&pool).await.unwrap().unwrap(); + assert_eq!(state.task_id, "task-1"); + assert_eq!(state.total_seconds, 300); + assert_eq!(state.remaining_seconds, 250); + assert!(!state.paused); + + // Update timer state (pause) + save_timer_state(&pool, "task-1", 300, 200, true).await.unwrap(); + + let state = get_timer_state(&pool).await.unwrap().unwrap(); + assert_eq!(state.remaining_seconds, 200); + assert!(state.paused); + + // Clear timer + clear_timer_state(&pool).await.unwrap(); + let state = get_timer_state(&pool).await.unwrap(); + assert!(state.is_none()); + } + #[tokio::test] async fn settings_crud_roundtrip() { let pool = test_pool().await; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b445049..f025a75 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -3,8 +3,10 @@ pub mod file_storage; pub mod migrations; pub use database::{ - complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, - insert_transcript, list_tasks, list_transcripts, log_error, set_setting, - InsertTranscriptParams, TaskRow, TranscriptRow, + clear_timer_state, complete_task, delete_task, delete_transcript, get_setting, + get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript, + list_tasks, list_tasks_by_status, list_transcripts, log_error, reorder_tasks, + save_timer_state, search_transcripts, set_setting, update_task_v2, + InsertTranscriptParams, TaskRow, TimerStateRow, TranscriptRow, }; pub use file_storage::{app_data_dir, database_path, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 2af4269..6fc1d91 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -73,8 +73,106 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id); CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context) "#), + (2, "phase 2 — task fields, FTS5, timer state", r#" + ALTER TABLE tasks ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium'; + ALTER TABLE tasks ADD COLUMN project TEXT; + ALTER TABLE tasks ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'; + ALTER TABLE tasks ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now')); + ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; + ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''; + + CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5( + text, + title, + content='transcripts', + content_rowid='rowid' + ); + + CREATE TRIGGER IF NOT EXISTS transcripts_ai AFTER INSERT ON transcripts BEGIN + INSERT INTO transcripts_fts(rowid, text, title) + VALUES (new.rowid, new.text, new.title); + END; + + CREATE TRIGGER IF NOT EXISTS transcripts_ad AFTER DELETE ON transcripts BEGIN + INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) + VALUES ('delete', old.rowid, old.text, old.title); + END; + + CREATE TRIGGER IF NOT EXISTS transcripts_au AFTER UPDATE ON transcripts BEGIN + INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title) + VALUES ('delete', old.rowid, old.text, old.title); + INSERT INTO transcripts_fts(rowid, text, title) + VALUES (new.rowid, new.text, new.title); + END; + + CREATE TABLE IF NOT EXISTS timer_state ( + id TEXT PRIMARY KEY DEFAULT 'active', + task_id TEXT NOT NULL, + total_seconds INTEGER NOT NULL, + remaining_seconds INTEGER NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + paused INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); + CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project) + "#), ]; +/// Split SQL text into individual statements, respecting BEGIN...END blocks +/// used by CREATE TRIGGER statements. A naive split on ';' would break +/// triggers that contain semicolons inside their body. +fn split_sql_statements(sql: &str) -> Vec { + let mut statements = Vec::new(); + let mut current = String::new(); + let mut depth = 0; // Track BEGIN...END nesting + + for fragment in sql.split(';') { + let trimmed = fragment.trim(); + if trimmed.is_empty() { + continue; + } + + let upper = trimmed.to_uppercase(); + + // Count BEGIN/END keywords to track trigger body nesting + if upper.contains("BEGIN") { + depth += 1; + } + + if depth > 0 { + // Inside a BEGIN...END block — accumulate fragments + if !current.is_empty() { + current.push(';'); + } + current.push_str(fragment); + + if upper.trim_end().ends_with("END") { + depth -= 1; + if depth == 0 { + let stmt = current.trim().to_string(); + if !stmt.is_empty() { + statements.push(stmt); + } + current.clear(); + } + } + } else { + // Normal statement — emit directly + statements.push(trimmed.to_string()); + } + } + + // Emit anything left over + let leftover = current.trim().to_string(); + if !leftover.is_empty() { + statements.push(leftover); + } + + statements +} + /// Ensure the schema_version table exists and run any pending migrations. pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { sqlx::query( @@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { if *version > current { log::info!("Running migration {}: {}", version, description); - let statements: Vec<&str> = sql.split(';') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .collect(); + let statements = split_sql_statements(sql); - for statement in statements { + for statement in &statements { sqlx::query(statement) .execute(pool) .await - .map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?; + .map_err(|e| KonError::StorageError(format!("Migration {} failed on statement: {e}\nSQL: {}", version, statement)))?; } sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)") @@ -141,7 +236,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 1); + assert_eq!(count, 2); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -149,6 +244,27 @@ mod tests { .unwrap(); } + #[test] + fn test_split_sql_handles_triggers() { + let sql = r#" + ALTER TABLE tasks ADD COLUMN priority TEXT; + + CREATE TRIGGER IF NOT EXISTS test_trigger AFTER INSERT ON tasks BEGIN + INSERT INTO log(msg) VALUES (new.id); + END; + + CREATE TABLE IF NOT EXISTS timer (id TEXT PRIMARY KEY) + "#; + + let stmts = split_sql_statements(sql); + assert_eq!(stmts.len(), 3, "Expected 3 statements, got: {stmts:#?}"); + assert!(stmts[0].contains("ALTER TABLE")); + assert!(stmts[1].contains("CREATE TRIGGER")); + assert!(stmts[1].contains("BEGIN")); + assert!(stmts[1].contains("END")); + assert!(stmts[2].contains("CREATE TABLE")); + } + #[tokio::test] async fn test_migrations_idempotent() { let pool = SqlitePoolOptions::new() @@ -163,6 +279,6 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 1); + assert_eq!(count, 2); } }