package webui import ( "embed" "encoding/json" "fmt" "io/fs" "net/http" "os" "sort" "strings" "scrappr/internal/logx" "scrappr/internal/model" ) //go:embed static/* var staticFiles embed.FS type Catalog struct { ItemNames []string `json:"item_names"` Items []CatalogItem `json:"items"` Craftables []CraftableEntry `json:"craftables"` } type CatalogItem struct { Name string `json:"name"` URL string `json:"url"` ImageURL string `json:"image_url,omitempty"` Categories []string `json:"categories,omitempty"` ItemType string `json:"item_type,omitempty"` } type CraftableEntry struct { CatalogItem Recipes []model.Recipe `json:"recipes"` } func Run(addr, dataPath string) error { catalog, err := loadCatalog(dataPath) if err != nil { return err } staticFS, err := fs.Sub(staticFiles, "static") if err != nil { return err } mux := http.NewServeMux() mux.HandleFunc("/", serveStaticFile(staticFS, "index.html", "text/html; charset=utf-8")) mux.HandleFunc("/styles.css", serveStaticFile(staticFS, "styles.css", "text/css; charset=utf-8")) mux.HandleFunc("/app.js", serveStaticFile(staticFS, "app.js", "application/javascript; charset=utf-8")) mux.HandleFunc("/api/catalog", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } setNoCache(w) w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(catalog); err != nil { logx.Eventf("error", "catalog encode failed: %v", err) } }) logx.Eventf( "start", "web UI listening on %s using %s (%d craftable items)", displayAddr(addr), dataPath, len(catalog.Craftables), ) return http.ListenAndServe(addr, mux) } func loadCatalog(dataPath string) (Catalog, error) { file, err := os.Open(dataPath) if err != nil { return Catalog{}, fmt.Errorf("open dataset %s: %w", dataPath, err) } defer file.Close() var dataset model.Dataset if err := json.NewDecoder(file).Decode(&dataset); err != nil { return Catalog{}, fmt.Errorf("decode dataset %s: %w", dataPath, err) } return buildCatalog(dataset), nil } func buildCatalog(dataset model.Dataset) Catalog { itemNames := make([]string, 0, len(dataset.Items)) items := make([]CatalogItem, 0, len(dataset.Items)) craftableByName := map[string]*CraftableEntry{} itemByName := map[string]CatalogItem{} seen := map[string]bool{} for _, item := range dataset.Items { name := strings.TrimSpace(item.Name) if name != "" && !seen[name] { seen[name] = true itemNames = append(itemNames, name) } catalogItem := CatalogItem{ Name: item.Name, URL: item.URL, ImageURL: item.ImageURL, Categories: item.Categories, ItemType: item.Infobox["Type"], } items = append(items, catalogItem) itemByName[strings.ToLower(strings.TrimSpace(item.Name))] = catalogItem } for _, item := range dataset.Items { for _, recipe := range item.Recipes { resultKey := strings.ToLower(strings.TrimSpace(recipe.Result)) if resultKey == "" { continue } entry := craftableByName[resultKey] if entry == nil { base := itemByName[resultKey] if strings.TrimSpace(base.Name) == "" { base = CatalogItem{ Name: recipe.Result, URL: item.URL, ImageURL: item.ImageURL, } } entry = &CraftableEntry{ CatalogItem: base, } craftableByName[resultKey] = entry } if !hasRecipe(entry.Recipes, recipe) { entry.Recipes = append(entry.Recipes, recipe) } } } sort.Strings(itemNames) sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) }) craftables := make([]CraftableEntry, 0, len(craftableByName)) for _, entry := range craftableByName { craftables = append(craftables, *entry) } sort.Slice(craftables, func(i, j int) bool { return strings.ToLower(craftables[i].Name) < strings.ToLower(craftables[j].Name) }) return Catalog{ ItemNames: itemNames, Items: items, Craftables: craftables, } } func hasRecipe(existing []model.Recipe, target model.Recipe) bool { for _, recipe := range existing { if recipe.Result != target.Result || recipe.ResultCount != target.ResultCount || recipe.Station != target.Station { continue } if len(recipe.Ingredients) != len(target.Ingredients) { continue } match := true for i := range recipe.Ingredients { if recipe.Ingredients[i] != target.Ingredients[i] { match = false break } } if match { return true } } return false } func displayAddr(addr string) string { if strings.HasPrefix(addr, ":") { return "http://localhost" + addr } if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") { return addr } return "http://" + addr } func serveStaticFile(staticFS fs.FS, name, contentType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && "/"+name != r.URL.Path { http.NotFound(w, r) return } content, err := fs.ReadFile(staticFS, name) if err != nil { http.Error(w, "asset missing", http.StatusInternalServerError) return } setNoCache(w) w.Header().Set("Content-Type", contentType) _, _ = w.Write(content) } } func setNoCache(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") }