diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 264 | ||||
| -rw-r--r-- | internal/config/config.ini | 26 | ||||
| -rw-r--r-- | internal/config/config.toml | 29 | ||||
| -rw-r--r-- | internal/config/config_test.go | 41 |
4 files changed, 269 insertions, 91 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 +} diff --git a/internal/config/config.ini b/internal/config/config.ini new file mode 100644 index 0000000..1e5ed94 --- /dev/null +++ b/internal/config/config.ini @@ -0,0 +1,26 @@ +# lectio configuration (INI). +# Interface/behavior settings; the computed liturgical calendar reads the +# [calendar] section. Lists are comma-separated. Booleans are true/false. + +schema_version = 1 +lectionary = new +traditional_lang = pl +versions = bt, wuj, vul, grb, drb +default_version = bt +width = 0 +all = false +offline = false +ui_language = en +sigla_style = auto +web_theme = transfiguration +web_port = 0 +web_display = horizontal +web_mono = false +web_versions = +pager = + +# Computed-calendar national placement options (defaults = Universal Roman). +[calendar] +epiphany = fixed +ascension = thursday +corpus_christi = thursday diff --git a/internal/config/config.toml b/internal/config/config.toml deleted file mode 100644 index 8324032..0000000 --- a/internal/config/config.toml +++ /dev/null @@ -1,29 +0,0 @@ -schema_version = 1 -lectionary = "new" # "new" (niedziela.pl) or "traditional" (missalemeum, 1962) -traditional_lang = "pl" # vernacular for traditional propers: "pl" or "en" -versions = ["bt", "wuj", "vul", "grb", "drb"] # compare set + TUI cycle order -default_version = "bt" # TUI start / `lectio show` default -width = 0 # CLI wrap width; 0 = detect terminal -all = false # default to all parts (true) or just the gospel (false) -offline = false # true = never fetch; read only harvested sigla + cache -ui_language = "en" # interface language (labels/keybar/banner): "en" or "pl". Readings stay source-language. -sigla_style = "auto" # citation dialect for `lectio --ref`/`--list`: "auto" (follow ui_language), "polish" (J 3,16), or "english" (Jn 3:16) -web_theme = "transfiguration" # default lectio-web theme (built-in order/season name, or a user theme in ~/.config/lectio/themes/) -web_port = 0 # lectio-web port; 0 = try 1099, then any free port -web_display = "horizontal" # default lectio-web layout ("uklad"): "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse) -web_mono = false # lectio-web: monospace reading face for any theme (also a top-bar "mono" toggle) -web_versions = [] # lectio-web: which versions start toggled on, e.g. ["wuj", "vul"]; empty = just default_version -pager = "" # send reading output through a pager, e.g. "less -R"; empty = off (also -P/--no-pager) - -# Hide reading parts you don't want. Every part shows by default; a part is -# hidden only when you uncomment its line below (each is preset to false = -# hide). Parts you don't list stay shown. Only the modern lectionary has -# part toggles -- the traditional lectionary always shows just the epistle -# and gospel. -# -# [parts.new] -# pierwsze_czytanie = false # 1. czytanie (1st reading) -# psalm = false # Psalm -# drugie_czytanie = false # 2. czytanie (2nd reading, on feasts) -# aklamacja = false # Aklamacja (acclamation) -# ewangelia = false # Ewangelia (gospel) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 53a3980..38c7604 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,7 +19,7 @@ func TestLoadSeeds(t *testing.T) { if cfg.Lectionary != "new" { t.Errorf("lectionary default wrong: %+v", cfg) } - if _, err := os.Stat(filepath.Join(dir, "lectio", "config.toml")); err != nil { + if _, err := os.Stat(filepath.Join(dir, "lectio", "config.ini")); err != nil { t.Error("config not seeded") } } @@ -201,7 +201,7 @@ func TestSiglaStyleDefaultsAndDialect(t *testing.T) { func TestSaveRoundTrip(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "config.toml") + path := filepath.Join(dir, "config.ini") t.Setenv("LECTIO_CONFIG", path) cfg := Default() @@ -224,6 +224,43 @@ func TestSaveRoundTrip(t *testing.T) { } } +func TestSelectionFromINI(t *testing.T) { + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) + os.WriteFile(filepath.Join(dir, "config.ini"), + []byte("lectionary = new\n[calendar]\nepiphany = sunday\nascension = sunday\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + sel := cfg.Selection() + if sel.Form != "new" || sel.Epiphany != "sunday" || sel.Ascension != "sunday" || sel.CorpusChristi != "thursday" { + t.Fatalf("selection = %+v", sel) + } +} + +func TestMigrateTOMLToINI(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.MkdirAll(filepath.Join(dir, "lectio"), 0o755) + // only the old TOML exists -> Load converts once and writes config.ini. + os.WriteFile(filepath.Join(dir, "lectio", "config.toml"), + []byte("ui_language = \"pl\"\ndefault_version = \"wuj\"\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.UILanguage != "pl" || cfg.DefaultVersion != "wuj" { + t.Errorf("migrated values wrong: %+v", cfg) + } + if _, err := os.Stat(filepath.Join(dir, "lectio", "config.ini")); err != nil { + t.Error("config.ini not written on migration") + } + if _, err := os.Stat(filepath.Join(dir, "lectio", "config.toml")); err != nil { + t.Error("old config.toml should be kept (reversible)") + } +} + func TestPartShown(t *testing.T) { var empty Config if !empty.PartShown("new", "psalm") { |
