Store/spm/search.go
2025-03-09 11:42:53 +01:00

242 lines
6.3 KiB
Go

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)
}
}