diff --git a/app.go b/app.go index c99af37..68d9f1d 100644 --- a/app.go +++ b/app.go @@ -39,14 +39,15 @@ const ( // AppState is sent to the frontend type AppState struct { - State RecordingState `json:"state"` - RecordingTime float64 `json:"recordingTime"` - LastTranscript string `json:"lastTranscript"` - Error string `json:"error"` - CurrentModel string `json:"currentModel"` - CurrentProvider string `json:"currentProvider"` - ModelReady bool `json:"modelReady"` - HotkeyEnabled bool `json:"hotkeyEnabled"` + State RecordingState `json:"state"` + RecordingTime float64 `json:"recordingTime"` + LastTranscript string `json:"lastTranscript"` + Error string `json:"error"` + TranscriptionOutputError string `json:"transcriptionOutputError"` + CurrentModel string `json:"currentModel"` + CurrentProvider string `json:"currentProvider"` + ModelReady bool `json:"modelReady"` + HotkeyEnabled bool `json:"hotkeyEnabled"` } // HistoryItem represents a transcription history entry @@ -103,13 +104,14 @@ type App struct { // Serializes native overlay label refreshes. overlayLabelsMu sync.Mutex - mu sync.Mutex - state RecordingState - lastTranscript string - lastError string - recordStartTime time.Time - hotkeyEnabled bool - history []HistoryItem + mu sync.Mutex + state RecordingState + lastTranscript string + lastError string + transcriptionOutputError string + recordStartTime time.Time + hotkeyEnabled bool + history []HistoryItem // Tray callback to update icon onTrayUpdate func(recording bool) @@ -330,14 +332,15 @@ func (a *App) GetState() AppState { } return AppState{ - State: a.state, - RecordingTime: recordingTime, - LastTranscript: a.lastTranscript, - Error: a.lastError, - CurrentModel: config.Model, - CurrentProvider: config.Provider, - ModelReady: modelReady, - HotkeyEnabled: a.hotkeyEnabled, + State: a.state, + RecordingTime: recordingTime, + LastTranscript: a.lastTranscript, + Error: a.lastError, + TranscriptionOutputError: a.transcriptionOutputError, + CurrentModel: config.Model, + CurrentProvider: config.Provider, + ModelReady: modelReady, + HotkeyEnabled: a.hotkeyEnabled, } } @@ -818,6 +821,10 @@ func (a *App) transcribe(samples []float32, duration float64) { sounds.PlayStop() } + if err == nil { + a.saveTranscriptionOutput(text) + } + a.emitState() runtime.EventsEmit(a.ctx, "historyChanged", a.GetHistory()) } diff --git a/app_transcription_output.go b/app_transcription_output.go new file mode 100644 index 0000000..4333d75 --- /dev/null +++ b/app_transcription_output.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// SetSaveTranscriptions enables automatic text files for future transcriptions. +func (a *App) SetSaveTranscriptions(enabled bool) error { + if a.configManager == nil { + return fmt.Errorf("configuration is not ready") + } + if enabled { + if err := checkTranscriptionOutputDir(a.configManager.Get().TranscriptionOutputDir); err != nil { + return err + } + } + if err := a.configManager.SetSaveTranscriptions(enabled); err != nil { + return fmt.Errorf("could not save transcription settings: %w", err) + } + a.clearTranscriptionOutputError() + return nil +} + +// ChooseTranscriptionOutputDir selects and persists the automatic-save folder. +// Cancellation returns an empty path and leaves the settings untouched. +func (a *App) ChooseTranscriptionOutputDir() (string, error) { + return a.chooseTranscriptionOutputDir(runtime.OpenDirectoryDialog) +} + +func (a *App) chooseTranscriptionOutputDir(choose func(context.Context, runtime.OpenDialogOptions) (string, error)) (string, error) { + if a.configManager == nil { + return "", fmt.Errorf("configuration is not ready") + } + options := runtime.OpenDialogOptions{Title: "Choose transcription output folder", CanCreateDirectories: true} + previous := a.configManager.Get().TranscriptionOutputDir + // A removed or disconnected folder must not prevent choosing its replacement. + if info, err := os.Stat(previous); err == nil && info.IsDir() { + options.DefaultDirectory = previous + } + dir, err := choose(a.ctx, options) + if err != nil { + return "", fmt.Errorf("could not choose transcription output folder: %w", err) + } + if dir == "" { + return "", nil + } + if err := checkTranscriptionOutputDir(dir); err != nil { + return "", err + } + if err := a.configManager.SetTranscriptionOutputDir(dir); err != nil { + return "", fmt.Errorf("could not save transcription output folder: %w", err) + } + a.clearTranscriptionOutputError() + return dir, nil +} + +func checkTranscriptionOutputDir(dir string) error { + if dir == "" || !filepath.IsAbs(dir) { + return fmt.Errorf("choose a transcription output folder first") + } + // Test actual access instead of relying on mode bits (ACLs and mounted + // volumes can make those misleading). The probe never contains user text. + f, err := os.CreateTemp(dir, ".yap-write-check-*") + if err != nil { + return fmt.Errorf("transcription output folder is not writable: %w", err) + } + closeErr := f.Close() + removeErr := os.Remove(f.Name()) + if closeErr != nil { + return fmt.Errorf("could not check transcription output folder: %w", closeErr) + } + if removeErr != nil { + return fmt.Errorf("could not clean up transcription output folder check: %w", removeErr) + } + return nil +} + +func (a *App) clearTranscriptionOutputError() { + a.mu.Lock() + a.transcriptionOutputError = "" + a.mu.Unlock() + a.emitState() +} + +// saveTranscriptionOutput runs only after successful transcription, outside the +// app mutex. Failure is separate from recording state: History and clipboard +// remain available even if the destination is full or disconnected. +func (a *App) saveTranscriptionOutput(text string) { + config := a.configManager.Get() + if !config.SaveTranscriptions { + return + } + _, err := writeTranscriptionFile(config.TranscriptionOutputDir, text) + a.mu.Lock() + defer a.mu.Unlock() + current := a.configManager.Get() + if !current.SaveTranscriptions || current.TranscriptionOutputDir != config.TranscriptionOutputDir { + return + } + if err != nil { + a.transcriptionOutputError = fmt.Sprintf("Could not save transcription to %q: %v", config.TranscriptionOutputDir, err) + } else { + a.transcriptionOutputError = "" + } +} + +func writeTranscriptionFile(dir, text string) (string, error) { + if dir == "" || !filepath.IsAbs(dir) { + return "", fmt.Errorf("choose a transcription output folder in Settings") + } + // CreateTemp uses exclusive creation: even simultaneous recordings never + // overwrite each other or an existing file. Keep the useful .txt extension. + f, err := os.CreateTemp(dir, "transcription-"+time.Now().Format("2006-01-02-150405")+"-*.txt") + if err != nil { + return "", err + } + path := f.Name() + // Preserve every UTF-8 byte, including whitespace and original line endings. + if _, err := f.WriteString(text); err != nil { + f.Close() + os.Remove(path) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(path) + return "", err + } + return path, nil +} diff --git a/app_transcription_output_test.go b/app_transcription_output_test.go new file mode 100644 index 0000000..1084e5a --- /dev/null +++ b/app_transcription_output_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "yap/internal/models" + "yap/internal/paths" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func outputTestApp(t *testing.T) *App { + t.Helper() + t.Setenv(paths.ConfigDirEnv, t.TempDir()) + manager, err := models.NewConfigManager() + if err != nil { + t.Fatal(err) + } + return &App{configManager: manager, state: StateReady} +} + +func TestTranscriptionFilesPreserveBytesAndNeverOverwrite(t *testing.T) { + dir := t.TempDir() + texts := []string{" Café — 你好 👋\n\nمرحبا\r\nПривіт\tend \n", "No trailing newline", ""} + var wg sync.WaitGroup + // Simultaneous saves within the same second must all receive unique paths. + for i := 0; i < 20; i++ { + for _, text := range texts { + wg.Add(1) + go func(text string) { + defer wg.Done() + path, err := writeTranscriptionFile(dir, text) + if err != nil { + t.Error(err) + return + } + if !strings.HasPrefix(filepath.Base(path), "transcription-") || filepath.Ext(path) != ".txt" { + t.Errorf("unexpected filename: %q", path) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != text { + t.Errorf("saved bytes = %q, error = %v; want %q", data, err, text) + } + }(text) + } + } + wg.Wait() + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 60 { + t.Fatalf("saved %d files, error = %v; want 60", len(entries), err) + } +} + +func TestTranscriptionOutputSettingsAndCancellation(t *testing.T) { + a := outputTestApp(t) + if err := a.SetSaveTranscriptions(true); err == nil || a.configManager.Get().SaveTranscriptions { + t.Fatal("enabled automatic saves without an output folder") + } + dir := t.TempDir() + selected, err := a.chooseTranscriptionOutputDir(func(_ context.Context, options runtime.OpenDialogOptions) (string, error) { + if options.Title != "Choose transcription output folder" || !options.CanCreateDirectories { + t.Fatalf("dialog options = %+v", options) + } + if !a.mu.TryLock() { + t.Fatal("app mutex held while choosing a directory") + } + a.mu.Unlock() + return dir, nil + }) + if err != nil || selected != dir || a.configManager.Get().SaveTranscriptions { + t.Fatalf("select folder = %q, %v; must not implicitly enable saving", selected, err) + } + if err := a.SetSaveTranscriptions(true); err != nil { + t.Fatal(err) + } + selected, err = a.chooseTranscriptionOutputDir(func(_ context.Context, options runtime.OpenDialogOptions) (string, error) { + if options.DefaultDirectory != dir { + t.Fatalf("default directory = %q, want %q", options.DefaultDirectory, dir) + } + return "", nil + }) + if err != nil || selected != "" { + t.Fatalf("cancel = %q, %v", selected, err) + } + reloaded, err := models.NewConfigManager() + if err != nil || !reloaded.Get().SaveTranscriptions || reloaded.Get().TranscriptionOutputDir != dir { + t.Fatalf("settings did not persist: %v", err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatalf("folder validation left probe files: %v", entries) + } + if err := a.SetSaveTranscriptions(false); err != nil || a.configManager.Get().TranscriptionOutputDir != dir { + t.Fatalf("disabling must preserve the folder: %v", err) + } +} + +func TestTranscriptionOutputChooserFailures(t *testing.T) { + a := outputTestApp(t) + original := t.TempDir() + if err := a.configManager.SetTranscriptionOutputDir(original); err != nil { + t.Fatal(err) + } + dialogErr := errors.New("dialog unavailable") + file := filepath.Join(t.TempDir(), "not-a-folder") + if err := os.WriteFile(file, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + dir string + err error + }{ + {"dialog failure", "", dialogErr}, + {"not a folder", file, nil}, + {"missing folder", filepath.Join(t.TempDir(), "missing"), nil}, + {"relative folder", "relative", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + selected, err := a.chooseTranscriptionOutputDir(func(context.Context, runtime.OpenDialogOptions) (string, error) { return tc.dir, tc.err }) + if err == nil || selected != "" || a.configManager.Get().TranscriptionOutputDir != original { + t.Fatalf("failed choice changed settings: %q, %v", selected, err) + } + if tc.err != nil && !errors.Is(err, tc.err) { + t.Fatalf("lost dialog error: %v", err) + } + }) + } + if err := os.Remove(original); err != nil { + t.Fatal(err) + } + _, err := a.chooseTranscriptionOutputDir(func(_ context.Context, options runtime.OpenDialogOptions) (string, error) { + if options.DefaultDirectory != "" { + t.Fatal("removed output folder prevents reopening the picker") + } + return "", nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAutomaticOutputUsesCompletionSettingsAndRecovers(t *testing.T) { + a := outputTestApp(t) + dir := t.TempDir() + if err := a.configManager.SetTranscriptionOutputDir(dir); err != nil { + t.Fatal(err) + } + a.saveTranscriptionOutput("Disabled") + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatal("created a file while automatic saving was disabled") + } + if err := a.SetSaveTranscriptions(true); err != nil { + t.Fatal(err) + } + // Settings changed while transcription was running: use the destination + // selected at completion, not a stale snapshot from recording start. + newDir := t.TempDir() + if err := a.configManager.SetTranscriptionOutputDir(newDir); err != nil { + t.Fatal(err) + } + a.saveTranscriptionOutput("First") + entries, _ = os.ReadDir(newDir) + if len(entries) != 1 { + t.Fatal("did not use the current output folder") + } + if err := os.RemoveAll(newDir); err != nil { + t.Fatal(err) + } + a.lastTranscript = "Keep this transcription" + a.history = []HistoryItem{{ID: "latest", Text: a.lastTranscript}} + a.saveTranscriptionOutput(a.lastTranscript) + if !strings.Contains(a.transcriptionOutputError, "Could not save transcription") { + t.Fatalf("missing visible output error: %q", a.transcriptionOutputError) + } + if a.state != StateReady || a.lastError != "" || a.lastTranscript != "Keep this transcription" || len(a.history) != 1 { + t.Fatal("output failure damaged successful transcription state/history") + } + if err := os.Mkdir(newDir, 0700); err != nil { + t.Fatal(err) + } + a.saveTranscriptionOutput("Recovered") + if a.transcriptionOutputError != "" { + t.Fatal("successful save did not clear the previous error") + } + a.transcriptionOutputError = "Previous failure" + if err := a.SetSaveTranscriptions(false); err != nil || a.transcriptionOutputError != "" { + t.Fatalf("disabling did not clear the error: %v", err) + } +} + +func TestTranscriptionOutputRejectsInvalidDestinations(t *testing.T) { + for _, dir := range []string{"", "relative", filepath.Join(t.TempDir(), "missing")} { + if path, err := writeTranscriptionFile(dir, "keep this"); err == nil || path != "" { + t.Fatalf("write to %q = %q, %v", dir, path, err) + } + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 998e1f2..ec5eabf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -938,6 +938,61 @@ body { opacity: 0.8; } +.transcription-output-folder { + min-width: 0; +} + +.transcription-file-settings .setting-row { + gap: 16px; +} + +.transcription-file-settings .switch { + flex-shrink: 0; +} + +.transcription-output-folder p { + overflow-wrap: anywhere; +} + +.transcription-output-error { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 24px; + background: rgba(255, 69, 58, 0.1); + border-bottom: 1px solid var(--border); + font-size: 13px; + line-height: 1.5; + color: var(--danger); + overflow-wrap: anywhere; +} + +.transcription-output-error span { + min-width: 0; +} + +.transcription-output-error button, +.setting-row > button { + flex-shrink: 0; +} + +.output-settings-error { + margin-top: 12px; + color: var(--danger); + font-size: 13px; + overflow-wrap: anywhere; +} + +.switch input:disabled + .slider { + opacity: 0.45; + cursor: not-allowed; +} + +.switch input:focus-visible + .slider { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + /* History Page */ .history-page { flex: 1; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0e712c7..f35f0bb 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -17,7 +17,7 @@ const api = Api as unknown as Record>; function appState(overrides: Record = {}) { return { - state: 'ready', recordingTime: 0, lastTranscript: '', error: '', + state: 'ready', recordingTime: 0, lastTranscript: '', error: '', transcriptionOutputError: '', currentModel: 'base.en', currentProvider: 'local', modelReady: true, hotkeyEnabled: true, ...overrides, }; @@ -26,12 +26,13 @@ function appState(overrides: Record = {}) { function setup({ state = appState(), models = MODELS, download = IDLE_DOWNLOAD, platform = 'linux' as string | Promise, hotkeys = ['rightalt', 'escape'], + config = {} as Record, } = {}) { api.IsOnboardingCompleted.mockResolvedValue(true); api.GetPlatform.mockImplementation(() => Promise.resolve(platform)); api.GetState.mockResolvedValue(state); api.GetModels.mockResolvedValue(models); - api.GetConfig.mockResolvedValue({ provider: state.currentProvider, model: state.currentModel, autoPaste: true }); + api.GetConfig.mockResolvedValue({ provider: state.currentProvider, model: state.currentModel, autoPaste: true, saveTranscriptions: false, transcriptionOutputDir: '', ...config }); api.GetHistory.mockResolvedValue([]); api.GetAudioInputDevices.mockResolvedValue([]); api.GetStats.mockResolvedValue({ averageWPM: 0, wordsThisWeek: 0, recordingsThisWeek: 0, timeSavedThisWeek: 0, totalRecordings: 0, totalWords: 0 }); @@ -198,3 +199,97 @@ describe('App hotkey labels', () => { expect(screen.getByRole('button', { name: /^Cancel/ }).querySelector('kbd')).toHaveTextContent(/^F10$/); }); }); + +describe('Automatic transcription files', () => { + beforeEach(() => { + resetListeners(); + Object.values(api).forEach((fn) => { + fn.mockReset(); + fn.mockImplementation(() => Promise.resolve()); + }); + }); + + async function openOutputSettings(config: Record = {}) { + setup({ config }); + fireEvent.click(await screen.findByRole('button', { name: /^Settings$/ })); + } + + const toggle = () => screen.getByRole('checkbox', { name: 'Save transcriptions as .txt' }); + + it('defaults off and requires a chosen folder before enabling', async () => { + await openOutputSettings(); + expect(toggle()).not.toBeChecked(); + expect(toggle()).toBeDisabled(); + expect(screen.getByText('Choose an output folder to enable automatic saving.')).toBeInTheDocument(); + api.ChooseTranscriptionOutputDir.mockResolvedValue('/exports/文字'); + api.GetConfig.mockResolvedValue({ saveTranscriptions: false, transcriptionOutputDir: '/exports/文字' }); + fireEvent.click(screen.getByRole('button', { name: 'Choose folder' })); + expect(await screen.findByText('/exports/文字')).toBeInTheDocument(); + expect(toggle()).toBeEnabled(); + expect(toggle()).not.toBeChecked(); + expect(api.SetSaveTranscriptions).not.toHaveBeenCalled(); + }); + + it('persists enabling/disabling and keeps the selected folder', async () => { + await openOutputSettings({ transcriptionOutputDir: '/exports' }); + api.GetConfig.mockResolvedValue({ saveTranscriptions: true, transcriptionOutputDir: '/exports' }); + fireEvent.click(toggle()); + await waitFor(() => expect(toggle()).toBeChecked()); + expect(api.SetSaveTranscriptions).toHaveBeenCalledWith(true); + api.GetConfig.mockResolvedValue({ saveTranscriptions: false, transcriptionOutputDir: '/exports' }); + fireEvent.click(toggle()); + await waitFor(() => expect(toggle()).not.toBeChecked()); + expect(api.SetSaveTranscriptions).toHaveBeenLastCalledWith(false); + expect(screen.getByText('/exports')).toBeInTheDocument(); + }); + + it('prevents duplicate folder dialogs and leaves settings unchanged on cancel', async () => { + await openOutputSettings({ transcriptionOutputDir: '/original', saveTranscriptions: true }); + let finish!: (path: string) => void; + api.ChooseTranscriptionOutputDir.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const choose = screen.getByRole('button', { name: 'Change folder' }); + fireEvent.click(choose); + expect(choose).toBeDisabled(); + expect(toggle()).toBeDisabled(); + fireEvent.click(choose); + expect(api.ChooseTranscriptionOutputDir).toHaveBeenCalledTimes(1); + await act(async () => finish('')); + expect(toggle()).toBeEnabled(); + expect(toggle()).toBeChecked(); + expect(screen.getByText('/original')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it.each(['disk full', new Error('permission denied')])('shows settings failures without falsely enabling: %s', async (error) => { + await openOutputSettings({ transcriptionOutputDir: '/exports' }); + api.SetSaveTranscriptions.mockRejectedValueOnce(error); + fireEvent.click(toggle()); + expect(await screen.findByRole('alert')).toHaveTextContent(error instanceof Error ? error.message : error); + expect(toggle()).not.toBeChecked(); + expect(toggle()).toBeEnabled(); + api.GetConfig.mockResolvedValue({ saveTranscriptions: true, transcriptionOutputDir: '/exports' }); + fireEvent.click(toggle()); + await waitFor(() => expect(toggle()).toBeChecked()); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('reports folder selection failures and preserves the configured folder', async () => { + await openOutputSettings({ transcriptionOutputDir: '/original', saveTranscriptions: true }); + api.ChooseTranscriptionOutputDir.mockRejectedValue('transcription output folder is not writable'); + fireEvent.click(screen.getByRole('button', { name: 'Change folder' })); + expect(await screen.findByRole('alert')).toHaveTextContent('not writable'); + expect(screen.getByText('/original')).toBeInTheDocument(); + expect(toggle()).toBeChecked(); + }); + + it('shows automatic-save errors on every page and routes to Settings for recovery', async () => { + setup({ state: appState({ lastTranscript: 'Keep this text', transcriptionOutputError: 'Could not save transcription: disk full' }) }); + expect(await screen.findByRole('alert')).toHaveTextContent('disk full. The transcription is still available in History.'); + fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })); + expect(screen.getByRole('heading', { name: 'Transcription files' })).toBeInTheDocument(); + fireEvent.click(nav('History')); + expect(screen.getByRole('alert')).toHaveTextContent('disk full'); + act(() => EventsEmit('stateChanged', appState())); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8628909..255b658 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { GetConfig, SetAutoPaste, SetSoundEnabled, + SetSaveTranscriptions, + ChooseTranscriptionOutputDir, GetHistory, ClearHistory, CopyHistoryItem, @@ -72,6 +74,7 @@ interface AppState { recordingTime: number; lastTranscript: string; error: string; + transcriptionOutputError: string; currentModel: string; currentProvider: string; modelReady: boolean; @@ -85,6 +88,8 @@ interface Config { audioInputDevice?: string; autoPaste: boolean; soundEnabled?: boolean; + saveTranscriptions?: boolean; + transcriptionOutputDir?: string; } interface HistoryItem { @@ -123,6 +128,7 @@ function App() { recordingTime: 0, lastTranscript: '', error: '', + transcriptionOutputError: '', currentModel: 'base.en', currentProvider: 'local', modelReady: false, @@ -132,6 +138,9 @@ function App() { const [config, setConfig] = useState(null); const [history, setHistory] = useState([]); const [selectedHistory, setSelectedHistory] = useState(null); + const [outputSettingsBusy, setOutputSettingsBusy] = useState(false); + const [outputSettingsError, setOutputSettingsError] = useState(''); + const outputSettingsInFlightRef = useRef(false); const [currentPage, setCurrentPage] = useState('home'); const [apiKey, setApiKey] = useState(''); const downloadState = useModelDownload(); @@ -367,6 +376,22 @@ function App() { try { await CancelRecording(); } catch (err) { LogError(`[frontend] CancelRecording failed: ${err}`); } }, []); + const updateOutputSettings = useCallback(async (change: () => Promise) => { + if (outputSettingsInFlightRef.current) return; + outputSettingsInFlightRef.current = true; + setOutputSettingsBusy(true); + setOutputSettingsError(''); + try { + await change(); + setConfig(await GetConfig()); + } catch (err) { + setOutputSettingsError(errorMessage(err)); + } finally { + outputSettingsInFlightRef.current = false; + setOutputSettingsBusy(false); + } + }, []); + // Global escape key handler - always active at app level useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -821,6 +846,13 @@ function App() { )} + {appState.transcriptionOutputError && ( +
+ {appState.transcriptionOutputError}. The transcription is still available in History. + +
+ )} + {currentPage === 'home' && (
{/* Stats Bar */} @@ -1083,6 +1115,43 @@ function App() { )} +
+

Transcription files

+
+
+ +

Automatically save each completed transcription as a separate text file.

+
+ +
+
+
+ +

{config?.transcriptionOutputDir || 'Choose an output folder to enable automatic saving.'}

+
+ +
+ {outputSettingsError &&

{outputSettingsError}

} +
+

Audio Input

diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 72ae94b..2a15f20 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -11,6 +11,8 @@ export function CheckAccessibilityPermission():Promise; export function CheckMicrophonePermission():Promise; +export function ChooseTranscriptionOutputDir():Promise; + export function ClearHistory():Promise; export function CopyHistoryItem(arg1:string):Promise; @@ -87,6 +89,8 @@ export function SetProvider(arg1:string):Promise; export function SetRecordingHotkey(arg1:string):Promise; +export function SetSaveTranscriptions(arg1:boolean):Promise; + export function SetSoundEnabled(arg1:boolean):Promise; export function SetTray(arg1:any):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index eb9712c..544323b 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,6 +18,10 @@ export function CheckMicrophonePermission() { return window['go']['main']['App']['CheckMicrophonePermission'](); } +export function ChooseTranscriptionOutputDir() { + return window['go']['main']['App']['ChooseTranscriptionOutputDir'](); +} + export function ClearHistory() { return window['go']['main']['App']['ClearHistory'](); } @@ -170,6 +174,10 @@ export function SetRecordingHotkey(arg1) { return window['go']['main']['App']['SetRecordingHotkey'](arg1); } +export function SetSaveTranscriptions(arg1) { + return window['go']['main']['App']['SetSaveTranscriptions'](arg1); +} + export function SetSoundEnabled(arg1) { return window['go']['main']['App']['SetSoundEnabled'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 374cfeb..3f9e2b9 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -5,6 +5,7 @@ export namespace main { recordingTime: number; lastTranscript: string; error: string; + transcriptionOutputError: string; currentModel: string; currentProvider: string; modelReady: boolean; @@ -20,6 +21,7 @@ export namespace main { this.recordingTime = source["recordingTime"]; this.lastTranscript = source["lastTranscript"]; this.error = source["error"]; + this.transcriptionOutputError = source["transcriptionOutputError"]; this.currentModel = source["currentModel"]; this.currentProvider = source["currentProvider"]; this.modelReady = source["modelReady"]; @@ -129,6 +131,8 @@ export namespace models { openaiApiKey?: string; audioInputDevice?: string; autoPaste: boolean; + saveTranscriptions: boolean; + transcriptionOutputDir: string; showNotification: boolean; recordingHotkey: string; cancelHotkey: string; @@ -147,6 +151,8 @@ export namespace models { this.openaiApiKey = source["openaiApiKey"]; this.audioInputDevice = source["audioInputDevice"]; this.autoPaste = source["autoPaste"]; + this.saveTranscriptions = source["saveTranscriptions"]; + this.transcriptionOutputDir = source["transcriptionOutputDir"]; this.showNotification = source["showNotification"]; this.recordingHotkey = source["recordingHotkey"]; this.cancelHotkey = source["cancelHotkey"]; diff --git a/internal/models/config.go b/internal/models/config.go index 4dc2fbc..1a87445 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -27,6 +27,10 @@ type Config struct { // Auto-paste after transcription AutoPaste bool `json:"autoPaste"` + // Save completed transcriptions as individual .txt files in the chosen folder. + SaveTranscriptions bool `json:"saveTranscriptions"` + TranscriptionOutputDir string `json:"transcriptionOutputDir"` + // Show notification after transcription ShowNotification bool `json:"showNotification"` @@ -181,8 +185,16 @@ func (cm *ConfigManager) saveLocked() error { func (cm *ConfigManager) update(change func(*Config)) error { cm.mu.Lock() defer cm.mu.Unlock() - change(cm.config) - return cm.saveLocked() + previous := cm.config + next := *previous + next.FavoriteModels = append([]string(nil), previous.FavoriteModels...) + change(&next) + cm.config = &next + if err := cm.saveLocked(); err != nil { + cm.config = previous + return err + } + return nil } // Get returns a snapshot of the current configuration @@ -222,6 +234,14 @@ func (cm *ConfigManager) SetAutoPaste(enabled bool) error { return cm.update(func(c *Config) { c.AutoPaste = enabled }) } +func (cm *ConfigManager) SetSaveTranscriptions(enabled bool) error { + return cm.update(func(c *Config) { c.SaveTranscriptions = enabled }) +} + +func (cm *ConfigManager) SetTranscriptionOutputDir(dir string) error { + return cm.update(func(c *Config) { c.TranscriptionOutputDir = dir }) +} + // SetAudioInputDevice updates the audio input device setting func (cm *ConfigManager) SetAudioInputDevice(deviceName string) error { return cm.update(func(c *Config) { c.AudioInputDevice = deviceName }) diff --git a/internal/models/config_test.go b/internal/models/config_test.go index 94de10a..ae4ad09 100644 --- a/internal/models/config_test.go +++ b/internal/models/config_test.go @@ -1,6 +1,7 @@ package models import ( + "os" "path/filepath" "reflect" "sort" @@ -101,3 +102,70 @@ func TestGetReturnsSnapshot(t *testing.T) { t.Fatal("mutating a snapshot must not change the stored config") } } + +func TestTranscriptionOutputSettingsPersistAndDefaultOff(t *testing.T) { + dir := t.TempDir() + t.Setenv(paths.ConfigDirEnv, dir) + // Existing configurations have neither of the new fields. + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"provider":"local","model":"base.en","autoPaste":true}`), 0600); err != nil { + t.Fatal(err) + } + cm, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + if cm.Get().SaveTranscriptions || cm.Get().TranscriptionOutputDir != "" { + t.Fatal("automatic text files must default to off") + } + output := t.TempDir() + var wg sync.WaitGroup + for _, update := range []func() error{ + func() error { return cm.SetTranscriptionOutputDir(output) }, + func() error { return cm.SetSaveTranscriptions(true) }, + func() error { return cm.SetAutoPaste(false) }, + } { + wg.Add(1) + go func(update func() error) { + defer wg.Done() + if err := update(); err != nil { + t.Error(err) + } + }(update) + } + wg.Wait() + reloaded, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + config := reloaded.Get() + if !config.SaveTranscriptions || config.TranscriptionOutputDir != output || config.AutoPaste || config.Model != "base.en" { + t.Fatalf("concurrent changes were not persisted: %+v", config) + } +} + +func TestFailedSettingsWriteRollsBackEffectiveConfig(t *testing.T) { + t.Setenv(paths.ConfigDirEnv, t.TempDir()) + cm, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + before := cm.Get() + // Force the final config rename to fail, leaving the real config intact. + cm.configFile = t.TempDir() + for _, update := range []func() error{ + func() error { return cm.SetSaveTranscriptions(true) }, + func() error { return cm.SetTranscriptionOutputDir("/unpersisted") }, + func() error { return cm.SetModelFavorite("small", true) }, + } { + if err := update(); err == nil { + t.Fatal("expected persistence failure") + } + if !reflect.DeepEqual(cm.Get(), before) { + t.Fatal("a failed write changed the effective configuration") + } + } + reloaded, err := NewConfigManager() + if err != nil || !reflect.DeepEqual(reloaded.Get(), before) { + t.Fatalf("persisted configuration changed: %v", err) + } +}