summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:53:34 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:53:34 +0200
commit809a47a227540ad8b8b2ac28a1bbbc2f115b7406 (patch)
tree03e609b85ebef46867166469b79a1c01b6c93f7d
parent06e32c85a5b8b0aaf5539a989347fbe68e27b95e (diff)
downloadlectio-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.
-rw-r--r--internal/config/config.go264
-rw-r--r--internal/config/config.ini26
-rw-r--r--internal/config/config.toml29
-rw-r--r--internal/config/config_test.go41
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") {