diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:53:34 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:53:34 +0200 |
| commit | 809a47a227540ad8b8b2ac28a1bbbc2f115b7406 (patch) | |
| tree | 03e609b85ebef46867166469b79a1c01b6c93f7d /internal/config/config.go | |
| parent | 06e32c85a5b8b0aaf5539a989347fbe68e27b95e (diff) | |
| download | lectio-809a47a227540ad8b8b2ac28a1bbbc2f115b7406.tar.gz lectio-809a47a227540ad8b8b2ac28a1bbbc2f115b7406.zip | |
feat(config): migrate to INI + one-shot TOML conversion + calendar Selection
config.Load/Save now read/write config.ini; a pre-existing config.toml is
converted once (old file kept, reversible). New [calendar] section
(epiphany/ascension/corpus_christi) + Config.Selection() feeding the engine.
Config struct API unchanged, so daily-readings and web /settings keep working.
Diffstat (limited to 'internal/config/config.go')
| -rw-r--r-- | internal/config/config.go | 264 |
1 files changed, 204 insertions, 60 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index a7365bb..d514d33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,11 @@ -// Package config loads lectio's TOML configuration file. +// Package config loads lectio's INI configuration file. // -// Resolution order: LECTIO_CONFIG env var -> ~/.config/lectio/config.toml +// 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. TOML that -// fails to parse falls back to defaults with a warning on stderr. +// 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 ( @@ -12,16 +14,20 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/pelletier/go-toml/v2" + + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/ini" ) -// seedTOML is the embedded default config.toml, written to disk on first -// run and parsed as the fallback default values. +// seedINI is the embedded default config.ini, written to disk on first run and +// parsed as the fallback default values. // -//go:embed config.toml -var seedTOML []byte +//go:embed config.ini +var seedINI []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). @@ -107,7 +113,8 @@ func NormalizeSiglaStyle(s string) string { } } -// Config holds lectio's user-configurable settings. +// 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"` @@ -126,6 +133,11 @@ type Config struct { 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:"-"` } // PartShown reports whether a part renders: true unless explicitly set false. @@ -155,6 +167,28 @@ func (c Config) SiglaLang() string { } } +// 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 { @@ -181,7 +215,7 @@ func Default() Config { // 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. +// 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 @@ -190,7 +224,7 @@ func configPath() (path string, isDefault bool, err error) { if err != nil { return "", false, err } - return filepath.Join(dir, "lectio", "config.toml"), true, nil + return filepath.Join(dir, "lectio", "config.ini"), true, nil } // BooksPath returns the path to the optional user books.toml (in the same @@ -230,14 +264,32 @@ func seedIfMissing(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return os.WriteFile(path, seedTOML, 0o644) + return os.WriteFile(path, seedINI, 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 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. +// 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() @@ -248,8 +300,11 @@ func Load() (Config, error) { } if isDefault { - if err := seedIfMissing(path); err != nil { - return def, err + 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 + } } } @@ -261,24 +316,112 @@ func Load() (Config, error) { return def, err } - cfg := def - if err := toml.Unmarshal(data, &cfg); err != nil { + 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 } - cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) - cfg.UILanguage = NormalizeUILanguage(cfg.UILanguage) - cfg.SiglaStyle = NormalizeSiglaStyle(cfg.SiglaStyle) + 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 } -// validate checks the semantic constraints Load enforces beyond what TOML -// unmarshaling itself catches. +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 + } +} + +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) @@ -307,8 +450,7 @@ func validate(cfg Config) error { 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). +// creating the directory if needed, as commented INI. func Save(cfg Config) error { path, _, err := configPath() if err != nil { @@ -317,42 +459,37 @@ func Save(cfg Config) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return os.WriteFile(path, renderConfigTOML(cfg), 0o644) + return os.WriteFile(path, renderConfigINI(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 { +// 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 - 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)) + b.WriteString("# lectio configuration (INI). Lists are comma-separated; booleans true/false.\n\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) + + 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 (not edited by the form) so [parts.*] survives Save. + // 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) @@ -372,3 +509,10 @@ func renderConfigTOML(cfg Config) []byte { } return []byte(b.String()) } + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} |
