From 05bddac137607102f12c1c464db34a1e10707af6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Apr 2026 10:16:14 +0300 Subject: 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 --- internal/processor/processor.go | 945 +++++++++------------------------------- 1 file changed, 202 insertions(+), 743 deletions(-) (limited to 'internal/processor/processor.go') 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", "
") } - 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", "
") - } - } - 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() - if usedPrompt == "" { - return - } - - promptFile := filepath.Join(wordDir, "image_prompt.txt") - if err := os.WriteFile(promptFile, []byte(usedPrompt), 0644); err != nil { - fmt.Printf(" Warning: Failed to save image prompt: %v\n", err) - } -} - -// Helper methods - -func (p *Processor) findOrCreateWordDirectory(word string) string { - return internal.FindOrCreateCardDirectory(p.flags.OutputDir, word) -} - -func (p *Processor) findCardDirectory(word string) string { - return internal.FindCardDirectory(p.flags.OutputDir, word) -} - -// isWordFullyProcessed checks if a word has already been fully processed -func (p *Processor) isWordFullyProcessed(word string) bool { - // Find the word directory - wordDir := p.findCardDirectory(word) - if wordDir == "" { - return false // No directory exists - } - - // Debug logging - // Check for required files - requiredFiles := []string{ - "word.txt", // Word metadata - "translation.txt", // Translation file - "phonetic.txt", // Phonetic information - } - - // Check for audio-related files (unless skipped) - if !p.flags.SkipAudio { - // Load card type to determine required audio files - cardType := internal.LoadCardType(wordDir) - audioFormat := p.effectiveAudioFormat() - - if cardType.IsBgBg() { - frontAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_front", audioFormat) - backAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_back", audioFormat) - if len(frontAudioFiles) == 0 || len(backAudioFiles) == 0 { - return false - } - for _, audioFile := range append(frontAudioFiles, backAudioFiles...) { - if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { - return false - } - } - } else { - // For en-bg cards, check for at least one resolved audio file and its metadata. - requiredFiles = append(requiredFiles, "audio_metadata.txt") - - audioFiles := anki.ResolveAudioPaths(wordDir, "audio", audioFormat) - if len(audioFiles) == 0 { - return false - } - for _, audioFile := range audioFiles { - if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { - return false - } - } - } - } - - // Check for image-related files (unless skipped) - if !p.flags.SkipImages { - // Add image-related files to required list - requiredFiles = append(requiredFiles, - "image_attribution.txt", - "image_prompt.txt", - ) - - // Check for at least one image file - imagePatterns := []string{"image_*.jpg", "image_*.png", "image_*.webp", "image.jpg", "image.png", "image.webp"} - hasImage := false - for _, pattern := range imagePatterns { - if strings.Contains(pattern, "*") { - matches, _ := filepath.Glob(filepath.Join(wordDir, pattern)) - if len(matches) > 0 { - hasImage = true - break - } - } else { - // Direct file check - if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { - hasImage = true - break - } - } - } - if !hasImage { - return false // No image files found - } - } - - // Check all required files exist - for _, file := range requiredFiles { - filePath := filepath.Join(wordDir, file) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return false // Required file missing - } - } - - return true // All required files exist -} -func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.Config) error { - processedText := audio.ProcessedTextForProvider(config.Provider, word) - instruction := audio.InstructionForProvider(config.Provider, config) - - // Build params from the provider-specific sub-config to avoid a manual switch. - params := audio.AttributionParamsFrom(config, word, instruction, processedText, time.Now()) - attribution := audio.BuildAttributionFor(config.Provider, params) - - // Save to file - attrPath := audio.AttributionPath(audioFile) - if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil { - return fmt.Errorf("failed to write audio attribution file: %w", err) - } - - // Also save metadata for GUI display - wordDir := filepath.Dir(audioFile) - metadataFile := filepath.Join(wordDir, "audio_metadata.txt") - metadata := p.buildAudioMetadata(config, audioFile) - if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { - // Non-fatal error, just log it - fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) - } - - return nil -} - -func (p *Processor) buildAudioMetadata(config *audio.Config, audioFile string) string { - audioFileHint, audioFileBackHint := p.audioMetadataFileHints(audioFile) - return audio.BuildSidecarMetadata(audio.SidecarMetadataParams{ - Provider: config.Provider, - OutputFormat: config.OutputFormat, - AudioFile: audioFileHint, - AudioFileBack: audioFileBackHint, - OpenAIModel: config.OpenAIModel, - OpenAIVoice: config.OpenAIVoice, - OpenAISpeed: config.OpenAISpeed, - OpenAIInstruction: config.OpenAIInstruction, - GeminiTTSModel: config.GeminiTTSModel, - GeminiVoice: config.GeminiVoice, - GeminiSpeed: config.GeminiSpeed, - }) -} - -func (p *Processor) audioMetadataFileHints(audioFile string) (string, string) { - if strings.TrimSpace(audioFile) == "" { - return "", "" - } - - wordDir := filepath.Dir(audioFile) - base := filepath.Base(audioFile) - ext := filepath.Ext(base) - name := strings.TrimSuffix(base, ext) - - switch name { - case "audio": - return audioFile, "" - case "audio_front": - backFile := filepath.Join(wordDir, "audio_back"+ext) - if _, err := os.Stat(backFile); err == nil { - return audioFile, backFile - } - return audioFile, "" - case "audio_back": - frontFile := filepath.Join(wordDir, "audio_front"+ext) - return frontFile, audioFile - default: - return audioFile, "" - } -} -- cgit v1.2.3