diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 10:16:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 10:16:14 +0300 |
| commit | 05bddac137607102f12c1c464db34a1e10707af6 (patch) | |
| tree | 4b7b74102f78e146b17c1e73370bb0bf4c42a11a | |
| parent | 51a8eaf8b759d6ef93c93b3e5430954959ea2aad (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>
| -rw-r--r-- | internal/audio/provider.go | 1 | ||||
| -rw-r--r-- | internal/audio/provider_test.go | 1 | ||||
| -rw-r--r-- | internal/batch/processor.go | 1 | ||||
| -rw-r--r-- | internal/batch/processor_test.go | 1 | ||||
| -rw-r--r-- | internal/cli/flags.go | 36 | ||||
| -rw-r--r-- | internal/gui/app.go | 8 | ||||
| -rw-r--r-- | internal/image/search.go | 4 | ||||
| -rw-r--r-- | internal/phonetic/fetcher.go | 12 | ||||
| -rw-r--r-- | internal/processor/audio_coordinator.go | 411 | ||||
| -rw-r--r-- | internal/processor/card_store.go | 149 | ||||
| -rw-r--r-- | internal/processor/image_downloader.go | 188 | ||||
| -rw-r--r-- | internal/processor/processor.go | 945 | ||||
| -rw-r--r-- | internal/story/generator.go | 2 | ||||
| -rw-r--r-- | internal/story/runner.go | 2 |
14 files changed, 981 insertions, 780 deletions
diff --git a/internal/audio/provider.go b/internal/audio/provider.go index 60be848..e4e5888 100644 --- a/internal/audio/provider.go +++ b/internal/audio/provider.go @@ -154,4 +154,3 @@ func NewProvider(config *Config) (Provider, error) { return nil, fmt.Errorf("unknown audio provider: %s", config.Provider) } } - diff --git a/internal/audio/provider_test.go b/internal/audio/provider_test.go index 92bb94e..8cf5bfa 100644 --- a/internal/audio/provider_test.go +++ b/internal/audio/provider_test.go @@ -129,4 +129,3 @@ func TestNewProvider(t *testing.T) { }) } } - diff --git a/internal/batch/processor.go b/internal/batch/processor.go index 2b002a9..c785a91 100644 --- a/internal/batch/processor.go +++ b/internal/batch/processor.go @@ -103,4 +103,3 @@ func parseBatchLine(line string) *WordEntry { CardType: internal.CardTypeEnBg, } } - diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go index 4ec1739..85171d8 100644 --- a/internal/batch/processor_test.go +++ b/internal/batch/processor_test.go @@ -165,4 +165,3 @@ func TestReadBatchFile_FileNotFound(t *testing.T) { t.Error("Expected error for non-existent file") } } - diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 9c83138..df493a4 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -16,25 +16,25 @@ type Flags struct { // AudioFormatSpecified records whether the audio format was explicitly set on the CLI. AudioFormatSpecified bool // AudioProvider selects the text-to-speech backend ("gemini" or "openai"). - AudioProvider string - ImageAPI string - ImageAPISpecified bool - BatchFile string - StoryFile string // --story <file>: generate vocabulary story + comic image - StoryStyle string // --story-style: override the random art style (empty = random) - StoryTheme string // --story-theme: override the random genre pick (empty = random) + AudioProvider string + ImageAPI string + ImageAPISpecified bool + BatchFile string + StoryFile string // --story <file>: generate vocabulary story + comic image + StoryStyle string // --story-style: override the random art style (empty = random) + StoryTheme string // --story-theme: override the random genre pick (empty = random) StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement - StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title) - NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random) - SkipAudio bool - SkipImages bool - GenerateAnki bool - AnkiCSV bool - DeckName string - ListModels bool - AllVoices bool - NoAutoPlay bool - Archive bool + StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title) + NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random) + SkipAudio bool + SkipImages bool + GenerateAnki bool + AnkiCSV bool + DeckName string + ListModels bool + AllVoices bool + NoAutoPlay bool + Archive bool // OpenAI flags OpenAIModel string diff --git a/internal/gui/app.go b/internal/gui/app.go index 13b3136..b66d66e 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -107,9 +107,9 @@ type Application struct { activeOpMu sync.Mutex // Mutex for activeOperations map // Injectable factory functions — replaced in tests to avoid real API calls. - newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient + newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient - newAudioProvider func(*audio.Config) (audio.Provider, error) + newAudioProvider func(*audio.Config) (audio.Provider, error) } // Config holds GUI application configuration @@ -225,9 +225,9 @@ func New(config *Config) *Application { autoPlayEnabled: config.AutoPlay, // Use config setting // Production defaults for factory functions; replaced in tests. - newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, + newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) }, - newAudioProvider: audio.NewProvider, + newAudioProvider: audio.NewProvider, } // Initialize the word processing queue diff --git a/internal/image/search.go b/internal/image/search.go index 80d5e33..7e405da 100644 --- a/internal/image/search.go +++ b/internal/image/search.go @@ -27,8 +27,8 @@ type SearchOptions struct { Page int // Page number (1-based) ImageType string // Type: "photo", "illustration", "vector", "all" Orientation string // Orientation: "horizontal", "vertical", "all" - CustomPrompt string // Custom prompt for AI image generation - AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default + CustomPrompt string // Custom prompt for AI image generation + AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default // ReferenceImages holds raw PNG bytes of previously generated images. // When non-empty, the NanoBanana client sends them as multimodal content // alongside the text prompt so the model can match character appearance diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index 7856ac7..aa821b5 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -22,15 +22,15 @@ const ( // ProviderOpenAI routes phonetic requests to OpenAI. ProviderOpenAI Provider = "openai" - defaultGeminiModel = "gemini-2.5-flash" - defaultOpenAIModel = openai.GPT4o - phoneticTimeout = 30 * time.Second - phoneticRetryCount = 3 - phoneticTemperature = 0.3 + defaultGeminiModel = "gemini-2.5-flash" + defaultOpenAIModel = openai.GPT4o + phoneticTimeout = 30 * time.Second + phoneticRetryCount = 3 + phoneticTemperature = 0.3 // 200 tokens gives ample room for IPA of multi-syllable Bulgarian phrases. // 50 was too tight: Gemini 2.5 Flash can emit several thinking tokens before // the IPA bracket pair, causing the output to be silently truncated mid-symbol. - phoneticMaxTokens = 200 + phoneticMaxTokens = 200 phoneticSystemPrompt = "You are a Bulgarian language expert. Provide only the IPA (International Phonetic Alphabet) transcription for Bulgarian words. Return ONLY the IPA transcription in square brackets, nothing else. No explanations, no word labels, just the IPA." ) diff --git a/internal/processor/audio_coordinator.go b/internal/processor/audio_coordinator.go new file mode 100644 index 0000000..7153c98 --- /dev/null +++ b/internal/processor/audio_coordinator.go @@ -0,0 +1,411 @@ +package processor + +// AudioCoordinator assembles audio provider configurations, selects voices, +// generates audio files, and writes attribution/metadata sidecars. It holds +// the audio-related viperCfg fields and flag references so that the main +// Processor struct does not need to deal with audio details directly. +// +// 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). + +import ( + "context" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/cli" +) + +// audioProviderName returns the configured audio provider name, preferring +// the viper config value over the CLI flag so config-file settings win. +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 "" +} + +// effectiveAudioFormat resolves the audio format from flags and config, with +// CLI flag taking precedence, then viper config, 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.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 p.audioProviderName() == "gemini" { + return audio.DefaultProviderConfig().OutputFormat + } + + return "mp3" +} + +// geminiTTSModel returns the Gemini TTS model, preferring viper config over CLI flag. +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) + } + return "" +} + +// geminiVoice returns the Gemini voice, preferring viper config over CLI flag. +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 "" +} + +// openAIVoice returns the OpenAI voice, preferring viper config over CLI flag. +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) + } + 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()) +} + +// 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() { + 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))] + } +} + +// logSelectedAudioVoice prints which voice was selected and whether it was +// specified by the user or picked randomly. Used for informational output only. +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) + } + } +} + +// generateAudio generates audio files for a word using the configured provider. +// When AllVoices is set all provider voices are generated; otherwise a single +// 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() + + if p.flags.AllVoices { + return p.generateAudioForAllVoices(ctx, word) + } + + voice := p.audioVoiceForProvider() + p.logSelectedAudioVoice(provider, voice) + + // For Gemini with no explicit voice, use automatic fallback through all voices. + 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 + } + + return p.generateAudioWithVoice(ctx, word, voice) +} + +// generateAudioForAllVoices iterates over every voice for the configured +// provider and generates a separate audio file for each. +func (p *Processor) generateAudioForAllVoices(ctx context.Context, word string) error { + voices := p.audioVoicesForProvider() + for i, voice := range voices { + 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) + } + } + return nil +} + +// generateAudioBgBg generates audio files for both sides of a bg-bg card. +// 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() + + 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) + } + + return nil + } + + // Use automatic fallback through all Gemini voices when no voice is pinned. + 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 + } + + return generatePair(voice) +} + +// generateAudioWithVoice generates audio for a word with a specific voice, +// saving to the standard "audio" filename in the word's card directory. +func (p *Processor) generateAudioWithVoice(ctx context.Context, word, voice string) error { + return p.generateAudioWithVoiceAndFilename(ctx, word, voice, "audio") +} + +// generateAudioWithVoiceAndFilename generates audio and saves it using the +// given base filename (without extension) inside the word's card directory. +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 is the core audio generation method. +// It assembles the provider config, creates the provider, runs TTS, and writes +// the audio file plus its attribution/metadata sidecars to wordDir. +// ctx is passed directly to provider.GenerateAudio so the caller's deadline applies. +func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, word, voice, filenameBase, wordDir string) error { + providerConfig := p.buildAudioProviderConfig(voice) + + provider, err := p.newAudioProvider(providerConfig) + if err != nil { + return err + } + + outputFile := p.buildAudioOutputPath(wordDir, filenameBase, voice, providerConfig.OutputFormat) + + if err := provider.GenerateAudio(ctx, word, outputFile); err != nil { + return err + } + + // Write attribution and metadata sidecars next to the audio file. + if err := p.saveAudioAttribution(word, outputFile, providerConfig); err != nil { + fmt.Printf(" Warning: Failed to save audio attribution: %v\n", err) + } + + return nil +} + +// buildAudioProviderConfig assembles an audio.Config from flags and viper +// 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() + + // 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 { + speed = 0.90 + rand.Float64()*0.10 + } + + 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: + p.applyOpenAIAudioConfig(providerConfig, voice, speed, audioFormat) + } + + return providerConfig +} + +// applyOpenAIAudioConfig populates the OpenAI-specific fields of providerConfig, +// applying viper overrides where the flag value still equals its default. +func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice string, speed float64, audioFormat string) { + providerConfig.OutputFormat = audioFormat + providerConfig.OpenAIModel = p.flags.OpenAIModel + providerConfig.OpenAIVoice = voice + providerConfig.OpenAISpeed = speed + 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.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 + } +} + +// buildAudioOutputPath constructs the output file path for an audio file. +// 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" { + return filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat)) + } + return filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat)) +} + +// saveAudioAttribution writes two sidecar files next to the audio file: +// - <audioFile>.attribution.txt — human-readable attribution for the clip +// - audio_metadata.txt — machine-readable metadata for the GUI +func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.Config) error { + processedText := audio.ProcessedTextForProvider(config.Provider, word) + instruction := audio.InstructionForProvider(config.Provider, config) + + params := audio.AttributionParamsFrom(config, word, instruction, processedText, time.Now()) + attribution := audio.BuildAttributionFor(config.Provider, params) + + 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 (non-fatal on failure). + 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 { + fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) + } + + return nil +} + +// buildAudioMetadata constructs the sidecar metadata string for the given +// audio file, resolving front/back file hints for bg-bg cards. +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, + }) +} + +// audioMetadataFileHints returns the (front, back) audio file path hints for +// the sidecar metadata. For a standard "audio" file the back hint is empty; +// for bg-bg front/back pairs both hints are returned when both files exist. +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, "" + } +} diff --git a/internal/processor/card_store.go b/internal/processor/card_store.go new file mode 100644 index 0000000..1020998 --- /dev/null +++ b/internal/processor/card_store.go @@ -0,0 +1,149 @@ +package processor + +// CardStore manages the on-disk layout of word card directories. +// It wraps the low-level internal.FindCardDirectory / +// internal.FindOrCreateCardDirectory helpers and adds the higher-level +// isWordFullyProcessed check used by the batch processor to skip words that +// have already been completely generated. +// +// 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). + +import ( + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/audio" +) + +// findOrCreateWordDirectory returns the existing card directory for word +// inside the configured output directory, creating it when absent. +func (p *Processor) findOrCreateWordDirectory(word string) string { + return internal.FindOrCreateCardDirectory(p.flags.OutputDir, word) +} + +// findCardDirectory searches the configured output directory for an existing +// card directory that contains the given word. Returns an empty string when +// no matching directory is found. +func (p *Processor) findCardDirectory(word string) string { + return internal.FindCardDirectory(p.flags.OutputDir, word) +} + +// isWordFullyProcessed returns true when the word's card directory already +// contains all expected output files (audio, image, translation, phonetic). +// The exact set of required files depends on the --skip-audio / --skip-images +// flags so partially-generated cards are still re-processed when relevant. +func (p *Processor) isWordFullyProcessed(word string) bool { + wordDir := p.findCardDirectory(word) + if wordDir == "" { + return false // No directory exists yet. + } + + // Base set of required files for every card type. + requiredFiles := []string{ + "word.txt", + "translation.txt", + "phonetic.txt", + } + + if !p.flags.SkipAudio { + if !p.hasRequiredAudioFiles(wordDir, &requiredFiles) { + return false + } + } + + if !p.flags.SkipImages { + if !p.hasRequiredImageFiles(wordDir, &requiredFiles) { + return false + } + } + + // Verify that every file in the required list actually exists on disk. + for _, file := range requiredFiles { + if _, err := os.Stat(filepath.Join(wordDir, file)); os.IsNotExist(err) { + return false + } + } + + return true +} + +// hasRequiredAudioFiles checks that all expected audio files and their +// attribution sidecars exist in wordDir. It appends extra filenames to +// requiredFiles as a side-effect so they are validated by the caller. +// 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() + + if cardType.IsBgBg() { + return p.hasBgBgAudioFiles(wordDir, audioFormat) + } + + return p.hasEnBgAudioFiles(wordDir, audioFormat, requiredFiles) +} + +// hasBgBgAudioFiles verifies that both audio_front and audio_back files exist +// along with their attribution sidecars. Used for bg-bg (definition) cards. +func (p *Processor) hasBgBgAudioFiles(wordDir, audioFormat string) bool { + 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 + } + } + return true +} + +// hasEnBgAudioFiles verifies that at least one resolved audio file exists +// along with its attribution sidecar. Used for en-bg (translation) cards. +// It also appends "audio_metadata.txt" to requiredFiles so the caller checks it. +func (p *Processor) hasEnBgAudioFiles(wordDir, audioFormat string, requiredFiles *[]string) bool { + *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 + } + } + return true +} + +// hasRequiredImageFiles checks that at least one image file exists and that +// the expected image sidecar files are present. It appends those sidecar +// filenames to requiredFiles as a side-effect. +// Returns false when no image file can be found. +func (p *Processor) hasRequiredImageFiles(wordDir string, requiredFiles *[]string) bool { + *requiredFiles = append(*requiredFiles, + "image_attribution.txt", + "image_prompt.txt", + ) + + // Accept any of the common image extensions and naming conventions. + imagePatterns := []string{"image_*.jpg", "image_*.png", "image_*.webp", "image.jpg", "image.png", "image.webp"} + for _, pattern := range imagePatterns { + if strings.Contains(pattern, "*") { + matches, _ := filepath.Glob(filepath.Join(wordDir, pattern)) + if len(matches) > 0 { + return true + } + } else { + if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { + return true + } + } + } + return false +} diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go new file mode 100644 index 0000000..c956c02 --- /dev/null +++ b/internal/processor/image_downloader.go @@ -0,0 +1,188 @@ +package processor + +// image_downloader.go delegates image downloading to the image package. +// It builds provider-specific ImageClient instances from flags/config and +// then calls image.Downloader to handle the actual HTTP download and +// attribution writing. This file implements the image-downloading concern +// so that processor.go can focus on the high-level word-processing flow. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/totalrecall/internal/cli" + "codeberg.org/snonux/totalrecall/internal/image" +) + +// downloadImagesWithTranslation downloads images for a word into its card +// directory. The translation is forwarded to the search options so that +// AI image providers can generate more contextually accurate images. +// ctx is passed to the image downloader so the caller's deadline applies. +func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, translationText string) error { + searcher, err := p.newImageSearcher() + if err != nil { + return err + } + + wordDir := p.findOrCreateWordDirectory(word) + + downloader := image.NewDownloader(searcher, &image.DownloadOptions{ + OutputDir: wordDir, + OverwriteExisting: true, + CreateDir: true, + FileNamePattern: "image", + MaxSizeBytes: 5 * 1024 * 1024, // 5 MB limit + }) + + searchOpts := image.DefaultSearchOptions(word) + if translationText != "" { + searchOpts.Translation = translationText + } + + // Register a prompt callback so the AI-generated prompt is persisted + // to disk before the download completes (used by the GUI and for debugging). + p.registerPromptCallback(searcher, wordDir) + + _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) + if err != nil { + return err + } + fmt.Printf(" Downloaded: %s\n", path) + + // Persist the final prompt used by the searcher (some providers set it + // only after the search call; this handles that case as a fallback). + p.saveImagePrompt(wordDir, searcher) + + return nil +} + +// registerPromptCallback wires a prompt-save callback into searchers that +// support SetPromptCallback. The callback fires during the Search call so the +// prompt is captured even if the subsequent download fails. +func (p *Processor) registerPromptCallback(searcher image.ImageClient, wordDir string) { + type promptSetter interface { + SetPromptCallback(func(prompt string)) + } + promptAware, ok := searcher.(promptSetter) + if !ok { + return + } + + 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) + } + }) +} + +// saveImagePrompt persists the last prompt used by a searcher that implements +// GetLastPrompt. This acts as a fallback when the prompt is not available via +// the callback during the search call itself. +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) + } +} + +// newImageSearcher creates the appropriate ImageClient based on the configured +// image provider (openai or nanobanana). +func (p *Processor) newImageSearcher() (image.ImageClient, error) { + switch p.imageProviderForRunMode() { + case "openai": + return p.newOpenAIImageSearcher() + case "nanobanana": + return p.newNanoBananaImageSearcher() + default: + return nil, fmt.Errorf("unknown image provider: %s", p.imageProviderForRunMode()) + } +} + +// imageProviderForRunMode resolves the image provider, giving precedence to +// the CLI flag when it was explicitly set, then the viper config value. +func (p *Processor) imageProviderForRunMode() string { + if p.flags.ImageAPISpecified { + return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) + } + if p.viperCfg.imageProvider != "" { + re |
