Add history limit setting (#150)

* Add history limit setting

Dearest Reviewer,

I have added a history limit setting. The limit change is applied instantly.
If the limit is 0 then no recordings are saved.

Becker

* remove extra save

save happens on exit

* move to debug menu

* use the text color

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
becker 2025-10-08 13:01:09 -07:00 committed by GitHub
parent 1432bac30f
commit d0e6a17c17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 136 additions and 40 deletions

View file

@ -44,8 +44,24 @@ pub async fn delete_history_entry(
id: i64,
) -> Result<(), String> {
history_manager
.as_ref()
.delete_entry(id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_history_limit(
app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
limit: usize,
) -> Result<(), String> {
let mut settings = crate::settings::get_settings(&app);
settings.history_limit = limit;
crate::settings::write_settings(&app, settings);
history_manager
.update_history_limit(limit)
.map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -162,8 +162,10 @@ pub fn run() {
TranscriptionManager::new(&app, model_manager.clone())
.expect("Failed to initialize transcription manager"),
);
let history_manager =
Arc::new(HistoryManager::new(&app).expect("Failed to initialize history manager"));
let history_manager = Arc::new(
HistoryManager::new(&app, settings.history_limit)
.expect("Failed to initialize history manager"),
);
// Add managers to Tauri's managed state
app.manage(recording_manager.clone());
@ -243,7 +245,8 @@ pub fn run() {
commands::history::get_history_entries,
commands::history::toggle_history_entry_saved,
commands::history::get_audio_file_path,
commands::history::delete_history_entry
commands::history::delete_history_entry,
commands::history::update_history_limit
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -5,13 +5,12 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use tauri::{App, AppHandle, Emitter, Manager};
use tauri_plugin_sql::{Migration, MigrationKind};
use crate::audio_toolkit::save_wav_file;
const HISTORY_LIMIT: usize = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: i64,
@ -22,15 +21,15 @@ pub struct HistoryEntry {
pub transcription_text: String,
}
#[derive(Clone)]
pub struct HistoryManager {
app_handle: AppHandle,
recordings_dir: PathBuf,
db_path: PathBuf,
history_limit: AtomicUsize,
}
impl HistoryManager {
pub fn new(app: &App) -> Result<Self> {
pub fn new(app: &App, history_limit: usize) -> Result<Self> {
let app_handle = app.app_handle().clone();
// Create recordings directory in app data dir
@ -48,6 +47,7 @@ impl HistoryManager {
app_handle,
recordings_dir,
db_path,
history_limit: AtomicUsize::new(history_limit),
};
// Initialize database
@ -99,6 +99,11 @@ impl HistoryManager {
audio_samples: Vec<f32>,
transcription_text: String,
) -> Result<()> {
// If history limit is 0, do not save at all.
if self.history_limit.load(Ordering::Relaxed) == 0 {
return Ok(());
}
let timestamp = Utc::now().timestamp();
let file_name = format!("handy-{}.wav", timestamp);
let title = self.format_timestamp_title(timestamp);
@ -155,8 +160,9 @@ impl HistoryManager {
entries.push(row?);
}
if entries.len() > HISTORY_LIMIT {
let entries_to_delete = &entries[HISTORY_LIMIT..];
let limit = self.history_limit.load(Ordering::Relaxed);
if entries.len() > limit {
let entries_to_delete = &entries[limit..];
for (id, file_name) in entries_to_delete {
// Delete database entry
@ -242,26 +248,28 @@ impl HistoryManager {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text
FROM transcription_history WHERE id = ?1"
FROM transcription_history WHERE id = ?1",
)?;
let entry = stmt.query_row([id], |row| {
Ok(HistoryEntry {
id: row.get("id")?,
file_name: row.get("file_name")?,
timestamp: row.get("timestamp")?,
saved: row.get("saved")?,
title: row.get("title")?,
transcription_text: row.get("transcription_text")?,
let entry = stmt
.query_row([id], |row| {
Ok(HistoryEntry {
id: row.get("id")?,
file_name: row.get("file_name")?,
timestamp: row.get("timestamp")?,
saved: row.get("saved")?,
title: row.get("title")?,
transcription_text: row.get("transcription_text")?,
})
})
}).optional()?;
.optional()?;
Ok(entry)
}
pub async fn delete_entry(&self, id: i64) -> Result<()> {
let conn = self.get_connection()?;
// Get the entry to find the file name
if let Some(entry) = self.get_entry_by_id(id).await? {
// Delete the audio file first
@ -273,7 +281,7 @@ impl HistoryManager {
}
}
}
// Delete from database
conn.execute(
"DELETE FROM transcription_history WHERE id = ?1",
@ -299,4 +307,10 @@ impl HistoryManager {
format!("Recording {}", timestamp)
}
}
pub fn update_history_limit(&self, new_limit: usize) -> Result<()> {
self.history_limit.swap(new_limit, Ordering::Relaxed);
self.cleanup_old_entries()?;
Ok(())
}
}

View file

@ -110,6 +110,8 @@ pub struct AppSettings {
pub model_unload_timeout: ModelUnloadTimeout,
#[serde(default = "default_word_correction_threshold")]
pub word_correction_threshold: f64,
#[serde(default = "default_history_limit")]
pub history_limit: usize,
#[serde(default)]
pub paste_method: PasteMethod,
}
@ -146,6 +148,10 @@ fn default_word_correction_threshold() -> f64 {
0.18
}
fn default_history_limit() -> usize {
5
}
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
pub fn get_default_settings() -> AppSettings {
@ -186,6 +192,7 @@ pub fn get_default_settings() -> AppSettings {
custom_words: Vec::new(),
model_unload_timeout: ModelUnloadTimeout::Never,
word_correction_threshold: default_word_correction_threshold(),
history_limit: default_history_limit(),
paste_method: PasteMethod::default(),
}
}

View file

@ -2,6 +2,7 @@ import React from "react";
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
import { AppDataDirectory } from "./AppDataDirectory";
import { SettingsGroup } from "../ui/SettingsGroup";
import { HistoryLimit } from "./HistoryLimit";
export const DebugSettings: React.FC = () => {
return (
@ -9,6 +10,7 @@ export const DebugSettings: React.FC = () => {
<SettingsGroup title="Debug">
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
<HistoryLimit descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
</div>
);

View file

@ -0,0 +1,48 @@
import React from "react";
import { useSettings } from "../../hooks/useSettings";
import { Input } from "../ui/Input";
import { SettingContainer } from "../ui/SettingContainer";
interface HistoryLimitProps {
descriptionMode?: "tooltip" | "inline";
grouped?: boolean;
}
export const HistoryLimit: React.FC<HistoryLimitProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const historyLimit = getSetting("history_limit") ?? 5;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value, 10);
if (!isNaN(value) && value >= 0) {
updateSetting("history_limit", value);
}
};
return (
<SettingContainer
title="History Limit"
description="Maximum number of transcription entries to keep in history"
descriptionMode={descriptionMode}
grouped={grouped}
layout="horizontal"
>
<div className="flex items-center space-x-2">
<Input
type="number"
min="0"
max="1000"
value={historyLimit}
onChange={handleChange}
disabled={isUpdating("history_limit")}
className="w-20"
/>
<span className="text-sm text-text">entries</span>
</div>
</SettingContainer>
);
};

View file

@ -18,3 +18,4 @@ export { CustomWords } from "./CustomWords";
export { AppDataDirectory } from "./AppDataDirectory";
export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
export { StartHidden } from "./StartHidden";
export { HistoryLimit } from "./HistoryLimit";

View file

@ -53,6 +53,7 @@ export const SettingsSchema = z.object({
custom_words: z.array(z.string()).optional().default([]),
model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"),
word_correction_threshold: z.number().optional().default(0.18),
history_limit: z.number().optional().default(5),
paste_method: PasteMethodSchema.optional().default("ctrl_v"),
});

View file

@ -45,6 +45,7 @@ const DEFAULT_SETTINGS: Partial<Settings> = {
overlay_position: "bottom",
debug_mode: false,
custom_words: [],
history_limit: 5,
};
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
@ -224,6 +225,9 @@ export const useSettingsStore = create<SettingsStore>()(
case "paste_method":
await invoke("change_paste_method_setting", { method: value });
break;
case "history_limit":
await invoke("update_history_limit", { limit: value });
break;
case "bindings":
case "selected_model":
break;
@ -265,15 +269,15 @@ export const useSettingsStore = create<SettingsStore>()(
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: binding,
},
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: binding,
},
}
},
}
: null,
}));
@ -286,15 +290,15 @@ export const useSettingsStore = create<SettingsStore>()(
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: originalBinding,
},
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: originalBinding,
},
}
},
}
: null,
}));
}