diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 269e43b..2729933 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -717,7 +717,7 @@ dependencies = [ [[package]] name = "dikt" -version = "1.25.0" +version = "1.26.0" dependencies = [ "arboard", "async-trait", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 32fa1c3..5cfae11 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ use crate::meeting::{ }; use crate::settings::AppSettings; use crate::state::AppState; -use crate::transcription_history::{HistoryPage, HistoryStats}; +use crate::transcription_history::{HistoryPage, HistoryStats, TranscriptionHistoryItem}; #[derive(serde::Serialize, Clone)] struct AudioLevelPayload { @@ -163,6 +163,14 @@ pub fn get_transcription_history_stats(today_start_ms: i64) -> Result Result { + crate::transcription_history::update_item(&id, &text) +} + #[tauri::command] pub fn delete_transcription_history_item(id: String) -> Result<(), String> { crate::transcription_history::delete_item(&id) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index cfaf362..f94dd13 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -74,6 +74,7 @@ fn open_database(db_path: &Path, app_dir: &Path) -> Result { .map_err(|error| format!("Failed to open database '{}': {error}", db_path.display()))?; configure_connection(&conn)?; create_schema(&conn)?; + ensure_history_columns(&conn)?; migrate_json_if_needed(&mut conn, app_dir)?; Ok(conn) } @@ -98,7 +99,8 @@ fn create_schema(conn: &Connection) -> Result<(), String> { duration_secs REAL, language TEXT, mode_name TEXT, - original_text TEXT + original_text TEXT, + edited_at_ms INTEGER ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON transcription_history(created_at_ms DESC); @@ -138,6 +140,44 @@ fn create_schema(conn: &Connection) -> Result<(), String> { .map_err(|error| format!("Failed to create database schema: {error}")) } +/// Adds columns introduced after the initial schema to pre-existing databases. +/// `CREATE TABLE IF NOT EXISTS` never alters an existing table, so each column +/// added later needs an idempotent `ALTER TABLE` guarded by a column-existence check. +fn ensure_history_columns(conn: &Connection) -> Result<(), String> { + if !table_has_column(conn, "transcription_history", "edited_at_ms")? { + conn.execute( + "ALTER TABLE transcription_history ADD COLUMN edited_at_ms INTEGER", + [], + ) + .map_err(|error| { + format!("Failed to add edited_at_ms column to transcription_history: {error}") + })?; + } + Ok(()) +} + +fn table_has_column(conn: &Connection, table: &str, column: &str) -> Result { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|error| format!("Failed to inspect {table} columns: {error}"))?; + let mut rows = stmt + .query([]) + .map_err(|error| format!("Failed to inspect {table} columns: {error}"))?; + while let Some(row) = rows + .next() + .map_err(|error| format!("Failed to read {table} columns: {error}"))? + { + // PRAGMA table_info columns: cid(0), name(1), type(2), notnull(3), dflt_value(4), pk(5) + let name: String = row + .get(1) + .map_err(|error| format!("Failed to read {table} column name: {error}"))?; + if name == column { + return Ok(true); + } + } + Ok(false) +} + fn migrate_json_if_needed(conn: &mut Connection, app_dir: &Path) -> Result<(), String> { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) @@ -545,6 +585,48 @@ mod tests { fs::remove_dir_all(app_dir).unwrap(); } + #[test] + fn ensure_history_columns_adds_edited_at_to_legacy_tables() { + let conn = Connection::open_in_memory().unwrap(); + // The transcription_history schema as shipped before edited_at_ms existed. + conn.execute_batch( + r#" + CREATE TABLE transcription_history ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + duration_secs REAL, + language TEXT, + mode_name TEXT, + original_text TEXT + ); + "#, + ) + .unwrap(); + assert!(!table_has_column(&conn, "transcription_history", "edited_at_ms").unwrap()); + + ensure_history_columns(&conn).unwrap(); + assert!(table_has_column(&conn, "transcription_history", "edited_at_ms").unwrap()); + + // Idempotent: running again on an already-migrated table is a harmless no-op. + ensure_history_columns(&conn).unwrap(); + + // Existing rows get NULL for the new column (i.e. "never edited"). + conn.execute( + "INSERT INTO transcription_history (id, text, created_at_ms) VALUES ('x', 'hi', 1)", + [], + ) + .unwrap(); + let edited: Option = conn + .query_row( + "SELECT edited_at_ms FROM transcription_history WHERE id = 'x'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(edited, None); + } + #[test] fn meeting_status_processing_round_trips() { assert_eq!(meeting_status_to_str(&MeetingStatus::Processing), "processing"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b7c9a1c..83a4101 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -118,6 +118,7 @@ fn main() { commands::fetch_provider_models, commands::get_transcription_history, commands::get_transcription_history_stats, + commands::update_transcription_history_item, commands::delete_transcription_history_item, commands::clear_transcription_history, commands::start_meeting, diff --git a/src-tauri/src/transcription_history.rs b/src-tauri/src/transcription_history.rs index e5ac58f..a9cf29d 100644 --- a/src-tauri/src/transcription_history.rs +++ b/src-tauri/src/transcription_history.rs @@ -20,6 +20,10 @@ pub struct TranscriptionHistoryItem { pub mode_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub original_text: Option, + /// Set when the user manually edits the transcription; doubles as the + /// "was edited" flag and the timestamp of the edit. `None` for untouched items. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edited_at_ms: Option, } #[derive(Debug, Clone, Serialize)] @@ -79,7 +83,7 @@ pub fn load_history_page( let mut stmt = conn .prepare( r#" - SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text + SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text, edited_at_ms FROM transcription_history WHERE text LIKE ?1 OR original_text LIKE ?1 OR mode_name LIKE ?1 ORDER BY created_at_ms DESC @@ -103,7 +107,7 @@ pub fn load_history_page( let mut stmt = conn .prepare( r#" - SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text + SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text, edited_at_ms FROM transcription_history ORDER BY created_at_ms DESC LIMIT ?1 OFFSET ?2 @@ -174,11 +178,67 @@ pub fn append_item(params: AppendItemParams) -> Result<(), String> { language: params.language, mode_name: params.mode_name, original_text: params.original_text, + edited_at_ms: None, }; crate::db::with_connection(|conn| crate::db::insert_history_item(conn, &item)) } +/// Replaces the text of an existing history item with a manual edit. +/// +/// Stamps `edited_at_ms` so the UI can show the entry was edited, and preserves +/// the original recognizer output in `original_text` the first time an item is +/// edited (`COALESCE(original_text, text)` keeps any pre-existing value, e.g. the +/// raw text saved when an AI mode reformatted the dictation). Returns the updated row. +pub fn update_item(id: &str, text: &str) -> Result { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis() as i64; + + crate::db::with_connection(|conn| update_item_in(conn, id, text, now_ms)) +} + +fn update_item_in( + conn: &rusqlite::Connection, + id: &str, + text: &str, + now_ms: i64, +) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err("Transcription text cannot be empty.".to_string()); + } + + let affected = conn + .execute( + r#" + UPDATE transcription_history + SET original_text = COALESCE(original_text, text), + text = ?2, + edited_at_ms = ?3 + WHERE id = ?1 + "#, + params![id, text, now_ms], + ) + .map_err(|error| format!("Failed to update transcription history item: {error}"))?; + + if affected == 0 { + return Err(format!("Transcription history item '{id}' not found.")); + } + + conn.query_row( + r#" + SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text, edited_at_ms + FROM transcription_history + WHERE id = ?1 + "#, + params![id], + history_item_from_row, + ) + .map_err(|error| format!("Failed to load updated transcription history item: {error}")) +} + pub fn delete_item(id: &str) -> Result<(), String> { crate::db::with_connection(|conn| { conn.execute( @@ -218,6 +278,7 @@ fn history_item_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + try { + const updated = await invoke('update_transcription_history_item', { + id, + text, + }); + // Patch in place: editing changes neither ordering nor counts, so a full + // reload would only cost a scroll jump. + setHistory((items) => items.map((item) => (item.id === id ? updated : item))); + notifySuccess('Entry updated.'); + } catch (err) { + notifyError(err, 'Failed to update history entry.'); + throw err; // keep the inline editor open so the unsaved edit isn't lost + } + }; + const deleteHistoryItem = async (id: string) => { try { await invoke('delete_transcription_history_item', { id }); @@ -825,6 +841,7 @@ export default function SettingsApp() { onSearchQueryChange={(value) => setHistorySearchQuery(value)} onPageChange={(page) => void loadHistory(page)} onCopy={copyHistoryText} + onEdit={updateHistoryItem} onDelete={deleteHistoryItem} onClearAll={clearHistory} /> diff --git a/src/components/Settings/HistoryPage.tsx b/src/components/Settings/HistoryPage.tsx index 8c7692c..7e2abcb 100644 --- a/src/components/Settings/HistoryPage.tsx +++ b/src/components/Settings/HistoryPage.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo } from 'solid-js'; +import { For, Show, createMemo, createSignal } from 'solid-js'; import type { Accessor } from 'solid-js'; import type { TranscriptionHistoryItem } from '../../types'; import { @@ -25,6 +25,9 @@ import { ChevronRight, Mail, Code, + Pencil, + Check, + X, } from 'lucide-solid'; import type { Component } from 'solid-js'; @@ -49,6 +52,7 @@ export type HistoryPageProps = { onSearchQueryChange: (value: string) => void; onPageChange: (page: number) => void; onCopy: (text: string) => void; + onEdit: (id: string, text: string) => Promise; onDelete: (id: string) => void; onClearAll: () => void; }; @@ -56,41 +60,143 @@ export type HistoryPageProps = { function HistoryItem(props: { item: TranscriptionHistoryItem; onCopy: (text: string) => void; + onEdit: (id: string, text: string) => Promise; onDelete: (id: string) => void; }) { + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(''); + const [saving, setSaving] = createSignal(false); + const hasOriginalText = () => props.item.original_text != null && props.item.original_text !== props.item.text; const isNonEnglish = () => props.item.language != null && props.item.language !== 'english' && props.item.language !== ''; + const autosize = (el: HTMLTextAreaElement) => { + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 320)}px`; + }; + + const startEdit = () => { + setDraft(props.item.text); + setEditing(true); + }; + + const cancelEdit = () => { + setEditing(false); + setDraft(''); + }; + + const saveEdit = async () => { + const next = draft().trim(); + if (!next) return; // keep editing \u2014 an empty transcription isn't useful + if (next === props.item.text) { + cancelEdit(); + return; + } + setSaving(true); + try { + await props.onEdit(props.item.id, next); + setEditing(false); + } catch { + // The handler already surfaced the error; keep the editor open so the + // unsaved edit isn't lost and the user can retry. + } finally { + setSaving(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void saveEdit(); + } + }; + return (
-
-

- {isNonEnglish() && '\u201C'}{props.item.text}{isNonEnglish() && '\u201D'} -

- - -
- - Show original - - -
- {props.item.original_text} - -
-
-
-
+ +

+ {isNonEnglish() && '\u201C'}{props.item.text}{isNonEnglish() && '\u201D'} +

+ + +
+ + Show original + + +
+ {props.item.original_text} + +
+
+
+
+ } + > +
+