diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-16 13:13:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-16 13:13:38 +0300 |
| commit | 7187e7464f16a9d2991ba2da3c672fdb3cf5de72 (patch) | |
| tree | 208d8e301dc55512a078f836f4f0c9ad2a927427 /internal | |
| parent | b105333c061ea165b3b79317415cbb8b9cfb7c75 (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.go | 2 | ||||
| -rw-r--r-- | internal/gui/app.go | 539 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 168 | ||||
| -rw-r--r-- | internal/gui/generator.go | 198 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 268 | ||||
| -rw-r--r-- | internal/gui/widgets.go | 122 |
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 |
