diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index dabb824..bfd5c8f 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -129,9 +129,13 @@ pub async fn insert_transcript( Ok(()) } +/// Fetch a transcript by id, EXCLUDING soft-deleted (deleted_at IS NOT NULL) +/// rows. Soft-deleted rows are still in the table — they are scoped to the +/// trash view via `list_trashed_transcripts` / `restore_transcript` — but the +/// regular read path treats them as gone. Rev-2 (atomiser 2026-05-12). pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( - "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, llm_tags 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, llm_tags FROM transcripts WHERE id = ? AND deleted_at IS NULL", ) .bind(id) .fetch_optional(pool) @@ -155,8 +159,11 @@ pub async fn list_transcripts_paged( limit: i64, offset: i64, ) -> Result> { + // `deleted_at IS NULL` filter is the Rev-2 soft-delete contract: rows + // in the trash are kept in the table for restore, but the regular list + // path treats them as gone. let rows = sqlx::query( - "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, llm_tags 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, llm_tags FROM transcripts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) @@ -171,14 +178,18 @@ pub async fn list_transcripts_paged( } /// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI. +/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count +/// toward the trash view. pub async fn count_transcripts(pool: &SqlitePool) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts") - .fetch_one(pool) - .await - .map_err(|source| Error::Query { - operation: "count_transcripts".into(), - source, - })?; + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL", + ) + .fetch_one(pool) + .await + .map_err(|source| Error::Query { + operation: "count_transcripts".into(), + source, + })?; Ok(n) } @@ -293,13 +304,202 @@ pub async fn update_transcript_meta( }) } +/// Soft-delete a transcript and best-effort remove its audio file. +/// +/// Rev-2 / Rev-3 reversibility kill (atomiser 2026-05-12). The previous +/// implementation issued a hard `DELETE FROM transcripts WHERE id = ?` +/// and never touched the WAV file at `audio_path`. Two failure modes +/// fanned out from that: +/// +/// * Rev-2: a single click on the History "Clear All" / "Confirm" +/// button immediately erased months of dictation with no trash, +/// no export, no undo. +/// * Rev-3: even single-row deletes left the audio file on disk; +/// the recordings dir grew monotonically. +/// +/// The new contract: +/// +/// 1. Capture the existing row (so we still have `audio_path` even if +/// the soft-delete is observed by a parallel reader before we get +/// here). +/// 2. UPDATE deleted_at = datetime('now'). FTS triggers fire on UPDATE +/// and re-index the row, but `search_transcripts` / `list_transcripts` +/// filter `deleted_at IS NULL`, so the trash rows don't surface. +/// 3. Best-effort `tokio::fs::remove_file(audio_path)`. A failure +/// (file already gone, permission denied) is logged but does NOT +/// propagate — the DB soft-delete has already succeeded, and +/// `purge_deleted_transcripts` will retry the removal later via +/// its own audio_path lookup before hard-deleting the row. +/// +/// Returns `Ok(())` even when no row matched the id (idempotent). pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { - sqlx::query("DELETE FROM transcripts WHERE id = ?") + // Capture audio_path FIRST. We deliberately bypass `get_transcript` + // (which filters deleted_at IS NULL) so a double-delete still finds + // the row and the second call is a no-op cleanup rather than an + // error. + let audio_path: Option = sqlx::query_scalar( + "SELECT audio_path FROM transcripts WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|source| Error::Query { + operation: "delete_transcript".into(), + source, + })? + .flatten(); + + let res = sqlx::query( + "UPDATE transcripts SET deleted_at = datetime('now') \ + WHERE id = ? AND deleted_at IS NULL", + ) + .bind(id) + .execute(pool) + .await + .map_err(|source| Error::Query { + operation: "delete_transcript".into(), + source, + })?; + + // Best-effort audio cleanup. Only attempt removal when the + // soft-delete actually flipped a row from live -> trashed; on a + // repeat soft-delete the file is already gone (or never existed) + // and there's nothing to do. + if res.rows_affected() > 0 { + if let Some(path) = audio_path.as_deref() { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + log::warn!( + target: "lumotia_storage", + "delete_transcript: failed to remove audio file at {path}: {err}" + ); + } + } + } + } + + Ok(()) +} + +/// Hard-delete soft-deleted rows older than `older_than_days`. Intended to +/// run once per startup (or on a daily cron). Returns the number of rows +/// removed. +/// +/// FK cascades: `segments` ON DELETE CASCADE (v1) fires; tasks with +/// `source_transcript_id` ON DELETE SET NULL (v8) is preserved. +/// +/// Audio files are also best-effort removed here in case the original +/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces). +pub async fn purge_deleted_transcripts( + pool: &SqlitePool, + older_than_days: i64, +) -> Result { + // Collect (id, audio_path) BEFORE the DELETE so we still have the + // paths to clean up after the row is gone. + let rows = sqlx::query( + "SELECT id, audio_path FROM transcripts \ + WHERE deleted_at IS NOT NULL \ + AND deleted_at < datetime('now', ?)", + ) + .bind(format!("-{older_than_days} days")) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "purge_deleted_transcripts_select".into(), + source, + })?; + + if rows.is_empty() { + return Ok(0); + } + + let mut ids: Vec = Vec::with_capacity(rows.len()); + let mut audio_paths: Vec = Vec::new(); + for row in &rows { + let id: String = row.get("id"); + let audio: Option = row.get("audio_path"); + ids.push(id); + if let Some(p) = audio { + audio_paths.push(p); + } + } + + // Build the IN clause manually because sqlx 0.8 doesn't expand Vec + // bindings; we batch into chunks of 200 to stay well clear of + // SQLITE_MAX_VARIABLE_NUMBER (default 999). + let mut total: u64 = 0; + for chunk in ids.chunks(200) { + let placeholders = std::iter::repeat("?") + .take(chunk.len()) + .collect::>() + .join(","); + let sql = format!("DELETE FROM transcripts WHERE id IN ({placeholders})"); + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + let res = q.execute(pool).await.map_err(|source| Error::Query { + operation: "purge_deleted_transcripts_delete".into(), + source, + })?; + total += res.rows_affected(); + } + + // Best-effort audio cleanup. NotFound is the expected case for rows + // whose audio was already removed at soft-delete time. + for path in &audio_paths { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + log::warn!( + target: "lumotia_storage", + "purge_deleted_transcripts: failed to remove audio file at {path}: {err}" + ); + } + } + } + + Ok(total) +} + +/// List soft-deleted transcripts (the "trash" view), most-recently-deleted +/// first. The contract mirrors `list_transcripts_paged` but filters +/// `deleted_at IS NOT NULL` so the regular history view and the trash view +/// are exact complements. +pub async fn list_trashed_transcripts( + pool: &SqlitePool, + limit: i64, + offset: i64, +) -> Result> { + let rows = sqlx::query( + "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, llm_tags \ + FROM transcripts \ + WHERE deleted_at IS NOT NULL \ + ORDER BY deleted_at DESC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(|source| Error::Query { + operation: "list_trashed_transcripts".into(), + source, + })?; + + Ok(rows.iter().map(transcript_row_from).collect()) +} + +/// Restore a soft-deleted transcript by clearing `deleted_at`. Idempotent: +/// restoring a live row is a no-op. Note that the audio file at +/// `audio_path` may already have been removed by `delete_transcript`'s +/// best-effort filesystem cleanup; restoring the row recovers the text and +/// metadata but the audio may still be missing. +pub async fn restore_transcript(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("UPDATE transcripts SET deleted_at = NULL WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { - operation: "delete_transcript".into(), + operation: "restore_transcript".into(), source, })?; Ok(()) @@ -314,11 +514,15 @@ pub async fn search_transcripts( query: &str, limit: i64, ) -> Result> { + // The FTS triggers from migration v2 keep `transcripts_fts` in sync + // for every INSERT/UPDATE/DELETE — including the soft-delete UPDATE, + // which keeps the row in transcripts_fts. The `t.deleted_at IS NULL` + // filter on the JOIN keeps trashed rows out of search results. let rows = sqlx::query( "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, t.llm_tags \ FROM transcripts t \ JOIN transcripts_fts fts ON fts.rowid = t.rowid \ - WHERE transcripts_fts MATCH ? \ + WHERE transcripts_fts MATCH ? AND t.deleted_at IS NULL \ ORDER BY fts.rank LIMIT ?", ) .bind(query) @@ -2862,4 +3066,151 @@ mod tests { assert_eq!(first, (1, 0)); assert_eq!(second, (0, 0)); } + + fn minimal_transcript(id: &'static str, audio_path: Option<&'static str>) -> InsertTranscriptParams<'static> { + InsertTranscriptParams { + id, + text: "soft-delete fixture", + source: "microphone", + profile_id: crate::DEFAULT_PROFILE_ID, + title: None, + audio_path, + duration: 1.0, + engine: None, + model_id: None, + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: true, + anti_hallucination: false, + } + } + + #[tokio::test] + async fn delete_transcript_soft_deletes() { + // Rev-2 contract: delete_transcript flips `deleted_at`, does not + // remove the row. A direct SELECT bypassing the deleted_at filter + // must still find the row. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-soft", None)) + .await + .unwrap(); + + delete_transcript(&pool, "t-soft").await.unwrap(); + + let deleted_at: Option = sqlx::query_scalar( + "SELECT deleted_at FROM transcripts WHERE id = ?", + ) + .bind("t-soft") + .fetch_one(&pool) + .await + .unwrap(); + assert!(deleted_at.is_some(), "row should still exist with deleted_at set"); + } + + #[tokio::test] + async fn delete_transcript_removes_audio_file() { + // Rev-3 contract: best-effort fs::remove_file fires when the + // soft-delete actually flipped a row. A repeat delete is a + // no-op and does NOT fail when the file is already gone. + let pool = test_pool().await; + let tmp = std::env::temp_dir().join(format!( + "lumotia-test-{}.wav", + std::process::id() + )); + std::fs::write(&tmp, b"fake wav").unwrap(); + let path_owned = tmp.to_string_lossy().to_string(); + let path_static: &'static str = Box::leak(path_owned.into_boxed_str()); + + insert_transcript(&pool, &minimal_transcript("t-audio", Some(path_static))) + .await + .unwrap(); + assert!(tmp.exists(), "fixture file should exist pre-delete"); + + delete_transcript(&pool, "t-audio").await.unwrap(); + assert!(!tmp.exists(), "audio file should be removed by delete_transcript"); + + // Repeat delete must not surface an error even though both the + // soft-delete UPDATE is a no-op AND the audio file is already gone. + delete_transcript(&pool, "t-audio").await.unwrap(); + } + + #[tokio::test] + async fn list_transcripts_excludes_soft_deleted() { + // Rev-2 list contract: soft-deleted rows are invisible to the + // regular list path; the trash view is the inverse. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-live", None)) + .await + .unwrap(); + insert_transcript(&pool, &minimal_transcript("t-trashed", None)) + .await + .unwrap(); + + delete_transcript(&pool, "t-trashed").await.unwrap(); + + let live = list_transcripts(&pool, 100).await.unwrap(); + assert_eq!(live.len(), 1); + assert_eq!(live[0].id, "t-live"); + + let trashed = list_trashed_transcripts(&pool, 100, 0).await.unwrap(); + assert_eq!(trashed.len(), 1); + assert_eq!(trashed[0].id, "t-trashed"); + + let total = count_transcripts(&pool).await.unwrap(); + assert_eq!(total, 1, "count_transcripts excludes the trash"); + + // Restore brings the row back into the live list. + restore_transcript(&pool, "t-trashed").await.unwrap(); + let after_restore = list_transcripts(&pool, 100).await.unwrap(); + assert_eq!(after_restore.len(), 2); + } + + #[tokio::test] + async fn purge_deleted_transcripts_hard_deletes_old() { + // Retention contract: purge_deleted_transcripts hard-removes rows + // whose deleted_at is older than `older_than_days`. Newer trash + // rows are preserved. + let pool = test_pool().await; + insert_transcript(&pool, &minimal_transcript("t-old", None)) + .await + .unwrap(); + insert_transcript(&pool, &minimal_transcript("t-new", None)) + .await + .unwrap(); + delete_transcript(&pool, "t-old").await.unwrap(); + delete_transcript(&pool, "t-new").await.unwrap(); + + // Backdate t-old past the 30-day window. t-new keeps "now". + sqlx::query( + "UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?", + ) + .bind("t-old") + .execute(&pool) + .await + .unwrap(); + + let purged = purge_deleted_transcripts(&pool, 30).await.unwrap(); + assert_eq!(purged, 1, "only t-old should be hard-deleted"); + + let old_exists: Option = sqlx::query_scalar( + "SELECT id FROM transcripts WHERE id = ?", + ) + .bind("t-old") + .fetch_optional(&pool) + .await + .unwrap(); + assert!(old_exists.is_none(), "t-old should be hard-gone"); + + let new_exists: Option = sqlx::query_scalar( + "SELECT id FROM transcripts WHERE id = ?", + ) + .bind("t-new") + .fetch_optional(&pool) + .await + .unwrap(); + assert!(new_exists.is_some(), "t-new still inside the retention window"); + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index e22fe02..3b4aad1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -16,11 +16,11 @@ pub use database::{ get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task, insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, - list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, - migrate_legacy_setting_keys, prune_error_log, record_feedback, search_transcripts, - set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task, update_profile, - update_task, update_transcript, - update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, + list_transcripts, list_transcripts_paged, list_trashed_transcripts, log_error, + mark_implementation_rule_fired, migrate_legacy_setting_keys, prune_error_log, + purge_deleted_transcripts, record_feedback, restore_transcript, search_transcripts, + set_implementation_rule_enabled, set_setting, set_task_energy, uncomplete_task, + update_profile, update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow, }; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index cee57ea..fa6d884 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -477,6 +477,36 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ON transcripts(profile_id, created_at DESC); "#, ), + ( + 16, + "transcripts: soft-delete column for trash + audio cleanup (Rev-2, Rev-3)", + r#" + -- Atomiser fix 2026-05-12 — Rev-2 (hard-DELETE w/ no trash) and + -- Rev-3 (orphan WAV files on transcript delete) both fanned out + -- from the same DELETE-based delete path. The new contract: + -- + -- * `delete_transcript` UPDATEs deleted_at = datetime('now') + -- and best-effort removes the audio file at audio_path. + -- The DB row is preserved so the user can restore. + -- * `list_transcripts*`, `search_transcripts`, `count_transcripts` + -- filter `deleted_at IS NULL`. + -- * `purge_deleted_transcripts(older_than_days)` does the + -- actual hard DELETE on rows past the retention window, + -- scheduled once at startup. FK CASCADE on segments still + -- fires at purge time. + -- + -- Pre-existing rows default to NULL (not deleted). The column + -- is plain TEXT (ISO-8601 datetime), nullable, no CHECK — we + -- only ever compare against datetime('now', '-N days') and + -- IS NULL / IS NOT NULL. + ALTER TABLE transcripts ADD COLUMN deleted_at TEXT; + + -- Partial index over the trash so the purge query stays cheap + -- even on databases with months of soft-deleted rows. + CREATE INDEX IF NOT EXISTS idx_transcripts_deleted_at + ON transcripts(deleted_at) WHERE deleted_at IS NOT NULL; + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -642,7 +672,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -661,7 +691,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); } #[tokio::test] @@ -1199,4 +1229,69 @@ mod tests { "planner should use the composite index, got plan: {plan:?}", ); } + + #[tokio::test] + async fn migration_v16_adds_deleted_at_column_and_index() { + // Rev-2 / Rev-3 atomiser fix (2026-05-12). Verify the soft-delete + // column is present, nullable, defaulting to NULL on pre-existing + // rows so the migration doesn't accidentally mark old transcripts + // as deleted. + let pool = fk_test_pool().await; + run_migrations_up_to(&pool, 15).await.expect("migrate to v15"); + + // Seed a pre-v16 row to verify backfill preserves NULL. + sqlx::query( + "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, + created_at, starred, manual_tags, template, language, + segments_json, llm_tags + ) VALUES ( + 'pre-v16', 'pre-existing body', 'microphone', ?, 'Pre-V16', + NULL, 1.0, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0, + datetime('now'), 0, '', '', '', '', '' + )", + ) + .bind(crate::DEFAULT_PROFILE_ID) + .execute(&pool) + .await + .expect("seed pre-v16 row"); + + run_migrations(&pool).await.expect("migrate to v16"); + + let info = sqlx::query("PRAGMA table_info(transcripts)") + .fetch_all(&pool) + .await + .expect("pragma"); + let names: Vec = info.iter().map(|r| r.get::("name")).collect(); + assert!( + names.contains(&"deleted_at".to_string()), + "transcripts must have deleted_at after v16; got {names:?}", + ); + + // Pre-existing rows must default to NULL — emphatically NOT + // pre-soft-deleted. + let deleted_at: Option = + sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = 'pre-v16'") + .fetch_one(&pool) + .await + .expect("read deleted_at"); + assert!( + deleted_at.is_none(), + "pre-v16 rows must have deleted_at = NULL after migration, got {deleted_at:?}", + ); + + // Partial index exists. + let index_names: Vec = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'transcripts'", + ) + .fetch_all(&pool) + .await + .expect("read indexes"); + assert!( + index_names.iter().any(|n| n == "idx_transcripts_deleted_at"), + "expected idx_transcripts_deleted_at, got {index_names:?}", + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c7b2c9d..0bfa3b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,13 +25,19 @@ use lumotia_core::types::EngineName; use lumotia_llm::LlmEngine; use lumotia_storage::{ database_path, get_setting, init as init_db, migrate_legacy_setting_keys, prune_error_log, - set_setting, + purge_deleted_transcripts, set_setting, }; /// How long to retain `error_log` rows. Pruned once on startup. /// 90 days is long enough to triage "this happened a few weeks ago" /// reports and short enough that the table stays kilobyte-scale. const ERROR_LOG_RETENTION_DAYS: i64 = 90; + +/// How long soft-deleted transcripts live in the trash before +/// `purge_deleted_transcripts` hard-removes them on startup. 30 days +/// gives the user a comfortable undo window without keeping months of +/// abandoned audio on disk. +const TRANSCRIPT_TRASH_RETENTION_DAYS: i64 = 30; use lumotia_transcription::LocalEngine; static TRACING_INIT: Once = Once::new(); @@ -491,6 +497,26 @@ pub fn run() { Err(e) => tracing::warn!(target: "lumotia_startup", error = %e, "error log prune failed"), } + // Hard-delete soft-deleted transcripts past the retention + // window (Rev-2 atomiser fix). Best-effort — a purge failure + // means trash rows stick around an extra day, not a blocker. + let t_purge_trash = Instant::now(); + match purge_deleted_transcripts(&db, TRANSCRIPT_TRASH_RETENTION_DAYS).await { + Ok(n) if n > 0 => tracing::info!( + target: "lumotia_startup", + rows_removed = n, + retention_days = TRANSCRIPT_TRASH_RETENTION_DAYS, + elapsed_ms = t_purge_trash.elapsed().as_millis(), + "transcript trash purge complete" + ), + Ok(_) => {} + Err(e) => tracing::warn!( + target: "lumotia_startup", + error = %e, + "transcript trash purge failed — trash rows remain for next startup" + ), + } + // Load saved preferences for webview injection. let t1 = Instant::now(); let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None);