summaryrefslogtreecommitdiff
path: root/internal/gui/generator.go
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/gui/generator.go
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/gui/generator.go')
-rw-r--r--internal/gui/generator.go198
1 files changed, 198 insertions, 0 deletions
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