From f486e9c677c72a409df8696104661847354286df Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 09:55:02 +0300 Subject: refactor: registry pattern for audio and image provider factories Add internal/registry generic Registry[K,T] for keyed factory registration. Wire audio.NewProvider via registered per-provider constructors; GUI and processor newImageSearcher use registries of *Orchestrator/*Processor methods. Export image.ImageProviderOpenAI and ImageProviderNanoBanana from search.go and use them across gui to avoid duplicate string constants. Made-with: Cursor --- internal/gui/app.go | 7 +--- internal/gui/app_test.go | 4 +- internal/gui/card_service.go | 3 +- internal/gui/generator_test.go | 6 +-- internal/gui/orchestrator.go | 83 +++++++++++++++++++++++++----------------- 5 files changed, 57 insertions(+), 46 deletions(-) (limited to 'internal/gui') diff --git a/internal/gui/app.go b/internal/gui/app.go index f7a7d51..6c1cf21 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -146,11 +146,6 @@ type Config struct { Translator *translation.Translator } -const ( - imageProviderOpenAI = "openai" - imageProviderNanoBanana = "nanobanana" -) - // DefaultConfig returns default GUI configuration func DefaultConfig() *Config { homeDir, err := appconfig.HomeDir() @@ -168,7 +163,7 @@ func DefaultConfig() *Config { NanoBananaModel: image.DefaultNanoBananaModel, NanoBananaTextModel: image.DefaultNanoBananaTextModel, GeminiTTSModel: audioDefaults.GeminiTTSModel, - ImageProvider: imageProviderNanoBanana, + ImageProvider: image.ImageProviderNanoBanana, TranslationProvider: translation.ProviderGemini, PhoneticProvider: phonetic.ProviderGemini, AutoPlay: true, // Auto-play enabled by default diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index 9ac0125..86e8ec2 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -19,8 +19,8 @@ func TestDefaultConfigUsesGeminiLanguageProviders(t *testing.T) { if config.PhoneticProvider != phonetic.ProviderGemini { t.Fatalf("DefaultConfig() phonetic provider = %q, want %q", config.PhoneticProvider, phonetic.ProviderGemini) } - if config.ImageProvider != imageProviderNanoBanana { - t.Fatalf("DefaultConfig() image provider = %q, want %q", config.ImageProvider, imageProviderNanoBanana) + if config.ImageProvider != image.ImageProviderNanoBanana { + t.Fatalf("DefaultConfig() image provider = %q, want %q", config.ImageProvider, image.ImageProviderNanoBanana) } if config.AudioProvider != audioDefaults.Provider { t.Fatalf("DefaultConfig() audio provider = %q, want %q", config.AudioProvider, audioDefaults.Provider) diff --git a/internal/gui/card_service.go b/internal/gui/card_service.go index 395c741..cd4d549 100644 --- a/internal/gui/card_service.go +++ b/internal/gui/card_service.go @@ -9,6 +9,7 @@ import ( "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/store" ) @@ -249,7 +250,7 @@ func (cs *CardService) loadImageFile(wordDir string, cf *CardFiles) { // Try to load the image prompt from the attribution file as a fallback // when the image provider is AI-based (OpenAI DALL-E or Nano Banana). - if cs.config.ImageProvider == imageProviderOpenAI || cs.config.ImageProvider == imageProviderNanoBanana { + if cs.config.ImageProvider == image.ImageProviderOpenAI || cs.config.ImageProvider == image.ImageProviderNanoBanana { cs.loadPromptFromAttribution(cf) } } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 40c0e2c..5ee238b 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -34,7 +34,7 @@ func (f *fakePromptAwareImageClient) Search(_ context.Context, opts *image.Searc Height: 1, Description: "fake result", Attribution: "fake attribution", - Source: imageProviderNanoBanana, + Source: image.ImageProviderNanoBanana, }, }, nil } @@ -48,7 +48,7 @@ func (f *fakePromptAwareImageClient) GetAttribution(*image.SearchResult) string } func (f *fakePromptAwareImageClient) Name() string { - return imageProviderNanoBanana + return image.ImageProviderNanoBanana } func (f *fakePromptAwareImageClient) SetPromptCallback(callback func(prompt string)) { @@ -99,7 +99,7 @@ func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { tempDir := t.TempDir() app := &Application{ config: &Config{ - ImageProvider: imageProviderNanoBanana, + ImageProvider: image.ImageProviderNanoBanana, GoogleAPIKey: "google-key", NanoBananaModel: "custom-image-model", NanoBananaTextModel: "custom-text-model", diff --git a/internal/gui/orchestrator.go b/internal/gui/orchestrator.go index 191e919..ef4a167 100644 --- a/internal/gui/orchestrator.go +++ b/internal/gui/orchestrator.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "fyne.io/fyne/v2" @@ -12,6 +13,7 @@ import ( "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/phonetic" + "codeberg.org/snonux/totalrecall/internal/registry" "codeberg.org/snonux/totalrecall/internal/translation" ) @@ -410,48 +412,61 @@ func (o *GenerationOrchestrator) imagePromptCallback(cardDir, word string) func( } } -// newImageSearcher constructs the appropriate image client based on the -// configured image provider. Returns image.PromptAwareClient so callers can -// call SetPromptCallback directly without a type-assertion. The factory -// functions are sourced from imageFactories (the shared image.ClientFactories -// value) to avoid duplicating the factory signatures in this package. -func (o *GenerationOrchestrator) newImageSearcher() (image.PromptAwareClient, error) { - switch o.config.ImageProvider { - case imageProviderOpenAI: - if o.config.OpenAIKey == "" { - return nil, fmt.Errorf("OpenAI API key is required for image generation") - } +// guiImageClientFactories maps provider name to image client builder. Add new +// providers by registering here instead of extending a switch in newImageSearcher. +var guiImageClientFactories = func() *registry.Registry[string, func(*GenerationOrchestrator) (image.PromptAwareClient, error)] { + r := registry.New[string, func(*GenerationOrchestrator) (image.PromptAwareClient, error)]() + r.Register(image.ImageProviderOpenAI, (*GenerationOrchestrator).buildOpenAIImageClient) + r.Register(image.ImageProviderNanoBanana, (*GenerationOrchestrator).buildNanoBananaImageClient) + return r +}() - openaiConfig := &image.OpenAIConfig{ - APIKey: o.config.OpenAIKey, - Model: "dall-e-2", // DALL-E 2 supports 512×512 - Size: "512x512", - Quality: "standard", - Style: "natural", - } +func (o *GenerationOrchestrator) buildOpenAIImageClient() (image.PromptAwareClient, error) { + if o.config.OpenAIKey == "" { + return nil, fmt.Errorf("OpenAI API key is required for image generation") + } - return o.imageFactories.NewOpenAIClient(openaiConfig), nil + openaiConfig := &image.OpenAIConfig{ + APIKey: o.config.OpenAIKey, + Model: "dall-e-2", // DALL-E 2 supports 512×512 + Size: "512x512", + Quality: "standard", + Style: "natural", + } - case imageProviderNanoBanana: - cfg := o.config - if cfg == nil { - cfg = DefaultConfig() - } - if cfg.GoogleAPIKey == "" { - return nil, fmt.Errorf("google API key is required for image generation") - } + return o.imageFactories.NewOpenAIClient(openaiConfig), nil +} - nanoBananaConfig := &image.NanoBananaConfig{ - APIKey: cfg.GoogleAPIKey, - Model: cfg.NanoBananaModel, - TextModel: cfg.NanoBananaTextModel, - } +func (o *GenerationOrchestrator) buildNanoBananaImageClient() (image.PromptAwareClient, error) { + cfg := o.config + if cfg == nil { + cfg = DefaultConfig() + } + if cfg.GoogleAPIKey == "" { + return nil, fmt.Errorf("google API key is required for image generation") + } - return o.imageFactories.NewNanoBananaClient(nanoBananaConfig), nil + nanoBananaConfig := &image.NanoBananaConfig{ + APIKey: cfg.GoogleAPIKey, + Model: cfg.NanoBananaModel, + TextModel: cfg.NanoBananaTextModel, + } + + return o.imageFactories.NewNanoBananaClient(nanoBananaConfig), nil +} - default: +// newImageSearcher constructs the appropriate image client based on the +// configured image provider. Returns image.PromptAwareClient so callers can +// call SetPromptCallback directly without a type-assertion. The factory +// functions are sourced from imageFactories (the shared image.ClientFactories +// value) to avoid duplicating the factory signatures in this package. +func (o *GenerationOrchestrator) newImageSearcher() (image.PromptAwareClient, error) { + key := strings.ToLower(strings.TrimSpace(o.config.ImageProvider)) + fn, ok := guiImageClientFactories.Get(key) + if !ok { return nil, fmt.Errorf("unknown image provider: %s", o.config.ImageProvider) } + return fn(o) } // --- Phonetics --- -- cgit v1.2.3