summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
committerPaul Buetow <paul@buetow.org>2025-07-14 22:27:33 +0300
commitcbb1581356ed59e81cf5fedb30145c7521165e3d (patch)
treea36a91d3a0d2258977a43ea1dc9da8bfd2741ca6
initial commit
-rw-r--r--.gitignore45
-rw-r--r--CLAUDE.md128
-rw-r--r--LICENSE21
-rw-r--r--README.md264
-rw-r--r--TODO.md4
-rw-r--r--Taskfile.yaml15
-rw-r--r--cmd/bulg/main.go447
-rw-r--r--go.mod24
-rw-r--r--go.sum44
-rw-r--r--internal/anki/doc.go3
-rw-r--r--internal/anki/generator.go318
-rw-r--r--internal/audio/doc.go3
-rw-r--r--internal/audio/espeak.go217
-rw-r--r--internal/audio/espeak_provider.go65
-rw-r--r--internal/audio/espeak_test.go198
-rw-r--r--internal/audio/openai_provider.go219
-rw-r--r--internal/audio/provider.go139
-rw-r--r--internal/config/doc.go3
-rw-r--r--internal/image/doc.go3
-rw-r--r--internal/image/download.go244
-rw-r--r--internal/image/pixabay.go231
-rw-r--r--internal/image/search.go87
-rw-r--r--internal/image/search_test.go146
-rw-r--r--internal/image/translate.go90
-rw-r--r--internal/image/unsplash.go263
-rw-r--r--internal/version.go3
-rw-r--r--testdata/common_words.txt20
27 files changed, 3244 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f0af1f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# Build output
+totalrecall
+
+# IDE files
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Temporary files
+*.tmp
+*.temp
+
+# Audio cache (OpenAI TTS)
+.audio_cache/
+
+# Geberated cards data
+anki_cards
+
+# Configuration with API keys
+.totalrecall.yaml
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ade1542
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,128 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+**bulg** - Bulgarian Anki Flashcard Generator
+
+A Go CLI tool that generates Anki flashcard materials from Bulgarian words:
+- Generates audio pronunciation using espeak-ng
+- Downloads representative images via web search
+- Creates Anki-compatible output files
+
+## Important: Task Tracking
+**Always check TODO.md for the current implementation status and pending tasks.** The TODO.md file contains a comprehensive breakdown of all features and their completion status.
+
+## Build and Development Commands
+
+### Available Tasks (via Taskfile)
+```bash
+# Build the binary
+task
+# or
+task default
+
+# Run the application
+task run
+
+# Run tests
+task test
+
+# Install to Go bin directory
+task install
+```
+
+### Common Development Commands
+```bash
+# Build for current platform
+go build -o bulg ./cmd/bulg
+
+# Run without building
+go run ./cmd/bulg "ябълка"
+
+# Run tests with coverage
+go test -v -cover ./...
+
+# Check for race conditions
+go test -race ./...
+
+# Format code
+go fmt ./...
+
+# Lint code (requires golangci-lint)
+golangci-lint run
+```
+
+## Architecture Overview
+
+### Package Structure
+```
+bulg/
+├── cmd/bulg/ # CLI entry point
+├── internal/ # Private packages
+│ ├── audio/ # Audio generation (espeak-ng wrapper)
+│ ├── image/ # Image search functionality
+│ ├── anki/ # Anki format generation
+│ ├── config/ # Configuration management
+│ └── version.go # Version information
+```
+
+### Key Design Decisions
+1. **espeak-ng for TTS**: Open source, supports Bulgarian, no API keys needed
+2. **Modular image search**: Support multiple providers (Pixabay, Unsplash)
+3. **Configuration via YAML**: User-friendly configuration with viper
+4. **Cobra for CLI**: Industry-standard CLI framework
+
+### External Dependencies
+- **espeak-ng**: Must be installed on the system
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install espeak-ng
+
+ # macOS
+ brew install espeak-ng
+ ```
+
+### API Configuration
+Image search APIs require configuration in `.bulg.yaml`:
+- **Pixabay**: Optional API key for higher rate limits
+- **Unsplash**: Required API key
+
+## Testing Approach
+1. Unit tests mock external commands (espeak-ng) and API calls
+2. Integration tests use real services when available
+3. Test with common Bulgarian words: ябълка, котка, куче, хляб
+
+## Common Issues and Solutions
+
+### espeak-ng Bulgarian pronunciation
+There have been reported issues with Bulgarian pronunciation in espeak-ng v1.49.3. If pronunciation sounds wrong, try:
+```bash
+# Check version
+espeak-ng --version
+
+# Test Bulgarian voice
+espeak-ng -v bg "Здравей"
+
+# Try different voice variants
+espeak-ng -v bg+f1 "Здравей"
+```
+
+### Package Declaration Error
+If you see an error about `package main`, ensure `cmd/bulg/main.go` has:
+```go
+package main // NOT package bulg
+```
+
+## Development Workflow
+1. Check TODO.md for next tasks
+2. Create feature branch
+3. Implement with tests
+4. Update documentation
+5. Run full test suite
+6. Submit for review
+
+## Bulgarian Language Notes
+- Input should be in Cyrillic script
+- Common test words: ябълка (apple), котка (cat), куче (dog)
+- Voice variants: bg+m1 (male), bg+f1 (female) \ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..275734a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 bulg contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e342fd7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,264 @@
+# bulg - Bulgarian Anki Flashcard Generator
+
+`bulg` is a command-line tool that generates Anki flashcard materials from Bulgarian words. It creates audio pronunciation files using espeak-ng or OpenAI TTS and downloads representative images from web search APIs.
+
+## Features
+
+- Audio generation with multiple providers:
+ - **espeak-ng**: Free, offline Bulgarian voices (robotic quality)
+ - **OpenAI TTS**: High-quality, natural-sounding voices (requires API key)
+- Image search via Pixabay and Unsplash APIs
+- Batch processing of multiple words
+- Anki-compatible CSV export
+- Configurable voice variants and speech speed
+- Support for WAV and MP3 audio formats
+- Audio caching to save API costs (OpenAI)
+
+## Installation
+
+### Prerequisites
+
+1. **For espeak-ng audio** (free, offline):
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install espeak-ng
+
+ # macOS
+ brew install espeak-ng
+ ```
+
+2. **ffmpeg** (optional, for MP3 conversion with espeak):
+ ```bash
+ # Ubuntu/Debian
+ sudo apt-get install ffmpeg
+
+ # macOS
+ brew install ffmpeg
+ ```
+
+3. **For OpenAI TTS** (paid, high quality):
+ - Create an account at https://platform.openai.com
+ - Generate an API key at https://platform.openai.com/api-keys
+ - Set the key using one of these methods:
+ - Environment variable: `export OPENAI_API_KEY="sk-..."`
+ - Configuration file: Add to `.bulg.yaml`
+
+### Building from Source
+
+```bash
+git clone https://github.com/yourusername/bulg.git
+cd bulg
+go build -o bulg ./cmd/bulg
+```
+
+Or install directly:
+
+```bash
+go install codeberg.org/snonux/bulg/cmd/bulg@latest
+```
+
+## Quick Start
+
+1. Generate materials for a single word:
+ ```bash
+ bulg ябълка
+ ```
+
+2. Process multiple words from a file:
+ ```bash
+ bulg --batch words.txt
+ ```
+
+3. Generate with Anki CSV:
+ ```bash
+ bulg ябълка --anki
+ ```
+
+## Configuration
+
+Create a `.bulg.yaml` file in your home directory or project folder:
+
+```yaml
+audio:
+ provider: openai # Audio provider (espeak or openai)
+ format: mp3 # Audio format (wav or mp3)
+
+ # ESpeak settings
+ voice: bg+f1 # Voice variant (bg, bg+m1, bg+f1, etc.)
+ speed: 150 # Speech speed (80-450 words/minute)
+ pitch: 50 # Pitch adjustment (0-99)
+
+ # OpenAI settings
+ openai_key: "sk-..." # Your OpenAI API key
+ openai_model: "tts-1" # Model: tts-1 or tts-1-hd
+ openai_voice: "nova" # Voice: alloy, echo, fable, onyx, nova, shimmer
+ openai_speed: 1.0 # Speed: 0.25 to 4.0
+
+ # Caching
+ enable_cache: true
+ cache_dir: "./.audio_cache"
+
+image:
+ provider: pixabay # Image provider (pixabay or unsplash)
+ pixabay_key: "" # Optional API key for higher limits
+ unsplash_key: "" # Required for Unsplash
+ size: medium # Image size preference
+
+output:
+ directory: ./anki_cards
+ naming: "{word}_{type}"
+```
+
+## Usage
+
+```bash
+bulg [word] [flags]
+```
+
+### Flags
+
+- `-v, --voice string`: Voice variant (default "bg+f1")
+- `-o, --output string`: Output directory (default "./anki_cards")
+- `-f, --format string`: Audio format - wav or mp3 (default "mp3")
+- `--batch string`: Process words from file (one per line)
+- `--anki`: Generate Anki import CSV file
+- `--skip-audio`: Skip audio generation
+- `--skip-images`: Skip image download
+- `--images-per-word int`: Number of images per word (default 1)
+- `--image-api string`: Image source - pixabay or unsplash (default "pixabay")
+
+#### Audio Provider Options
+- `--audio-provider string`: Audio provider - espeak or openai (default "espeak")
+
+#### ESpeak Tuning Options
+- `--pitch int`: Pitch adjustment 0-99 (default 50, lower=deeper, espeak only)
+- `--amplitude int`: Volume 0-200 (default 100, espeak only)
+- `--word-gap int`: Gap between words in 10ms units (default 0, espeak only)
+
+#### OpenAI Options
+- `--openai-model string`: Model - tts-1 or tts-1-hd (default "tts-1")
+- `--openai-voice string`: Voice - alloy, echo, fable, onyx, nova, shimmer (default "nova")
+- `--openai-speed float`: Speech speed 0.25-4.0 (default 1.0)
+
+## API Keys
+
+### Pixabay
+- Optional - works without key but with lower rate limits
+- Get your key at: https://pixabay.com/api/docs/
+
+### Unsplash
+- Required for Unsplash searches
+- Get your key at: https://unsplash.com/developers
+
+## Examples
+
+### Basic Usage
+```bash
+# Single word with espeak-ng
+bulg котка
+
+# Using OpenAI TTS (requires API key in config)
+bulg котка --audio-provider openai
+
+# High-quality OpenAI with specific voice
+bulg ябълка --audio-provider openai --openai-model tts-1-hd --openai-voice alloy
+
+# Multiple words with custom output
+bulg --batch animals.txt -o ./animal_cards
+
+# ESpeak with tuning
+bulg ябълка --pitch 40 --word-gap 3
+
+# Skip images, audio only
+bulg куче --skip-images
+
+# Generate Anki import file
+bulg --batch words.txt --anki
+```
+
+### Batch File Format
+Create a text file with one Bulgarian word per line:
+```
+ябълка
+котка
+куче
+хляб
+вода
+```
+
+## Anki Import
+
+1. Generate materials with the `--anki` flag
+2. In Anki, go to File → Import
+3. Select the generated `anki_import.csv`
+4. Copy all media files to your Anki media folder
+5. Map fields appropriately during import
+
+## Voice Variants
+
+Available Bulgarian voices:
+- `bg` - Default Bulgarian voice
+- `bg+m1`, `bg+m2`, `bg+m3` - Male voices
+- `bg+f1`, `bg+f2`, `bg+f3` - Female voices
+
+## Troubleshooting
+
+### espeak-ng not found
+Make sure espeak-ng is installed and in your PATH.
+
+### No images found
+- Check your internet connection
+- Verify API keys in configuration
+- Try using English translations for better results
+
+### OpenAI API errors
+- Verify your API key is correct and has credits
+- Check the API key has TTS permissions enabled
+- If you get rate limit errors, wait a moment and try again
+- The tool will automatically fall back to espeak-ng if OpenAI fails
+
+### Audio sounds robotic
+The Bulgarian voice in espeak-ng can sound robotic. To improve quality:
+
+```bash
+# Test with different settings
+espeak-ng -v bg -p 40 -s 140 "Здравей" # Deeper, slower
+espeak-ng -v bg+f1 -p 60 -g 2 "Здравей" # Higher pitch, word gaps
+
+# Using bulg with tuning
+bulg ябълка --pitch 40 --word-gap 2 --amplitude 120
+```
+
+Recommended settings for clearer pronunciation:
+- `--pitch 40`: Slightly deeper voice (less robotic)
+- `--word-gap 2-5`: Small gaps between words
+- `--amplitude 120`: Slightly louder
+- `-v bg+f1`: Female variant often sounds clearer
+
+### Using OpenAI for Better Quality
+
+OpenAI TTS provides much more natural Bulgarian pronunciation:
+
+```bash
+# Option 1: Use environment variable
+export OPENAI_API_KEY="sk-your-key-here"
+bulg ябълка --audio-provider openai
+
+# Option 2: Set in .bulg.yaml
+audio:
+ provider: openai
+ openai_key: "sk-your-key-here"
+
+# Use with custom voice
+bulg ябълка --audio-provider openai --openai-voice alloy
+```
+
+**OpenAI Pricing**:
+- tts-1: $0.015 per 1K characters (~$0.0001 per word)
+- tts-1-hd: $0.030 per 1K characters (~$0.0002 per word)
+
+The tool caches audio to avoid repeated API calls for the same words.
+
+## License
+
+MIT License - see LICENSE file for details \ No newline at end of file
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..9679bab
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,4 @@
+# TODO's
+
+[ ] Rename the project from bulg to totalrecall. Look at all bulg references, and rename them. Also change the Go module name.
+[ ] Ultra think about an Implementation of using OpenAPI key to use an OpenAI LLM to generate an image for the flash card. And add all to-do's into this file.
diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000..dc10b97
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,15 @@
+version: '3'
+
+tasks:
+ default:
+ cmds:
+ - go build -o bulg ./cmd/bulg
+ run:
+ cmds:
+ - go run ./cmd/bulg
+ test:
+ cmds:
+ - go test ./...
+ install:
+ cmds:
+ - go install ./cmd/bulg
diff --git a/cmd/bulg/main.go b/cmd/bulg/main.go
new file mode 100644
index 0000000..b3c2af1
--- /dev/null
+++ b/cmd/bulg/main.go
@@ -0,0 +1,447 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ "codeberg.org/snonux/bulg/internal"
+ "codeberg.org/snonux/bulg/internal/anki"
+ "codeberg.org/snonux/bulg/internal/audio"
+ "codeberg.org/snonux/bulg/internal/image"
+)
+
+var (
+ // Flags
+ cfgFile string
+ voice string
+ outputDir string
+ audioFormat string
+ imageAPI string
+ batchFile string
+ skipAudio bool
+ skipImages bool
+ imagesPerWord int
+ generateAnki bool
+ // Audio provider flags
+ audioProvider string
+ // Audio tuning flags (espeak)
+ audioPitch int
+ audioAmplitude int
+ audioWordGap int
+ // OpenAI flags
+ openAIModel string
+ openAIVoice string
+ openAISpeed float64
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+ Use: "bulg [word]",
+ Short: "Bulgarian Anki Flashcard Generator",
+ Long: `bulg generates Anki flashcard materials from Bulgarian words.
+
+It creates audio pronunciation files using espeak-ng and downloads
+representative images from web search APIs.
+
+Example:
+ bulg ябълка # Generate materials for "apple"
+ bulg --batch words.txt # Process multiple words from file`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: runCommand,
+ Version: internal.Version,
+}
+
+func init() {
+ cobra.OnInitialize(initConfig)
+
+ // Global flags
+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bulg.yaml)")
+
+ // Local flags
+ rootCmd.Flags().StringVarP(&voice, "voice", "v", "bg+f1", "Voice variant (bg, bg+m1, bg+f1, etc.)")
+ rootCmd.Flags().StringVarP(&outputDir, "output", "o", "./anki_cards", "Output directory")
+ rootCmd.Flags().StringVarP(&audioFormat, "format", "f", "mp3", "Audio format (wav or mp3)")
+ rootCmd.Flags().StringVar(&imageAPI, "image-api", "pixabay", "Image source (pixabay or unsplash)")
+ rootCmd.Flags().StringVar(&batchFile, "batch", "", "Process words from file (one per line)")
+ rootCmd.Flags().BoolVar(&skipAudio, "skip-audio", false, "Skip audio generation")
+ rootCmd.Flags().BoolVar(&skipImages, "skip-images", false, "Skip image download")
+ rootCmd.Flags().IntVar(&imagesPerWord, "images-per-word", 1, "Number of images to download per word")
+ rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file")
+
+ // Audio provider selection
+ rootCmd.Flags().StringVar(&audioProvider, "audio-provider", "espeak", "Audio provider: espeak or openai")
+
+ // Audio tuning flags (espeak)
+ rootCmd.Flags().IntVar(&audioPitch, "pitch", 50, "Audio pitch adjustment (0-99, default 50, espeak only)")
+ rootCmd.Flags().IntVar(&audioAmplitude, "amplitude", 100, "Audio volume (0-200, default 100, espeak only)")
+ rootCmd.Flags().IntVar(&audioWordGap, "word-gap", 0, "Gap between words in 10ms units (default 0, espeak only)")
+
+ // OpenAI flags
+ rootCmd.Flags().StringVar(&openAIModel, "openai-model", "tts-1", "OpenAI model: tts-1 or tts-1-hd")
+ rootCmd.Flags().StringVar(&openAIVoice, "openai-voice", "nova", "OpenAI voice: alloy, echo, fable, onyx, nova, shimmer")
+ rootCmd.Flags().Float64Var(&openAISpeed, "openai-speed", 1.0, "OpenAI speech speed (0.25 to 4.0)")
+
+ // Bind flags to viper
+ viper.BindPFlag("audio.provider", rootCmd.Flags().Lookup("audio-provider"))
+ viper.BindPFlag("audio.voice", rootCmd.Flags().Lookup("voice"))
+ viper.BindPFlag("audio.format", rootCmd.Flags().Lookup("format"))
+ viper.BindPFlag("audio.pitch", rootCmd.Flags().Lookup("pitch"))
+ viper.BindPFlag("audio.amplitude", rootCmd.Flags().Lookup("amplitude"))
+ viper.BindPFlag("audio.word_gap", rootCmd.Flags().Lookup("word-gap"))
+ viper.BindPFlag("audio.openai_model", rootCmd.Flags().Lookup("openai-model"))
+ viper.BindPFlag("audio.openai_voice", rootCmd.Flags().Lookup("openai-voice"))
+ viper.BindPFlag("audio.openai_speed", rootCmd.Flags().Lookup("openai-speed"))
+ viper.BindPFlag("output.directory", rootCmd.Flags().Lookup("output"))
+ viper.BindPFlag("image.provider", rootCmd.Flags().Lookup("image-api"))
+}
+
+func initConfig() {
+ if cfgFile != "" {
+ // Use config file from the flag
+ viper.SetConfigFile(cfgFile)
+ } else {
+ // Find home directory
+ home, err := os.UserHomeDir()
+ cobra.CheckErr(err)
+
+ // Search config in home directory with name ".bulg" (without extension)
+ viper.AddConfigPath(home)
+ viper.AddConfigPath(".")
+ viper.SetConfigType("yaml")
+ viper.SetConfigName(".bulg")
+ }
+
+ // Environment variables
+ viper.SetEnvPrefix("BULG")
+ viper.AutomaticEnv()
+
+ // Read config file
+ if err := viper.ReadInConfig(); err == nil {
+ fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
+ }
+}
+
+func runCommand(cmd *cobra.Command, args []string) error {
+ // Determine words to process
+ var words []string
+
+ if batchFile != "" {
+ // Read words from file
+ content, err := os.ReadFile(batchFile)
+ if err != nil {
+ return fmt.Errorf("failed to read batch file: %w", err)
+ }
+ // Split by newlines and filter empty lines
+ lines := string(content)
+ for _, line := range splitLines(lines) {
+ if line = trimSpace(line); line != "" {
+ words = append(words, line)
+ }
+ }
+ } else if len(args) > 0 {
+ // Single word from command line
+ words = []string{args[0]}
+ } else {
+ // No input provided
+ return fmt.Errorf("please provide a Bulgarian word or use --batch flag")
+ }
+
+ // Validate words
+ for _, word := range words {
+ if err := audio.ValidateBulgarianText(word); err != nil {
+ return fmt.Errorf("invalid word '%s': %w", word, err)
+ }
+ }
+
+ // Create output directory
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ // Process each word
+ for i, word := range words {
+ fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(words), word)
+
+ if err := processWord(word); err != nil {
+ fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", word, err)
+ // Continue with next word
+ }
+ }
+
+ // Generate Anki CSV if requested
+ if generateAnki {
+ fmt.Printf("\nGenerating Anki import file...\n")
+ if err := generateAnkiCSV(); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: Failed to generate Anki CSV: %v\n", err)
+ } else {
+ fmt.Println("Anki import file created: anki_import.csv")
+ }
+ }
+
+ fmt.Println("\nDone! Materials saved to:", outputDir)
+ return nil
+}
+
+func processWord(word string) error {
+ // Generate audio
+ if !skipAudio {
+ fmt.Printf(" Generating audio...\n")
+ if err := generateAudio(word); err != nil {
+ return fmt.Errorf("audio generation failed: %w", err)
+ }
+ }
+
+ // Download images
+ if !skipImages {
+ fmt.Printf(" Downloading images...\n")
+ if err := downloadImages(word); err != nil {
+ return fmt.Errorf("image download failed: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func generateAudio(word string) error {
+ // Create audio provider configuration
+ providerConfig := &audio.Config{
+ Provider: audioProvider,
+ OutputDir: outputDir,
+ OutputFormat: audioFormat,
+
+ // ESpeak settings
+ ESpeakVoice: voice,
+ ESpeakSpeed: viper.GetInt("audio.speed"),
+ ESpeakPitch: audioPitch,
+ ESpeakAmplitude: audioAmplitude,
+ ESpeakWordGap: audioWordGap,
+
+ // OpenAI settings
+ OpenAIKey: getOpenAIKey(),
+ OpenAIModel: openAIModel,
+ OpenAIVoice: openAIVoice,
+ OpenAISpeed: openAISpeed,
+
+ // Caching
+ EnableCache: viper.GetBool("audio.enable_cache"),
+ CacheDir: viper.GetString("audio.cache_dir"),
+ }
+
+ // Set defaults
+ if providerConfig.ESpeakSpeed == 0 {
+ providerConfig.ESpeakSpeed = 150
+ }
+ if providerConfig.CacheDir == "" {
+ providerConfig.CacheDir = "./.audio_cache"
+ }
+
+ // Use config file values if not overridden by flags
+ if audioProvider == "espeak" && viper.IsSet("audio.provider") {
+ providerConfig.Provider = viper.GetString("audio.provider")
+ }
+ if audioPitch == 50 && viper.IsSet("audio.pitch") {
+ providerConfig.ESpeakPitch = viper.GetInt("audio.pitch")
+ }
+ if audioAmplitude == 100 && viper.IsSet("audio.amplitude") {
+ providerConfig.ESpeakAmplitude = viper.GetInt("audio.amplitude")
+ }
+ if audioWordGap == 0 && viper.IsSet("audio.word_gap") {
+ providerConfig.ESpeakWordGap = viper.GetInt("audio.word_gap")
+ }
+ if openAIModel == "tts-1" && viper.IsSet("audio.openai_model") {
+ providerConfig.OpenAIModel = viper.GetString("audio.openai_model")
+ }
+ if openAIVoice == "nova" && viper.IsSet("audio.openai_voice") {
+ providerConfig.OpenAIVoice = viper.GetString("audio.openai_voice")
+ }
+ if openAISpeed == 1.0 && viper.IsSet("audio.openai_speed") {
+ providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
+ }
+
+ // Create the audio provider
+ provider, err := audio.NewProvider(providerConfig)
+ if err != nil {
+ // If OpenAI fails, try to create a fallback to espeak
+ if providerConfig.Provider == "openai" {
+ fmt.Printf("Warning: OpenAI provider failed (%v), falling back to espeak-ng\n", err)
+ providerConfig.Provider = "espeak"
+ fallbackProvider, fallbackErr := audio.NewProvider(providerConfig)
+ if fallbackErr != nil {
+ return fmt.Errorf("both OpenAI and espeak-ng failed: %v", fallbackErr)
+ }
+ provider = fallbackProvider
+ } else {
+ return err
+ }
+ }
+
+ // Generate audio file
+ filename := sanitizeFilename(word)
+ outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat))
+
+ ctx := context.Background()
+ return provider.GenerateAudio(ctx, word, outputFile)
+}
+
+func downloadImages(word string) error {
+ // Create image searcher based on provider
+ var searcher image.ImageSearcher
+ var err error
+
+ switch imageAPI {
+ case "pixabay":
+ apiKey := viper.GetString("image.pixabay_key")
+ searcher = image.NewPixabayClient(apiKey)
+
+ case "unsplash":
+ apiKey := viper.GetString("image.unsplash_key")
+ if apiKey == "" {
+ return fmt.Errorf("Unsplash API key is required in config")
+ }
+ searcher, err = image.NewUnsplashClient(apiKey)
+ if err != nil {
+ return err
+ }
+
+ default:
+ return fmt.Errorf("unknown image provider: %s", imageAPI)
+ }
+
+ // Create downloader
+ downloadOpts := &image.DownloadOptions{
+ OutputDir: outputDir,
+ OverwriteExisting: false,
+ CreateDir: true,
+ FileNamePattern: "{word}_{index}",
+ MaxSizeBytes: 5 * 1024 * 1024, // 5MB
+ }
+
+ downloader := image.NewDownloader(searcher, downloadOpts)
+
+ // Download images
+ ctx := context.Background()
+ if imagesPerWord == 1 {
+ _, path, err := downloader.DownloadBestMatch(ctx, word)
+ if err != nil {
+ return err
+ }
+ fmt.Printf(" Downloaded: %s\n", path)
+ } else {
+ paths, err := downloader.DownloadMultiple(ctx, word, imagesPerWord)
+ if err != nil {
+ return err
+ }
+ for _, path := range paths {
+ fmt.Printf(" Downloaded: %s\n", path)
+ }
+ }
+
+ return nil
+}
+
+func sanitizeFilename(s string) string {
+ // Simple filename sanitization
+ result := ""
+ for _, r := range s {
+ if isAlphaNumeric(r) || r == '-' || r == '_' {
+ result += string(r)
+ } else {
+ result += "_"
+ }
+ }
+ return result
+}
+
+func isAlphaNumeric(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') ||
+ (r >= 'А' && r <= 'Я')
+}
+
+func splitLines(s string) []string {
+ // Simple line splitter
+ var lines []string
+ current := ""
+ for _, r := range s {
+ if r == '\n' {
+ lines = append(lines, current)
+ current = ""
+ } else if r != '\r' {
+ current += string(r)
+ }
+ }
+ if current != "" {
+ lines = append(lines, current)
+ }
+ return lines
+}
+
+func trimSpace(s string) string {
+ // Simple trim implementation
+ start := 0
+ end := len(s)
+
+ // Trim from start
+ for start < end && isSpace(rune(s[start])) {
+ start++
+ }
+
+ // Trim from end
+ for end > start && isSpace(rune(s[end-1])) {
+ end--
+ }
+
+ return s[start:end]
+}
+
+func isSpace(r rune) bool {
+ return r == ' ' || r == '\t' || r == '\n' || r == '\r'
+}
+
+func generateAnkiCSV() error {
+ // Create Anki generator
+ gen := anki.NewGenerator(&anki.GeneratorOptions{
+ OutputPath: filepath.Join(outputDir, "anki_import.csv"),
+ MediaFolder: outputDir,
+ IncludeHeaders: true,
+ AudioFormat: audioFormat,
+ })
+
+ // Generate cards from output directory
+ if err := gen.GenerateFromDirectory(outputDir); err != nil {
+ return fmt.Errorf("failed to generate cards: %w", err)
+ }
+
+ // Generate CSV
+ if err := gen.GenerateCSV(); err != nil {
+ return fmt.Errorf("failed to generate CSV: %w", err)
+ }
+
+ // Print stats
+ total, withAudio, withImages := gen.Stats()
+ fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n",
+ total, withAudio, withImages)
+
+ return nil
+}
+
+func getOpenAIKey() string {
+ // First check environment variable
+ if key := os.Getenv("OPENAI_API_KEY"); key != "" {
+ return key
+ }
+
+ // Then check config file
+ return viper.GetString("audio.openai_key")
+}
+
+func main() {
+ if err := rootCmd.Execute(); err != nil {
+ os.Exit(1)
+ }
+} \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2a00223
--- /dev/null
+++ b/go.mo