// Package config loads lectio's INI configuration file. // // Resolution order: LECTIO_CONFIG env var -> ~/.config/lectio/config.ini // (auto-seeded from the embedded default on first run, honoring // XDG_CONFIG_HOME via os.UserConfigDir()) -> built-in defaults. A pre-existing // config.toml (lectio's former format) at the default location is converted to // config.ini once, on first run, and left on disk. INI that fails to parse // falls back to defaults with a warning on stderr. package config import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "github.com/pelletier/go-toml/v2" "github.com/lukaszkasprzak/lectio/internal/calendar" "github.com/lukaszkasprzak/lectio/internal/ini" ) // configHeader is the reference comment block written at the top of every // generated config.ini (the first-run seed and every Save), documenting each // option's allowed values. Full-line comments only, so the INI reader skips // them and values stay verbatim. const configHeader = `# lectio configuration (INI). Full-line comments only (# or ;); no inline comments. # Lists are comma-separated. Booleans are true/false. # # Allowed values: # lectionary new | traditional (new = modern OF; traditional = 1962 EF) # traditional_lang pl | en (vernacular for traditional propers) # versions any of: bt, wuj, vul, grb, drb (comma list; bt = Biblia Tysiąclecia) # default_version bt | wuj | vul | grb | drb # width integer; 0 = detect terminal width # all true | false (all readings, or just the gospel) # offline true | false (never fetch; cache/sigla only) # ui_language en | pl (interface chrome; readings stay source-language) # sigla_style auto | polish | english | latin (citation dialect; auto follows ui_language) # web_theme a theme name (see docs/THEMES.md) # web_port integer; 0 = try 1099, then any free port # web_display horizontal | vertical | interlinear # web_mono true | false (monospace reading face in the web UI) # web_versions any of: bt, wuj, vul, grb, drb (comma list); empty = just default_version # pager shell command, e.g. less -R; empty = off # use calendar layers to stack over the universal calendar: names of # ~/.config/lectio/calendars/.ini files (comma list, order = precedence) # # [calendar] — computed-calendar placement options (defaults = Universal Roman Calendar): # epiphany fixed | sunday (fixed = Jan 6; sunday = the Sunday of Jan 2-8) # ascension thursday | sunday (sunday = moved to the following Sunday, 7th of Easter) # corpus_christi thursday | sunday (sunday = moved to the following Sunday) # # Optional [parts.new] / [parts.traditional] sections hide reading parts, e.g.: # [parts.new] # psalm = false ` // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). const Version = "0.32.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 "latin", "la": return "latin" case "english", "en": return "english" default: return "auto" } } // Config holds lectio's user-configurable settings. The toml tags are used only // by the one-shot config.toml -> config.ini migration; the live format is INI. 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"` // Computed-calendar placement options (INI [calendar] section; INI-only). CalEpiphany string `toml:"-"` CalAscension string `toml:"-"` CalCorpusChristi string `toml:"-"` // Use is the ordered stack of user calendar-layer ids (INI "use"); each id // names a /.ini file layered over the universal calendar. Use []string `toml:"-"` } // 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 "latin": return "la" case "english": return "en" default: if c.UILanguage == "pl" { return "pl" } return "en" } } // Selection maps the config onto the calendar engine's Selection: the form // (from Lectionary: "new" -> OF, "traditional" -> EF) plus the national // placement options, each falling back to the Universal Roman default. func (c Config) Selection() calendar.Selection { sel := calendar.DefaultSelection() if c.Lectionary == "traditional" { sel.Form = "old" } else { sel.Form = "new" } if c.CalEpiphany != "" { sel.Epiphany = c.CalEpiphany } if c.CalAscension != "" { sel.Ascension = c.CalAscension } if c.CalCorpusChristi != "" { sel.CorpusChristi = c.CalCorpusChristi } return sel } // 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/migratable) 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.ini"), true, nil } // CalendarsDir returns the directory holding user calendar-layer files // (/calendars). func CalendarsDir() (string, error) { p, _, err := configPath() if err != nil { return "", err } return filepath.Join(filepath.Dir(p), "calendars"), nil } // BooksPath returns the path to the optional user books.ini (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.ini"), nil } // UserBooksINI returns the bytes of the optional user books.ini (see BooksPath), // or nil when absent/unreadable -- callers then fall back to bible's embedded // default table. A former books.toml at the same location is converted to // books.ini once, on first read (the old file is kept). func UserBooksINI() []byte { p, err := BooksPath() if err != nil { return nil } if b, err := os.ReadFile(p); err == nil { return b } tomlPath := filepath.Join(filepath.Dir(p), "books.toml") data, err := os.ReadFile(tomlPath) if err != nil { return nil } out := booksTOMLtoINI(data) if out == nil { return nil } _ = os.WriteFile(p, out, 0o644) fmt.Fprintf(os.Stderr, "lectio: migrated %s -> books.ini (old file kept)\n", tomlPath) return out } // booksTOMLtoINI converts a former books.toml (dialect -> book -> [forms]) into // books.ini. Returns nil if the TOML is unparseable. func booksTOMLtoINI(data []byte) []byte { var raw map[string]map[string][]string if err := toml.Unmarshal(data, &raw); err != nil { return nil } var b strings.Builder b.WriteString("# lectio book names & abbreviations, per dialect (migrated from books.toml).\n") emit := func(d string) { books := raw[d] if books == nil { return } fmt.Fprintf(&b, "\n[%s]\n", d) keys := make([]string, 0, len(books)) for k := range books { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Fprintf(&b, "%s = %s\n", k, strings.Join(books[k], ", ")) } } seen := map[string]bool{"en": true, "pl": true, "la": true} emit("en") emit("pl") emit("la") others := make([]string, 0) for d := range raw { if !seen[d] { others = append(others, d) } } sort.Strings(others) for _, d := range others { emit(d) } return []byte(b.String()) } // 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, renderConfigINI(Default()), 0o644) } // migrateFromTOML converts a former config.toml sitting next to iniPath into // config.ini, once. The old file is left in place (so the change is // reversible). A missing or unreadable config.toml is not an error. func migrateFromTOML(iniPath string) { tomlPath := filepath.Join(filepath.Dir(iniPath), "config.toml") data, err := os.ReadFile(tomlPath) if err != nil { return } cfg := Default() if err := toml.Unmarshal(data, &cfg); err != nil { return } normalize(&cfg) _ = Save(cfg) // writes config.ini fmt.Fprintf(os.Stderr, "lectio: migrated %s -> config.ini (old file kept)\n", tomlPath) } // 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 an INI 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 _, statErr := os.Stat(path); os.IsNotExist(statErr) { migrateFromTOML(path) // convert a former config.toml if present 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, err := readINI(data) if err != nil { fmt.Fprintf(os.Stderr, "lectio: warning: invalid config at %s: %v; using defaults\n", path, err) return def, nil } normalize(&cfg) if err := validate(cfg); err != nil { return Config{}, err } return cfg, nil } // normalize applies the lenient field fix-ups Load enforces after parsing. func normalize(cfg *Config) { cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) cfg.UILanguage = NormalizeUILanguage(cfg.UILanguage) cfg.SiglaStyle = NormalizeSiglaStyle(cfg.SiglaStyle) } // readINI parses INI bytes into a Config over Default(). Absent keys keep the // default; present keys override (an empty list value clears the list). func readINI(data []byte) (Config, error) { cfg := Default() secs, err := ini.Parse(data) if err != nil { return cfg, err } for _, s := range secs { switch { case s.Name == "": for _, p := range s.Pairs { applyScalar(&cfg, p.Key, p.Val) } case s.Name == "calendar": for _, p := range s.Pairs { switch p.Key { case "epiphany": cfg.CalEpiphany = p.Val case "ascension": cfg.CalAscension = p.Val case "corpus_christi": cfg.CalCorpusChristi = p.Val } } case strings.HasPrefix(s.Name, "parts."): lect := strings.TrimPrefix(s.Name, "parts.") if cfg.Parts == nil { cfg.Parts = map[string]map[string]bool{} } if cfg.Parts[lect] == nil { cfg.Parts[lect] = map[string]bool{} } for _, p := range s.Pairs { cfg.Parts[lect][p.Key] = p.Val == "true" } } } return cfg, nil } func applyScalar(cfg *Config, key, val string) { switch key { case "schema_version": cfg.SchemaVersion = atoiOr(val, cfg.SchemaVersion) case "lectionary": cfg.Lectionary = val case "traditional_lang": cfg.TraditionalLang = val case "versions": cfg.Versions = ini.List(val) case "default_version": cfg.DefaultVersion = val case "width": cfg.Width = atoiOr(val, cfg.Width) case "all": cfg.All = val == "true" case "offline": cfg.Offline = val == "true" case "ui_language": cfg.UILanguage = val case "sigla_style": cfg.SiglaStyle = val case "web_theme": cfg.WebTheme = val case "web_port": cfg.WebPort = atoiOr(val, cfg.WebPort) case "web_display": cfg.WebDisplay = val case "web_mono": cfg.WebMono = val == "true" case "web_versions": cfg.WebVersions = ini.List(val) case "pager": cfg.Pager = val case "use": cfg.Use = ini.List(val) } } func atoiOr(s string, def int) int { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { return n } return def } // validate checks the semantic constraints Load enforces beyond parsing. 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 INI. 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, renderConfigINI(cfg), 0o644) } // renderConfigINI regenerates config.ini from cfg. Comments are on their own // lines so no value ever shares a line with a "#"/";" comment character. func renderConfigINI(cfg Config) []byte { var b strings.Builder b.WriteString(configHeader) b.WriteString("\n") fmt.Fprintf(&b, "schema_version = %d\n", cfg.SchemaVersion) fmt.Fprintf(&b, "lectionary = %s\n", cfg.Lectionary) fmt.Fprintf(&b, "traditional_lang = %s\n", cfg.TraditionalLang) fmt.Fprintf(&b, "versions = %s\n", strings.Join(cfg.Versions, ", ")) fmt.Fprintf(&b, "default_version = %s\n", cfg.DefaultVersion) fmt.Fprintf(&b, "width = %d\n", cfg.Width) fmt.Fprintf(&b, "all = %t\n", cfg.All) fmt.Fprintf(&b, "offline = %t\n", cfg.Offline) fmt.Fprintf(&b, "ui_language = %s\n", cfg.UILanguage) fmt.Fprintf(&b, "sigla_style = %s\n", cfg.SiglaStyle) fmt.Fprintf(&b, "web_theme = %s\n", cfg.WebTheme) fmt.Fprintf(&b, "web_port = %d\n", cfg.WebPort) fmt.Fprintf(&b, "web_display = %s\n", cfg.WebDisplay) fmt.Fprintf(&b, "web_mono = %t\n", cfg.WebMono) fmt.Fprintf(&b, "web_versions = %s\n", strings.Join(cfg.WebVersions, ", ")) fmt.Fprintf(&b, "pager = %s\n", cfg.Pager) fmt.Fprintf(&b, "use = %s\n", strings.Join(cfg.Use, ", ")) b.WriteString("\n# Computed-calendar national placement options.\n[calendar]\n") fmt.Fprintf(&b, "epiphany = %s\n", orDefault(cfg.CalEpiphany, "fixed")) fmt.Fprintf(&b, "ascension = %s\n", orDefault(cfg.CalAscension, "thursday")) fmt.Fprintf(&b, "corpus_christi = %s\n", orDefault(cfg.CalCorpusChristi, "thursday")) // Preserve the parts map (per-lectionary hide flags) across a 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()) } func orDefault(v, def string) string { if v == "" { return def } return v }