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 <cj@cjpais.com>
This commit is contained in:
Jorge 2025-11-17 03:41:05 +00:00 committed by GitHub
parent 45eec57226
commit 3b3ed5abc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 319 additions and 59 deletions

View file

@ -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<HistoryManager>>,
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(())

View file

@ -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<String, String> {
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::<String>)
.map_err(|e| format!("Failed to open recordings folder: {}", e))?;
Ok(())
}

View file

@ -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");

View file

@ -102,11 +102,6 @@ impl HistoryManager {
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
) -> 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<usize> {
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(())
}
}

View file

@ -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
}

View file

@ -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<RecordingRetentionPeriodProps> = 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 (
<SettingContainer
title="Delete Recordings"
description="Automatically delete recordings from the device"
descriptionMode={descriptionMode}
grouped={grouped}
>
<Dropdown
options={retentionOptions}
selectedValue={selectedRetentionPeriod}
onSelect={handleRetentionPeriodSelect}
placeholder="Select retention period..."
disabled={isUpdating("recording_retention_period")}
/>
</SettingContainer>
);
},
);
RecordingRetentionPeriodSelector.displayName = "RecordingRetentionPeriodSelector";

View file

@ -24,4 +24,4 @@ export const AdvancedSettings: React.FC = () => {
</SettingsGroup>
</div>
);
};
};

View file

@ -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 = () => {
/>
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<HistoryLimit descriptionMode="tooltip" grouped={true} />
<RecordingRetentionPeriodSelector descriptionMode="tooltip" grouped={true} />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />

View file

@ -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<OpenRecordingsButtonProps> = ({
onClick,
}) => (
<Button
onClick={onClick}
variant="secondary"
size="sm"
className="flex items-center gap-2"
title="Open recordings folder"
>
<FolderOpen className="w-4 h-4" />
<span>Open Recordings Folder</span>
</Button>
);
export const HistorySettings: React.FC = () => {
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
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 (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<div className="px-4 py-3 text-center text-text/60">
Loading history...
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
</SettingsGroup>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
Loading history...
</div>
</div>
</div>
</div>
);
}
@ -108,29 +145,51 @@ export const HistorySettings: React.FC = () => {
if (historyEntries.length === 0) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<div className="px-4 py-3 text-center text-text/60">
No transcriptions yet. Start recording to build your history!
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
</SettingsGroup>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
No transcriptions yet. Start recording to build your history!
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
{historyEntries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
deleteAudio={deleteAudioEntry}
/>
))}
</SettingsGroup>
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="divide-y divide-mid-gray/20">
{historyEntries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
deleteAudio={deleteAudioEntry}
/>
))}
</div>
</div>
</div>
</div>
);
};

View file

@ -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";

View file

@ -13,15 +13,17 @@ export const Button: React.FC<ButtonProps> = ({
...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 = {

View file

@ -43,6 +43,15 @@ export const ClipboardHandlingSchema = z.enum([
]);
export type ClipboardHandling = z.infer<typeof ClipboardHandlingSchema>;
export const RecordingRetentionPeriodSchema = z.enum([
"never",
"preserve_limit",
"days3",
"weeks2",
"months3",
]);
export type RecordingRetentionPeriod = z.infer<typeof RecordingRetentionPeriodSchema>;
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),

View file

@ -74,6 +74,7 @@ const DEFAULT_SETTINGS: Partial<Settings> = {
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) =>