From 08d7d36ac6d49d7d5efd39a837bc08c9c52b6eb8 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 10:01:57 +0300 Subject: refactor(processor): split BatchProcessor, AnkiExporter, CLIConfigResolver Extract CLIConfigResolver for flag/config precedence and GUI wiring; embed it on Processor so audio and image helpers use promoted accessors. Move batch file flow to BatchProcessor and Anki generation to AnkiExporter; Processor delegates while keeping per-word processing and translation cache. Made-with: Cursor --- internal/processor/anki_exporter.go | 144 +++++++++++++ internal/processor/audio_coordinator.go | 127 +++-------- internal/processor/batch_processor.go | 129 ++++++++++++ internal/processor/card_store.go | 6 +- internal/processor/cli_config_resolver.go | 179 ++++++++++++++++ internal/processor/doc.go | 7 +- internal/processor/image_downloader.go | 46 ++-- internal/processor/processor.go | 337 ++---------------------------- internal/processor/processor_test.go | 2 +- 9 files changed, 528 insertions(+), 449 deletions(-) create mode 100644 internal/processor/anki_exporter.go create mode 100644 internal/processor/batch_processor.go create mode 100644 internal/processor/cli_config_resolver.go diff --git a/internal/processor/anki_exporter.go b/internal/processor/anki_exporter.go new file mode 100644 index 0000000..fa0a481 --- /dev/null +++ b/internal/processor/anki_exporter.go @@ -0,0 +1,144 @@ +package processor + +// AnkiExporter builds Anki import artifacts (CSV or APKG) from the in-memory +// translation cache and on-disk card directories. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" +) + +// AnkiExporter generates Anki deck output using Processor state (flags, cache, +// card directory lookups). +type AnkiExporter struct { + p *Processor +} + +// GenerateAnkiFile generates the Anki import file and returns the output path. +// When --anki is specified the file is placed in the user's home directory; +// otherwise it goes into the configured output directory. +func (e *AnkiExporter) GenerateAnkiFile() (string, error) { + p := e.p + outputDir, err := e.resolveAnkiOutputDir() + if err != nil { + return "", err + } + + audioFormat := p.EffectiveAudioFormat() + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: filepath.Join(outputDir, "anki_import.csv"), + MediaFolder: p.Flags.OutputDir, + IncludeHeaders: true, + AudioFormat: audioFormat, + }) + + if err := e.populateAnkiGenerator(gen, audioFormat); err != nil { + return "", err + } + + return e.writeAnkiOutput(gen, outputDir) +} + +// resolveAnkiOutputDir returns the directory where the Anki file should be +// written. When --anki is set it resolves to the user's home directory. +func (e *AnkiExporter) resolveAnkiOutputDir() (string, error) { + p := e.p + if p.Flags.GenerateAnki { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return homeDir, nil + } + return p.Flags.OutputDir, nil +} + +// populateAnkiGenerator fills the generator with cards. When the translation +// cache is populated it is used as the authoritative source; otherwise the +// generator falls back to scanning the output directory for existing cards. +func (e *AnkiExporter) populateAnkiGenerator(gen *anki.Generator, audioFormat string) error { + p := e.p + translations := p.translationCache.GetAll() + if len(translations) == 0 { + fmt.Println(" No translations found in cache, generating cards from directory...") + if err := gen.GenerateFromDirectory(p.Flags.OutputDir); err != nil { + return fmt.Errorf("failed to generate cards from directory: %w", err) + } + return nil + } + + fmt.Printf(" Generating cards from %d translations in cache...\n", len(translations)) + for bulgarian, english := range translations { + card := e.buildAnkiCard(bulgarian, english, audioFormat) + gen.AddCard(card) + } + return nil +} + +// buildAnkiCard constructs an anki.Card for a word, resolving all associated +// media files (audio, image, phonetic) from the word's card directory. +func (e *AnkiExporter) buildAnkiCard(bulgarian, english, audioFormat string) anki.Card { + p := e.p + card := anki.Card{ + Bulgarian: bulgarian, + Translation: english, + } + + wordDir := p.findCardDirectory(bulgarian) + if wordDir == "" { + return card + } + + cardType := internal.LoadCardType(wordDir) + if cardType.IsBgBg() { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio_front", audioFormat) + card.AudioFileBack = anki.ResolveAudioFile(wordDir, "audio_back", audioFormat) + } else { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio", audioFormat) + } + + imageFile := filepath.Join(wordDir, "image.jpg") + if _, err := os.Stat(imageFile); err == nil { + card.ImageFile = imageFile + } + + phoneticFile := filepath.Join(wordDir, "phonetic.txt") + if data, err := os.ReadFile(phoneticFile); err == nil { + notes := strings.TrimSpace(string(data)) + card.Notes = strings.ReplaceAll(notes, "\n", "
") + } + + return card +} + +// writeAnkiOutput generates either a CSV or APKG file depending on the +// --anki-csv flag and returns the output path. +func (e *AnkiExporter) writeAnkiOutput(gen *anki.Generator, outputDir string) (string, error) { + p := e.p + if p.Flags.AnkiCSV { + outputPath := filepath.Join(outputDir, "anki_import.csv") + if err := gen.GenerateCSV(); err != nil { + return "", fmt.Errorf("failed to generate CSV: %w", err) + } + e.printAnkiStats(gen) + return outputPath, nil + } + + outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(p.Flags.DeckName))) + if err := gen.GenerateAPKG(outputPath, p.Flags.DeckName); err != nil { + return "", fmt.Errorf("failed to generate APKG: %w", err) + } + e.printAnkiStats(gen) + return outputPath, nil +} + +// printAnkiStats logs the card generation statistics to stdout. +func (e *AnkiExporter) printAnkiStats(gen *anki.Generator) { + total, withAudio, withImages := gen.Stats() + fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n", total, withAudio, withImages) +} diff --git a/internal/processor/audio_coordinator.go b/internal/processor/audio_coordinator.go index 441ee51..c19745b 100644 --- a/internal/processor/audio_coordinator.go +++ b/internal/processor/audio_coordinator.go @@ -9,6 +9,8 @@ package processor // All methods are on *Processor rather than a separate struct to avoid an // extra layer of indirection while still keeping the concerns separated into // their own file (SRP at the file level, as recommended for Go packages). +// Audio provider name and format resolution live on CLIConfigResolver (embedded +// on Processor). import ( "context" @@ -23,90 +25,19 @@ import ( "codeberg.org/snonux/totalrecall/internal/cli" ) -// audioProviderName returns the configured audio provider name, preferring -// the config-file value over the CLI flag so config-file settings win. -func (p *Processor) audioProviderName() string { - if p.cfg.AudioProvider != "" { - return p.cfg.AudioProvider - } - if p != nil && p.flags != nil { - return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider)) - } - return "" -} - -// effectiveAudioFormat resolves the audio format from flags and config, with -// CLI flag taking precedence, then the config-file value, then provider-specific defaults. -func (p *Processor) effectiveAudioFormat() string { - if p != nil && p.flags != nil && p.flags.AudioFormatSpecified { - if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { - return format - } - } - - if p.cfg.AudioFormatSet && p.cfg.AudioFormat != "" { - return p.cfg.AudioFormat - } - - if p != nil && p.flags != nil { - if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { - return format - } - } - - if p.audioProviderName() == "gemini" { - return audio.DefaultProviderConfig().OutputFormat - } - - return "mp3" -} - -// geminiTTSModel returns the Gemini TTS model, preferring the config-file value over the CLI flag. -func (p *Processor) geminiTTSModel() string { - if p.cfg.GeminiTTSModel != "" { - return p.cfg.GeminiTTSModel - } - if p != nil && p.flags != nil { - return strings.TrimSpace(p.flags.GeminiTTSModel) - } - return "" -} - -// geminiVoice returns the Gemini voice, preferring the config-file value over the CLI flag. -func (p *Processor) geminiVoice() string { - if p.cfg.GeminiVoice != "" { - return p.cfg.GeminiVoice - } - if p != nil && p.flags != nil { - return strings.TrimSpace(p.flags.GeminiVoice) - } - return "" -} - -// openAIVoice returns the OpenAI voice, preferring the config-file value over the CLI flag. -func (p *Processor) openAIVoice() string { - if p.cfg.OpenAIVoice != "" { - return p.cfg.OpenAIVoice - } - if p != nil && p.flags != nil { - return strings.TrimSpace(p.flags.OpenAIVoice) - } - return "" -} - // audioVoicesForProvider returns all available voices for the configured provider // without requiring a Provider instance (uses the package-level VoicesFor helper). func (p *Processor) audioVoicesForProvider() []string { - return audio.VoicesFor(p.audioProviderName()) + return audio.VoicesFor(p.AudioProviderName()) } // audioVoiceForProvider selects a single voice for the configured provider. // If a specific voice is configured, it is returned; otherwise a random voice // from the provider's list is chosen using the injected randomIntn function. func (p *Processor) audioVoiceForProvider() string { - switch p.audioProviderName() { + switch p.AudioProviderName() { case "gemini": - if voice := p.geminiVoice(); voice != "" { + if voice := p.GeminiVoice(); voice != "" { return voice } voices := p.audioVoicesForProvider() @@ -115,7 +46,7 @@ func (p *Processor) audioVoiceForProvider() string { } return voices[rand.Intn(len(voices))] default: - if voice := p.openAIVoice(); voice != "" { + if voice := p.OpenAIVoice(); voice != "" { return voice } voices := p.audioVoicesForProvider() @@ -131,13 +62,13 @@ func (p *Processor) audioVoiceForProvider() string { func (p *Processor) logSelectedAudioVoice(provider, voice string) { switch provider { case "gemini": - if p.geminiVoice() != "" { + if p.GeminiVoice() != "" { fmt.Printf(" Using specified Gemini voice: %s\n", voice) } else { fmt.Printf(" Using random Gemini voice: %s\n", voice) } default: - if p.openAIVoice() != "" { + if p.OpenAIVoice() != "" { fmt.Printf(" Using specified voice: %s\n", voice) } else { fmt.Printf(" Using random voice: %s\n", voice) @@ -150,9 +81,9 @@ func (p *Processor) logSelectedAudioVoice(provider, voice string) { // voice is selected (with Gemini fallback retry on empty audio). // ctx is threaded down to provider.GenerateAudio so the caller's deadline applies. func (p *Processor) generateAudio(ctx context.Context, word string) error { - provider := p.audioProviderName() + provider := p.AudioProviderName() - if p.flags.AllVoices { + if p.Flags.AllVoices { return p.generateAudioForAllVoices(ctx, word) } @@ -160,7 +91,7 @@ func (p *Processor) generateAudio(ctx context.Context, word string) error { p.logSelectedAudioVoice(provider, voice) // For Gemini with no explicit voice, use automatic fallback through all voices. - if provider == "gemini" && p.geminiVoice() == "" { + if provider == "gemini" && p.GeminiVoice() == "" { _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { if candidate != voice { fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) @@ -192,7 +123,7 @@ func (p *Processor) generateAudioForAllVoices(ctx context.Context, word string) // Both audio files are saved to the same directory as the front-word card. // ctx is threaded down to provider.GenerateAudio so the caller's deadline applies. func (p *Processor) generateAudioBgBg(ctx context.Context, front, back string) error { - provider := p.audioProviderName() + provider := p.AudioProviderName() voice := p.audioVoiceForProvider() p.logSelectedAudioVoice(provider, voice) @@ -216,7 +147,7 @@ func (p *Processor) generateAudioBgBg(ctx context.Context, front, back string) e } // Use automatic fallback through all Gemini voices when no voice is pinned. - if provider == "gemini" && p.geminiVoice() == "" { + if provider == "gemini" && p.GeminiVoice() == "" { _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { if candidate != voice { fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) @@ -273,29 +204,29 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, // buildAudioProviderConfig assembles an audio.Config from CLI flags and the // resolved processor Config. The voice argument is the already-resolved voice string for this call. func (p *Processor) buildAudioProviderConfig(voice string) *audio.Config { - audioProvider := p.audioProviderName() - audioFormat := p.effectiveAudioFormat() + audioProvider := p.AudioProviderName() + audioFormat := p.EffectiveAudioFormat() // 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 && !p.cfg.OpenAISpeedSet { + speed := p.Flags.OpenAISpeed + if audioProvider == "openai" && p.Flags.OpenAISpeed == 0.9 && !p.Config.OpenAISpeedSet { speed = 0.90 + rand.Float64()*0.10 } providerConfig := audio.DefaultProviderConfig() providerConfig.Provider = audioProvider - providerConfig.OutputDir = p.flags.OutputDir + providerConfig.OutputDir = p.Flags.OutputDir providerConfig.OpenAIKey = cli.GetOpenAIKey() providerConfig.GoogleAPIKey = cli.GetGoogleAPIKey() switch audioProvider { case "gemini": providerConfig.OutputFormat = audioFormat - providerConfig.GeminiTTSModel = p.geminiTTSModel() + providerConfig.GeminiTTSModel = p.GeminiTTSModel() if voice != "" { providerConfig.GeminiVoice = voice } else { - providerConfig.GeminiVoice = p.geminiVoice() + providerConfig.GeminiVoice = p.GeminiVoice() } providerConfig.GeminiSpeed = 1.0 default: @@ -309,20 +240,20 @@ func (p *Processor) buildAudioProviderConfig(voice string) *audio.Config { // applying config-file overrides where the CLI flag still holds its default value. func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice string, speed float64, audioFormat string) { providerConfig.OutputFormat = audioFormat - providerConfig.OpenAIModel = p.flags.OpenAIModel + providerConfig.OpenAIModel = p.Flags.OpenAIModel providerConfig.OpenAIVoice = voice providerConfig.OpenAISpeed = speed - providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction + providerConfig.OpenAIInstruction = p.Flags.OpenAIInstruction // Override with config-file values when the CLI flag is still at its default. - if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.cfg.OpenAIModelSet { - providerConfig.OpenAIModel = p.cfg.OpenAIModel + if p.Flags.OpenAIModel == "gpt-4o-mini-tts" && p.Config.OpenAIModelSet { + providerConfig.OpenAIModel = p.Config.OpenAIModel } - if p.flags.OpenAISpeed == 0.9 && p.cfg.OpenAISpeedSet { - providerConfig.OpenAISpeed = p.cfg.OpenAISpeed + if p.Flags.OpenAISpeed == 0.9 && p.Config.OpenAISpeedSet { + providerConfig.OpenAISpeed = p.Config.OpenAISpeed } - if p.flags.OpenAIInstruction == "" && p.cfg.OpenAIInstructionSet { - providerConfig.OpenAIInstruction = p.cfg.OpenAIInstruction + if p.Flags.OpenAIInstruction == "" && p.Config.OpenAIInstructionSet { + providerConfig.OpenAIInstruction = p.Config.OpenAIInstruction } } @@ -330,7 +261,7 @@ func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice s // When AllVoices is set and filenameBase is "audio", the voice name is embedded // in the filename to keep each voice's file distinct. func (p *Processor) buildAudioOutputPath(wordDir, filenameBase, voice, outputFormat string) string { - if p.flags.AllVoices && filenameBase == "audio" { + if p.Flags.AllVoices && filenameBase == "audio" { return filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat)) } return filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat)) diff --git a/internal/processor/batch_processor.go b/internal/processor/batch_processor.go new file mode 100644 index 0000000..faa515f --- /dev/null +++ b/internal/processor/batch_processor.go @@ -0,0 +1,129 @@ +package processor + +// BatchProcessor runs multi-word batch files: translation pass, validation, +// per-word processing with timeouts, and summary output. It delegates +// per-word work and skip detection to Processor. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/batch" +) + +// BatchProcessor orchestrates batch file processing. It holds a reference to +// the main Processor for shared services (translation, card directories, +// ProcessWordWithTranslationAndType). +type BatchProcessor struct { + p *Processor +} + +// ProcessBatch processes multiple words from a batch file. +// It first translates any entries that have English-to-Bulgarian translation +// needs, then validates all Bulgarian words, and finally processes each word +// with a per-word timeout to prevent a single hung API call from stalling the batch. +func (b *BatchProcessor) ProcessBatch() error { + p := b.p + entries, err := batch.ReadBatchFile(p.Flags.BatchFile) + if err != nil { + return err + } + + if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + if err := b.translateBatchEntries(entries); err != nil { + return err + } + + if err := b.validateBatchEntries(entries); err != nil { + return err + } + + skipped, processed, errCount := b.processBatchEntries(entries) + + b.printBatchSummary(len(entries), processed, skipped, errCount) + return nil +} + +// translateBatchEntries runs the first pass over entries that need English→Bulgarian +// translation and mutates the slice in place with the result. +func (b *BatchProcessor) translateBatchEntries(entries []batch.WordEntry) error { + p := b.p + for i, entry := range entries { + if !entry.NeedsTranslation || entry.Translation == "" { + continue + } + bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) + if err != nil { + fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) + continue + } + entries[i].Bulgarian = bulgarian + fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) + } + return nil +} + +// validateBatchEntries checks that every entry with a Bulgarian word contains +// only valid Bulgarian text. Returns on the first validation failure. +func (b *BatchProcessor) validateBatchEntries(entries []batch.WordEntry) error { + for _, entry := range entries { + if entry.Bulgarian == "" { + continue + } + if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { + return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) + } + } + return nil +} + +// processBatchEntries iterates the validated entries and processes each word, +// skipping words that are already fully processed. Returns skip, process, and +// error counts for the summary. +func (b *BatchProcessor) processBatchEntries(entries []batch.WordEntry) (skipped, processed, errCount int) { + p := b.p + for i, entry := range entries { + if entry.Bulgarian == "" { + continue + } + + fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian) + + if p.isWordFullyProcessed(entry.Bulgarian) { + wordDir := p.findCardDirectory(entry.Bulgarian) + fmt.Printf(" ✓ Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir)) + skipped++ + continue + } + + wordCtx, wordCancel := context.WithTimeout(context.Background(), 5*time.Minute) + err := p.ProcessWordWithTranslationAndType(wordCtx, entry.Bulgarian, entry.Translation, entry.CardType) + wordCancel() + if err != nil { + fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) + errCount++ + } else { + processed++ + } + } + return +} + +// printBatchSummary prints a human-readable summary of the batch run. +func (b *BatchProcessor) printBatchSummary(total, processed, skipped, errCount int) { + fmt.Printf("\n=== Batch Processing Summary ===\n") + fmt.Printf("Total words: %d\n", total) + fmt.Printf("Processed: %d\n", processed) + fmt.Printf("Skipped (already complete): %d\n", skipped) + if errCount > 0 { + fmt.Printf("Errors: %d\n", errCount) + } + fmt.Printf("================================\n") +} diff --git a/internal/processor/card_store.go b/internal/processor/card_store.go index 25b4e36..ef93d0e 100644 --- a/internal/processor/card_store.go +++ b/internal/processor/card_store.go @@ -51,13 +51,13 @@ func (p *Processor) isWordFullyProcessed(word string) bool { "phonetic.txt", } - if !p.flags.SkipAudio { + if !p.Flags.SkipAudio { if !p.hasRequiredAudioFiles(wordDir, &requiredFiles) { return false } } - if !p.flags.SkipImages { + if !p.Flags.SkipImages { if !p.hasRequiredImageFiles(wordDir, &requiredFiles) { return false } @@ -79,7 +79,7 @@ func (p *Processor) isWordFullyProcessed(word string) bool { // Returns false as soon as a required audio file is determined to be missing. func (p *Processor) hasRequiredAudioFiles(wordDir string, requiredFiles *[]string) bool { cardType := internal.LoadCardType(wordDir) - audioFormat := p.effectiveAudioFormat() + audioFormat := p.EffectiveAudioFormat() if cardType.IsBgBg() { return p.hasBgBgAudioFiles(wordDir, audioFormat) diff --git a/internal/processor/cli_config_resolver.go b/internal/processor/cli_config_resolver.go new file mode 100644 index 0000000..5cb18cb --- /dev/null +++ b/internal/processor/cli_config_resolver.go @@ -0,0 +1,179 @@ +package processor + +// CLIConfigResolver applies CLI-flag vs config-file precedence for run-mode +// settings used by the CLI, GUI wiring, and downstream helpers (audio format, +// provider names, Nano Banana models). It does not own API clients or I/O; +// those stay on Processor. + +import ( + "strings" + + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/cli" + "codeberg.org/snonux/totalrecall/internal/gui" + "codeberg.org/snonux/totalrecall/internal/image" + "codeberg.org/snonux/totalrecall/internal/phonetic" + "codeberg.org/snonux/totalrecall/internal/translation" +) + +// CLIConfigResolver holds the resolved CLI flags and config snapshot from the +// composition root. Methods implement precedence rules between explicit flags +// and YAML config without importing Viper. +type CLIConfigResolver struct { + Flags *cli.Flags + Config *Config +} + +// AudioProviderName returns the configured audio provider name, preferring the +// config-file value over the CLI flag so config-file settings win. +func (r *CLIConfigResolver) AudioProviderName() string { + if r.Config.AudioProvider != "" { + return r.Config.AudioProvider + } + if r != nil && r.Flags != nil { + return strings.ToLower(strings.TrimSpace(r.Flags.AudioProvider)) + } + return "" +} + +// EffectiveAudioFormat resolves the audio format from flags and config, with +// CLI flag taking precedence, then the config-file value, then provider-specific defaults. +func (r *CLIConfigResolver) EffectiveAudioFormat() string { + if r != nil && r.Flags != nil && r.Flags.AudioFormatSpecified { + if format := strings.ToLower(strings.TrimSpace(r.Flags.AudioFormat)); format != "" { + return format + } + } + + if r.Config.AudioFormatSet && r.Config.AudioFormat != "" { + return r.Config.AudioFormat + } + + if r != nil && r.Flags != nil { + if format := strings.ToLower(strings.TrimSpace(r.Flags.AudioFormat)); format != "" { + return format + } + } + + if r.AudioProviderName() == "gemini" { + return audio.DefaultProviderConfig().OutputFormat + } + + return "mp3" +} + +// GeminiTTSModel returns the Gemini TTS model, preferring the config-file value over the CLI flag. +func (r *CLIConfigResolver) GeminiTTSModel() string { + if r.Config.GeminiTTSModel != "" { + return r.Config.GeminiTTSModel + } + if r != nil && r.Flags != nil { + return strings.TrimSpace(r.Flags.GeminiTTSModel) + } + return "" +} + +// GeminiVoice returns the Gemini voice, preferring the config-file value over the CLI flag. +func (r *CLIConfigResolver) GeminiVoice() string { + if r.Config.GeminiVoice != "" { + return r.Config.GeminiVoice + } + if r != nil && r.Flags != nil { + return strings.TrimSpace(r.Flags.GeminiVoice) + } + return "" +} + +// OpenAIVoice returns the OpenAI voice, preferring the config-file value over the CLI flag. +func (r *CLIConfigResolver) OpenAIVoice() string { + if r.Config.OpenAIVoice != "" { + return r.Config.OpenAIVoice + } + if r != nil && r.Flags != nil { + return strings.TrimSpace(r.Flags.OpenAIVoice) + } + return "" +} + +// GUIConfig returns a gui.Config populated from flags and config. +// Callers (typically cmd/main.go) use this to construct the GUI application +// so that gui.New() lives outside the processor package and the processor→gui +// dependency is limited to the Config type only. +func (r *CLIConfigResolver) GUIConfig() *gui.Config { + imageProvider := r.Flags.ImageAPI + if !r.Flags.ImageAPISpecified { + imageProvider = gui.DefaultConfig().ImageProvider + } + + openAIKey := cli.GetOpenAIKey() + googleAPIKey := cli.GetGoogleAPIKey() + translationProvider := translation.Provider(r.Config.TranslationProvider) + phoneticProvider := phonetic.Provider(r.Config.PhoneticProvider) + + phoneticFetcher := phonetic.NewFetcher(&phonetic.Config{ + Provider: phoneticProvider, + OpenAIKey: openAIKey, + GoogleAPIKey: googleAPIKey, + }) + translator := translation.NewTranslator(&translation.Config{ + Provider: translationProvider, + OpenAIKey: openAIKey, + GeminiModel: r.Config.TranslationGeminiModel, + }) + + return &gui.Config{ + AudioFormat: r.EffectiveAudioFormat(), + AudioProvider: r.AudioProviderName(), + ImageProvider: imageProvider, + OpenAIKey: openAIKey, + GoogleAPIKey: googleAPIKey, + NanoBananaModel: r.NanoBananaModelForRunMode(), + NanoBananaTextModel: r.NanoBananaTextModelForRunMode(), + GeminiTTSModel: r.GeminiTTSModel(), + GeminiVoice: r.GeminiVoice(), + TranslationProvider: translationProvider, + PhoneticProvider: phoneticProvider, + AutoPlay: !r.Flags.NoAutoPlay, // Invert the flag (--no-auto-play disables auto-play) + PhoneticFetcher: phoneticFetcher, + Translator: translator, + } +} + +// NanoBananaModelForRunMode resolves the NanoBanana image model, preferring +// the explicit CLI flag value when set, then the config-file value, then the +// package default. +func (r *CLIConfigResolver) NanoBananaModelForRunMode() string { + if r != nil && r.Flags != nil && r.Flags.NanoBananaModelSpecified { + if model := strings.TrimSpace(r.Flags.NanoBananaModel); model != "" { + return model + } + } + if r.Config.ImageNanoBananaModel != "" { + return r.Config.ImageNanoBananaModel + } + if r != nil && r.Flags != nil { + if model := strings.TrimSpace(r.Flags.NanoBananaModel); model != "" { + return model + } + } + return image.DefaultNanoBananaModel +} + +// NanoBananaTextModelForRunMode resolves the NanoBanana text (prompt) model +// using the same CLI-flag-over-config precedence as NanoBananaModelForRunMode. +func (r *CLIConfigResolver) NanoBananaTextModelForRunMode() string { + if r != nil && r.Flags != nil && r.Flags.NanoBananaTextModelSpecified { + if model := strings.TrimSpace(r.Flags.NanoBananaTextModel); model != "" { + return model + } + } + if r.Config.ImageNanoBananaTextModel != "" { + return r.Config.ImageNanoBananaTextModel + } + if r != nil && r.Flags != nil { + if model := strings.TrimSpace(r.Flags.NanoBananaTextModel); model != "" { + return model + } + } + return image.DefaultNanoBananaTextModel +} diff --git a/internal/processor/doc.go b/internal/processor/doc.go index 6738816..29f85ba 100644 --- a/internal/processor/doc.go +++ b/internal/processor/doc.go @@ -1,5 +1,6 @@ // Package processor contains the core business logic for processing Bulgarian -// words. It orchestrates audio generation, image downloading, translation, -// phonetic information fetching, and Anki file generation. This package -// serves as the main coordinator between all other components. +// words. Processor composes BatchProcessor (batch file orchestration), +// AnkiExporter (deck export), and CLIConfigResolver (CLI vs config-file +// precedence for run-mode settings). It also coordinates audio generation, +// image downloading, translation, and phonetic fetching. package processor diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go index 60fa921..1d6c002 100644 --- a/internal/processor/image_downloader.go +++ b/internal/processor/image_downloader.go @@ -139,13 +139,13 @@ func (p *Processor) newImageSearcher() (image.PromptAwareClient, error) { // imageProviderForRunMode resolves the image provider, giving precedence to // the CLI flag when it was explicitly set, then the config-file value. func (p *Processor) imageProviderForRunMode() string { - if p.flags.ImageAPISpecified { - return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) + if p.Flags.ImageAPISpecified { + return strings.ToLower(strings.TrimSpace(p.Flags.ImageAPI)) } - if p.cfg.ImageProvider != "" { - return p.cfg.ImageProvider + if p.Config.ImageProvider != "" { + return p.Config.ImageProvider } - return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) + return strings.ToLower(strings.TrimSpace(p.Flags.ImageAPI)) } // newOpenAIImageSearcher builds an OpenAI PromptAwareClient from CLI flags and @@ -154,24 +154,24 @@ func (p *Processor) imageProviderForRunMode() string { func (p *Processor) newOpenAIImageSearcher() (image.PromptAwareClient, error) { openaiConfig := &image.OpenAIConfig{ APIKey: cli.GetOpenAIKey(), - Model: p.flags.OpenAIImageModel, - Size: p.flags.OpenAIImageSize, - Quality: p.flags.OpenAIImageQuality, - Style: p.flags.OpenAIImageStyle, + Model: p.Flags.OpenAIImageModel, + Size: p.Flags.OpenAIImageSize, + Quality: p.Flags.OpenAIImageQuality, + Style: p.Flags.OpenAIImageStyle, } // Apply config-file overrides when CLI flag holds its zero/default value. - if p.flags.OpenAIImageModel == "dall-e-2" && p.cfg.ImageOpenAIModelSet { - openaiConfig.Model = p.cfg.ImageOpenAIModel + if p.Flags.OpenAIImageModel == "dall-e-2" && p.Config.ImageOpenAIModelSet { + openaiConfig.Model = p.Config.ImageOpenAIModel } - if p.flags.OpenAIImageSize == "512x512" && p.cfg.ImageOpenAISizeSet { - openaiConfig.Size = p.cfg.ImageOpenAISize + if p.Flags.OpenAIImageSize == "512x512" && p.Config.ImageOpenAISizeSet { + openaiConfig.Size = p.Config.ImageOpenAISize } - if p.flags.OpenAIImageQuality == "standard" && p.cfg.ImageOpenAIQualitySet { - openaiConfig.Quality = p.cfg.ImageOpenAIQuality + if p.Flags.OpenAIImageQuality == "standard" && p.Config.ImageOpenAIQualitySet { + openaiConfig.Quality = p.Config.ImageOpenAIQuality } - if p.flags.OpenAIImageStyle == "natural" && p.cfg.ImageOpenAIStyleSet { - openaiConfig.Style = p.cfg.ImageOpenAIStyle + if p.Flags.OpenAIImageStyle == "natural" && p.Config.ImageOpenAIStyleSet { + openaiConfig.Style = p.Config.ImageOpenAIStyle } if openaiConfig.APIKey == "" { @@ -187,15 +187,15 @@ func (p *Processor) newOpenAIImageSearcher() (image.PromptAwareClient, error) { func (p *Processor) newNanoBananaImageSearcher() (image.PromptAwareClient, error) { nanoBananaConfig := &image.NanoBananaConfig{ APIKey: cli.GetGoogleAPIKey(), - Model: p.flags.NanoBananaModel, - TextModel: p.flags.NanoBananaTextModel, + Model: p.Flags.NanoBananaModel, + TextModel: p.Flags.NanoBananaTextModel, } - if !p.flags.NanoBananaModelSpecified && p.cfg.ImageNanoBananaModelSet { - nanoBananaConfig.Model = p.cfg.ImageNanoBananaModel + if !p.Flags.NanoBananaModelSpecified && p.Config.ImageNanoBananaModelSet { + nanoBananaConfig.Model = p.Config.ImageNanoBananaModel } - if !p.flags.NanoBananaTextModelSpecified && p.cfg.ImageNanoBananaTextModelSet { - nanoBananaConfig.TextModel = p.cfg.ImageNanoBananaTextModel + if !p.Flags.NanoBananaTextModelSpecified && p.Config.ImageNanoBananaTextModelSet { + nanoBananaConfig.TextModel = p.Config.ImageNanoBananaTextModel } if nanoBananaConfig.APIKey == "" { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 433ef28..6ef0964 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -6,15 +6,10 @@ import ( "math/rand" "os" "path/filepath" - "strings" - "time" "codeberg.org/snonux/totalrecall/internal" - "codeberg.org/snonux/totalrecall/internal/anki" "codeberg.org/snonux/totalrecall/internal/audio" - "codeberg.org/snonux/totalrecall/internal/batch" "codeberg.org/snonux/totalrecall/internal/cli" - "codeberg.org/snonux/totalrecall/internal/gui" "codeberg.org/snonux/totalrecall/internal/httpctx" "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/phonetic" @@ -66,18 +61,17 @@ type Config struct { // Processor handles the main word processing logic. // Audio coordination is in audio_coordinator.go, card directory management is // in card_store.go, and image downloading is in image_downloader.go. +// Batch orchestration lives in batch_processor.go; Anki export in anki_exporter.go; +// CLI vs file config precedence in cli_config_resolver.go. // Factory functions for image and audio providers are grouped in image.ClientFactories // and the audio.ProviderFactory type so the signatures are defined once and // shared with the gui package — eliminating parallel field duplication. type Processor struct { - flags *cli.Flags + *CLIConfigResolver translator *translation.Translator translationCache *translation.TranslationCache phoneticFetcher *phonetic.Fetcher randomIntn func(n int) int - // cfg holds all config-file values resolved once at startup by the caller, - // so individual methods never call Viper directly. - cfg *Config // cardStore is the shared CardStore for locating and creating on-disk // card directories. It is initialised from flags.OutputDir in NewProcessor @@ -91,6 +85,9 @@ type Processor struct { // newAudioProvider constructs an audio.Provider from a Config. // Production code uses audio.NewProvider; tests replace it with a fake. newAudioProvider audio.ProviderFactory + + batchProcessor *BatchProcessor + ankiExporter *AnkiExporter } // NewProcessor creates a new word processor with default production factories. @@ -103,124 +100,24 @@ func NewProcessor(flags *cli.Flags, cfg *Config) *Processor { googleAPIKey := cli.GetGoogleAPIKey() translationProvider := translation.Provider(cfg.TranslationProvider) phoneticProvider := phonetic.Provider(cfg.PhoneticProvider) - return &Processor{ - flags: flags, - cfg: cfg, + p := &Processor{ + CLIConfigResolver: &CLIConfigResolver{Flags: flags, Config: 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, - // cardStore is rooted at the output directory so card-discovery helpers - // never need to know about flags directly. cardStore: store.New(flags.OutputDir), imageFactories: image.DefaultClientFactories(), newAudioProvider: audio.NewProvider, } + p.batchProcessor = &BatchProcessor{p: p} + p.ankiExporter = &AnkiExporter{p: p} + return p } // ProcessBatch processes multiple words from a batch file. -// It first translates any entries that have English-to-Bulgarian translation -// needs, then validates all Bulgarian words, and finally processes each word -// with a per-word timeout to prevent a single hung API call from stalling the batch. func (p *Processor) ProcessBatch() error { - entries, err := batch.ReadBatchFile(p.flags.BatchFile) - if err != nil { - return err - } - - if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - if err := p.translateBatchEntries(entries); err != nil { - return err - } - - if err := p.validateBatchEntries(entries); err != nil { - return err - } - - skipped, processed, errCount := p.processBatchEntries(entries) - - p.printBatchSummary(len(entries), processed, skipped, errCount) - return nil -} - -// translateBatchEntries runs the first pass over entries that need English→Bulgarian -// translation and mutates the slice in place with the result. -func (p *Processor) translateBatchEntries(entries []batch.WordEntry) error { - for i, entry := range entries { - if !entry.NeedsTranslation || entry.Translation == "" { - continue - } - bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) - if err != nil { - fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) - continue - } - entries[i].Bulgarian = bulgarian - fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) - } - return nil -} - -// validateBatchEntries checks that every entry with a Bulgarian word contains -// only valid Bulgarian text. Returns on the first validation failure. -func (p *Processor) validateBatchEntries(entries []batch.WordEntry) error { - for _, entry := range entries { - if entry.Bulgarian == "" { - continue - } - if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { - return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) - } - } - return nil -} - -// processBatchEntries iterates the validated entries and processes each word, -// skipping words that are already fully processed. Returns skip, process, and -// error counts for the summary. -func (p *Processor) processBatchEntries(entries []batch.WordEntry) (skipped, processed, errCount int) { - for i, entry := range entries { - if entry.Bulgarian == "" { - continue - } - - fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian) - - if p.isWordFullyProcessed(entry.Bulgarian) { - wordDir := p.findCardDirectory(entry.Bulgarian) - fmt.Printf(" ✓ Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir)) - skipped++ - continue - } - - // Per-word timeout so a single hung API call cannot stall the whole batch. - // 5 minutes is generous for audio TTS + image download. - wordCtx, wordCancel := context.WithTimeout(context.Background(), 5*time.Minute) - err := p.ProcessWordWithTranslationAndType(wordCtx, entry.Bulgarian, entry.Translation, entry.CardType) - wordCancel() // release resources even on success - if err != nil { - fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) - errCount++ - } else { - processed++ - } - } - return -} - -// printBatchSummary prints a human-readable summary of the batch run. -func (p *Processor) printBatchSummary(total, processed, skipped, errCount int) { - fmt.Printf("\n=== Batch Processing Summary ===\n") - fmt.Printf("Total words: %d\n", total) - fmt.Printf("Processed: %d\n", processed) - fmt.Printf("Skipped (already complete): %d\n", skipped) - if errCount > 0 { - fmt.Printf("Errors: %d\n", errCount) - } - fmt.Printf("================================\n") + return p.batchProcessor.ProcessBatch() } // ProcessSingleWord validates and processes a single word from the command line. @@ -229,7 +126,7 @@ func (p *Processor) ProcessSingleWord(word string) error { return fmt.Errorf("invalid word '%s': %w", word, err) } - if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { + if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } @@ -269,14 +166,14 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, } fmt.Printf(" Saved phonetic information\n") - if !p.flags.SkipAudio { + if !p.Flags.SkipAudio { fmt.Printf(" Generating audio...\n") if err := p.generateAudioForCard(ctx, word, translationText, cardType); err != nil { return fmt.Errorf("audio generation failed: %w", err) } } - if !p.flags.SkipImages { + if !p.Flags.SkipImages { fmt.Printf(" Downloading images...\n") if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil { return fmt.Errorf("image download failed: %w", err) @@ -300,7 +197,6 @@ func (p *Processor) resolveTranslation(_ context.Context, word, providedTranslat } if cardType.IsBgBg() { - // bg-bg cards do not need an English translation. return "" } @@ -342,207 +238,6 @@ func (p *Processor) generateAudioForCard(ctx context.Context, word, translationT } // GenerateAnkiFile generates the Anki import file and returns the output path. -// When --anki is specified the file is placed in the user's home directory; -// otherwise it goes into the configured output directory. func (p *Processor) GenerateAnkiFile() (string, error) { - outputDir, err := p.resolveAnkiOutputDir() - if err != nil { - return "", err - } - - audioFormat := p.effectiveAudioFormat() - gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: filepath.Join(outputDir, "anki_import.csv"), - MediaFolder: p.flags.OutputDir, - IncludeHeaders: true, - AudioFormat: audioFormat, - }) - - if err := p.populateAnkiGenerator(gen, audioFormat); err != nil { - return "", err - } - - return p.writeAnkiOutput(gen, outputDir) -} - -// resolveAnkiOutputDir returns the directory where the Anki file should be -// written. When --anki is set it resolves to the user's home directory. -func (p *Processor) resolveAnkiOutputDir() (string, error) { - if p.flags.GenerateAnki { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return homeDir, nil - } - return p.flags.OutputDir, nil -} - -// populateAnkiGenerator fills the generator with cards. When the translation -// cache is populated it is used as the authoritative source; otherwise the -// generator falls back to scanning the output directory for existing cards. -func (p *Processor) populateAnkiGenerator(gen *anki.Generator, audioFormat string) error { - translations := p.translationCache.GetAll() - if len(translations) == 0 { - fmt.Println(" No translations found in cache, generating cards from directory...") - if err := gen.GenerateFromDirectory(p.flags.OutputDir); err != nil { - return fmt.Errorf("failed to generate cards from directory: %w", err) - } - return nil - } - - fmt.Printf(" Generating cards from %d translations in cache...\n", len(translations)) - for bulgarian, english := range translations { - card := p.buildAnkiCard(bulgarian, english, audioFormat) - gen.AddCard(card) - } - return nil -} - -// buildAnkiCard constructs an anki.Card for a word, resolving all associated -// media files (audio, image, phonetic) from the word's card directory. -func (p *Processor) buildAnkiCard(bulgarian, english, audioFormat string) anki.Card { - card := anki.Card{ - Bulgarian: bulgarian, - Translation: english, - } - - wordDir := p.findCardDirectory(bulgarian) - if wordDir == "" { - return card - } - - cardType := internal.LoadCardType(wordDir) - if cardType.IsBgBg() { - card.AudioFile = anki.ResolveAudioFile(wordDir, "audio_front", audioFormat) - card.AudioFileBack = anki.ResolveAudioFile(wordDir, "audio_back", audioFormat) - } else { - card.AudioFile = anki.ResolveAudioFile(wordDir, "audio", audioFormat) - } - - // Image file (prefer .jpg; the downloader may use other extensions). - imageFile := filepath.Join(wordDir, "image.jpg") - if _, err := os.Stat(imageFile); err == nil { - card.ImageFile = imageFile - } - - // Phonetic notes (newlines replaced with HTML line breaks for Anki). - phoneticFile := filepath.Join(wordDir, "phonetic.txt") - if data, err := os.ReadFile(phoneticFile); err == nil { - notes := strings.TrimSpace(string(data)) - card.Notes = strings.ReplaceAll(notes, "\n", "
") - } - - return card -} - -// writeAnkiOutput generates either a CSV or APKG file depending on the -// --anki-csv flag and returns the output path. -func (p *Processor) writeAnkiOutput(gen *anki.Generator, outputDir string) (string, error) { - if p.flags.AnkiCSV { - outputPath := filepath.Join(outputDir, "anki_import.csv") - if err := gen.GenerateCSV(); err != nil { - return "", fmt.Errorf("failed to generate CSV: %w", err) - } - p.printAnkiStats(gen) - return outputPath, nil - } - - outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(p.flags.DeckName))) - if err := gen.GenerateAPKG(outputPath, p.flags.DeckName); err != nil { - return "", fmt.Errorf("failed to generate APKG: %w", err) - } - p.printAnkiStats(gen) - return outputPath, nil -} - -// printAnkiStats logs the card generation statistics to stdout. -func (p *Processor) printAnkiStats(gen *anki.Generator) { - total, withAudio, withImages := gen.Stats() - fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n", total, withAudio, withImages) -} - -// GUIConfig returns a gui.Config populated from the processor's flags and -// Viper settings. Callers (typically cmd/main.go) use this to construct the -// GUI application so that gui.New() lives outside the processor package and -// the processor→gui dependency is limited to the Config type only. -func (p *Processor) GUIConfig() *gui.Config { - imageProvider := p.flags.ImageAPI - if !p.flags.ImageAPISpecified { - imageProvider = gui.DefaultConfig().ImageProvider - } - - openAIKey := cli.GetOpenAIKey() - googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(p.cfg.TranslationProvider) - phoneticProvider := phonetic.Provider(p.cfg.PhoneticProvider) - - // Construct and inject phonetic/translation dependencies at the composition - // root so gui.New() receives ready-to-use instances rather than raw config strings. - phoneticFetcher := phonetic.NewFetcher(&phonetic.Config{ - Provider: phoneticProvider, - OpenAIKey: openAIKey, - GoogleAPIKey: googleAPIKey, - }) - translator := translation.NewTranslator(&translation.Config{ - Provider: translationProvider, - OpenAIKey: openAIKey, - GeminiModel: p.cfg.TranslationGeminiModel, - }) - - return &gui.Config{ - AudioFormat: p.effectiveAudioFormat(), - AudioProvider: p.audioProviderName(), - ImageProvider: imageProvider, - OpenAIKey: openAIKey, - GoogleAPIKey: googleAPIKey, - NanoBananaModel: p.nanoBananaModelForRunMode(), - NanoBananaTextModel: p.nanoBananaTextModelForRunMode(), - GeminiTTSModel: p.geminiTTSModel(), - GeminiVoice: p.geminiVoice(), - TranslationProvider: translationProvider, - PhoneticProvider: phoneticProvider, - AutoPlay: !p.flags.NoAutoPlay, // Invert the flag (--no-auto-play disables auto-play) - PhoneticFetcher: phoneticFetcher, - Translator: translator, - } -} - -// nanoBananaModelForRunMode resolves the NanoBanana image model, preferring -// the explicit CLI flag value when set, then the config-file value, then the -// package default. -func (p *Processor) nanoBananaModelForRunMode() string { - if p != nil && p.flags != nil && p.flags.NanoBananaModelSpecified { - if model := strings.TrimSpace(p.flags.NanoBananaModel); model != "" { - return model - } - } - if p.cfg.ImageNanoBananaModel != "" { - return p.cfg.ImageNanoBananaModel - } - if p != nil && p.flags != nil { - if model := strings.TrimSpace(p.flags.NanoBananaModel); model != "" { - return model - } - } - return image.DefaultNanoBananaModel -} - -// nanoBananaTextModelForRunMode resolves the NanoBanana text (prompt) model -// using the same CLI-flag-over-config precedence as nanoBananaModelForRunMode. -func (p *Processor) nanoBananaTextModelForRunMode() string { - if p != nil && p.flags != nil && p.flags.NanoBananaTextModelSpecified { - if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { - return model - } - } - if p.cfg.ImageNanoBananaTextModel != "" { - return p.cfg.ImageNanoBananaTextModel - } - if p != nil && p.flags != nil { - if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { - return model - } - } - return image.DefaultNanoBananaTextModel + return p.ankiExporter.GenerateAnkiFile() } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 947f9d3..649c092 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -152,7 +152,7 @@ func TestNewProcessor(t *testing.T) { t.Fatal("NewProcessor returned nil") } - if p.flags != flags { + if p.Flags != flags { t.Error("Processor flags not set correctly") } -- cgit v1.2.3