From 3b3ed5abc5fe1e1afa712da91c6d860d2ca27460 Mon Sep 17 00:00:00 2001 From: Jorge <103663493+jorgedanisc@users.noreply.github.com> Date: Mon, 17 Nov 2025 03:41:05 +0000 Subject: [PATCH] feat: add more options for deleting recordings automatically and added button to open the folder that holds the recordings (#330) * feat: add more options for deleting recordings automatically and added button to open the folder that olds the recordings - deleting automatically options: after certain time, never or preserving certain number of recordings - new button "Open Recordings Folder" located on History page * refactor: moved "Delete Recordings" selector to Debug settings * refactor: simplify opening recordings folder logic by using tauri's opener plugin * clean up ui and code a bit --------- Co-authored-by: CJ Pais --- src-tauri/src/commands/history.rs | 30 ++++- src-tauri/src/commands/mod.rs | 18 +++ src-tauri/src/lib.rs | 4 +- src-tauri/src/managers/history.rs | 117 +++++++++++++----- src-tauri/src/settings.rs | 22 ++++ .../settings/RecordingRetentionPeriod.tsx | 54 ++++++++ .../settings/advanced/AdvancedSettings.tsx | 2 +- .../settings/debug/DebugSettings.tsx | 2 + .../settings/history/HistorySettings.tsx | 103 +++++++++++---- src/components/settings/index.ts | 1 + src/components/ui/Button.tsx | 12 +- src/lib/types.ts | 10 ++ src/stores/settingsStore.ts | 3 + 13 files changed, 319 insertions(+), 59 deletions(-) create mode 100644 src/components/settings/RecordingRetentionPeriod.tsx diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 888b98f..bcc15d2 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -60,7 +60,35 @@ pub async fn update_history_limit( crate::settings::write_settings(&app, settings); history_manager - .update_history_limit() + .cleanup_old_entries() + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn update_recording_retention_period( + app: AppHandle, + history_manager: State<'_, Arc>, + period: String, +) -> Result<(), String> { + use crate::settings::RecordingRetentionPeriod; + + let retention_period = match period.as_str() { + "never" => RecordingRetentionPeriod::Never, + "preserve_limit" => RecordingRetentionPeriod::PreserveLimit, + "days3" => RecordingRetentionPeriod::Days3, + "weeks2" => RecordingRetentionPeriod::Weeks2, + "months3" => RecordingRetentionPeriod::Months3, + _ => return Err(format!("Invalid retention period: {}", period)), + }; + + let mut settings = crate::settings::get_settings(&app); + settings.recording_retention_period = retention_period; + crate::settings::write_settings(&app, settings); + + history_manager + .cleanup_old_entries() .map_err(|e| e.to_string())?; Ok(()) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5c29081..13c6490 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod transcription; use crate::utils::cancel_current_operation; use tauri::{AppHandle, Manager}; +use tauri_plugin_opener::OpenerExt; #[tauri::command] pub fn cancel_operation(app: AppHandle) { @@ -20,3 +21,20 @@ pub fn get_app_dir_path(app: AppHandle) -> Result { Ok(app_data_dir.to_string_lossy().to_string()) } + +#[tauri::command] +pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + + let recordings_dir = app_data_dir.join("recordings"); + + let path = recordings_dir.to_string_lossy().as_ref().to_string(); + app.opener() + .open_path(path, None::) + .map_err(|e| format!("Failed to open recordings folder: {}", e))?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d788770..be5ad0d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -253,6 +253,7 @@ pub fn run() { trigger_update_check, commands::cancel_operation, commands::get_app_dir_path, + commands::open_recordings_folder, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, @@ -286,7 +287,8 @@ pub fn run() { commands::history::toggle_history_entry_saved, commands::history::get_audio_file_path, commands::history::delete_history_entry, - commands::history::update_history_limit + commands::history::update_history_limit, + commands::history::update_recording_retention_period ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index e1a06f0..5afc5bc 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -102,11 +102,6 @@ impl HistoryManager { post_processed_text: Option, post_process_prompt: Option, ) -> Result<()> { - // If history limit is 0, do not save at all. - if crate::settings::get_history_limit(&self.app_handle) == 0 { - return Ok(()); - } - let timestamp = Utc::now().timestamp(); let file_name = format!("handy-{}.wav", timestamp); let title = self.format_timestamp_title(timestamp); @@ -155,7 +150,57 @@ impl HistoryManager { Ok(()) } - fn cleanup_old_entries(&self) -> Result<()> { + pub fn cleanup_old_entries(&self) -> Result<()> { + let retention_period = crate::settings::get_recording_retention_period(&self.app_handle); + + match retention_period { + crate::settings::RecordingRetentionPeriod::Never => { + // Don't delete anything + return Ok(()); + } + crate::settings::RecordingRetentionPeriod::PreserveLimit => { + // Use the old count-based logic with history_limit + let limit = crate::settings::get_history_limit(&self.app_handle); + return self.cleanup_by_count(limit); + } + _ => { + // Use time-based logic + return self.cleanup_by_time(retention_period); + } + } + } + + fn delete_entries_and_files(&self, entries: &[(i64, String)]) -> Result { + if entries.is_empty() { + return Ok(0); + } + + let conn = self.get_connection()?; + let mut deleted_count = 0; + + for (id, file_name) in entries { + // Delete database entry + conn.execute( + "DELETE FROM transcription_history WHERE id = ?1", + params![id], + )?; + + // Delete WAV file + let file_path = self.recordings_dir.join(file_name); + if file_path.exists() { + if let Err(e) = fs::remove_file(&file_path) { + error!("Failed to delete WAV file {}: {}", file_name, e); + } else { + debug!("Deleted old WAV file: {}", file_name); + deleted_count += 1; + } + } + } + + Ok(deleted_count) + } + + fn cleanup_by_count(&self, limit: usize) -> Result<()> { let conn = self.get_connection()?; // Get all entries that are not saved, ordered by timestamp desc @@ -172,29 +217,48 @@ impl HistoryManager { entries.push(row?); } - let limit = crate::settings::get_history_limit(&self.app_handle); if entries.len() > limit { let entries_to_delete = &entries[limit..]; + let deleted_count = self.delete_entries_and_files(entries_to_delete)?; - for (id, file_name) in entries_to_delete { - // Delete database entry - conn.execute( - "DELETE FROM transcription_history WHERE id = ?1", - params![id], - )?; - - // Delete WAV file - let file_path = self.recordings_dir.join(file_name); - if file_path.exists() { - if let Err(e) = fs::remove_file(&file_path) { - error!("Failed to delete WAV file {}: {}", file_name, e); - } else { - debug!("Deleted old WAV file: {}", file_name); - } - } + if deleted_count > 0 { + debug!("Cleaned up {} old history entries by count", deleted_count); } + } - debug!("Cleaned up {} old history entries", entries_to_delete.len()); + Ok(()) + } + + fn cleanup_by_time(&self, retention_period: crate::settings::RecordingRetentionPeriod) -> Result<()> { + let conn = self.get_connection()?; + + // Calculate cutoff timestamp (current time minus retention period) + let now = Utc::now().timestamp(); + let cutoff_timestamp = match retention_period { + crate::settings::RecordingRetentionPeriod::Days3 => now - (3 * 24 * 60 * 60), // 3 days in seconds + crate::settings::RecordingRetentionPeriod::Weeks2 => now - (2 * 7 * 24 * 60 * 60), // 2 weeks in seconds + crate::settings::RecordingRetentionPeriod::Months3 => now - (3 * 30 * 24 * 60 * 60), // 3 months in seconds (approximate) + _ => unreachable!("Should not reach here"), + }; + + // Get all unsaved entries older than the cutoff timestamp + let mut stmt = conn.prepare( + "SELECT id, file_name FROM transcription_history WHERE saved = 0 AND timestamp < ?1" + )?; + + let rows = stmt.query_map(params![cutoff_timestamp], |row| { + Ok((row.get::<_, i64>("id")?, row.get::<_, String>("file_name")?)) + })?; + + let mut entries_to_delete: Vec<(i64, String)> = Vec::new(); + for row in rows { + entries_to_delete.push(row?); + } + + let deleted_count = self.delete_entries_and_files(&entries_to_delete)?; + + if deleted_count > 0 { + debug!("Cleaned up {} old history entries based on retention period", deleted_count); } Ok(()) @@ -323,9 +387,4 @@ impl HistoryManager { format!("Recording {}", timestamp) } } - - pub fn update_history_limit(&self) -> Result<()> { - self.cleanup_old_entries()?; - Ok(()) - } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5ed45c0..c5a2d52 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -67,6 +67,16 @@ pub enum ClipboardHandling { CopyToClipboard, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecordingRetentionPeriod { + Never, + PreserveLimit, + Days3, + Weeks2, + Months3, +} + impl Default for ModelUnloadTimeout { fn default() -> Self { ModelUnloadTimeout::Never @@ -179,6 +189,8 @@ pub struct AppSettings { pub word_correction_threshold: f64, #[serde(default = "default_history_limit")] pub history_limit: usize, + #[serde(default = "default_recording_retention_period")] + pub recording_retention_period: RecordingRetentionPeriod, #[serde(default)] pub paste_method: PasteMethod, #[serde(default)] @@ -244,6 +256,10 @@ fn default_history_limit() -> usize { 5 } +fn default_recording_retention_period() -> RecordingRetentionPeriod { + RecordingRetentionPeriod::PreserveLimit +} + fn default_audio_feedback_volume() -> f32 { 1.0 } @@ -362,6 +378,7 @@ pub fn get_default_settings() -> AppSettings { model_unload_timeout: ModelUnloadTimeout::Never, word_correction_threshold: default_word_correction_threshold(), history_limit: default_history_limit(), + recording_retention_period: default_recording_retention_period(), paste_method: PasteMethod::default(), clipboard_handling: ClipboardHandling::default(), post_process_enabled: default_post_process_enabled(), @@ -472,3 +489,8 @@ pub fn get_history_limit(app: &AppHandle) -> usize { let settings = get_settings(app); settings.history_limit } + +pub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod { + let settings = get_settings(app); + settings.recording_retention_period +} diff --git a/src/components/settings/RecordingRetentionPeriod.tsx b/src/components/settings/RecordingRetentionPeriod.tsx new file mode 100644 index 0000000..41b8428 --- /dev/null +++ b/src/components/settings/RecordingRetentionPeriod.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { useSettings } from "../../hooks/useSettings"; +import { RecordingRetentionPeriod } from "../../lib/types"; + +interface RecordingRetentionPeriodProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const RecordingRetentionPeriodSelector: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { + getSetting, + updateSetting, + isUpdating, + } = useSettings(); + + const selectedRetentionPeriod = getSetting("recording_retention_period") || "never"; + const historyLimit = getSetting("history_limit") || 5; + + const handleRetentionPeriodSelect = async (period: string) => { + await updateSetting("recording_retention_period", period as RecordingRetentionPeriod); + }; + + const retentionOptions = [ + { value: "never", label: "Never" }, + { value: "preserve_limit", label: `Preserve ${historyLimit} Recordings` }, + { value: "days3", label: "After 3 Days" }, + { value: "weeks2", label: "After 2 Weeks" }, + { value: "months3", label: "After 3 Months" }, + ]; + + return ( + + + + ); + }, +); + +RecordingRetentionPeriodSelector.displayName = "RecordingRetentionPeriodSelector"; \ No newline at end of file diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index b1fbb9b..d59662d 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -24,4 +24,4 @@ export const AdvancedSettings: React.FC = () => { ); -}; +}; \ No newline at end of file diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index b9251bd..c0210c9 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -6,6 +6,7 @@ import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; import { PostProcessingToggle } from "../PostProcessingToggle"; import { MuteWhileRecording } from "../MuteWhileRecording"; +import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; export const DebugSettings: React.FC = () => { @@ -18,6 +19,7 @@ export const DebugSettings: React.FC = () => { /> + diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index 4fb560c..3a462fc 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; -import { SettingsGroup } from "../../ui/SettingsGroup"; import { AudioPlayer } from "../../ui/AudioPlayer"; -import { Copy, Star, Check, Trash2 } from "lucide-react"; +import { Button } from "../../ui/Button"; +import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; @@ -14,6 +14,25 @@ interface HistoryEntry { transcription_text: string; } +interface OpenRecordingsButtonProps { + onClick: () => void; +} + +const OpenRecordingsButton: React.FC = ({ + onClick, +}) => ( + +); + export const HistorySettings: React.FC = () => { const [historyEntries, setHistoryEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -93,14 +112,32 @@ export const HistorySettings: React.FC = () => { } }; + const openRecordingsFolder = async () => { + try { + await invoke("open_recordings_folder"); + } catch (error) { + console.error("Failed to open recordings folder:", error); + } + }; + if (loading) { return (
- -
- Loading history... +
+
+
+

+ History +

+
+
- +
+
+ Loading history... +
+
+
); } @@ -108,29 +145,51 @@ export const HistorySettings: React.FC = () => { if (historyEntries.length === 0) { return (
- -
- No transcriptions yet. Start recording to build your history! +
+
+
+

+ History +

+
+
- +
+
+ No transcriptions yet. Start recording to build your history! +
+
+
); } return (
- - {historyEntries.map((entry) => ( - toggleSaved(entry.id)} - onCopyText={() => copyToClipboard(entry.transcription_text)} - getAudioUrl={getAudioUrl} - deleteAudio={deleteAudioEntry} - /> - ))} - +
+
+
+

+ History +

+
+ +
+
+
+ {historyEntries.map((entry) => ( + toggleSaved(entry.id)} + onCopyText={() => copyToClipboard(entry.transcription_text)} + getAudioUrl={getAudioUrl} + deleteAudio={deleteAudioEntry} + /> + ))} +
+
+
); }; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index cecc122..0dd3c2e 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -24,4 +24,5 @@ export { AppDataDirectory } from "./AppDataDirectory"; export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; export { StartHidden } from "./StartHidden"; export { HistoryLimit } from "./HistoryLimit"; +export { RecordingRetentionPeriodSelector } from "./RecordingRetentionPeriod"; export { AutostartToggle } from "./AutostartToggle"; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index abba817..adb389d 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -13,15 +13,17 @@ export const Button: React.FC = ({ ...props }) => { const baseClasses = - "font-medium rounded focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; + "font-medium rounded border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; const variantClasses = { primary: - "text-white bg-background-ui hover:bg-background-ui/80 focus:ring-1 focus:ring-background-ui", - secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none", + "text-white bg-background-ui border-background-ui hover:bg-background-ui/80 hover:border-background-ui/80 focus:ring-1 focus:ring-background-ui", + secondary: + "bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none", danger: - "text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500", - ghost: "text-current hover:bg-mid-gray/10 focus:bg-mid-gray/20", + "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", + ghost: + "text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20", }; const sizeClasses = { diff --git a/src/lib/types.ts b/src/lib/types.ts index 521c25f..ca6f254 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -43,6 +43,15 @@ export const ClipboardHandlingSchema = z.enum([ ]); export type ClipboardHandling = z.infer; +export const RecordingRetentionPeriodSchema = z.enum([ + "never", + "preserve_limit", + "days3", + "weeks2", + "months3", +]); +export type RecordingRetentionPeriod = z.infer; + export const LLMPromptSchema = z.object({ id: z.string(), name: z.string(), @@ -89,6 +98,7 @@ export const SettingsSchema = z.object({ model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"), word_correction_threshold: z.number().optional().default(0.18), history_limit: z.number().optional().default(5), + recording_retention_period: RecordingRetentionPeriodSchema.optional().default("preserve_limit"), paste_method: PasteMethodSchema.optional().default("ctrl_v"), clipboard_handling: ClipboardHandlingSchema.optional().default("dont_modify"), post_process_enabled: z.boolean().optional().default(false), diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index df55f4e..461629f 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -74,6 +74,7 @@ const DEFAULT_SETTINGS: Partial = { debug_mode: false, custom_words: [], history_limit: 5, + recording_retention_period: "preserve_limit", mute_while_recording: false, }; @@ -111,6 +112,8 @@ const settingUpdaters: { invoke("set_selected_output_device", { deviceName: value === "Default" ? "default" : value, }), + recording_retention_period: (value) => + invoke("update_recording_retention_period", { period: value }), translate_to_english: (value) => invoke("change_translate_to_english_setting", { enabled: value }), selected_language: (value) =>