summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
committerPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
commitcbb1581356ed59e81cf5fedb30145c7521165e3d (patch)
treea36a91d3a0d2258977a43ea1dc9da8bfd2741ca6
initial commit
-rw-r--r--.gitignore45
-rw-r--r--CLAUDE.md128
-rw-r--r--LICENSE21
-rw-r--r--README.md264
-rw-r--r--TODO.md4
-rw-r--r--Taskfile.yaml15
-rw-r--r--cmd/bulg/main.go447
-rw-r--r--go.mod24
-rw-r--r--go.sum44
-rw-r--r--internal/anki/doc.go3
-rw-r--r--internal/anki/generator.go318
-rw-r--r--internal/audio/doc.go3
-rw-r--r--internal/audio/espeak.go217
-rw-r--r--internal/audio/espeak_provider.go65
-rw-r--r--internal/audio/espeak_test.go198
-rw-r--r--internal/audio/openai_provider.go219
-rw-r--r--internal/audio/provider.go139
-rw-r--r--internal/config/doc.go3
-rw-r--r--internal/image/doc.go3
-rw-r--r--internal/image/download.go244
-rw-r--r--internal/image/pixabay.go231
-rw-r--r--internal/image/search.go87
-rw-r--r--internal/image/search_test.go146
-rw-r--r--internal/image/translate.go90
-rw-r--r--internal/image/unsplash.go263
-rw-r--r--internal/version.go3
-rw-r--r--testdata/common_words.txt20
27 files changed, 3244 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f0af1f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# Build output
+totalrecall
+
+# IDE files
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Temporary files
+*.tmp
+*.temp
+
+# Audio cache (OpenAI TTS)
+.audio_cache/
+
+# Geberated cards data
+anki_cards
+
+# Configuration with API keys
+.totalrecall.yaml
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ade1542
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,128 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+**bulg** - Bulgarian Anki Flashcard Generator
+
+A Go CLI tool that generates Anki flashcard materials from Bulgarian words:
+- Generates audio pronunciation using espeak-ng
+- Downloads representative images via web search
+- Creates Anki-compatible output files
+
+## Important: Task Tracking
+**Always check TODO.md for the current implementation status and pending tasks.** The TODO.md file contains a comprehensive breakdown of all features and their completion status.
+
+## Build and Development Commands
+
+### Available Tasks (via Taskfile)
+```bash
+# Build the binary
+task
+# or
+task default
+
+# Run the application
+task run
+
+# Run tests
+task test
+
+# Install to Go bin directory
+task install
+```
+
+### Common Development Commands
+```bash
+# Build for current platform
+go build -o bulg ./cmd/bulg
+
+# Run without building
+go run ./cmd/bulg "ябълка"
+
+# Run tests with coverage
+go test -v -cover ./...
+
+# Check for race conditions
+go test -race ./...
+
+# Format code
+go fmt ./...
+
+# Lint code (requires golangci-lint)
+golangci-lint run
+```
+
+## Architecture Overview
+
+### Package Structure
+```
+bulg/
+├── cmd/bulg/ # CLI entry point
+├── internal/ # Private packages
+│ ├── audio/ # Audio generation (espeak-ng wrapper)
+│ ├── image/ # Image search functionality
+│ ├── anki/ # Anki format generation
+│ ├── config/ # Configuration management
+│ └── version.go # Version information
+```
+
+### Key Design Decisions
+1. **espeak-ng for TTS**: Open source, supports Bulgarian, no API keys needed
+2. **Modular image search**: Support multiple providers (Pixabay, Unsplash)
+3. **Configuration via YAML**: User-friendly configuration with viper
+4. **Cobra for CLI**: Industry-standard CLI framework
+
+### External Dependencies
+- **espeak-ng**: Must be installed on the system
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install espeak-ng
+
+ # macOS
+ brew install espeak-ng
+ ```
+
+### API Configuration
+Image search APIs require configuration in `.bulg.yaml`:
+- **Pixabay**: Optional API key for higher rate limits
+- **Unsplash**: Required API key
+
+## Testing Approach
+1. Unit tests mock external commands (espeak-ng) and API calls
+2. Integration tests use real services when available
+3. Test with common Bulgarian words: ябълка, котка, куче, хляб
+
+## Common Issues and Solutions
+
+### espeak-ng Bulgarian pronunciation
+There have been reported issues with Bulgarian pronunciation in espeak-ng v1.49.3. If pronunciation sounds wrong, try:
+```bash
+# Check version
+espeak-ng --version
+
+# Test Bulgarian voice
+espeak-ng -v bg "Здравей"
+
+# Try different voice variants
+espeak-ng -v bg+f1 "Здравей"
+```
+
+### Package Declaration Error
+If you see an error about `package main`, ensure `cmd/bulg/main.go` has:
+```go
+package main // NOT package bulg
+```
+
+## Development Workflow
+1. Check TODO.md for next tasks
+2. Create feature branch
+3. Implement with tests
+4. Update documentation
+5. Run full test suite
+6. Submit for review
+
+## Bulgarian Language Notes
+- Input should be in Cyrillic script
+- Common test words: ябълка (apple), котка (cat), куче (dog)
+- Voice variants: bg+m1 (male), bg+f1 (female) \ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..275734a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 bulg contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e342fd7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,264 @@
+# bulg - Bulgarian Anki Flashcard Generator
+
+`bulg` is a command-line tool that generates Anki flashcard materials from Bulgarian words. It creates audio pronunciation files using espeak-ng or OpenAI TTS and downloads representative images from web search APIs.
+
+## Features
+
+- Audio generation with multiple providers:
+ - **espeak-ng**: Free, offline Bulgarian voices (robotic quality)
+ - **OpenAI TTS**: High-quality, natural-sounding voices (requires API key)
+- Image search via Pixabay and Unsplash APIs
+- Batch processing of multiple words
+- Anki-compatible CSV export
+- Configurable voice variants and speech speed
+- Support for WAV and MP3 audio formats
+- Audio caching to save API costs (OpenAI)
+
+## Installation
+
+### Prerequisites
+
+1. **For espeak-ng audio** (free, offline):
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install espeak-ng
+
+ # macOS
+ brew install espeak-ng
+ ```
+
+2. **ffmpeg** (optional, for MP3 conversion with espeak):
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install ffmpeg
+
+ # macOS
+ brew install ffmpeg
+ ```
+
+3. **For OpenAI TTS** (paid, high quality):
+ - Create an account at https://platform.openai.com
+ - Generate an API key at https://platform.openai.com/api-keys
+ - Set the key using one of these methods:
+ - Environment variable: `export OPENAI_API_KEY="sk-..."`
+ - Configuration file: Add to `.bulg.yaml`
+
+### Building from Source
+
+```bash
+git clone https://github.com/yourusername/bulg.git
+cd bulg
+go build -o bulg ./cmd/bulg
+```
+
+Or install directly:
+
+```bash
+go install codeberg.org/snonux/bulg/cmd/bulg@latest
+```
+
+## Quick Start
+
+1. Generate materials for a single word:
+ ```bash
+ bulg ябълка
+ ```
+
+2. Process multiple words from a file:
+ ```bash
+ bulg --batch words.txt
+ ```
+
+3. Generate with Anki CSV:
+ ```bash
+ bulg ябълка --anki
+ ```
+
+## Configuration
+
+Create a `.bulg.yaml` file in your home directory or project folder:
+
+```yaml
+audio:
+ provider: openai # Audio provider (espeak or openai)
+ format: mp3 # Audio format (wav or mp3)
+
+ # ESpeak settings
+ voice: bg+f1 # Voice variant (bg, bg+m1, bg+f1, etc.)
+ speed: 150 # Speech speed (80-450 words/minute)
+ pitch: 50 # Pitch adjustment (0-99)
+
+ # OpenAI settings
+ openai_key: "sk-..." # Your OpenAI API key
+ openai_model: "tts-1" # Model: tts-1 or tts-1-hd
+ openai_voice: "nova" # Voice: alloy, echo, fable, onyx, nova, shimmer
+ openai_speed: 1.0 # Speed: 0.25 to 4.0
+
+ # Caching
+ enable_cache: true
+ cache_dir: "./.audio_cache"
+
+image:
+ provider: pixabay # Image provider (pixabay or unsplash)
+ pixabay_key: "" # Optional API key for higher limits
+ unsplash_key: "" # Required for Unsplash
+ size: medium # Image size preference
+
+output:
+ directory: ./anki_cards
+ naming: "{word}_{type}"
+```
+
+## Usage
+
+```bash
+bulg [word] [flags]
+```
+
+### Flags
+
+- `-v, --voice string`: Voice variant (default "bg+f1")
+- `-o, --output string`: Output directory (default "./anki_cards")
+- `-f, --format string`: Audio format - wav or mp3 (default "mp3")
+- `--batch string`: Process words from file (one per line)
+- `--anki`: Generate Anki import CSV file
+- `--skip-audio`: Skip audio generation
+- `--skip-images`: Skip image download
+- `--images-per-word int`: Number of images per word (default 1)
+- `--image-api string`: Image source - pixabay or unsplash (default "pixabay")
+
+#### Audio Provider Options
+- `--audio-provider string`: Audio provider - espeak or openai (default "espeak")
+
+#### ESpeak Tuning Options
+- `--pitch int`: Pitch adjustment 0-99 (default 50, lower=deeper, espeak only)
+- `--amplitude int`: Volume 0-200 (default 100, espeak only)
+- `--word-gap int`: Gap between words in 10ms units (default 0, espeak only)
+
+#### OpenAI Options
+- `--openai-model string`: Model - tts-1 or tts-1-hd (default "tts-1")
+- `--openai-voice string`: Voice - alloy, echo, fable, onyx, nova, shimmer (default "nova")
+- `--openai-speed float`: Speech speed 0.25-4.0 (default 1.0)
+
+## API Keys
+
+### Pixabay
+- Optional - works without key but with lower rate limits
+- Get your key at: https://pixabay.com/api/docs/
+
+### Unsplash
+- Required for Unsplash searches
+- Get your key at: https://unsplash.com/developers
+
+## Examples
+
+### Basic Usage
+```bash
+# Single word with espeak-ng
+bulg котка
+
+# Using OpenAI TTS (requires API key in config)
+bulg котка --audio-provider openai
+
+# High-quality OpenAI with specific voice
+bulg ябълка --audio-provider openai --openai-model tts-1-hd --openai-voice alloy
+
+# Multiple words with custom output
+bulg --batch animals.txt -o ./animal_cards
+
+# ESpeak with tuning
+bulg ябълка --pitch 40 --word-gap 3
+
+# Skip images, audio only
+bulg куче --skip-images
+
+# Generate Anki import file
+bulg --batch words.txt --anki
+```
+
+### Batch File Format
+Create a text file with one Bulgarian word per line:
+```
+ябълка
+котка
+куче
+хляб
+вода
+```
+
+## Anki Import
+
+1. Generate materials with the `--anki` flag
+2. In Anki, go to File → Import
+3. Select the generated `anki_import.csv`
+4. Copy all media files to your Anki media folder
+5. Map fields appropriately during import
+
+## Voice Variants
+
+Available Bulgarian voices:
+- `bg` - Default Bulgarian voice
+- `bg+m1`, `bg+m2`, `bg+m3` - Male voices
+- `bg+f1`, `bg+f2`, `bg+f3` - Female voices
+
+## Troubleshooting
+
+### espeak-ng not found
+Make sure espeak-ng is installed and in your PATH.
+
+### No images found
+- Check your internet connection
+- Verify API keys in configuration
+- Try using English translations for better results
+
+### OpenAI API errors
+- Verify your API key is correct and has credits
+- Check the API key has TTS permissions enabled
+- If you get rate limit errors, wait a moment and try again
+- The tool will automatically fall back to espeak-ng if OpenAI fails
+
+### Audio sounds robotic
+The Bulgarian voice in espeak-ng can sound robotic. To improve quality:
+
+```bash
+# Test with different settings
+espeak-ng -v bg -p 40 -s 140 "Здравей" # Deeper, slower
+espeak-ng -v bg+f1 -p 60 -g 2 "Здравей" # Higher pitch, word gaps
+
+# Using bulg with tuning
+bulg ябълка --pitch 40 --word-gap 2 --amplitude 120
+```
+
+Recommended settings for clearer pronunciation:
+- `--pitch 40`: Slightly deeper voice (less robotic)
+- `--word-gap 2-5`: Small gaps between words
+- `--amplitude 120`: Slightly louder
+- `-v bg+f1`: Female variant often sounds clearer
+
+### Using OpenAI for Better Quality
+
+OpenAI TTS provides much more natural Bulgarian pronunciation:
+
+```bash
+# Option 1: Use environment variable
+export OPENAI_API_KEY="sk-your-key-here"
+bulg ябълка --audio-provider openai
+
+# Option 2: Set in .bulg.yaml
+audio:
+ provider: openai
+ openai_key: "sk-your-key-here"
+
+# Use with custom voice
+bulg ябълка --audio-provider openai --openai-voice alloy
+```
+
+**OpenAI Pricing**:
+- tts-1: $0.015 per 1K characters (~$0.0001 per word)
+- tts-1-hd: $0.030 per 1K characters (~$0.0002 per word)
+
+The tool caches audio to avoid repeated API calls for the same words.
+
+## License
+
+MIT License - see LICENSE file for details \ No newline at end of file
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..9679bab
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,4 @@
+# TODO's
+
+[ ] Rename the project from bulg to totalrecall. Look at all bulg references, and rename them. Also change the Go module name.
+[ ] Ultra think about an Implementation of using OpenAPI key to use an OpenAI LLM to generate an image for the flash card. And add all to-do's into this file.
diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000..dc10b97
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,15 @@
+version: '3'
+
+tasks:
+ default:
+ cmds:
+ - go build -o bulg ./cmd/bulg
+ run:
+ cmds:
+ - go run ./cmd/bulg
+ test:
+ cmds:
+ - go test ./...
+ install:
+ cmds:
+ - go install ./cmd/bulg
diff --git a/cmd/bulg/main.go b/cmd/bulg/main.go
new file mode 100644
index 0000000..b3c2af1
--- /dev/null
+++ b/cmd/bulg/main.go
@@ -0,0 +1,447 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ "codeberg.org/snonux/bulg/internal"
+ "codeberg.org/snonux/bulg/internal/anki"
+ "codeberg.org/snonux/bulg/internal/audio"
+ "codeberg.org/snonux/bulg/internal/image"
+)
+
+var (
+ // Flags
+ cfgFile string
+ voice string
+ outputDir string
+ audioFormat string
+ imageAPI string
+ batchFile string
+ skipAudio bool
+ skipImages bool
+ imagesPerWord int
+ generateAnki bool
+ // Audio provider flags
+ audioProvider string
+ // Audio tuning flags (espeak)
+ audioPitch int
+ audioAmplitude int
+ audioWordGap int
+ // OpenAI flags
+ openAIModel string
+ openAIVoice string
+ openAISpeed float64
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+ Use: "bulg [word]",
+ Short: "Bulgarian Anki Flashcard Generator",
+ Long: `bulg generates Anki flashcard materials from Bulgarian words.
+
+It creates audio pronunciation files using espeak-ng and downloads
+representative images from web search APIs.
+
+Example:
+ bulg ябълка # Generate materials for "apple"
+ bulg --batch words.txt # Process multiple words from file`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: runCommand,
+ Version: internal.Version,
+}
+
+func init() {
+ cobra.OnInitialize(initConfig)
+
+ // Global flags
+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bulg.yaml)")
+
+ // Local flags
+ rootCmd.Flags().StringVarP(&voice, "voice", "v", "bg+f1", "Voice variant (bg, bg+m1, bg+f1, etc.)")
+ rootCmd.Flags().StringVarP(&outputDir, "output", "o", "./anki_cards", "Output directory")
+ rootCmd.Flags().StringVarP(&audioFormat, "format", "f", "mp3", "Audio format (wav or mp3)")
+ rootCmd.Flags().StringVar(&imageAPI, "image-api", "pixabay", "Image source (pixabay or unsplash)")
+ rootCmd.Flags().StringVar(&batchFile, "batch", "", "Process words from file (one per line)")
+ rootCmd.Flags().BoolVar(&skipAudio, "skip-audio", false, "Skip audio generation")
+ rootCmd.Flags().BoolVar(&skipImages, "skip-images", false, "Skip image download")
+ rootCmd.Flags().IntVar(&imagesPerWord, "images-per-word", 1, "Number of images to download per word")
+ rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file")
+
+ // Audio provider selection
+ rootCmd.Flags().StringVar(&audioProvider, "audio-provider", "espeak", "Audio provider: espeak or openai")
+
+ // Audio tuning flags (espeak)
+ rootCmd.Flags().IntVar(&audioPitch, "pitch", 50, "Audio pitch adjustment (0-99, default 50, espeak only)")
+ rootCmd.Flags().IntVar(&audioAmplitude, "amplitude", 100, "Audio volume (0-200, default 100, espeak only)")
+ rootCmd.Flags().IntVar(&audioWordGap, "word-gap", 0, "Gap between words in 10ms units (default 0, espeak only)")
+
+ // OpenAI flags
+ rootCmd.Flags().StringVar(&openAIModel, "openai-model", "tts-1", "OpenAI model: tts-1 or tts-1-hd")
+ rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "nova", "OpenAI voice: alloy, echo, fable, onyx, nova, shimmer")
+ rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 1.0, "OpenAI speech speed (0.25 to 4.0)")
+
+ // Bind flags to viper
+ viper.BindPFlag("audio.provider", rootCmd.Flags().Lookup("audio-provider"))
+ viper.BindPFlag("audio.voice", rootCmd.Flags().Lookup("voice"))
+ viper.BindPFlag("audio.format", rootCmd.Flags().Lookup("format"))
+ viper.BindPFlag("audio.pitch", rootCmd.Flags().Lookup("pitch"))
+ viper.BindPFlag("audio.amplitude", rootCmd.Flags().Lookup("amplitude"))
+ viper.BindPFlag("audio.word_gap", rootCmd.Flags().Lookup("word-gap"))
+ viper.BindPFlag("audio.openai_model", rootCmd.Flags().Lookup("openai-model"))
+ viper.BindPFlag("audio.openai_voice", rootCmd.Flags().Lookup("openai-voice"))
+ viper.BindPFlag("audio.openai_speed", rootCmd.Flags().Lookup("openai-speed"))
+ viper.BindPFlag("output.directory", rootCmd.Flags().Lookup("output"))
+ viper.BindPFlag("image.provider", rootCmd.Flags().Lookup("image-api"))
+}
+
+func initConfig() {
+ if cfgFile != "" {
+ // Use config file from the flag
+ viper.SetConfigFile(cfgFile)
+ } else {
+ // Find home directory
+ home, err := os.UserHomeDir()
+ cobra.CheckErr(err)
+
+ // Search config in home directory with name ".bulg" (without extension)
+ viper.AddConfigPath(home)
+ viper.AddConfigPath(".")
+ viper.SetConfigType("yaml")
+ viper.SetConfigName(".bulg")
+ }
+
+ // Environment variables
+ viper.SetEnvPrefix("BULG")
+ viper.AutomaticEnv()
+
+ // Read config file
+ if err := viper.ReadInConfig(); err == nil {
+ fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
+ }
+}
+
+func runCommand(cmd *cobra.Command, args []string) error {
+ // Determine words to process
+ var words []string
+
+ if batchFile != "" {
+ // Read words from file
+ content, err := os.ReadFile(batchFile)
+ if err != nil {
+ return fmt.Errorf("failed to read batch file: %w", err)
+ }
+ // Split by newlines and filter empty lines
+ lines := string(content)
+ for _, line := range splitLines(lines) {
+ if line = trimSpace(line); line != "" {
+ words = append(words, line)
+ }
+ }
+ } else if len(args) > 0 {
+ // Single word from command line
+ words = []string{args[0]}
+ } else {
+ // No input provided
+ return fmt.Errorf("please provide a Bulgarian word or use --batch flag")
+ }
+
+ // Validate words
+ for _, word := range words {
+ if err := audio.ValidateBulgarianText(word); err != nil {
+ return fmt.Errorf("invalid word '%s': %w", word, err)
+ }
+ }
+
+ // Create output directory
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ // Process each word
+ for i, word := range words {
+ fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(words), word)
+
+ if err := processWord(word); err != nil {
+ fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", word, err)
+ // Continue with next word
+ }
+ }
+
+ // Generate Anki CSV if requested
+ if generateAnki {
+ fmt.Printf("\nGenerating Anki import file...\n")
+ if err := generateAnkiCSV(); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: Failed to generate Anki CSV: %v\n", err)
+ } else {
+ fmt.Println("Anki import file created: anki_import.csv")
+ }
+ }
+
+ fmt.Println("\nDone! Materials saved to:", outputDir)
+ return nil
+}
+
+func processWord(word string) error {
+ // Generate audio
+ if !skipAudio {
+ fmt.Printf(" Generating audio...\n")
+ if err := generateAudio(word); err != nil {
+ return fmt.Errorf("audio generation failed: %w", err)
+ }
+ }
+
+ // Download images
+ if !skipImages {
+ fmt.Printf(" Downloading images...\n")
+ if err := downloadImages(word); err != nil {
+ return fmt.Errorf("image download failed: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func generateAudio(word string) error {
+ // Create audio provider configuration
+ providerConfig := &audio.Config{
+ Provider: audioProvider,
+ OutputDir: outputDir,
+ OutputFormat: audioFormat,
+
+ // ESpeak settings
+ ESpeakVoice: voice,
+ ESpeakSpeed: viper.GetInt("audio.speed"),
+ ESpeakPitch: audioPitch,
+ ESpeakAmplitude: audioAmplitude,
+ ESpeakWordGap: audioWordGap,
+
+ // OpenAI settings
+ OpenAIKey: getOpenAIKey(),
+ OpenAIModel: openAIModel,
+ OpenAIVoice: openAIVoice,
+ OpenAISpeed: openAISpeed,
+
+ // Caching
+ EnableCache: viper.GetBool("audio.enable_cache"),
+ CacheDir: viper.GetString("audio.cache_dir"),
+ }
+
+ // Set defaults
+ if providerConfig.ESpeakSpeed == 0 {
+ providerConfig.ESpeakSpeed = 150
+ }
+ if providerConfig.CacheDir == "" {
+ providerConfig.CacheDir = "./.audio_cache"
+ }
+
+ // Use config file values if not overridden by flags
+ if audioProvider == "espeak" && viper.IsSet("audio.provider") {
+ providerConfig.Provider = viper.GetString("audio.provider")
+ }
+ if audioPitch == 50 && viper.IsSet("audio.pitch") {
+ providerConfig.ESpeakPitch = viper.GetInt("audio.pitch")
+ }
+ if audioAmplitude == 100 && viper.IsSet("audio.amplitude") {
+ providerConfig.ESpeakAmplitude = viper.GetInt("audio.amplitude")
+ }
+ if audioWordGap == 0 && viper.IsSet("audio.word_gap") {
+ providerConfig.ESpeakWordGap = viper.GetInt("audio.word_gap")
+ }
+ if openAIModel == "tts-1" && viper.IsSet("audio.openai_model") {
+ providerConfig.OpenAIModel = viper.GetString("audio.openai_model")
+ }
+ if openAIVoice == "nova" && viper.IsSet("audio.openai_voice") {
+ providerConfig.OpenAIVoice = viper.GetString("audio.openai_voice")
+ }
+ if openAISpeed == 1.0 && viper.IsSet("audio.openai_speed") {
+ providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
+ }
+
+ // Create the audio provider
+ provider, err := audio.NewProvider(providerConfig)
+ if err != nil {
+ // If OpenAI fails, try to create a fallback to espeak
+ if providerConfig.Provider == "openai" {
+ fmt.Printf("Warning: OpenAI provider failed (%v), falling back to espeak-ng\n", err)
+ providerConfig.Provider = "espeak"
+ fallbackProvider, fallbackErr := audio.NewProvider(providerConfig)
+ if fallbackErr != nil {
+ return fmt.Errorf("both OpenAI and espeak-ng failed: %v", fallbackErr)
+ }
+ provider = fallbackProvider
+ } else {
+ return err
+ }
+ }
+
+ // Generate audio file
+ filename := sanitizeFilename(word)
+ outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat))
+
+ ctx := context.Background()
+ return provider.GenerateAudio(ctx, word, outputFile)
+}
+
+func downloadImages(word string) error {
+ // Create image searcher based on provider
+ var searcher image.ImageSearcher
+ var err error
+
+ switch imageAPI {
+ case "pixabay":
+ apiKey := viper.GetString("image.pixabay_key")
+ searcher = image.NewPixabayClient(apiKey)
+
+ case "unsplash":
+ apiKey := viper.GetString("image.unsplash_key")
+ if apiKey == "" {
+ return fmt.Errorf("Unsplash API key is required in config")
+ }
+ searcher, err = image.NewUnsplashClient(apiKey)
+ if err != nil {
+ return err
+ }
+
+ default:
+ return fmt.Errorf("unknown image provider: %s", imageAPI)
+ }
+
+ // Create downloader
+ downloadOpts := &image.DownloadOptions{
+ OutputDir: outputDir,
+ OverwriteExisting: false,
+ CreateDir: true,
+ FileNamePattern: "{word}_{index}",
+ MaxSizeBytes: 5 * 1024 * 1024, // 5MB
+ }
+
+ downloader := image.NewDownloader(searcher, downloadOpts)
+
+ // Download images
+ ctx := context.Background()
+ if imagesPerWord == 1 {
+ _, path, err := downloader.DownloadBestMatch(ctx, word)
+ if err != nil {
+ return err
+ }
+ fmt.Printf(" Downloaded: %s\n", path)
+ } else {
+ paths, err := downloader.DownloadMultiple(ctx, word, imagesPerWord)
+ if err != nil {
+ return err
+ }
+ for _, path := range paths {
+ fmt.Printf(" Downloaded: %s\n", path)
+ }
+ }
+
+ return nil
+}
+
+func sanitizeFilename(s string) string {
+ // Simple filename sanitization
+ result := ""
+ for _, r := range s {
+ if isAlphaNumeric(r) || r == '-' || r == '_' {
+ result += string(r)
+ } else {
+ result += "_"
+ }
+ }
+ return result
+}
+
+func isAlphaNumeric(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') ||
+ (r >= 'А' && r <= 'Я')
+}
+
+func splitLines(s string) []string {
+ // Simple line splitter
+ var lines []string
+ current := ""
+ for _, r := range s {
+ if r == '\n' {
+ lines = append(lines, current)
+ current = ""
+ } else if r != '\r' {
+ current += string(r)
+ }
+ }
+ if current != "" {
+ lines = append(lines, current)
+ }
+ return lines
+}
+
+func trimSpace(s string) string {
+ // Simple trim implementation
+ start := 0
+ end := len(s)
+
+ // Trim from start
+ for start < end && isSpace(rune(s[start])) {
+ start++
+ }
+
+ // Trim from end
+ for end > start && isSpace(rune(s[end-1])) {
+ end--
+ }
+
+ return s[start:end]
+}
+
+func isSpace(r rune) bool {
+ return r == ' ' || r == '\t' || r == '\n' || r == '\r'
+}
+
+func generateAnkiCSV() error {
+ // Create Anki generator
+ gen := anki.NewGenerator(&anki.GeneratorOptions{
+ OutputPath: filepath.Join(outputDir, "anki_import.csv"),
+ MediaFolder: outputDir,
+ IncludeHeaders: true,
+ AudioFormat: audioFormat,
+ })
+
+ // Generate cards from output directory
+ if err := gen.GenerateFromDirectory(outputDir); err != nil {
+ return fmt.Errorf("failed to generate cards: %w", err)
+ }
+
+ // Generate CSV
+ if err := gen.GenerateCSV(); err != nil {
+ return fmt.Errorf("failed to generate CSV: %w", err)
+ }
+
+ // Print stats
+ total, withAudio, withImages := gen.Stats()
+ fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n",
+ total, withAudio, withImages)
+
+ return nil
+}
+
+func getOpenAIKey() string {
+ // First check environment variable
+ if key := os.Getenv("OPENAI_API_KEY"); key != "" {
+ return key
+ }
+
+ // Then check config file
+ return viper.GetString("audio.openai_key")
+}
+
+func main() {
+ if err := rootCmd.Execute(); err != nil {
+ os.Exit(1)
+ }
+} \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2a00223
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,24 @@
+module codeberg.org/snonux/bulg
+
+go 1.24.4
+
+require (
+ github.com/fsnotify/fsnotify v1.8.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+ github.com/sagikazarmark/locafero v0.7.0 // indirect
+ github.com/sashabaranov/go-openai v1.40.5 // 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/cobra v1.9.1 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/spf13/viper v1.20.1 // indirect
+ github.com/subosito/gotenv v1.6.0 // 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
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..58daf75
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,44 @@
+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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+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=
+github.com/sashabaranov/go-openai v1.40.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
+github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+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/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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+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=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/anki/doc.go b/internal/anki/doc.go
new file mode 100644
index 0000000..5fd3d5f
--- /dev/null
+++ b/internal/anki/doc.go
@@ -0,0 +1,3 @@
+// Package anki provides functionality to generate Anki-compatible
+// flashcard formats from Bulgarian words, audio, and images.
+package anki \ No newline at end of file
diff --git a/internal/anki/generator.go b/internal/anki/generator.go
new file mode 100644
index 0000000..22221f3
--- /dev/null
+++ b/internal/anki/generator.go
@@ -0,0 +1,318 @@
+package anki
+
+import (
+ "encoding/csv"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Card represents a single Anki flashcard
+type Card struct {
+ Bulgarian string // The Bulgarian word/phrase
+ AudioFile string // Path to audio file
+ ImageFiles []string // Paths to image files
+ Translation string // Optional translation
+ Notes string // Optional notes
+}
+
+// GeneratorOptions configures the Anki export
+type GeneratorOptions struct {
+ OutputPath string // Output CSV file path
+ MediaFolder string // Folder containing media files
+ IncludeHeaders bool // Include CSV headers
+ AudioFormat string // Audio file format (mp3, wav)
+ ImageFormat string // Image file format (jpg, png)
+}
+
+// DefaultGeneratorOptions returns sensible defaults
+func DefaultGeneratorOptions() *GeneratorOptions {
+ return &GeneratorOptions{
+ OutputPath: "anki_import.csv",
+ MediaFolder: ".",
+ IncludeHeaders: true,
+ AudioFormat: "mp3",
+ ImageFormat: "jpg",
+ }
+}
+
+// Generator creates Anki-compatible import files
+type Generator struct {
+ options *GeneratorOptions
+ cards []Card
+}
+
+// NewGenerator creates a new Anki generator
+func NewGenerator(options *GeneratorOptions) *Generator {
+ if options == nil {
+ options = DefaultGeneratorOptions()
+ }
+ return &Generator{
+ options: options,
+ cards: make([]Card, 0),
+ }
+}
+
+// AddCard adds a card to the collection
+func (g *Generator) AddCard(card Card) {
+ g.cards = append(g.cards, card)
+}
+
+// GenerateCSV creates a CSV file for Anki import
+func (g *Generator) GenerateCSV() error {
+ // Create output file
+ file, err := os.Create(g.options.OutputPath)
+ if err != nil {
+ return fmt.Errorf("failed to create CSV file: %w", err)
+ }
+ defer file.Close()
+
+ // Create CSV writer
+ writer := csv.NewWriter(file)
+ defer writer.Flush()
+
+ // Write headers if requested
+ if g.options.IncludeHeaders {
+ headers := []string{"Bulgarian", "Audio", "Image", "Translation", "Notes"}
+ if err := writer.Write(headers); err != nil {
+ return fmt.Errorf("failed to write headers: %w", err)
+ }
+ }
+
+ // Write cards
+ for _, card := range g.cards {
+ record := []string{
+ card.Bulgarian,
+ g.formatAudioField(card.AudioFile),
+ g.formatImageField(card.ImageFiles),
+ card.Translation,
+ card.Notes,
+ }
+
+ if err := writer.Write(record); err != nil {
+ return fmt.Errorf("failed to write card: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// formatAudioField formats the audio file reference for Anki
+func (g *Generator) formatAudioField(audioFile string) string {
+ if audioFile == "" {
+ return ""
+ }
+
+ // Get just the filename
+ filename := filepath.Base(audioFile)
+
+ // Anki audio format: [sound:filename.mp3]
+ return fmt.Sprintf("[sound:%s]", filename)
+}
+
+// formatImageField formats image file references for Anki
+func (g *Generator) formatImageField(imageFiles []string) string {
+ if len(imageFiles) == 0 {
+ return ""
+ }
+
+ // For multiple images, we'll use HTML to display them
+ if len(imageFiles) == 1 {
+ filename := filepath.Base(imageFiles[0])
+ return fmt.Sprintf(`<img src="%s">`, filename)
+ }
+
+ // Multiple images - create a simple layout
+ var html strings.Builder
+ html.WriteString(`<div style="display: flex; flex-wrap: wrap; gap: 10px;">`)
+
+ for _, imageFile := range imageFiles {
+ filename := filepath.Base(imageFile)
+ html.WriteString(fmt.Sprintf(`<img src="%s" style="max-width: 200px; height: auto;">`, filename))
+ }
+
+ html.WriteString(`</div>`)
+ return html.String()
+}
+
+// GenerateFromDirectory creates cards from a directory of materials
+func (g *Generator) GenerateFromDirectory(dir string) error {
+ // Map to group files by word
+ wordFiles := make(map[string]*Card)
+
+ // Walk the directory
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories
+ if info.IsDir() {
+ return nil
+ }
+
+ // Get filename without extension
+ filename := info.Name()
+ ext := filepath.Ext(filename)
+ base := strings.TrimSuffix(filename, ext)
+
+ // Skip attribution files
+ if strings.HasSuffix(base, "_attribution") {
+ return nil
+ }
+
+ // Extract word from filename (assumes format: word_type.ext or word_index.ext)
+ parts := strings.Split(base, "_")
+ if len(parts) == 0 {
+ return nil
+ }
+
+ word := parts[0]
+
+ // Get or create card for this word
+ card, exists := wordFiles[word]
+ if !exists {
+ card = &Card{
+ Bulgarian: word,
+ ImageFiles: make([]string, 0),
+ }
+ wordFiles[word] = card
+ }
+
+ // Add file to appropriate field
+ switch strings.ToLower(ext) {
+ case ".mp3", ".wav":
+ if card.AudioFile == "" { // Use first audio file found
+ card.AudioFile = path
+ }
+ case ".jpg", ".jpeg", ".png", ".gif":
+ card.ImageFiles = append(card.ImageFiles, path)
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to walk directory: %w", err)
+ }
+
+ // Add all cards to generator
+ for _, card := range wordFiles {
+ g.AddCard(*card)
+ }
+
+ return nil
+}
+
+// GeneratePackage creates a complete Anki package with media files
+func (g *Generator) GeneratePackage(outputDir string) error {
+ // Create output directory
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ // Create media directory
+ mediaDir := filepath.Join(outputDir, "collection.media")
+ if err := os.MkdirAll(mediaDir, 0755); err != nil {
+ return fmt.Errorf("failed to create media directory: %w", err)
+ }
+
+ // Copy media files and update paths
+ for i, card := range g.cards {
+ // Copy audio file
+ if card.AudioFile != "" {
+ newPath, err := g.copyMediaFile(card.AudioFile, mediaDir)
+ if err != nil {
+ return fmt.Errorf("failed to copy audio file: %w", err)
+ }
+ g.cards[i].AudioFile = newPath
+ }
+
+ // Copy image files
+ newImagePaths := make([]string, 0, len(card.ImageFiles))
+ for _, imagePath := range card.ImageFiles {
+ newPath, err := g.copyMediaFile(imagePath, mediaDir)
+ if err != nil {
+ return fmt.Errorf("failed to copy image file: %w", err)
+ }
+ newImagePaths = append(newImagePaths, newPath)
+ }
+ g.cards[i].ImageFiles = newImagePaths
+ }
+
+ // Update output path to package directory
+ g.options.OutputPath = filepath.Join(outputDir, "import.csv")
+
+ // Generate CSV
+ return g.GenerateCSV()
+}
+
+// copyMediaFile copies a media file to the destination directory
+func (g *Generator) copyMediaFile(src, destDir string) (string, error) {
+ // Get source file info
+ srcInfo, err := os.Stat(src)
+ if err != nil {
+ return "", err
+ }
+
+ // Create destination path
+ filename := filepath.Base(src)
+ destPath := filepath.Join(destDir, filename)
+
+ // Check if file already exists
+ if _, err := os.Stat(destPath); err == nil {
+ // File exists, generate unique name
+ ext := filepath.Ext(filename)
+ base := strings.TrimSuffix(filename, ext)
+ for i := 1; ; i++ {
+ filename = fmt.Sprintf("%s_%d%s", base, i, ext)
+ destPath = filepath.Join(destDir, filename)
+ if _, err := os.Stat(destPath); os.IsNotExist(err) {
+ break
+ }
+ }
+ }
+
+ // Open source file
+ srcFile, err := os.Open(src)
+ if err != nil {
+ return "", err
+ }
+ defer srcFile.Close()
+
+ // Create destination file
+ destFile, err := os.Create(destPath)
+ if err != nil {
+ return "", err
+ }
+ defer destFile.Close()
+
+ // Copy content
+ if _, err := destFile.ReadFrom(srcFile); err != nil {
+ return "", err
+ }
+
+ // Preserve file mode
+ if err := os.Chmod(destPath, srcInfo.Mode()); err != nil {
+ return "", err
+ }
+
+ return filename, nil
+}
+
+// Stats returns statistics about the card collection
+func (g *Generator) Stats() (totalCards, withAudio, withImages int) {
+ totalCards = len(g.cards)
+
+ for _, card := range g.cards {
+ if card.AudioFile != "" {
+ withAudio++
+ }
+ if len(card.ImageFiles) > 0 {
+ withImages++
+ }
+ }
+
+ return
+} \ No newline at end of file
diff --git a/internal/audio/doc.go b/internal/audio/doc.go
new file mode 100644
index 0000000..1fd216b
--- /dev/null
+++ b/internal/audio/doc.go
@@ -0,0 +1,3 @@
+// Package audio provides audio generation functionality using espeak-ng
+// for Bulgarian text-to-speech conversion.
+package audio
diff --git a/internal/audio/espeak.go b/internal/audio/espeak.go
new file mode 100644
index 0000000..cd42360
--- /dev/null
+++ b/internal/audio/espeak.go
@@ -0,0 +1,217 @@
+package audio
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// ESpeakConfig holds configuration for espeak-ng audio generation
+type ESpeakConfig struct {
+ Voice string // Voice variant (e.g., "bg", "bg+m1", "bg+f1")
+ Speed int // Speech speed in words per minute (default: 150)
+ Pitch int // Pitch adjustment, 0 to 99 (default: 50)
+ Amplitude int // Volume/amplitude, 0 to 200 (default: 100)
+ WordGap int // Gap between words in 10ms units (default: 0)
+ OutputDir string // Directory for output files
+}
+
+// DefaultConfig returns the default configuration for Bulgarian voice
+func DefaultConfig() *ESpeakConfig {
+ return &ESpeakConfig{
+ Voice: "bg",
+ Speed: 150,
+ Pitch: 50,
+ Amplitude: 100,
+ WordGap: 0,
+ OutputDir: "./",
+ }
+}
+
+// ESpeak provides an interface to the espeak-ng text-to-speech engine
+type ESpeak struct {
+ config *ESpeakConfig
+}
+
+// New creates a new ESpeak instance with the given configuration
+func New(config *ESpeakConfig) (*ESpeak, error) {
+ // Check if espeak-ng is installed
+ if err := checkESpeakInstalled(); err != nil {
+ return nil, err
+ }
+
+ if config == nil {
+ config = DefaultConfig()
+ }
+
+ return &ESpeak{config: config}, nil
+}
+
+// GenerateAudio generates an audio file for the given Bulgarian text
+func (e *ESpeak) GenerateAudio(text string, outputFile string) error {
+ // Validate input
+ if text == "" {
+ return fmt.Errorf("text cannot be empty")
+ }
+
+ // Ensure output directory exists
+ dir := filepath.Dir(outputFile)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+ }
+
+ // Build espeak-ng command
+ args := []string{
+ "-v", e.config.Voice, // Voice selection
+ "-s", fmt.Sprintf("%d", e.config.Speed), // Speed
+ "-p", fmt.Sprintf("%d", e.config.Pitch), // Pitch
+ "-a", fmt.Sprintf("%d", e.config.Amplitude), // Amplitude/volume
+ }
+
+ // Add word gap if specified
+ if e.config.WordGap > 0 {
+ args = append(args, "-g", fmt.Sprintf("%d", e.config.WordGap))
+ }
+
+ // Add output file and text
+ args = append(args, "-w", outputFile, text)
+
+ cmd := exec.Command("espeak-ng", args...)
+
+ // Run the command
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("espeak-ng failed: %w\nOutput: %s", err, string(output))
+ }
+
+ return nil
+}
+
+// SetVoice updates the voice variant
+func (e *ESpeak) SetVoice(voice string) {
+ e.config.Voice = voice
+}
+
+// SetSpeed updates the speech speed
+func (e *ESpeak) SetSpeed(speed int) {
+ if speed < 80 {
+ speed = 80
+ } else if speed > 450 {
+ speed = 450
+ }
+ e.config.Speed = speed
+}
+
+// SetPitch updates the pitch (0-99, 50 is default)
+func (e *ESpeak) SetPitch(pitch int) {
+ if pitch < 0 {
+ pitch = 0
+ } else if pitch > 99 {
+ pitch = 99
+ }
+ e.config.Pitch = pitch
+}
+
+// SetAmplitude updates the volume/amplitude (0-200, 100 is default)
+func (e *ESpeak) SetAmplitude(amplitude int) {
+ if amplitude < 0 {
+ amplitude = 0
+ } else if amplitude > 200 {
+ amplitude = 200
+ }
+ e.config.Amplitude = amplitude
+}
+
+// SetWordGap updates the gap between words in 10ms units
+func (e *ESpeak) SetWordGap(gap int) {
+ if gap < 0 {
+ gap = 0
+ }
+ e.config.WordGap = gap
+}
+
+// checkESpeakInstalled verifies that espeak-ng is available on the system
+func checkESpeakInstalled() error {
+ cmd := exec.Command("espeak-ng", "--version")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("espeak-ng is not installed or not in PATH: %w", err)
+ }
+ return nil
+}
+
+// ValidateBulgarianText performs basic validation of Bulgarian text
+func ValidateBulgarianText(text string) error {
+ if text == "" {
+ return fmt.Errorf("text cannot be empty")
+ }
+
+ // Check if text contains at least one Cyrillic character
+ hasCyrillic := false
+ for _, r := range text {
+ // Bulgarian Cyrillic range
+ if (r >= 'А' && r <= 'я') || r == 'Ё' || r == 'ё' {
+ hasCyrillic = true
+ break
+ }
+ }
+
+ if !hasCyrillic {
+ return fmt.Errorf("text must contain Bulgarian Cyrillic characters")
+ }
+
+ return nil
+}
+
+// ListVoices returns available Bulgarian voice variants
+func ListVoices() []string {
+ return []string{
+ "bg", // Default Bulgarian voice
+ "bg+m1", // Bulgarian male voice 1
+ "bg+m2", // Bulgarian male voice 2
+ "bg+m3", // Bulgarian male voice 3
+ "bg+f1", // Bulgarian female voice 1
+ "bg+f2", // Bulgarian female voice 2
+ "bg+f3", // Bulgarian female voice 3
+ }
+}
+
+// ConvertWAVToMP3 converts a WAV file to MP3 using ffmpeg
+func ConvertWAVToMP3(wavFile, mp3File string) error {
+ // Check if ffmpeg is installed
+ if err := exec.Command("ffmpeg", "-version").Run(); err != nil {
+ return fmt.Errorf("ffmpeg is not installed or not in PATH: %w", err)
+ }
+
+ cmd := exec.Command("ffmpeg", "-i", wavFile, "-acodec", "mp3", "-y", mp3File)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("ffmpeg conversion failed: %w\nOutput: %s", err, string(output))
+ }
+
+ return nil
+}
+
+// GenerateMP3 generates an MP3 file for the given Bulgarian text
+func (e *ESpeak) GenerateMP3(text string, outputFile string) error {
+ // Generate temporary WAV file
+ tempWAV := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + "_temp.wav"
+
+ // Generate WAV
+ if err := e.GenerateAudio(text, tempWAV); err != nil {
+ return err
+ }
+
+ // Convert to MP3
+ if err := ConvertWAVToMP3(tempWAV, outputFile); err != nil {
+ // Clean up temporary file
+ os.Remove(tempWAV)
+ return err
+ }
+
+ // Clean up temporary file
+ return os.Remove(tempWAV)
+} \ No newline at end of file
diff --git a/internal/audio/espeak_provider.go b/internal/audio/espeak_provider.go
new file mode 100644
index 0000000..177e2a6
--- /dev/null
+++ b/internal/audio/espeak_provider.go
@@ -0,0 +1,65 @@
+package audio
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+)
+
+// ESpeakProvider implements Provider interface for espeak-ng
+type ESpeakProvider struct {
+ espeak *ESpeak
+ format string
+}
+
+// NewESpeakProvider creates a new espeak-ng provider
+func NewESpeakProvider(config *ESpeakConfig) (Provider, error) {
+ espeak, err := New(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ESpeakProvider{
+ espeak: espeak,
+ format: "mp3", // default format
+ }, nil
+}
+
+// GenerateAudio generates audio using espeak-ng
+func (p *ESpeakProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ // Validate Bulgarian text
+ if err := ValidateBulgarianText(text); err != nil {
+ return err
+ }
+
+ // Determine format from output file extension
+ ext := strings.ToLower(filepath.Ext(outputFile))
+
+ switch ext {
+ case ".mp3":
+ return p.espeak.GenerateMP3(text, outputFile)
+ case ".wav":
+ return p.espeak.GenerateAudio(text, outputFile)
+ default:
+ // Default to MP3 if extension is unclear
+ if !strings.HasSuffix(outputFile, ".mp3") {
+ outputFile += ".mp3"
+ }
+ return p.espeak.GenerateMP3(text, outputFile)
+ }
+}
+
+// Name returns the provider name
+func (p *ESpeakProvider) Name() string {
+ return "espeak-ng"
+}
+
+// IsAvailable checks if espeak-ng is installed
+func (p *ESpeakProvider) IsAvailable() error {
+ return checkESpeakInstalled()
+}
+
+// SetFormat sets the output format preference
+func (p *ESpeakProvider) SetFormat(format string) {
+ p.format = format
+} \ No newline at end of file
diff --git a/internal/audio/espeak_test.go b/internal/audio/espeak_test.go
new file mode 100644
index 0000000..66c45f5
--- /dev/null
+++ b/internal/audio/espeak_test.go
@@ -0,0 +1,198 @@
+package audio
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestValidateBulgarianText(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ wantErr bool
+ }{
+ {
+ name: "valid Bulgarian word",
+ text: "ябълка",
+ wantErr: false,
+ },
+ {
+ name: "valid Bulgarian phrase",
+ text: "добър ден",
+ wantErr: false,
+ },
+ {
+ name: "empty string",
+ text: "",
+ wantErr: true,
+ },
+ {
+ name: "only Latin characters",
+ text: "apple",
+ wantErr: true,
+ },
+ {
+ name: "mixed Cyrillic and Latin",
+ text: "ябълка apple",
+ wantErr: false, // Contains at least one Cyrillic
+ },
+ {
+ name: "numbers only",
+ text: "12345",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateBulgarianText(tt.text)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ValidateBulgarianText() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestListVoices(t *testing.T) {
+ voices := ListVoices()
+
+ if len(voices) == 0 {
+ t.Error("ListVoices() returned empty slice")
+ }
+
+ // Check for expected voices
+ expectedVoices := []string{"bg", "bg+m1", "bg+f1"}
+ for _, expected := range expectedVoices {
+ found := false
+ for _, voice := range voices {
+ if voice == expected {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected voice %s not found in list", expected)
+ }
+ }
+}
+
+func TestDefaultConfig(t *testing.T) {
+ config := DefaultConfig()
+
+ if config == nil {
+ t.Fatal("DefaultConfig() returned nil")
+ }
+
+ if config.Voice != "bg" {
+ t.Errorf("Expected default voice 'bg', got '%s'", config.Voice)
+ }
+
+ if config.Speed != 150 {
+ t.Errorf("Expected default speed 150, got %d", config.Speed)
+ }
+
+ if config.OutputDir != "./" {
+ t.Errorf("Expected default output dir './', got '%s'", config.OutputDir)
+ }
+}
+
+func TestNew(t *testing.T) {
+ // This test will fail if espeak-ng is not installed
+ // We'll skip it in that case
+ espeak, err := New(nil)
+ if err != nil {
+ if checkESpeakInstalled() != nil {
+ t.Skip("espeak-ng not installed, skipping test")
+ }
+ t.Fatalf("New() failed: %v", err)
+ }
+
+ if espeak == nil {
+ t.Fatal("New() returned nil ESpeak instance")
+ }
+
+ if espeak.config == nil {
+ t.Fatal("ESpeak instance has nil config")
+ }
+}
+
+func TestSetSpeed(t *testing.T) {
+ config := DefaultConfig()
+ espeak := &ESpeak{config: config}
+
+ tests := []struct {
+ input int
+ expected int
+ }{
+ {150, 150}, // Normal speed
+ {50, 80}, // Below minimum
+ {500, 450}, // Above maximum
+ {200, 200}, // Valid speed
+ }
+
+ for _, tt := range tests {
+ espeak.SetSpeed(tt.input)
+ if espeak.config.Speed != tt.expected {
+ t.Errorf("SetSpeed(%d) resulted in speed %d, expected %d",
+ tt.input, espeak.config.Speed, tt.expected)
+ }
+ }
+}
+
+func TestGenerateAudio_InvalidInput(t *testing.T) {
+ // Skip if espeak-ng not installed
+ if checkESpeakInstalled() != nil {
+ t.Skip("espeak-ng not installed, skipping test")
+ }
+
+ espeak, err := New(nil)
+ if err != nil {
+ t.Fatalf("Failed to create ESpeak: %v", err)
+ }
+
+ // Test with empty text
+ err = espeak.GenerateAudio("", "test.wav")
+ if err == nil {
+ t.Error("GenerateAudio() with empty text should return error")
+ }
+}
+
+func TestGenerateAudio_Integration(t *testing.T) {
+ // Skip if espeak-ng not installed
+ if checkESpeakInstalled() != nil {
+ t.Skip("espeak-ng not installed, skipping integration test")
+ }
+
+ // Create temporary directory
+ tempDir := t.TempDir()
+
+ config := &ESpeakConfig{
+ Voice: "bg",
+ Speed: 150,
+ OutputDir: tempDir,
+ }
+
+ espeak, err := New(config)
+ if err != nil {
+ t.Fatalf("Failed to create ESpeak: %v", err)
+ }
+
+ // Generate audio file
+ outputFile := filepath.Join(tempDir, "test.wav")
+ err = espeak.GenerateAudio("ябълка", outputFile)
+ if err != nil {
+ t.Fatalf("GenerateAudio() failed: %v", err)
+ }
+
+ // Check if file was created
+ info, err := os.Stat(outputFile)
+ if err != nil {
+ t.Fatalf("Output file not created: %v", err)
+ }
+
+ // Check file size (WAV file should have some content)
+ if info.Size() == 0 {
+ t.Error("Output file is empty")
+ }
+} \ No newline at end of file
diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go
new file mode 100644
index 0000000..9efbcd2
--- /dev/null
+++ b/internal/audio/openai_provider.go
@@ -0,0 +1,219 @@
+package audio
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sashabaranov/go-openai"
+)
+
+// OpenAIProvider implements Provider interface for OpenAI TTS
+type OpenAIProvider struct {
+ client *openai.Client
+ config *Config
+ cacheDir string
+ enableCache bool
+}
+
+// NewOpenAIProvider creates a new OpenAI TTS provider
+func NewOpenAIProvider(config *Config) (Provider, error) {
+ if config.OpenAIKey == "" {
+ return nil, fmt.Errorf("OpenAI API key is required")
+ }
+
+ client := openai.NewClient(config.OpenAIKey)
+
+ provider := &OpenAIProvider{
+ client: client,
+ config: config,
+ cacheDir: config.CacheDir,
+ enableCache: config.EnableCache,
+ }
+
+ // Create cache directory if caching is enabled
+ if provider.enableCache && provider.cacheDir != "" {
+ if err := os.MkdirAll(provider.cacheDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create cache directory: %w", err)
+ }
+ }
+
+ return provider, nil
+}
+
+// GenerateAudio generates audio using OpenAI TTS
+func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ // Validate Bulgarian text
+ if err := ValidateBulgarianText(text); err != nil {
+ return err
+ }
+
+ // Check cache first
+ if p.enableCache {
+ cacheFile := p.getCacheFilePath(text)
+ if _, err := os.Stat(cacheFile); err == nil {
+ // Cache hit - copy cached file
+ return p.copyFile(cacheFile, outputFile)
+ }
+ }
+
+ // Prepare the TTS request
+ req := openai.CreateSpeechRequest{
+ Model: openai.SpeechModel(p.config.OpenAIModel),
+ Input: text,
+ Voice: openai.SpeechVoice(p.config.OpenAIVoice),
+ Speed: p.config.OpenAISpeed,
+ }
+
+ // Determine response format based on output file extension
+ ext := strings.ToLower(filepath.Ext(outputFile))
+ switch ext {
+ case ".mp3":
+ req.ResponseFormat = openai.SpeechResponseFormatMp3
+ case ".wav":
+ req.ResponseFormat = openai.SpeechResponseFormatWav
+ case ".opus":
+ req.ResponseFormat = openai.SpeechResponseFormatOpus
+ case ".aac":
+ req.ResponseFormat = openai.SpeechResponseFormatAac
+ case ".flac":
+ req.ResponseFormat = openai.SpeechResponseFormatFlac
+ default:
+ req.ResponseFormat = openai.SpeechResponseFormatMp3
+ if !strings.HasSuffix(outputFile, ".mp3") {
+ outputFile += ".mp3"
+ }
+ }
+
+ // Make the API call
+ response, err := p.client.CreateSpeech(ctx, req)
+ if err != nil {
+ return fmt.Errorf("OpenAI TTS API error: %w", err)
+ }
+ defer response.Close()
+
+ // Ensure output directory exists
+ dir := filepath.Dir(outputFile)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+ }
+
+ // Create output file
+ out, err := os.Create(outputFile)
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %w", err)
+ }
+ defer out.Close()
+
+ // Copy the audio data
+ written, err := io.Copy(out, response)
+ if err != nil {
+ return fmt.Errorf("failed to write audio file: %w", err)
+ }
+
+ if written == 0 {
+ return fmt.Errorf("no audio data received from OpenAI")
+ }
+
+ // Cache the result if caching is enabled
+ if p.enableCache {
+ cacheFile := p.getCacheFilePath(text)
+ _ = p.copyFile(outputFile, cacheFile) // Ignore cache errors
+ }
+
+ return nil
+}
+
+// Name returns the provider name
+func (p *OpenAIProvider) Name() string {
+ return "openai"
+}
+
+// IsAvailable checks if the OpenAI API is accessible
+func (p *OpenAIProvider) IsAvailable() error {
+ if p.config.OpenAIKey == "" {
+ return fmt.Errorf("OpenAI API key not configured")
+ }
+
+ // We could make a test API call here, but that would use credits
+ // For now, just check that we have a key
+ return nil
+}
+
+// getCacheFilePath generates a cache file path for the given text
+func (p *OpenAIProvider) getCacheFilePath(text string) string {
+ // Create a hash of the text and settings
+ h := md5.New()
+ h.Write([]byte(text))
+ h.Write([]byte(p.config.OpenAIModel))
+ h.Write([]byte(p.config.OpenAIVoice))
+ h.Write([]byte(fmt.Sprintf("%.2f", p.config.OpenAISpeed)))
+ hash := hex.EncodeToString(h.Sum(nil))
+
+ // Use first 2 chars as subdirectory for better file system performance
+ subdir := hash[:2]
+ filename := hash[2:] + ".mp3"
+
+ return filepath.Join(p.cacheDir, subdir, filename)
+}
+
+// copyFile copies a file from src to dst
+func (p *OpenAIProvider) copyFile(src, dst string) error {
+ // Ensure destination directory exists
+ dir := filepath.Dir(dst)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ }
+
+ source, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ destination, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destination.Close()
+
+ _, err = io.Copy(destination, source)
+ return err
+}
+
+// ClearCache removes all cached audio files
+func (p *OpenAIProvider) ClearCache() error {
+ if p.cacheDir == "" {
+ return nil
+ }
+ return os.RemoveAll(p.cacheDir)
+}
+
+// GetCacheStats returns cache statistics
+func (p *OpenAIProvider) GetCacheStats() (fileCount int, totalSize int64, err error) {
+ if !p.enableCache || p.cacheDir == "" {
+ return 0, 0, nil
+ }
+
+ err = filepath.Walk(p.cacheDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ fileCount++
+ totalSize += info.Size()
+ }
+ return nil
+ })
+
+ return fileCount, totalSize, err
+} \ No newline at end of file
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
new file mode 100644
index 0000000..5b8c336
--- /dev/null
+++ b/internal/audio/provider.go
@@ -0,0 +1,139 @@
+package audio
+
+import (
+ "context"
+ "fmt"
+)
+
+// Provider defines the interface for text-to-speech providers
+type Provider interface {
+ // GenerateAudio generates audio from text and saves it to the specified file
+ GenerateAudio(ctx context.Context, text string, outputFile string) error
+
+ // Name returns the provider name
+ Name() string
+
+ // IsAvailable checks if the provider is properly configured and available
+ IsAvailable() error
+}
+
+// Config holds common configuration for audio providers
+type Config struct {
+ Provider string // Provider name: "espeak" or "openai"
+ OutputDir string // Directory for output files
+ OutputFormat string // Output format: "mp3" or "wav"
+
+ // ESpeak-specific settings
+ ESpeakVoice string
+ ESpeakSpeed int
+ ESpeakPitch int
+ ESpeakAmplitude int
+ ESpeakWordGap int
+
+ // OpenAI-specific settings
+ OpenAIKey string
+ OpenAIModel string // "tts-1" or "tts-1-hd"
+ OpenAIVoice string // "alloy", "echo", "fable", "onyx", "nova", "shimmer"
+ OpenAISpeed float64 // 0.25 to 4.0
+
+ // Caching settings
+ EnableCache bool
+ CacheDir string
+}
+
+// DefaultConfig returns default configuration
+func DefaultProviderConfig() *Config {
+ return &Config{
+ Provider: "espeak",
+ OutputDir: "./",
+ OutputFormat: "mp3",
+ ESpeakVoice: "bg",
+ ESpeakSpeed: 150,
+ ESpeakPitch: 50,
+ ESpeakAmplitude: 100,
+ ESpeakWordGap: 0,
+ OpenAIModel: "tts-1",
+ OpenAIVoice: "nova",
+ OpenAISpeed: 1.0,
+ EnableCache: true,
+ CacheDir: "./.audio_cache",
+ }
+}
+
+// NewProvider creates the appropriate audio provider based on configuration
+func NewProvider(config *Config) (Provider, error) {
+ if config == nil {
+ config = DefaultProviderConfig()
+ }
+
+ switch config.Provider {
+ case "espeak", "espeak-ng":
+ espeakConfig := &ESpeakConfig{
+ Voice: config.ESpeakVoice,
+ Speed: config.ESpeakSpeed,
+ Pitch: config.ESpeakPitch,
+ Amplitude: config.ESpeakAmplitude,
+ WordGap: config.ESpeakWordGap,
+ OutputDir: config.OutputDir,
+ }
+ return NewESpeakProvider(espeakConfig)
+
+ case "openai":
+ if config.OpenAIKey == "" {
+ return nil, fmt.Errorf("OpenAI API key is required")
+ }
+ return NewOpenAIProvider(config)
+
+ default:
+ return nil, fmt.Errorf("unknown audio provider: %s", config.Provider)
+ }
+}
+
+// ProviderWithFallback wraps a primary provider with a fallback option
+type ProviderWithFallback struct {
+ primary Provider
+ fallback Provider
+}
+
+// NewProviderWithFallback creates a provider that falls back to secondary if primary fails
+func NewProviderWithFallback(primary, fallback Provider) Provider {
+ return &ProviderWithFallback{
+ primary: primary,
+ fallback: fallback,
+ }
+}
+
+// GenerateAudio tries primary provider first, falls back to secondary on error
+func (p *ProviderWithFallback) GenerateAudio(ctx context.Context, text string, outputFile string) error {
+ err := p.primary.GenerateAudio(ctx, text, outputFile)
+ if err != nil {
+ // Log the primary error
+ fmt.Printf("Primary provider (%s) failed: %v. Falling back to %s\n",
+ p.primary.Name(), err, p.fallback.Name())
+
+ // Try fallback
+ return p.fallback.GenerateAudio(ctx, text, outputFile)
+ }
+ return nil
+}
+
+// Name returns the provider name
+func (p *ProviderWithFallback) Name() string {
+ return fmt.Sprintf("%s (fallback: %s)", p.primary.Name(), p.fallback.Name())
+}
+
+// IsAvailable checks if at least one provider is available
+func (p *ProviderWithFallback) IsAvailable() error {
+ primaryErr := p.primary.IsAvailable()
+ if primaryErr == nil {
+ return nil
+ }
+
+ fallbackErr := p.fallback.IsAvailable()
+ if fallbackErr == nil {
+ return nil
+ }
+
+ return fmt.Errorf("both providers unavailable: primary=%v, fallback=%v",
+ primaryErr, fallbackErr)
+} \ No newline at end of file
diff --git a/internal/config/doc.go b/internal/config/doc.go
new file mode 100644
index 0000000..b616b99
--- /dev/null
+++ b/internal/config/doc.go
@@ -0,0 +1,3 @@
+// Package config provides configuration management for the bulg
+// application using viper for flexible configuration options.
+package config \ No newline at end of file
diff --git a/internal/image/doc.go b/internal/image/doc.go
new file mode 100644
index 0000000..2fb3723
--- /dev/null
+++ b/internal/image/doc.go
@@ -0,0 +1,3 @@
+// Package image provides image search functionality to find
+// representative images for Bulgarian words from various APIs.
+package image \ No newline at end of file
diff --git a/internal/image/download.go b/internal/image/download.go
new file mode 100644
index 0000000..f684260
--- /dev/null
+++ b/internal/image/download.go
@@ -0,0 +1,244 @@
+package image
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// DownloadOptions configures image download behavior
+type DownloadOptions struct {
+ OutputDir string // Directory to save images
+ OverwriteExisting bool // Whether to overwrite existing files
+ CreateDir bool // Create output directory if it doesn't exist
+ FileNamePattern string // Pattern for file naming (e.g., "{word}_{source}")
+ MaxSizeBytes int64 // Maximum file size to download (0 = no limit)
+}
+
+// DefaultDownloadOptions returns sensible defaults for image downloads
+func DefaultDownloadOptions() *DownloadOptions {
+ return &DownloadOptions{
+ OutputDir: "./images",
+ OverwriteExisting: false,
+ CreateDir: true,
+ FileNamePattern: "{word}_{source}",
+ MaxSizeBytes: 10 * 1024 * 1024, // 10MB
+ }
+}
+
+// Downloader handles image downloads from search results
+type Downloader struct {
+ searcher ImageSearcher
+ options *DownloadOptions
+}
+
+// NewDownloader creates a new image downloader
+func NewDownloader(searcher ImageSearcher, options *DownloadOptions) *Downloader {
+ if options == nil {
+ options = DefaultDownloadOptions()
+ }
+ return &Downloader{
+ searcher: searcher,
+ options: options,
+ }
+}
+
+// DownloadImage downloads a single image to the specified path
+func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, outputPath string) error {
+ // Ensure directory exists
+ dir := filepath.Dir(outputPath)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+ }
+
+ // Check if file already exists
+ if !d.options.OverwriteExisting {
+ if _, err := os.Stat(outputPath); err == nil {
+ return fmt.Errorf("file already exists: %s", outputPath)
+ }
+ }
+
+ // Download the image
+ reader, err := d.searcher.Download(ctx, result.URL)
+ if err != nil {
+ return fmt.Errorf("failed to download image: %w", err)
+ }
+ defer reader.Close()
+
+ // Create output file
+ file, err := os.Create(outputPath)
+ if err != nil {
+ return fmt.Errorf("failed to create file: %w", err)
+ }
+ defer file.Close()
+
+ // Copy with size limit if specified
+ var written int64
+ if d.options.MaxSizeBytes > 0 {
+ written, err = io.CopyN(file, reader, d.options.MaxSizeBytes)
+ if err != nil && err != io.EOF {
+ os.Remove(outputPath) // Clean up on error
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ // Check if we hit the size limit
+ if written == d.options.MaxSizeBytes {
+ // Try to read one more byte to see if file is larger
+ if _, err := reader.Read(make([]byte, 1)); err != io.EOF {
+ os.Remove(outputPath) // Clean up
+ return fmt.Errorf("image exceeds maximum size of %d bytes", d.options.MaxSizeBytes)
+ }
+ }
+ } else {
+ written, err = io.Copy(file, reader)
+ if err != nil {
+ os.Remove(outputPath) // Clean up on error
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+ }
+
+ // Save attribution if required
+ if attribution := d.searcher.GetAttribution(result); attribution != "" {
+ attrPath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + "_attribution.txt"
+ if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil {
+ // Non-fatal error - log but don't fail the download
+ fmt.Fprintf(os.Stderr, "Warning: failed to save attribution: %v\n", err)
+ }
+ }
+
+ return nil
+}
+
+// DownloadBestMatch downloads the best matching image for a query
+func (d *Downloader) DownloadBestMatch(ctx context.Context, query string) (*SearchResult, string, error) {
+ // Search for images
+ opts := DefaultSearchOptions(query)
+ opts.PerPage = 5 // Get top 5 results
+
+ results, err := d.searcher.Search(ctx, opts)
+ if err != nil {
+ return nil, "", fmt.Errorf("search failed: %w", err)
+ }
+
+ if len(results) == 0 {
+ return nil, "", fmt.Errorf("no images found for query: %s", query)
+ }
+
+ // Try to download the first available image
+ for i, result := range results {
+ // Generate filename
+ filename := d.generateFileName(query, &result, i)
+ outputPath := filepath.Join(d.options.OutputDir, filename)
+
+ // Try to download
+ err := d.DownloadImage(ctx, &result, outputPath)
+ if err == nil {
+ return &result, outputPath, nil
+ }
+
+ // Log error and try next
+ fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
+ }
+
+ return nil, "", fmt.Errorf("failed to download any images for query: %s", query)
+}
+
+// generateFileName creates a filename based on the pattern
+func (d *Downloader) generateFileName(word string, result *SearchResult, index int) string {
+ // Start with the pattern
+ filename := d.options.FileNamePattern
+
+ // Replace placeholders
+ filename = strings.ReplaceAll(filename, "{word}", sanitizeFileName(word))
+ filename = strings.ReplaceAll(filename, "{source}", result.Source)
+ filename = strings.ReplaceAll(filename, "{id}", result.ID)
+ filename = strings.ReplaceAll(filename, "{index}", fmt.Sprintf("%d", index))
+
+ // Determine extension from URL
+ ext := filepath.Ext(result.URL)
+ if ext == "" || len(ext) > 5 { // Probably not a real extension
+ ext = ".jpg" // Default to jpg
+ }
+
+ // Add extension if not present
+ if filepath.Ext(filename) == "" {
+ filename += ext
+ }
+
+ return filename
+}
+
+// sanitizeFileName removes or replaces characters that are problematic in filenames
+func sanitizeFileName(name string) string {
+ // Replace common problematic characters
+ replacer := strings.NewReplacer(
+ "/", "_",
+ "\\", "_",
+ ":", "_",
+ "*", "_",
+ "?", "_",
+ "\"", "_",
+ "<", "_",
+ ">", "_",
+ "|", "_",
+ " ", "_",
+ ".", "_",
+ )
+
+ sanitized := replacer.Replace(name)
+
+ // Ensure the filename is not too long
+ if len(sanitized) > 50 {
+ sanitized = sanitized[:50]
+ }
+
+ return sanitized
+}
+
+// DownloadMultiple downloads multiple images for a query
+func (d *Downloader) DownloadMultiple(ctx context.Context, query string, count int) ([]string, error) {
+ // Search for images
+ opts := DefaultSearchOptions(query)
+ opts.PerPage = count * 2 // Get extra in case some fail
+
+ results, err := d.searcher.Search(ctx, opts)
+ if err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+
+ if len(results) == 0 {
+ return nil, fmt.Errorf("no images found for query: %s", query)
+ }
+
+ // Download up to 'count' images
+ var downloaded []string
+ for i, result := range results {
+ if len(downloaded) >= count {
+ break
+ }
+
+ // Generate filename
+ filename := d.generateFileName(query, &result, i)
+ outputPath := filepath.Join(d.options.OutputDir, filename)
+
+ // Try to download
+ err := d.DownloadImage(ctx, &result, outputPath)
+ if err == nil {
+ downloaded = append(downloaded, outputPath)
+ } else {
+ // Log error and continue
+ fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err)
+ }
+ }
+
+ if len(downloaded) == 0 {
+ return nil, fmt.Errorf("failed to download any images for query: %s", query)
+ }
+
+ return downloaded, nil
+} \ No newline at end of file
diff --git a/internal/image/pixabay.go b/internal/image/pixabay.go
new file mode 100644
index 0000000..7b714b1
--- /dev/null
+++ b/internal/image/pixabay.go
@@ -0,0 +1,231 @@
+package image
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+const (
+ pixabayAPIURL = "https://pixabay.com/api/"
+ pixabayTimeout = 30 * time.Second
+)
+
+// PixabayClient implements ImageSearcher for Pixabay API
+type PixabayClient struct {
+ apiKey string
+ httpClient *http.Client
+ rateLimit *rateLimiter
+}
+
+// pixabayResponse represents the API response structure
+type pixabayResponse struct {
+ Total int `json:"total"`
+ TotalHits int `json:"totalHits"`
+ Hits []pixabayImage `json:"hits"`
+}
+
+// pixabayImage represents a single image in the response
+type pixabayImage struct {
+ ID int `json:"id"`
+ PageURL string `json:"pageURL"`
+ Type string `json:"type"`
+ Tags string `json:"tags"`
+ PreviewURL string `json:"previewURL"`
+ PreviewWidth int `json:"previewWidth"`
+ PreviewHeight int `json:"previewHeight"`
+ WebformatURL string `json:"webformatURL"`
+ WebformatWidth int `json:"webformatWidth"`
+ WebformatHeight int `json:"webformatHeight"`
+ LargeImageURL string `json:"largeImageURL"`
+ ImageWidth int `json:"imageWidth"`
+ ImageHeight int `json:"imageHeight"`
+ Views int `json:"views"`
+ Downloads int `json:"downloads"`
+ Collections int `json:"collections"`
+ Likes int `json:"likes"`
+ Comments int `json:"comments"`
+ UserID int `json:"user_id"`
+ User string `json:"user"`
+ UserImageURL string `json:"userImageURL"`
+}
+
+// rateLimiter implements simple rate limiting
+type rateLimiter struct {
+ requestsPerMinute int
+ requests []time.Time
+}
+
+func newRateLimiter(rpm int) *rateLimiter {
+ return &rateLimiter{
+ requestsPerMinute: rpm,
+ requests: make([]time.Time, 0, rpm),
+ }
+}
+
+func (rl *rateLimiter) wait() {
+ now := time.Now()
+
+ // Remove requests older than 1 minute
+ cutoff := now.Add(-1 * time.Minute)
+ i := 0
+ for i < len(rl.requests) && rl.requests[i].Before(cutoff) {
+ i++
+ }
+ rl.requests = rl.requests[i:]
+
+ // If we're at the limit, wait
+ if len(rl.requests) >= rl.requestsPerMinute {
+ oldestRequest := rl.requests[0]
+ waitDuration := oldestRequest.Add(1 * time.Minute).Sub(now)
+ if waitDuration > 0 {
+ time.Sleep(waitDuration)
+ }
+ }
+
+ // Record this request
+ rl.requests = append(rl.requests, now)
+}
+
+// NewPixabayClient creates a new Pixabay API client
+func NewPixabayClient(apiKey string) *PixabayClient {
+ return &PixabayClient{
+ apiKey: apiKey,
+ httpClient: &http.Client{
+ Timeout: pixabayTimeout,
+ },
+ rateLimit: newRateLimiter(100), // 100 requests per minute
+ }
+}
+
+// Search performs an image search on Pixabay
+func (p *PixabayClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ // Apply rate limiting
+ p.rateLimit.wait()
+
+ // Build query parameters
+ params := url.Values{}
+ if p.apiKey != "" {
+ params.Set("key", p.apiKey)
+ }
+ params.Set("q", opts.Query)
+ params.Set("lang", opts.Language)
+ params.Set("image_type", opts.ImageType)
+ params.Set("safesearch", fmt.Sprintf("%t", opts.SafeSearch))
+ params.Set("per_page", fmt.Sprintf("%d", opts.PerPage))
+ params.Set("page", fmt.Sprintf("%d", opts.Page))
+
+ if opts.Orientation != "all" && opts.Orientation != "" {
+ params.Set("orientation", opts.Orientation)
+ }
+
+ // Make request
+ reqURL := pixabayAPIURL + "?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Check status code
+ if resp.StatusCode == http.StatusTooManyRequests {
+ return nil, &RateLimitError{
+ Provider: "pixabay",
+ RetryAfter: 60,
+ LimitPerHour: 5000,
+ }
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, &SearchError{
+ Provider: "pixabay",
+ Code: fmt.Sprintf("%d", resp.StatusCode),
+ Message: string(body),
+ }
+ }
+
+ // Parse response
+ var pixResp pixabayResponse
+ if err := json.NewDecoder(resp.Body).Decode(&pixResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to SearchResult
+ results := make([]SearchResult, 0, len(pixResp.Hits))
+ for _, hit := range pixResp.Hits {
+ results = append(results, SearchResult{
+ ID: fmt.Sprintf("%d", hit.ID),
+ URL: hit.WebformatURL,
+ ThumbnailURL: hit.PreviewURL,
+ Width: hit.WebformatWidth,
+ Height: hit.WebformatHeight,
+ Description: hit.Tags,
+ Attribution: fmt.Sprintf("Image by %s from Pixabay", hit.User),
+ Source: "pixabay",
+ })
+ }
+
+ return results, nil
+}
+
+// Download downloads an image from the given URL
+func (p *PixabayClient) Download(ctx context.Context, imageURL string) (io.ReadCloser, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create download request: %w", err)
+ }
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("download failed: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
+ }
+
+ return resp.Body, nil
+}
+
+// GetAttribution returns the required attribution text for an image
+func (p *PixabayClient) GetAttribution(result *SearchResult) string {
+ if p.apiKey == "" {
+ // Without API key, attribution is required
+ return result.Attribution
+ }
+ // With API key, attribution is optional but recommended
+ return ""
+}
+
+// Name returns the name of the search provider
+func (p *PixabayClient) Name() string {
+ return "pixabay"
+}
+
+
+// SearchWithTranslation performs a search with automatic translation
+func (p *PixabayClient) SearchWithTranslation(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ // Try with translated query first
+ translatedQuery := translateBulgarianQuery(opts.Query)
+ translatedOpts := *opts
+ translatedOpts.Query = translatedQuery
+
+ results, err := p.Search(ctx, &translatedOpts)
+ if err != nil || len(results) == 0 {
+ // Fall back to original query
+ return p.Search(ctx, opts)
+ }
+
+ return results, nil
+} \ No newline at end of file
diff --git a/internal/image/search.go b/internal/image/search.go
new file mode 100644
index 0000000..acc9dc8
--- /dev/null
+++ b/internal/image/search.go
@@ -0,0 +1,87 @@
+package image
+
+import (
+ "context"
+ "io"
+)
+
+// SearchResult represents a single image search result
+type SearchResult struct {
+ ID string // Unique identifier
+ URL string // Direct URL to the image
+ ThumbnailURL string // URL to thumbnail version
+ Width int // Image width in pixels
+ Height int // Image height in pixels
+ Description string // Image description or tags
+ Attribution string // Attribution text if required
+ Source string // Source provider (e.g., "pixabay", "unsplash")
+}
+
+// SearchOptions configures the image search
+type SearchOptions struct {
+ Query string // Search query (Bulgarian word)
+ Language string // Language code (default: "bg")
+ SafeSearch bool // Enable safe search filtering
+ PerPage int // Number of results per page
+ Page int // Page number (1-based)
+ ImageType string // Type: "photo", "illustration", "vector", "all"
+ Orientation string // Orientation: "horizontal", "vertical", "all"
+}
+
+// DefaultSearchOptions returns sensible defaults for Bulgarian word searches
+func DefaultSearchOptions(query string) *SearchOptions {
+ return &SearchOptions{
+ Query: query,
+ Language: "bg",
+ SafeSearch: true,
+ PerPage: 10,
+ Page: 1,
+ ImageType: "photo",
+ Orientation: "all",
+ }
+}
+
+// ImageSearcher defines the interface for image search providers
+type ImageSearcher interface {
+ // Search performs an image search with the given options
+ Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error)
+
+ // Download downloads an image from the given URL
+ Download(ctx context.Context, url string) (io.ReadCloser, error)
+
+ // GetAttribution returns the required attribution text for an image
+ GetAttribution(result *SearchResult) string
+
+ // Name returns the name of the search provider
+ Name() string
+}
+
+// SearchError represents an error from an image search provider
+type SearchError struct {
+ Provider string
+ Code string
+ Message string
+}
+
+func (e *SearchError) Error() string {
+ return e.Provider + ": " + e.Message
+}
+
+// RateLimitError indicates that the API rate limit has been exceeded
+type RateLimitError struct {
+ Provider string
+ RetryAfter int // Seconds to wait before retry
+ LimitPerHour int
+ LimitPerDay int
+}
+
+func (e *RateLimitError) Error() string {
+ return e.Provider + ": rate limit exceeded"
+}
+
+// DownloadImage is a utility function to download an image to a file
+func DownloadImage(ctx context.Context, searcher ImageSearcher, url string, outputPath string) error {
+ // Implementation will be in a separate download.go file
+ // This is just the interface definition
+ return nil
+} \ No newline at end of file
diff --git a/internal/image/search_test.go b/internal/image/search_test.go
new file mode 100644
index 0000000..b7018d9
--- /dev/null
+++ b/internal/image/search_test.go
@@ -0,0 +1,146 @@
+package image
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+)
+
+// mockSearcher implements ImageSearcher for testing
+type mockSearcher struct {
+ name string
+ searchResults []SearchResult
+ searchErr error
+ downloadErr error
+}
+
+func (m *mockSearcher) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ if m.searchErr != nil {
+ return nil, m.searchErr
+ }
+ return m.searchResults, nil
+}
+
+func (m *mockSearcher) Download(ctx context.Context, url string) (io.ReadCloser, error) {
+ if m.downloadErr != nil {
+ return nil, m.downloadErr
+ }
+ return io.NopCloser(strings.NewReader("mock image data")), nil
+}
+
+func (m *mockSearcher) GetAttribution(result *SearchResult) string {
+ return result.Attribution
+}
+
+func (m *mockSearcher) Name() string {
+ return m.name
+}
+
+func TestDefaultSearchOptions(t *testing.T) {
+ opts := DefaultSearchOptions("ябълка")
+
+ if opts.Query != "ябълка" {
+ t.Errorf("Expected query 'ябълка', got '%s'", opts.Query)
+ }
+
+ if opts.Language != "bg" {
+ t.Errorf("Expected language 'bg', got '%s'", opts.Language)
+ }
+
+ if !opts.SafeSearch {
+ t.Error("Expected SafeSearch to be true")
+ }
+
+ if opts.PerPage != 10 {
+ t.Errorf("Expected PerPage 10, got %d", opts.PerPage)
+ }
+
+ if opts.Page != 1 {
+ t.Errorf("Expected Page 1, got %d", opts.Page)
+ }
+
+ if opts.ImageType != "photo" {
+ t.Errorf("Expected ImageType 'photo', got '%s'", opts.ImageType)
+ }
+}
+
+func TestSearchError(t *testing.T) {
+ err := &SearchError{
+ Provider: "test",
+ Code: "404",
+ Message: "Not found",
+ }
+
+ expected := "test: Not found"
+ if err.Error() != expected {
+ t.Errorf("Expected error '%s', got '%s'", expected, err.Error())
+ }
+}
+
+func TestRateLimitError(t *testing.T) {
+ err := &RateLimitError{
+ Provider: "test",
+ RetryAfter: 60,
+ LimitPerHour: 100,
+ }
+
+ expected := "test: rate limit exceeded"
+ if err.Error() != expected {
+ t.Errorf("Expected error '%s', got '%s'", expected, err.Error())
+ }
+}
+
+func TestMockSearcher(t *testing.T) {
+ mockResults := []SearchResult{
+ {
+ ID: "1",
+ URL: "https://example.com/image1.jpg",
+ Width: 800,
+ Height: 600,
+ Description: "Test image",
+ Source: "mock",
+ },
+ }
+
+ searcher := &mockSearcher{
+ name: "mock",
+ searchResults: mockResults,
+ }
+
+ ctx := context.Background()
+ opts := DefaultSearchOptions("test")
+
+ results, err := searcher.Search(ctx, opts)
+ if err != nil {
+ t.Fatalf("Search() failed: %v", err)
+ }
+
+ if len(results) != 1 {
+ t.Fatalf("Expected 1 result, got %d", len(results))
+ }
+
+ if results[0].ID != "1" {
+ t.Errorf("Expected ID '1', got '%s'", results[0].ID)
+ }
+}
+
+func TestDownloadOptions(t *testing.T) {
+ opts := DefaultDownloadOptions()
+
+ if opts.OutputDir != "./images" {
+ t.Errorf("Expected output dir './images', got '%s'", opts.OutputDir)
+ }
+
+ if opts.OverwriteExisting {
+ t.Error("Expected OverwriteExisting to be false")
+ }
+
+ if !opts.CreateDir {
+ t.Error("Expected CreateDir to be true")
+ }
+
+ if opts.MaxSizeBytes != 10*1024*1024 {
+ t.Errorf("Expected MaxSizeBytes 10MB, got %d", opts.MaxSizeBytes)
+ }
+} \ No newline at end of file
diff --git a/internal/image/translate.go b/internal/image/translate.go
new file mode 100644
index 0000000..03d5875
--- /dev/null
+++ b/internal/image/translate.go
@@ -0,0 +1,90 @@
+package image
+
+import "strings"
+
+// translateBulgarianQuery attempts to translate a Bulgarian query to English for better results
+// This is a simple implementation - in production you might use a translation API
+func translateBulgarianQuery(query string) string {
+ // Common Bulgarian words for flashcard creation
+ translations := map[string]string{
+ "ябълка": "apple",
+ "котка": "cat",
+ "куче": "dog",
+ "хляб": "bread",
+ "вода": "water",
+ "къща": "house",
+ "дърво": "tree",
+ "цвете": "flower",
+ "книга": "book",
+ "стол": "chair",
+ "маса": "table",
+ "прозорец": "window",
+ "врата": "door",
+ "ръка": "hand",
+ "око": "eye",
+ "слънце": "sun",
+ "луна": "moon",
+ "звезда": "star",
+ "море": "sea",
+ "планина": "mountain",
+ "кола": "car",
+ "автобус": "bus",
+ "влак": "train",
+ "самолет": "airplane",
+ "училище": "school",
+ "учител": "teacher",
+ "ученик": "student",
+ "приятел": "friend",
+ "семейство": "family",
+ "майка": "mother",
+ "баща": "father",
+ "брат": "brother",
+ "сестра": "sister",
+ "дете": "child",
+ "мъж": "man",
+ "жена": "woman",
+ "момче": "boy",
+ "момиче": "girl",
+ "храна": "food",
+ "плод": "fruit",
+ "зеленчук": "vegetable",
+ "мляко": "milk",
+ "сирене": "cheese",
+ "месо": "meat",
+ "риба": "fish",
+ "пиле": "chicken",
+ "яйце": "egg",
+ "захар": "sugar",
+ "сол": "salt",
+ "кафе": "coffee",
+ "чай": "tea",
+ "вино": "wine",
+ "бира": "beer",
+ "сок": "juice",
+ "град": "city",
+ "село": "village",
+ "улица": "street",
+ "парк": "park",
+ "магазин": "shop",
+ "ресторант": "restaurant",
+ "хотел": "hotel",
+ "болница": "hospital",
+ "аптека": "pharmacy",
+ "банка": "bank",
+ "пощa": "post office",
+ "полиция": "police",
+ "пожарна": "fire station",
+ "летище": "airport",
+ "гара": "train station",
+ }
+
+ // Try exact match first
+ query = strings.ToLower(strings.TrimSpace(query))
+ if translated, ok := translations[query]; ok {
+ return translated
+ }
+
+ // If no translation found, return original
+ // Pixabay might still return results for common words
+ return query
+} \ No newline at end of file
diff --git a/internal/image/unsplash.go b/internal/image/unsplash.go
new file mode 100644
index 0000000..709ab17
--- /dev/null
+++ b/internal/image/unsplash.go
@@ -0,0 +1,263 @@
+package image
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+const (
+ unsplashAPIURL = "https://api.unsplash.com"
+ unsplashTimeout = 30 * time.Second
+)
+
+// UnsplashClient implements ImageSearcher for Unsplash API
+type UnsplashClient struct {
+ accessKey string
+ httpClient *http.Client
+ rateLimit *rateLimiter
+}
+
+// unsplashSearchResponse represents the search API response
+type unsplashSearchResponse struct {
+ Total int `json:"total"`
+ TotalPages int `json:"total_pages"`
+ Results []unsplashPhoto `json:"results"`
+}
+
+// unsplashPhoto represents a photo in the response
+type unsplashPhoto struct {
+ ID string `json:"id"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Color string `json:"color"`
+ BlurHash string `json:"blur_hash"`
+ Description string `json:"description"`
+ AltDesc string `json:"alt_description"`
+ URLs unsplashPhotoURLs `json:"urls"`
+ Links unsplashPhotoLinks `json:"links"`
+ User unsplashUser `json:"user"`
+}
+
+// unsplashPhotoURLs contains various size URLs
+type unsplashPhotoURLs struct {
+ Raw string `json:"raw"`
+ Full string `json:"full"`
+ Regular string `json:"regular"`
+ Small string `json:"small"`
+ Thumb string `json:"thumb"`
+}
+
+// unsplashPhotoLinks contains photo-related links
+type unsplashPhotoLinks struct {
+ Self string `json:"self"`
+ HTML string `json:"html"`
+ Download string `json:"download"`
+}
+
+// unsplashUser represents the photo author
+type unsplashUser struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+}
+
+// NewUnsplashClient creates a new Unsplash API client
+func NewUnsplashClient(accessKey string) (*UnsplashClient, error) {
+ if accessKey == "" {
+ return nil, fmt.Errorf("Unsplash access key is required")
+ }
+
+ return &UnsplashClient{
+ accessKey: accessKey,
+ httpClient: &http.Client{
+ Timeout: unsplashTimeout,
+ },
+ rateLimit: newRateLimiter(50), // 50 requests per hour
+ }, nil
+}
+
+// Search performs an image search on Unsplash
+func (u *UnsplashClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ // Apply rate limiting (50 per hour = ~0.83 per minute)
+ u.rateLimit.wait()
+
+ // Build query parameters
+ params := url.Values{}
+ params.Set("query", opts.Query)
+ params.Set("per_page", fmt.Sprintf("%d", opts.PerPage))
+ params.Set("page", fmt.Sprintf("%d", opts.Page))
+
+ if opts.Orientation != "all" && opts.Orientation != "" {
+ params.Set("orientation", mapOrientation(opts.Orientation))
+ }
+
+ // Make request
+ reqURL := unsplashAPIURL + "/search/photos?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add authorization header
+ req.Header.Set("Authorization", "Client-ID "+u.accessKey)
+ req.Header.Set("Accept-Version", "v1")
+
+ resp, err := u.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Check status code
+ if resp.StatusCode == http.StatusTooManyRequests {
+ // Try to parse rate limit headers
+ retryAfter := 3600 // Default to 1 hour
+ if retryStr := resp.Header.Get("X-Ratelimit-Reset"); retryStr != "" {
+ // Parse Unix timestamp and calculate seconds until reset
+ // Implementation simplified for brevity
+ retryAfter = 3600
+ }
+
+ return nil, &RateLimitError{
+ Provider: "unsplash",
+ RetryAfter: retryAfter,
+ LimitPerHour: 50,
+ }
+ }
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return nil, &SearchError{
+ Provider: "unsplash",
+ Code: "401",
+ Message: "Invalid access key",
+ }
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, &SearchError{
+ Provider: "unsplash",
+ Code: fmt.Sprintf("%d", resp.StatusCode),
+ Message: string(body),
+ }
+ }
+
+ // Parse response
+ var searchResp unsplashSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to SearchResult
+ results := make([]SearchResult, 0, len(searchResp.Results))
+ for _, photo := range searchResp.Results {
+ description := photo.Description
+ if description == "" {
+ description = photo.AltDesc
+ }
+
+ results = append(results, SearchResult{
+ ID: photo.ID,
+ URL: photo.URLs.Regular,
+ ThumbnailURL: photo.URLs.Thumb,
+ Width: photo.Width,
+ Height: photo.Height,
+ Description: description,
+ Attribution: u.formatAttribution(&photo),
+ Source: "unsplash",
+ })
+ }
+
+ // Trigger download tracking as per Unsplash guidelines
+ go u.trackDownloads(searchResp.Results)
+
+ return results, nil
+}
+
+// Download downloads an image from the given URL
+func (u *UnsplashClient) Download(ctx context.Context, imageURL string) (io.ReadCloser, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create download request: %w", err)
+ }
+
+ resp, err := u.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("download failed: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
+ }
+
+ return resp.Body, nil
+}
+
+// GetAttribution returns the required attribution text for an image
+func (u *UnsplashClient) GetAttribution(result *SearchResult) string {
+ // Unsplash always requires attribution
+ return result.Attribution
+}
+
+// Name returns the name of the search provider
+func (u *UnsplashClient) Name() string {
+ return "unsplash"
+}
+
+// formatAttribution creates the proper attribution string as per Unsplash guidelines
+func (u *UnsplashClient) formatAttribution(photo *unsplashPhoto) string {
+ return fmt.Sprintf("Photo by %s on Unsplash", photo.User.Name)
+}
+
+// mapOrientation maps our orientation values to Unsplash API values
+func mapOrientation(orientation string) string {
+ switch orientation {
+ case "horizontal":
+ return "landscape"
+ case "vertical":
+ return "portrait"
+ default:
+ return ""
+ }
+}
+
+// trackDownloads triggers download events as required by Unsplash API guidelines
+func (u *UnsplashClient) trackDownloads(photos []unsplashPhoto) {
+ // Unsplash requires triggering their download endpoint when images are used
+ // This is done asynchronously to not block the search
+ for _, photo := range photos {
+ go func(downloadURL string) {
+ req, _ := http.NewRequest("GET", downloadURL, nil)
+ req.Header.Set("Authorization", "Client-ID "+u.accessKey)
+ u.httpClient.Do(req)
+ }(photo.Links.Download)
+ }
+}
+
+// SearchWithTranslation performs a search with automatic translation
+// Unsplash has better international support, so we'll try both queries
+func (u *UnsplashClient) SearchWithTranslation(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) {
+ // First try with original Bulgarian query
+ results, err := u.Search(ctx, opts)
+ if err == nil && len(results) > 0 {
+ return results, nil
+ }
+
+ // If no results, try with translated query
+ translatedQuery := translateBulgarianQuery(opts.Query)
+ if translatedQuery != opts.Query {
+ translatedOpts := *opts
+ translatedOpts.Query = translatedQuery
+ return u.Search(ctx, &translatedOpts)
+ }
+
+ return results, err
+} \ No newline at end of file
diff --git a/internal/version.go b/internal/version.go
new file mode 100644
index 0000000..93a42a8
--- /dev/null
+++ b/internal/version.go
@@ -0,0 +1,3 @@
+package internal
+
+const Version = "0.0.0"
diff --git a/testdata/common_words.txt b/testdata/common_words.txt
new file mode 100644
index 0000000..48a7ff7
--- /dev/null
+++ b/testdata/common_words.txt
@@ -0,0 +1,20 @@
+ябълка
+котка
+куче
+хляб
+вода
+къща
+дърво
+цвете
+книга
+стол
+маса
+прозорец
+врата
+ръка
+око
+слънце
+луна
+звезда
+море
+планина \ No newline at end of file