Evil
This commit is contained in:
227
internal/webui/server.go
Normal file
227
internal/webui/server.go
Normal file
@@ -0,0 +1,227 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user