feat(transcripts): migration v5 meta — starred, tags, template, language, segments persisted
This commit is contained in:
@@ -88,7 +88,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 FROM transcripts WHERE id = ?",
|
||||
"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 = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -110,7 +110,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 FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
"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 ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
@@ -179,6 +179,49 @@ pub async fn update_transcript(
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch-style update for the Task 2.5 transcripts_meta columns (starred,
|
||||
/// manual_tags, template, language, segments_json). Follows the same
|
||||
/// COALESCE pattern as `update_task`: each `Option::Some` overwrites,
|
||||
/// each `None` preserves the existing column value. Returns the refreshed
|
||||
/// row or errors if `id` does not exist after the UPDATE.
|
||||
///
|
||||
/// Keeping this separate from `update_transcript` (text / title) means the
|
||||
/// existing command surface stays stable — the viewer and history store
|
||||
/// keep calling `update_transcript` for content edits, and call this for
|
||||
/// UI-metadata persistence.
|
||||
pub async fn update_transcript_meta(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
starred: Option<bool>,
|
||||
manual_tags: Option<&str>,
|
||||
template: Option<&str>,
|
||||
language: Option<&str>,
|
||||
segments_json: Option<&str>,
|
||||
) -> Result<TranscriptRow> {
|
||||
sqlx::query(
|
||||
"UPDATE transcripts SET \
|
||||
starred = COALESCE(?, starred), \
|
||||
manual_tags = COALESCE(?, manual_tags), \
|
||||
template = COALESCE(?, template), \
|
||||
language = COALESCE(?, language), \
|
||||
segments_json = COALESCE(?, segments_json) \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(starred.map(|b| b as i64))
|
||||
.bind(manual_tags)
|
||||
.bind(template)
|
||||
.bind(language)
|
||||
.bind(segments_json)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.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")))
|
||||
}
|
||||
|
||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
||||
.bind(id)
|
||||
@@ -198,7 +241,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 \
|
||||
"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 \
|
||||
FROM transcripts t \
|
||||
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
||||
WHERE transcripts_fts MATCH ? \
|
||||
@@ -512,6 +555,13 @@ pub struct TranscriptRow {
|
||||
pub british_english: bool,
|
||||
pub anti_hallucination: bool,
|
||||
pub created_at: String,
|
||||
// Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata
|
||||
// that previously lived in the removed localStorage `kon_history` cache.
|
||||
pub starred: bool,
|
||||
pub manual_tags: String,
|
||||
pub template: String,
|
||||
pub language: String,
|
||||
pub segments_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -547,6 +597,12 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
british_english: r.get("british_english"),
|
||||
anti_hallucination: r.get("anti_hallucination"),
|
||||
created_at: r.get("created_at"),
|
||||
// Task 2.5 (migration v5). INTEGER 0/1 → bool via `!= 0`.
|
||||
starred: r.get::<i64, _>("starred") != 0,
|
||||
manual_tags: r.get("manual_tags"),
|
||||
template: r.get("template"),
|
||||
language: r.get("language"),
|
||||
segments_json: r.get("segments_json"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +799,107 @@ mod tests {
|
||||
assert!(parent.done, "parent should auto-complete when all children done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_transcript_meta_happy_path() {
|
||||
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
||||
let pool = test_pool().await;
|
||||
insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "tm1",
|
||||
text: "body",
|
||||
source: "microphone",
|
||||
title: Some("Meta happy"),
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
engine: None,
|
||||
model_id: None,
|
||||
inference_ms: None,
|
||||
sample_rate: None,
|
||||
audio_channels: None,
|
||||
format_mode: None,
|
||||
remove_fillers: false,
|
||||
british_english: false,
|
||||
anti_hallucination: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(row.starred, "starred must flip true after update");
|
||||
|
||||
let fetched = get_transcript(&pool, "tm1").await.unwrap().unwrap();
|
||||
assert!(fetched.starred, "starred must persist via SELECT");
|
||||
assert_eq!(fetched.manual_tags, "");
|
||||
assert_eq!(fetched.template, "");
|
||||
assert_eq!(fetched.language, "");
|
||||
assert_eq!(fetched.segments_json, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_transcript_meta_partial_leaves_others_unchanged() {
|
||||
// Task 2.5 — partial update: only segments_json changes; the other
|
||||
// meta columns must be preserved by COALESCE.
|
||||
let pool = test_pool().await;
|
||||
insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "tm2",
|
||||
text: "body",
|
||||
source: "microphone",
|
||||
title: None,
|
||||
audio_path: None,
|
||||
duration: 0.0,
|
||||
engine: None,
|
||||
model_id: None,
|
||||
inference_ms: None,
|
||||
sample_rate: None,
|
||||
audio_channels: None,
|
||||
format_mode: None,
|
||||
remove_fillers: false,
|
||||
british_english: false,
|
||||
anti_hallucination: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Seed starred + tags + template + language first.
|
||||
update_transcript_meta(
|
||||
&pool,
|
||||
"tm2",
|
||||
Some(true),
|
||||
Some("urgent,review"),
|
||||
Some("Meeting"),
|
||||
Some("en-GB"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now update only segments_json.
|
||||
let row = update_transcript_meta(
|
||||
&pool,
|
||||
"tm2",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(row.starred, "starred must survive partial update");
|
||||
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"}]"#);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_overwrites_provided_fields() {
|
||||
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
||||
|
||||
@@ -7,7 +7,8 @@ pub use database::{
|
||||
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
|
||||
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
|
||||
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
||||
log_error, search_transcripts, set_setting, update_task, update_transcript, DictionaryEntry,
|
||||
ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
log_error, search_transcripts, set_setting, update_task, update_transcript,
|
||||
update_transcript_meta, DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow,
|
||||
TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -115,6 +115,17 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
(4, "tasks_meta: notes column for persisted free-text", r#"
|
||||
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
|
||||
"#),
|
||||
(
|
||||
5,
|
||||
"transcripts_meta",
|
||||
r#"
|
||||
ALTER TABLE transcripts ADD COLUMN starred INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE transcripts ADD COLUMN manual_tags TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE transcripts ADD COLUMN template TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE transcripts ADD COLUMN language TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -215,7 +226,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 4);
|
||||
assert_eq!(count, 5);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -237,7 +248,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 4);
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -268,6 +279,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcripts_meta_adds_columns() {
|
||||
// Task 2.5 — verify starred / manual_tags / template / language /
|
||||
// segments_json are all present on the transcripts table after
|
||||
// migrations run. All five are added by v5.
|
||||
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();
|
||||
for col in ["starred", "manual_tags", "template", "language", "segments_json"] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
"transcripts must have {col}; got {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
|
||||
Reference in New Issue
Block a user