diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/anki/apkg_generator.go | 46 | ||||
| -rw-r--r-- | internal/audio/openai_provider.go | 8 | ||||
| -rw-r--r-- | internal/cli/command.go | 51 | ||||
| -rw-r--r-- | internal/gui/app.go | 193 | ||||
| -rw-r--r-- | internal/gui/generator.go | 34 | ||||
| -rw-r--r-- | internal/gui/log_viewer.go | 63 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 16 | ||||
| -rw-r--r-- | internal/gui/persistence.go | 111 | ||||
| -rw-r--r-- | internal/gui/queue.go | 1 | ||||
| -rw-r--r-- | internal/image/download.go | 12 | ||||
| -rw-r--r-- | internal/image/openai.go | 6 |
11 files changed, 315 insertions, 226 deletions
diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go index c5a31d2..86b28df 100644 --- a/internal/anki/apkg_generator.go +++ b/internal/anki/apkg_generator.go @@ -16,13 +16,13 @@ import ( // APKGGenerator creates Anki package files (.apkg) type APKGGenerator struct { - deckName string - deckID int64 - modelID int64 - modelIDBgBg int64 // Separate model for bg-bg cards - cards []Card - mediaFiles map[string]int // maps original filename to media number - mediaCounter int + deckName string + deckID int64 + modelID int64 + modelIDBgBg int64 // Separate model for bg-bg cards + cards []Card + mediaFiles map[string]int // maps original filename to media number + mediaCounter int } // NewAPKGGenerator creates a new APKG generator @@ -52,7 +52,9 @@ func (g *APKGGenerator) GenerateAPKG(outputPath string) error { if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } - defer os.RemoveAll(tempDir) + defer func() { + _ = os.RemoveAll(tempDir) + }() // Copy media files FIRST (this populates g.mediaFiles map) if err := g.copyMediaFiles(tempDir); err != nil { @@ -92,7 +94,9 @@ func (g *APKGGenerator) createDatabase(dbPath string) error { if err != nil { return err } - defer db.Close() + defer func() { + _ = db.Close() + }() // Create tables if err := g.createTables(db); err != nil { @@ -244,8 +248,8 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error { // Create model (note type) configuration models := map[string]interface{}{ - fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(), - fmt.Sprintf("%d", g.modelIDBgBg): g.createBgBgNoteTypeConfig(), + fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(), + fmt.Sprintf("%d", g.modelIDBgBg): g.createBgBgNoteTypeConfig(), } modelsJSON, _ := json.Marshal(models) @@ -943,10 +947,14 @@ func (g *APKGGenerator) createZipPackage(tempDir, outputPath string) error { if err != nil { return err } - defer zipFile.Close() + defer func() { + _ = zipFile.Close() + }() archive := zip.NewWriter(zipFile) - defer archive.Close() + defer func() { + _ = archive.Close() + }() // Walk the temp directory and add all files to the zip return filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { @@ -976,7 +984,9 @@ func (g *APKGGenerator) createZipPackage(tempDir, outputPath string) error { if err != nil { return err } - defer file.Close() + defer func() { + _ = file.Close() + }() _, err = io.Copy(writer, file) return err @@ -995,13 +1005,17 @@ func copyFile(src, dst string) error { if err != nil { return err } - defer srcFile.Close() + defer func() { + _ = srcFile.Close() + }() dstFile, err := os.Create(dst) if err != nil { return err } - defer dstFile.Close() + defer func() { + _ = dstFile.Close() + }() _, err = io.Copy(dstFile, srcFile) return err diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go index 27c0131..ca7d418 100644 --- a/internal/audio/openai_provider.go +++ b/internal/audio/openai_provider.go @@ -94,7 +94,9 @@ func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputF } return err } - defer response.Close() + defer func() { + _ = response.Close() + }() // Ensure output directory exists dir := filepath.Dir(outputFile) @@ -109,7 +111,9 @@ func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputF if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer out.Close() + defer func() { + _ = out.Close() + }() // Copy the audio data written, err := io.Copy(out, response) diff --git a/internal/cli/command.go b/internal/cli/command.go index bf73221..b07bd26 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "os" "path/filepath" @@ -79,27 +80,39 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().StringVar(&flags.OpenAIImageStyle, "openai-image-style", flags.OpenAIImageStyle, "Image style: natural or vivid (dall-e-3 only)") // Bind flags to viper - bindFlagsToViper(cmd) + if err := bindFlagsToViper(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to bind flags to config: %v\n", err) + } } -func bindFlagsToViper(cmd *cobra.Command) { - viper.BindPFlag("audio.provider", cmd.Flags().Lookup("audio-provider")) - viper.BindPFlag("audio.voice", cmd.Flags().Lookup("voice")) - viper.BindPFlag("audio.format", cmd.Flags().Lookup("format")) - viper.BindPFlag("audio.pitch", cmd.Flags().Lookup("pitch")) - viper.BindPFlag("audio.amplitude", cmd.Flags().Lookup("amplitude")) - viper.BindPFlag("audio.word_gap", cmd.Flags().Lookup("word-gap")) - viper.BindPFlag("audio.openai_model", cmd.Flags().Lookup("openai-model")) - viper.BindPFlag("audio.openai_voice", cmd.Flags().Lookup("openai-voice")) - viper.BindPFlag("audio.openai_speed", cmd.Flags().Lookup("openai-speed")) - viper.BindPFlag("audio.openai_instruction", cmd.Flags().Lookup("openai-instruction")) - viper.BindPFlag("output.directory", cmd.Flags().Lookup("output")) - viper.BindPFlag("image.provider", cmd.Flags().Lookup("image-api")) - // Bind OpenAI image flags - viper.BindPFlag("image.openai_model", cmd.Flags().Lookup("openai-image-model")) - viper.BindPFlag("image.openai_size", cmd.Flags().Lookup("openai-image-size")) - viper.BindPFlag("image.openai_quality", cmd.Flags().Lookup("openai-image-quality")) - viper.BindPFlag("image.openai_style", cmd.Flags().Lookup("openai-image-style")) +func bindFlagsToViper(cmd *cobra.Command) error { + bindings := map[string]string{ + "audio.format": "format", + "audio.openai_model": "openai-model", + "audio.openai_voice": "openai-voice", + "audio.openai_speed": "openai-speed", + "audio.openai_instruction": "openai-instruction", + "output.directory": "output", + "image.provider": "image-api", + "image.openai_model": "openai-image-model", + "image.openai_size": "openai-image-size", + "image.openai_quality": "openai-image-quality", + "image.openai_style": "openai-image-style", + } + + var errs []error + for key, flagName := range bindings { + flag := cmd.Flags().Lookup(flagName) + if flag == nil { + errs = append(errs, fmt.Errorf("flag %q not found for key %q", flagName, key)) + continue + } + if err := viper.BindPFlag(key, flag); err != nil { + errs = append(errs, fmt.Errorf("bind %q to %q: %w", key, flagName, err)) + } + } + + return errors.Join(errs...) } // InitConfig initializes viper configuration diff --git a/internal/gui/app.go b/internal/gui/app.go index ea4d840..c65cb42 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -449,6 +449,10 @@ func (a *Application) setupUI() { if a.fileCheckTicker != nil { a.fileCheckTicker.Stop() } + // Restore stdio streams and close capture pipes. + if a.logViewer != nil { + a.logViewer.StopCapture() + } // Cancel any ongoing operations if a.cancel != nil { a.cancel() @@ -524,7 +528,7 @@ func (a *Application) onSubmit() { a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", secondaryText)) bulgarian, err := a.translateEnglishToBulgarian(secondaryText) if err != nil { - dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window) + dialog.ShowError(fmt.Errorf("translation failed: %w", err), a.window) return } wordToProcess = bulgarian @@ -537,7 +541,7 @@ func (a *Application) onSubmit() { a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText)) english, err := a.translateWord(bulgarianText) if err != nil { - dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window) + dialog.ShowError(fmt.Errorf("translation failed: %w", err), a.window) return } a.currentTranslation = english @@ -592,7 +596,7 @@ func (a *Application) generateMaterials(word string) { cardDir, err := a.ensureCardDirectory(word) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.showError(fmt.Errorf("failed to create card directory: %w", err)) a.setUIEnabled(true) }) return @@ -606,7 +610,7 @@ func (a *Application) generateMaterials(word string) { translation, err := a.translateWord(word) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Translation failed: %w", err)) + a.showError(fmt.Errorf("translation failed: %w", err)) a.setUIEnabled(true) }) return @@ -751,7 +755,7 @@ func (a *Application) generateMaterials(word string) { audioRes := <-audioChan if audioRes.err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Audio generation failed: %w", audioRes.err)) + a.showError(fmt.Errorf("audio generation failed: %w", audioRes.err)) }) hasError = true } else { @@ -778,7 +782,7 @@ func (a *Application) generateMaterials(word string) { imageRes := <-imageChan if imageRes.err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Image download failed: %w", imageRes.err)) + a.showError(fmt.Errorf("image download failed: %w", imageRes.err)) }) hasError = true } else if imageRes.file != "" { @@ -921,7 +925,7 @@ func (a *Application) onRegenerateImage() { cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.showError(fmt.Errorf("failed to create card directory: %w", err)) }) return } @@ -929,7 +933,7 @@ func (a *Application) onRegenerateImage() { imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Image regeneration failed: %w", err)) + a.showError(fmt.Errorf("image regeneration failed: %w", err)) }) } else { if imageFile != "" { @@ -994,7 +998,7 @@ func (a *Application) onRegenerateRandomImage() { cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.showError(fmt.Errorf("failed to create card directory: %w", err)) }) return } @@ -1002,7 +1006,7 @@ func (a *Application) onRegenerateRandomImage() { imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Random image generation failed: %w", err)) + a.showError(fmt.Errorf("random image generation failed: %w", err)) }) } else { if imageFile != "" { @@ -1038,7 +1042,7 @@ func (a *Application) onRegenerateAudio() { fmt.Printf("DEBUG (onRegenerateAudio): Starting front audio regeneration\n") fmt.Printf(" - currentWord: %s\n", a.currentWord) fmt.Printf(" - currentCardType: %s\n", a.currentCardType) - + // Only disable the audio-related buttons a.regenerateAudioBtn.Disable() a.regenerateAllBtn.Disable() @@ -1073,7 +1077,7 @@ func (a *Application) onRegenerateAudio() { cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.showError(fmt.Errorf("failed to create card directory: %w", err)) }) return } @@ -1083,7 +1087,7 @@ func (a *Application) onRegenerateAudio() { audioFile, err := a.generateAudioFront(cardCtx, wordForGeneration, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Front audio regeneration failed: %w", err)) + a.showError(fmt.Errorf("front audio regeneration failed: %w", err)) }) } else { a.mu.Lock() @@ -1109,7 +1113,7 @@ func (a *Application) onRegenerateAudio() { audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) + a.showError(fmt.Errorf("audio regeneration failed: %w", err)) }) } else { a.mu.Lock() @@ -1147,7 +1151,7 @@ func (a *Application) onRegenerateBackAudio() { fmt.Printf(" - currentCardType: %s\n", a.currentCardType) fmt.Printf(" - currentTranslation (state var): %s\n", a.currentTranslation) fmt.Printf(" - translationEntry.Text (UI field): %s\n", a.translationEntry.Text) - + if a.currentCardType != "bg-bg" { fmt.Printf("DEBUG (onRegenerateBackAudio): Not a bg-bg card, returning\n") return @@ -1168,13 +1172,13 @@ func (a *Application) onRegenerateBackAudio() { translation := a.currentTranslation fmt.Printf("DEBUG (onRegenerateBackAudio): In goroutine - translation from a.currentTranslation: %s\n", translation) fmt.Printf("DEBUG (onRegenerateBackAudio): In goroutine - translation UI field: %s\n", a.translationEntry.Text) - + if translation == "" { fmt.Printf("DEBUG (onRegenerateBackAudio): WARNING - translation state was empty, falling back to UI field\n") translation = strings.TrimSpace(a.translationEntry.Text) fmt.Printf("DEBUG (onRegenerateBackAudio): Using UI field translation: %s\n", translation) } - + wordForGeneration := a.currentWord fmt.Printf("DEBUG (onRegenerateBackAudio): Final decision - will generate back audio for: %s\n", translation) fmt.Printf("DEBUG (onRegenerateBackAudio): (NOT for word: %s)\n", wordForGeneration) @@ -1184,14 +1188,14 @@ func (a *Application) onRegenerateBackAudio() { // Creating a new context would cancel the front audio operation. fmt.Printf("DEBUG (onRegenerateBackAudio): Using main context (not creating new card context)\n") fmt.Printf("DEBUG (onRegenerateBackAudio): This prevents cancelling ongoing front audio operation\n") - + a.startOperation(wordForGeneration) defer a.endOperation(wordForGeneration) cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.showError(fmt.Errorf("failed to create card directory: %w", err)) }) return } @@ -1200,16 +1204,16 @@ func (a *Application) onRegenerateBackAudio() { fmt.Printf(" - ctx: a.ctx (main app context)\n") fmt.Printf(" - translation: %s\n", translation) fmt.Printf(" - cardDir: %s\n", cardDir) - + audioFile, err := a.generateAudioBack(a.ctx, translation, cardDir) - + fmt.Printf("DEBUG (onRegenerateBackAudio): generateAudioBack returned:\n") fmt.Printf(" - err: %v\n", err) fmt.Printf(" - audioFile: %s\n", audioFile) - + if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Back audio regeneration failed: %w", err)) + a.showError(fmt.Errorf("back audio regeneration failed: %w", err)) }) } else { a.mu.Lock() @@ -1352,12 +1356,12 @@ func (a *Application) onExportToAnki() { // Load all cards from the anki_cards directory if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { - dialog.ShowError(fmt.Errorf("Failed to load cards: %w", err), a.window) + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) return } if err := gen.GenerateAPKG(outputPath, deckName); err != nil { - dialog.ShowError(fmt.Errorf("Failed to generate APKG: %w", err), a.window) + dialog.ShowError(fmt.Errorf("failed to generate APKG: %w", err), a.window) return } @@ -1381,12 +1385,12 @@ func (a *Application) onExportToAnki() { // Load all cards from the anki_cards directory if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { - dialog.ShowError(fmt.Errorf("Failed to load cards: %w", err), a.window) + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) return } if err := gen.GenerateCSV(); err != nil { - dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window) + dialog.ShowError(fmt.Errorf("failed to generate CSV: %w", err), a.window) return } @@ -1943,30 +1947,6 @@ func (a *Application) getOrCreateCardContext(word string) (context.Context, cont return ctx, cancel } -// ensureCardDirectory ensures a card directory exists for the given word and returns its path -func (a *Application) ensureCardDirectory(word string) (string, error) { - // First check if directory already exists - wordDir := a.findCardDirectory(word) - if wordDir != "" { - return wordDir, nil - } - - // Create new directory with card ID - cardID := internal.GenerateCardID(word) - wordDir = filepath.Join(a.config.OutputDir, cardID) - if err := os.MkdirAll(wordDir, 0755); err != nil { - return "", fmt.Errorf("failed to create word directory: %w", err) - } - - // Save the original Bulgarian word in a metadata file - metadataFile := filepath.Join(wordDir, "word.txt") - if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { - return "", fmt.Errorf("failed to save word metadata: %w", err) - } - - return wordDir, nil -} - // cancelCardOperations cancels all ongoing operations for a specific word func (a *Application) cancelCardOperations(word string) { a.cardMu.Lock() @@ -2033,11 +2013,17 @@ func (a *Application) processWordJob(job *WordJob) { // Determine if this is a bg-bg card isBgBg := job.CardType == "bg-bg" - // Save card type + // Save card type; fail fast if persistence is unavailable. + var cardTypeErr error if isBgBg { - internal.SaveCardType(cardDir, internal.CardTypeBgBg) + cardTypeErr = internal.SaveCardType(cardDir, internal.CardTypeBgBg) } else { - internal.SaveCardType(cardDir, internal.CardTypeEnBg) + cardTypeErr = internal.SaveCardType(cardDir, internal.CardTypeEnBg) + } + if cardTypeErr != nil { + a.queue.FailJob(job.ID, fmt.Errorf("failed to save card type: %w", cardTypeErr)) + a.finishCurrentJob() + return } // Handle translation @@ -2065,7 +2051,11 @@ func (a *Application) processWordJob(job *WordJob) { if translation != "" { translationFile := filepath.Join(cardDir, "translation.txt") content := fmt.Sprintf("%s = %s\n", job.Word, translation) - os.WriteFile(translationFile, []byte(content), 0644) + if err := os.WriteFile(translationFile, []byte(content), 0644); err != nil { + a.queue.FailJob(job.ID, fmt.Errorf("failed to save translation: %w", err)) + a.finishCurrentJob() + return + } } // Update UI with translation immediately if this is still the current job @@ -2681,33 +2671,6 @@ func (a *Application) handleShortcutKey(key fyne.KeyName) { } } -// saveTranslation saves the current translation to a file -func (a *Application) saveTranslation() { - if a.currentWord != "" && a.currentTranslation != "" { - // Find existing card directory - wordDir := a.findCardDirectory(a.currentWord) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(a.currentWord) - wordDir = filepath.Join(a.config.OutputDir, cardID) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - // Save word metadata - metadataFile := filepath.Join(wordDir, "word.txt") - os.WriteFile(metadataFile, []byte(a.currentWord), 0644) - } - translationFile := filepath.Join(wordDir, "translation.txt") - content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation) - os.WriteFile(translationFile, []byte(content), 0644) - } -} - -// saveImagePrompt saves the current image prompt to a file -func (a *Application) saveImagePrompt() { - // With timestamp-based card IDs, we can't update existing prompts - // The prompt is saved when the image is generated - // This function is kept for compatibility but does nothing -} - // handleWordChange is called when the Bulgarian word is changed func (a *Application) handleWordChange(oldWord, newWord string) { // Update current word @@ -2736,74 +2699,10 @@ func (a *Application) handleWordChange(oldWord, newWord string) { } } -// savePhoneticInfo saves the phonetic information to a file -func (a *Application) savePhoneticInfo() { - phoneticText := a.currentPhonetic - if a.currentWord != "" && phoneticText != "" && - phoneticText != "Failed to fetch phonetic information" { - // Find existing card directory - wordDir := a.findCardDirectory(a.currentWord) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(a.currentWord) - wordDir = filepath.Join(a.config.OutputDir, cardID) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - // Save word metadata - metadataFile := filepath.Join(wordDir, "word.txt") - os.WriteFile(metadataFile, []byte(a.currentWord), 0644) - } - phoneticFile := filepath.Join(wordDir, "phonetic.txt") - os.WriteFile(phoneticFile, []byte(phoneticText), 0644) - } -} - -// savePhoneticInfoForWord saves the phonetic information for a specific word -func (a *Application) savePhoneticInfoForWord(word, phoneticText string) { - if word != "" && phoneticText != "" && - phoneticText != "Failed to fetch phonetic information" && - phoneticText != "Phonetic information will appear here..." { - // Find existing card directory first - wordDir := a.findCardDirectory(word) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(word) - wordDir = filepath.Join(a.config.OutputDir, cardID) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - // Save word metadata - metadataFile := filepath.Join(wordDir, "word.txt") - os.WriteFile(metadataFile, []byte(word), 0644) - } - phoneticFile := filepath.Join(wordDir, "phonetic.txt") - os.WriteFile(phoneticFile, []byte(phoneticText), 0644) - } -} - -// loadPhoneticInfo loads phonetic information from a file if it exists -func (a *Application) loadPhoneticInfo(word string) { - wordDir := a.findCardDirectory(word) - if wordDir == "" { - return - } - - phoneticFile := filepath.Join(wordDir, "phonetic.txt") - if data, err := os.ReadFile(phoneticFile); err == nil { - phoneticText := string(data) - a.currentPhonetic = phoneticText - fyne.Do(func() { - // Display the IPA in the audio player - if phoneticText != "" { - a.audioPlayer.SetPhonetic(phoneticText) - } else { - a.audioPlayer.SetPhonetic("") - } - }) - } -} - // getPhoneticInfo fetches phonetic information for a Bulgarian word using OpenAI GPT-4o func (a *Application) getPhoneticInfo(word string) (string, error) { if a.config.OpenAIKey == "" { - return "", fmt.Errorf("OpenAI API key not configured") + return "", fmt.Errorf("openai API key not configured") } client := openai.NewClient(a.config.OpenAIKey) diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 4569191..9c20e92 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -16,6 +16,13 @@ import ( "codeberg.org/snonux/totalrecall/internal/image" ) +func randomVoiceAndSpeed(voices []string) (string, float64) { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + voice := voices[rng.Intn(len(voices))] + speed := 0.90 + rng.Float64()*0.10 + return voice, speed +} + // translateWord translates a Bulgarian word to English func (a *Application) translateWord(word string) (string, error) { if a.config.OpenAIKey == "" { @@ -97,11 +104,7 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} // Select a random voice - rand.Seed(time.Now().UnixNano()) - voice := allVoices[rand.Intn(len(allVoices))] - - // Generate random speed between 0.90 and 1.00 - speed := 0.90 + rand.Float64()*0.10 + voice, speed := randomVoiceAndSpeed(allVoices) // Create a copy of audio config with selected voice and speed audioConfig := *a.audioConfig @@ -155,16 +158,14 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st // generateAudioFront generates front audio for a bg-bg card func (a *Application) generateAudioFront(ctx context.Context, word string, cardDir string) (string, error) { fmt.Printf("DEBUG (generateAudioFront): Called with word: %s, cardDir: %s\n", word, cardDir) - + if cardDir == "" { fmt.Printf("DEBUG (generateAudioFront): Card directory not provided, returning error\n") return "", fmt.Errorf("card directory not provided") } allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} - rand.Seed(time.Now().UnixNano()) - voice := allVoices[rand.Intn(len(allVoices))] - speed := 0.90 + rand.Float64()*0.10 + voice, speed := randomVoiceAndSpeed(allVoices) audioConfig := *a.audioConfig audioConfig.OpenAIVoice = voice @@ -199,16 +200,14 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD // generateAudioBack generates back audio for a bg-bg card func (a *Application) generateAudioBack(ctx context.Context, text string, cardDir string) (string, error) { fmt.Printf("DEBUG (generateAudioBack): Called with text: %s, cardDir: %s\n", text, cardDir) - + if cardDir == "" { fmt.Printf("DEBUG (generateAudioBack): Card directory not provided, returning error\n") return "", fmt.Errorf("card directory not provided") } allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} - rand.Seed(time.Now().UnixNano()) - voice := allVoices[rand.Intn(len(allVoices))] - speed := 0.90 + rand.Float64()*0.10 + voice, speed := randomVoiceAndSpeed(allVoices) audioConfig := *a.audioConfig audioConfig.OpenAIVoice = voice @@ -240,9 +239,7 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi } allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} - rand.Seed(time.Now().UnixNano()) - voice := allVoices[rand.Intn(len(allVoices))] - speed := 0.90 + rand.Float64()*0.10 + voice, speed := randomVoiceAndSpeed(allVoices) audioConfig := *a.audioConfig audioConfig.OpenAIVoice = voice @@ -283,11 +280,6 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi return frontFile, backFile, nil } -// generateImages downloads images for a word -func (a *Application) generateImages(ctx context.Context, word string, cardDir string) (string, error) { - return a.generateImagesWithPrompt(ctx, word, "", "", cardDir) -} - // generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string, cardDir string) (string, error) { // Create image searcher based on provider diff --git a/internal/gui/log_viewer.go b/internal/gui/log_viewer.go index 79a1269..c804a27 100644 --- a/internal/gui/log_viewer.go +++ b/internal/gui/log_viewer.go @@ -23,7 +23,9 @@ type LogWriter struct { func (w *LogWriter) Write(p []byte) (n int, err error) { // Write to original output if w.original != nil { - w.original.Write(p) + if _, err := w.original.Write(p); err != nil { + return 0, err + } } // Send to log viewer @@ -54,6 +56,10 @@ type LogViewer struct { originalStderr *os.File stdoutWriter *LogWriter stderrWriter *LogWriter + stdoutR *os.File + stdoutW *os.File + stderrR *os.File + stderrW *os.File } // NewLogViewer creates a new log viewer widget @@ -104,11 +110,32 @@ func (v *LogViewer) StartCapture() { v.stderrWriter = &LogWriter{viewer: v, original: v.originalStderr} // Create pipe for stdout - stdoutR, stdoutW, _ := os.Pipe() + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + if v.originalStderr != nil { + _, _ = fmt.Fprintf(v.originalStderr, "Warning: failed to create stdout capture pipe: %v\n", err) + } + return + } + v.stdoutR = stdoutR + v.stdoutW = stdoutW os.Stdout = stdoutW // Create pipe for stderr - stderrR, stderrW, _ := os.Pipe() + stderrR, stderrW, err := os.Pipe() + if err != nil { + if v.originalStderr != nil { + _, _ = fmt.Fprintf(v.originalStderr, "Warning: failed to create stderr capture pipe: %v\n", err) + } + os.Stdout = v.originalStdout + _ = stdoutR.Close() + _ = stdoutW.Close() + v.stdoutR = nil + v.stdoutW = nil + return + } + v.stderrR = stderrR + v.stderrW = stderrW os.Stderr = stderrW // Also redirect log package output @@ -121,11 +148,19 @@ func (v *LogViewer) StartCapture() { // pipeReader reads from a pipe and writes to a LogWriter func (v *LogViewer) pipeReader(pipe *os.File, writer *LogWriter) { + defer func() { + if pipe != nil { + _ = pipe.Close() + } + }() + buf := make([]byte, 1024) for { n, err := pipe.Read(buf) if n > 0 { - writer.Write(buf[:n]) + if _, writeErr := writer.Write(buf[:n]); writeErr != nil { + break + } } if err != nil { break @@ -146,6 +181,26 @@ func (v *LogViewer) StopCapture() { // Reset log package output log.SetOutput(os.Stderr) + + // Close write ends to unblock reader goroutines. + if v.stdoutW != nil { + _ = v.stdoutW.Close() + v.stdoutW = nil + } + if v.stderrW != nil { + _ = v.stderrW.Close() + v.stderrW = nil + } + + // Close read ends if still open. + if v.stdoutR != nil { + _ = v.stdoutR.Close() + v.stdoutR = nil + } + if v.stderrR != nil { + _ = v.stderrR.Close() + v.stderrR = nil + } } // AddMessage adds a message to the log diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 79703de..8787080 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -99,7 +99,7 @@ func (a *Application) scanExistingWords() { if _, err := os.Stat(audioFile); err == nil { hasContent = true } - + // Check for bg-bg audio files (audio_front and audio_back) if !hasContent { frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) @@ -383,15 +383,15 @@ func (a *Application) loadExistingFiles(word string) { if len(parts) >= 2 { translation := strings.TrimSpace(parts[1]) fmt.Printf("DEBUG (loadExistingFiles): Extracted translation (part 1, after '='): %s\n", translation) - + // CRITICAL: Set the state BEFORE SetText so it's available when needed a.currentTranslation = translation fmt.Printf("DEBUG (loadExistingFiles): Set a.currentTranslation state variable to: %s\n", a.currentTranslation) - + fyne.Do(func() { a.translationEntry.SetText(translation) fmt.Printf("DEBUG (loadExistingFiles): Set translationEntry UI field to: %s\n", translation) - + // CRITICAL: After SetText, verify the state is correct fmt.Printf("DEBUG (loadExistingFiles): After SetText, a.currentTranslation is: %s\n", a.currentTranslation) }) @@ -458,7 +458,7 @@ func (a *Application) loadExistingFiles(word string) { } else { fmt.Printf("DEBUG (loadExistingFiles): Front audio not found: %s\n", frontAudio) } - + if _, err := os.Stat(backAudio); err == nil { a.currentAudioFileBack = backAudio fmt.Printf("DEBUG (loadExistingFiles): Found back audio: %s\n", backAudio) @@ -599,7 +599,7 @@ func (a *Application) checkForMissingFiles(word string) { } } } - + // Check for missing back audio file (bg-bg cards) if a.currentAudioFileBack == "" { backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) @@ -687,13 +687,13 @@ func (a *Application) onDelete() { // Check if this word has active operations if a.hasActiveOperations(a.currentWord) { - dialog.ShowError(fmt.Errorf("Cannot delete '%s' while content is being generated.\nPlease wait for generation to complete.", a.currentWord), a.window) + dialog.ShowError(fmt.Errorf("cannot delete %q while content is being generated; please wait for generation to complete", a.currentWord), a.window) return } // Also check if word is in the processing queue if a.queue.IsWordProcessing(a.currentWord) { - dialog.ShowError(fmt.Errorf("Cannot delete '%s' while it is in the processing queue.\nPlease wait for processing to complete.", a.currentWord), a.window) + dialog.ShowError(fmt.Errorf("cannot delete %q while it is in the processing queue; please wait for processing to complete", a.currentWord), a.window) return } diff --git a/internal/gui/persistence.go b/internal/gui/persistence.go new file mode 100644 index 0000000..0919974 --- /dev/null +++ b/internal/gui/persistence.go @@ -0,0 +1,111 @@ +package gui + +import ( + "fmt" + "os" + "path/filepath" + + "fyne.io/fyne/v2" + + "codeberg.org/snonux/totalrecall/internal" +) + +// ensureWordDirectoryAndMetadata creates a new card directory and writes word metadata. +func (a *Application) ensureWordDirectoryAndMetadata(word string) (string, error) { + cardID := internal.GenerateCardID(word) + wordDir := filepath.Join(a.config.OutputDir, cardID) + if err := os.MkdirAll(wordDir, 0755); err != nil { + return "", fmt.Errorf("failed to create card directory: %w", err) + } + + metadataFile := filepath.Join(wordDir, "word.txt") + if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { + return "", fmt.Errorf("failed to save word metadata: %w", err) + } + + return wordDir, nil +} + +// ensureCardDirectory ensures a card directory exists for the given word and returns its path. +func (a *Application) ensureCardDirectory(word string) (string, error) { + wordDir := a.findCardDirectory(word) + if wordDir != "" { + return wordDir, nil + } + + return a.ensureWordDirectoryAndMetadata(word) +} + +// saveTranslation saves the current translation to a file. +func (a *Application) saveTranslation() { + if a.currentWord == "" || a.currentTranslation == "" { + return + } + + wordDir := a.findCardDirectory(a.currentWord) + if wordDir == "" { + newWordDir, err := a.ensureWordDirectoryAndMetadata(a.currentWord) + if err != nil { + a.showError(err) + return + } + wordDir = newWordDir + } + + translationFile := filepath.Join(wordDir, "translation.txt") + content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation) + if err := os.WriteFile(translationFile, []byte(content), 0644); err != nil { + a.showError(fmt.Errorf("failed to save translation: %w", err)) + } +} + +// saveImagePrompt saves the current image prompt to a file. +func (a *Application) saveImagePrompt() { + // With timestamp-based card IDs, we can't update existing prompts. + // The prompt is saved when the image is generated. + // This function is kept for compatibility but does nothing. +} + +// savePhoneticInfo saves the phonetic information to a file. +func (a *Application) savePhoneticInfo() { + phoneticText := a.currentPhonetic + if a.currentWord == "" || phoneticText == "" || phoneticText == "Failed to fetch phonetic information" { + return + } + + wordDir := a.findCardDirectory(a.currentWord) + if wordDir == "" { + newWordDir, err := a.ensureWordDirectoryAndMetadata(a.currentWord) + if err != nil { + a.showError(err) + return + } + wordDir = newWordDir + } + + phoneticFile := filepath.Join(wordDir, "phonetic.txt") + if err := os.WriteFile(phoneticFile, []byte(phoneticText), 0644); err != nil { + a.showError(fmt.Errorf("failed to save phonetic info: %w", err)) + } +} + +// loadPhoneticInfo loads phonetic information from a file if it exists. +func (a *Application) loadPhoneticInfo(word string) { + wordDir := a.findCardDirectory(word) + if wordDir == "" { + return + } + + phoneticFile := filepath.Join(wordDir, "phonetic.txt") + if data, err := os.ReadFile(phoneticFile); err == nil { + phoneticText := string(data) + a.currentPhonetic = phoneticText + fyne.Do(func() { + if phoneticText != "" { + a.audioPlayer.SetPhonetic(phoneticText) + } else { + a.audioPlayer.SetPhonetic("") + } + }) + } +} diff --git a/internal/gui/queue.go b/internal/gui/queue.go index 86c6ffd..cbf814c 100644 --- a/internal/gui/queue.go +++ b/internal/gui/queue.go @@ -65,7 +65,6 @@ type WordQueue struct { ctx context.Context cancel context.CancelFunc - wg sync.WaitGroup } // NewWordQueue creates a new word processing queue diff --git a/internal/image/download.go b/internal/image/download.go index d01f829..8ea897e 100644 --- a/internal/image/download.go +++ b/internal/image/download.go @@ -68,7 +68,9 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou if err != nil { return fmt.Errorf("download %q: %w", result.URL, err) } - defer reader.Close() + defer func() { + _ = reader.Close() + }() // Create output file file, err := os.Create(outputPath) @@ -82,7 +84,7 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou if d.options.MaxSizeBytes > 0 { written, err = io.CopyN(file, reader, d.options.MaxSizeBytes) if err != nil && err != io.EOF { - os.Remove(outputPath) // Clean up on error + _ = os.Remove(outputPath) // Clean up on error return fmt.Errorf("write output file %q: %w", outputPath, err) } @@ -90,7 +92,7 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou if written == d.options.MaxSizeBytes { // Try to read one more byte to see if file is larger if _, err := reader.Read(make([]byte, 1)); err != io.EOF { - os.Remove(outputPath) // Clean up + _ = os.Remove(outputPath) // Clean up return fmt.Errorf("image exceeds max size %d bytes", d.options.MaxSizeBytes) } } @@ -100,9 +102,9 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou return fmt.Errorf("sync output file %q: %w", outputPath, err) } } else { - written, err = io.Copy(file, reader) + _, err = io.Copy(file, reader) if err != nil { - os.Remove(outputPath) // Clean up on error + _ = os.Remove(outputPath) // Clean up on error return fmt.Errorf("write output file %q: %w", outputPath, err) } } diff --git a/internal/image/openai.go b/internal/image/openai.go index d964b55..5bb7db4 100644 --- a/internal/image/openai.go +++ b/internal/image/openai.go @@ -110,7 +110,7 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc } fmt.Printf("Using custom prompt: %s\n", prompt) } else { - prompt = c.createEducationalPrompt(opts.Query, translatedWord) + prompt = c.createEducationalPrompt(ctx, opts.Query, translatedWord) if prompt == "" { return nil, &SearchError{ Provider: "openai", @@ -234,9 +234,9 @@ func (c *OpenAIClient) SetPromptCallback(callback func(prompt string)) { } // createEducationalPrompt generates a prompt optimized for language learning -func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation string) string { +func (c *OpenAIClient) createEducationalPrompt(ctx context.Context, bulgarianWord, englishTranslation string) string { // Generate a scene description for the word - scene, err := c.generateSceneDescription(context.Background(), bulgarianWord, englishTranslation) + scene, err := c.generateSceneDescription(ctx, bulgarianWord, englishTranslation) if err != nil { fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err) scene = "" |
