summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-17 14:45:55 +0300
committerPaul Buetow <paul@buetow.org>2025-07-17 14:45:55 +0300
commit094447b570c5c5a7c751e0e60279cfa08e945755 (patch)
tree0d4b0a486e5bd48cf6e81f2faa2b9160be0ee0fc /internal
parent81dabe63bbd5c90819dff5219c0d81880b3bdc8a (diff)
feat: add manual translation editing and bidirectional translation
- Replace translation label with editable entry field - Move translation field next to Bulgarian input for better UX - Add bidirectional translation support: - Bulgarian → English: auto-translate when only Bulgarian provided - English → Bulgarian: auto-translate when only English provided - No translation when both fields are filled - Show translations immediately upon generation - Auto-save translations when edited - Keep input fields populated during processing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/gui/app.go192
-rw-r--r--internal/gui/generator.go33
-rw-r--r--internal/gui/navigation.go4
-rw-r--r--internal/gui/queue.go21
4 files changed, 189 insertions, 61 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 37c7b9d..a586d73 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
"fyne.io/fyne/v2"
@@ -31,7 +32,7 @@ type Application struct {
submitButton *widget.Button
imageDisplay *ImageDisplay
audioPlayer *AudioPlayer
- translationText *widget.Label
+ translationEntry *widget.Entry
progressBar *widget.ProgressBar
statusLabel *widget.Label
queueStatusLabel *widget.Label
@@ -150,25 +151,39 @@ func (a *Application) setupUI() {
// Create input section with navigation
a.wordInput = widget.NewEntry()
- a.wordInput.SetPlaceHolder("Enter Bulgarian word...")
+ a.wordInput.SetPlaceHolder("Bulgarian word...")
a.wordInput.OnSubmitted = func(string) { a.onSubmit() }
+ // Create translation entry
+ a.translationEntry = widget.NewEntry()
+ a.translationEntry.SetPlaceHolder("English translation...")
+ a.translationEntry.OnChanged = func(text string) {
+ a.currentTranslation = text
+ // Save the updated translation immediately
+ a.saveTranslation()
+ }
+ a.translationEntry.OnSubmitted = func(string) { a.onSubmit() }
+
a.submitButton = widget.NewButton("Generate (g)", a.onSubmit)
a.prevWordBtn = widget.NewButton("◀ Prev (←)", a.onPrevWord)
a.nextWordBtn = widget.NewButton("Next (→) ▶", a.onNextWord)
+ // Create a grid layout for inputs
+ inputGrid := container.New(layout.NewGridLayout(2),
+ a.wordInput,
+ a.translationEntry,
+ )
+
inputSection := container.NewBorder(
nil, nil,
a.prevWordBtn,
container.NewHBox(a.submitButton, a.nextWordBtn),
- a.wordInput,
+ inputGrid,
)
// Create display section
a.imageDisplay = NewImageDisplay()
a.audioPlayer = NewAudioPlayer()
- a.translationText = widget.NewLabel("")
- a.translationText.Alignment = fyne.TextAlignCenter
// Create image prompt entry
a.imagePromptEntry = widget.NewMultiLineEntry()
@@ -192,7 +207,7 @@ func (a *Application) setupUI() {
imageSection.SetOffset(0.5) // Equal 50/50 split
displaySection := container.NewBorder(
- a.translationText,
+ nil,
a.audioPlayer,
nil, nil,
imageSection,
@@ -279,13 +294,66 @@ func (a *Application) Run() {
// onSubmit handles word submission
func (a *Application) onSubmit() {
- word := a.wordInput.Text
- if word == "" {
+ bulgarianText := strings.TrimSpace(a.wordInput.Text)
+ englishText := strings.TrimSpace(a.translationEntry.Text)
+
+ // Determine which word to process and if translation is needed
+ var wordToProcess string
+ var needsTranslation bool
+ var translationDirection string
+
+ if bulgarianText != "" && englishText != "" {
+ // Both provided - use Bulgarian as primary, no translation needed
+ wordToProcess = bulgarianText
+ needsTranslation = false
+ a.currentTranslation = englishText
+ } else if bulgarianText != "" && englishText == "" {
+ // Only Bulgarian provided - translate to English
+ wordToProcess = bulgarianText
+ needsTranslation = true
+ translationDirection = "bg-to-en"
+ } else if bulgarianText == "" && englishText != "" {
+ // Only English provided - translate to Bulgarian
+ needsTranslation = true
+ translationDirection = "en-to-bg"
+ // We'll get the Bulgarian word after translation
+ } else {
+ // Both empty
return
}
+ // Handle English to Bulgarian translation first if needed
+ if translationDirection == "en-to-bg" {
+ a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", englishText))
+ bulgarian, err := a.translateEnglishToBulgarian(englishText)
+ if err != nil {
+ dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window)
+ return
+ }
+ wordToProcess = bulgarian
+ a.wordInput.SetText(bulgarian)
+ a.currentTranslation = englishText
+ // Update current word for saving
+ a.currentWord = bulgarian
+ // Save the translation immediately
+ a.saveTranslation()
+ } else if translationDirection == "bg-to-en" {
+ // Handle Bulgarian to English translation immediately
+ a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText))
+ english, err := a.translateWord(bulgarianText)
+ if err != nil {
+ dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window)
+ return
+ }
+ a.currentTranslation = english
+ a.translationEntry.SetText(english)
+ needsTranslation = false // We've already done the translation
+ // Save the translation immediately
+ a.saveTranslation()
+ }
+
// Validate Bulgarian text
- if err := audio.ValidateBulgarianText(word); err != nil {
+ if err := audio.ValidateBulgarianText(wordToProcess); err != nil {
dialog.ShowError(err, a.window)
return
}
@@ -294,13 +362,19 @@ func (a *Application) onSubmit() {
customPrompt := a.imagePromptEntry.Text
// Add word to processing queue with custom prompt
- job := a.queue.AddWordWithPrompt(word, customPrompt)
+ job := a.queue.AddWordWithPrompt(wordToProcess, customPrompt)
- // Clear the input field for next word
- a.wordInput.SetText("")
+ // Store whether translation is needed and the translation if already provided
+ job.NeedsTranslation = needsTranslation
+ if a.currentTranslation != "" {
+ job.Translation = a.currentTranslation
+ }
+
+ // Don't clear the input fields yet - they should stay populated
+ // until the user is ready to enter a new word
// Update status to show word was queued
- a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", word, job.ID))
+ a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID))
// Update queue status immediately
a.updateQueueStatus()
@@ -311,22 +385,25 @@ func (a *Application) onSubmit() {
// generateMaterials generates all materials for a word (used by regenerate functions)
func (a *Application) generateMaterials(word string) {
- // Translate word
- fyne.Do(func() {
- a.updateStatus("Translating...")
- })
- translation, err := a.translateWord(word)
- if err != nil {
+ // Check if we already have a translation
+ if a.currentTranslation == "" {
+ // Translate word
fyne.Do(func() {
- a.showError(fmt.Errorf("Translation failed: %w", err))
- a.setUIEnabled(true)
+ a.updateStatus("Translating...")
+ })
+ translation, err := a.translateWord(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Translation failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentTranslation = translation
+ fyne.Do(func() {
+ a.translationEntry.SetText(translation)
})
- return
}
- a.currentTranslation = translation
- fyne.Do(func() {
- a.translationText.SetText(fmt.Sprintf("%s = %s", word, translation))
- })
// Generate audio
fyne.Do(func() {
@@ -401,12 +478,7 @@ func (a *Application) onKeepAndContinue() {
a.mu.Unlock()
// Save translation file for future navigation
- if a.currentTranslation != "" {
- filename := sanitizeFilename(a.currentWord)
- translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
- content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation)
- os.WriteFile(translationFile, []byte(content), 0644)
- }
+ a.saveTranslation()
// Rescan existing words to include the new one
a.scanExistingWords()
@@ -425,9 +497,10 @@ func (a *Application) onKeepAndContinue() {
a.updateStatus("Previous word continues processing in background")
}
- // Clear UI for next word
+ // Clear UI and input fields for next word
a.clearUI()
a.wordInput.SetText("")
+ a.translationEntry.SetText("")
a.wordInput.FocusGained() // Focus input for next word
// Hide progress bar if it was showing
@@ -635,7 +708,7 @@ func (a *Application) showError(err error) {
func (a *Application) clearUI() {
a.imageDisplay.Clear()
a.audioPlayer.Clear()
- a.translationText.SetText("")
+ // Don't clear the word input or translation entry - they should stay populated
a.imagePromptEntry.SetText("")
a.setActionButtonsEnabled(false)
}
@@ -676,24 +749,33 @@ func (a *Application) processNextInQueue() {
// processWordJob processes a single word job
func (a *Application) processWordJob(job *WordJob) {
- // Translate word
- fyne.Do(func() {
- a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word))
- })
+ // Handle translation
+ var translation string
+ var err error
- translation, err := a.translateWord(job.Word)
- if err != nil {
- a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err))
- a.finishCurrentJob()
- return
+ if job.NeedsTranslation {
+ // Translate word
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word))
+ })
+
+ translation, err = a.translateWord(job.Word)
+ if err != nil {
+ a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err))
+ a.finishCurrentJob()
+ return
+ }
+ } else if job.Translation != "" {
+ // Use provided translation
+ translation = job.Translation
}
// Update UI with translation immediately if this is still the current job
a.mu.Lock()
- if a.currentJobID == job.ID {
+ if a.currentJobID == job.ID && translation != "" {
a.currentTranslation = translation
fyne.Do(func() {
- a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation))
+ a.translationEntry.SetText(translation)
})
}
a.mu.Unlock()
@@ -761,7 +843,7 @@ func (a *Application) processWordJob(job *WordJob) {
}
fyne.Do(func() {
- a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation))
+ a.translationEntry.SetText(translation)
if imageFile != "" {
a.imageDisplay.SetImages([]string{imageFile})
}
@@ -828,7 +910,7 @@ func (a *Application) onJobComplete(job *WordJob) {
// Update each component individually to show progress
if job.Translation != "" && a.currentTranslation == "" {
a.currentTranslation = job.Translation
- a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, job.Translation))
+ a.translationEntry.SetText(job.Translation)
}
if job.AudioFile != "" && a.currentAudioFile == "" {
a.currentAudioFile = job.AudioFile
@@ -910,8 +992,9 @@ func (a *Application) decrementProcessing() {
func (a *Application) setupKeyboardShortcuts() {
// Create a custom shortcut handler
a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) {
- // Don't process shortcuts if the word input is focused
- if a.window.Canvas().Focused() == a.wordInput || a.window.Canvas().Focused() == a.imagePromptEntry {
+ // Don't process shortcuts if any input field is focused
+ focused := a.window.Canvas().Focused()
+ if focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry {
return
}
@@ -980,3 +1063,14 @@ func (a *Application) setupKeyboardShortcuts() {
})
}
+
+// saveTranslation saves the current translation to a file
+func (a *Application) saveTranslation() {
+ if a.currentWord != "" && a.currentTranslation != "" {
+ filename := sanitizeFilename(a.currentWord)
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
+ content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation)
+ os.WriteFile(translationFile, []byte(content), 0644)
+ }
+}
+
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 86b7013..0d30f79 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -48,6 +48,39 @@ func (a *Application) translateWord(word string) (string, error) {
return translation, nil
}
+// translateEnglishToBulgarian translates an English word to Bulgarian
+func (a *Application) translateEnglishToBulgarian(word string) (string, error) {
+ if a.config.OpenAIKey == "" {
+ return "", fmt.Errorf("OpenAI API key not configured")
+ }
+
+ client := openai.NewClient(a.config.OpenAIKey)
+
+ req := openai.ChatCompletionRequest{
+ Model: openai.GPT4oMini,
+ Messages: []openai.ChatCompletionMessage{
+ {
+ Role: openai.ChatMessageRoleUser,
+ Content: fmt.Sprintf("Translate the English word '%s' to Bulgarian. Respond with only the Bulgarian translation in Cyrillic script, nothing else.", word),
+ },
+ },
+ MaxTokens: 50,
+ Temperature: 0.3,
+ }
+
+ resp, err := client.CreateChatCompletion(a.ctx, req)
+ if err != nil {
+ return "", fmt.Errorf("OpenAI API error: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no translation returned")
+ }
+
+ translation := strings.TrimSpace(resp.Choices[0].Message.Content)
+ return translation, nil
+}
+
// generateAudio generates audio for a word
func (a *Application) generateAudio(word string) (string, error) {
// Get available voices
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index bc65c8f..59c656e 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -179,7 +179,7 @@ func (a *Application) loadWordByIndex(index int) {
fyne.Do(func() {
if job.Translation != "" {
- a.translationText.SetText(fmt.Sprintf("%s = %s", word, job.Translation))
+ a.translationEntry.SetText(job.Translation)
}
if job.AudioFile != "" {
a.audioPlayer.SetAudioFile(job.AudioFile)
@@ -220,7 +220,7 @@ func (a *Application) loadExistingFiles(word string) {
if len(parts) >= 2 {
a.currentTranslation = strings.TrimSpace(parts[1])
fyne.Do(func() {
- a.translationText.SetText(fmt.Sprintf("%s = %s", word, a.currentTranslation))
+ a.translationEntry.SetText(a.currentTranslation)
})
}
}
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
index df39908..f72b059 100644
--- a/internal/gui/queue.go
+++ b/internal/gui/queue.go
@@ -9,16 +9,17 @@ import (
// WordJob represents a single word processing job
type WordJob struct {
- ID int
- Word string
- Translation string
- AudioFile string
- ImageFile string // Changed from ImageFiles []string to single image
- Status JobStatus
- Error error
- StartedAt time.Time
- CompletedAt time.Time
- CustomPrompt string // Custom prompt for image generation
+ ID int
+ Word string
+ Translation string
+ AudioFile string
+ ImageFile string // Changed from ImageFiles []string to single image
+ Status JobStatus
+ Error error
+ StartedAt time.Time
+ CompletedAt time.Time
+ CustomPrompt string // Custom prompt for image generation
+ NeedsTranslation bool // Whether translation is needed
}
// JobStatus represents the current state of a job