aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 11:00:19 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 11:00:19 +0200
commitefeadd49b9d78e29778647835bd2a454ddce73a5 (patch)
tree6588d0090db438354f45355588179bb681d4613b /internal/calendar
parentf0bfe1efb93e04e0e3b9a33b8b085fc09e86af36 (diff)
downloadlectio-efeadd49b9d78e29778647835bd2a454ddce73a5.tar.gz
lectio-efeadd49b9d78e29778647835bd2a454ddce73a5.zip
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.
Diffstat (limited to 'internal/calendar')
-rw-r--r--internal/calendar/names.go151
-rw-r--r--internal/calendar/names_test.go27
2 files changed, 178 insertions, 0 deletions
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]+)$`) // <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
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)
+ }
+ }
+}