summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 08:22:10 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 08:22:10 +0200
commit9a1a8d4f072166a91f853d9b9eabfc6ebbab3474 (patch)
tree4d7cd2efb0b78ca9250154abe293056e06bbb814
parentb0ae81a4abaddb02fa0ec4b9d868ac1aee662fb9 (diff)
fix: complete code-quality task queue (373-378)
-rw-r--r--cmd/totalrecall/main.go5
-rw-r--r--internal/anki/apkg_generator.go46
-rw-r--r--internal/audio/openai_provider.go8
-rw-r--r--internal/cli/command.go51
-rw-r--r--internal/gui/app.go193
-rw-r--r--internal/gui/generator.go34
-rw-r--r--internal/gui/log_viewer.go63
-rw-r--r--internal/gui/navigation.go16
-rw-r--r--internal/gui/persistence.go111
-rw-r--r--internal/gui/queue.go1
-rw-r--r--internal/image/download.go12
-rw-r--r--internal/image/openai.go6
12 files changed, 315 insertions, 231 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index a17bc83..c58565f 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -37,11 +37,6 @@ func main() {
}
func runCommand(cmd *cobra.Command, args []string, flags *cli.Flags) error {
- // Check if output directory was set in config file
- if !cmd.Flags().Changed("output") && flags.OutputDir != "" {
- // Output directory already set by flags
- }
-
// Handle --archive flag
if flags.Archive {
home, _ := os.UserHomeDir()
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 = ""