// 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/.ini // shipped with lectio, and/or a user file at /.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. and falls back through English, Latin, and // finally the humanized slug. Saint names themselves live in the calendar data // (name.), 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+)$`) // -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]+)$`) // -- 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 // (/.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/.ini (if lectio ships one), overlaid by the user's // /.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. // 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] = name // [season_prep] = preposition (may be empty) // [named] = 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 }