diff --git a/package.json b/package.json index d6f41b0..1b15bef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thoth-tauri", - "version": "2026.2.7", + "version": "2026.2.16", "private": true, "description": "Privacy-first, offline-capable voice transcription application", "type": "module", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a494d4..f9f375e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thoth" -version = "2026.2.7" +version = "2026.2.16" description = "Privacy-first, offline-capable voice transcription application" authors = ["Paul Roberts"] license = "MIT" diff --git a/src-tauri/src/audio/preview.rs b/src-tauri/src/audio/preview.rs index 31c614f..b148b61 100644 --- a/src-tauri/src/audio/preview.rs +++ b/src-tauri/src/audio/preview.rs @@ -239,11 +239,50 @@ pub fn start_recording_metering(app: AppHandle, device_id: Option<&str>) -> Resu // Spawn emitter thread to send levels to the recording-indicator window let emit_stop_flag = stop_flag.clone(); let emit_handle = std::thread::spawn(move || { + // Track silence for no-input warning + // After NO_INPUT_GRACE_SECS seconds with RMS below threshold, emit warning + const NO_INPUT_GRACE_SECS: f64 = 3.0; + const NO_INPUT_RMS_THRESHOLD: f32 = 0.001; // effectively silence + let mut silence_since: Option = None; + let mut no_input_warning_sent = false; + let start_time = std::time::Instant::now(); + while !emit_stop_flag.load(Ordering::Relaxed) { while let Ok(samples) = rx.try_recv() { let mut meter = meter.lock(); let level = meter.process(&samples); + // Track silence window for no-input detection + // Only start counting after grace period from recording start + let elapsed_secs = start_time.elapsed().as_secs_f64(); + if elapsed_secs >= NO_INPUT_GRACE_SECS { + if level.rms < NO_INPUT_RMS_THRESHOLD { + let now = std::time::Instant::now(); + let silence_duration = silence_since + .get_or_insert(now) + .elapsed() + .as_secs_f64(); + if silence_duration >= NO_INPUT_GRACE_SECS && !no_input_warning_sent { + // Emit no-input warning + let _ = app.emit("recording-no-input", true); + if let Some(w) = app.get_webview_window("recording-indicator") { + let _ = w.emit("recording-no-input", true); + } + no_input_warning_sent = true; + } + } else { + // Voice detected — clear silence tracker and reset warning + if no_input_warning_sent { + let _ = app.emit("recording-no-input", false); + if let Some(w) = app.get_webview_window("recording-indicator") { + let _ = w.emit("recording-no-input", false); + } + no_input_warning_sent = false; + } + silence_since = None; + } + } + let event = AudioLevelEvent { rms: level.rms, peak: level.peak, diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 102bf9e..915963a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -122,6 +122,9 @@ pub struct ShortcutConfig { pub toggle_recording_alt: Option, /// Copy last transcription shortcut pub copy_last: Option, + /// Quick-add selected text to dictionary shortcut + #[serde(default)] + pub add_to_dictionary: Option, /// Recording mode: toggle or push-to-talk pub recording_mode: RecordingMode, } @@ -132,6 +135,7 @@ impl Default for ShortcutConfig { toggle_recording: "F13".to_string(), toggle_recording_alt: Some("ShiftRight".to_string()), copy_last: Some("F14".to_string()), + add_to_dictionary: None, recording_mode: RecordingMode::default(), } } @@ -196,6 +200,9 @@ pub struct GeneralConfig { pub show_recording_indicator: bool, /// Visual style for the recording indicator pub indicator_style: IndicatorStyle, + /// Override the update check endpoint (None = use built-in default) + #[serde(default)] + pub update_endpoint_override: Option, } impl Default for GeneralConfig { @@ -207,6 +214,7 @@ impl Default for GeneralConfig { check_for_updates: true, show_recording_indicator: true, indicator_style: IndicatorStyle::default(), + update_endpoint_override: None, } } } @@ -748,6 +756,7 @@ mod tests { check_for_updates: true, show_recording_indicator: true, indicator_style: IndicatorStyle::CursorDot, + update_endpoint_override: None, }, recorder: RecorderConfig { position: RecorderPosition::Centre, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbc0969..439093f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,6 +111,13 @@ fn register_shortcuts_from_config(app: &tauri::AppHandle, cfg: &config::Config) "Copy last transcription", ) }), + cfg.shortcuts.add_to_dictionary.as_deref().map(|accel| { + ( + shortcut_ids::ADD_TO_DICTIONARY, + accel, + "Quick-add selected word to dictionary", + ) + }), ] .into_iter() .flatten() diff --git a/src-tauri/src/shortcuts/manager.rs b/src-tauri/src/shortcuts/manager.rs index ac609f8..c5a3ea9 100644 --- a/src-tauri/src/shortcuts/manager.rs +++ b/src-tauri/src/shortcuts/manager.rs @@ -30,6 +30,7 @@ pub mod shortcut_ids { pub const TOGGLE_RECORDING: &str = "toggle_recording"; pub const TOGGLE_RECORDING_ALT: &str = "toggle_recording_alt"; pub const COPY_LAST_TRANSCRIPTION: &str = "copy_last_transcription"; + pub const ADD_TO_DICTIONARY: &str = "add_to_dictionary"; } /// Global shortcut manager instance @@ -81,6 +82,12 @@ pub fn get_defaults() -> Vec { description: "Toggle recording (alternative)".to_string(), is_enabled: false, }, + ShortcutInfo { + id: shortcut_ids::ADD_TO_DICTIONARY.to_string(), + accelerator: String::new(), // No default — user sets their own key + description: "Quick-add selected text to dictionary".to_string(), + is_enabled: false, + }, ] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d7dcc97..65c9122 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Thoth", - "version": "2026.2.7", "identifier": "com.poodle64.thoth", "build": { "beforeDevCommand": "npm run dev", @@ -65,8 +64,10 @@ "requireLiteralLeadingDot": false }, "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEExOEY5RURGQUJBNkQzRjkKUldUNTA2YXIzNTZQb1lNSGQ3WVlkbDY3VVlUQlJYSzJzQW5ieUhhci9NVnVDUms5T0NJa21acnEK", - "endpoints": ["https://github.com/poodle64/thoth/releases/latest/download/latest.json"] + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU0NzMwMTlBQzJDQzEyOUMKUldTY0VzekNtZ0Z6VkxOQlZZSzlmczdBby92bnZnSmlVamRWODJEU3lvajJYcXJ3b1hJODRRUTQK", + "endpoints": [ + "https://github.com/sk8ersquare/thoth/releases/latest/download/latest.json" + ] } }, "bundle": { @@ -106,11 +107,15 @@ "bundleMediaFramework": true }, "deb": { - "depends": ["libasound2", "libwebkit2gtk-4.1-0", "libgtk-3-0"], + "depends": [ + "libasound2", + "libwebkit2gtk-4.1-0", + "libgtk-3-0" + ], "section": "sound", "priority": "optional", "desktopTemplate": "linux/thoth.desktop" } } } -} +} \ No newline at end of file diff --git a/src/App.svelte b/src/App.svelte index 63f464f..9a5e93e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,6 +13,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { onMount, onDestroy } from 'svelte'; import Settings from './lib/windows/Settings.svelte'; + import DictionaryAddModal from './lib/components/DictionaryAddModal.svelte'; import { configStore } from './lib/stores/config.svelte'; import { pipelineStore } from './lib/stores/pipeline.svelte'; import { settingsStore } from './lib/stores/settings.svelte'; @@ -26,10 +27,15 @@ : () => {}; let indicatorLogUnlisten: UnlistenFn | null = null; + let dictAddUnlisten: UnlistenFn | null = null; let isInitialising = $state(true); let initError = $state(null); + // Dictionary quick-add modal state + let dictModalOpen = $state(false); + let dictModalWord = $state(''); + async function initialise() { try { debug('Starting initialisation...'); @@ -105,14 +111,19 @@ debug(event.payload.message); }); + // Listen for dictionary quick-add events (from shortcut handler in pipeline store) + dictAddUnlisten = await listen<{ word: string }>('show-dictionary-add', (event) => { + dictModalWord = event.payload.word ?? ''; + dictModalOpen = true; + }); + initialise(); }); onDestroy(() => { pipelineStore.cleanup(); - if (indicatorLogUnlisten) { - indicatorLogUnlisten(); - } + if (indicatorLogUnlisten) indicatorLogUnlisten(); + if (dictAddUnlisten) dictAddUnlisten(); }); @@ -130,6 +141,12 @@ {/if} + (dictModalOpen = false)} +/> + diff --git a/src/lib/components/OverviewPane.svelte b/src/lib/components/OverviewPane.svelte index 0cbb0c4..141c6a2 100644 --- a/src/lib/components/OverviewPane.svelte +++ b/src/lib/components/OverviewPane.svelte @@ -67,6 +67,8 @@ const updaterState = getUpdaterState(); let currentVersion = $state(''); + let showUpdateEndpointEditor = $state(false); + let updateEndpointDraft = $state(''); /** Average recording duration */ let avgRecordingDuration = $derived( @@ -724,14 +726,46 @@ Check Now {/if} - +
+ + +
+ {#if showUpdateEndpointEditor} +
+
Update source URL (leave blank to use default)
+
+ + + +
+
+ Currently checking: {configStore.general.updateEndpointOverride ?? 'https://github.com/poodle64/thoth/releases/latest/download/latest.json (default)'} +
+
+ {/if} @@ -1380,6 +1414,69 @@ letter-spacing: 0.3px; } + .update-btn-group { + display: flex; + gap: 4px; + align-items: center; + } + + .btn-icon { + padding: 4px 8px; + font-size: 14px; + line-height: 1; + } + + .update-endpoint-editor { + margin-top: 8px; + padding: 10px 12px; + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: 6px; + } + + .endpoint-editor-label { + font-size: var(--text-xs); + color: var(--color-text-primary); + font-weight: 500; + } + + .endpoint-hint { + font-weight: 400; + color: var(--color-text-secondary); + margin-left: 4px; + } + + .endpoint-input-row { + display: flex; + gap: 6px; + align-items: center; + } + + .endpoint-input { + flex: 1; + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: var(--text-xs); + font-family: var(--font-mono); + } + + .endpoint-current { + font-size: var(--text-xs); + color: var(--color-text-secondary); + } + + .endpoint-current code { + font-family: var(--font-mono); + font-size: 10px; + word-break: break-all; + } + .btn-setup.warning, .btn-small.warning { background: var(--color-warning); diff --git a/src/lib/stores/config.svelte.ts b/src/lib/stores/config.svelte.ts index f2da5c2..bba1c00 100644 --- a/src/lib/stores/config.svelte.ts +++ b/src/lib/stores/config.svelte.ts @@ -40,6 +40,8 @@ export interface ShortcutConfig { toggleRecordingAlt: string | null; /** Copy last transcription shortcut */ copyLast: string | null; + /** Quick-add selected text to dictionary shortcut */ + addToDictionary: string | null; /** Recording mode: toggle or push-to-talk */ recordingMode: RecordingMode; } @@ -73,6 +75,8 @@ export interface GeneralConfig { showRecordingIndicator: boolean; /** Visual style for the recording indicator */ indicatorStyle: IndicatorStyle; + /** Override the update check endpoint URL (null = use built-in default) */ + updateEndpointOverride: string | null; } /** Recorder window position options */ @@ -133,6 +137,7 @@ interface ConfigRaw { toggle_recording: string; toggle_recording_alt: string | null; copy_last: string | null; + add_to_dictionary: string | null; recording_mode: RecordingMode; }; enhancement: { @@ -148,6 +153,7 @@ interface ConfigRaw { check_for_updates: boolean; show_recording_indicator: boolean; indicator_style: IndicatorStyle; + update_endpoint_override: string | null; }; recorder: { position: RecorderPosition; @@ -176,6 +182,7 @@ function parseConfig(raw: ConfigRaw): Config { toggleRecording: raw.shortcuts.toggle_recording, toggleRecordingAlt: raw.shortcuts.toggle_recording_alt, copyLast: raw.shortcuts.copy_last, + addToDictionary: raw.shortcuts.add_to_dictionary ?? null, recordingMode: raw.shortcuts.recording_mode, }, enhancement: { @@ -191,6 +198,7 @@ function parseConfig(raw: ConfigRaw): Config { checkForUpdates: raw.general.check_for_updates, showRecordingIndicator: raw.general.show_recording_indicator, indicatorStyle: raw.general.indicator_style, + updateEndpointOverride: raw.general.update_endpoint_override ?? null, }, recorder: { position: raw.recorder.position, @@ -220,6 +228,7 @@ function serialiseConfig(config: Config): ConfigRaw { toggle_recording: config.shortcuts.toggleRecording, toggle_recording_alt: config.shortcuts.toggleRecordingAlt, copy_last: config.shortcuts.copyLast, + add_to_dictionary: config.shortcuts.addToDictionary, recording_mode: config.shortcuts.recordingMode, }, enhancement: { @@ -235,6 +244,7 @@ function serialiseConfig(config: Config): ConfigRaw { check_for_updates: config.general.checkForUpdates, show_recording_indicator: config.general.showRecordingIndicator, indicator_style: config.general.indicatorStyle, + update_endpoint_override: config.general.updateEndpointOverride, }, recorder: { position: config.recorder.position, @@ -264,6 +274,7 @@ function getDefaultConfig(): Config { toggleRecording: 'F13', toggleRecordingAlt: 'CommandOrControl+Shift+Space', copyLast: 'F14', + addToDictionary: null, recordingMode: 'toggle', }, enhancement: { @@ -279,6 +290,7 @@ function getDefaultConfig(): Config { checkForUpdates: true, showRecordingIndicator: true, indicatorStyle: 'cursor-dot', + updateEndpointOverride: null, }, recorder: { position: 'top-right', diff --git a/src/lib/stores/pipeline.svelte.ts b/src/lib/stores/pipeline.svelte.ts index 6f5040c..e4e65f3 100644 --- a/src/lib/stores/pipeline.svelte.ts +++ b/src/lib/stores/pipeline.svelte.ts @@ -11,7 +11,7 @@ */ import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'; import { configStore } from './config.svelte'; import { settingsStore } from './settings.svelte'; @@ -204,6 +204,12 @@ function createPipelineStore() { await toggleRecording(); debug(`${timestamp} toggleRecording completed, new state:`, state); } + + // Handle quick-add to dictionary shortcut + if (shortcutId === 'add_to_dictionary') { + debug(`${timestamp} Quick-add to dictionary triggered`); + await handleQuickAddToDictionary(); + } }); debug(' shortcut-triggered listener registered'); unlisteners.push(shortcutUnlisten); @@ -349,6 +355,26 @@ function createPipelineStore() { } } + /** + * Quick-add to dictionary: reads clipboard text as "from" word, + * prompts user for replacement, then saves the entry. + */ + async function handleQuickAddToDictionary(): Promise { + try { + const { readText } = await import('@tauri-apps/plugin-clipboard-manager'); + + // Read clipboard — user should have the word copied + const clipText = (await readText()) ?? ''; + const fromWord = clipText.trim(); + + // Emit to the main window — it will show an inline modal with a text input + // (window.prompt is disabled in WKWebView; plugin-dialog has no text input type) + emit('show-dictionary-add', { word: fromWord }).catch(() => {}); + } catch (e) { + console.error('[Pipeline] Quick-add to dictionary failed:', e); + } + } + /** * Toggle recording (start if idle, stop and process if recording) */ diff --git a/src/lib/stores/updater.svelte.ts b/src/lib/stores/updater.svelte.ts index 119e988..4be303e 100644 --- a/src/lib/stores/updater.svelte.ts +++ b/src/lib/stores/updater.svelte.ts @@ -47,9 +47,10 @@ const updaterState = $state({ }); /** - * Check for available updates + * Check for available updates. + * @param endpointOverride Optional URL to use instead of the built-in endpoint. */ -export async function checkForUpdate(): Promise { +export async function checkForUpdate(endpointOverride?: string | null): Promise { // Reset state updaterState.state = 'checking'; updaterState.error = null; @@ -58,7 +59,7 @@ export async function checkForUpdate(): Promise { updaterState.releaseNotes = null; try { - const update = await check(); + const update = await check(endpointOverride ? { headers: {}, url: endpointOverride } : undefined); if (update) { // Update available diff --git a/src/lib/windows/Settings.svelte b/src/lib/windows/Settings.svelte index e6fb773..30d6b71 100644 --- a/src/lib/windows/Settings.svelte +++ b/src/lib/windows/Settings.svelte @@ -150,6 +150,9 @@ case 'copy_last_transcription': configStore.updateShortcuts('copyLast', accelerator); break; + case 'add_to_dictionary': + configStore.updateShortcuts('addToDictionary', accelerator); + break; } } @@ -165,6 +168,7 @@ toggle_recording: configStore.shortcuts.toggleRecording, toggle_recording_alt: configStore.shortcuts.toggleRecordingAlt, copy_last: configStore.shortcuts.copyLast, + add_to_dictionary: configStore.shortcuts.addToDictionary, recording_mode: configStore.shortcuts.recordingMode, }, }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3572496..d6a5f9e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,8 +1,10 @@