summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 21:44:28 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 21:44:28 +0300
commite2e75315e5e7c3eaccdc38881bd2fa669bb9dda5 (patch)
treec751e8c9b4d1efd858310fba54451e791c67737b /internal
parent6b0b4c9ce28b88ab0abbff03c5f367d89ba97c89 (diff)
only one img per card
Diffstat (limited to 'internal')
-rw-r--r--internal/anki/generator.go49
-rw-r--r--internal/gui/app.go52
-rw-r--r--internal/gui/generator.go32
-rw-r--r--internal/gui/navigation.go20
-rw-r--r--internal/gui/queue.go6
-rw-r--r--internal/image/download.go84
-rw-r--r--internal/image/openai.go4
7 files changed, 72 insertions, 175 deletions
diff --git a/internal/anki/generator.go b/internal/anki/generator.go
index ad41903..3685e97 100644
--- a/internal/anki/generator.go
+++ b/internal/anki/generator.go
@@ -12,7 +12,7 @@ import (
type Card struct {
Bulgarian string // The Bulgarian word/phrase
AudioFile string // Path to audio file
- ImageFiles []string // Paths to image files
+ ImageFile string // Path to image file
Translation string // Optional translation
Notes string // Optional notes
}
@@ -90,7 +90,7 @@ func (g *Generator) GenerateCSV() error {
record := []string{
card.Bulgarian,
g.formatAudioField(card.AudioFile),
- g.formatImageField(card.ImageFiles),
+ g.formatImageField(card.ImageFile),
card.Translation,
card.Notes,
}
@@ -116,29 +116,15 @@ func (g *Generator) formatAudioField(audioFile string) string {
return fmt.Sprintf("[sound:%s]", filename)
}
-// formatImageField formats image file references for Anki
-func (g *Generator) formatImageField(imageFiles []string) string {
- if len(imageFiles) == 0 {
+// formatImageField formats image file reference for Anki
+func (g *Generator) formatImageField(imageFile string) string {
+ if imageFile == "" {
return ""
}
- // For multiple images, we'll use HTML to display them
- if len(imageFiles) == 1 {
- filename := filepath.Base(imageFiles[0])
- return fmt.Sprintf(`<img src="%s">`, filename)
- }
-
- // Multiple images - create a simple layout
- var html strings.Builder
- html.WriteString(`<div style="display: flex; flex-wrap: wrap; gap: 10px;">`)
-
- for _, imageFile := range imageFiles {
- filename := filepath.Base(imageFile)
- html.WriteString(fmt.Sprintf(`<img src="%s" style="max-width: 200px; height: auto;">`, filename))
- }
-
- html.WriteString(`</div>`)
- return html.String()
+ // Get just the filename
+ filename := filepath.Base(imageFile)
+ return fmt.Sprintf(`<img src="%s">`, filename)
}
// GenerateFromDirectory creates cards from a directory of materials
@@ -179,8 +165,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
card, exists := wordFiles[word]
if !exists {
card = &Card{
- Bulgarian: word,
- ImageFiles: make([]string, 0),
+ Bulgarian: word,
}
wordFiles[word] = card
}
@@ -192,7 +177,9 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
card.AudioFile = path
}
case ".jpg", ".jpeg", ".png", ".gif":
- card.ImageFiles = append(card.ImageFiles, path)
+ if card.ImageFile == "" { // Use first image file found
+ card.ImageFile = path
+ }
}
return nil
@@ -234,16 +221,14 @@ func (g *Generator) GeneratePackage(outputDir string) error {
g.cards[i].AudioFile = newPath
}
- // Copy image files
- newImagePaths := make([]string, 0, len(card.ImageFiles))
- for _, imagePath := range card.ImageFiles {
- newPath, err := g.copyMediaFile(imagePath, mediaDir)
+ // Copy image file
+ if card.ImageFile != "" {
+ newPath, err := g.copyMediaFile(card.ImageFile, mediaDir)
if err != nil {
return fmt.Errorf("failed to copy image file: %w", err)
}
- newImagePaths = append(newImagePaths, newPath)
+ g.cards[i].ImageFile = newPath
}
- g.cards[i].ImageFiles = newImagePaths
}
// Update output path to package directory
@@ -314,7 +299,7 @@ func (g *Generator) Stats() (totalCards, withAudio, withImages int) {
if card.AudioFile != "" {
withAudio++
}
- if len(card.ImageFiles) > 0 {
+ if card.ImageFile != "" {
withImages++
}
}
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 836ebca..3a8dd33 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -51,7 +51,7 @@ type Application struct {
// State management
currentWord string
currentAudioFile string
- currentImages []string
+ currentImage string
currentTranslation string
currentJobID int
savedCards []anki.Card
@@ -81,7 +81,6 @@ type Config struct {
OutputDir string
AudioFormat string
ImageProvider string
- ImagesPerWord int
EnableCache bool
OpenAIKey string
PixabayKey string
@@ -94,7 +93,6 @@ func DefaultConfig() *Config {
OutputDir: "./anki_cards",
AudioFormat: "mp3",
ImageProvider: "openai",
- ImagesPerWord: 1,
EnableCache: true,
}
}
@@ -361,7 +359,7 @@ func (a *Application) generateMaterials(word string) {
// Get custom prompt from UI
customPrompt := a.imagePromptEntry.Text
- images, err := a.generateImagesWithPrompt(word, customPrompt)
+ imageFile, err := a.generateImagesWithPrompt(word, customPrompt)
a.decrementProcessing() // Image processing ends
if err != nil {
@@ -371,10 +369,12 @@ func (a *Application) generateMaterials(word string) {
})
return
}
- a.currentImages = images
- fyne.Do(func() {
- a.imageDisplay.SetImages(images)
- })
+ if imageFile != "" {
+ a.currentImage = imageFile
+ fyne.Do(func() {
+ a.imageDisplay.SetImages([]string{imageFile})
+ })
+ }
// Enable action buttons
fyne.Do(func() {
@@ -388,12 +388,12 @@ func (a *Application) generateMaterials(word string) {
// onKeepAndContinue saves the current card and clears for a new word
func (a *Application) onKeepAndContinue() {
// Check if we have a complete word to save
- if a.currentWord != "" && a.currentAudioFile != "" && len(a.currentImages) > 0 {
+ if a.currentWord != "" && a.currentAudioFile != "" && a.currentImage != "" {
// Save current card
card := anki.Card{
Bulgarian: a.currentWord,
AudioFile: a.currentAudioFile,
- ImageFiles: a.currentImages,
+ ImageFile: a.currentImage,
Translation: a.currentTranslation,
}
@@ -457,16 +457,18 @@ func (a *Application) onRegenerateImage() {
defer a.wg.Done()
defer a.decrementProcessing() // Image processing ends
- images, err := a.generateImagesWithPrompt(a.currentWord, customPrompt)
+ imageFile, err := a.generateImagesWithPrompt(a.currentWord, customPrompt)
if err != nil {
fyne.Do(func() {
a.showError(fmt.Errorf("Image regeneration failed: %w", err))
})
} else {
- a.currentImages = images
- fyne.Do(func() {
- a.imageDisplay.SetImages(images)
- })
+ if imageFile != "" {
+ a.currentImage = imageFile
+ fyne.Do(func() {
+ a.imageDisplay.SetImages([]string{imageFile})
+ })
+ }
}
fyne.Do(func() {
@@ -734,7 +736,7 @@ func (a *Application) processWordJob(job *WordJob) {
})
// Use the custom prompt from the job
- imageFiles, err := a.generateImagesWithPrompt(job.Word, job.CustomPrompt)
+ imageFile, err := a.generateImagesWithPrompt(job.Word, job.CustomPrompt)
a.decrementProcessing() // Image processing ends
if err != nil {
@@ -749,18 +751,22 @@ func (a *Application) processWordJob(job *WordJob) {
a.updateStatus(fmt.Sprintf("Finalizing '%s'...", job.Word))
})
- a.queue.CompleteJob(job.ID, translation, audioFile, imageFiles)
+ a.queue.CompleteJob(job.ID, translation, audioFile, imageFile)
// Update UI with results if this is still the current job
a.mu.Lock()
if a.currentJobID == job.ID {
a.currentTranslation = translation
a.currentAudioFile = audioFile
- a.currentImages = imageFiles
+ if imageFile != "" {
+ a.currentImage = imageFile
+ }
fyne.Do(func() {
a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation))
- a.imageDisplay.SetImages(imageFiles)
+ if imageFile != "" {
+ a.imageDisplay.SetImages([]string{imageFile})
+ }
a.audioPlayer.SetAudioFile(audioFile)
a.hideProgress()
a.setActionButtonsEnabled(true)
@@ -817,7 +823,7 @@ func (a *Application) onJobComplete(job *WordJob) {
// Only update UI if the current word is still empty (waiting for this job)
if job.Word == a.currentWord && job.ID != a.currentJobID {
// Check if the UI is still empty/waiting for content
- hasContent := a.currentAudioFile != "" || len(a.currentImages) > 0
+ hasContent := a.currentAudioFile != "" || a.currentImage != ""
if !hasContent {
// Update the UI with the completed results since it's still waiting
@@ -831,9 +837,9 @@ func (a *Application) onJobComplete(job *WordJob) {
a.audioPlayer.SetAudioFile(job.AudioFile)
a.regenerateAudioBtn.Enable()
}
- if len(job.ImageFiles) > 0 && len(a.currentImages) == 0 {
- a.currentImages = job.ImageFiles
- a.imageDisplay.SetImages(job.ImageFiles)
+ if job.ImageFile != "" && a.currentImage == "" {
+ a.currentImage = job.ImageFile
+ a.imageDisplay.SetImages([]string{job.ImageFile})
a.regenerateImageBtn.Enable()
}
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 9738d88..7a6d26e 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -86,12 +86,12 @@ func (a *Application) generateAudio(word string) (string, error) {
}
// generateImages downloads images for a word
-func (a *Application) generateImages(word string) ([]string, error) {
+func (a *Application) generateImages(word string) (string, error) {
return a.generateImagesWithPrompt(word, "")
}
-// generateImagesWithPrompt downloads images for a word with optional custom prompt
-func (a *Application) generateImagesWithPrompt(word string, customPrompt string) ([]string, error) {
+// generateImagesWithPrompt downloads a single image for a word with optional custom prompt
+func (a *Application) generateImagesWithPrompt(word string, customPrompt string) (string, error) {
// Create image searcher based on provider
var searcher image.ImageSearcher
var err error
@@ -102,11 +102,11 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string)
case "unsplash":
if a.config.UnsplashKey == "" {
- return nil, fmt.Errorf("Unsplash API key is required")
+ return "", fmt.Errorf("Unsplash API key is required")
}
searcher, err = image.NewUnsplashClient(a.config.UnsplashKey)
if err != nil {
- return nil, err
+ return "", err
}
case "openai":
@@ -127,7 +127,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string)
}
default:
- return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
+ return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
}
// Create downloader
@@ -147,20 +147,10 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string)
searchOpts.CustomPrompt = customPrompt
}
- // Download images
- var paths []string
-
- if a.config.ImagesPerWord == 1 {
- _, path, err := downloader.DownloadBestMatchWithOptions(a.ctx, searchOpts)
- if err != nil {
- return nil, err
- }
- paths = []string{path}
- } else {
- paths, err = downloader.DownloadMultipleWithOptions(a.ctx, searchOpts, a.config.ImagesPerWord)
- if err != nil {
- return nil, err
- }
+ // Download single image
+ _, path, err := downloader.DownloadBestMatchWithOptions(a.ctx, searchOpts)
+ if err != nil {
+ return "", err
}
// If using OpenAI, get the last used prompt and update the UI
@@ -175,7 +165,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string)
}
}
- return paths, nil
+ return path, nil
}
// saveAudioAttribution saves attribution info for generated audio
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index 05bf988..bc65c8f 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -175,7 +175,7 @@ func (a *Application) loadWordByIndex(index int) {
// Load from queue job
a.currentTranslation = job.Translation
a.currentAudioFile = job.AudioFile
- a.currentImages = job.ImageFiles
+ a.currentImage = job.ImageFile
fyne.Do(func() {
if job.Translation != "" {
@@ -184,8 +184,8 @@ func (a *Application) loadWordByIndex(index int) {
if job.AudioFile != "" {
a.audioPlayer.SetAudioFile(job.AudioFile)
}
- if len(job.ImageFiles) > 0 {
- a.imageDisplay.SetImages(job.ImageFiles)
+ if job.ImageFile != "" {
+ a.imageDisplay.SetImages([]string{job.ImageFile})
}
a.updateStatus(fmt.Sprintf("Loaded from queue: %s", word))
})
@@ -234,8 +234,8 @@ func (a *Application) loadExistingFiles(word string) {
})
}
- // Load image files
- a.currentImages = []string{}
+ // Load image file
+ a.currentImage = ""
// Try to find images with different patterns
patterns := []string{
fmt.Sprintf("%s.jpg", sanitized),
@@ -249,20 +249,20 @@ func (a *Application) loadExistingFiles(word string) {
for _, pattern := range patterns {
imagePath := filepath.Join(a.config.OutputDir, pattern)
if _, err := os.Stat(imagePath); err == nil {
- a.currentImages = append(a.currentImages, imagePath)
+ a.currentImage = imagePath
break // Just load the first image found
}
}
- if len(a.currentImages) > 0 {
+ if a.currentImage != "" {
fyne.Do(func() {
- a.imageDisplay.SetImages(a.currentImages)
+ a.imageDisplay.SetImages([]string{a.currentImage})
})
// Try to load the prompt from attribution file if using OpenAI
- if a.config.ImageProvider == "openai" && len(a.currentImages) > 0 {
+ if a.config.ImageProvider == "openai" {
// Look for attribution file
- baseImagePath := a.currentImages[0]
+ baseImagePath := a.currentImage
attrPath := strings.TrimSuffix(baseImagePath, filepath.Ext(baseImagePath)) + "_attribution.txt"
if data, err := os.ReadFile(attrPath); err == nil {
// Parse prompt from attribution file
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
index aaa0c55..df39908 100644
--- a/internal/gui/queue.go
+++ b/internal/gui/queue.go
@@ -13,7 +13,7 @@ type WordJob struct {
Word string
Translation string
AudioFile string
- ImageFiles []string
+ ImageFile string // Changed from ImageFiles []string to single image
Status JobStatus
Error error
StartedAt time.Time
@@ -194,7 +194,7 @@ func (q *WordQueue) Stop() {
}
// CompleteJob marks a job as completed with results
-func (q *WordQueue) CompleteJob(jobID int, translation, audioFile string, imageFiles []string) {
+func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, imageFile string) {
q.mu.Lock()
defer q.mu.Unlock()
@@ -202,7 +202,7 @@ func (q *WordQueue) CompleteJob(jobID int, translation, audioFile string, imageF
job.Status = StatusCompleted
job.Translation = translation
job.AudioFile = audioFile
- job.ImageFiles = imageFiles
+ job.ImageFile = imageFile
job.CompletedAt = time.Now()
delete(q.processing, jobID)
diff --git a/internal/image/download.go b/internal/image/download.go
index 7083a6f..fabb1bb 100644
--- a/internal/image/download.go
+++ b/internal/image/download.go
@@ -200,48 +200,6 @@ func sanitizeFileName(name string) string {
return sanitized
}
-// DownloadMultiple downloads multiple images for a query
-func (d *Downloader) DownloadMultiple(ctx context.Context, query string, count int) ([]string, error) {
- // Search for images
- opts := DefaultSearchOptions(query)
- opts.PerPage = count * 2 // Get extra in case some fail
-
- results, err := d.searcher.Search(ctx, opts)
- if err != nil {
- return nil, fmt.Errorf("search failed: %w", err)
- }
-
- if len(results) == 0 {
- return nil, fmt.Errorf("no images found for query: %s", query)
- }
-
- // Download up to 'count' images
- var downloaded []string
- for i, result := range results {
- if len(downloaded) >= count {
- break
- }
-
- // Generate filename
- filename := d.generateFileName(query, &result, i)
- outputPath := filepath.Join(d.options.OutputDir, filename)
-
- // Try to download
- err := d.DownloadImage(ctx, &result, outputPath)
- if err == nil {
- downloaded = append(downloaded, outputPath)
- } else {
- // Log error and continue
- fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
- }
- }
-
- if len(downloaded) == 0 {
- return nil, fmt.Errorf("failed to download any images for query: %s", query)
- }
-
- return downloaded, nil
-}
// DownloadBestMatchWithOptions downloads the best matching image for given search options
func (d *Downloader) DownloadBestMatchWithOptions(ctx context.Context, opts *SearchOptions) (*SearchResult, string, error) {
@@ -277,45 +235,3 @@ func (d *Downloader) DownloadBestMatchWithOptions(ctx context.Context, opts *Sea
return nil, "", fmt.Errorf("failed to download any images for query: %s", opts.Query)
}
-// DownloadMultipleWithOptions downloads multiple images for given search options
-func (d *Downloader) DownloadMultipleWithOptions(ctx context.Context, opts *SearchOptions, count int) ([]string, error) {
- // Search for images
- searchOpts := *opts // Copy to avoid modifying original
- searchOpts.PerPage = count * 2 // Get extra in case some fail
-
- results, err := d.searcher.Search(ctx, &searchOpts)
- if err != nil {
- return nil, fmt.Errorf("search failed: %w", err)
- }
-
- if len(results) == 0 {
- return nil, fmt.Errorf("no images found for query: %s", opts.Query)
- }
-
- // Download up to 'count' images
- var downloaded []string
- for i, result := range results {
- if len(downloaded) >= count {
- break
- }
-
- // Generate filename
- filename := d.generateFileName(opts.Query, &result, i)
- outputPath := filepath.Join(d.options.OutputDir, filename)
-
- // Try to download
- err := d.DownloadImage(ctx, &result, outputPath)
- if err == nil {
- downloaded = append(downloaded, outputPath)
- } else {
- // Log error and continue
- fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
- }
- }
-
- if len(downloaded) == 0 {
- return nil, fmt.Errorf("failed to download any images for query: %s", opts.Query)
- }
-
- return downloaded, nil
-} \ No newline at end of file
diff --git a/internal/image/openai.go b/internal/image/openai.go
index 2fbb043..dc82b90 100644
--- a/internal/image/openai.go
+++ b/internal/image/openai.go
@@ -261,7 +261,7 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
if creativeStyle := c.getCreativeStyleFromOpenAI(context.Background(), englishTranslation); creativeStyle != "" {
fmt.Printf(" Using OpenAI-suggested style: %s\n", creativeStyle)
return fmt.Sprintf(
- "Generate %s of: %s. "+
+ "Generate %s of %s. "+
"The image should be educational and suitable for language learning flashcards. "+
"Requirements: single main subject, plain background, clear and recognizable. "+
"IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",
@@ -331,7 +331,7 @@ func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation
// Create a simple, clear prompt for educational images
return fmt.Sprintf(
- "Generate a %s of: %s. "+
+ "Generate a %s of %s. "+
"The image should be educational and suitable for language learning flashcards. "+
"Requirements: single main subject, plain background, clear and recognizable. "+
"IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.",