diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 10:01:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 10:01:57 +0300 |
| commit | 08d7d36ac6d49d7d5efd39a837bc08c9c52b6eb8 (patch) | |
| tree | 8ce1eecdeb832dadf1d35a4edf496542e13de2a0 /internal/processor/processor.go | |
| parent | 35a3c860f63403d14c83668dcdd017b6ef517fbf (diff) | |
refactor(processor): split BatchProcessor, AnkiExporter, CLIConfigResolver
Extract CLIConfigResolver for flag/config precedence and GUI wiring; embed
it on Processor so audio and image helpers use promoted accessors.
Move batch file flow to BatchProcessor and Anki generation to AnkiExporter;
Processor delegates while keeping per-word processing and translation cache.
Made-with: Cursor
Diffstat (limited to 'internal/processor/processor.go')
| -rw-r--r-- | internal/processor/processor.go | 337 |
1 files changed, 16 insertions, 321 deletions
diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 433ef28..6ef0964 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -6,15 +6,10 @@ import ( "math/rand" "os" "path/filepath" - "strings" - "time" "codeberg.org/snonux/totalrecall/internal" - "codeberg.org/snonux/totalrecall/internal/anki" "codeberg.org/snonux/totalrecall/internal/audio" - "codeberg.org/snonux/totalrecall/internal/batch" "codeberg.org/snonux/totalrecall/internal/cli" - "codeberg.org/snonux/totalrecall/internal/gui" "codeberg.org/snonux/totalrecall/internal/httpctx" "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/phonetic" @@ -66,18 +61,17 @@ type Config struct { // Processor handles the main word processing logic. // Audio coordination is in audio_coordinator.go, card directory management is // in card_store.go, and image downloading is in image_downloader.go. +// Batch orchestration lives in batch_processor.go; Anki export in anki_exporter.go; +// CLI vs file config precedence in cli_config_resolver.go. // Factory functions for image and audio providers are grouped in image.ClientFactories // and the audio.ProviderFactory type so the signatures are defined once and // shared with the gui package — eliminating parallel field duplication. type Processor struct { - flags *cli.Flags + *CLIConfigResolver translator *translation.Translator translationCache *translation.TranslationCache phoneticFetcher *phonetic.Fetcher randomIntn func(n int) int - // cfg holds all config-file values resolved once at startup by the caller, - // so individual methods never call Viper directly. - cfg *Config // cardStore is the shared CardStore for locating and creating on-disk // card directories. It is initialised from flags.OutputDir in NewProcessor @@ -91,6 +85,9 @@ type Processor struct { // newAudioProvider constructs an audio.Provider from a Config. // Production code uses audio.NewProvider; tests replace it with a fake. newAudioProvider audio.ProviderFactory + + batchProcessor *BatchProcessor + ankiExporter *AnkiExporter } // NewProcessor creates a new word processor with default production factories. @@ -103,124 +100,24 @@ func NewProcessor(flags *cli.Flags, cfg *Config) *Processor { googleAPIKey := cli.GetGoogleAPIKey() translationProvider := translation.Provider(cfg.TranslationProvider) phoneticProvider := phonetic.Provider(cfg.PhoneticProvider) - return &Processor{ - flags: flags, - cfg: cfg, + p := &Processor{ + CLIConfigResolver: &CLIConfigResolver{Flags: flags, Config: cfg}, translator: translation.NewTranslator(&translation.Config{Provider: translationProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), translationCache: translation.NewTranslationCache(), phoneticFetcher: phonetic.NewFetcher(&phonetic.Config{Provider: phoneticProvider, OpenAIKey: openAIKey, GoogleAPIKey: googleAPIKey}), randomIntn: rand.Intn, - // cardStore is rooted at the output directory so card-discovery helpers - // never need to know about flags directly. cardStore: store.New(flags.OutputDir), imageFactories: image.DefaultClientFactories(), newAudioProvider: audio.NewProvider, } + p.batchProcessor = &BatchProcessor{p: p} + p.ankiExporter = &AnkiExporter{p: p} + return p } // ProcessBatch processes multiple words from a batch file. -// It first translates any entries that have English-to-Bulgarian translation -// needs, then validates all Bulgarian words, and finally processes each word -// with a per-word timeout to prevent a single hung API call from stalling the batch. func (p *Processor) ProcessBatch() error { - entries, err := batch.ReadBatchFile(p.flags.BatchFile) - if err != nil { - return err - } - - if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - if err := p.translateBatchEntries(entries); err != nil { - return err - } - - if err := p.validateBatchEntries(entries); err != nil { - return err - } - - skipped, processed, errCount := p.processBatchEntries(entries) - - p.printBatchSummary(len(entries), processed, skipped, errCount) - return nil -} - -// translateBatchEntries runs the first pass over entries that need English→Bulgarian -// translation and mutates the slice in place with the result. -func (p *Processor) translateBatchEntries(entries []batch.WordEntry) error { - for i, entry := range entries { - if !entry.NeedsTranslation || entry.Translation == "" { - continue - } - bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) - if err != nil { - fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) - continue - } - entries[i].Bulgarian = bulgarian - fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) - } - return nil -} - -// validateBatchEntries checks that every entry with a Bulgarian word contains -// only valid Bulgarian text. Returns on the first validation failure. -func (p *Processor) validateBatchEntries(entries []batch.WordEntry) error { - for _, entry := range entries { - if entry.Bulgarian == "" { - continue - } - if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { - return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) - } - } - return nil -} - -// processBatchEntries iterates the validated entries and processes each word, -// skipping words that are already fully processed. Returns skip, process, and -// error counts for the summary. -func (p *Processor) processBatchEntries(entries []batch.WordEntry) (skipped, processed, errCount int) { - for i, entry := range entries { - if entry.Bulgarian == "" { - continue - } - - fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian) - - if p.isWordFullyProcessed(entry.Bulgarian) { - wordDir := p.findCardDirectory(entry.Bulgarian) - fmt.Printf(" ✓ Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir)) - skipped++ - continue - } - - // Per-word timeout so a single hung API call cannot stall the whole batch. - // 5 minutes is generous for audio TTS + image download. - wordCtx, wordCancel := context.WithTimeout(context.Background(), 5*time.Minute) - err := p.ProcessWordWithTranslationAndType(wordCtx, entry.Bulgarian, entry.Translation, entry.CardType) - wordCancel() // release resources even on success - if err != nil { - fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) - errCount++ - } else { - processed++ - } - } - return -} - -// printBatchSummary prints a human-readable summary of the batch run. -func (p *Processor) printBatchSummary(total, processed, skipped, errCount int) { - fmt.Printf("\n=== Batch Processing Summary ===\n") - fmt.Printf("Total words: %d\n", total) - fmt.Printf("Processed: %d\n", processed) - fmt.Printf("Skipped (already complete): %d\n", skipped) - if errCount > 0 { - fmt.Printf("Errors: %d\n", errCount) - } - fmt.Printf("================================\n") + return p.batchProcessor.ProcessBatch() } // ProcessSingleWord validates and processes a single word from the command line. @@ -229,7 +126,7 @@ func (p *Processor) ProcessSingleWord(word string) error { return fmt.Errorf("invalid word '%s': %w", word, err) } - if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { + if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } @@ -269,14 +166,14 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, } fmt.Printf(" Saved phonetic information\n") - if !p.flags.SkipAudio { + if !p.Flags.SkipAudio { fmt.Printf(" Generating audio...\n") if err := p.generateAudioForCard(ctx, word, translationText, cardType); err != nil { return fmt.Errorf("audio generation failed: %w", err) } } - if !p.flags.SkipImages { + if !p.Flags.SkipImages { fmt.Printf(" Downloading images...\n") if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil { return fmt.Errorf("image download failed: %w", err) @@ -300,7 +197,6 @@ func (p *Processor) resolveTranslation(_ context.Context, word, providedTranslat } if cardType.IsBgBg() { - // bg-bg cards do not need an English translation. return "" } @@ -342,207 +238,6 @@ func (p *Processor) generateAudioForCard(ctx context.Context, word, translationT } // GenerateAnkiFile generates the Anki import file and returns the output path. -// When --anki is specified the file is placed in the user's home directory; -// otherwise it goes into the configured output directory. func (p *Processor) GenerateAnkiFile() (string, error) { - outputDir, err := p.resolveAnkiOutputDir() - if err != nil { - return "", err - } - - audioFormat := p.effectiveAudioFormat() - gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: filepath.Join(outputDir, "anki_import.csv"), - MediaFolder: p.flags.OutputDir, - IncludeHeaders: true, - AudioFormat: audioFormat, - }) - - if err := p.populateAnkiGenerator(gen, audioFormat); err != nil { - return "", err - } - - return p.writeAnkiOutput(gen, outputDir) -} - -// resolveAnkiOutputDir returns the directory where the Anki file should be -// written. When --anki is set it resolves to the user's home directory. -func (p *Processor) resolveAnkiOutputDir() (string, error) { - if p.flags.GenerateAnki { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return homeDir, nil - } - return p.flags.OutputDir, nil -} - -// populateAnkiGenerator fills the generator with cards. When the translation -// cache is populated it is used as the authoritative source; otherwise the -// generator falls back to scanning the output directory for existing cards. -func (p *Processor) populateAnkiGenerator(gen *anki.Generator, audioFormat string) error { - translations := p.translationCache.GetAll() - if len(translations) == 0 { - fmt.Println(" No translations found in cache, generating cards from directory...") - if err := gen.GenerateFromDirectory(p.flags.OutputDir); err != nil { - return fmt.Errorf("failed to generate cards from directory: %w", err) - } - return nil - } - - fmt.Printf(" Generating cards from %d translations in cache...\n", len(translations)) - for bulgarian, english := range translations { - card := p.buildAnkiCard(bulgarian, english, audioFormat) - gen.AddCard(card) - } - return nil -} - -// buildAnkiCard constructs an anki.Card for a word, resolving all associated -// media files (audio, image, phonetic) from the word's card directory. -func (p *Processor) buildAnkiCard(bulgarian, english, audioFormat string) anki.Card { - card := anki.Card{ - Bulgarian: bulgarian, - Translation: english, - } - - wordDir := p.findCardDirectory(bulgarian) - if wordDir == "" { - return card - } - - cardType := internal.LoadCardType(wordDir) - if cardType.IsBgBg() { - card.AudioFile = anki.ResolveAudioFile(wordDir, "audio_front", audioFormat) - card.AudioFileBack = anki.ResolveAudioFile(wordDir, "audio_back", audioFormat) - } else { - card.AudioFile = anki.ResolveAudioFile(wordDir, "audio", audioFormat) - } - - // Image file (prefer .jpg; the downloader may use other extensions). - imageFile := filepath.Join(wordDir, "image.jpg") - if _, err := os.Stat(imageFile); err == nil { - card.ImageFile = imageFile - } - - // Phonetic notes (newlines replaced with HTML line breaks for Anki). - phoneticFile := filepath.Join(wordDir, "phonetic.txt") - if data, err := os.ReadFile(phoneticFile); err == nil { - notes := strings.TrimSpace(string(data)) - card.Notes = strings.ReplaceAll(notes, "\n", "<br>") - } - - return card -} - -// writeAnkiOutput generates either a CSV or APKG file depending on the -// --anki-csv flag and returns the output path. -func (p *Processor) writeAnkiOutput(gen *anki.Generator, outputDir string) (string, error) { - if p.flags.AnkiCSV { - outputPath := filepath.Join(outputDir, "anki_import.csv") - if err := gen.GenerateCSV(); err != nil { - return "", fmt.Errorf("failed to generate CSV: %w", err) - } - p.printAnkiStats(gen) - return outputPath, nil - } - - outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(p.flags.DeckName))) - if err := gen.GenerateAPKG(outputPath, p.flags.DeckName); err != nil { - return "", fmt.Errorf("failed to generate APKG: %w", err) - } - p.printAnkiStats(gen) - return outputPath, nil -} - -// printAnkiStats logs the card generation statistics to stdout. -func (p *Processor) printAnkiStats(gen *anki.Generator) { - total, withAudio, withImages := gen.Stats() - fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n", total, withAudio, withImages) -} - -// GUIConfig returns a gui.Config populated from the processor's flags and -// Viper settings. Callers (typically cmd/main.go) use this to construct the -// GUI application so that gui.New() lives outside the processor package and -// the processor→gui dependency is limited to the Config type only. -func (p *Processor) GUIConfig() *gui.Config { - imageProvider := p.flags.ImageAPI - if !p.flags.ImageAPISpecified { - imageProvider = gui.DefaultConfig().ImageProvider - } - - openAIKey := cli.GetOpenAIKey() - googleAPIKey := cli.GetGoogleAPIKey() - translationProvider := translation.Provider(p.cfg.TranslationProvider) - phoneticProvider := phonetic.Provider(p.cfg.PhoneticProvider) - - // Construct and inject phonetic/translation dependencies at the composition - // root so gui.New() receives ready-to-use instances rather than raw config strings. - phoneticFetcher := phonetic.NewFetcher(&phonetic.Config{ - Provider: phoneticProvider, - OpenAIKey: openAIKey, - GoogleAPIKey: googleAPIKey, - }) - translator := translation.NewTranslator(&translation.Config{ - Provider: translationProvider, - OpenAIKey: openAIKey, - GeminiModel: p.cfg.TranslationGeminiModel, - }) - - return &gui.Config{ - AudioFormat: p.effectiveAudioFormat(), - AudioProvider: p.audioProviderName(), - ImageProvider: imageProvider, - OpenAIKey: openAIKey, - GoogleAPIKey: googleAPIKey, - NanoBananaModel: p.nanoBananaModelForRunMode(), - NanoBananaTextModel: p.nanoBananaTextModelForRunMode(), - GeminiTTSModel: p.geminiTTSModel(), - GeminiVoice: p.geminiVoice(), - TranslationProvider: translationProvider, - PhoneticProvider: phoneticProvider, - AutoPlay: !p.flags.NoAutoPlay, // Invert the flag (--no-auto-play disables auto-play) - PhoneticFetcher: phoneticFetcher, - Translator: translator, - } -} - -// nanoBananaModelForRunMode resolves the NanoBanana image model, preferring -// the explicit CLI flag value when set, then the config-file value, then the -// package default. -func (p *Processor) nanoBananaModelForRunMode() string { - if p != nil && p.flags != nil && p.flags.NanoBananaModelSpecified { - if model := strings.TrimSpace(p.flags.NanoBananaModel); model != "" { - return model - } - } - if p.cfg.ImageNanoBananaModel != "" { - return p.cfg.ImageNanoBananaModel - } - if p != nil && p.flags != nil { - if model := strings.TrimSpace(p.flags.NanoBananaModel); model != "" { - return model - } - } - return image.DefaultNanoBananaModel -} - -// nanoBananaTextModelForRunMode resolves the NanoBanana text (prompt) model -// using the same CLI-flag-over-config precedence as nanoBananaModelForRunMode. -func (p *Processor) nanoBananaTextModelForRunMode() string { - if p != nil && p.flags != nil && p.flags.NanoBananaTextModelSpecified { - if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { - return model - } - } - if p.cfg.ImageNanoBananaTextModel != "" { - return p.cfg.ImageNanoBananaTextModel - } - if p != nil && p.flags != nil { - if model := strings.TrimSpace(p.flags.NanoBananaTextModel); model != "" { - return model - } - } - return image.DefaultNanoBananaTextModel + return p.ankiExporter.GenerateAnkiFile() } |
