diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 18:15:29 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 18:15:29 +0200 |
| commit | 539a7c71b60115144959f712ef5966f48c63f9f0 (patch) | |
| tree | 0f07fe348a3a6c99d5a8713e39dd99640aa6efd9 | |
| parent | 5e1664b8008675177a551aff1de371f54d4e20e4 (diff) | |
| download | lectio-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.
| -rw-r--r-- | cmd/lectio-ui/main.go | 7 | ||||
| -rw-r--r-- | cmd/lectio-web/main.go | 8 | ||||
| -rw-r--r-- | internal/calendar/names.go | 151 | ||||
| -rw-r--r-- | internal/calendar/names_test.go | 27 | ||||
| -rw-r--r-- | internal/calfeed/build.go | 18 | ||||
| -rw-r--r-- | internal/cli/cli.go | 4 | ||||
| -rw-r--r-- | internal/cli/liturgy.go | 20 | ||||
| -rw-r--r-- | internal/config/config.go | 36 | ||||
| -rw-r--r-- | internal/config/config_test.go | 11 | ||||
| -rw-r--r-- | internal/naming/lang/pl.ini | 101 | ||||
| -rw-r--r-- | internal/naming/naming.go | 453 | ||||
| -rw-r--r-- | internal/naming/naming_test.go | 55 | ||||
| -rw-r--r-- | internal/readings/offline.go | 21 |
13 files changed, 672 insertions, 240 deletions
diff --git a/cmd/lectio-ui/main.go b/cmd/lectio-ui/main.go index b08188c..1b995a4 100644 --- a/cmd/lectio-ui/main.go +++ b/cmd/lectio-ui/main.go @@ -13,6 +13,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/naming" "github.com/lukaszkasprzak/lectio/internal/tui" ) @@ -64,6 +65,12 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "lectio-ui:", err) return 1 } + if dir, err := config.CorporaDir(); err == nil { + bible.SetUserCorporaDir(dir) + } + if dir, err := config.NamesDir(); err == nil { + naming.SetUserDir(dir) + } var all, offline, reader bool var bibleVer, lectionary string diff --git a/cmd/lectio-web/main.go b/cmd/lectio-web/main.go index 327496a..683fabe 100644 --- a/cmd/lectio-web/main.go +++ b/cmd/lectio-web/main.go @@ -7,7 +7,9 @@ import ( "io" "os" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/naming" "github.com/lukaszkasprzak/lectio/internal/web" ) @@ -48,6 +50,12 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "lectio-web:", err) return 1 } + if dir, err := config.CorporaDir(); err == nil { + bible.SetUserCorporaDir(dir) + } + if dir, err := config.NamesDir(); err == nil { + naming.SetUserDir(dir) + } var offline bool var port int 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) } |
