Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -163,6 +163,14 @@ pub fn get_transcription_history_stats(today_start_ms: i64) -> Result<HistorySta
crate::transcription_history::history_stats(today_start_ms)
}

#[tauri::command]
pub fn update_transcription_history_item(
id: String,
text: String,
) -> Result<TranscriptionHistoryItem, String> {
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)
Expand Down
84 changes: 83 additions & 1 deletion src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn open_database(db_path: &Path, app_dir: &Path) -> Result<Connection, String> {
.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)
}
Expand All @@ -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);
Expand Down Expand Up @@ -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<bool, String> {
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))
Expand Down Expand Up @@ -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<i64> = 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");
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
118 changes: 114 additions & 4 deletions src-tauri/src/transcription_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub struct TranscriptionHistoryItem {
pub mode_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original_text: Option<String>,
/// 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<i64>,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<TranscriptionHistoryItem, String> {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_millis() as i64;
Comment on lines +194 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the system clock is misconfigured or set to a time before the UNIX epoch (e.g., during early system boot or due to clock desynchronization), SystemTime::now().duration_since(UNIX_EPOCH) will return an Err. This will cause the entire manual edit operation to fail, potentially causing the user to lose their edited text.

Using unwrap_or_default() provides a safe fallback of 0 (or epoch time) and ensures the edit can always be saved successfully regardless of system clock anomalies.

Suggested change
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_millis() as i64;
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.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<TranscriptionHistoryItem, String> {
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(
Expand Down Expand Up @@ -218,6 +278,7 @@ fn history_item_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Transcript
language: row.get("language")?,
mode_name: row.get("mode_name")?,
original_text: row.get("original_text")?,
edited_at_ms: row.get("edited_at_ms")?,
})
}

Expand All @@ -238,7 +299,8 @@ mod tests {
duration_secs REAL,
language TEXT,
mode_name TEXT,
original_text TEXT
original_text TEXT,
edited_at_ms INTEGER
);
CREATE INDEX idx_history_created_at ON transcription_history(created_at_ms DESC);
"#,
Expand All @@ -258,6 +320,7 @@ mod tests {
language: Some("en".to_string()),
mode_name: Some("Clean Draft".to_string()),
original_text: None,
edited_at_ms: None,
},
)
.unwrap();
Expand All @@ -271,7 +334,7 @@ mod tests {

let mut stmt = conn
.prepare(
"SELECT id, text, created_at_ms, duration_secs, language, mode_name, original_text FROM transcription_history ORDER BY created_at_ms DESC",
"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",
)
.unwrap();
let rows = stmt
Expand Down Expand Up @@ -306,6 +369,53 @@ mod tests {
assert_eq!(remaining.as_deref(), Some("b"));
}

#[test]
fn editing_preserves_first_original_text_and_stamps_edited_at() {
let conn = test_conn();
// `insert` stores original_text = None, so `text` is the raw recognizer output.
insert(&conn, "a", "raw transcription", 1);

let first = update_item_in(&conn, "a", " first edit ", 100).unwrap();
assert_eq!(first.text, "first edit", "edited text is trimmed and saved");
assert_eq!(
first.original_text.as_deref(),
Some("raw transcription"),
"raw recognizer output is preserved into original_text on first edit"
);
assert_eq!(first.edited_at_ms, Some(100));

let second = update_item_in(&conn, "a", "second edit", 200).unwrap();
assert_eq!(second.text, "second edit");
assert_eq!(
second.original_text.as_deref(),
Some("raw transcription"),
"the preserved original is never overwritten by later edits"
);
assert_eq!(second.edited_at_ms, Some(200));
}

#[test]
fn editing_missing_item_is_an_error() {
let conn = test_conn();
assert!(update_item_in(&conn, "missing", "text", 1).is_err());
}

#[test]
fn editing_to_empty_text_is_rejected() {
let conn = test_conn();
insert(&conn, "a", "raw transcription", 1);
assert!(update_item_in(&conn, "a", " ", 100).is_err());
// The original row is left untouched.
let text: String = conn
.query_row(
"SELECT text FROM transcription_history WHERE id = ?1",
params!["a"],
|row| row.get(0),
)
.unwrap();
assert_eq!(text, "raw transcription");
}

#[test]
fn clear_removes_all_rows() {
let conn = test_conn();
Expand Down
17 changes: 17 additions & 0 deletions src/SettingsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,22 @@ export default function SettingsApp() {
}
};

const updateHistoryItem = async (id: string, text: string) => {
try {
const updated = await invoke<TranscriptionHistoryItem>('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 });
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading
Loading