terraria-companion/fetcher.go
Felipe M. e617286ac8
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline failed
fix: move fetcher icons to data path
2026-02-18 08:07:10 +01:00

887 lines
23 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"time"
)
// ── Config ──────────────────────────────────────────────────────
const (
wikiAPI = "https://terraria.wiki.gg/api.php"
wikiImg = "https://terraria.wiki.gg/wiki/Special:FilePath/"
pageSize = 500
requestDelay = 400 * time.Millisecond
iconDelay = 1000 * time.Millisecond
iconRetryDelay = 5000 * time.Millisecond
maxRetries = 5
dataDir = "data"
iconsDir = "data/icons"
stateFile = "data/.fetch-state.json"
itemsFile = "data/items.json"
recipesFile = "data/recipes.json"
dropsFile = "data/drops.json"
splashesFile = "data/splashes.json"
)
var itemFields = []string{
"itemid", "name", "internalname", "imagefile", "autoswing", "stack",
"consumable", "hardmode", "type", "listcat", "tag",
"damage", "damagetype", "defense", "velocity", "knockback",
"rare", "buy", "sell", "usetime", "critical", "tooltip",
"pick", "axe", "hammer", "fishing", "bait", "mana",
"placeable", "bodyslot", "buffs", "debuffs", "unobtainable",
}
const recipeFields = "result,resultid,resultimage,resulttext,amount,station,ingredients,ings,legacy"
const dropFields = "nameraw,item,quantity,rate,isfromnpc,normal,expert,master"
// ── State ───────────────────────────────────────────────────────
type fetchState struct {
Phase string `json:"phase"`
ItemsOffset int `json:"itemsOffset"`
ItemsTotal *int `json:"itemsTotal"`
RecipesOffset int `json:"recipesOffset"`
RecipesTotal *int `json:"recipesTotal"`
DropsOffset int `json:"dropsOffset"`
DropsTotal *int `json:"dropsTotal"`
IconsDownloaded int `json:"iconsDownloaded"`
IconsTotal *int `json:"iconsTotal"`
IconsFailed []string `json:"iconsFailed"`
}
var state fetchState
func defaultState() fetchState {
return fetchState{
Phase: "items",
IconsFailed: []string{},
}
}
func loadFetchState() {
data, err := os.ReadFile(stateFile)
if err != nil {
state = defaultState()
return
}
if err := json.Unmarshal(data, &state); err != nil {
state = defaultState()
}
if state.IconsFailed == nil {
state.IconsFailed = []string{}
}
// Backward compat: if drops phase was never run and we're past recipes,
// rewind to "drops" so existing users get drops without --reset.
if state.DropsTotal == nil && (state.Phase == "icons" || state.Phase == "done") {
if _, err := os.Stat(dropsFile); os.IsNotExist(err) {
state.Phase = "drops"
}
}
}
func saveFetchState() {
data, _ := json.Marshal(state)
if err := os.WriteFile(stateFile, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save fetch state: %v\n", err)
}
}
// ── Helpers ─────────────────────────────────────────────────────
func ensureDir(dir string) {
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not create directory %s: %v\n", dir, err)
}
}
func loadJSONFile(file string, v any) error {
data, err := os.ReadFile(file)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
func saveJSONFile(file string, v any) {
data, _ := json.Marshal(v)
if err := os.WriteFile(file, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", file, err)
}
}
func fmtNum(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
return fmt.Sprintf("%d,%03d", n/1000, n%1000)
}
func progressBar(current, total, width int) string {
if total == 0 {
return fmt.Sprintf("[%s] 0.0%%", strings.Repeat("░", width))
}
pct := float64(current) / float64(total)
filled := int(pct*float64(width) + 0.5)
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
return fmt.Sprintf("[%s] %.1f%%", bar, pct*100)
}
// ── HTTP client ─────────────────────────────────────────────────
var httpClient = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
req.Header.Set("User-Agent", "TerrariaItemTree/1.0")
return nil
},
}
func httpGet(ctx context.Context, rawURL string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("User-Agent", "TerrariaItemTree/1.0")
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
// ── Cargo API ───────────────────────────────────────────────────
type cargoResponse struct {
CargoQuery []struct {
Title json.RawMessage `json:"title"`
} `json:"cargoquery"`
Error *struct {
Info string `json:"info"`
} `json:"error"`
}
func cargoQuery(ctx context.Context, params map[string]string) ([]json.RawMessage, error) {
u, _ := url.Parse(wikiAPI)
q := u.Query()
q.Set("action", "cargoquery")
q.Set("format", "json")
q.Set("origin", "*")
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
for attempt := 1; attempt <= maxRetries; attempt++ {
body, status, err := httpGet(ctx, u.String())
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt == maxRetries {
return nil, err
}
sleepCtx(ctx, requestDelay*time.Duration(attempt))
continue
}
if status == 429 {
wait := iconRetryDelay * time.Duration(attempt)
fmt.Fprintf(os.Stderr, "\n ⏳ Rate limited, waiting %ds (attempt %d/%d)...", int(wait.Seconds()), attempt, maxRetries)
sleepCtx(ctx, wait)
continue
}
if status != 200 {
if attempt == maxRetries {
return nil, fmt.Errorf("HTTP %d", status)
}
sleepCtx(ctx, requestDelay*time.Duration(attempt))
continue
}
var resp cargoResponse
if err := json.Unmarshal(body, &resp); err != nil {
if attempt == maxRetries {
return nil, fmt.Errorf("JSON parse error: %w", err)
}
sleepCtx(ctx, requestDelay*time.Duration(attempt))
continue
}
if resp.Error != nil {
return nil, fmt.Errorf("API error: %s", resp.Error.Info)
}
results := make([]json.RawMessage, len(resp.CargoQuery))
for i, row := range resp.CargoQuery {
results[i] = row.Title
}
return results, nil
}
return nil, fmt.Errorf("max retries exceeded")
}
func getCount(ctx context.Context, table string) (int, error) {
rows, err := cargoQuery(ctx, map[string]string{
"tables": table,
"fields": "COUNT(*)=count",
})
if err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, fmt.Errorf("no count returned")
}
var result struct {
Count string `json:"count"`
}
if err := json.Unmarshal(rows[0], &result); err != nil {
return 0, err
}
var n int
if _, err := fmt.Sscanf(result.Count, "%d", &n); err != nil {
return 0, fmt.Errorf("parsing count %q: %w", result.Count, err)
}
return n, nil
}
func sleepCtx(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// ── Phase 1: Fetch all items ────────────────────────────────────
func fileHasData(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
// An empty JSON array "[]" is 2 bytes; anything larger has real data
return info.Size() > 2
}
func fetchAllItems(ctx context.Context) error {
fmt.Println("\n📦 Phase 1: Fetching all items...")
if fileHasData(itemsFile) && state.ItemsOffset == 0 {
fmt.Println(" ⏭ data/items.json already exists on disk, skipping")
state.Phase = "recipes"
saveFetchState()
return nil
}
if state.ItemsTotal == nil {
count, err := getCount(ctx, "Items")
if err != nil {
return fmt.Errorf("counting items: %w", err)
}
state.ItemsTotal = &count
saveFetchState()
}
fmt.Printf(" Total items in wiki: %s\n", fmtNum(*state.ItemsTotal))
var items []json.RawMessage
if state.ItemsOffset > 0 {
if data, err := os.ReadFile(itemsFile); err == nil {
_ = json.Unmarshal(data, &items)
}
}
if items == nil {
items = []json.RawMessage{}
}
fmt.Printf(" Resuming from offset %s (%s items loaded)\n\n", fmtNum(state.ItemsOffset), fmtNum(len(items)))
offset := state.ItemsOffset
for offset < *state.ItemsTotal {
if ctx.Err() != nil {
return ctx.Err()
}
fmt.Fprintf(os.Stderr, "\r %s %s/%s items", progressBar(offset, *state.ItemsTotal, 30), fmtNum(offset), fmtNum(*state.ItemsTotal))
page, err := cargoQuery(ctx, map[string]string{
"tables": "Items",
"fields": strings.Join(itemFields, ","),
"limit": fmt.Sprintf("%d", pageSize),
"offset": fmt.Sprintf("%d", offset),
"order_by": "itemid",
})
if err != nil {
return fmt.Errorf("fetching items at offset %d: %w", offset, err)
}
if len(page) == 0 {
break
}
items = append(items, page...)
offset += len(page)
state.ItemsOffset = offset
saveJSONFile(itemsFile, items)
saveFetchState()
sleepCtx(ctx, requestDelay)
}
saveJSONFile(itemsFile, items)
state.ItemsOffset = offset
state.Phase = "recipes"
saveFetchState()
fmt.Fprintf(os.Stderr, "\r %s %s items fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(items)))
return nil
}
// ── Phase 2: Fetch all recipes ──────────────────────────────────
func fetchAllRecipes(ctx context.Context) error {
fmt.Println("\n📜 Phase 2: Fetching all recipes...")
if fileHasData(recipesFile) && state.RecipesOffset == 0 {
fmt.Println(" ⏭ data/recipes.json already exists on disk, skipping")
state.Phase = "drops"
saveFetchState()
return nil
}
if state.RecipesTotal == nil {
count, err := getCount(ctx, "Recipes")
if err != nil {
return fmt.Errorf("counting recipes: %w", err)
}
state.RecipesTotal = &count
saveFetchState()
}
fmt.Printf(" Total recipes in wiki: %s\n", fmtNum(*state.RecipesTotal))
var recipes []json.RawMessage
if state.RecipesOffset > 0 {
if data, err := os.ReadFile(recipesFile); err == nil {
_ = json.Unmarshal(data, &recipes)
}
}
if recipes == nil {
recipes = []json.RawMessage{}
}
fmt.Printf(" Resuming from offset %s (%s recipes loaded)\n\n", fmtNum(state.RecipesOffset), fmtNum(len(recipes)))
offset := state.RecipesOffset
for offset < *state.RecipesTotal {
if ctx.Err() != nil {
return ctx.Err()
}
fmt.Fprintf(os.Stderr, "\r %s %s/%s recipes", progressBar(offset, *state.RecipesTotal, 30), fmtNum(offset), fmtNum(*state.RecipesTotal))
page, err := cargoQuery(ctx, map[string]string{
"tables": "Recipes",
"fields": recipeFields,
"limit": fmt.Sprintf("%d", pageSize),
"offset": fmt.Sprintf("%d", offset),
"order_by": "resultid",
})
if err != nil {
return fmt.Errorf("fetching recipes at offset %d: %w", offset, err)
}
if len(page) == 0 {
break
}
recipes = append(recipes, page...)
offset += len(page)
state.RecipesOffset = offset
saveJSONFile(recipesFile, recipes)
saveFetchState()
sleepCtx(ctx, requestDelay)
}
saveJSONFile(recipesFile, recipes)
state.RecipesOffset = offset
state.Phase = "drops"
saveFetchState()
fmt.Fprintf(os.Stderr, "\r %s %s recipes fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(recipes)))
return nil
}
// ── Phase 3: Fetch all drops ────────────────────────────────────
func fetchAllDrops(ctx context.Context) error {
fmt.Println("\n💀 Phase 3: Fetching all drops...")
if fileHasData(dropsFile) && state.DropsOffset == 0 {
fmt.Println(" ⏭ data/drops.json already exists on disk, skipping")
state.Phase = "icons"
saveFetchState()
return nil
}
if state.DropsTotal == nil {
count, err := getCount(ctx, "Drops")
if err != nil {
return fmt.Errorf("counting drops: %w", err)
}
state.DropsTotal = &count
saveFetchState()
}
fmt.Printf(" Total drops in wiki: %s\n", fmtNum(*state.DropsTotal))
var drops []json.RawMessage
if state.DropsOffset > 0 {
if data, err := os.ReadFile(dropsFile); err == nil {
_ = json.Unmarshal(data, &drops)
}
}
if drops == nil {
drops = []json.RawMessage{}
}
fmt.Printf(" Resuming from offset %s (%s drops loaded)\n\n", fmtNum(state.DropsOffset), fmtNum(len(drops)))
offset := state.DropsOffset
for offset < *state.DropsTotal {
if ctx.Err() != nil {
return ctx.Err()
}
fmt.Fprintf(os.Stderr, "\r %s %s/%s drops", progressBar(offset, *state.DropsTotal, 30), fmtNum(offset), fmtNum(*state.DropsTotal))
page, err := cargoQuery(ctx, map[string]string{
"tables": "Drops",
"fields": dropFields,
"limit": fmt.Sprintf("%d", pageSize),
"offset": fmt.Sprintf("%d", offset),
"order_by": "item,nameraw",
})
if err != nil {
return fmt.Errorf("fetching drops at offset %d: %w", offset, err)
}
if len(page) == 0 {
break
}
drops = append(drops, page...)
offset += len(page)
state.DropsOffset = offset
saveJSONFile(dropsFile, drops)
saveFetchState()
sleepCtx(ctx, requestDelay)
}
saveJSONFile(dropsFile, drops)
state.DropsOffset = offset
state.Phase = "icons"
saveFetchState()
fmt.Fprintf(os.Stderr, "\r %s %s drops fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(drops)))
return nil
}
// ── Phase 4: Download all icons ─────────────────────────────────
func fetcherSanitizeImagefile(name string) string {
if idx := strings.Index(name, " / "); idx != -1 {
return strings.TrimSpace(name[:idx])
}
return name
}
func downloadIcon(ctx context.Context, filename string) string {
dest := filepath.Join(iconsDir, filename)
// Already downloaded — skip
if info, err := os.Stat(dest); err == nil && info.Size() > 0 {
return "skip"
}
wikiFilename := strings.ReplaceAll(filename, "_", " ")
iconURL := wikiImg + url.PathEscape(wikiFilename)
for attempt := 1; attempt <= maxRetries; attempt++ {
if ctx.Err() != nil {
return "fail"
}
body, status, err := httpGet(ctx, iconURL)
if err != nil {
if attempt == maxRetries {
return "fail"
}
sleepCtx(ctx, iconDelay*time.Duration(attempt))
continue
}
if status == 429 {
wait := iconRetryDelay * time.Duration(attempt)
fmt.Fprintf(os.Stderr, "\n ⏳ Rate limited, waiting %ds...", int(wait.Seconds()))
sleepCtx(ctx, wait)
continue
}
if status != 200 {
if attempt == maxRetries {
return "fail"
}
sleepCtx(ctx, iconDelay*time.Duration(attempt))
continue
}
if err := os.WriteFile(dest, body, 0644); err != nil {
return "fail"
}
return "ok"
}
return "fail"
}
func fetchAllIcons(ctx context.Context) error {
fmt.Println("\n🖼️ Phase 4: Downloading item icons...")
// Load items to get icon list
var items []struct {
ImageFile string `json:"imagefile"`
}
if err := loadJSONFile(itemsFile, &items); err != nil {
return fmt.Errorf("loading items for icons: %w", err)
}
// Collect unique icon filenames
iconSet := make(map[string]bool)
for _, item := range items {
f := fetcherSanitizeImagefile(item.ImageFile)
if f != "" {
iconSet[f] = true
}
}
iconList := make([]string, 0, len(iconSet))
for f := range iconSet {
iconList = append(iconList, f)
}
sort.Strings(iconList)
state.IconsTotal = intPtr(len(iconList))
fmt.Printf(" Total unique icons: %s\n", fmtNum(len(iconList)))
downloaded := 0
skipped := 0
var failed []string
for i, filename := range iconList {
if ctx.Err() != nil {
break
}
fmt.Fprintf(os.Stderr, "\r %s %s/%s icons", progressBar(i, len(iconList), 30), fmtNum(i), fmtNum(len(iconList)))
result := downloadIcon(ctx, filename)
switch result {
case "skip":
skipped++
case "ok":
downloaded++
sleepCtx(ctx, iconDelay)
default:
failed = append(failed, filename)
}
if (i+1)%50 == 0 {
state.IconsDownloaded = downloaded + skipped
state.IconsFailed = failed
if state.IconsFailed == nil {
state.IconsFailed = []string{}
}
saveFetchState()
}
}
state.IconsDownloaded = downloaded + skipped
state.IconsFailed = failed
if state.IconsFailed == nil {
state.IconsFailed = []string{}
}
state.Phase = "done"
saveFetchState()
fmt.Fprintf(os.Stderr, "\r %s Icons done ✓\n", progressBar(1, 1, 30))
fmt.Printf(" ✓ Downloaded: %s\n", fmtNum(downloaded))
fmt.Printf(" ⏭ Skipped (cached): %s\n", fmtNum(skipped))
if len(failed) > 0 {
fmt.Printf(" ✗ Failed: %d\n", len(failed))
_ = os.WriteFile(filepath.Join(dataDir, "icons-failed.txt"), []byte(strings.Join(failed, "\n")), 0644)
fmt.Println(" (see data/icons-failed.txt)")
}
return nil
}
func intPtr(n int) *int {
return &n
}
// ── Splash texts ────────────────────────────────────────────────
func generateSplashes() {
splashes := []string{
"Dig Peon, Dig!",
"Epic Dirt",
"Adaman-TIGHT!",
"Sand is Overpowered",
"The Return of the Guide",
"A Bunnies Tale",
"Dr. Bones and The Temple of Blood Moon",
"Slimeassic Park",
"The Grass is Greener on This Side",
"Small Blocks, Not for Children Under the Age of 5",
"Digger T' Blocks",
"There is No Cow Layer",
"Suspicious Looking Eyeballs",
"Purple Grass!",
"No one Dug Behind!",
"The Water Fall Of Content!",
"Earthbound",
"Dig Dug Ain't Got Nuthin on Me",
"Ore's Well That Ends Well",
"Judgement Clay",
"Terrestrial Trouble",
"(Not Responding)",
"Red Dev Redemption",
"Rise of the Slimes",
"Now with more things to kill you!",
"Rumors of the Guides' death were greatly exaggerated",
"I Pity the Tools...",
"A spelunker says 'What'?",
"May the blocks be with you",
"Better than life",
"Terraria: Terraria:",
"Now in 1D",
"Coming soon to a computer near you",
"Dividing by zero",
"Now with SOUND",
"Press alt-f4",
"You sand bro?",
"A good day to dig hard",
"Can You Re-Dig-It?",
"I don't know that-- aaaaa!",
"What's that purple spiked thing?",
"I wanna be the guide",
"Cthulhu is mad... and is missing an eye!",
"NOT THE BEES!!!",
"Legend of Maxx",
"Cult of Cenx",
"Electric Boogaloo",
"Also try Minecraft!",
"Also try Breath of the Wild!",
"I just wanna know where the gold at?",
"Now with more ducks!",
"1 + 1 = 10",
"Infinite Plantera",
"Also try Stardew Valley!",
"Also try Core Keeper!",
"Also try Project Zomboid!",
"Now with microtransactions!",
"Built on Blockchain Technology",
"Now with even less Ocram!",
"Otherworld",
"Touch Grass Simulator",
"Don't dig up!",
"For the worthy!",
"Now with even more Ocram!",
"Shut Up and Dig Gaiden!",
"Also try Don't Starve!",
"The Final Update",
"Bigger and Boulder",
"The Revenge of Moon Lord",
"Treraira",
"Also try tModLoader!",
"Super Terraria Kart",
"It's Scragglin' Time",
"Dig, fight, explore, build on that thang!",
}
saveJSONFile(splashesFile, splashes)
fmt.Printf(" ✨ Generated %s with %d entries\n", splashesFile, len(splashes))
}
// ── Main fetcher entry point ────────────────────────────────────
var validPhases = map[string]bool{
"items": true, "recipes": true, "drops": true, "icons": true,
}
func runFetcher(args []string) {
fmt.Println("╔══════════════════════════════════════════╗")
fmt.Println("║ Terraria Wiki Full Data Fetcher ║")
fmt.Println("║ Safe to Ctrl+C and resume anytime ║")
fmt.Println("╚══════════════════════════════════════════╝")
// Parse flags
reset := false
only := ""
for _, arg := range args {
if arg == "--reset" {
reset = true
} else if strings.HasPrefix(arg, "--only=") {
only = strings.TrimPrefix(arg, "--only=")
if !validPhases[only] {
fmt.Fprintf(os.Stderr, "❌ Unknown phase %q. Valid phases: items, recipes, drops, icons\n", only)
os.Exit(1)
}
}
}
if reset {
fmt.Println("\n🗑️ Resetting all state...")
_ = os.Remove(stateFile)
_ = os.Remove(itemsFile)
_ = os.Remove(recipesFile)
_ = os.Remove(dropsFile)
}
ensureDir(dataDir)
ensureDir(iconsDir)
generateSplashes()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
start := time.Now()
// --only mode: run a single phase independently
if only != "" {
fmt.Printf("\n⏩ Running only: %s\n", only)
var err error
switch only {
case "items":
state = fetchState{Phase: "items", IconsFailed: []string{}}
err = fetchAllItems(ctx)
case "recipes":
state = fetchState{Phase: "recipes", IconsFailed: []string{}}
err = fetchAllRecipes(ctx)
case "drops":
state = fetchState{Phase: "drops", IconsFailed: []string{}}
err = fetchAllDrops(ctx)
case "icons":
state = fetchState{Phase: "icons", IconsFailed: []string{}}
err = fetchAllIcons(ctx)
}
if err != nil {
if ctx.Err() != nil {
fmt.Println("\n\n⚠️ Interrupted!")
os.Exit(0)
}
fmt.Fprintf(os.Stderr, "\n\n❌ Fatal error: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n✅ %s done in %.1fs\n", only, time.Since(start).Seconds())
return
}
// Normal mode: resume from saved state
loadFetchState()
fmt.Printf("\n⏩ Current phase: %s\n", state.Phase)
var err error
if state.Phase == "items" {
err = fetchAllItems(ctx)
if err != nil {
goto done
}
}
if state.Phase == "recipes" {
err = fetchAllRecipes(ctx)
if err != nil {
goto done
}
}
if state.Phase == "drops" {
err = fetchAllDrops(ctx)
if err != nil {
goto done
}
}
if state.Phase == "icons" {
err = fetchAllIcons(ctx)
if err != nil {
goto done
}
}
done:
if err != nil {
if ctx.Err() != nil {
fmt.Println("\n\n⚠️ Interrupted! State saved. Run again to resume.")
saveFetchState()
os.Exit(0)
}
fmt.Fprintf(os.Stderr, "\n\n❌ Fatal error: %v\n", err)
saveFetchState()
os.Exit(1)
}
elapsed := time.Since(start).Seconds()
if state.Phase == "done" {
var items []json.RawMessage
_ = loadJSONFile(itemsFile, &items)
var recipes []json.RawMessage
_ = loadJSONFile(recipesFile, &recipes)
var drops []json.RawMessage
_ = loadJSONFile(dropsFile, &drops)
iconCount := 0
if entries, err := os.ReadDir(iconsDir); err == nil {
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
iconCount++
}
}
}
fmt.Println("\n══════════════════════════════════════════")
fmt.Printf("✅ All done in %.1fs!\n", elapsed)
fmt.Printf(" 📦 %s items → data/items.json\n", fmtNum(len(items)))
fmt.Printf(" 📜 %s recipes → data/recipes.json\n", fmtNum(len(recipes)))
fmt.Printf(" 💀 %s drops → data/drops.json\n", fmtNum(len(drops)))
fmt.Printf(" 🖼️ %s icons → data/icons/\n", fmtNum(iconCount))
fmt.Println("══════════════════════════════════════════")
}
}