summaryrefslogtreecommitdiff
path: root/internal/processor/processor.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 10:01:57 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 10:01:57 +0300
commit08d7d36ac6d49d7d5efd39a837bc08c9c52b6eb8 (patch)
tree8ce1eecdeb832dadf1d35a4edf496542e13de2a0 /internal/processor/processor.go
parent35a3c860f63403d14c83668dcdd017b6ef517fbf (diff)
refactor(processor): split BatchProcessor, AnkiExporter, CLIConfigResolver
Extract CLIConfigResolver for flag/config precedence and GUI wiring; embed it on Processor so audio and image helpers use promoted accessors. Move batch file flow to BatchProcessor and Anki generation to AnkiExporter; Processor delegates while keeping per-word processing and translation cache. Made-with: Cursor
Diffstat (limited to 'internal/processor/processor.go')
-rw-r--r--internal/processor/processor.go337
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()
}