▍ humdrum codex / sportsball v0.1.0
license AGPL-3.0
2.9 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
// Package config persists user preferences (favorite teams, selected leagues,
// active theme) under the XDG config dir (~/.config/sportsball/config.json). Reads are
// forgiving: a missing or corrupt file yields defaults rather than an error, so
// the app always starts.
package config

import (
	"encoding/json"
	"os"
	"path/filepath"
)

// Config is the persisted user state. Fields are optional; zero values are
// valid defaults.
type Config struct {
	Favorites []FavTeam `json:"favorites"`

	// League preferences (TASK-002 / TASK-019). Leagues auto-show by season;
	// these layer per-league overrides on top:
	//   LeagueOrder — display order (league IDs); empty = catalog order.
	//   HideLeagues — force-hidden even when in season.
	//   ShowLeagues — force-shown even when out of season.
	// Toggling a league back to what the season would do clears its override.
	LeagueOrder []string `json:"league_order,omitempty"`
	HideLeagues []string `json:"hide_leagues,omitempty"`
	ShowLeagues []string `json:"show_leagues,omitempty"`

	Theme string `json:"theme,omitempty"` // active theme name (TASK-005)
}

// FavTeam identifies one favorited team. League+ID is the stable key; Abbr/Name
// are stored for display and as a fallback when ID is unavailable.
type FavTeam struct {
	League string `json:"league"`
	ID     string `json:"id"`
	Abbr   string `json:"abbr"`
	Name   string `json:"name"`
}

// dir is the config directory: $XDG_CONFIG_HOME/sportsball, else ~/.config/sportsball.
func dir() (string, error) {
	base := os.Getenv("XDG_CONFIG_HOME")
	if base == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", err
		}
		base = filepath.Join(home, ".config")
	}
	return filepath.Join(base, "sportsball"), nil
}

func path() (string, error) {
	d, err := dir()
	if err != nil {
		return "", err
	}
	return filepath.Join(d, "config.json"), nil
}

// Load reads the config. A missing or unreadable/corrupt file is not an error:
// it returns the zero Config so callers can proceed with defaults.
func Load() Config {
	p, err := path()
	if err != nil {
		return Config{}
	}
	data, err := os.ReadFile(p)
	if err != nil {
		return Config{}
	}
	var c Config
	if err := json.Unmarshal(data, &c); err != nil {
		return Config{} // corrupt → defaults
	}
	return c
}

// Save writes the config atomically (temp file + rename), creating the config
// directory if needed.
func Save(c Config) error {
	d, err := dir()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(d, 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return err
	}
	p := filepath.Join(d, "config.json")
	tmp, err := os.CreateTemp(d, "config-*.json")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpName)
		return err
	}
	return os.Rename(tmpName, p)
}