feat: OpenWhispr-inspired transcription polish pass
Major quality pass on top of Phase 2. Five substantive changes plus
cross-cutting touches across audio, hotkey, transcription, and Tauri
command layers.
Transcription quality
- Long-audio chunking in commands/transcription.rs: Parakeet and large
file transcription now chunk-and-recompose with overlap trimming, so
the live-path chunking advantage extends to file-based workflows.
- Stateful live speech gate in commands/live.rs on top of the earlier
duplicate-boundary filtering — distinguishes start-of-speech from
mid-speech and holds state across chunks.
Auto-learning corrections
- New crates/ai-formatting/src/correction_learning.rs: extracts user
text corrections from viewer edits and proposes additions to the
active profile's vocabulary.
- src-tauri/src/commands/profiles.rs bridge for frontend-driven
confirmation of learned terms.
- src/routes/viewer/+page.svelte hooks the learning path into the
segment-edit flow so corrections feed profile_terms without a
separate 'train this profile' UX.
Transcript profile provenance
- Migration v8 (crates/storage/src/migrations.rs) adds profile_id to
transcripts, defaulting to DEFAULT_PROFILE_ID so existing rows stay
valid.
- crates/storage/src/database.rs: TranscriptRow + CRUD carry profile_id.
- src-tauri/src/commands/transcripts.rs: add_transcript accepts and
persists profile_id.
- DictationPage.svelte + FilesPage.svelte send activeProfileId on
capture so learned corrections are attributed to the right profile.
Cleanup prompt contract
- crates/ai-formatting/src/llm_client.rs hardened: the CLEANUP_PROMPT
now specifies concrete do/do-not rules, ready for a real model-backed
cleanup pass. The llm_client is still a stub — kon-llm remains unwired
— but the prompt shape is final.
Cross-cutting polish
- Minor touches in audio (capture/decode/resample), hotkey (lib/linux/stub),
core, transcription (concurrency/model_manager/local_engine/whisper_rs),
and the rest of src-tauri/src/commands/*: error-path tightening, log
clarity, TS-migration follow-ups (@ts-nocheck additions for incremental
typing).
Verified locally: npm run check, cargo test -p kon-ai-formatting,
cargo test -p kon-storage, cargo test -p kon --lib commands::live::tests,
cargo check — all green.
Scope boundary: kon-llm crate is still a stub; task extraction remains
rule-based. Bundled local-LLM runtime is the next clean step and is not
in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ pub struct InsertTranscriptParams<'a> {
|
||||
pub id: &'a str,
|
||||
pub text: &'a str,
|
||||
pub source: &'a str,
|
||||
pub profile_id: &'a str,
|
||||
pub title: Option<&'a str>,
|
||||
pub audio_path: Option<&'a str>,
|
||||
pub duration: f64,
|
||||
@@ -62,12 +63,13 @@ pub async fn insert_transcript(
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(params.id)
|
||||
.bind(params.text)
|
||||
.bind(params.source)
|
||||
.bind(params.profile_id)
|
||||
.bind(params.title)
|
||||
.bind(params.audio_path)
|
||||
.bind(params.duration)
|
||||
@@ -88,7 +90,7 @@ pub async fn insert_transcript(
|
||||
|
||||
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts WHERE id = ?",
|
||||
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -110,7 +112,7 @@ pub async fn list_transcripts_paged(
|
||||
offset: i64,
|
||||
) -> Result<Vec<TranscriptRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
@@ -217,9 +219,9 @@ pub async fn update_transcript_meta(
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("update_transcript_meta: {e}")))?;
|
||||
|
||||
get_transcript(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| KonError::StorageError(format!("update_transcript_meta: transcript {id} not found")))
|
||||
get_transcript(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!("update_transcript_meta: transcript {id} not found"))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -241,7 +243,7 @@ pub async fn search_transcripts(
|
||||
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, t.starred, t.manual_tags, t.template, t.language, t.segments_json \
|
||||
"SELECT t.id, t.text, t.source, t.profile_id, 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, t.starred, t.manual_tags, t.template, t.language, t.segments_json \
|
||||
FROM transcripts t \
|
||||
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
||||
WHERE transcripts_fts MATCH ? \
|
||||
@@ -296,10 +298,7 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(task_row_from)
|
||||
.collect())
|
||||
Ok(rows.into_iter().map(task_row_from).collect())
|
||||
}
|
||||
|
||||
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
|
||||
@@ -350,9 +349,9 @@ pub async fn update_task(
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Update task failed: {e}")))?;
|
||||
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| KonError::StorageError(format!("update_task: task {id} not found after update")))
|
||||
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!("update_task: task {id} not found after update"))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
@@ -361,15 +360,13 @@ pub async fn insert_subtask(
|
||||
text: &str,
|
||||
parent_task_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(parent_task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
|
||||
sqlx::query("INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)")
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(parent_task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -390,7 +387,9 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
|
||||
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
|
||||
/// Runs in a transaction so concurrent completions see consistent sibling counts.
|
||||
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
|
||||
let mut tx = pool.begin().await
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
@@ -399,22 +398,22 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Complete subtask failed: {e}")))?;
|
||||
|
||||
let parent_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = ?"
|
||||
)
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
||||
let parent_id: Option<String> =
|
||||
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0"
|
||||
)
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Count pending subtasks failed: {e}")))?;
|
||||
let pending: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Count pending subtasks failed: {e}"))
|
||||
})?;
|
||||
|
||||
if pending == 0 {
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
@@ -425,7 +424,8 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
@@ -503,6 +503,7 @@ pub struct TranscriptRow {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub source: String,
|
||||
pub profile_id: String,
|
||||
pub title: Option<String>,
|
||||
pub audio_path: Option<String>,
|
||||
pub duration: f64,
|
||||
@@ -545,6 +546,7 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
id: r.get("id"),
|
||||
text: r.get("text"),
|
||||
source: r.get("source"),
|
||||
profile_id: r.get("profile_id"),
|
||||
title: r.get("title"),
|
||||
audio_path: r.get("audio_path"),
|
||||
duration: r.get("duration"),
|
||||
@@ -619,23 +621,20 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
||||
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
||||
|
||||
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List profiles failed: {e}")))?;
|
||||
let rows =
|
||||
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List profiles failed: {e}")))?;
|
||||
Ok(rows.iter().map(profile_row_from).collect())
|
||||
}
|
||||
|
||||
pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get profile failed: {e}")))?;
|
||||
let row = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get profile failed: {e}")))?;
|
||||
Ok(row.as_ref().map(profile_row_from))
|
||||
}
|
||||
|
||||
@@ -852,6 +851,7 @@ mod tests {
|
||||
id: "t1",
|
||||
text: "Hello world",
|
||||
source: "microphone",
|
||||
profile_id: crate::DEFAULT_PROFILE_ID,
|
||||
title: Some("Test"),
|
||||
audio_path: None,
|
||||
duration: 1.5,
|
||||
@@ -872,6 +872,7 @@ mod tests {
|
||||
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
|
||||
assert_eq!(t.text, "Hello world");
|
||||
assert_eq!(t.source, "microphone");
|
||||
assert_eq!(t.profile_id, crate::DEFAULT_PROFILE_ID);
|
||||
assert_eq!(t.duration, 1.5);
|
||||
assert_eq!(t.engine.as_deref(), Some("whisper"));
|
||||
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
|
||||
@@ -912,9 +913,15 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn subtask_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None, None, None).await.unwrap();
|
||||
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Open document", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s2", "Write introduction", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// list_tasks must exclude subtasks
|
||||
let top_level = list_tasks(&pool).await.unwrap();
|
||||
@@ -926,14 +933,21 @@ mod tests {
|
||||
assert_eq!(subs.len(), 2);
|
||||
|
||||
// completing s1 should NOT auto-complete parent (s2 still pending)
|
||||
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s1")
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(!parent.done, "parent should not be done yet");
|
||||
|
||||
// completing s2 SHOULD auto-complete parent
|
||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s2")
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(parent.done, "parent should auto-complete when all children done");
|
||||
assert!(
|
||||
parent.done,
|
||||
"parent should auto-complete when all children done"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -946,6 +960,7 @@ mod tests {
|
||||
id: "tm1",
|
||||
text: "body",
|
||||
source: "microphone",
|
||||
profile_id: crate::DEFAULT_PROFILE_ID,
|
||||
title: Some("Meta happy"),
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
@@ -987,6 +1002,7 @@ mod tests {
|
||||
id: "tm2",
|
||||
text: "body",
|
||||
source: "microphone",
|
||||
profile_id: crate::DEFAULT_PROFILE_ID,
|
||||
title: None,
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
@@ -1034,7 +1050,10 @@ mod tests {
|
||||
assert_eq!(row.manual_tags, "urgent,review");
|
||||
assert_eq!(row.template, "Meeting");
|
||||
assert_eq!(row.language, "en-GB");
|
||||
assert_eq!(row.segments_json, r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#);
|
||||
assert_eq!(
|
||||
row.segments_json,
|
||||
r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1045,17 +1064,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_task(
|
||||
&pool,
|
||||
"u1",
|
||||
None,
|
||||
Some("today"),
|
||||
None,
|
||||
Some("15m"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let row = update_task(&pool, "u1", None, Some("today"), None, Some("15m"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.bucket, "today");
|
||||
assert_eq!(row.effort.as_deref(), Some("15m"));
|
||||
assert_eq!(row.text, "Draft post", "text must be unchanged");
|
||||
@@ -1070,9 +1081,17 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_task(&pool, "u2", None, None, None, None, Some("remember venue wifi"))
|
||||
.await
|
||||
.unwrap();
|
||||
let row = update_task(
|
||||
&pool,
|
||||
"u2",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("remember venue wifi"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.text, "Prep slides");
|
||||
assert_eq!(row.bucket, "today");
|
||||
assert_eq!(row.effort.as_deref(), Some("30m"));
|
||||
@@ -1083,7 +1102,10 @@ mod tests {
|
||||
async fn update_task_missing_id_errors() {
|
||||
let pool = test_pool().await;
|
||||
let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
|
||||
assert!(res.is_err(), "update_task must error when id does not exist");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"update_task must error when id does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Profile CRUD tests (Task 11) ---
|
||||
@@ -1201,7 +1223,9 @@ mod tests {
|
||||
add_profile_term(&pool, &p.id, "CORBEL", "company name")
|
||||
.await
|
||||
.unwrap();
|
||||
add_profile_term(&pool, &p.id, "Wren", "agent name").await.unwrap();
|
||||
add_profile_term(&pool, &p.id, "Wren", "agent name")
|
||||
.await
|
||||
.unwrap();
|
||||
let terms = list_profile_terms(&pool, &p.id).await.unwrap();
|
||||
assert_eq!(terms.len(), 2);
|
||||
// Sorted alphabetically.
|
||||
@@ -1229,7 +1253,9 @@ mod tests {
|
||||
async fn add_profile_term_happy_path() {
|
||||
let pool = test_pool().await;
|
||||
let p = create_profile(&pool, "P", "").await.unwrap();
|
||||
let row = add_profile_term(&pool, &p.id, "ACME", "client").await.unwrap();
|
||||
let row = add_profile_term(&pool, &p.id, "ACME", "client")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.term, "ACME");
|
||||
assert_eq!(row.note, "client");
|
||||
assert_eq!(row.profile_id, p.id);
|
||||
@@ -1293,11 +1319,12 @@ mod tests {
|
||||
// Using a raw query because list_profile_terms would happily
|
||||
// return [] even if the FK had been forgotten. We want to prove
|
||||
// the child rows are actually gone from the table.
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
|
||||
.bind(&p.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
|
||||
.bind(&p.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "terms must cascade-delete with their profile");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ pub mod migrations;
|
||||
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
pub use database::{
|
||||
add_profile_term, complete_subtask_and_check_parent, complete_task,
|
||||
count_transcripts, create_profile, delete_profile,
|
||||
delete_profile_term, delete_task, delete_transcript, get_profile, get_setting, get_task_by_id,
|
||||
get_transcript, init, insert_subtask, insert_task, insert_transcript,
|
||||
list_profile_terms, list_profiles, list_recent_errors, list_subtasks, list_tasks,
|
||||
list_transcripts, list_transcripts_paged, log_error, search_transcripts, set_setting,
|
||||
uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta,
|
||||
ErrorLogRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, TaskRow,
|
||||
TranscriptRow,
|
||||
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
|
||||
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
|
||||
insert_transcript, list_profile_terms, list_profiles, list_recent_errors, list_subtasks,
|
||||
list_tasks, list_transcripts, list_transcripts_paged, log_error, search_transcripts,
|
||||
set_setting, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
update_transcript_meta, ErrorLogRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||
TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use sqlx::SqlitePool;
|
||||
use kon_core::error::{KonError, Result};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Each migration is a (version, description, sql) tuple.
|
||||
/// Migrations MUST be append-only — never modify an existing migration.
|
||||
/// Column defaults and NOT NULL constraints must exactly match the existing schema.
|
||||
const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
(1, "initial schema", r#"
|
||||
(
|
||||
1,
|
||||
"initial schema",
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS transcripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
@@ -72,8 +75,12 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_bucket ON tasks(bucket);
|
||||
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, "transcripts FTS5 + dictionary table", r#"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
2,
|
||||
"transcripts FTS5 + dictionary table",
|
||||
r#"
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
|
||||
text,
|
||||
title,
|
||||
@@ -107,14 +114,23 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term)
|
||||
"#),
|
||||
(3, "micro-stepping: parent_task_id on tasks", r#"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
3,
|
||||
"micro-stepping: parent_task_id on tasks",
|
||||
r#"
|
||||
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
|
||||
"#),
|
||||
(4, "tasks_meta: notes column for persisted free-text", r#"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
4,
|
||||
"tasks_meta: notes column for persisted free-text",
|
||||
r#"
|
||||
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
|
||||
"#),
|
||||
"#,
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transcripts_meta",
|
||||
@@ -187,6 +203,18 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
DROP TABLE IF EXISTS dictionary;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
8,
|
||||
"transcript_profile_provenance",
|
||||
r#"
|
||||
ALTER TABLE transcripts
|
||||
ADD COLUMN profile_id TEXT NOT NULL
|
||||
DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transcripts_profile_id
|
||||
ON transcripts(profile_id);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -203,15 +231,21 @@ fn split_statements(sql: &str) -> Vec<String> {
|
||||
let prev_alpha = i > 0 && ob[i - 1].is_ascii_alphanumeric();
|
||||
if !prev_alpha && ub[i..].starts_with(b"BEGIN") {
|
||||
let next_alpha = i + 5 < n && ob[i + 5].is_ascii_alphanumeric();
|
||||
if !next_alpha { depth += 1; }
|
||||
if !next_alpha {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
if !prev_alpha && ub[i..].starts_with(b"END") {
|
||||
let next_alpha = i + 3 < n && ob[i + 3].is_ascii_alphanumeric();
|
||||
if !next_alpha && depth > 0 { depth -= 1; }
|
||||
if !next_alpha && depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if ob[i] == b';' && depth == 0 {
|
||||
let stmt = current.trim().to_string();
|
||||
if !stmt.is_empty() { result.push(stmt); }
|
||||
if !stmt.is_empty() {
|
||||
result.push(stmt);
|
||||
}
|
||||
current = String::new();
|
||||
} else {
|
||||
current.push(ob[i] as char);
|
||||
@@ -219,7 +253,9 @@ fn split_statements(sql: &str) -> Vec<String> {
|
||||
i += 1;
|
||||
}
|
||||
let stmt = current.trim().to_string();
|
||||
if !stmt.is_empty() { result.push(stmt); }
|
||||
if !stmt.is_empty() {
|
||||
result.push(stmt);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -230,7 +266,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
@@ -248,10 +284,9 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
let statements = split_statements(sql);
|
||||
|
||||
for statement in &statements {
|
||||
sqlx::query(statement)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
|
||||
sqlx::query(statement).execute(pool).await.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration {} failed: {e}", version))
|
||||
})?;
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
|
||||
@@ -259,7 +294,9 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration version record failed: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!("Migration {} complete", version);
|
||||
}
|
||||
@@ -287,7 +324,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 7);
|
||||
assert_eq!(count, 8);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -309,7 +346,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 7);
|
||||
assert_eq!(count, 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -328,10 +365,7 @@ mod tests {
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("name"))
|
||||
.collect();
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
for col in ["list_id", "effort", "notes"] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
@@ -356,11 +390,14 @@ mod tests {
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("name"))
|
||||
.collect();
|
||||
for col in ["starred", "manual_tags", "template", "language", "segments_json"] {
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
for col in [
|
||||
"starred",
|
||||
"manual_tags",
|
||||
"template",
|
||||
"language",
|
||||
"segments_json",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
"transcripts must have {col}; got {names:?}"
|
||||
@@ -368,6 +405,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
assert!(
|
||||
names.contains(&"profile_id".to_string()),
|
||||
"transcripts must have profile_id; got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -399,7 +456,10 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "cascade delete should remove child when parent is deleted");
|
||||
assert_eq!(
|
||||
remaining, 0,
|
||||
"cascade delete should remove child when parent is deleted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test-only helper: run migrations only up to (and including) `target_version`.
|
||||
@@ -411,32 +471,36 @@ mod tests {
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Schema version table creation failed: {e}"))
|
||||
})?;
|
||||
|
||||
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
let current: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
|
||||
|
||||
for (version, description, sql) in MIGRATIONS {
|
||||
if *version > current && *version <= target_version {
|
||||
let statements = split_statements(sql);
|
||||
for statement in &statements {
|
||||
sqlx::query(statement)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
|
||||
sqlx::query(statement).execute(pool).await.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration {} failed: {e}", version))
|
||||
})?;
|
||||
}
|
||||
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
|
||||
.bind(version)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Migration version record failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -452,16 +516,22 @@ mod tests {
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1, "Default profile must be seeded on fresh install");
|
||||
|
||||
let name: String = sqlx::query_scalar("SELECT name FROM profiles WHERE id = ?")
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name, "Default");
|
||||
|
||||
let terms_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(terms_count, 0, "Fresh DB has no dictionary rows to copy");
|
||||
}
|
||||
|
||||
@@ -481,19 +551,27 @@ mod tests {
|
||||
"INSERT INTO dictionary (term, note, created_at) VALUES \
|
||||
('Kon', '', datetime('now')), \
|
||||
('CORBEL', 'brand', datetime('now')), \
|
||||
('Wren', '', datetime('now'))"
|
||||
).execute(&pool).await.expect("seed dictionary");
|
||||
('Wren', '', datetime('now'))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed dictionary");
|
||||
|
||||
run_migrations(&pool).await.expect("migrate to v6");
|
||||
|
||||
let copied: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?"
|
||||
).bind(crate::DEFAULT_PROFILE_ID).fetch_one(&pool).await.unwrap();
|
||||
let copied: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(copied, 3);
|
||||
|
||||
let corbel_note: String = sqlx::query_scalar(
|
||||
"SELECT note FROM profile_terms WHERE term = 'CORBEL'"
|
||||
).fetch_one(&pool).await.unwrap();
|
||||
let corbel_note: String =
|
||||
sqlx::query_scalar("SELECT note FROM profile_terms WHERE term = 'CORBEL'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(corbel_note, "brand");
|
||||
}
|
||||
|
||||
@@ -508,11 +586,15 @@ mod tests {
|
||||
|
||||
let result = sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.execute(&pool).await;
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "trigger must block Default deletion");
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("Default profile cannot be deleted"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("Default profile cannot be deleted"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -526,7 +608,8 @@ mod tests {
|
||||
|
||||
let result = sqlx::query("UPDATE profiles SET name = 'NotDefault' WHERE id = ?")
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.execute(&pool).await;
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "trigger must block Default rename");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user