summaryrefslogtreecommitdiff
path: root/internal/processor/image_downloader.go
blob: fa0f6cffcf4a7f8ddef8a3bed7ca8ea6fc14dfa3 (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
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"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"codeberg.org/snonux/totalrecall/internal/cli"
	"codeberg.org/snonux/totalrecall/internal/image"
	"codeberg.org/snonux/totalrecall/internal/registry"
)

// 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 {
	return p.downloadImagesWithPrompt(ctx, word, translationText, "")
}

// downloadImagesWithPrompt downloads images for a word and optionally reuses a
// previously-saved prompt. When customPrompt is empty the provider generates a
// fresh prompt as usual.
func (p *Processor) downloadImagesWithPrompt(ctx context.Context, word, translationText, customPrompt 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
	}
	if strings.TrimSpace(customPrompt) != "" {
		searchOpts.CustomPrompt = strings.TrimSpace(customPrompt)
	}

	// Register a prompt callback so the AI-generated prompt is persisted
	// to disk before the download completes (used by the GUI and for debugging).
	var promptSaveErr error
	p.registerPromptCallback(searcher, wordDir, &promptSaveErr)

	_, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts)
	if err != nil {
		return errors.Join(err, promptSaveErr)
	}
	fmt.Printf("    Downloaded: %s\n", path)

	if promptSaveErr != nil {
		return promptSaveErr
	}

	// Persist the final prompt used by the searcher (some providers set it
	// only after the search call; this handles that case as a fallback).
	if err := p.saveImagePrompt(wordDir, searcher); err != nil {
		return err
	}

	return nil
}

// registerPromptCallback wires a prompt-save callback into the searcher. The
// callback fires during the Search call so the prompt is captured even if the
// subsequent download fails. All searchers returned by newImageSearcher
// implement image.PromptAwareClient, so no type-assertion is needed.
// promptErr accumulates write failures so downloadImagesWithTranslation can
// return them to the caller instead of only logging.
func (p *Processor) registerPromptCallback(searcher image.PromptAwareClient, wordDir string, promptErr *error) {
	promptFile := filepath.Join(wordDir, "image_prompt.txt")
	searcher.SetPromptCallback(func(prompt string) {
		if prompt == "" {
			return
		}
		if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil {
			if promptErr != nil {
				*promptErr = errors.Join(*promptErr, fmt.Errorf("failed to save image prompt: %w", 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. The local promptGetter interface
// is intentionally narrow: not all PromptAwareClients expose GetLastPrompt.
func (p *Processor) saveImagePrompt(wordDir string, searcher image.PromptAwareClient) error {
	type promptGetter interface {
		GetLastPrompt() string
	}

	promptSource, ok := searcher.(promptGetter)
	if !ok {
		return nil
	}

	usedPrompt := promptSource.GetLastPrompt()
	if usedPrompt == "" {
		return nil
	}

	promptFile := filepath.Join(wordDir, "image_prompt.txt")
	if err := os.WriteFile(promptFile, []byte(usedPrompt), 0644); err != nil {
		return fmt.Errorf("failed to save image prompt: %w", err)
	}
	return nil
}

// processorImageClientFactories maps run-mode image provider name to builder.
// Register new backends here instead of extending a switch in newImageSearcher.
var processorImageClientFactories = func() *registry.Registry[string, func(*Processor) (image.PromptAwareClient, error)] {
	r := registry.New[string, func(*Processor) (image.PromptAwareClient, error)]()
	r.Register(image.ImageProviderOpenAI, (*Processor).newOpenAIImageSearcher)
	r.Register(image.ImageProviderNanoBanana, (*Processor).newNanoBananaImageSearcher)
	return r
}()

// newImageSearcher creates the appropriate PromptAwareClient based on the
// configured image provider (openai or nanobanana). Returning PromptAwareClient
// instead of ImageClient means callers can call SetPromptCallback directly
// without a type-assertion.
func (p *Processor) newImageSearcher() (image.PromptAwareClient, error) {
	key := strings.ToLower(strings.TrimSpace(p.imageProviderForRunMode()))
	fn, ok := processorImageClientFactories.Get(key)
	if !ok {
		return nil, fmt.Errorf("unknown image provider: %s", p.imageProviderForRunMode())
	}
	return fn(p)
}

// imageProviderForRunMode resolves the image provider, giving precedence to
// the CLI flag when it was explicitly set, then the config-file value.
func (p *Processor) imageProviderForRunMode() string {
	if p.Flags.ImageAPISpecified {
		return strings.ToLower(strings.TrimSpace(p.Flags.ImageAPI))
	}
	if p.Config.ImageProvider != "" {
		return p.Config.ImageProvider
	}
	return strings.ToLower(strings.TrimSpace(p.Flags.ImageAPI))
}

// newOpenAIImageSearcher builds an OpenAI PromptAwareClient from CLI flags and
// the resolved processor Config. Config-file overrides are applied only when
// the flag still holds its default value so explicit CLI flags always win.
func (p *Processor) newOpenAIImageSearcher() (image.PromptAwareClient, error) {
	openaiConfig := &image.OpenAIConfig{
		APIKey:  cli.GetOpenAIKey(),
		Model:   p.Flags.OpenAIImageModel,
		Size:    p.Flags.OpenAIImageSize,
		Quality: p.Flags.OpenAIImageQuality,
		Style:   p.Flags.OpenAIImageStyle,
	}

	// Apply config-file overrides when CLI flag holds its zero/default value.
	if p.Flags.OpenAIImageModel == "dall-e-2" && p.Config.ImageOpenAIModelSet {
		openaiConfig.Model = p.Config.ImageOpenAIModel
	}
	if p.Flags.OpenAIImageSize == "512x512" && p.Config.ImageOpenAISizeSet {
		openaiConfig.Size = p.Config.ImageOpenAISize
	}
	if p.Flags.OpenAIImageQuality == "standard" && p.Config.ImageOpenAIQualitySet {
		openaiConfig.Quality = p.Config.ImageOpenAIQuality
	}
	if p.Flags.OpenAIImageStyle == "natural" && p.Config.ImageOpenAIStyleSet {
		openaiConfig.Style = p.Config.ImageOpenAIStyle
	}

	if openaiConfig.APIKey == "" {
		return nil, fmt.Errorf("OpenAI API key is required for image generation")
	}

	return p.imageFactories.NewOpenAIClient(openaiConfig), nil
}

// newNanoBananaImageSearcher builds a NanoBanana PromptAwareClient from CLI
// flags and the resolved processor Config, applying overrides in the same
// flag-wins-over-config pattern.
func (p *Processor) newNanoBananaImageSearcher() (image.PromptAwareClient, error) {
	nanoBananaConfig := &image.NanoBananaConfig{
		APIKey:    cli.GetGoogleAPIKey(),
		Model:     p.Flags.NanoBananaModel,
		TextModel: p.Flags.NanoBananaTextModel,
	}

	if !p.Flags.NanoBananaModelSpecified && p.Config.ImageNanoBananaModelSet {
		nanoBananaConfig.Model = p.Config.ImageNanoBananaModel
	}
	if !p.Flags.NanoBananaTextModelSpecified && p.Config.ImageNanoBananaTextModelSet {
		nanoBananaConfig.TextModel = p.Config.ImageNanoBananaTextModel
	}

	if nanoBananaConfig.APIKey == "" {
		return nil, fmt.Errorf("google API key is required for image generation")
	}

	return p.imageFactories.NewNanoBananaClient(nanoBananaConfig), nil
}