improve history performance (#1107)
* paginate history * fixes * fix * format * remove unecessary key
This commit is contained in:
parent
8836d45532
commit
e35f0a714e
5 changed files with 355 additions and 225 deletions
|
|
@ -1,4 +1,4 @@
|
|||
use crate::managers::history::{HistoryEntry, HistoryManager};
|
||||
use crate::managers::history::{HistoryManager, PaginatedHistory};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
|
|
@ -7,9 +7,11 @@ use tauri::{AppHandle, State};
|
|||
pub async fn get_history_entries(
|
||||
_app: AppHandle,
|
||||
history_manager: State<'_, Arc<HistoryManager>>,
|
||||
) -> Result<Vec<HistoryEntry>, String> {
|
||||
cursor: Option<i64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<PaginatedHistory, String> {
|
||||
history_manager
|
||||
.get_history_entries()
|
||||
.get_history_entries(cursor, limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ mod utils;
|
|||
pub use cli::CliArgs;
|
||||
#[cfg(debug_assertions)]
|
||||
use specta_typescript::{BigIntExportBehavior, Typescript};
|
||||
use tauri_specta::{collect_commands, Builder};
|
||||
use tauri_specta::{collect_commands, collect_events, Builder};
|
||||
|
||||
use env_filter::Builder as EnvFilterBuilder;
|
||||
use managers::audio::AudioRecordingManager;
|
||||
|
|
@ -321,106 +321,108 @@ pub fn run(cli_args: CliArgs) {
|
|||
// when the variable is unset
|
||||
let console_filter = build_console_filter();
|
||||
|
||||
let specta_builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
shortcut::change_binding,
|
||||
shortcut::reset_binding,
|
||||
shortcut::change_ptt_setting,
|
||||
shortcut::change_audio_feedback_setting,
|
||||
shortcut::change_audio_feedback_volume_setting,
|
||||
shortcut::change_sound_theme_setting,
|
||||
shortcut::change_start_hidden_setting,
|
||||
shortcut::change_autostart_setting,
|
||||
shortcut::change_translate_to_english_setting,
|
||||
shortcut::change_selected_language_setting,
|
||||
shortcut::change_overlay_position_setting,
|
||||
shortcut::change_debug_mode_setting,
|
||||
shortcut::change_word_correction_threshold_setting,
|
||||
shortcut::change_extra_recording_buffer_setting,
|
||||
shortcut::change_paste_method_setting,
|
||||
shortcut::get_available_typing_tools,
|
||||
shortcut::change_typing_tool_setting,
|
||||
shortcut::change_external_script_path_setting,
|
||||
shortcut::change_clipboard_handling_setting,
|
||||
shortcut::change_auto_submit_setting,
|
||||
shortcut::change_auto_submit_key_setting,
|
||||
shortcut::change_post_process_enabled_setting,
|
||||
shortcut::change_experimental_enabled_setting,
|
||||
shortcut::change_post_process_base_url_setting,
|
||||
shortcut::change_post_process_api_key_setting,
|
||||
shortcut::change_post_process_model_setting,
|
||||
shortcut::set_post_process_provider,
|
||||
shortcut::fetch_post_process_models,
|
||||
shortcut::add_post_process_prompt,
|
||||
shortcut::update_post_process_prompt,
|
||||
shortcut::delete_post_process_prompt,
|
||||
shortcut::set_post_process_selected_prompt,
|
||||
shortcut::update_custom_words,
|
||||
shortcut::suspend_binding,
|
||||
shortcut::resume_binding,
|
||||
shortcut::change_mute_while_recording_setting,
|
||||
shortcut::change_append_trailing_space_setting,
|
||||
shortcut::change_lazy_stream_close_setting,
|
||||
shortcut::change_app_language_setting,
|
||||
shortcut::change_update_checks_setting,
|
||||
shortcut::change_keyboard_implementation_setting,
|
||||
shortcut::get_keyboard_implementation,
|
||||
shortcut::change_show_tray_icon_setting,
|
||||
shortcut::change_whisper_accelerator_setting,
|
||||
shortcut::change_ort_accelerator_setting,
|
||||
shortcut::get_available_accelerators,
|
||||
shortcut::handy_keys::start_handy_keys_recording,
|
||||
shortcut::handy_keys::stop_handy_keys_recording,
|
||||
trigger_update_check,
|
||||
show_main_window_command,
|
||||
commands::cancel_operation,
|
||||
commands::get_app_dir_path,
|
||||
commands::get_app_settings,
|
||||
commands::get_default_settings,
|
||||
commands::get_log_dir_path,
|
||||
commands::set_log_level,
|
||||
commands::open_recordings_folder,
|
||||
commands::open_log_dir,
|
||||
commands::open_app_data_dir,
|
||||
commands::check_apple_intelligence_available,
|
||||
commands::initialize_enigo,
|
||||
commands::initialize_shortcuts,
|
||||
commands::models::get_available_models,
|
||||
commands::models::get_model_info,
|
||||
commands::models::download_model,
|
||||
commands::models::delete_model,
|
||||
commands::models::cancel_download,
|
||||
commands::models::set_active_model,
|
||||
commands::models::get_current_model,
|
||||
commands::models::get_transcription_model_status,
|
||||
commands::models::is_model_loading,
|
||||
commands::models::has_any_models_available,
|
||||
commands::models::has_any_models_or_downloads,
|
||||
commands::audio::update_microphone_mode,
|
||||
commands::audio::get_microphone_mode,
|
||||
commands::audio::get_windows_microphone_permission_status,
|
||||
commands::audio::open_microphone_privacy_settings,
|
||||
commands::audio::get_available_microphones,
|
||||
commands::audio::set_selected_microphone,
|
||||
commands::audio::get_selected_microphone,
|
||||
commands::audio::get_available_output_devices,
|
||||
commands::audio::set_selected_output_device,
|
||||
commands::audio::get_selected_output_device,
|
||||
commands::audio::play_test_sound,
|
||||
commands::audio::check_custom_sounds,
|
||||
commands::audio::set_clamshell_microphone,
|
||||
commands::audio::get_clamshell_microphone,
|
||||
commands::audio::is_recording,
|
||||
commands::transcription::set_model_unload_timeout,
|
||||
commands::transcription::get_model_load_status,
|
||||
commands::transcription::unload_model_manually,
|
||||
commands::history::get_history_entries,
|
||||
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_recording_retention_period,
|
||||
helpers::clamshell::is_laptop,
|
||||
]);
|
||||
let specta_builder = Builder::<tauri::Wry>::new()
|
||||
.commands(collect_commands![
|
||||
shortcut::change_binding,
|
||||
shortcut::reset_binding,
|
||||
shortcut::change_ptt_setting,
|
||||
shortcut::change_audio_feedback_setting,
|
||||
shortcut::change_audio_feedback_volume_setting,
|
||||
shortcut::change_sound_theme_setting,
|
||||
shortcut::change_start_hidden_setting,
|
||||
shortcut::change_autostart_setting,
|
||||
shortcut::change_translate_to_english_setting,
|
||||
shortcut::change_selected_language_setting,
|
||||
shortcut::change_overlay_position_setting,
|
||||
shortcut::change_debug_mode_setting,
|
||||
shortcut::change_word_correction_threshold_setting,
|
||||
shortcut::change_extra_recording_buffer_setting,
|
||||
shortcut::change_paste_method_setting,
|
||||
shortcut::get_available_typing_tools,
|
||||
shortcut::change_typing_tool_setting,
|
||||
shortcut::change_external_script_path_setting,
|
||||
shortcut::change_clipboard_handling_setting,
|
||||
shortcut::change_auto_submit_setting,
|
||||
shortcut::change_auto_submit_key_setting,
|
||||
shortcut::change_post_process_enabled_setting,
|
||||
shortcut::change_experimental_enabled_setting,
|
||||
shortcut::change_post_process_base_url_setting,
|
||||
shortcut::change_post_process_api_key_setting,
|
||||
shortcut::change_post_process_model_setting,
|
||||
shortcut::set_post_process_provider,
|
||||
shortcut::fetch_post_process_models,
|
||||
shortcut::add_post_process_prompt,
|
||||
shortcut::update_post_process_prompt,
|
||||
shortcut::delete_post_process_prompt,
|
||||
shortcut::set_post_process_selected_prompt,
|
||||
shortcut::update_custom_words,
|
||||
shortcut::suspend_binding,
|
||||
shortcut::resume_binding,
|
||||
shortcut::change_mute_while_recording_setting,
|
||||
shortcut::change_append_trailing_space_setting,
|
||||
shortcut::change_lazy_stream_close_setting,
|
||||
shortcut::change_app_language_setting,
|
||||
shortcut::change_update_checks_setting,
|
||||
shortcut::change_keyboard_implementation_setting,
|
||||
shortcut::get_keyboard_implementation,
|
||||
shortcut::change_show_tray_icon_setting,
|
||||
shortcut::change_whisper_accelerator_setting,
|
||||
shortcut::change_ort_accelerator_setting,
|
||||
shortcut::get_available_accelerators,
|
||||
shortcut::handy_keys::start_handy_keys_recording,
|
||||
shortcut::handy_keys::stop_handy_keys_recording,
|
||||
trigger_update_check,
|
||||
show_main_window_command,
|
||||
commands::cancel_operation,
|
||||
commands::get_app_dir_path,
|
||||
commands::get_app_settings,
|
||||
commands::get_default_settings,
|
||||
commands::get_log_dir_path,
|
||||
commands::set_log_level,
|
||||
commands::open_recordings_folder,
|
||||
commands::open_log_dir,
|
||||
commands::open_app_data_dir,
|
||||
commands::check_apple_intelligence_available,
|
||||
commands::initialize_enigo,
|
||||
commands::initialize_shortcuts,
|
||||
commands::models::get_available_models,
|
||||
commands::models::get_model_info,
|
||||
commands::models::download_model,
|
||||
commands::models::delete_model,
|
||||
commands::models::cancel_download,
|
||||
commands::models::set_active_model,
|
||||
commands::models::get_current_model,
|
||||
commands::models::get_transcription_model_status,
|
||||
commands::models::is_model_loading,
|
||||
commands::models::has_any_models_available,
|
||||
commands::models::has_any_models_or_downloads,
|
||||
commands::audio::update_microphone_mode,
|
||||
commands::audio::get_microphone_mode,
|
||||
commands::audio::get_windows_microphone_permission_status,
|
||||
commands::audio::open_microphone_privacy_settings,
|
||||
commands::audio::get_available_microphones,
|
||||
commands::audio::set_selected_microphone,
|
||||
commands::audio::get_selected_microphone,
|
||||
commands::audio::get_available_output_devices,
|
||||
commands::audio::set_selected_output_device,
|
||||
commands::audio::get_selected_output_device,
|
||||
commands::audio::play_test_sound,
|
||||
commands::audio::check_custom_sounds,
|
||||
commands::audio::set_clamshell_microphone,
|
||||
commands::audio::get_clamshell_microphone,
|
||||
commands::audio::is_recording,
|
||||
commands::transcription::set_model_unload_timeout,
|
||||
commands::transcription::get_model_load_status,
|
||||
commands::transcription::unload_model_manually,
|
||||
commands::history::get_history_entries,
|
||||
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_recording_retention_period,
|
||||
helpers::clamshell::is_laptop,
|
||||
])
|
||||
.events(collect_events![managers::history::HistoryUpdatePayload,]);
|
||||
|
||||
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
||||
specta_builder
|
||||
|
|
@ -430,6 +432,8 @@ pub fn run(cli_args: CliArgs) {
|
|||
)
|
||||
.expect("Failed to export typescript bindings");
|
||||
|
||||
let invoke_handler = specta_builder.invoke_handler();
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.device_event_filter(tauri::DeviceEventFilter::Always)
|
||||
|
|
@ -497,6 +501,8 @@ pub fn run(cli_args: CliArgs) {
|
|||
))
|
||||
.manage(cli_args.clone())
|
||||
.setup(move |app| {
|
||||
specta_builder.mount_events(app);
|
||||
|
||||
// Create main window programmatically so we can set data_directory
|
||||
// for portable mode (redirects WebView2 cache to portable Data dir)
|
||||
let mut win_builder =
|
||||
|
|
@ -580,7 +586,7 @@ pub fn run(cli_args: CliArgs) {
|
|||
}
|
||||
_ => {}
|
||||
})
|
||||
.invoke_handler(specta_builder.invoke_handler())
|
||||
.invoke_handler(invoke_handler)
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize};
|
|||
use specta::Type;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::AppHandle;
|
||||
use tauri_specta::Event;
|
||||
|
||||
use crate::audio_toolkit::save_wav_file;
|
||||
|
||||
|
|
@ -33,6 +34,23 @@ static MIGRATIONS: &[M] = &[
|
|||
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct PaginatedHistory {
|
||||
pub entries: Vec<HistoryEntry>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type, tauri_specta::Event)]
|
||||
#[serde(tag = "action")]
|
||||
pub enum HistoryUpdatePayload {
|
||||
#[serde(rename = "added")]
|
||||
Added { entry: HistoryEntry },
|
||||
#[serde(rename = "deleted")]
|
||||
Deleted { id: i64 },
|
||||
#[serde(rename = "toggled")]
|
||||
Toggled { id: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct HistoryEntry {
|
||||
pub id: i64,
|
||||
|
|
@ -193,20 +211,30 @@ impl HistoryManager {
|
|||
save_wav_file(file_path, &audio_samples).await?;
|
||||
|
||||
// Save to database
|
||||
self.save_to_database(
|
||||
file_name,
|
||||
let id = self.save_to_database(
|
||||
file_name.clone(),
|
||||
timestamp,
|
||||
title,
|
||||
transcription_text,
|
||||
post_processed_text,
|
||||
post_process_prompt,
|
||||
title.clone(),
|
||||
transcription_text.clone(),
|
||||
post_processed_text.clone(),
|
||||
post_process_prompt.clone(),
|
||||
)?;
|
||||
|
||||
// Clean up old entries
|
||||
self.cleanup_old_entries()?;
|
||||
|
||||
// Emit history updated event
|
||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
||||
// Emit history updated event with the full entry
|
||||
let entry = HistoryEntry {
|
||||
id,
|
||||
file_name,
|
||||
timestamp,
|
||||
saved: false,
|
||||
title,
|
||||
transcription_text,
|
||||
post_processed_text,
|
||||
post_process_prompt,
|
||||
};
|
||||
if let Err(e) = (HistoryUpdatePayload::Added { entry }).emit(&self.app_handle) {
|
||||
error!("Failed to emit history-updated event: {}", e);
|
||||
}
|
||||
|
||||
|
|
@ -221,15 +249,16 @@ impl HistoryManager {
|
|||
transcription_text: String,
|
||||
post_processed_text: Option<String>,
|
||||
post_process_prompt: Option<String>,
|
||||
) -> Result<()> {
|
||||
) -> Result<i64> {
|
||||
let conn = self.get_connection()?;
|
||||
conn.execute(
|
||||
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
|
||||
)?;
|
||||
|
||||
debug!("Saved transcription to database");
|
||||
Ok(())
|
||||
let id = conn.last_insert_rowid();
|
||||
debug!("Saved transcription to database with id {}", id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn cleanup_old_entries(&self) -> Result<()> {
|
||||
|
|
@ -352,13 +381,15 @@ impl HistoryManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
|
||||
pub async fn get_history_entries(
|
||||
&self,
|
||||
cursor: Option<i64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<PaginatedHistory> {
|
||||
let conn = self.get_connection()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC"
|
||||
)?;
|
||||
let limit = limit.map(|l| l.min(100));
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result<HistoryEntry> {
|
||||
Ok(HistoryEntry {
|
||||
id: row.get("id")?,
|
||||
file_name: row.get("file_name")?,
|
||||
|
|
@ -369,14 +400,55 @@ impl HistoryManager {
|
|||
post_processed_text: row.get("post_processed_text")?,
|
||||
post_process_prompt: row.get("post_process_prompt")?,
|
||||
})
|
||||
})?;
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for row in rows {
|
||||
entries.push(row?);
|
||||
let mut entries: Vec<HistoryEntry> = match (cursor, limit) {
|
||||
(Some(cursor_id), Some(lim)) => {
|
||||
let fetch_count = (lim + 1) as i64;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||
FROM transcription_history
|
||||
WHERE id < ?1
|
||||
ORDER BY id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let result = stmt
|
||||
.query_map(params![cursor_id, fetch_count], row_mapper)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
result
|
||||
}
|
||||
(None, Some(lim)) => {
|
||||
let fetch_count = (lim + 1) as i64;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||
FROM transcription_history
|
||||
ORDER BY id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let result = stmt
|
||||
.query_map(params![fetch_count], row_mapper)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
result
|
||||
}
|
||||
(_, None) => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||
FROM transcription_history
|
||||
ORDER BY id DESC",
|
||||
)?;
|
||||
let result = stmt
|
||||
.query_map([], row_mapper)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
let has_more = limit.is_some_and(|lim| entries.len() > lim);
|
||||
if has_more {
|
||||
entries.pop();
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
Ok(PaginatedHistory { entries, has_more })
|
||||
}
|
||||
|
||||
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
|
||||
|
|
@ -430,7 +502,7 @@ impl HistoryManager {
|
|||
debug!("Toggled saved status for entry {}: {}", id, new_saved);
|
||||
|
||||
// Emit history updated event
|
||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
||||
if let Err(e) = (HistoryUpdatePayload::Toggled { id }).emit(&self.app_handle) {
|
||||
error!("Failed to emit history-updated event: {}", e);
|
||||
}
|
||||
|
||||
|
|
@ -490,7 +562,7 @@ impl HistoryManager {
|
|||
debug!("Deleted history entry with id: {}", id);
|
||||
|
||||
// Emit history updated event
|
||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
||||
if let Err(e) = (HistoryUpdatePayload::Deleted { id }).emit(&self.app_handle) {
|
||||
error!("Failed to emit history-updated event: {}", e);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -721,9 +721,9 @@ async unloadModelManually() : Promise<Result<null, string>> {
|
|||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getHistoryEntries() : Promise<Result<HistoryEntry[], string>> {
|
||||
async getHistoryEntries(cursor: number | null, limit: number | null) : Promise<Result<PaginatedHistory, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_history_entries") };
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_history_entries", { cursor, limit }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
|
|
@ -788,6 +788,11 @@ async isLaptop() : Promise<Result<boolean, string>> {
|
|||
/** user-defined events **/
|
||||
|
||||
|
||||
export const events = __makeEvents__<{
|
||||
historyUpdatePayload: HistoryUpdatePayload
|
||||
}>({
|
||||
historyUpdatePayload: "history-update-payload"
|
||||
})
|
||||
|
||||
/** user-defined constants **/
|
||||
|
||||
|
|
@ -804,6 +809,7 @@ export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
|
|||
export type CustomSounds = { start: boolean; stop: boolean }
|
||||
export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary"
|
||||
export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
|
||||
export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number }
|
||||
/**
|
||||
* Result of changing keyboard implementation
|
||||
*/
|
||||
|
|
@ -820,6 +826,7 @@ export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null
|
|||
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15"
|
||||
export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm"
|
||||
export type OverlayPosition = "none" | "top" | "bottom"
|
||||
export type PaginatedHistory = { entries: HistoryEntry[]; has_more: boolean }
|
||||
export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script"
|
||||
export type PermissionAccess = "allowed" | "denied" | "unknown"
|
||||
export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { Check, Copy, FolderOpen, Star, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
commands,
|
||||
events,
|
||||
type HistoryEntry,
|
||||
type HistoryUpdatePayload,
|
||||
} from "@/bindings";
|
||||
import { useOsType } from "@/hooks/useOsType";
|
||||
import { formatDateTime } from "@/utils/dateFormat";
|
||||
import { AudioPlayer } from "../../ui/AudioPlayer";
|
||||
import { Button } from "../../ui/Button";
|
||||
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { commands, type HistoryEntry } from "@/bindings";
|
||||
import { formatDateTime } from "@/utils/dateFormat";
|
||||
import { useOsType } from "@/hooks/useOsType";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
interface OpenRecordingsButtonProps {
|
||||
onClick: () => void;
|
||||
|
|
@ -34,53 +40,109 @@ const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
|
|||
export const HistorySettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const osType = useOsType();
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const entriesRef = useRef<HistoryEntry[]>([]);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// Keep ref in sync for use in IntersectionObserver callback
|
||||
useEffect(() => {
|
||||
entriesRef.current = entries;
|
||||
}, [entries]);
|
||||
|
||||
const loadPage = useCallback(async (cursor?: number) => {
|
||||
const isFirstPage = cursor === undefined;
|
||||
if (!isFirstPage && loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
|
||||
if (isFirstPage) setLoading(true);
|
||||
|
||||
const loadHistoryEntries = useCallback(async () => {
|
||||
try {
|
||||
const result = await commands.getHistoryEntries();
|
||||
const result = await commands.getHistoryEntries(
|
||||
cursor ?? null,
|
||||
PAGE_SIZE,
|
||||
);
|
||||
if (result.status === "ok") {
|
||||
setHistoryEntries(result.data);
|
||||
const { entries: newEntries, has_more } = result.data;
|
||||
setEntries((prev) =>
|
||||
isFirstPage ? newEntries : [...prev, ...newEntries],
|
||||
);
|
||||
setHasMore(has_more);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load history entries:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadHistoryEntries();
|
||||
loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
// Listen for history update events
|
||||
const setupListener = async () => {
|
||||
const unlisten = await listen("history-updated", () => {
|
||||
console.log("History updated, reloading entries...");
|
||||
loadHistoryEntries();
|
||||
});
|
||||
// Infinite scroll via IntersectionObserver
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
// Return cleanup function
|
||||
return unlisten;
|
||||
};
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !hasMore) return;
|
||||
|
||||
let unlistenPromise = setupListener();
|
||||
const observer = new IntersectionObserver(
|
||||
(observerEntries) => {
|
||||
const first = observerEntries[0];
|
||||
if (first.isIntersecting) {
|
||||
const lastEntry = entriesRef.current[entriesRef.current.length - 1];
|
||||
if (lastEntry) {
|
||||
loadPage(lastEntry.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [loading, hasMore, loadPage]);
|
||||
|
||||
// Listen for new entries added from the transcription pipeline
|
||||
useEffect(() => {
|
||||
const unlisten = events.historyUpdatePayload.listen((event) => {
|
||||
const payload: HistoryUpdatePayload = event.payload;
|
||||
if (payload.action === "added") {
|
||||
setEntries((prev) => [payload.entry, ...prev]);
|
||||
}
|
||||
// "deleted" and "toggled" are handled by optimistic updates only,
|
||||
// so we intentionally ignore them here to avoid double-mutation.
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenPromise.then((unlisten) => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
});
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [loadHistoryEntries]);
|
||||
}, []);
|
||||
|
||||
const toggleSaved = async (id: number) => {
|
||||
// Optimistic update
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||
);
|
||||
try {
|
||||
await commands.toggleHistoryEntrySaved(id);
|
||||
// No need to reload here - the event listener will handle it
|
||||
const result = await commands.toggleHistoryEntrySaved(id);
|
||||
if (result.status !== "ok") {
|
||||
// Revert on failure
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle saved status:", error);
|
||||
// Revert on failure
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -100,10 +162,8 @@ export const HistorySettings: React.FC = () => {
|
|||
if (osType === "linux") {
|
||||
const fileData = await readFile(result.data);
|
||||
const blob = new Blob([fileData], { type: "audio/wav" });
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return convertFileSrc(result.data, "asset");
|
||||
}
|
||||
return null;
|
||||
|
|
@ -116,70 +176,64 @@ export const HistorySettings: React.FC = () => {
|
|||
);
|
||||
|
||||
const deleteAudioEntry = async (id: number) => {
|
||||
// Optimistically remove
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
try {
|
||||
await commands.deleteHistoryEntry(id);
|
||||
const result = await commands.deleteHistoryEntry(id);
|
||||
if (result.status !== "ok") {
|
||||
// Reload on failure
|
||||
loadPage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete audio entry:", error);
|
||||
throw error;
|
||||
console.error("Failed to delete entry:", error);
|
||||
loadPage();
|
||||
}
|
||||
};
|
||||
|
||||
const openRecordingsFolder = async () => {
|
||||
try {
|
||||
await commands.openRecordingsFolder();
|
||||
const result = await commands.openRecordingsFolder();
|
||||
if (result.status !== "ok") {
|
||||
throw new Error(String(result.error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open recordings folder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<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">
|
||||
{t("settings.history.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<OpenRecordingsButton
|
||||
onClick={openRecordingsFolder}
|
||||
label={t("settings.history.openFolder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||
<div className="px-4 py-3 text-center text-text/60">
|
||||
{t("settings.history.loading")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
content = (
|
||||
<div className="px-4 py-3 text-center text-text/60">
|
||||
{t("settings.history.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (historyEntries.length === 0) {
|
||||
return (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<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">
|
||||
{t("settings.history.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<OpenRecordingsButton
|
||||
onClick={openRecordingsFolder}
|
||||
label={t("settings.history.openFolder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||
<div className="px-4 py-3 text-center text-text/60">
|
||||
{t("settings.history.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else if (entries.length === 0) {
|
||||
content = (
|
||||
<div className="px-4 py-3 text-center text-text/60">
|
||||
{t("settings.history.empty")}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<div className="divide-y divide-mid-gray/20">
|
||||
{entries.map((entry) => (
|
||||
<HistoryEntryComponent
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onToggleSaved={() => toggleSaved(entry.id)}
|
||||
onCopyText={() => copyToClipboard(entry.transcription_text)}
|
||||
getAudioUrl={getAudioUrl}
|
||||
deleteAudio={deleteAudioEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -197,18 +251,7 @@ export const HistorySettings: React.FC = () => {
|
|||
/>
|
||||
</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>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,7 +292,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
|||
await deleteAudio(entry.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete entry:", error);
|
||||
alert("Failed to delete entry. Please try again.");
|
||||
alert(t("settings.history.deleteError"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -262,7 +305,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
|||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopyText}
|
||||
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
|
||||
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
|
||||
title={t("settings.history.copyToClipboard")}
|
||||
>
|
||||
{showCopied ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue