summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 13:13:38 +0300
commit7187e7464f16a9d2991ba2da3c672fdb3cf5de72 (patch)
tree208d8e301dc55512a078f836f4f0c9ad2a927427 /internal
parentb105333c061ea165b3b79317415cbb8b9cfb7c75 (diff)
feat: add Fyne GUI mode with interactive flashcard management
- Add --gui flag to launch interactive GUI mode - Implement word navigation with prev/next buttons through existing cards - Add delete functionality to remove unwanted flashcards - Add fine-grained regeneration (image-only, audio-only, or both) - Implement audio playback using mpg123 on Linux - Auto-load first word on startup if cards exist - Save translation files for navigation persistence - Use DALL-E 2 with 512x512 images (half size) - Update audio speed to 0.9 (from 0.8) - Add comprehensive GUI documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/audio/provider.go2
-rw-r--r--internal/gui/app.go539
-rw-r--r--internal/gui/audio_player.go168
-rw-r--r--internal/gui/generator.go198
-rw-r--r--internal/gui/navigation.go268
-rw-r--r--internal/gui/widgets.go122
6 files changed, 1296 insertions, 1 deletions
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
index 3508121..fd47ef4 100644
--- a/internal/audio/provider.go
+++ b/internal/audio/provider.go
@@ -43,7 +43,7 @@ func DefaultProviderConfig() *Config {
OutputFormat: "mp3",
OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support
OpenAIVoice: "nova",
- OpenAISpeed: 0.8, // Slightly slower for clarity (note: may be ignored by gpt-4o-mini-tts)
+ OpenAISpeed: 0.9, // Slightly slower for clarity (note: may be ignored by gpt-4o-mini-tts)
OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.",
EnableCache: true,
CacheDir: "./.audio_cache",
diff --git a/internal/gui/app.go b/internal/gui/app.go
new file mode 100644
index 0000000..73dcd37
--- /dev/null
+++ b/internal/gui/app.go
@@ -0,0 +1,539 @@
+package gui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/app"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/widget"
+
+ "codeberg.org/snonux/totalrecall/internal/anki"
+ "codeberg.org/snonux/totalrecall/internal/audio"
+)
+
+// Application represents the main GUI application
+type Application struct {
+ // Fyne components
+ app fyne.App
+ window fyne.Window
+
+ // UI elements
+ wordInput *widget.Entry
+ submitButton *widget.Button
+ imageDisplay *ImageDisplay
+ audioPlayer *AudioPlayer
+ translationText *widget.Label
+ progressBar *widget.ProgressBar
+ statusLabel *widget.Label
+
+ // Navigation buttons
+ prevWordBtn *widget.Button
+ nextWordBtn *widget.Button
+
+ // Action buttons
+ keepButton *widget.Button
+ regenerateImageBtn *widget.Button
+ regenerateAudioBtn *widget.Button
+ regenerateAllBtn *widget.Button
+ deleteButton *widget.Button
+
+ // State management
+ currentWord string
+ currentAudioFile string
+ currentImages []string
+ currentTranslation string
+ savedCards []anki.Card
+ existingWords []string // Words already in anki_cards folder
+ currentWordIndex int
+
+ // Configuration
+ config *Config
+ audioConfig *audio.Config
+
+ // Background processing
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+ mu sync.Mutex
+}
+
+// Config holds GUI application configuration
+type Config struct {
+ OutputDir string
+ AudioFormat string
+ ImageProvider string
+ ImagesPerWord int
+ EnableCache bool
+ OpenAIKey string
+ PixabayKey string
+ UnsplashKey string
+}
+
+// DefaultConfig returns default GUI configuration
+func DefaultConfig() *Config {
+ return &Config{
+ OutputDir: "./anki_cards",
+ AudioFormat: "mp3",
+ ImageProvider: "openai",
+ ImagesPerWord: 1,
+ EnableCache: true,
+ }
+}
+
+// New creates a new GUI application
+func New(config *Config) *Application {
+ if config == nil {
+ config = DefaultConfig()
+ }
+
+ // Ensure output directory exists
+ os.MkdirAll(config.OutputDir, 0755)
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ app := &Application{
+ app: app.New(),
+ config: config,
+ ctx: ctx,
+ cancel: cancel,
+ savedCards: make([]anki.Card, 0),
+ }
+
+ // Set up audio configuration
+ app.audioConfig = &audio.Config{
+ Provider: "openai",
+ OutputDir: config.OutputDir,
+ OutputFormat: config.AudioFormat,
+ OpenAIKey: config.OpenAIKey,
+ OpenAIModel: "gpt-4o-mini-tts",
+ OpenAIVoice: "nova",
+ OpenAISpeed: 0.9,
+ OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.",
+ EnableCache: config.EnableCache,
+ CacheDir: "./.audio_cache",
+ }
+
+ app.setupUI()
+
+ // Scan existing words in output directory
+ app.scanExistingWords()
+
+ return app
+}
+
+// setupUI creates the main user interface
+func (a *Application) setupUI() {
+ a.window = a.app.NewWindow("TotalRecall - Bulgarian Flashcard Generator")
+ a.window.Resize(fyne.NewSize(800, 600))
+
+ // Create input section with navigation
+ a.wordInput = widget.NewEntry()
+ a.wordInput.SetPlaceHolder("Enter Bulgarian word...")
+ a.wordInput.OnSubmitted = func(string) { a.onSubmit() }
+
+ a.submitButton = widget.NewButton("Generate", a.onSubmit)
+ a.prevWordBtn = widget.NewButton("◀ Prev", a.onPrevWord)
+ a.nextWordBtn = widget.NewButton("Next ▶", a.onNextWord)
+
+ inputSection := container.NewBorder(
+ nil, nil,
+ a.prevWordBtn,
+ container.NewHBox(a.submitButton, a.nextWordBtn),
+ a.wordInput,
+ )
+
+ // Create display section
+ a.imageDisplay = NewImageDisplay()
+ a.audioPlayer = NewAudioPlayer()
+ a.translationText = widget.NewLabel("")
+ a.translationText.Alignment = fyne.TextAlignCenter
+
+ displaySection := container.NewBorder(
+ a.translationText,
+ a.audioPlayer,
+ nil, nil,
+ a.imageDisplay,
+ )
+
+ // Create action buttons
+ a.keepButton = widget.NewButton("Keep & Continue", a.onKeepAndContinue)
+ a.regenerateImageBtn = widget.NewButton("Regenerate Image", a.onRegenerateImage)
+ a.regenerateAudioBtn = widget.NewButton("Regenerate Audio", a.onRegenerateAudio)
+ a.regenerateAllBtn = widget.NewButton("Regenerate All", a.onRegenerateAll)
+ a.deleteButton = widget.NewButton("Delete", a.onDelete)
+ a.deleteButton.Importance = widget.DangerImportance
+
+ // Initially disable action buttons
+ a.setActionButtonsEnabled(false)
+
+ actionSection := container.NewHBox(
+ a.keepButton,
+ layout.NewSpacer(),
+ a.deleteButton,
+ widget.NewSeparator(),
+ a.regenerateImageBtn,
+ a.regenerateAudioBtn,
+ a.regenerateAllBtn,
+ )
+
+ // Create status section
+ a.progressBar = widget.NewProgressBar()
+ a.progressBar.Hide()
+ a.statusLabel = widget.NewLabel("Ready")
+
+ statusSection := container.NewBorder(
+ nil, nil, nil, nil,
+ container.NewVBox(
+ a.progressBar,
+ a.statusLabel,
+ ),
+ )
+
+ // Create menu
+ fileMenu := fyne.NewMenu("File",
+ fyne.NewMenuItem("Export to Anki...", a.onExportToAnki),
+ fyne.NewMenuItemSeparator(),
+ fyne.NewMenuItem("Preferences...", a.onPreferences),
+ fyne.NewMenuItemSeparator(),
+ fyne.NewMenuItem("Quit", a.app.Quit),
+ )
+
+ mainMenu := fyne.NewMainMenu(fileMenu)
+ a.window.SetMainMenu(mainMenu)
+
+ // Combine all sections
+ content := container.NewBorder(
+ inputSection,
+ container.NewVBox(
+ widget.NewSeparator(),
+ actionSection,
+ widget.NewSeparator(),
+ statusSection,
+ ),
+ nil, nil,
+ displaySection,
+ )
+
+ a.window.SetContent(content)
+ a.window.SetOnClosed(func() {
+ a.cancel()
+ a.wg.Wait()
+ })
+}
+
+// Run starts the GUI application
+func (a *Application) Run() {
+ a.window.ShowAndRun()
+}
+
+// onSubmit handles word submission
+func (a *Application) onSubmit() {
+ word := a.wordInput.Text
+ if word == "" {
+ return
+ }
+
+ // Validate Bulgarian text
+ if err := audio.ValidateBulgarianText(word); err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+
+ // Clear previous content
+ a.clearUI()
+
+ a.currentWord = word
+ a.setUIEnabled(false)
+ a.showProgress("Generating materials for: " + word)
+
+ // Generate in background
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ a.generateMaterials(word)
+ }()
+}
+
+// generateMaterials generates all materials for a word
+func (a *Application) generateMaterials(word string) {
+ // Translate word
+ fyne.Do(func() {
+ 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.translationText.SetText(fmt.Sprintf("%s = %s", word, translation))
+ })
+
+ // Generate audio
+ fyne.Do(func() {
+ a.updateStatus("Generating audio...")
+ })
+ audioFile, err := a.generateAudio(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Audio generation failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+
+ // Generate images
+ fyne.Do(func() {
+ a.updateStatus("Downloading images...")
+ })
+ images, err := a.generateImages(word)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Image download failed: %w", err))
+ a.setUIEnabled(true)
+ })
+ return
+ }
+ a.currentImages = images
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(images)
+ })
+
+ // Enable action buttons
+ fyne.Do(func() {
+ a.hideProgress()
+ a.updateStatus("Ready - Review and decide")
+ a.setUIEnabled(true)
+ a.setActionButtonsEnabled(true)
+ })
+}
+
+// onKeepAndContinue saves the current card and clears for next
+func (a *Application) onKeepAndContinue() {
+ // Save current card
+ card := anki.Card{
+ Bulgarian: a.currentWord,
+ AudioFile: a.currentAudioFile,
+ ImageFiles: a.currentImages,
+ Translation: a.currentTranslation,
+ }
+
+ a.mu.Lock()
+ a.savedCards = append(a.savedCards, card)
+ count := len(a.savedCards)
+ 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)
+ }
+
+ // Rescan existing words to include the new one
+ a.scanExistingWords()
+
+ // Clear UI for next word
+ a.clearUI()
+ a.updateStatus(fmt.Sprintf("Card saved! Total cards: %d", count))
+ a.wordInput.SetText("")
+ a.wordInput.FocusGained() // Focus input for next word
+}
+
+// onRegenerateImage regenerates only the image
+func (a *Application) onRegenerateImage() {
+ a.setActionButtonsEnabled(false)
+ a.showProgress("Regenerating image...")
+
+ // Clear the current image immediately
+ a.imageDisplay.Clear()
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+
+ images, err := a.generateImages(a.currentWord)
+ 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)
+ })
+ }
+
+ fyne.Do(func() {
+ a.hideProgress()
+ a.setActionButtonsEnabled(true)
+ })
+ }()
+}
+
+// onRegenerateAudio regenerates audio with a different voice
+func (a *Application) onRegenerateAudio() {
+ a.setActionButtonsEnabled(false)
+ a.showProgress("Regenerating audio...")
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+
+ audioFile, err := a.generateAudio(a.currentWord)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Audio regeneration failed: %w", err))
+ })
+ } else {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+ }
+
+ fyne.Do(func() {
+ a.hideProgress()
+ a.setActionButtonsEnabled(true)
+ })
+ }()
+}
+
+// onRegenerateAll regenerates both audio and images
+func (a *Application) onRegenerateAll() {
+ a.setUIEnabled(false)
+ a.showProgress("Regenerating all materials...")
+
+ // Clear the current image immediately
+ a.imageDisplay.Clear()
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ a.generateMaterials(a.currentWord)
+ }()
+}
+
+// onExportToAnki exports saved cards to Anki CSV
+func (a *Application) onExportToAnki() {
+ if len(a.savedCards) == 0 {
+ dialog.ShowInformation("No Cards", "No cards to export. Generate some cards first!", a.window)
+ return
+ }
+
+ // Create save dialog
+ saveDialog := dialog.NewFileSave(func(writer fyne.URIWriteCloser, err error) {
+ if err != nil {
+ dialog.ShowError(err, a.window)
+ return
+ }
+ if writer == nil {
+ return
+ }
+ defer writer.Close()
+
+ // Generate Anki CSV
+ outputPath := writer.URI().Path()
+ gen := anki.NewGenerator(&anki.GeneratorOptions{
+ OutputPath: outputPath,
+ MediaFolder: a.config.OutputDir,
+ IncludeHeaders: true,
+ AudioFormat: a.config.AudioFormat,
+ })
+
+ // Add all saved cards
+ for _, card := range a.savedCards {
+ gen.AddCard(card)
+ }
+
+ // Generate CSV
+ if err := gen.GenerateCSV(); err != nil {
+ dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window)
+ return
+ }
+
+ dialog.ShowInformation("Export Complete",
+ fmt.Sprintf("Exported %d cards to:\n%s", len(a.savedCards), outputPath),
+ a.window)
+ }, a.window)
+
+ saveDialog.SetFileName("anki_import.csv")
+ saveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".csv"}))
+ saveDialog.Show()
+}
+
+// onPreferences shows the preferences dialog
+func (a *Application) onPreferences() {
+ // This will be implemented in preferences.go
+ dialog.ShowInformation("Preferences", "Preferences dialog coming soon!", a.window)
+}
+
+// Helper methods
+func (a *Application) setUIEnabled(enabled bool) {
+ if enabled {
+ a.wordInput.Enable()
+ a.submitButton.Enable()
+ } else {
+ a.wordInput.Disable()
+ a.submitButton.Disable()
+ }
+}
+
+func (a *Application) setActionButtonsEnabled(enabled bool) {
+ if enabled {
+ a.keepButton.Enable()
+ a.regenerateImageBtn.Enable()
+ a.regenerateAudioBtn.Enable()
+ a.regenerateAllBtn.Enable()
+ a.deleteButton.Enable()
+ } else {
+ a.keepButton.Disable()
+ a.regenerateImageBtn.Disable()
+ a.regenerateAudioBtn.Disable()
+ a.regenerateAllBtn.Disable()
+ a.deleteButton.Disable()
+ }
+}
+
+func (a *Application) showProgress(message string) {
+ a.progressBar.Show()
+ a.progressBar.SetValue(0.5) // Indeterminate progress
+ a.statusLabel.SetText(message)
+}
+
+func (a *Application) hideProgress() {
+ a.progressBar.Hide()
+}
+
+func (a *Application) updateStatus(message string) {
+ a.statusLabel.SetText(message)
+}
+
+func (a *Application) showError(err error) {
+ dialog.ShowError(err, a.window)
+ a.updateStatus("Error: " + err.Error())
+}
+
+func (a *Application) clearUI() {
+ a.imageDisplay.Clear()
+ a.audioPlayer.Clear()
+ a.translationText.SetText("")
+ a.setActionButtonsEnabled(false)
+} \ No newline at end of file
diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go
new file mode 100644
index 0000000..161c635
--- /dev/null
+++ b/internal/gui/audio_player.go
@@ -0,0 +1,168 @@
+package gui
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/widget"
+)
+
+// AudioPlayer is a custom widget for playing audio files
+type AudioPlayer struct {
+ widget.BaseWidget
+
+ container *fyne.Container
+ playButton *widget.Button
+ stopButton *widget.Button
+ statusLabel *widget.Label
+
+ audioFile string
+ isPlaying bool
+ playCmd *exec.Cmd
+}
+
+// NewAudioPlayer creates a new audio player widget
+func NewAudioPlayer() *AudioPlayer {
+ p := &AudioPlayer{}
+
+ // Create controls
+ p.playButton = widget.NewButton("▶ Play", p.onPlay)
+ p.stopButton = widget.NewButton("■ Stop", p.onStop)
+ p.statusLabel = widget.NewLabel("No audio loaded")
+
+ // Initially disable controls
+ p.playButton.Disable()
+ p.stopButton.Disable()
+
+ // Create container
+ p.container = container.NewHBox(
+ p.playButton,
+ p.stopButton,
+ layout.NewSpacer(),
+ p.statusLabel,
+ )
+
+ p.ExtendBaseWidget(p)
+ return p
+}
+
+// CreateRenderer implements fyne.Widget
+func (p *AudioPlayer) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(p.container)
+}
+
+// SetAudioFile sets the audio file to play
+func (p *AudioPlayer) SetAudioFile(audioFile string) {
+ p.audioFile = audioFile
+ p.isPlaying = false
+
+ if audioFile != "" {
+ p.playButton.Enable()
+ p.statusLabel.SetText(fmt.Sprintf("Audio: %s", filepath.Base(audioFile)))
+ } else {
+ p.Clear()
+ }
+}
+
+// Clear clears the audio player
+func (p *AudioPlayer) Clear() {
+ p.onStop() // Stop any playing audio
+ p.audioFile = ""
+ p.isPlaying = false
+ p.playButton.Disable()
+ p.stopButton.Disable()
+ p.statusLabel.SetText("No audio loaded")
+}
+
+// onPlay handles play button click
+func (p *AudioPlayer) onPlay() {
+ if p.audioFile == "" {
+ return
+ }
+
+ if p.isPlaying {
+ // Pause functionality - just stop for now
+ p.onStop()
+ return
+ }
+
+ // Start playing
+ if err := p.startPlayback(); err != nil {
+ p.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
+ return
+ }
+
+ p.isPlaying = true
+ p.playButton.SetText("⏸ Pause")
+ p.stopButton.Enable()
+ p.statusLabel.SetText("Playing: " + filepath.Base(p.audioFile))
+}
+
+// onStop handles stop button click
+func (p *AudioPlayer) onStop() {
+ if p.playCmd != nil && p.playCmd.Process != nil {
+ p.playCmd.Process.Kill()
+ p.playCmd = nil
+ }
+
+ p.isPlaying = false
+ p.playButton.SetText("▶ Play")
+ p.stopButton.Disable()
+ p.statusLabel.SetText("Stopped: " + filepath.Base(p.audioFile))
+}
+
+// startPlayback starts audio playback using platform-specific commands
+func (p *AudioPlayer) startPlayback() error {
+ var cmd *exec.Cmd
+
+ switch runtime.GOOS {
+ case "darwin": // macOS
+ cmd = exec.Command("afplay", p.audioFile)
+ case "linux":
+ // Try multiple commands in order of preference
+ // mpg123 first since it handles MP3 files best
+ if _, err := exec.LookPath("mpg123"); err == nil {
+ cmd = exec.Command("mpg123", "-q", p.audioFile) // -q for quiet mode
+ } else if _, err := exec.LookPath("ffplay"); err == nil {
+ cmd = exec.Command("ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", p.audioFile)
+ } else if _, err := exec.LookPath("play"); err == nil {
+ // SoX play command
+ cmd = exec.Command("play", "-q", p.audioFile)
+ } else if _, err := exec.LookPath("paplay"); err == nil {
+ cmd = exec.Command("paplay", p.audioFile)
+ } else if _, err := exec.LookPath("aplay"); err == nil {
+ cmd = exec.Command("aplay", "-q", p.audioFile)
+ } else {
+ return fmt.Errorf("no audio player found. Install mpg123, ffplay, sox, paplay, or aplay")
+ }
+ case "windows":
+ // Use Windows Media Player
+ cmd = exec.Command("cmd", "/c", "start", "/min", p.audioFile)
+ default:
+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
+ }
+
+ // Store the command so we can stop it later
+ p.playCmd = cmd
+
+ // Start playback in background
+ go func() {
+ err := cmd.Run()
+ if err == nil {
+ // Playback finished normally
+ fyne.Do(func() {
+ p.isPlaying = false
+ p.playButton.SetText("▶ Play")
+ p.stopButton.Disable()
+ p.statusLabel.SetText("Finished: " + filepath.Base(p.audioFile))
+ })
+ }
+ }()
+
+ return nil
+} \ No newline at end of file
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
new file mode 100644
index 0000000..7656bcd
--- /dev/null
+++ b/internal/gui/generator.go
@@ -0,0 +1,198 @@
+package gui
+
+import (
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sashabaranov/go-openai"
+
+ "codeberg.org/snonux/totalrecall/internal/audio"
+ "codeberg.org/snonux/totalrecall/internal/image"
+)
+
+// translateWord translates a Bulgarian word to English
+func (a *Application) translateWord(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 Bulgarian word '%s' to English. Respond with only the English translation, 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
+ allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+
+ // Select a random voice
+ rand.Seed(time.Now().UnixNano())
+ voice := allVoices[rand.Intn(len(allVoices))]
+
+ // Update audio config with random voice
+ a.audioConfig.OpenAIVoice = voice
+
+ // Create audio provider
+ provider, err := audio.NewProvider(a.audioConfig)
+ if err != nil {
+ return "", err
+ }
+
+ // Generate filename
+ filename := sanitizeFilename(word)
+ outputFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", filename, a.config.AudioFormat))
+
+ // Generate audio
+ err = provider.GenerateAudio(a.ctx, word, outputFile)
+ if err != nil {
+ return "", err
+ }
+
+ // Save audio attribution
+ if err := a.saveAudioAttribution(word, outputFile, voice); err != nil {
+ // Non-fatal error, just log it
+ fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
+ }
+
+ return outputFile, nil
+}
+
+// generateImages downloads images for a word
+func (a *Application) generateImages(word string) ([]string, error) {
+ // Create image searcher based on provider
+ var searcher image.ImageSearcher
+ var err error
+
+ switch a.config.ImageProvider {
+ case "pixabay":
+ searcher = image.NewPixabayClient(a.config.PixabayKey)
+
+ case "unsplash":
+ if a.config.UnsplashKey == "" {
+ return nil, fmt.Errorf("Unsplash API key is required")
+ }
+ searcher, err = image.NewUnsplashClient(a.config.UnsplashKey)
+ if err != nil {
+ return nil, err
+ }
+
+ case "openai":
+ openaiConfig := &image.OpenAIConfig{
+ APIKey: a.config.OpenAIKey,
+ Model: "dall-e-2", // DALL-E 2 supports 512x512
+ Size: "512x512", // Half of 1024x1024
+ Quality: "standard",
+ Style: "natural",
+ CacheDir: "./.image_cache",
+ EnableCache: a.config.EnableCache,
+ }
+
+ searcher = image.NewOpenAIClient(openaiConfig)
+ if openaiConfig.APIKey == "" {
+ // Fall back to Pixabay
+ searcher = image.NewPixabayClient("")
+ }
+
+ default:
+ return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
+ }
+
+ // Create downloader
+ downloadOpts := &image.DownloadOptions{
+ OutputDir: a.config.OutputDir,
+ OverwriteExisting: true,
+ CreateDir: true,
+ FileNamePattern: "{word}_{index}",
+ MaxSizeBytes: 5 * 1024 * 1024, // 5MB
+ }
+
+ downloader := image.NewDownloader(searcher, downloadOpts)
+
+ // Download images
+ var paths []string
+
+ if a.config.ImagesPerWord == 1 {
+ _, path, err := downloader.DownloadBestMatch(a.ctx, word)
+ if err != nil {
+ return nil, err
+ }
+ paths = []string{path}
+ } else {
+ paths, err = downloader.DownloadMultiple(a.ctx, word, a.config.ImagesPerWord)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return paths, nil
+}
+
+// saveAudioAttribution saves attribution info for generated audio
+func (a *Application) saveAudioAttribution(word, audioFile, voice string) error {
+ attribution := fmt.Sprintf("Audio generated by OpenAI TTS\n\n")
+ attribution += fmt.Sprintf("Bulgarian word: %s\n", word)
+ attribution += fmt.Sprintf("Model: %s\n", a.audioConfig.OpenAIModel)
+ attribution += fmt.Sprintf("Voice: %s\n", voice)
+ attribution += fmt.Sprintf("Speed: %.2f\n", a.audioConfig.OpenAISpeed)
+
+ if a.audioConfig.OpenAIInstruction != "" {
+ attribution += fmt.Sprintf("\nVoice instructions:\n%s\n", a.audioConfig.OpenAIInstruction)
+ }
+
+ attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05"))
+
+ // Save to file
+ attrPath := strings.TrimSuffix(audioFile, filepath.Ext(audioFile)) + "_attribution.txt"
+ if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil {
+ return fmt.Errorf("failed to write audio attribution file: %w", err)
+ }
+
+ return nil
+}
+
+// sanitizeFilename creates a safe filename from a string
+func sanitizeFilename(s string) string {
+ result := ""
+ for _, r := range s {
+ if isAlphaNumeric(r) || r == '-' || r == '_' {
+ result += string(r)
+ } else {
+ result += "_"
+ }
+ }
+ return result
+}
+
+// isAlphaNumeric checks if a rune is alphanumeric
+func isAlphaNumeric(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') ||
+ (r >= 'А' && r <= 'Я')
+} \ No newline at end of file
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
new file mode 100644
index 0000000..f8931ba
--- /dev/null
+++ b/internal/gui/navigation.go
@@ -0,0 +1,268 @@
+package gui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/dialog"
+)
+
+// scanExistingWords scans the output directory for existing words
+func (a *Application) scanExistingWords() {
+ a.existingWords = []string{}
+
+ // Read directory
+ entries, err := os.ReadDir(a.config.OutputDir)
+ if err != nil {
+ // Directory doesn't exist yet, that's OK
+ return
+ }
+
+ // Collect unique words
+ wordMap := make(map[string]bool)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ name := entry.Name()
+ // Skip attribution and translation files
+ if strings.Contains(name, "_attribution") || strings.Contains(name, "_translation") {
+ continue
+ }
+
+ // Extract word from filename (before first underscore or dot)
+ base := strings.TrimSuffix(name, filepath.Ext(name))
+ parts := strings.Split(base, "_")
+ if len(parts) > 0 {
+ word := parts[0]
+ wordMap[word] = true
+ }
+ }
+
+ // Convert map to sorted slice
+ for word := range wordMap {
+ a.existingWords = append(a.existingWords, word)
+ }
+ sort.Strings(a.existingWords)
+
+ // Update navigation buttons
+ a.updateNavigation()
+
+ // Load first word if available and nothing is loaded yet
+ if len(a.existingWords) > 0 && a.currentWord == "" {
+ a.loadWordByIndex(0)
+ }
+}
+
+// updateNavigation updates the navigation button states
+func (a *Application) updateNavigation() {
+ if len(a.existingWords) > 0 {
+ a.prevWordBtn.Enable()
+ a.nextWordBtn.Enable()
+
+ // Find current word index
+ a.currentWordIndex = -1
+ for i, word := range a.existingWords {
+ if word == a.currentWord {
+ a.currentWordIndex = i
+ break
+ }
+ }
+
+ // Disable at boundaries
+ if a.currentWordIndex <= 0 {
+ a.prevWordBtn.Disable()
+ }
+ if a.currentWordIndex >= len(a.existingWords)-1 || a.currentWordIndex == -1 {
+ a.nextWordBtn.Disable()
+ }
+ } else {
+ a.prevWordBtn.Disable()
+ a.nextWordBtn.Disable()
+ }
+}
+
+// onPrevWord loads the previous word
+func (a *Application) onPrevWord() {
+ if a.currentWordIndex > 0 {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ }
+}
+
+// onNextWord loads the next word
+func (a *Application) onNextWord() {
+ if a.currentWordIndex < len(a.existingWords)-1 && a.currentWordIndex >= 0 {
+ a.loadWordByIndex(a.currentWordIndex + 1)
+ }
+}
+
+// loadWordByIndex loads a word by its index in existingWords
+func (a *Application) loadWordByIndex(index int) {
+ if index < 0 || index >= len(a.existingWords) {
+ return
+ }
+
+ word := a.existingWords[index]
+ a.currentWord = word
+ a.currentWordIndex = index
+
+ // Update input field
+ a.wordInput.SetText(word)
+
+ // Clear UI
+ a.clearUI()
+
+ // Load existing files
+ a.loadExistingFiles(word)
+
+ // Update navigation
+ a.updateNavigation()
+
+ // Enable action buttons since we have loaded content
+ a.setActionButtonsEnabled(true)
+}
+
+// loadExistingFiles loads existing files for a word
+func (a *Application) loadExistingFiles(word string) {
+ sanitized := sanitizeFilename(word)
+
+ // Load translation
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", sanitized))
+ if data, err := os.ReadFile(translationFile); err == nil {
+ // Parse translation from "word = translation" format
+ content := string(data)
+ parts := strings.Split(content, "=")
+ if len(parts) >= 2 {
+ a.currentTranslation = strings.TrimSpace(parts[1])
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", word, a.currentTranslation))
+ })
+ }
+ }
+
+ // Load audio file
+ audioFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", sanitized, a.config.AudioFormat))
+ if _, err := os.Stat(audioFile); err == nil {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ })
+ }
+
+ // Load image files
+ a.currentImages = []string{}
+ // Try to find images with different patterns
+ patterns := []string{
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s_0.jpg", sanitized),
+ fmt.Sprintf("%s_0.png", sanitized),
+ fmt.Sprintf("%s_1.jpg", sanitized),
+ fmt.Sprintf("%s_1.png", sanitized),
+ }
+
+ for _, pattern := range patterns {
+ imagePath := filepath.Join(a.config.OutputDir, pattern)
+ if _, err := os.Stat(imagePath); err == nil {
+ a.currentImages = append(a.currentImages, imagePath)
+ break // Just load the first image found
+ }
+ }
+
+ if len(a.currentImages) > 0 {
+ fyne.Do(func() {
+ a.imageDisplay.SetImages(a.currentImages)
+ })
+ }
+
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Loaded: %s", word))
+ })
+}
+
+// onDelete deletes the current word's files
+func (a *Application) onDelete() {
+ if a.currentWord == "" {
+ return
+ }
+
+ // Confirm deletion
+ dialog.ShowConfirm("Delete Word",
+ fmt.Sprintf("Delete all files for '%s'?", a.currentWord),
+ func(confirm bool) {
+ if confirm {
+ a.deleteCurrentWord()
+ }
+ }, a.window)
+}
+
+// deleteCurrentWord deletes all files for the current word
+func (a *Application) deleteCurrentWord() {
+ sanitized := sanitizeFilename(a.currentWord)
+ deletedCount := 0
+
+ // List of possible files to delete
+ patterns := []string{
+ fmt.Sprintf("%s.mp3", sanitized),
+ fmt.Sprintf("%s.wav", sanitized),
+ fmt.Sprintf("%s.jpg", sanitized),
+ fmt.Sprintf("%s.png", sanitized),
+ fmt.Sprintf("%s.gif", sanitized),
+ fmt.Sprintf("%s_*.jpg", sanitized),
+ fmt.Sprintf("%s_*.png", sanitized),
+ fmt.Sprintf("%s_translation.txt", sanitized),
+ fmt.Sprintf("%s_attribution.txt", sanitized),
+ fmt.Sprintf("%s_*_attribution.txt", sanitized),
+ }
+
+ // Delete files matching patterns
+ for _, pattern := range patterns {
+ matches, err := filepath.Glob(filepath.Join(a.config.OutputDir, pattern))
+ if err != nil {
+ continue
+ }
+ for _, match := range matches {
+ if err := os.Remove(match); err == nil {
+ deletedCount++
+ }
+ }
+ }
+
+ // Remove from existingWords
+ newWords := []string{}
+ for _, w := range a.existingWords {
+ if w != a.currentWord {
+ newWords = append(newWords, w)
+ }
+ }
+ a.existingWords = newWords
+
+ // Clear UI
+ a.clearUI()
+
+ // Update status
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Deleted %d files for '%s'", deletedCount, a.currentWord))
+ })
+
+ // Clear current word
+ a.currentWord = ""
+ a.wordInput.SetText("")
+
+ // Try to load previous or next word
+ if a.currentWordIndex > 0 && a.currentWordIndex <= len(a.existingWords) {
+ a.loadWordByIndex(a.currentWordIndex - 1)
+ } else if len(a.existingWords) > 0 {
+ a.loadWordByIndex(0)
+ } else {
+ // No more words
+ a.updateNavigation()
+ a.setActionButtonsEnabled(false)
+ }
+} \ No newline at end of file
diff --git a/internal/gui/widgets.go b/internal/gui/widgets.go
new file mode 100644
index 0000000..eb9818c
--- /dev/null
+++ b/internal/gui/widgets.go
@@ -0,0 +1,122 @@
+package gui
+
+import (
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "path/filepath"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/widget"
+)
+
+// ImageDisplay is a custom widget for displaying images
+type ImageDisplay struct {
+ widget.BaseWidget
+
+ container *fyne.Container
+ imageCanvas *canvas.Image
+ imageLabel *widget.Label
+
+ currentImage string
+}
+
+// NewImageDisplay creates a new image display widget
+func NewImageDisplay() *ImageDisplay {
+ d := &ImageDisplay{}
+
+ // Create image canvas
+ d.imageCanvas = canvas.NewImageFromResource(nil)
+ d.imageCanvas.FillMode = canvas.ImageFillContain
+ d.imageCanvas.SetMinSize(fyne.NewSize(200, 150)) // Half the size
+
+ // Create label
+ d.imageLabel = widget.NewLabel("No image")
+ d.imageLabel.Alignment = fyne.TextAlignCenter
+
+ // Create main container - no navigation buttons here
+ d.container = container.NewBorder(
+ nil,
+ d.imageLabel,
+ nil, nil,
+ d.imageCanvas,
+ )
+
+ d.ExtendBaseWidget(d)
+ return d
+}
+
+// CreateRenderer implements fyne.Widget
+func (d *ImageDisplay) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(d.container)
+}
+
+// SetImage sets a single image to display
+func (d *ImageDisplay) SetImage(imagePath string) {
+ if imagePath == "" {
+ d.Clear()
+ return
+ }
+
+ d.currentImage = imagePath
+
+ // Load image from file
+ file, err := os.Open(imagePath)
+ if err != nil {
+ d.imageLabel.SetText(fmt.Sprintf("Error loading image: %v", err))
+ return
+ }
+ defer file.Close()
+
+ img, _, err := image.Decode(file)
+ if err != nil {
+ d.imageLabel.SetText(fmt.Sprintf("Error decoding image: %v", err))
+ return
+ }
+
+ // Update canvas
+ d.imageCanvas.Image = img
+ d.imageCanvas.Refresh()
+
+ // Update label
+ d.imageLabel.SetText(filepath.Base(imagePath))
+}
+
+// SetImages sets multiple images but only displays the first one
+func (d *ImageDisplay) SetImages(images []string) {
+ if len(images) > 0 {
+ d.SetImage(images[0])
+ } else {
+ d.Clear()
+ }
+}
+
+// Clear clears the display
+func (d *ImageDisplay) Clear() {
+ d.currentImage = ""
+ d.imageCanvas.Image = nil
+ d.imageCanvas.Refresh()
+ d.imageLabel.SetText("No image")
+}
+
+
+// ResourceFromPath creates a Fyne resource from a file path
+func ResourceFromPath(path string) (fyne.Resource, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ return fyne.NewStaticResource(filepath.Base(path), data), nil
+} \ No newline at end of file