diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-14 22:27:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-14 22:27:33 +0300 |
| commit | cbb1581356ed59e81cf5fedb30145c7521165e3d (patch) | |
| tree | a36a91d3a0d2258977a43ea1dc9da8bfd2741ca6 | |
initial commit
| -rw-r--r-- | .gitignore | 45 | ||||
| -rw-r--r-- | CLAUDE.md | 128 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 264 | ||||
| -rw-r--r-- | TODO.md | 4 | ||||
| -rw-r--r-- | Taskfile.yaml | 15 | ||||
| -rw-r--r-- | cmd/bulg/main.go | 447 | ||||
| -rw-r--r-- | go.mod | 24 | ||||
| -rw-r--r-- | go.sum | 44 | ||||
| -rw-r--r-- | internal/anki/doc.go | 3 | ||||
| -rw-r--r-- | internal/anki/generator.go | 318 | ||||
| -rw-r--r-- | internal/audio/doc.go | 3 | ||||
| -rw-r--r-- | internal/audio/espeak.go | 217 | ||||
| -rw-r--r-- | internal/audio/espeak_provider.go | 65 | ||||
| -rw-r--r-- | internal/audio/espeak_test.go | 198 | ||||
| -rw-r--r-- | internal/audio/openai_provider.go | 219 | ||||
| -rw-r--r-- | internal/audio/provider.go | 139 | ||||
| -rw-r--r-- | internal/config/doc.go | 3 | ||||
| -rw-r--r-- | internal/image/doc.go | 3 | ||||
| -rw-r--r-- | internal/image/download.go | 244 | ||||
| -rw-r--r-- | internal/image/pixabay.go | 231 | ||||
| -rw-r--r-- | internal/image/search.go | 87 | ||||
| -rw-r--r-- | internal/image/search_test.go | 146 | ||||
| -rw-r--r-- | internal/image/translate.go | 90 | ||||
| -rw-r--r-- | internal/image/unsplash.go | 263 | ||||
| -rw-r--r-- | internal/version.go | 3 | ||||
| -rw-r--r-- | testdata/common_words.txt | 20 |
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 @@ -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 @@ -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 @@ -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 +) @@ -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 |
