summaryrefslogtreecommitdiff
path: root/internal/processor/processor.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 10:16:14 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 10:16:14 +0300
commit05bddac137607102f12c1c464db34a1e10707af6 (patch)
tree4b7b74102f78e146b17c1e73370bb0bf4c42a11a /internal/processor/processor.go
parent51a8eaf8b759d6ef93c93b3e5430954959ea2aad (diff)
refactor: decompose Processor god object into focused files (SRP)
Extract audio coordination (voice selection, config assembly, attribution writing) into audio_coordinator.go, card directory management into card_store.go, and image downloading/searcher construction into image_downloader.go. processor.go shrinks from ~1119 to ~575 lines, each file now has a single clear responsibility. Also apply go fmt to all touched files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/processor/processor.go')
-rw-r--r--internal/processor/processor.go945
1 files changed, 202 insertions, 743 deletions
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 248bcc2..107fd12 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -99,6 +99,8 @@ func newViperConfig() viperConfig {
}
// 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.
// The factory fields (newOpenAIImageClient, newNanoBananaImageClient, newAudioProvider)
// are injected at construction time so tests can swap them without mutating global state.
type Processor struct {
@@ -144,96 +146,117 @@ func NewProcessor(flags *cli.Flags) *Processor {
}
}
-// ProcessBatch processes multiple words from a batch file
+// 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
}
- // Create output directory (including parent directories)
if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
- // First pass: handle entries that need English to Bulgarian translation
+ 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 != "" {
- // Translate English to Bulgarian
- 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)
+ 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
+}
- // Validate Bulgarian words
+// 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 != "" {
- if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil {
- return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err)
- }
+ if entry.Bulgarian == "" {
+ continue
+ }
+ if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil {
+ return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err)
}
}
+ return nil
+}
- // Track statistics
- skippedCount := 0
- processedCount := 0
- errorCount := 0
-
- // Process each entry
+// 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 // Skip entries without Bulgarian word
+ continue
}
fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian)
- // Check if word already exists and has all required files
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))
- skippedCount++
+ skipped++
continue
}
- // Create a per-word timeout so a single hung API call cannot stall the
- // whole batch. 5 minutes is generous for audio TTS + image download.
+ // 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)
- errorCount++
+ errCount++
} else {
- processedCount++
+ processed++
}
}
+ return
+}
- // Print summary
+// 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", len(entries))
- fmt.Printf("Processed: %d\n", processedCount)
- fmt.Printf("Skipped (already complete): %d\n", skippedCount)
- if errorCount > 0 {
- fmt.Printf("Errors: %d\n", errorCount)
+ 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 nil
}
-// ProcessSingleWord processes a single word from command line
+// ProcessSingleWord validates and processes a single word from the command line.
func (p *Processor) ProcessSingleWord(word string) error {
- // Validate word
if err := audio.ValidateBulgarianText(word); err != nil {
return fmt.Errorf("invalid word '%s': %w", word, err)
}
- // Create output directory (including parent directories)
if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
@@ -242,64 +265,30 @@ func (p *Processor) ProcessSingleWord(word string) error {
return p.ProcessWordWithTranslation(word, "")
}
-// ProcessWordWithTranslation processes a word with optional provided translation (en-bg mode)
+// ProcessWordWithTranslation processes a word with an optional provided English
+// translation, using the default en-bg card type.
func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error {
return p.ProcessWordWithTranslationAndType(context.Background(), word, providedTranslation, internal.CardTypeEnBg)
}
-// ProcessWordWithTranslationAndType processes a word with optional provided translation and card type.
-// ctx is used for all downstream API calls (audio TTS, image generation) so the caller can
-// cancel or time out the whole operation. ProcessBatch passes a per-word deadline; callers
-// that do not need a deadline may pass context.Background().
+// ProcessWordWithTranslationAndType processes a word with optional provided
+// translation and card type. ctx is used for all downstream API calls (audio
+// TTS, image generation) so the caller can cancel or time-out the operation.
+// ProcessBatch passes a per-word deadline; callers without a deadline may pass
+// context.Background().
func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, providedTranslation string, cardType internal.CardType) error {
- var translationText string
-
- // For bg-bg cards, translation is the back side (Bulgarian definition)
- // For en-bg cards, translation is the English word
- if providedTranslation != "" {
- translationText = providedTranslation
- if cardType.IsBgBg() {
- fmt.Printf(" Using provided definition: %s\n", translationText)
- } else {
- fmt.Printf(" Using provided translation: %s\n", translationText)
- }
- } else if !cardType.IsBgBg() {
- // Only translate to English for en-bg cards
- fmt.Printf(" Translating to English...\n")
- var err error
- translationText, err = p.translator.TranslateWord(word)
- if err != nil {
- fmt.Printf(" Warning: Translation failed: %v\n", err)
- translationText = ""
- } else {
- fmt.Printf(" Translation: %s\n", translationText)
- }
- }
+ translationText := p.resolveTranslation(ctx, word, providedTranslation, cardType)
- // Find or create word directory
wordDir := p.findOrCreateWordDirectory(word)
- // Save card type
if err := internal.SaveCardType(wordDir, cardType); err != nil {
fmt.Printf(" Warning: Failed to save card type: %v\n", err)
}
- // Store translation for Anki export
- if translationText != "" {
- p.translationCache.Add(word, translationText)
-
- // Check if translation file already exists
- translationFile := filepath.Join(wordDir, "translation.txt")
- if _, err := os.Stat(translationFile); os.IsNotExist(err) {
- if err := translation.SaveTranslation(wordDir, word, translationText); err != nil {
- fmt.Printf(" Warning: Failed to save translation: %v\n", err)
- }
- } else {
- fmt.Printf(" Translation file already exists\n")
- }
+ if err := p.saveTranslationIfNeeded(word, translationText, wordDir); err != nil {
+ fmt.Printf(" Warning: Failed to save translation: %v\n", err)
}
- // Fetch phonetic information
fmt.Printf(" Fetching phonetic information...\n")
if err := p.phoneticFetcher.FetchAndSave(word, wordDir); err != nil {
fmt.Printf(" Warning: Failed to fetch phonetic info: %v\n", err)
@@ -307,22 +296,13 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word,
fmt.Printf(" Saved phonetic information\n")
}
- // Generate audio
if !p.flags.SkipAudio {
fmt.Printf(" Generating audio...\n")
- if cardType.IsBgBg() {
- // Generate audio for both sides
- if err := p.generateAudioBgBg(ctx, word, translationText); err != nil {
- return fmt.Errorf("audio generation failed: %w", err)
- }
- } else {
- if err := p.generateAudio(ctx, word); err != nil {
- return fmt.Errorf("audio generation failed: %w", err)
- }
+ if err := p.generateAudioForCard(ctx, word, translationText, cardType); err != nil {
+ return fmt.Errorf("audio generation failed: %w", err)
}
}
- // Download images - pass the translation for better image generation
if !p.flags.SkipImages {
fmt.Printf(" Downloading images...\n")
if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil {
@@ -333,436 +313,182 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word,
return nil
}
-func (p *Processor) audioProviderName() string {
- if p.viperCfg.audioProvider != "" {
- return p.viperCfg.audioProvider
- }
- if p != nil && p.flags != nil {
- return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider))
- }
- return ""
-}
-
-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
+// resolveTranslation determines the effective translation text for the word.
+// For bg-bg cards it uses the provided definition; for en-bg cards it fetches
+// an English translation when none was provided.
+func (p *Processor) resolveTranslation(_ context.Context, word, providedTranslation string, cardType internal.CardType) string {
+ if providedTranslation != "" {
+ if cardType.IsBgBg() {
+ fmt.Printf(" Using provided definition: %s\n", providedTranslation)
+ } else {
+ fmt.Printf(" Using provided translation: %s\n", providedTranslation)
}
+ return providedTranslation
}
- if p.viperCfg.audioFormatSet && p.viperCfg.audioFormat != "" {
- return p.viperCfg.audioFormat
- }
-
- if p != nil && p.flags != nil {
- if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" {
- return format
- }
+ if cardType.IsBgBg() {
+ // bg-bg cards do not need an English translation.
+ return ""
}
- if p.audioProviderName() == "gemini" {
- return audio.DefaultProviderConfig().OutputFormat
+ fmt.Printf(" Translating to English...\n")
+ translationText, err := p.translator.TranslateWord(word)
+ if err != nil {
+ fmt.Printf(" Warning: Translation failed: %v\n", err)
+ return ""
}
-
- return "mp3"
+ fmt.Printf(" Translation: %s\n", translationText)
+ return translationText
}
-func (p *Processor) geminiTTSModel() string {
- if p.viperCfg.geminiTTSModel != "" {
- return p.viperCfg.geminiTTSModel
- }
- if p != nil && p.flags != nil {
- return strings.TrimSpace(p.flags.GeminiTTSModel)
+// saveTranslationIfNeeded stores the translation in the in-memory cache and
+// writes translation.txt to wordDir if the file does not already exist.
+func (p *Processor) saveTranslationIfNeeded(word, translationText, wordDir string) error {
+ if translationText == "" {
+ return nil
}
- return ""
-}
-func (p *Processor) geminiVoice() string {
- if p.viperCfg.geminiVoice != "" {
- return p.viperCfg.geminiVoice
- }
- if p != nil && p.flags != nil {
- return strings.TrimSpace(p.flags.GeminiVoice)
- }
- return ""
-}
+ p.translationCache.Add(word, translationText)
-func (p *Processor) openAIVoice() string {
- if p.viperCfg.openAIVoice != "" {
- return p.viperCfg.openAIVoice
- }
- if p != nil && p.flags != nil {
- return strings.TrimSpace(p.flags.OpenAIVoice)
+ translationFile := filepath.Join(wordDir, "translation.txt")
+ if _, err := os.Stat(translationFile); os.IsNotExist(err) {
+ return translation.SaveTranslation(wordDir, word, translationText)
}
- return ""
-}
-// audioVoicesForProvider returns the voice list for the configured provider
-// without needing a Provider instance (uses the package-level VoicesFor).
-func (p *Processor) audioVoicesForProvider() []string {
- return audio.VoicesFor(p.audioProviderName())
+ fmt.Printf(" Translation file already exists\n")
+ return nil
}
-func (p *Processor) audioVoiceForProvider() string {
- switch p.audioProviderName() {
- case "gemini":
- if voice := p.geminiVoice(); voice != "" {
- return voice
- }
- voices := p.audioVoicesForProvider()
- if p.randomIntn != nil {
- return voices[p.randomIntn(len(voices))]
- }
- return voices[rand.Intn(len(voices))]
- default:
- if voice := p.openAIVoice(); voice != "" {
- return voice
- }
- voices := p.audioVoicesForProvider()
- if p.randomIntn != nil {
- return voices[p.randomIntn(len(voices))]
- }
- return voices[rand.Intn(len(voices))]
+// generateAudioForCard dispatches audio generation to the appropriate helper
+// based on card type. bg-bg cards need audio for both front and back sides.
+func (p *Processor) generateAudioForCard(ctx context.Context, word, translationText string, cardType internal.CardType) error {
+ if cardType.IsBgBg() {
+ return p.generateAudioBgBg(ctx, word, translationText)
}
+ return p.generateAudio(ctx, word)
}
-func (p *Processor) logSelectedAudioVoice(provider, voice string) {
- switch provider {
- case "gemini":
- 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() != "" {
- fmt.Printf(" Using specified voice: %s\n", voice)
- } else {
- fmt.Printf(" Using random voice: %s\n", voice)
- }
+// 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
}
-}
-
-// generateAudio generates audio files for a word using the configured provider.
-// 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()
- // Get the provider-specific voice list.
- var voices []string
- if p.flags.AllVoices {
- voices = p.audioVoicesForProvider()
- } else {
- voice := p.audioVoiceForProvider()
- p.logSelectedAudioVoice(provider, voice)
- 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)
- }
- return p.generateAudioWithVoice(ctx, word, candidate)
- }, func(candidate string) {
- fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate)
- })
- return err
- }
- voices = []string{voice}
- }
+ audioFormat := p.effectiveAudioFormat()
+ gen := anki.NewGenerator(&anki.GeneratorOptions{
+ OutputPath: filepath.Join(outputDir, "anki_import.csv"),
+ MediaFolder: p.flags.OutputDir,
+ IncludeHeaders: true,
+ AudioFormat: audioFormat,
+ })
- // Generate audio for each voice
- for i, voice := range voices {
- if p.flags.AllVoices {
- fmt.Printf(" Generating audio %d/%d (voice: %s)...\n", i+1, len(voices), voice)
- }
- if err := p.generateAudioWithVoice(ctx, word, voice); err != nil {
- return fmt.Errorf("failed to generate audio with voice %s: %w", voice, err)
- }
+ if err := p.populateAnkiGenerator(gen, audioFormat); err != nil {
+ return "", err
}
- return nil
+ return p.writeAnkiOutput(gen, outputDir)
}
-// generateAudioBgBg generates audio files for both sides of a bg-bg 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()
-
- voice := p.audioVoiceForProvider()
- p.logSelectedAudioVoice(provider, voice)
-
- // Find or create the word directory ONCE (for the front word)
- // Both audio files will be saved to this same directory.
- wordDir := p.findOrCreateWordDirectory(front)
-
- generatePair := func(candidate string) error {
- fmt.Printf(" Generating front audio for '%s'...\n", front)
- if err := p.generateAudioWithVoiceAndFilenameInDir(ctx, front, candidate, "audio_front", wordDir); err != nil {
- return fmt.Errorf("failed to generate front audio: %w", err)
- }
-
- fmt.Printf(" Generating back audio for '%s'...\n", back)
- if err := p.generateAudioWithVoiceAndFilenameInDir(ctx, back, candidate, "audio_back", wordDir); err != nil {
- return fmt.Errorf("failed to generate back audio: %w", err)
+// 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 nil
- }
-
- 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)
- }
- return generatePair(candidate)
- }, func(candidate string) {
- fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate)
- })
- return err
- }
-
- if err := generatePair(voice); err != nil {
- return err
+ return homeDir, nil
}
-
- return nil
-}
-
-// generateAudioWithVoice generates audio for a word with a specific voice.
-func (p *Processor) generateAudioWithVoice(ctx context.Context, word, voice string) error {
- return p.generateAudioWithVoiceAndFilename(ctx, word, voice, "audio")
+ return p.flags.OutputDir, nil
}
-// generateAudioWithVoiceAndFilename generates audio for a word with a specific voice and filename.
-func (p *Processor) generateAudioWithVoiceAndFilename(ctx context.Context, word, voice, filenameBase string) error {
- wordDir := p.findOrCreateWordDirectory(word)
- return p.generateAudioWithVoiceAndFilenameInDir(ctx, word, voice, filenameBase, wordDir)
-}
-
-// generateAudioWithVoiceAndFilenameInDir generates audio for a word and saves it to a specific directory.
-// ctx is passed directly to provider.GenerateAudio so the caller's cancellation and deadline apply
-// to the TTS API call; use context.Background() when no deadline is needed.
-func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, word, voice, filenameBase, wordDir string) error {
- 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.viperCfg.openAISpeedSet {
- // Default was used, generate random speed
- speed = 0.90 + rand.Float64()*0.10
- }
-
- // Create audio provider configuration
- providerConfig := audio.DefaultProviderConfig()
- providerConfig.Provider = audioProvider
- providerConfig.OutputDir = p.flags.OutputDir
- providerConfig.OpenAIKey = cli.GetOpenAIKey()
- providerConfig.GoogleAPIKey = cli.GetGoogleAPIKey()
-
- switch audioProvider {
- case "gemini":
- providerConfig.OutputFormat = audioFormat
- providerConfig.GeminiTTSModel = p.geminiTTSModel()
- if voice != "" {
- providerConfig.GeminiVoice = voice
- } else {
- providerConfig.GeminiVoice = p.geminiVoice()
- }
- providerConfig.GeminiSpeed = 1.0
- default:
- providerConfig.OutputFormat = audioFormat
- providerConfig.OpenAIModel = p.flags.OpenAIModel
- providerConfig.OpenAIVoice = voice
- providerConfig.OpenAISpeed = speed
- providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction
-
- // Use config file values if not overridden by flags
- if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.viperCfg.openAIModelSet {
- providerConfig.OpenAIModel = p.viperCfg.openAIModel
- }
- if p.flags.OpenAISpeed == 0.9 && p.viperCfg.openAISpeedSet {
- providerConfig.OpenAISpeed = p.viperCfg.openAISpeed
- }
- if p.flags.OpenAIInstruction == "" && p.viperCfg.openAIInstructionSet {
- providerConfig.OpenAIInstruction = p.viperCfg.openAIInstruction
+// 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
}
- // Create the audio provider
- provider, err := p.newAudioProvider(providerConfig)
- if err != nil {
- return err
- }
-
- // Build filename using the provided base
- outputFormat := providerConfig.OutputFormat
- var outputFile string
- if p.flags.AllVoices && filenameBase == "audio" {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat))
- } else {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat))
- }
-
- // Generate the audio
- err = provider.GenerateAudio(ctx, word, outputFile)
- if err != nil {
- return err
- }
-
- // Save audio attribution
- if err := p.saveAudioAttribution(word, outputFile, providerConfig); err != nil {
- fmt.Printf(" Warning: Failed to save audio attribution: %v\n", err)
+ 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
}
-// downloadImagesWithTranslation downloads images for a word.
-// ctx is passed to the image downloader so the caller's cancellation and deadline apply
-// to the image search and download API calls.
-func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, translationText string) error {
- searcher, err := p.newImageSearcher()
- if err != nil {
- return err
+// 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,
}
- // Find existing card directory or create new one
- wordDir := p.findOrCreateWordDirectory(word)
-
- // Create downloader
- downloadOpts := &image.DownloadOptions{
- OutputDir: wordDir,
- OverwriteExisting: true,
- CreateDir: true,
- FileNamePattern: "image",
- MaxSizeBytes: 5 * 1024 * 1024, // 5MB
+ wordDir := p.findCardDirectory(bulgarian)
+ if wordDir == "" {
+ return card
}
- downloader := image.NewDownloader(searcher, downloadOpts)
-
- // Create search options with translation if provided
- searchOpts := image.DefaultSearchOptions(word)
- if translationText != "" {
- searchOpts.Translation = translationText
+ 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)
}
- type promptSetter interface {
- SetPromptCallback(func(prompt string))
- }
- if promptAware, ok := searcher.(promptSetter); ok {
- promptFile := filepath.Join(wordDir, "image_prompt.txt")
- promptAware.SetPromptCallback(func(prompt string) {
- if prompt == "" {
- return
- }
- if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil {
- fmt.Printf(" Warning: Failed to save image prompt: %v\n", err)
- }
- })
+ // 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
}
- // Download single image using the caller-provided context so deadlines propagate.
- _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts)
- if err != nil {
- return err
+ // 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", "<br>")
}
- fmt.Printf(" Downloaded: %s\n", path)
- p.saveImagePrompt(wordDir, searcher)
-
- return nil
+ return card
}
-// GenerateAnkiFile generates the Anki import file and returns the output path
-func (p *Processor) GenerateAnkiFile() (string, error) {
- // When --anki is used from CLI, save to home directory
- var outputDir string
- if p.flags.GenerateAnki {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("failed to get home directory: %w", err)
- }
- outputDir = homeDir
- } else {
- outputDir = p.flags.OutputDir
- }
-
- // Create Anki generator
- audioFormat := p.effectiveAudioFormat()
- gen := anki.NewGenerator(&anki.GeneratorOptions{
- OutputPath: filepath.Join(outputDir, "anki_import.csv"),
- MediaFolder: p.flags.OutputDir,
- IncludeHeaders: true,
- AudioFormat: audioFormat,
- })
-
- // Use the translation cache as the source of truth for cards
- translations := p.translationCache.GetAll()
- if len(translations) == 0 {
- fmt.Println(" No translations found in cache, generating cards from directory...")
- // Fallback to old method if cache is empty but files might exist
- if err := gen.GenerateFromDirectory(p.flags.OutputDir); err != nil {
- return "", fmt.Errorf("failed to generate cards from directory: %w", err)
- }
- } else {
- fmt.Printf(" Generating cards from %d translations in cache...\n", len(translations))
- for bulgarian, english := range translations {
- card := anki.Card{
- Bulgarian: bulgarian,
- Translation: english,
- }
-
- // Find associated media files in the output directory
- wordDir := p.findCardDirectory(bulgarian)
- if wordDir != "" {
- 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)
- }
-
- // Look for image file
- imageFile := filepath.Join(wordDir, "image.jpg") // Assuming jpg, adjust if needed
- if _, err := os.Stat(imageFile); err == nil {
- card.ImageFile = imageFile
- }
-
- // Load phonetic information as notes
- 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>")
- }
- }
- gen.AddCard(card)
- }
- }
-
- var outputPath string
+// 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 {
- // Generate CSV
- outputPath = filepath.Join(outputDir, "anki_import.csv")
+ outputPath := filepath.Join(outputDir, "anki_import.csv")
if err := gen.GenerateCSV(); err != nil {
return "", fmt.Errorf("failed to generate CSV: %w", err)
}
- } else {
- // Generate APKG
- 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
}
- // Print stats
- total, withAudio, withImages := gen.Stats()
- fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n",
- total, withAudio, withImages)
-
+ 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
@@ -778,8 +504,8 @@ func (p *Processor) GUIConfig() *gui.Config {
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.
+ // 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,
@@ -809,308 +535,41 @@ func (p *Processor) GUIConfig() *gui.Config {
}
}
+// nanoBananaModelForRunMode resolves the NanoBanana image model, preferring
+// the explicit CLI flag value when set, then the viper config 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.viperCfg.imageNanoBananaModel != "" {
return p.viperCfg.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 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.viperCfg.imageNanoBananaTextModel != "" {
return p.viperCfg.imageNanoBananaTextModel
}
-
if p != nil && p.flags != nil {
if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" {
return model
}
}
-
return image.DefaultNanoBananaTextModel
}
-
-func (p *Processor) newImageSearcher() (image.ImageClient, error) {
- provider := p.imageProviderForRunMode()
-
- switch provider {
- case "openai":
- return p.newOpenAIImageSearcher()
- case "nanobanana":
- return p.newNanoBananaImageSearcher()
- default:
- return nil, fmt.Errorf("unknown image provider: %s", provider)
- }
-}
-
-func (p *Processor) imageProviderForRunMode() string {
- if p.flags.ImageAPISpecified {
- return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI))
- }
-
- if p.viperCfg.imageProvider != "" {
- return p.viperCfg.imageProvider
- }
-
- return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI))
-}
-
-func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) {
- openaiConfig := &image.OpenAIConfig{
- APIKey: cli.GetOpenAIKey(),
- Model: p.flags.OpenAIImageModel,
- Size: p.flags.OpenAIImageSize,
- Quality: p.flags.OpenAIImageQuality,
- Style: p.flags.OpenAIImageStyle,
- }
-
- if p.flags.OpenAIImageModel == "dall-e-2" && p.viperCfg.imageOpenAIModelSet {
- openaiConfig.Model = p.viperCfg.imageOpenAIModel
- }
- if p.flags.OpenAIImageSize == "512x512" && p.viperCfg.imageOpenAISizeSet {
- openaiConfig.Size = p.viperCfg.imageOpenAISize
- }
- if p.flags.OpenAIImageQuality == "standard" && p.viperCfg.imageOpenAIQualitySet {
- openaiConfig.Quality = p.viperCfg.imageOpenAIQuality
- }
- 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 p.newOpenAIImageClient(openaiConfig), nil
-}
-
-func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) {
- nanoBananaConfig := &image.NanoBananaConfig{
- APIKey: cli.GetGoogleAPIKey(),
- Model: p.flags.NanoBananaModel,
- TextModel: p.flags.NanoBananaTextModel,
- }
-
- if !p.flags.NanoBananaModelSpecified && p.viperCfg.imageNanoBananaModelSet {
- nanoBananaConfig.Model = p.viperCfg.imageNanoBananaModel
- }
- 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 p.newNanoBananaImageClient(nanoBananaConfig), nil
-}
-
-func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) {
- type promptGetter interface {
- GetLastPrompt() string
- }
-
- promptSource, ok := searcher.(promptGetter)
- if !ok {
- return
- }
-
- usedPrompt := promptSource.GetLastPrompt()