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 /internal/image | |
initial commit
Diffstat (limited to 'internal/image')
| -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 |
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 |
