feat(storage): add migration v2 — task fields, FTS5 search, timer state
- 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
This commit is contained in:
@@ -136,25 +136,12 @@ pub async fn insert_task(
|
|||||||
|
|
||||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||||
let rows =
|
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)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows.into_iter().map(task_row_from).collect())
|
||||||
.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<()> {
|
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(())
|
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<Vec<TaskRow>> {
|
||||||
|
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<Vec<TranscriptRow>> {
|
||||||
|
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<Option<TimerStateRow>> {
|
||||||
|
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 ---
|
// --- Settings CRUD ---
|
||||||
|
|
||||||
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
||||||
@@ -229,6 +390,43 @@ pub struct TaskRow {
|
|||||||
pub done_at: Option<String>,
|
pub done_at: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub source_transcript_id: Option<String>,
|
pub source_transcript_id: Option<String>,
|
||||||
|
// v2 fields
|
||||||
|
pub priority: String,
|
||||||
|
pub project: Option<String>,
|
||||||
|
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 {
|
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||||
@@ -357,6 +555,12 @@ mod tests {
|
|||||||
assert_eq!(tasks.len(), 1);
|
assert_eq!(tasks.len(), 1);
|
||||||
assert_eq!(tasks[0].text, "Buy groceries");
|
assert_eq!(tasks[0].text, "Buy groceries");
|
||||||
assert!(!tasks[0].done);
|
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();
|
complete_task(&pool, "task1").await.unwrap();
|
||||||
let tasks = list_tasks(&pool).await.unwrap();
|
let tasks = list_tasks(&pool).await.unwrap();
|
||||||
@@ -367,6 +571,182 @@ mod tests {
|
|||||||
assert!(tasks.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn settings_crud_roundtrip() {
|
async fn settings_crud_roundtrip() {
|
||||||
let pool = test_pool().await;
|
let pool = test_pool().await;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ pub mod file_storage;
|
|||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
|
clear_timer_state, complete_task, delete_task, delete_transcript, get_setting,
|
||||||
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
|
get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript,
|
||||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
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};
|
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
||||||
|
|||||||
@@ -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_tasks_transcript ON tasks(source_transcript_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
|
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<String> {
|
||||||
|
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.
|
/// Ensure the schema_version table exists and run any pending migrations.
|
||||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -97,16 +195,13 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
|||||||
if *version > current {
|
if *version > current {
|
||||||
log::info!("Running migration {}: {}", version, description);
|
log::info!("Running migration {}: {}", version, description);
|
||||||
|
|
||||||
let statements: Vec<&str> = sql.split(';')
|
let statements = split_sql_statements(sql);
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for statement in statements {
|
for statement in &statements {
|
||||||
sqlx::query(statement)
|
sqlx::query(statement)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.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 (?, ?)")
|
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
|
||||||
@@ -141,7 +236,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 2);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -149,6 +244,27 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
async fn test_migrations_idempotent() {
|
async fn test_migrations_idempotent() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
@@ -163,6 +279,6 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user