summaryrefslogtreecommitdiff
path: root/internal/processor/processor.go
blob: 6ef09649f691fbff1ff8044c1ec3c96b697b8541 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
package processor

import (
	"context"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"

	"codeberg.org/snonux/totalrecall/internal"
	"codeberg.org/snonux/totalrecall/internal/audio"
	"codeberg.org/snonux/totalrecall/internal/cli"
	"codeberg.org/snonux/totalrecall/internal/httpctx"
	"codeberg.org/snonux/totalrecall/internal/image"
	"codeberg.org/snonux/totalrecall/internal/phonetic"
	"codeberg.org/snonux/totalrecall/internal/store"
	"codeberg.org/snonux/totalrecall/internal/translation"
)

// Config holds all configuration-file values resolved once at startup by the
// composition root (cmd/totalrecall/main.go via cli.NewProcessorConfig).
// Keeping config in a dedicated struct means the processor package never
// imports or queries Viper directly, which improves testability and removes
// tight coupling to the global Viper singleton.
type Config struct {
	// Translation & phonetic settings
	TranslationProvider    string
	PhoneticProvider       string
	TranslationGeminiModel string

	// Audio settings
	AudioProvider        string
	AudioFormat          string
	AudioFormatSet       bool
	GeminiTTSModel       string
	GeminiVoice          string
	OpenAIVoice          string
	OpenAIModel          string
	OpenAIModelSet       bool
	OpenAISpeed          float64
	OpenAISpeedSet       bool
	OpenAIInstruction    string
	OpenAIInstructionSet bool

	// Image settings
	ImageProvider               string
	ImageOpenAIModel            string
	ImageOpenAIModelSet         bool
	ImageOpenAISize             string
	ImageOpenAISizeSet          bool
	ImageOpenAIQuality          string
	ImageOpenAIQualitySet       bool
	ImageOpenAIStyle            string
	ImageOpenAIStyleSet         bool
	ImageNanoBananaModel        string
	ImageNanoBananaModelSet     bool
	ImageNanoBananaTextModel    string
	ImageNanoBananaTextModelSet bool
}

// 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 {
	*CLIConfigResolver
	translator       *translation.Translator
	translationCache *translation.TranslationCache
	phoneticFetcher  *phonetic.Fetcher
	randomIntn       func(n int) int

	// cardStore is the shared CardStore for locating and creating on-disk
	// card directories. It is initialised from flags.OutputDir in NewProcessor
	// and used by all card_store.go helpers.
	cardStore *store.CardStore

	// imageFactories groups the two image-provider construction functions.
	// Production code uses image.DefaultClientFactories(); tests replace fields.
	imageFactories image.ClientFactories

	// 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.
// cfg must be fully resolved before calling NewProcessor; the composition root
// (cmd/totalrecall/main.go) builds it via cli.NewProcessorConfig() so that
// the processor package never imports or queries Viper.
// Tests can replace the factory fields on the returned struct to inject fakes.
func NewProcessor(flags *cli.Flags, cfg *Config) *Processor {
	openAIKey := cli.GetOpenAIKey()
	googleAPIKey := cli.GetGoogleAPIKey()
	translationProvider := translation.Provider(cfg.TranslationProvider)
	phoneticProvider := phonetic.Provider(cfg.PhoneticProvider)
	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:        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.
func (p *Processor) ProcessBatch() error {
	return p.batchProcessor.ProcessBatch()
}

// ProcessSingleWord validates and processes a single word from the command line.
func (p *Processor) ProcessSingleWord(word string) error {
	if err := audio.ValidateBulgarianText(word); err != nil {
		return fmt.Errorf("invalid word '%s': %w", word, err)
	}

	if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil {
		return fmt.Errorf("failed to create output directory: %w", err)
	}

	fmt.Printf("\nProcessing: %s\n", word)
	return p.ProcessWordWithTranslation(word, "")
}

// 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 {
	ctx, cancel := context.WithTimeout(context.Background(), httpctx.SingleWordProcessTimeout)
	defer cancel()
	return p.ProcessWordWithTranslationAndType(ctx, 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 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 {
	translationText := p.resolveTranslation(ctx, word, providedTranslation, cardType)

	wordDir := p.findOrCreateWordDirectory(word)

	if err := internal.SaveCardType(wordDir, cardType); err != nil {
		return fmt.Errorf("failed to save card type: %w", err)
	}

	if err := p.saveTranslationIfNeeded(word, translationText, wordDir); err != nil {
		return fmt.Errorf("failed to save translation: %w", err)
	}

	fmt.Printf("  Fetching phonetic information...\n")
	if err := p.phoneticFetcher.FetchAndSave(word, wordDir); err != nil {
		return fmt.Errorf("failed to fetch phonetic info: %w", err)
	}
	fmt.Printf("  Saved phonetic information\n")

	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 {
		fmt.Printf("  Downloading images...\n")
		if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil {
			return fmt.Errorf("image download failed: %w", err)
		}
	}

	return nil
}

// 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 cardType.IsBgBg() {
		return ""
	}

	fmt.Printf("  Translating to English...\n")
	translationText, err := p.translator.TranslateWord(word)
	if err != nil {
		fmt.Printf("  Warning: Translation failed: %v\n", err)
		return ""
	}
	fmt.Printf("  Translation: %s\n", translationText)
	return translationText
}

// 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
	}

	p.translationCache.Add(word, translationText)

	translationFile := filepath.Join(wordDir, "translation.txt")
	if _, err := os.Stat(translationFile); os.IsNotExist(err) {
		return translation.SaveTranslation(wordDir, word, translationText)
	}

	fmt.Printf("  Translation file already exists\n")
	return nil
}

// 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)
}

// GenerateAnkiFile generates the Anki import file and returns the output path.
func (p *Processor) GenerateAnkiFile() (string, error) {
	return p.ankiExporter.GenerateAnkiFile()
}