// Package config loads lectio's TOML configuration file. // // Resolution order: LECTIO_CONFIG env var -> ~/.config/lectio/config.toml // (auto-seeded from the embedded default on first run, honoring // XDG_CONFIG_HOME via os.UserConfigDir()) -> built-in defaults. TOML that // fails to parse falls back to defaults with a warning on stderr. package config import ( _ "embed" "fmt" "os" "path/filepath" "sort" "strings" "github.com/pelletier/go-toml/v2" ) // seedTOML is the embedded default config.toml, written to disk on first // run and parsed as the fallback default values. // //go:embed config.toml var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). const Version = "0.18.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ "bt": true, "wuj": true, "vul": true, "grb": true, "drb": true, } // ValidVersion reports whether v is one of the five scripture versions // lectio understands (bt, wuj, vul, grb, drb). func ValidVersion(v string) bool { return validVersions[v] } // NormalizeLectionary maps -l/--lectionary's accepted spellings ("new", // "trad", "traditional") onto the canonical Config.Lectionary values ("new", // "traditional"); anything else reports ok=false. func NormalizeLectionary(s string) (string, bool) { switch strings.ToLower(s) { case "new": return "new", true case "trad", "traditional": return "traditional", true default: return "", false } } // validDisplays are the lectio-web reading-pane layouts. var validDisplays = map[string]bool{ "horizontal": true, "vertical": true, "interlinear": true, } // NormalizeDisplay lower-cases display and falls back to "horizontal" when // it is not one of validDisplays -- the same lenient style as the rest of // lectio-web's config fields (no error, just a safe default). Exported so // internal/web can apply the identical normalization to an explicit // ?display= query override, keeping the valid set in this one place. func NormalizeDisplay(display string) string { d := strings.ToLower(display) if !validDisplays[d] { return "horizontal" } return d } // validUILanguages are the two UI chrome languages lectio understands. var validUILanguages = map[string]bool{ "en": true, "pl": true, } // NormalizeUILanguage lower-cases lang and falls back to "en" when it is not // one of validUILanguages -- the same lenient style as NormalizeDisplay (no // error, just a safe default). func NormalizeUILanguage(lang string) string { l := strings.ToLower(lang) if !validUILanguages[l] { return "en" } return l } // NormalizeSiglaStyle maps a sigla_style setting to one of "auto", "polish", // "english"; anything unrecognised (incl. "") falls back to "auto". Accepts the // short aliases "pl"/"en" for convenience. func NormalizeSiglaStyle(s string) string { switch strings.ToLower(strings.TrimSpace(s)) { case "polish", "pl": return "polish" case "english", "en": return "english" default: return "auto" } } // Config holds lectio's user-configurable settings. type Config struct { SchemaVersion int `toml:"schema_version"` Lectionary string `toml:"lectionary"` TraditionalLang string `toml:"traditional_lang"` Versions []string `toml:"versions"` DefaultVersion string `toml:"default_version"` Width int `toml:"width"` All bool `toml:"all"` Offline bool `toml:"offline"` UILanguage string `toml:"ui_language"` SiglaStyle string `toml:"sigla_style"` WebTheme string `toml:"web_theme"` WebPort int `toml:"web_port"` WebDisplay string `toml:"web_display"` WebMono bool `toml:"web_mono"` WebVersions []string `toml:"web_versions"` Pager string `toml:"pager"` Parts map[string]map[string]bool `toml:"parts"` } // PartShown reports whether a part renders: true unless explicitly set false. func (c Config) PartShown(lectionary, partID string) bool { if m, ok := c.Parts[lectionary]; ok { if v, ok := m[partID]; ok { return v } } return true } // SiglaLang resolves the book dialect (a books.toml section code, "en"/"pl") // that `lectio --ref`/`--list` use: the explicit sigla_style, or -- when "auto" // -- the UI language. func (c Config) SiglaLang() string { switch c.SiglaStyle { case "polish": return "pl" case "english": return "en" default: if c.UILanguage == "pl" { return "pl" } return "en" } } // Default returns lectio's built-in configuration, used when no config file // is found and as the base that a partial config file overrides. func Default() Config { return Config{ SchemaVersion: 1, Lectionary: "new", TraditionalLang: "pl", Versions: []string{"bt", "wuj", "vul", "grb", "drb"}, DefaultVersion: "bt", Width: 0, All: false, Offline: false, UILanguage: "en", SiglaStyle: "auto", WebTheme: "transfiguration", WebPort: 0, WebDisplay: "horizontal", WebMono: false, WebVersions: nil, Pager: "", Parts: nil, } } // configPath resolves the config file location: LECTIO_CONFIG if set, // otherwise the default XDG-aware path under os.UserConfigDir(). The bool // reports whether this is the default (seedable) location. func configPath() (path string, isDefault bool, err error) { if p := os.Getenv("LECTIO_CONFIG"); p != "" { return p, false, nil } dir, err := os.UserConfigDir() if err != nil { return "", false, err } return filepath.Join(dir, "lectio", "config.toml"), true, nil } // BooksPath returns the path to the optional user books.toml (in the same // directory as the config file). There is no embedded seed on disk -- absence // means "use the built-in defaults". func BooksPath() (string, error) { p, _, err := configPath() if err != nil { return "", err } return filepath.Join(filepath.Dir(p), "books.toml"), nil } // UserBooksTOML returns the bytes of the optional user books.toml (see // BooksPath), or nil when it is absent or unreadable -- callers then fall back // to bible's embedded default table. func UserBooksTOML() []byte { p, err := BooksPath() if err != nil { return nil } b, err := os.ReadFile(p) if err != nil { return nil } return b } // seedIfMissing writes the embedded default config to path if nothing is // there yet. func seedIfMissing(path string) error { if _, err := os.Stat(path); err == nil { return nil } else if !os.IsNotExist(err) { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, seedTOML, 0o644) } // Load reads lectio's configuration, following the package's resolution // order (see the package doc comment). It always returns a usable Config: // on a missing config dir or a TOML parse error it falls back to Default() // (warning on stderr in the parse-error case). It returns a non-nil error // only when the loaded config contains a semantically invalid value. func Load() (Config, error) { def := Default() path, isDefault, err := configPath() if err != nil { fmt.Fprintf(os.Stderr, "lectio: warning: could not resolve config directory: %v; using defaults\n", err) return def, nil } if isDefault { if err := seedIfMissing(path); err != nil { return def, err } } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return def, nil } return def, err } cfg := def if err := toml.Unmarshal(data, &cfg); err != nil { fmt.Fprintf(os.Stderr, "lectio: warning: invalid config at %s: %v; using defaults\n", path, err) return def, nil } cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) cfg.UILanguage = NormalizeUILanguage(cfg.UILanguage) cfg.SiglaStyle = NormalizeSiglaStyle(cfg.SiglaStyle) if err := validate(cfg); err != nil { return Config{}, err } return cfg, nil } // validate checks the semantic constraints Load enforces beyond what TOML // unmarshaling itself catches. func validate(cfg Config) error { if cfg.Lectionary != "new" && cfg.Lectionary != "traditional" { return fmt.Errorf("config: invalid lectionary %q (must be \"new\" or \"traditional\")", cfg.Lectionary) } if cfg.TraditionalLang != "pl" && cfg.TraditionalLang != "en" { return fmt.Errorf("config: invalid traditional_lang %q (must be \"pl\" or \"en\")", cfg.TraditionalLang) } for _, v := range cfg.Versions { if !validVersions[v] { return fmt.Errorf("config: invalid version %q in versions (must be one of bt, wuj, vul, grb, drb)", v) } } if !validVersions[cfg.DefaultVersion] { return fmt.Errorf("config: invalid default_version %q (must be one of bt, wuj, vul, grb, drb)", cfg.DefaultVersion) } for _, v := range cfg.WebVersions { if !validVersions[v] { return fmt.Errorf("config: invalid version %q in web_versions (must be one of bt, wuj, vul, grb, drb)", v) } } return nil } // Validate exposes the semantic checks Load applies, so callers that build a // Config (the web /settings form) can reject an invalid one before saving. func Validate(cfg Config) error { return validate(cfg) } // Save writes cfg to the config file (configPath, honoring LECTIO_CONFIG), // creating the directory if needed, as commented TOML (regenerated -- the // explanatory comments are kept, the values are cfg's). func Save(cfg Config) error { path, _, err := configPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, renderConfigTOML(cfg), 0o644) } func tomlStr(s string) string { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } func tomlStrSlice(ss []string) string { q := make([]string, len(ss)) for i, s := range ss { q[i] = tomlStr(s) } return "[" + strings.Join(q, ", ") + "]" } // renderConfigTOML regenerates config.toml from cfg, preserving the seed's // field comments. Parts (the per-lectionary hide map) is written back verbatim // so a config that had [parts.*] sections keeps them across a Save. func renderConfigTOML(cfg Config) []byte { var b strings.Builder fmt.Fprintf(&b, "schema_version = %d\n", cfg.SchemaVersion) fmt.Fprintf(&b, "lectionary = %s # \"new\" (niedziela.pl) or \"traditional\" (missalemeum, 1962)\n", tomlStr(cfg.Lectionary)) fmt.Fprintf(&b, "traditional_lang = %s # vernacular for traditional propers: \"pl\" or \"en\"\n", tomlStr(cfg.TraditionalLang)) fmt.Fprintf(&b, "versions = %s # compare set + TUI cycle order (bt = Biblia TysiÄ…clecia)\n", tomlStrSlice(cfg.Versions)) fmt.Fprintf(&b, "default_version = %s # TUI start / `lectio -b` default\n", tomlStr(cfg.DefaultVersion)) fmt.Fprintf(&b, "width = %d # CLI wrap width; 0 = detect terminal\n", cfg.Width) fmt.Fprintf(&b, "all = %t # all parts (true) or just the gospel (false)\n", cfg.All) fmt.Fprintf(&b, "offline = %t # true = never fetch; read only cache + harvested sigla\n", cfg.Offline) fmt.Fprintf(&b, "ui_language = %s # interface language: \"en\" or \"pl\" (readings stay source-language)\n", tomlStr(cfg.UILanguage)) fmt.Fprintf(&b, "sigla_style = %s # citation dialect for --ref/--list/--citation: \"auto\", \"polish\", \"english\"\n", tomlStr(cfg.SiglaStyle)) fmt.Fprintf(&b, "web_theme = %s # default lectio-web theme\n", tomlStr(cfg.WebTheme)) fmt.Fprintf(&b, "web_port = %d # lectio-web port; 0 = try 1099, then any free port\n", cfg.WebPort) fmt.Fprintf(&b, "web_display = %s # lectio-web layout: \"horizontal\", \"vertical\", \"interlinear\"\n", tomlStr(cfg.WebDisplay)) fmt.Fprintf(&b, "web_mono = %t # lectio-web: monospace reading face\n", cfg.WebMono) fmt.Fprintf(&b, "web_versions = %s # which versions start toggled on; empty = just default_version\n", tomlStrSlice(cfg.WebVersions)) fmt.Fprintf(&b, "pager = %s # pager for `lectio` output; \"\" = off\n", tomlStr(cfg.Pager)) // Preserve the parts map (not edited by the form) so [parts.*] survives Save. lects := make([]string, 0, len(cfg.Parts)) for l := range cfg.Parts { lects = append(lects, l) } sort.Strings(lects) for _, l := range lects { parts := cfg.Parts[l] keys := make([]string, 0, len(parts)) for k := range parts { keys = append(keys, k) } sort.Strings(keys) fmt.Fprintf(&b, "\n[parts.%s]\n", l) for _, k := range keys { fmt.Fprintf(&b, "%s = %t\n", k, parts[k]) } } return []byte(b.String()) }