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 | |
| 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>
| -rw-r--r-- | GUI.md | 71 | ||||
| -rw-r--r-- | TODO.md | 22 | ||||
| -rw-r--r-- | cmd/totalrecall/main.go | 32 | ||||
| -rw-r--r-- | go.mod | 32 | ||||
| -rw-r--r-- | go.sum | 72 | ||||
| -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 |
11 files changed, 1515 insertions, 11 deletions
@@ -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 @@ -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) @@ -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 ) @@ -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() + } +} |
