summaryrefslogtreecommitdiff
path: root/internal/image
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 /internal/image
initial commit
Diffstat (limited to 'internal/image')
-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
7 files changed, 1064 insertions, 0 deletions
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