summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 18:15:29 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 18:15:29 +0200
commit539a7c71b60115144959f712ef5966f48c63f9f0 (patch)
tree0f07fe348a3a6c99d5a8713e39dd99640aa6efd9 /internal
parent5e1664b8008675177a551aff1de371f54d4e20e4 (diff)
downloadlectio-539a7c71b60115144959f712ef5966f48c63f9f0.tar.gz
lectio-539a7c71b60115144959f712ef5966f48c63f9f0.zip
naming: localise liturgical day names in any language
Add internal/naming, which renders temporal day names and celebration display names from the calendar engine's slugs in any language. English is the built-in baseline; a language is data -- an embedded lang/<code>.ini (pl shipped) and/or a user file at <config dir>/names/<code>.ini that overrides it key by key. Names compose from a small vocabulary plus per-language format templates, so word order and grammatical case can differ (e.g. Polish genitive "3. Niedziela Okresu Zwykłego"); any string a language omits falls back to English. - Move day-name generation out of calendar (calendar.HumanizeSlug removed, calendar stays a pure engine) into naming.DayName; the English golden cases carry over verbatim. - naming.CelebrationName unifies the three duplicated resolvers (cli/readings/calfeed): name.<lang> -> English -> Latin -> humanized slug. Saint names stay in the calendar data (name.<lang>), overridable via calendar layers. - config: ui_language now accepts any code (lower-cased, not clamped to en/pl) so names/<code>.ini applies; UI chrome still resolves en/pl and falls back to English. Add NamesDir(); wire naming.SetUserDir (and the previously unwired bible.SetUserCorporaDir) in the TUI and web mains too, so external corpora and name files work across all three binaries.
Diffstat (limited to 'internal')
-rw-r--r--internal/calendar/names.go151
-rw-r--r--internal/calendar/names_test.go27
-rw-r--r--internal/calfeed/build.go18
-rw-r--r--internal/cli/cli.go4
-rw-r--r--internal/cli/liturgy.go20
-rw-r--r--internal/config/config.go36
-rw-r--r--internal/config/config_test.go11
-rw-r--r--internal/naming/lang/pl.ini101
-rw-r--r--internal/naming/naming.go453
-rw-r--r--internal/naming/naming_test.go55
-rw-r--r--internal/readings/offline.go21
11 files changed, 657 insertions, 240 deletions
diff --git a/internal/calendar/names.go b/internal/calendar/names.go
deleted file mode 100644
index 935b60c..0000000
--- a/internal/calendar/names.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package calendar
-
-import (
- "regexp"
- "strconv"
- "strings"
-)
-
-var weekdayName = map[string]string{
- "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday",
- "monday": "Monday", "tuesday": "Tuesday", "wednesday": "Wednesday", "thursday": "Thursday", "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday",
-}
-
-// namedDay maps a computed temporal slug (with any "ef-" prefix removed) to its
-// proper English name, for days that have one.
-var namedDay = map[string]string{
- "triduum-thu": "Holy Thursday", "triduum-fri": "Good Friday", "triduum-sat": "Holy Saturday",
- "maundy-thursday": "Holy Thursday", "good-friday": "Good Friday", "holy-saturday": "Holy Saturday",
- "palm-sunday": "Palm Sunday", "passion-sunday": "Passion Sunday", "low-sunday": "Second Sunday of Easter",
- "trinity-sunday": "The Most Holy Trinity", "trinity": "Trinity Sunday",
- "corpus-christi": "The Body and Blood of Christ", "sacred-heart": "The Most Sacred Heart of Jesus",
- "christ-the-king": "Our Lord Jesus Christ, King of the Universe",
- "easter-sunday": "Easter Sunday", "pentecost": "Pentecost Sunday", "ascension": "The Ascension of the Lord",
- "holy-family": "The Holy Family of Jesus, Mary and Joseph", "baptism-of-the-lord": "The Baptism of the Lord",
- "epiphany": "The Epiphany of the Lord", "christmas": "The Nativity of the Lord", "nativity": "The Nativity of the Lord",
- "circumcision": "The Circumcision of the Lord", "ash-wednesday": "Ash Wednesday",
- "ascension-vigil": "Vigil of the Ascension", "pentecost-vigil": "Vigil of Pentecost",
- "christmas-sunday-sun": "Second Sunday after the Nativity",
- "mary-mother-of-god-octave-of-christmas": "Mary, the Holy Mother of God",
-}
-
-var (
- reSundayNum = regexp.MustCompile(`^(ordinary|advent|lent|easter)-sunday-(\d+)$`)
- reWeekday = regexp.MustCompile(`^(.+)-(\d+)-([a-z]+)$`) // <season>-<week>-<weekday>
- reOctave = regexp.MustCompile(`^easter-octave-([a-z]+)$`)
- reAfterAsh = regexp.MustCompile(`^lent-after-ashes-([a-z]+)$`)
- reHolyWeek = regexp.MustCompile(`^holy-week-([a-z]+)$`)
- reAdventDec = regexp.MustCompile(`^advent-dec-(\d+)$`)
- reXmasDate = regexp.MustCompile(`^christmas-(dec|jan)-(\d+)$`)
- reAftEpiph = regexp.MustCompile(`^christmas-after-epiphany-([a-z]+)$`)
- reEmber = regexp.MustCompile(`^(september|advent)-ember-([a-z]+)$`)
- reSeasonSun = regexp.MustCompile(`^(.+)-sunday-(\d+)$`)
-)
-
-func ordinal(n int) string {
- s := strconv.Itoa(n)
- if n%100 >= 11 && n%100 <= 13 {
- return s + "th"
- }
- switch n % 10 {
- case 1:
- return s + "st"
- case 2:
- return s + "nd"
- case 3:
- return s + "rd"
- }
- return s + "th"
-}
-
-func titleCase(s string) string {
- small := map[string]bool{"of": true, "the": true, "in": true, "after": true, "before": true}
- words := strings.Fields(s)
- for i, w := range words {
- if i > 0 && small[w] {
- continue
- }
- words[i] = strings.ToUpper(w[:1]) + w[1:]
- }
- return strings.Join(words, " ")
-}
-
-// seasonPhrase renders a season slug ("ordinary", "time-after-pentecost") and the
-// preposition that introduces its numbered weeks.
-func seasonPhrase(season string) (name, prep string) {
- switch season {
- case "ordinary":
- return "Ordinary Time", "in"
- case "advent", "lent", "easter", "septuagesima", "passiontide":
- return titleCase(season), "of"
- }
- if rest := strings.TrimPrefix(season, "time-after-"); rest != season {
- return titleCase(rest), "after" // e.g. "Week after Pentecost"
- }
- return titleCase(strings.ReplaceAll(season, "-", " ")), "of"
-}
-
-// HumanizeSlug turns a computed temporal-day slug into a proper English name (used
-// when a temporal celebration carries no explicit name).
-func HumanizeSlug(slug string) string {
- s := strings.TrimPrefix(slug, "ef-")
- if n, ok := namedDay[s]; ok {
- return n
- }
- if m := reSundayNum.FindStringSubmatch(s); m != nil {
- n, _ := strconv.Atoi(m[2])
- name, _ := seasonPhrase(m[1])
- if m[1] == "ordinary" {
- return ordinal(n) + " Sunday in " + name
- }
- return ordinal(n) + " Sunday of " + name
- }
- if m := reOctave.FindStringSubmatch(s); m != nil {
- return weekdayName[m[1]] + " in the Octave of Easter"
- }
- if m := reAfterAsh.FindStringSubmatch(s); m != nil {
- if m[1] == "wed" {
- return "Ash Wednesday"
- }
- return weekdayName[m[1]] + " after Ash Wednesday"
- }
- if m := reHolyWeek.FindStringSubmatch(s); m != nil {
- return weekdayName[m[1]] + " of Holy Week"
- }
- if m := reAdventDec.FindStringSubmatch(s); m != nil {
- n, _ := strconv.Atoi(m[1])
- return "December " + strconv.Itoa(n)
- }
- if m := reXmasDate.FindStringSubmatch(s); m != nil {
- n, _ := strconv.Atoi(m[2])
- if m[1] == "dec" {
- return "December " + strconv.Itoa(n)
- }
- return "January " + strconv.Itoa(n)
- }
- if m := reAftEpiph.FindStringSubmatch(s); m != nil {
- return weekdayName[m[1]] + " after the Epiphany"
- }
- if m := reEmber.FindStringSubmatch(s); m != nil {
- return "Ember " + weekdayName[m[2]] + " of " + titleCase(m[1])
- }
- if m := reSeasonSun.FindStringSubmatch(s); m != nil { // <multiword-season>-sunday-N (EF)
- n, _ := strconv.Atoi(m[2])
- name, prep := seasonPhrase(m[1])
- return ordinal(n) + " Sunday " + prep + " " + name
- }
- if m := reWeekday.FindStringSubmatch(s); m != nil {
- if wd, ok := weekdayName[m[3]]; ok {
- n, _ := strconv.Atoi(m[2])
- name, prep := seasonPhrase(m[1])
- if n == 0 { // the pre-Palm-Sunday week of Passiontide has no week number
- if m[1] == "passiontide" {
- return wd + " of Passion Week"
- }
- return wd + " " + prep + " " + name
- }
- return wd + " of the " + ordinal(n) + " Week " + prep + " " + name
- }
- }
- return titleCase(strings.ReplaceAll(s, "-", " "))
-}
diff --git a/internal/calendar/names_test.go b/internal/calendar/names_test.go
deleted file mode 100644
index 217f481..0000000
--- a/internal/calendar/names_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package calendar
-
-import "testing"
-
-func TestHumanizeSlug(t *testing.T) {
- cases := map[string]string{
- "ordinary-sunday-11": "11th Sunday in Ordinary Time",
- "ordinary-11-tue": "Tuesday of the 11th Week in Ordinary Time",
- "advent-2-mon": "Monday of the 2nd Week of Advent",
- "advent-dec-17": "December 17",
- "lent-after-ashes-wed": "Ash Wednesday",
- "lent-after-ashes-thu": "Thursday after Ash Wednesday",
- "triduum-fri": "Good Friday",
- "easter-octave-mon": "Monday in the Octave of Easter",
- "christmas-jan-2": "January 2",
- "christmas-after-epiphany-mon": "Monday after the Epiphany",
- "trinity-sunday": "The Most Holy Trinity",
- "ef-time-after-pentecost-4-tue": "Tuesday of the 4th Week after Pentecost",
- "ef-passiontide-0-thursday": "Thursday of Passion Week",
- "ef-september-ember-wed": "Ember Wednesday of September",
- }
- for slug, want := range cases {
- if got := HumanizeSlug(slug); got != want {
- t.Errorf("HumanizeSlug(%q) = %q, want %q", slug, got, want)
- }
- }
-}
diff --git a/internal/calfeed/build.go b/internal/calfeed/build.go
index 216daff..8b4f7f0 100644
--- a/internal/calfeed/build.go
+++ b/internal/calfeed/build.go
@@ -4,6 +4,7 @@ import (
"time"
"github.com/lukaszkasprzak/lectio/internal/calendar"
+ "github.com/lukaszkasprzak/lectio/internal/naming"
)
// Build computes the day list from..to inclusive (calendar.Compute per date)
@@ -49,19 +50,12 @@ func celView(uiLang string, c calendar.Celebration) CelView {
}
}
-// celebrationName resolves c's name in uiLang, falling back to English, then
-// a humanized slug (with the "ef-" prefix stripped and dashes turned to
-// spaces), then "(feria)" for an unnamed temporal day. Mirrors
-// cli.celebrationName.
+// celebrationName resolves c's name in uiLang via naming.CelebrationName
+// (name.<lang> -> English -> Latin -> humanized slug), then "(feria)" for an
+// unnamed temporal day.
func celebrationName(uiLang string, c calendar.Celebration) string {
- if n := c.Name[uiLang]; n != "" {
+ if n := naming.CelebrationName(uiLang, c); n != "" {
return n
}
- if n := c.Name["en"]; n != "" {
- return n
- }
- if c.Slug == "" {
- return "(feria)"
- }
- return calendar.HumanizeSlug(c.Slug)
+ return "(feria)"
}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index c4f063d..1fc7ac0 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -19,6 +19,7 @@ import (
"github.com/lukaszkasprzak/lectio/internal/export"
"github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
+ "github.com/lukaszkasprzak/lectio/internal/naming"
"github.com/lukaszkasprzak/lectio/internal/readings"
"github.com/lukaszkasprzak/lectio/internal/render"
)
@@ -203,6 +204,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if dir, err := config.CorporaDir(); err == nil {
bible.SetUserCorporaDir(dir)
}
+ if dir, err := config.NamesDir(); err == nil {
+ naming.SetUserDir(dir)
+ }
if lectionary != "" {
cfg.Lectionary = lectionary
}
diff --git a/internal/cli/liturgy.go b/internal/cli/liturgy.go
index 07a87bc..9d7f3b6 100644
--- a/internal/cli/liturgy.go
+++ b/internal/cli/liturgy.go
@@ -11,6 +11,7 @@ import (
"github.com/lukaszkasprzak/lectio/internal/calendar"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/i18n"
+ "github.com/lukaszkasprzak/lectio/internal/naming"
"github.com/lukaszkasprzak/lectio/internal/render"
)
@@ -149,23 +150,14 @@ func printLiturgicalDay(out io.Writer, cfg config.Config, tbl *bible.BookTable,
}
}
-// celebrationName is the celebration's name in the UI language (falling back to
-// English, then a humanized slug for temporal days that carry no proper name).
+// celebrationName is the celebration's name in the UI language, resolved by
+// naming.CelebrationName (name.<lang> -> English -> Latin -> humanized slug).
+// A bare, unnamed feria renders as "(feria)".
func celebrationName(cfg config.Config, c calendar.Celebration) string {
- lang := "en"
- if cfg.UILanguage == "pl" {
- lang = "pl"
- }
- if n := c.Name[lang]; n != "" {
- return n
- }
- if n := c.Name["en"]; n != "" {
+ if n := naming.CelebrationName(cfg.UILanguage, c); n != "" {
return n
}
- if c.Slug == "" {
- return "(feria)"
- }
- return calendar.HumanizeSlug(c.Slug)
+ return "(feria)"
}
func rankLabel(r calendar.Rank) string {
diff --git a/internal/config/config.go b/internal/config/config.go
index 299c942..c9a00f2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -37,7 +37,7 @@ const configHeader = `# lectio configuration (INI). Full-line comments only (# o
# 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)
+# ui_language any code (en, pl, ...) (chrome is en/pl, else English; day/saint names from names/<code>.ini)
# reading_lang language for offline reading text (e.g. en, pl); blank follows ui_language
# reading_version force a specific bible corpus code (e.g. drb, wuj); blank = auto-resolve
# sigla_style auto | polish | english | latin (citation dialect; auto follows ui_language)
@@ -131,21 +131,14 @@ func NormalizeDisplay(display string) string {
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).
+// NormalizeUILanguage lower-cases and trims a ui_language code. Any code is
+// accepted: it selects day- and saint-name localisation (names/<code>.ini, via
+// internal/naming) and, for the UI chrome, resolves through i18n.Get, which
+// falls back to English for anything other than the built-in "en"/"pl". So a
+// user can set ui_language = fr, ship a names/fr.ini, and get French day names
+// with English chrome -- no rebuild.
func NormalizeUILanguage(lang string) string {
- l := strings.ToLower(lang)
- if !validUILanguages[l] {
- return "en"
- }
- return l
+ return strings.ToLower(strings.TrimSpace(lang))
}
// NormalizeSiglaStyle maps a sigla_style setting to one of "auto", "polish",
@@ -354,6 +347,19 @@ func CorporaDir() (string, error) {
return filepath.Join(filepath.Dir(p), "corpora"), nil
}
+// NamesDir is the user directory for drop-in day-name language files
+// (<code>.ini), alongside calendars/ and corpora/ under the lectio config dir.
+// A file here overrides the built-in day names for its language key by key;
+// combined with ui_language = <code>, it localises the daily view to any
+// language without a rebuild. Mirrors CalendarsDir/CorporaDir.
+func NamesDir() (string, error) {
+ p, _, err := configPath()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(filepath.Dir(p), "names"), 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".
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 1035426..dd75808 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -169,18 +169,21 @@ func TestUILanguageLoadsPL(t *testing.T) {
}
}
-func TestUILanguageNormalizesUnknown(t *testing.T) {
+func TestUILanguagePreservesAnyCode(t *testing.T) {
+ // Any ui_language code is kept (lower-cased): it drives day/saint-name
+ // localisation via names/<code>.ini. The UI chrome, resolved through
+ // i18n.Get, still falls back to English for a code it has no table for.
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
os.WriteFile(filepath.Join(dir, "lectio", "config.toml"),
- []byte("ui_language = \"bogus\"\n"), 0o644)
+ []byte("ui_language = \"FR\"\n"), 0o644)
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
- if cfg.UILanguage != "en" {
- t.Errorf("UILanguage = %q, want %q (normalized from bogus)", cfg.UILanguage, "en")
+ if cfg.UILanguage != "fr" {
+ t.Errorf("UILanguage = %q, want %q (lower-cased, not clamped)", cfg.UILanguage, "fr")
}
}
diff --git a/internal/naming/lang/pl.ini b/internal/naming/lang/pl.ini
new file mode 100644
index 0000000..f7a3217
--- /dev/null
+++ b/internal/naming/lang/pl.ini
@@ -0,0 +1,101 @@
+# Polish (pl) temporal day names for lectio.
+#
+# This file overrides the built-in English baseline key by key: anything left
+# out falls back to English. Copy it to <config dir>/names/pl.ini to customise,
+# or use it as a template for another language (<config dir>/names/<code>.ini,
+# then set ui_language = <code>). Placeholders in [templates]:
+# {ord} ordinal (see [ordinal]) {wd} weekday (see [weekdays])
+# {season} season name (see below) {prep} preposition (see [season_prep])
+# {day} day-of-month number {month} month name (see [months])
+# Season names are given in the genitive case Polish uses after "Niedziela"/
+# "tygodnia", so no linking preposition is needed ([season_prep] left empty).
+
+[templates]
+sunday = {ord} Niedziela {season}
+weekday = {wd} {ord} tygodnia {season}
+weekday_no_week = {wd} {season}
+octave_easter = {wd} w Oktawie Wielkanocnej
+after_ashes = {wd} po Środzie Popielcowej
+holy_week = {wd} Wielkiego Tygodnia
+after_epiphany = {wd} po Objawieniu Pańskim
+ember = {wd} suchych dni {season}
+passion_week = {wd} Tygodnia Męki Pańskiej
+date = {day} {month}
+
+[ordinal]
+format = %d.
+
+[weekdays]
+mon = Poniedziałek
+tue = Wtorek
+wed = Środa
+thu = Czwartek
+fri = Piątek
+sat = Sobota
+sun = Niedziela
+
+[months]
+1 = stycznia
+2 = lutego
+3 = marca
+4 = kwietnia
+5 = maja
+6 = czerwca
+7 = lipca
+8 = sierpnia
+9 = września
+10 = października
+11 = listopada
+12 = grudnia
+
+[seasons]
+ordinary = Okresu Zwykłego
+advent = Adwentu
+lent = Wielkiego Postu
+easter = Okresu Wielkanocnego
+septuagesima = Przedpościa
+passiontide = Męki Pańskiej
+time-after-pentecost = po Zesłaniu Ducha Świętego
+time-after-epiphany = po Objawieniu Pańskim
+september = września
+
+[season_prep]
+ordinary =
+advent =
+lent =
+easter =
+septuagesima =
+passiontide =
+time-after-pentecost =
+time-after-epiphany =
+september =
+
+[named]
+triduum-thu = Wielki Czwartek
+triduum-fri = Wielki Piątek
+triduum-sat = Wielka Sobota
+maundy-thursday = Wielki Czwartek
+good-friday = Wielki Piątek
+holy-saturday = Wielka Sobota
+palm-sunday = Niedziela Palmowa
+passion-sunday = Niedziela Męki Pańskiej
+low-sunday = 2. Niedziela Wielkanocna
+trinity-sunday = Uroczystość Najświętszej Trójcy
+trinity = Niedziela Trójcy Świętej
+corpus-christi = Najświętszego Ciała i Krwi Chrystusa
+sacred-heart = Najświętszego Serca Pana Jezusa
+christ-the-king = Jezusa Chrystusa Króla Wszechświata
+easter-sunday = Niedziela Zmartwychwstania Pańskiego
+pentecost = Zesłanie Ducha Świętego
+ascension = Wniebowstąpienie Pańskie
+holy-family = Świętej Rodziny: Jezusa, Maryi i Józefa
+baptism-of-the-lord = Chrzest Pański
+epiphany = Objawienie Pańskie
+christmas = Narodzenie Pańskie
+nativity = Narodzenie Pańskie
+circumcision = Obrzezanie Pańskie
+ash-wednesday = Środa Popielcowa
+ascension-vigil = Wigilia Wniebowstąpienia
+pentecost-vigil = Wigilia Zesłania Ducha Świętego
+christmas-sunday-sun = 2. Niedziela po Narodzeniu Pańskim
+mary-mother-of-god-octave-of-christmas = Świętej Bożej Rodzicielki Maryi
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
new file mode 100644
index 0000000..ca47a15
--- /dev/null
+++ b/internal/naming/naming.go
@@ -0,0 +1,453 @@
+// Package naming renders human-readable liturgical day names from the calendar
+// engine's computed slugs, in any language. English is the built-in baseline
+// (builtinEN, below); every other language is data: an embedded lang/<code>.ini
+// shipped with lectio, and/or a user file at <names dir>/<code>.ini that
+// overrides it key by key. A partial translation never breaks -- any string a
+// language omits falls back to English.
+//
+// Two things are localised here: the generated TEMPORAL day names (e.g. "3rd
+// Sunday in Ordinary Time"), composed from a small vocabulary plus per-language
+// format templates so word order and grammatical case can differ; and the
+// display name of any celebration (CelebrationName), which prefers the
+// calendar entry's own name.<lang> and falls back through English, Latin, and
+// finally the humanized slug. Saint names themselves live in the calendar data
+// (name.<lang>), not here.
+package naming
+
+import (
+ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+ "github.com/lukaszkasprzak/lectio/internal/ini"
+)
+
+//go:embed lang
+var langFS embed.FS
+
+// Table is one language's temporal-naming data. Every field is optional in a
+// language file: a missing entry falls back to the English baseline (for maps,
+// per key), so a translator fills in only what they want to change.
+type Table struct {
+ Weekdays map[string]string // mon,tue,wed,thu,fri,sat,sun -> weekday name
+ NamedDays map[string]string // temporal slug -> proper name (Good Friday, ...)
+ Seasons map[string]string // season slug -> localised season name
+ SeasonPrep map[string]string // season slug -> preposition joining it to a week/Sunday
+ Months []string // 12 month names (index 0 = January), for dated ferias
+ Templates map[string]string // template id -> format with {ord}{wd}{season}{prep}{day}{month}
+ Ordinals map[string]string // "1".."40" -> localised ordinal (optional; overrides OrdinalFmt)
+ OrdinalFmt string // printf-style fallback ordinal, e.g. "%d." (optional)
+}
+
+// ---- temporal slug patterns (the calendar engine's own slug shapes) ---------
+
+var (
+ reSeasonSun = regexp.MustCompile(`^(.+)-sunday-(\d+)$`) // <season>-sunday-N (all numbered Sundays)
+ reOctave = regexp.MustCompile(`^easter-octave-([a-z]+)$`) // Octave of Easter weekday
+ reAfterAsh = regexp.MustCompile(`^lent-after-ashes-([a-z]+)$`) // Thu/Fri/Sat after Ash Wednesday
+ reHolyWeek = regexp.MustCompile(`^holy-week-([a-z]+)$`) // Mon-Wed of Holy Week
+ reAdventDec = regexp.MustCompile(`^advent-dec-(\d+)$`) // late-Advent dated feria
+ reXmasDate = regexp.MustCompile(`^christmas-(dec|jan)-(\d+)$`) // Christmastide dated feria
+ reAftEpiph = regexp.MustCompile(`^christmas-after-epiphany-([a-z]+)$`) // weekday after Epiphany
+ reEmber = regexp.MustCompile(`^(september|advent)-ember-([a-z]+)$`) // Ember day
+ reWeekday = regexp.MustCompile(`^(.+)-(\d+)-([a-z]+)$`) // <season>-<week>-<weekday>
+ rePlacehold = regexp.MustCompile(`\{[a-z]+\}`) // leftover {token} cleanup
+)
+
+// weekdayAbbrev maps any weekday token the slugs use (abbreviated or full) to
+// the canonical three-letter key the Table's Weekdays map is keyed by.
+var weekdayAbbrev = map[string]string{
+ "mon": "mon", "tue": "tue", "wed": "wed", "thu": "thu", "fri": "fri", "sat": "sat", "sun": "sun",
+ "monday": "mon", "tuesday": "tue", "wednesday": "wed", "thursday": "thu",
+ "friday": "fri", "saturday": "sat", "sunday": "sun",
+}
+
+// builtinEN is the English baseline every other language overlays. The season
+// name/preposition and ordinal for English are computed (enSeasonName,
+// enSeasonPrep, enOrdinal) rather than tabulated, so unlisted seasons still
+// read correctly; a language file supplies Seasons/SeasonPrep/Ordinals to
+// override them.
+var builtinEN = Table{
+ Weekdays: map[string]string{
+ "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday",
+ "fri": "Friday", "sat": "Saturday", "sun": "Sunday",
+ },
+ NamedDays: map[string]string{
+ "triduum-thu": "Holy Thursday", "triduum-fri": "Good Friday", "triduum-sat": "Holy Saturday",
+ "maundy-thursday": "Holy Thursday", "good-friday": "Good Friday", "holy-saturday": "Holy Saturday",
+ "palm-sunday": "Palm Sunday", "passion-sunday": "Passion Sunday", "low-sunday": "Second Sunday of Easter",
+ "trinity-sunday": "The Most Holy Trinity", "trinity": "Trinity Sunday",
+ "corpus-christi": "The Body and Blood of Christ", "sacred-heart": "The Most Sacred Heart of Jesus",
+ "christ-the-king": "Our Lord Jesus Christ, King of the Universe",
+ "easter-sunday": "Easter Sunday", "pentecost": "Pentecost Sunday", "ascension": "The Ascension of the Lord",
+ "holy-family": "The Holy Family of Jesus, Mary and Joseph", "baptism-of-the-lord": "The Baptism of the Lord",
+ "epiphany": "The Epiphany of the Lord", "christmas": "The Nativity of the Lord", "nativity": "The Nativity of the Lord",
+ "circumcision": "The Circumcision of the Lord", "ash-wednesday": "Ash Wednesday",
+ "ascension-vigil": "Vigil of the Ascension", "pentecost-vigil": "Vigil of Pentecost",
+ "christmas-sunday-sun": "Second Sunday after the Nativity",
+ "mary-mother-of-god-octave-of-christmas": "Mary, the Holy Mother of God",
+ },
+ Months: []string{"January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"},
+ Templates: map[string]string{
+ "sunday": "{ord} Sunday {prep} {season}",
+ "weekday": "{wd} of the {ord} Week {prep} {season}",
+ "weekday_no_week": "{wd} {prep} {season}",
+ "octave_easter": "{wd} in the Octave of Easter",
+ "after_ashes": "{wd} after Ash Wednesday",
+ "holy_week": "{wd} of Holy Week",
+ "after_epiphany": "{wd} after the Epiphany",
+ "ember": "Ember {wd} of {season}",
+ "passion_week": "{wd} of Passion Week",
+ "date": "{month} {day}",
+ },
+ Seasons: map[string]string{},
+ SeasonPrep: map[string]string{},
+ Ordinals: map[string]string{},
+}
+
+// ---- table resolution + caching ---------------------------------------------
+
+var (
+ tblMu sync.Mutex
+ tblCache = map[string]Table{}
+ userDir string
+)
+
+// SetUserDir points naming at the drop-in dir holding user language files
+// (<dir>/<code>.ini) and invalidates the cache. Empty string disables it.
+func SetUserDir(dir string) {
+ tblMu.Lock()
+ userDir = dir
+ tblCache = map[string]Table{}
+ tblMu.Unlock()
+}
+
+// tableFor returns the merged table for lang: the English baseline, overlaid by
+// the embedded lang/<code>.ini (if lectio ships one), overlaid by the user's
+// <dir>/<code>.ini (if present).
+func tableFor(lang string) Table {
+ tblMu.Lock()
+ defer tblMu.Unlock()
+ if t, ok := tblCache[lang]; ok {
+ return t
+ }
+ t := builtinEN.clone()
+ if lang != "" && lang != "en" {
+ if data, err := langFS.ReadFile("lang/" + lang + ".ini"); err == nil {
+ t.overlay(parseTable(data))
+ }
+ }
+ if userDir != "" && lang != "" {
+ if data, err := os.ReadFile(filepath.Join(userDir, lang+".ini")); err == nil {
+ t.overlay(parseTable(data))
+ }
+ }
+ tblCache[lang] = t
+ return t
+}
+
+func (t Table) clone() Table {
+ c := Table{
+ Weekdays: cloneMap(t.Weekdays), NamedDays: cloneMap(t.NamedDays),
+ Seasons: cloneMap(t.Seasons), SeasonPrep: cloneMap(t.SeasonPrep),
+ Templates: cloneMap(t.Templates), Ordinals: cloneMap(t.Ordinals),
+ OrdinalFmt: t.OrdinalFmt,
+ }
+ c.Months = append([]string(nil), t.Months...)
+ return c
+}
+
+// overlay copies o's non-empty entries onto t (per key), so a partial language
+// file overrides only the strings it provides.
+func (t *Table) overlay(o Table) {
+ mergeMap(t.Weekdays, o.Weekdays)
+ mergeMap(t.NamedDays, o.NamedDays)
+ mergeMap(t.Seasons, o.Seasons)
+ mergeMap(t.SeasonPrep, o.SeasonPrep)
+ mergeMap(t.Templates, o.Templates)
+ mergeMap(t.Ordinals, o.Ordinals)
+ if o.OrdinalFmt != "" {
+ t.OrdinalFmt = o.OrdinalFmt
+ }
+ for i, m := range o.Months {
+ if m != "" && i < len(t.Months) {
+ t.Months[i] = m
+ }
+ }
+}
+
+func cloneMap(m map[string]string) map[string]string {
+ c := make(map[string]string, len(m))
+ for k, v := range m {
+ c[k] = v
+ }
+ return c
+}
+
+// mergeMap copies every entry of src over dst, including empty values: an empty
+// override is respected where it is meaningful (a season_prep a language leaves
+// blank because it joins by grammatical case) and harmless elsewhere, since the
+// getters fall back to English when a looked-up string is empty.
+func mergeMap(dst, src map[string]string) {
+ for k, v := range src {
+ dst[k] = v
+ }
+}
+
+// ---- public API -------------------------------------------------------------
+
+// DayName renders a temporal day slug as a proper name in lang, e.g.
+// DayName("ordinary-sunday-11", "en") == "11th Sunday in Ordinary Time".
+func DayName(slug, lang string) string {
+ return tableFor(lang).day(slug)
+}
+
+// CelebrationName is a celebration's display name in lang: its own name.<lang>
+// if present, else name.en, else name.la, else the humanized temporal slug.
+// Returns "" only for an unnamed celebration with an empty slug (the caller
+// decides how to render a bare feria).
+func CelebrationName(lang string, c calendar.Celebration) string {
+ if lang != "" {
+ if n := c.Name[lang]; n != "" {
+ return n
+ }
+ }
+ if n := c.Name["en"]; n != "" {
+ return n
+ }
+ if n := c.Name["la"]; n != "" {
+ return n
+ }
+ if c.Slug == "" {
+ return ""
+ }
+ return DayName(c.Slug, lang)
+}
+
+// ---- composition ------------------------------------------------------------
+
+func (t Table) day(slug string) string {
+ s := strings.TrimPrefix(slug, "ef-")
+ if n, ok := t.NamedDays[s]; ok {
+ return n
+ }
+ if m := reSeasonSun.FindStringSubmatch(s); m != nil {
+ return t.render("sunday", map[string]string{
+ "ord": t.ordinal(atoi(m[2])), "prep": t.prep(m[1]), "season": t.season(m[1]),
+ })
+ }
+ if m := reOctave.FindStringSubmatch(s); m != nil {
+ return t.render("octave_easter", map[string]string{"wd": t.weekday(m[1])})
+ }
+ if m := reAfterAsh.FindStringSubmatch(s); m != nil {
+ if weekdayAbbrev[m[1]] == "wed" {
+ return t.NamedDays["ash-wednesday"] // the day the ferias hang off of
+ }
+ return t.render("after_ashes", map[string]string{"wd": t.weekday(m[1])})
+ }
+ if m := reHolyWeek.FindStringSubmatch(s); m != nil {
+ return t.render("holy_week", map[string]string{"wd": t.weekday(m[1])})
+ }
+ if m := reAdventDec.FindStringSubmatch(s); m != nil {
+ return t.render("date", map[string]string{"month": t.month(12), "day": m[1]})
+ }
+ if m := reXmasDate.FindStringSubmatch(s); m != nil {
+ mon := 12
+ if m[1] == "jan" {
+ mon = 1
+ }
+ return t.render("date", map[string]string{"month": t.month(mon), "day": m[2]})
+ }
+ if m := reAftEpiph.FindStringSubmatch(s); m != nil {
+ return t.render("after_epiphany", map[string]string{"wd": t.weekday(m[1])})
+ }
+ if m := reEmber.FindStringSubmatch(s); m != nil {
+ return t.render("ember", map[string]string{"wd": t.weekday(m[2]), "season": t.season(m[1])})
+ }
+ if m := reWeekday.FindStringSubmatch(s); m != nil {
+ if _, ok := weekdayAbbrev[m[3]]; ok {
+ n := atoi(m[2])
+ if n == 0 { // Passiontide's pre-Palm-Sunday week carries no number
+ if m[1] == "passiontide" {
+ return t.render("passion_week", map[string]string{"wd": t.weekday(m[3])})
+ }
+ return t.render("weekday_no_week", map[string]string{
+ "wd": t.weekday(m[3]), "prep": t.prep(m[1]), "season": t.season(m[1]),
+ })
+ }
+ return t.render("weekday", map[string]string{
+ "wd": t.weekday(m[3]), "ord": t.ordinal(n), "prep": t.prep(m[1]), "season": t.season(m[1]),
+ })
+ }
+ }
+ return titleCase(strings.ReplaceAll(s, "-", " "))
+}
+
+// render fills a template's {tokens}, drops any left unfilled, and collapses
+// whitespace -- so a language with an empty preposition (case-joined seasons)
+// never leaves a double space.
+func (t Table) render(id string, vars map[string]string) string {
+ f := t.Templates[id]
+ if f == "" {
+ f = builtinEN.Templates[id]
+ }
+ for k, v := range vars {
+ f = strings.ReplaceAll(f, "{"+k+"}", v)
+ }
+ f = rePlacehold.ReplaceAllString(f, "")
+ return strings.Join(strings.Fields(f), " ")
+}
+
+func (t Table) weekday(tok string) string {
+ if ab, ok := weekdayAbbrev[tok]; ok {
+ if name := t.Weekdays[ab]; name != "" {
+ return name
+ }
+ }
+ return titleCase(tok)
+}
+
+func (t Table) month(n int) string {
+ if n >= 1 && n <= len(t.Months) && t.Months[n-1] != "" {
+ return t.Months[n-1]
+ }
+ return builtinEN.Months[n-1]
+}
+
+func (t Table) ordinal(n int) string {
+ if s, ok := t.Ordinals[strconv.Itoa(n)]; ok {
+ return s
+ }
+ if t.OrdinalFmt != "" {
+ return fmt.Sprintf(t.OrdinalFmt, n)
+ }
+ return enOrdinal(n)
+}
+
+func (t Table) season(slug string) string {
+ if s, ok := t.Seasons[slug]; ok {
+ return s
+ }
+ return enSeasonName(slug)
+}
+
+func (t Table) prep(slug string) string {
+ if p, ok := t.SeasonPrep[slug]; ok {
+ return p // may be intentionally empty
+ }
+ return enSeasonPrep(slug)
+}
+
+// ---- English fallbacks (match the historical HumanizeSlug behaviour) ---------
+
+func enSeasonName(slug string) string {
+ if slug == "ordinary" {
+ return "Ordinary Time"
+ }
+ if rest := strings.TrimPrefix(slug, "time-after-"); rest != slug {
+ return titleCase(rest)
+ }
+ return titleCase(strings.ReplaceAll(slug, "-", " "))
+}
+
+func enSeasonPrep(slug string) string {
+ if slug == "ordinary" {
+ return "in"
+ }
+ if strings.HasPrefix(slug, "time-after-") {
+ return "after"
+ }
+ return "of"
+}
+
+func enOrdinal(n int) string {
+ s := strconv.Itoa(n)
+ if n%100 >= 11 && n%100 <= 13 {
+ return s + "th"
+ }
+ switch n % 10 {
+ case 1:
+ return s + "st"
+ case 2:
+ return s + "nd"
+ case 3:
+ return s + "rd"
+ }
+ return s + "th"
+}
+
+func titleCase(s string) string {
+ small := map[string]bool{"of": true, "the": true, "in": true, "after": true, "before": true}
+ words := strings.Fields(s)
+ for i, w := range words {
+ if i > 0 && small[w] {
+ continue
+ }
+ if w != "" {
+ words[i] = strings.ToUpper(w[:1]) + w[1:]
+ }
+ }
+ return strings.Join(words, " ")
+}
+
+func atoi(s string) int { n, _ := strconv.Atoi(s); return n }
+
+// ---- language-file parsing --------------------------------------------------
+
+// parseTable reads a language INI into a (partial) Table. Sections:
+//
+// [templates] id = format
+// [weekdays] mon..sun = name
+// [months] 1..12 = name
+// [seasons] <season slug> = name
+// [season_prep] <season slug> = preposition (may be empty)
+// [named] <temporal slug> = name
+// [ordinal] format = %d. | 1 = 1st, 2 = 2nd, ...
+func parseTable(data []byte) Table {
+ t := Table{
+ Weekdays: map[string]string{}, NamedDays: map[string]string{},
+ Seasons: map[string]string{}, SeasonPrep: map[string]string{},
+ Templates: map[string]string{}, Ordinals: map[string]string{},
+ Months: make([]string, 12),
+ }
+ secs, err := ini.Parse(data)
+ if err != nil {
+ return t
+ }
+ for _, s := range secs {
+ for _, p := range s.Pairs {
+ switch s.Name {
+ case "templates":
+ t.Templates[p.Key] = p.Val
+ case "weekdays":
+ if ab, ok := weekdayAbbrev[p.Key]; ok {
+ t.Weekdays[ab] = p.Val
+ }
+ case "months":
+ if i := atoi(p.Key); i >= 1 && i <= 12 {
+ t.Months[i-1] = p.Val
+ }
+ case "seasons":
+ t.Seasons[p.Key] = p.Val
+ case "season_prep":
+ t.SeasonPrep[p.Key] = p.Val
+ case "named":
+ t.NamedDays[p.Key] = p.Val
+ case "ordinal":
+ if p.Key == "format" {
+ t.OrdinalFmt = p.Val
+ } else {
+ t.Ordinals[p.Key] = p.Val
+ }
+ }
+ }
+ }
+ return t
+}
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
new file mode 100644
index 0000000..a3bc49f
--- /dev/null
+++ b/internal/naming/naming_test.go
@@ -0,0 +1,55 @@
+package naming
+
+import "testing"
+
+// English must reproduce exactly what calendar.HumanizeSlug produced before the
+// name generation moved here (the golden cases carried over verbatim).
+func TestDayNameEnglish(t *testing.T) {
+ cases := map[string]string{
+ "ordinary-sunday-11": "11th Sunday in Ordinary Time",
+ "ordinary-11-tue": "Tuesday of the 11th Week in Ordinary Time",
+ "advent-2-mon": "Monday of the 2nd Week of Advent",
+ "advent-dec-17": "December 17",
+ "lent-after-ashes-wed": "Ash Wednesday",
+ "lent-after-ashes-thu": "Thursday after Ash Wednesday",
+ "triduum-fri": "Good Friday",
+ "easter-octave-mon": "Monday in the Octave of Easter",
+ "christmas-jan-2": "January 2",
+ "christmas-after-epiphany-mon": "Monday after the Epiphany",
+ "trinity-sunday": "The Most Holy Trinity",
+ "ef-time-after-pentecost-4-tue": "Tuesday of the 4th Week after Pentecost",
+ "ef-passiontide-0-thursday": "Thursday of Passion Week",
+ "ef-september-ember-wed": "Ember Wednesday of September",
+ }
+ for slug, want := range cases {
+ if got := DayName(slug, "en"); got != want {
+ t.Errorf("DayName(%q, en) = %q, want %q", slug, got, want)
+ }
+ }
+}
+
+// Polish must localise both named days and composed names, using its own word
+// order and genitive-case season names.
+func TestDayNamePolish(t *testing.T) {
+ cases := map[string]string{
+ "ordinary-sunday-11": "11. Niedziela Okresu Zwykłego",
+ "ordinary-11-tue": "Wtorek 11. tygodnia Okresu Zwykłego",
+ "advent-2-mon": "Poniedziałek 2. tygodnia Adwentu",
+ "advent-dec-17": "17 grudnia",
+ "triduum-fri": "Wielki Piątek",
+ "lent-after-ashes-wed": "Środa Popielcowa",
+ "ef-time-after-pentecost-4-tue": "Wtorek 4. tygodnia po Zesłaniu Ducha Świętego",
+ }
+ for slug, want := range cases {
+ if got := DayName(slug, "pl"); got != want {
+ t.Errorf("DayName(%q, pl) = %q, want %q", slug, got, want)
+ }
+ }
+}
+
+// An unknown language falls back entirely to English.
+func TestDayNameUnknownLangFallsBackToEnglish(t *testing.T) {
+ if got := DayName("ordinary-sunday-3", "xx"); got != "3rd Sunday in Ordinary Time" {
+ t.Errorf("unknown lang = %q", got)
+ }
+}
diff --git a/internal/readings/offline.go b/internal/readings/offline.go
index 566fc7a..d0b8942 100644
--- a/internal/readings/offline.go
+++ b/internal/readings/offline.go
@@ -9,6 +9,7 @@ import (
"github.com/lukaszkasprzak/lectio/internal/calendar"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
+ "github.com/lukaszkasprzak/lectio/internal/naming"
)
// offlineLoad resolves a day's readings entirely from the embedded calendar
@@ -119,22 +120,8 @@ func dayInfo(cfg config.Config, day calendar.LiturgicalDay) liturgy.DayInfo {
}
// celebrationName is the observed celebration's name in the UI language,
-// falling back to English then a humanized slug for temporal days that carry no
-// proper name (mirrors the CLI's cli.celebrationName). An empty result omits
-// the header line.
+// resolved by naming.CelebrationName (name.<lang> -> English -> Latin ->
+// humanized slug). An empty result (unnamed feria) omits the header line.
func celebrationName(cfg config.Config, c calendar.Celebration) string {
- lang := "en"
- if cfg.UILanguage == "pl" {
- lang = "pl"
- }
- if n := c.Name[lang]; n != "" {
- return n
- }
- if n := c.Name["en"]; n != "" {
- return n
- }
- if c.Slug == "" {
- return ""
- }
- return calendar.HumanizeSlug(c.Slug)
+ return naming.CelebrationName(cfg.UILanguage, c)
}