From 7187e7464f16a9d2991ba2da3c672fdb3cf5de72 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 16 Jul 2025 13:13:38 +0300 Subject: feat: add Fyne GUI mode with interactive flashcard management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- GUI.md | 71 ++++++ TODO.md | 22 ++ cmd/totalrecall/main.go | 32 ++- go.mod | 32 ++- go.sum | 72 +++++- internal/audio/provider.go | 2 +- internal/gui/app.go | 539 +++++++++++++++++++++++++++++++++++++++++++ internal/gui/audio_player.go | 168 ++++++++++++++ internal/gui/generator.go | 198 ++++++++++++++++ internal/gui/navigation.go | 268 +++++++++++++++++++++ internal/gui/widgets.go | 122 ++++++++++ 11 files changed, 1515 insertions(+), 11 deletions(-) create mode 100644 GUI.md create mode 100644 internal/gui/app.go create mode 100644 internal/gui/audio_player.go create mode 100644 internal/gui/generator.go create mode 100644 internal/gui/navigation.go create mode 100644 internal/gui/widgets.go diff --git a/GUI.md b/GUI.md new file mode 100644 index 0000000..17b66cc --- /dev/null +++ b/GUI.md @@ -0,0 +1,71 @@ +# GUI Mode for TotalRecall + +TotalRecall now includes an interactive GUI mode for a more user-friendly flashcard generation experience. + +## Prerequisites + +The GUI mode requires Fyne, which has the following system dependencies: + +### Linux +```bash +# Debian/Ubuntu +sudo apt-get install gcc libgl1-mesa-dev xorg-dev + +# Fedora +sudo dnf install gcc mesa-libGL-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libXxf86vm-devel +``` + +### macOS +No additional dependencies required (uses system frameworks). + +### Windows +No additional dependencies required if using MinGW or similar. + +## Running GUI Mode + +```bash +./totalrecall --gui +``` + +## Features + +The GUI provides: + +1. **Interactive Input**: Enter Bulgarian words one at a time +2. **Live Preview**: See generated images and hear audio pronunciation +3. **Fine-grained Regeneration**: + - Regenerate just the image (cycles through different results) + - Regenerate just the audio (uses a different voice) + - Regenerate both +4. **Session Management**: Keep track of all generated cards in a session +5. **Export to Anki**: Export all saved cards to CSV format + +## GUI Layout + +- **Top Section**: Input field for Bulgarian words with submit button +- **Middle Section**: + - Image display with navigation (if multiple images) + - Audio player with play controls + - Translation display +- **Bottom Section**: Action buttons + - "Keep & Continue" - saves the current card + - "Regenerate Image" - gets a new image + - "Regenerate Audio" - generates with a different voice + - "Regenerate All" - regenerates everything + +## Building from Source + +If you're building from source and encounter issues with the GUI: + +1. Ensure you have the system dependencies installed (see Prerequisites) +2. The build might take longer the first time as it compiles Fyne +3. If the build times out, try building without the GUI first: + ```bash + go build -tags nogui ./cmd/totalrecall + ``` + +## Troubleshooting + +- **Build fails**: Check that you have the required system dependencies +- **GUI doesn't start**: Ensure you're running in a graphical environment +- **Audio doesn't play**: The current implementation shows audio controls but actual playback requires additional audio libraries \ No newline at end of file diff --git a/TODO.md b/TODO.md index 9b41e0a..d89b2bb 100644 --- a/TODO.md +++ b/TODO.md @@ -1,2 +1,24 @@ # TODO's +## Completed +- [x] Added Fyne GUI mode with `--gui` flag +- [x] Interactive word input with Bulgarian validation +- [x] Live preview of generated images and audio +- [x] Fine-grained regeneration (image-only, audio-only, or both) +- [x] Session management with "Keep & Continue" functionality +- [x] Export to Anki CSV from GUI session +- [x] Progress indicators and status updates + +## GUI Enhancements (Future) +- [ ] Implement actual audio playback (currently shows controls only) +- [ ] Add preferences dialog for GUI settings +- [ ] Add drag & drop support for batch word lists +- [ ] Add recent words history +- [ ] Add dark/light theme toggle +- [ ] Keyboard shortcuts (Enter to submit, Space to play audio) +- [ ] Save/restore GUI window size and position + +## Known Limitations +- Audio playback requires additional audio library integration (e.g., github.com/hajimehoshi/oto) +- First build with GUI may be slow due to Fyne compilation + diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index 658301d..89d0ad9 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -17,6 +17,7 @@ import ( "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/anki" "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/gui" "codeberg.org/snonux/totalrecall/internal/image" ) @@ -34,6 +35,7 @@ var ( generateAnki bool listModels bool allVoices bool + guiMode bool // Audio provider flags removed - now only OpenAI // OpenAI flags openAIModel string @@ -84,13 +86,14 @@ func init() { rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file") rootCmd.Flags().BoolVar(&listModels, "list-models", false, "List available OpenAI models for the current API key") rootCmd.Flags().BoolVar(&allVoices, "all-voices", false, "Generate audio in all available voices (creates multiple files)") + rootCmd.Flags().BoolVar(&guiMode, "gui", false, "Launch interactive GUI mode") // Audio provider removed - now only OpenAI // OpenAI flags rootCmd.Flags().StringVar(&openAIModel, "openai-model", "gpt-4o-mini-tts", "OpenAI TTS model: tts-1, tts-1-hd, gpt-4o-mini-tts") rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "", "OpenAI voice: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse (default: random)") - rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 0.8, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)") + rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 0.9, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)") rootCmd.Flags().StringVar(&openAIInstruction, "openai-instruction", "", "Voice instructions for gpt-4o-mini-tts model (e.g., 'speak slowly with a Bulgarian accent')") // OpenAI Image Generation flags @@ -151,6 +154,11 @@ func runCommand(cmd *cobra.Command, args []string) error { return listAvailableModels() } + // Handle --gui flag + if guiMode { + return runGUIMode() + } + // Determine words to process var words []string @@ -309,7 +317,7 @@ func generateAudioWithVoice(word, voice string) error { if openAIVoice == "nova" && viper.IsSet("audio.openai_voice") { providerConfig.OpenAIVoice = viper.GetString("audio.openai_voice") } - if openAISpeed == 0.8 && viper.IsSet("audio.openai_speed") { + if openAISpeed == 0.9 && viper.IsSet("audio.openai_speed") { providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed") } if openAIInstruction == "" && viper.IsSet("audio.openai_instruction") { @@ -712,6 +720,26 @@ func saveAudioAttribution(word, audioFile string, config *audio.Config) error { return nil } +func runGUIMode() error { + // Create GUI configuration from command line flags and viper config + guiConfig := &gui.Config{ + OutputDir: outputDir, + AudioFormat: audioFormat, + ImageProvider: imageAPI, + ImagesPerWord: imagesPerWord, + EnableCache: viper.GetBool("cache.enable"), + OpenAIKey: getOpenAIKey(), + PixabayKey: viper.GetString("image.pixabay_key"), + UnsplashKey: viper.GetString("image.unsplash_key"), + } + + // Create and run GUI application + app := gui.New(guiConfig) + app.Run() + + return nil +} + func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/go.mod b/go.mod index 185ca8f..f507d7a 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,53 @@ module codeberg.org/snonux/totalrecall go 1.24.4 require ( + fyne.io/fyne/v2 v2.6.1 github.com/sashabaranov/go-openai v1.40.5 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 ) require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f00a23..091aae2 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,78 @@ +fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is= +fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sashabaranov/go-openai v1.40.5 h1:SwIlNdWflzR1Rxd1gv3pUg6pwPc6cQ2uMoHs8ai+/NY= @@ -39,22 +89,32 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 -- cgit v1.2.3