From efeadd49b9d78e29778647835bd2a454ddce73a5 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 11:00:19 +0200 Subject: feat: humanize temporal-day display names (OF + EF) Temporal days with no proper name showed a slug-derived string ('triduum fri', 'ordinary 11 tue', 'advent-dec-17'). Add calendar.HumanizeSlug, used by both celebrationName fallbacks (cli + calfeed), turning slugs into proper liturgical names: Good Friday, Ash Wednesday, 'Tuesday of the 11th Week in Ordinary Time', '11th Sunday in Ordinary Time', 'December 17', 'The Most Holy Trinity', and the EF forms ('4th Week after Pentecost', 'Passion Week', 'Ember Wednesday of September'). Adds TestHumanizeSlug. --- internal/calendar/names.go | 151 ++++++++++++++++++++++++++++++++++++++++ internal/calendar/names_test.go | 27 +++++++ internal/calfeed/build.go | 3 +- internal/cli/liturgy.go | 2 +- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 internal/calendar/names.go create mode 100644 internal/calendar/names_test.go diff --git a/internal/calendar/names.go b/internal/calendar/names.go new file mode 100644 index 0000000..935b60c --- /dev/null +++ b/internal/calendar/names.go @@ -0,0 +1,151 @@ +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]+)$`) // -- + 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 { // -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 new file mode 100644 index 0000000..217f481 --- /dev/null +++ b/internal/calendar/names_test.go @@ -0,0 +1,27 @@ +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 d3962aa..216daff 100644 --- a/internal/calfeed/build.go +++ b/internal/calfeed/build.go @@ -1,7 +1,6 @@ package calfeed import ( - "strings" "time" "github.com/lukaszkasprzak/lectio/internal/calendar" @@ -64,5 +63,5 @@ func celebrationName(uiLang string, c calendar.Celebration) string { if c.Slug == "" { return "(feria)" } - return strings.ReplaceAll(strings.TrimPrefix(c.Slug, "ef-"), "-", " ") + return calendar.HumanizeSlug(c.Slug) } diff --git a/internal/cli/liturgy.go b/internal/cli/liturgy.go index 5b6036e..aa3f97e 100644 --- a/internal/cli/liturgy.go +++ b/internal/cli/liturgy.go @@ -164,7 +164,7 @@ func celebrationName(cfg config.Config, c calendar.Celebration) string { if c.Slug == "" { return "(feria)" } - return strings.ReplaceAll(strings.TrimPrefix(c.Slug, "ef-"), "-", " ") + return calendar.HumanizeSlug(c.Slug) } func rankLabel(r calendar.Rank) string { -- cgit v1.3