summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 08:03:05 +0300
commit63bd86d8046949e80e5b1122a1fcf717f51915c6 (patch)
tree10bc72c97d1ba597bc82fb273987716eabf2e989
parent5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (diff)
tasks 00d, 006, 007: move test seams to struct fields, centralize Viper config, extract shared card-dir logic
task 00d: remove package-level var test seams from processor and gui packages; factory functions (newAudioProvider, newOpenAIImageClient, newNanoBananaImageClient) are now struct fields on Processor and Application, initialized with production defaults in constructors and replaced in tests without global mutation. task 006: add viperConfig struct captured once in NewProcessor; no method body calls viper.GetString/IsSet/GetFloat64 directly any more — all config-file values are accessed via p.viperCfg fields. task 007: extract FindCardDirectory and FindOrCreateCardDirectory into internal/utils.go; both Processor.findCardDirectory and Application.findCardDirectory now delegate to the shared implementation, which also handles the legacy _word.txt backward-compat fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/gui/app.go10
-rw-r--r--internal/gui/generator.go16
-rw-r--r--internal/gui/generator_test.go145
-rw-r--r--internal/gui/navigation.go38
-rw-r--r--internal/processor/processor.go246
-rw-r--r--internal/processor/processor_test.go222
-rw-r--r--internal/utils.go59
7 files changed, 348 insertions, 388 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 4f8311e..e5cc9dd 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -105,6 +105,11 @@ type Application struct {
// Active operations tracking
activeOperations map[string]int // Map of word -> count of active operations
activeOpMu sync.Mutex // Mutex for activeOperations map
+
+ // Injectable factory functions — replaced in tests to avoid real API calls.
+ newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient
+ newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient
+ newAudioProvider func(*audio.Config) (audio.Provider, error)
}
// Config holds GUI application configuration
@@ -218,6 +223,11 @@ func New(config *Config) *Application {
cardContexts: make(map[string]context.CancelFunc),
activeOperations: make(map[string]int),
autoPlayEnabled: config.AutoPlay, // Use config setting
+
+ // Production defaults for factory functions; replaced in tests.
+ newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) },
+ newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) },
+ newAudioProvider: audio.NewProvider,
}
// Initialize the word processing queue
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 4763149..4b55608 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -22,16 +22,6 @@ type promptAwareImageClient interface {
SetPromptCallback(func(prompt string))
}
-var newOpenAIImageClient = func(config *image.OpenAIConfig) promptAwareImageClient {
- return image.NewOpenAIClient(config)
-}
-
-var newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient {
- return image.NewNanoBananaClient(config)
-}
-
-var newAudioProvider = audio.NewProvider
-
func randomVoice(voices []string) string {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
return voices[rng.Intn(len(voices))]
@@ -123,7 +113,7 @@ func (a *Application) audioConfigForGeneration(voice string, speed float64) audi
func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error {
audioConfig := a.audioConfigForGeneration(voice, speed)
- provider, err := newAudioProvider(&audioConfig)
+ provider, err := a.newAudioProvider(&audioConfig)
if err != nil {
return err
}
@@ -410,7 +400,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) {
Style: "natural",
}
- return newOpenAIImageClient(openaiConfig), nil
+ return a.newOpenAIImageClient(openaiConfig), nil
case imageProviderNanoBanana:
config := a.config
if config == nil {
@@ -426,7 +416,7 @@ func (a *Application) newImageSearcher() (promptAwareImageClient, error) {
TextModel: config.NanoBananaTextModel,
}
- return newNanoBananaImageClient(nanoBananaConfig), nil
+ return a.newNanoBananaImageClient(nanoBananaConfig), nil
default:
return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
}
diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go
index 4e4e3cc..c8bcf42 100644
--- a/internal/gui/generator_test.go
+++ b/internal/gui/generator_test.go
@@ -93,29 +93,9 @@ func (f *fakeAudioProvider) BuildAttribution(params audio.AttributionParams) str
}
func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
- originalNanoBananaClient := newNanoBananaImageClient
- originalOpenAIClient := newOpenAIImageClient
- t.Cleanup(func() {
- newNanoBananaImageClient = originalNanoBananaClient
- newOpenAIImageClient = originalOpenAIClient
- })
-
fakeClient := &fakePromptAwareImageClient{}
var capturedConfig *image.NanoBananaConfig
- newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient {
- capturedConfig = &image.NanoBananaConfig{
- APIKey: config.APIKey,
- Model: config.Model,
- TextModel: config.TextModel,
- }
- return fakeClient
- }
- newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient {
- t.Fatal("unexpected OpenAI image client construction")
- return nil
- }
-
tempDir := t.TempDir()
app := &Application{
config: &Config{
@@ -127,6 +107,18 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
},
currentWord: "друго",
}
+ app.newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient {
+ capturedConfig = &image.NanoBananaConfig{
+ APIKey: config.APIKey,
+ Model: config.Model,
+ TextModel: config.TextModel,
+ }
+ return fakeClient
+ }
+ app.newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient {
+ t.Fatal("unexpected OpenAI image client construction")
+ return nil
+ }
outputPath, err := app.generateImagesWithPrompt(context.Background(), "ябълка", "custom prompt", "apple", tempDir)
if err != nil {
@@ -176,11 +168,6 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
}
func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -190,11 +177,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -215,6 +197,11 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
OpenAIInstruction: "Speak clearly.",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir)
if err != nil {
@@ -267,11 +254,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
@@ -281,11 +263,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -306,6 +283,11 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) {
GeminiVoice: "",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir)
if err != nil {
@@ -367,11 +349,6 @@ func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) {
}
func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.GeminiVoices...)
t.Cleanup(func() {
audio.GeminiVoices = originalVoices
@@ -379,17 +356,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
audio.GeminiVoices = []string{"Charon", "Kore", "Leda"}
var attemptedVoices []string
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- attemptedVoices = append(attemptedVoices, config.GeminiVoice)
- return &fakeAudioProvider{
- generateFunc: func(_ string, _ string) error {
- if config.GeminiVoice == "Charon" {
- return audio.ErrGeminiNoAudioData
- }
- return nil
- },
- }, nil
- }
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "audio.wav")
@@ -405,6 +371,17 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
GeminiTTSModel: "gemini-2.5-flash-preview-tts",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ attemptedVoices = append(attemptedVoices, config.GeminiVoice)
+ return &fakeAudioProvider{
+ generateFunc: func(_ string, _ string) error {
+ if config.GeminiVoice == "Charon" {
+ return audio.ErrGeminiNoAudioData
+ }
+ return nil
+ },
+ }, nil
+ }
voice, err := audio.RunWithVoiceFallbacks("Charon", func(candidate string) error {
return app.generateAudioFile(context.Background(), "ябълка", outputPath, candidate, 1.0)
@@ -422,11 +399,6 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
}
func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -436,11 +408,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -460,6 +427,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
OpenAIInstruction: "Speak clearly.",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
frontPath, backPath, err := app.generateAudioBgBg(context.Background(), "ябълка", "круша", cardDir)
if err != nil {
@@ -526,11 +498,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -540,11 +507,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -564,6 +526,11 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) {
OpenAIInstruction: "Speak clearly.",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
outputPath, err := app.generateAudioFront(context.Background(), "ябълка", cardDir)
if err != nil {
@@ -604,11 +571,6 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -618,11 +580,6 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -642,6 +599,11 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) {
OpenAIInstruction: "Speak clearly.",
},
}
+ app.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
outputPath, err := app.generateAudioBack(context.Background(), "круша", cardDir)
if err != nil {
@@ -682,20 +644,12 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioProviderFactoryError(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
})
audio.OpenAIVoices = []string{"sentinel-error-voice"}
- newAudioProvider = func(*audio.Config) (audio.Provider, error) {
- return nil, errors.New("provider factory failed")
- }
tempDir := t.TempDir()
cardDir := filepath.Join(tempDir, "card")
@@ -715,6 +669,9 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) {
OpenAIInstruction: "Speak clearly.",
},
}
+ app.newAudioProvider = func(*audio.Config) (audio.Provider, error) {
+ return nil, errors.New("provider factory failed")
+ }
_, err := app.generateAudioFront(context.Background(), "ябълка", cardDir)
if err == nil {
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index 206da00..c7e9264 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -15,41 +15,11 @@ import (
"codeberg.org/snonux/totalrecall/internal/anki"
)
-// findCardDirectory finds the directory for a given Bulgarian word
+// findCardDirectory finds the directory for a given Bulgarian word.
+// Delegates to the shared internal.FindCardDirectory which also handles the
+// legacy _word.txt fallback for backward compatibility.
func (a *Application) findCardDirectory(word string) string {
- entries, err := os.ReadDir(a.config.OutputDir)
- if err != nil {
- return ""
- }
-
- // Look through all directories to find one with matching _word.txt
- for _, entry := range entries {
- if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
- continue
- }
-
- dirPath := filepath.Join(a.config.OutputDir, entry.Name())
- wordFile := filepath.Join(dirPath, "word.txt")
-
- // Read the word file to check if it matches
- if data, err := os.ReadFile(wordFile); err == nil {
- storedWord := strings.TrimSpace(string(data))
- if storedWord == word {
- return dirPath
- }
- } else {
- // Try old format with underscore for backward compatibility
- wordFile = filepath.Join(dirPath, "_word.txt")
- if data, err := os.ReadFile(wordFile); err == nil {
- storedWord := strings.TrimSpace(string(data))
- if storedWord == word {
- return dirPath
- }
- }
- }
- }
-
- return ""
+ return internal.FindCardDirectory(a.config.OutputDir, word)
}
// scanExistingWords scans the output directory for existing words
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 41d6858..248bcc2 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -22,37 +22,125 @@ import (
"codeberg.org/snonux/totalrecall/internal/translation"
)
-// Processor handles the main word processing logic
+// viperConfig holds all Viper-sourced settings captured once in NewProcessor.
+// Storing them in a struct avoids repeated global Viper access in method bodies
+// and makes the values testable without mutating process-wide Viper state.
+type viperConfig struct {
+ // Translation & phonetic settings
+ translationProvider string
+ phoneticProvider string
+ translationGeminiModel string
+
+ // Audio settings
+ audioProvider string
+ audioFormat string
+ audioFormatSet bool
+ geminiTTSModel string
+ geminiVoice string
+ openAIVoice string
+ openAIModel string
+ openAIModelSet bool
+ openAISpeed float64
+ openAISpeedSet bool
+ openAIInstruction string
+ openAIInstructionSet bool
+
+ // Image settings
+ imageProvider string
+ imageOpenAIModel string
+ imageOpenAIModelSet bool
+ imageOpenAISize string
+ imageOpenAISizeSet bool
+ imageOpenAIQuality string
+ imageOpenAIQualitySet bool
+ imageOpenAIStyle string
+ imageOpenAIStyleSet bool
+ imageNanoBananaModel string
+ imageNanoBananaModelSet bool
+ imageNanoBananaTextModel string
+ imageNanoBananaTextModelSet bool
+}
+
+// newViperConfig reads all Viper settings in one pass. Called once from NewProcessor
+// so the processor methods never touch the global Viper instance directly.
+func newViperConfig() viperConfig {
+ return viperConfig{
+ translationProvider: strings.TrimSpace(viper.GetString("translation.provider")),
+ phoneticProvider: strings.TrimSpace(viper.GetString("phonetic.provider")),
+ translationGeminiModel: viper.GetString("translation.gemini_model"),
+
+ audioProvider: strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))),
+ audioFormat: strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))),
+ audioFormatSet: viper.IsSet("audio.format"),
+ geminiTTSModel: strings.TrimSpace(viper.GetString("audio.gemini_tts_model")),
+ geminiVoice: strings.TrimSpace(viper.GetString("audio.gemini_voice")),
+ openAIVoice: strings.TrimSpace(viper.GetString("audio.openai_voice")),
+ openAIModel: viper.GetString("audio.openai_model"),
+ openAIModelSet: viper.IsSet("audio.openai_model"),
+ openAISpeed: viper.GetFloat64("audio.openai_speed"),
+ openAISpeedSet: viper.IsSet("audio.openai_speed"),
+ openAIInstruction: viper.GetString("audio.openai_instruction"),
+ openAIInstructionSet: viper.IsSet("audio.openai_instruction"),
+
+ imageProvider: strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))),
+ imageOpenAIModel: viper.GetString("image.openai_model"),
+ imageOpenAIModelSet: viper.IsSet("image.openai_model"),
+ imageOpenAISize: viper.GetString("image.openai_size"),
+ imageOpenAISizeSet: viper.IsSet("image.openai_size"),
+ imageOpenAIQuality: viper.GetString("image.openai_quality"),
+ imageOpenAIQualitySet: viper.IsSet("image.openai_quality"),
+ imageOpenAIStyle: viper.GetString("image.openai_style"),
+ imageOpenAIStyleSet: viper.IsSet("image.openai_style"),
+ imageNanoBananaModel: strings.TrimSpace(viper.GetString("image.nanobanana_model")),
+ imageNanoBananaModelSet: viper.IsSet("image.nanobanana_model"),
+ imageNanoBananaTextModel: strings.TrimSpace(viper.GetString("image.nanobanana_text_model")),
+ imageNanoBananaTextModelSet: viper.IsSet("image.nanobanana_text_model"),
+ }
+}
+
+// Processor handles the main word processing logic.
+// The factory fields (newOpenAIImageClient, newNanoBananaImageClient, newAudioProvider)
+// are injected at construction time so tests can swap them without mutating global state.
type Processor struct {
flags *cli.Flags
translator *translation.Translator
translationCache *translation.TranslationCache
phoneticFetcher *phonetic.Fetcher
randomIntn func(n int) int
+ // viperCfg holds all config-file values read once at construction time,
+ // so individual methods never call Viper directly.
+ viperCfg viperConfig
+
+ // Factories — replaced by tests to inject fakes.
+ newOpenAIImageClient func(*image.OpenAIConfig) image.ImageClient
+ newNanoBananaImageClient func(*image.NanoBananaConfig) image.ImageClient
+ newAudioProvider func(*audio.Config) (audio.Provider, error)
}
-var newOpenAIImageClient = func(config *image.OpenAIConfig) image.ImageClient {
- return image.NewOpenAIClient(config)
-}
-
-var newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageClient {
- return image.NewNanoBananaClient(config)
-}
-
-var newAudioProvider = audio.NewProvider
-
-// NewProcessor creates a new word processor
+// NewProcessor creates a new word processor with default production factories.
+// All Viper config values are read once here via newViperConfig() so that no
+// method body ever calls Viper directly.
+// Tests can replace the factory fields on the returned struct to inject fakes.
func NewProcessor(flags *cli.Flags) *Processor {
+ cfg := newViperConfig()
openAIKey := cli.GetOpenAIKey()
googleAPIKey := cli.GetGoogleAPIKey()
- translationProvider := translation.Provider(viper.GetString("translation.provider"))
- phoneticProvider := phonetic.Provider(viper.GetString("phonetic.provider"))
+ translationProvider := translation.Provider(cfg.translationProvider)
+ phoneticProvider := phonetic.Provider(cfg.phoneticProvider)
return &Processor{
flags: flags,
+ viperCfg: cfg,
translator: translation.NewTranslator(&translation.Config{Provider: translationProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}),
translationCache: translation.NewTranslationCache(),
phoneticFetcher: phonetic.NewFetcher(&phonetic.Config{Provider: phoneticProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}),
randomIntn: rand.Intn,
+ newOpenAIImageClient: func(config *image.OpenAIConfig) image.ImageClient {
+ return image.NewOpenAIClient(config)
+ },
+ newNanoBananaImageClient: func(config *image.NanoBananaConfig) image.ImageClient {
+ return image.NewNanoBananaClient(config)
+ },
+ newAudioProvider: audio.NewProvider,
}
}
@@ -246,8 +334,8 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word,
}
func (p *Processor) audioProviderName() string {
- if provider := strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))); provider != "" {
- return provider
+ if p.viperCfg.audioProvider != "" {
+ return p.viperCfg.audioProvider
}
if p != nil && p.flags != nil {
return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider))
@@ -262,10 +350,8 @@ func (p *Processor) effectiveAudioFormat() string {
}
}
- if viper.IsSet("audio.format") {
- if format := strings.ToLower(strings.TrimSpace(viper.GetString("audio.format"))); format != "" {
- return format
- }
+ if p.viperCfg.audioFormatSet && p.viperCfg.audioFormat != "" {
+ return p.viperCfg.audioFormat
}
if p != nil && p.flags != nil {
@@ -282,8 +368,8 @@ func (p *Processor) effectiveAudioFormat() string {
}
func (p *Processor) geminiTTSModel() string {
- if model := strings.TrimSpace(viper.GetString("audio.gemini_tts_model")); model != "" {
- return model
+ if p.viperCfg.geminiTTSModel != "" {
+ return p.viperCfg.geminiTTSModel
}
if p != nil && p.flags != nil {
return strings.TrimSpace(p.flags.GeminiTTSModel)
@@ -292,8 +378,8 @@ func (p *Processor) geminiTTSModel() string {
}
func (p *Processor) geminiVoice() string {
- if voice := strings.TrimSpace(viper.GetString("audio.gemini_voice")); voice != "" {
- return voice
+ if p.viperCfg.geminiVoice != "" {
+ return p.viperCfg.geminiVoice
}
if p != nil && p.flags != nil {
return strings.TrimSpace(p.flags.GeminiVoice)
@@ -302,8 +388,8 @@ func (p *Processor) geminiVoice() string {
}
func (p *Processor) openAIVoice() string {
- if voice := strings.TrimSpace(viper.GetString("audio.openai_voice")); voice != "" {
- return voice
+ if p.viperCfg.openAIVoice != "" {
+ return p.viperCfg.openAIVoice
}
if p != nil && p.flags != nil {
return strings.TrimSpace(p.flags.OpenAIVoice)
@@ -461,7 +547,7 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context,
// Generate random speed between 0.90 and 1.00 if not explicitly set
speed := p.flags.OpenAISpeed
- if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") {
+ if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !p.viperCfg.openAISpeedSet {
// Default was used, generate random speed
speed = 0.90 + rand.Float64()*0.10
}
@@ -491,19 +577,19 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context,
providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction
// Use config file values if not overridden by flags
- if p.flags.OpenAIModel == "gpt-4o-mini-tts" && viper.IsSet("audio.openai_model") {
- providerConfig.OpenAIModel = viper.GetString("audio.openai_model")
+ if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.viperCfg.openAIModelSet {
+ providerConfig.OpenAIModel = p.viperCfg.openAIModel
}
- if p.flags.OpenAISpeed == 0.9 && viper.IsSet("audio.openai_speed") {
- providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
+ if p.flags.OpenAISpeed == 0.9 && p.viperCfg.openAISpeedSet {
+ providerConfig.OpenAISpeed = p.viperCfg.openAISpeed
}
- if p.flags.OpenAIInstruction == "" && viper.IsSet("audio.openai_instruction") {
- providerConfig.OpenAIInstruction = viper.GetString("audio.openai_instruction")
+ if p.flags.OpenAIInstruction == "" && p.viperCfg.openAIInstructionSet {
+ providerConfig.OpenAIInstruction = p.viperCfg.openAIInstruction
}
}
// Create the audio provider
- provider, err := newAudioProvider(providerConfig)
+ provider, err := p.newAudioProvider(providerConfig)
if err != nil {
return err
}
@@ -689,8 +775,8 @@ func (p *Processor) GUIConfig() *gui.Config {
openAIKey := cli.GetOpenAIKey()
googleAPIKey := cli.GetGoogleAPIKey()
- translationProvider := translation.Provider(viper.GetString("translation.provider"))
- phoneticProvider := phonetic.Provider(viper.GetString("phonetic.provider"))
+ translationProvider := translation.Provider(p.viperCfg.translationProvider)
+ phoneticProvider := phonetic.Provider(p.viperCfg.phoneticProvider)
// Construct and inject phonetic/translation dependencies at the composition root
// so gui.New() receives ready-to-use instances rather than raw config strings.
@@ -702,7 +788,7 @@ func (p *Processor) GUIConfig() *gui.Config {
translator := translation.NewTranslator(&translation.Config{
Provider: translationProvider,
OpenAIKey: openAIKey,
- GeminiModel: viper.GetString("translation.gemini_model"),
+ GeminiModel: p.viperCfg.translationGeminiModel,
})
return &gui.Config{
@@ -730,8 +816,8 @@ func (p *Processor) nanoBananaModelForRunMode() string {
}
}
- if model := strings.TrimSpace(viper.GetString("image.nanobanana_model")); model != "" {
- return model
+ if p.viperCfg.imageNanoBananaModel != "" {
+ return p.viperCfg.imageNanoBananaModel
}
if p != nil && p.flags != nil {
@@ -750,8 +836,8 @@ func (p *Processor) nanoBananaTextModelForRunMode() string {
}
}
- if model := strings.TrimSpace(viper.GetString("image.nanobanana_text_model")); model != "" {
- return model
+ if p.viperCfg.imageNanoBananaTextModel != "" {
+ return p.viperCfg.imageNanoBananaTextModel
}
if p != nil && p.flags != nil {
@@ -781,8 +867,8 @@ func (p *Processor) imageProviderForRunMode() string {
return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI))
}
- if provider := strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))); provider != "" {
- return provider
+ if p.viperCfg.imageProvider != "" {
+ return p.viperCfg.imageProvider
}
return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI))
@@ -797,24 +883,24 @@ func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) {
Style: p.flags.OpenAIImageStyle,
}
- if p.flags.OpenAIImageModel == "dall-e-2" && viper.IsSet("image.openai_model") {
- openaiConfig.Model = viper.GetString("image.openai_model")
+ if p.flags.OpenAIImageModel == "dall-e-2" && p.viperCfg.imageOpenAIModelSet {
+ openaiConfig.Model = p.viperCfg.imageOpenAIModel
}
- if p.flags.OpenAIImageSize == "512x512" && viper.IsSet("image.openai_size") {
- openaiConfig.Size = viper.GetString("image.openai_size")
+ if p.flags.OpenAIImageSize == "512x512" && p.viperCfg.imageOpenAISizeSet {
+ openaiConfig.Size = p.viperCfg.imageOpenAISize
}
- if p.flags.OpenAIImageQuality == "standard" && viper.IsSet("image.openai_quality") {
- openaiConfig.Quality = viper.GetString("image.openai_quality")
+ if p.flags.OpenAIImageQuality == "standard" && p.viperCfg.imageOpenAIQualitySet {
+ openaiConfig.Quality = p.viperCfg.imageOpenAIQuality
}
- if p.flags.OpenAIImageStyle == "natural" && viper.IsSet("image.openai_style") {
- openaiConfig.Style = viper.GetString("image.openai_style")
+ if p.flags.OpenAIImageStyle == "natural" && p.viperCfg.imageOpenAIStyleSet {
+ openaiConfig.Style = p.viperCfg.imageOpenAIStyle
}
if openaiConfig.APIKey == "" {
return nil, fmt.Errorf("OpenAI API key is required for image generation")
}
- return newOpenAIImageClient(openaiConfig), nil
+ return p.newOpenAIImageClient(openaiConfig), nil
}
func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) {
@@ -824,18 +910,18 @@ func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) {
TextModel: p.flags.NanoBananaTextModel,
}
- if !p.flags.NanoBananaModelSpecified && viper.IsSet("image.nanobanana_model") {
- nanoBananaConfig.Model = viper.GetString("image.nanobanana_model")
+ if !p.flags.NanoBananaModelSpecified && p.viperCfg.imageNanoBananaModelSet {
+ nanoBananaConfig.Model = p.viperCfg.imageNanoBananaModel
}
- if !p.flags.NanoBananaTextModelSpecified && viper.IsSet("image.nanobanana_text_model") {
- nanoBananaConfig.TextModel = viper.GetString("image.nanobanana_text_model")
+ if !p.flags.NanoBananaTextModelSpecified && p.viperCfg.imageNanoBananaTextModelSet {
+ nanoBananaConfig.TextModel = p.viperCfg.imageNanoBananaTextModel
}
if nanoBananaConfig.APIKey == "" {
return nil, fmt.Errorf("google API key is required for image generation")
}
- return newNanoBananaImageClient(nanoBananaConfig), nil
+ return p.newNanoBananaImageClient(nanoBananaConfig), nil
}
func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) {
@@ -862,53 +948,11 @@ func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient)
// Helper methods
func (p *Processor) findOrCreateWordDirectory(word string) string {
- // Try to find existing directory first
- if dir := p.findCardDirectory(word); dir != "" {
- return dir
- }
-
- // No existing directory, create new one with card ID
- cardID := internal.GenerateCardID(word)
- wordDir := filepath.Join(p.flags.OutputDir, cardID)
- if err := os.MkdirAll(wordDir, 0755); err != nil {
- fmt.Printf("Warning: failed to create word directory: %v\n", err)
- return p.flags.OutputDir // Fallback to output directory
- }
-
- // Save word metadata
- metadataFile := filepath.Join(wordDir, "word.txt")
- if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil {
- fmt.Printf("Warning: failed to save word metadata: %v\n", err)
- }
-
- return wordDir
+ return internal.FindOrCreateCardDirectory(p.flags.OutputDir, word)
}
func (p *Processor) findCardDirectory(word string) string {
- entries, err := os.ReadDir(p.flags.OutputDir)
- if err != nil {
- return ""
- }
-
- // Look through all directories to find one with matching word.txt
- for _, entry := range entries {
- if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
- continue
- }
-
- dirPath := filepath.Join(p.flags.OutputDir, entry.Name())
- wordFile := filepath.Join(dirPath, "word.txt")
-
- // Read the word file to check if it matches
- if data, err := os.ReadFile(wordFile); err == nil {
- storedWord := strings.TrimSpace(string(data))
- if storedWord == word {
- return dirPath
- }
- }
- }
-
- return ""
+ return internal.FindCardDirectory(p.flags.OutputDir, word)
}
// isWordFullyProcessed checks if a word has already been fully processed
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
index 34cc844..0d0e9e3 100644
--- a/internal/processor/processor_test.go
+++ b/internal/processor/processor_test.go
@@ -369,11 +369,6 @@ func TestGUIConfigForRunModeHonorsExplicitNanoBananaModelFlags(t *testing.T) {
}
func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -383,11 +378,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
flags := cli.NewFlags()
@@ -397,6 +387,11 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
flags.AudioProvider = "openai"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
if err := p.generateAudio(context.Background(), "ябълка"); err != nil {
t.Fatalf("generateAudio() unexpected error: %v", err)
@@ -420,11 +415,6 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
}
func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
- originalFactory := newAudioProvider
- t.Cleanup(func() {
- newAudioProvider = originalFactory
- })
-
originalVoices := append([]string(nil), audio.OpenAIVoices...)
t.Cleanup(func() {
audio.OpenAIVoices = originalVoices
@@ -434,11 +424,6 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
- newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
- copyConfig := *config
- capturedConfig = &copyConfig
- return fakeProvider, nil
- }
tempDir := t.TempDir()
flags := cli.NewFlags()
@@ -447,6 +432,11 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
flags.AudioProvider = "openai"
p := NewProcessor(flags)
+ p.newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config<