From e248923f5d43daff9e779a4b9b0d2e742fbacf84 Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 19 Apr 2026 16:35:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(transcripts):=20migration=20v5=20meta=20?= =?UTF-8?q?=E2=80=94=20starred,=20tags,=20template,=20language,=20segments?= =?UTF-8?q?=20persisted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/storage/src/database.rs | 163 +++++++++++++++++++++++++- crates/storage/src/lib.rs | 5 +- crates/storage/src/migrations.rs | 43 ++++++- src-tauri/src/commands/transcripts.rs | 56 ++++++++- src-tauri/src/lib.rs | 1 + src/lib/stores/page.svelte.js | 76 +++++++++++- src/routes/viewer/+page.svelte | 40 +++++-- 7 files changed, 360 insertions(+), 24 deletions(-) diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 4129a03..35b11e7 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -88,7 +88,7 @@ pub async fn insert_transcript( pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result> { 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> { 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, + manual_tags: Option<&str>, + template: Option<&str>, + language: Option<&str>, + segments_json: Option<&str>, +) -> Result { + 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> { 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::("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. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index d533177..afe07a4 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index a3cfa85..544e808 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -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 = info + .iter() + .map(|r| r.get::("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() diff --git a/src-tauri/src/commands/transcripts.rs b/src-tauri/src/commands/transcripts.rs index f6edac6..973d451 100644 --- a/src-tauri/src/commands/transcripts.rs +++ b/src-tauri/src/commands/transcripts.rs @@ -13,7 +13,8 @@ use kon_storage::{ delete_transcript as db_delete_transcript, get_transcript as db_get_transcript, insert_transcript as db_insert_transcript, list_dictionary, list_transcripts_paged, search_transcripts as db_search_transcripts, - update_transcript as db_update_transcript, DictionaryEntry, InsertTranscriptParams, + update_transcript as db_update_transcript, + update_transcript_meta as db_update_transcript_meta, DictionaryEntry, InsertTranscriptParams, TranscriptRow, }; @@ -22,6 +23,10 @@ use crate::AppState; /// Frontend-facing shape of a stored transcript. Mirrors `TranscriptRow` /// from the storage crate but drops fields the UI does not need yet /// (sample_rate, audio_channels, format flags) and renames to camelCase. +/// +/// Task 2.5 — `starred`, `manualTags`, `template`, `language`, `segmentsJson` +/// were added to back the viewer metadata that previously lived only in the +/// removed `kon_history` localStorage cache. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TranscriptDto { @@ -34,6 +39,11 @@ pub struct TranscriptDto { pub engine: Option, pub model_id: Option, pub created_at: String, + pub starred: bool, + pub manual_tags: String, + pub template: String, + pub language: String, + pub segments_json: String, } impl From for TranscriptDto { @@ -48,6 +58,11 @@ impl From for TranscriptDto { engine: r.engine, model_id: r.model_id, created_at: r.created_at, + starred: r.starred, + manual_tags: r.manual_tags, + template: r.template, + language: r.language, + segments_json: r.segments_json, } } } @@ -179,6 +194,45 @@ pub async fn search_transcripts( .map_err(|e| e.to_string()) } +/// Task 2.5 — patch-style update for the transcripts_meta columns. Each +/// field is optional: `Some` overwrites, omitted / `None` preserves via +/// COALESCE server-side. Separate from `update_transcript` (text / title) +/// so the existing command surface stays untouched. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTranscriptMetaRequest { + #[serde(default)] + pub starred: Option, + #[serde(default)] + pub manual_tags: Option, + #[serde(default)] + pub template: Option, + #[serde(default)] + pub language: Option, + #[serde(default)] + pub segments_json: Option, +} + +#[tauri::command] +pub async fn update_transcript_meta_cmd( + state: tauri::State<'_, AppState>, + id: String, + patch: UpdateTranscriptMetaRequest, +) -> Result { + let row = db_update_transcript_meta( + &state.db, + &id, + patch.starred, + patch.manual_tags.as_deref(), + patch.template.as_deref(), + patch.language.as_deref(), + patch.segments_json.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + Ok(TranscriptDto::from(row)) +} + // --- Dictionary commands (custom vocabulary) --- #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bb62233..f845b32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -271,6 +271,7 @@ pub fn run() { commands::transcripts::count_transcripts_command, commands::transcripts::get_transcript, commands::transcripts::update_transcript, + commands::transcripts::update_transcript_meta_cmd, commands::transcripts::delete_transcript, commands::transcripts::search_transcripts, commands::transcripts::list_dictionary_command, diff --git a/src/lib/stores/page.svelte.js b/src/lib/stores/page.svelte.js index d8fb972..e4c3207 100644 --- a/src/lib/stores/page.svelte.js +++ b/src/lib/stores/page.svelte.js @@ -94,10 +94,30 @@ import { toasts } from "$lib/stores/toasts.svelte.js"; export const history = $state([]); /** Shape a TranscriptDto row from `list_transcripts` into the UI entry shape - * that HistoryPage expects. UI-only fields that were never persisted to - * SQLite (segments, starred, manualTags, language, template) are absent. */ + * that HistoryPage expects. + * + * Task 2.5 — the meta columns (starred, manualTags, template, language, + * segments_json) are now carried by SQLite. `manualTags` is stored comma- + * joined in a single TEXT column (empty string = no tags); `segmentsJson` + * is a serialised JSON array of segment objects (empty string = no + * segments). + */ function mapTranscriptRow(row) { const text = row.text ?? ""; + // manual_tags is a single TEXT column. Empty string ⇒ empty array. + const rawTags = row.manualTags ?? ""; + const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : []; + // segments_json is a serialised array. Empty string ⇒ empty array. + const rawSegments = row.segmentsJson ?? ""; + let segments = []; + if (rawSegments) { + try { + const parsed = JSON.parse(rawSegments); + if (Array.isArray(parsed)) segments = parsed; + } catch (err) { + console.warn("mapTranscriptRow: segmentsJson parse failed", err); + } + } return { id: row.id, text, @@ -110,6 +130,11 @@ function mapTranscriptRow(row) { createdAt: row.createdAt ?? null, date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "", preview: text.slice(0, 120), + starred: !!row.starred, + manualTags, + template: row.template ?? "", + language: row.language ?? "", + segments, }; } @@ -138,9 +163,9 @@ if (typeof window !== "undefined" && hasTauriRuntime()) { // `saveHistory` is retained as a no-op for callers in HistoryPage that use // it as a mutation hook (tag add/remove, clear-all). SQLite writes happen // via the specific `add_transcript` / `update_transcript` / -// `delete_transcript` commands in the functions below; ad-hoc UI-only -// mutations (e.g. manualTags) are session-scoped because they are not -// backed by a SQLite column. +// `update_transcript_meta_cmd` / `delete_transcript` commands in the +// functions below. HistoryPage's manualTags mutations will need to call +// `saveTranscriptMeta` directly (Task 2.5) to persist past reload. export function saveHistory() {} /** @@ -211,6 +236,47 @@ export async function renameHistoryEntry(id, updates) { } } +/** + * Task 2.5 — persist viewer metadata to SQLite via update_transcript_meta_cmd. + * `patch` may contain any of: + * - `starred: boolean` + * - `manualTags: string[] | string` (array joined on ',' server-side) + * - `template: string` + * - `language: string` + * - `segments: Array` (JSON.stringify'd before the wire hop) + * Omitted fields are preserved by COALESCE server-side. The in-memory + * history row (if present) is refreshed from the returned canonical row + * so the main-window list stays consistent. + * + * Returns nothing — callers needing the new row should read `history`. + * + * @param {string} id + * @param {Record} patch + */ +export async function saveTranscriptMeta(id, patch) { + if (!hasTauriRuntime()) return; + try { + const payload = { + starred: patch.starred, + manualTags: patch.manualTags == null + ? undefined + : (Array.isArray(patch.manualTags) + ? patch.manualTags.join(",") + : String(patch.manualTags)), + template: patch.template, + language: patch.language, + segmentsJson: patch.segments ? JSON.stringify(patch.segments) : undefined, + }; + const row = await invoke("update_transcript_meta_cmd", { id: String(id), patch: payload }); + const idx = history.findIndex((h) => String(h.id) === String(id)); + if (idx !== -1) { + history[idx] = mapTranscriptRow(row); + } + } catch (err) { + toasts.error("Failed to save transcript metadata", String(err)); + } +} + export function deleteFromHistory(index) { const entry = history[index]; history.splice(index, 1); diff --git a/src/routes/viewer/+page.svelte b/src/routes/viewer/+page.svelte index b3cfb16..5b51f4c 100644 --- a/src/routes/viewer/+page.svelte +++ b/src/routes/viewer/+page.svelte @@ -4,6 +4,7 @@ import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { formatTime, formatDuration } from "$lib/utils/time.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; + import { saveTranscriptMeta } from "$lib/stores/page.svelte.js"; let item = $state(null); let audioEl = $state(null); @@ -184,6 +185,10 @@ // Update full text item.text = item.segments.map((s) => s.text).join(" ").trim(); saveItemToHistory(); + // Task 2.5 — persist segment boundaries (text + starred flags) to + // SQLite via segments_json. update_transcript above only covers text + // and title; the segment array needs the dedicated meta command. + persistSegments(); } editingIdx = -1; editingText = ""; @@ -194,20 +199,35 @@ item.segments.splice(idx, 1); item.text = item.segments.map((s) => s.text).join(" ").trim(); saveItemToHistory(); + // Task 2.5 — persist the shrunk segment array. + persistSegments(); if (editingIdx === idx) { editingIdx = -1; } } } function toggleStar(idx) { if (item?.segments) { - // TODO: Phase 2 remaining Task 1.5 — starred is not a SQLite column - // on the transcripts table yet, so this mutation is in-memory only - // and will not survive a reload. Task 1.5 will extend update_transcript - // (and the transcripts schema) to carry starred segment state. item.segments[idx].starred = !item.segments[idx].starred; + // Task 2.5 — persist the updated segments array (incl. starred flags) + // via the new update_transcript_meta_cmd. Before this, starring a + // segment was in-memory only and vanished on reload. + persistSegments(); } } + // Task 2.5 — single entry point for segments_json persistence. Callers + // already guard with `if (item?.segments)` so the snapshot here is safe; + // the @ts-ignore matches the file-wide pattern where `item` inferred as + // `null` from `$state(null)` narrows to `never` after guards. Replacing + // the annotation needs a full viewer re-typing pass — out of scope for + // Task 2.5, tracked by the file's 80-odd pre-existing TS errors. + function persistSegments() { + if (!item) return; + /** @type {any} */ + const snap = item; + saveTranscriptMeta(snap.id, { segments: snap.segments }); + } + function copySegment(idx) { if (item?.segments?.[idx]) { const text = item.segments[idx].text.trim(); @@ -217,13 +237,11 @@ } } - // Persist edits back to SQLite via update_transcript (the canonical store). - // Only `text` and `title` are accepted by update_transcript today. Segment - // structure, segment-level starred flags, and manualTags are not SQLite - // columns yet — Phase 2 remaining Task 1.5 will extend the command and the - // transcripts schema to cover those. Until then, segment-level edits that - // change `item.text` (the joined full transcript) survive reload; the - // segment array itself does not. + // Persist edits back to SQLite. `update_transcript` covers `text` / `title`; + // segment structure, per-segment starred flags, manualTags, template and + // language go through `update_transcript_meta_cmd` (Task 2.5) via the + // store's `saveTranscriptMeta` helper — callers in this file wire those + // up alongside the plain-text update below. // // The cross-window `kon_viewer_item` localStorage payload stays in sync so // a second viewer window hydrating from it reflects the latest edit.