package spm import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strings" ) // Addon struct capturing fields from AMO search results (v5) type Addon struct { ID int `json:"id"` Name map[string]string `json:"name"` Slug string `json:"slug"` URL string `json:"url"` CurrentVersion struct { Version string `json:"version"` ReleaseNotes map[string]string `json:"release_notes"` Files []struct { URL string `json:"url"` } `json:"files"` } `json:"current_version"` Author struct { Name string `json:"name"` } `json:"author"` IconURL string `json:"icon_url"` Summary map[string]string `json:"summary"` Tags []string `json:"tags"` License struct { Name string `json:"name"` } `json:"license"` // Make sure we match the real AMO JSON: "image_url" is where // the full-size preview is stored (not "url") Previews []struct { ID int `json:"id"` Caption string `json:"caption"` ImageURL string `json:"image_url"` ThumbnailURL string `json:"thumbnail_url"` } `json:"previews"` ThemeData struct { Images struct { Header string `json:"header"` } `json:"images"` } `json:"theme_data"` } // AMOResponse is the structure returned by the AMO search endpoint (v5). type AMOResponse struct { Count int `json:"count"` Results []Addon `json:"results"` } func searchMozillaAddons(query, addonType string) ([]AppIndexEntry, error) { baseURL := "https://addons.mozilla.org/api/v5/addons/search/" q := url.QueryEscape(query) apiURL := fmt.Sprintf("%s?app=firefox&q=%s", baseURL, q) if addonType != "" && addonType != "all" { apiURL += "&type=" + url.QueryEscape(addonType) } log.Printf("AMO API request: %s", apiURL) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed building request: %w", err) } req.Header.Set("User-Agent", "MyCustomBrowser/1.0") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to query AMO: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("AMO returned status: %d", resp.StatusCode) } bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed reading AMO response: %w", err) } var amoResp AMOResponse if err := json.Unmarshal(bodyBytes, &amoResp); err != nil { return nil, fmt.Errorf("failed parsing AMO JSON: %w", err) } var entries []AppIndexEntry for _, a := range amoResp.Results { // Determine display name. var addonName string if val, ok := a.Name["en-US"]; ok && val != "" { addonName = val } else { for _, v := range a.Name { addonName = v break } if addonName == "" { addonName = "Unknown Add-on" } } finalType := addonType if finalType == "" || finalType == "all" { finalType = "addon" } // Pick download URL from current version if present. downloadURL := a.URL if len(a.CurrentVersion.Files) > 0 && a.CurrentVersion.Files[0].URL != "" { downloadURL = a.CurrentVersion.Files[0].URL } // Gather screenshot URLs from Previews using preview.ImageURL. var screenshots []string for _, preview := range a.Previews { screenshots = append(screenshots, preview.ImageURL) } // If this is a theme, override screenshots to first preview. if strings.EqualFold(finalType, "theme") || strings.EqualFold(finalType, "statictheme") || strings.EqualFold(addonType, "theme") || strings.EqualFold(addonType, "statictheme") { if len(a.Previews) > 0 { log.Printf("Using first preview for theme %s: %s", a.Slug, a.Previews[0].ImageURL) screenshots = []string{a.Previews[0].ImageURL} } else { log.Printf("No preview images available for theme %s", a.Slug) } } // Release notes releaseNotes := "" if rn, ok := a.CurrentVersion.ReleaseNotes["en-US"]; ok && rn != "" { releaseNotes = rn } else { for _, rn := range a.CurrentVersion.ReleaseNotes { releaseNotes = rn break } } // Description description := "" if d, ok := a.Summary["en-US"]; ok && d != "" { description = d } else { for _, d := range a.Summary { description = d break } } entries = append(entries, AppIndexEntry{ Name: addonName, Version: a.CurrentVersion.Version, Type: finalType, DownloadURL: downloadURL, Maintainer: a.Author.Name, Icon: a.IconURL, Screenshots: screenshots, Tags: a.Tags, Description: description, URL: a.URL, License: a.License.Name, Notes: releaseNotes, }) } log.Printf("AMO results: %d", len(entries)) return entries, nil } // Search in local SPM index by type func searchLocalByType(typeFilter string) ([]AppIndexEntry, error) { allEntries, err := GetIndex() if err != nil { return nil, err } var filtered []AppIndexEntry for _, e := range allEntries { if strings.EqualFold(e.Type, typeFilter) { filtered = append(filtered, e) } } return filtered, nil } // Search in local SPM index for all, optionally filtered by name substring. func searchLocalAll(query string) ([]AppIndexEntry, error) { allEntries, err := GetIndex() if err != nil { return nil, err } if query == "" { return allEntries, nil } var result []AppIndexEntry for _, e := range allEntries { if strings.Contains(strings.ToLower(e.Name), strings.ToLower(query)) { result = append(result, e) } } return result, nil } // SearchPackages coordinates between local and AMO-based searches depending on filter func SearchPackages(query, filter string) ([]AppIndexEntry, error) { switch filter { case "addon": return searchMozillaAddons(query, "extension") case "theme": return searchMozillaAddons(query, "statictheme") case "layout", "bundle", "config": return searchLocalByType(filter) case "", "all": localAll, err := searchLocalAll(query) if err != nil { return nil, fmt.Errorf("local search error: %w", err) } mozExt, err := searchMozillaAddons(query, "extension") if err != nil { return nil, fmt.Errorf("mozilla (extension) error: %w", err) } mozTheme, err := searchMozillaAddons(query, "statictheme") if err != nil { return nil, fmt.Errorf("mozilla (theme) error: %w", err) } return append(append(localAll, mozExt...), mozTheme...), nil default: return nil, fmt.Errorf("unrecognized filter: %s", filter) } }