summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 10:01:57 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 10:01:57 +0300
commit08d7d36ac6d49d7d5efd39a837bc08c9c52b6eb8 (patch)
tree8ce1eecdeb832dadf1d35a4edf496542e13de2a0 /internal/processor
parent35a3c860f63403d14c83668dcdd017b6ef517fbf (diff)
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
Diffstat (limited to 'internal/processor')
-rw-r--r--internal/processor/anki_exporter.go144
-rw-r--r--internal/processor/audio_coordinator.go127
-rw-r--r--internal/processor/batch_processor.go129
-rw-r--r--internal/processor/card_store.go6
-rw-r--r--internal/processor/cli_config_resolver.go179
-rw-r--r--internal/processor/doc.go7
-rw-r--r--internal/processor/image_downloader.go46
-rw-r--r--internal/processor/processor.go337
-rw-r--r--internal/processor/processor_test.go2
9 files changed, 528 insertions, 449 deletions
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", "<br>")
+ }
+
+ 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.