package caldata import ( "sort" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/calendar" ) var partOrder = map[string]int{"first": 0, "psalm": 1, "second": 2, "acclamation": 3, "gospel": 4} // ofFerial returns the day's Ordinary-Form ferial (weekday) readings, or nil. func ofFerial(sel calendar.Selection, date time.Time, day calendar.LiturgicalDay) []calendar.Reading { if day.WeekdayCycle == "" || date.Weekday() == time.Sunday { return nil } if temp := calendar.Compute(date, sel, nil); temp.Observed.Layer == "temporal" { return TemporalReadings("new", temp.Observed.Slug+"-"+day.WeekdayCycle) } return nil } // Readings resolves a day's Mass readings: the observed celebration's inline // propers, else the temporal lectionary for its slug, else -- for an EF // weekday with no proper Mass -- the preceding Sunday's Mass (the 1962 rule // that green-season ferias repeat the Sunday). Shared by the CLI (cli.dayReadings // delegates here) and calfeed.Build (injected as its readings func). func Readings(sel calendar.Selection, layers []calendar.Layer, date time.Time, day calendar.LiturgicalDay) []calendar.Reading { var rs []calendar.Reading for _, m := range day.Observed.Masses { rs = append(rs, m.Readings...) } if len(rs) > 0 { // OF: a memorial with only a PROPER gospel (or only a proper first) takes // the missing reading from the day's ferial -- e.g. the Guardian Angels are // the ferial first reading + their proper gospel (Matt 18), and St Barnabas // is his proper first (Acts 11) + the ferial gospel. A fully-proper feast // (first AND gospel present) is left untouched. have := map[string]bool{} for _, r := range rs { have[r.Part] = true } if sel.Form != "old" && !(have["first"] && have["gospel"]) { for _, fr := range ofFerial(sel, date, day) { if p := fr.Part; !have[p] && (p == "first" || p == "psalm" || p == "gospel") { rs = append(rs, fr) have[p] = true } } sort.SliceStable(rs, func(i, j int) bool { return partOrder[rs[i].Part] < partOrder[rs[j].Part] }) } return rs } // temporal table: EF by bare slug; OF by slug+cycle. Sundays & solemnities // use the 3-year Sunday cycle (A/B/C); ferial weekdays use the 2-year // weekday cycle (I/II). Sunday-slugs and weekday-slugs are disjoint, so // trying both cycle suffixes is unambiguous. if sel.Form == "old" { if r := efBVMSaturdayReadings(day); len(r) > 0 { return r } if r := efNativityOctaveReadings(day); len(r) > 0 { return r } if r := efEpiphanytideOpeningReadings(day); len(r) > 0 { return r } if r := efEpiphanyWeekReadings(day); len(r) > 0 { return r } if r := TemporalReadings("old", day.Observed.Slug); len(r) > 0 { return r } } else { for _, cyc := range []string{day.SundayCycle, day.WeekdayCycle} { if cyc == "" { continue } if r := TemporalReadings("new", day.Observed.Slug+"-"+cyc); len(r) > 0 { return r } } } // OF: a sanctoral memorial / optional memorial with no proper readings of its // own reads the FERIAL (weekday) readings of the day -- the ordinary OF rule. if sel.Form != "old" && day.Observed.Layer != "temporal" { if r := ofFerial(sel, date, day); len(r) > 0 { return r } } // EF: a green-season weekday with no proper Mass repeats the preceding // Sunday's Mass. Use the Sunday's TEMPORAL slug (not its observed) so the // repeat still resolves when that Sunday was a feast (e.g. the Purification). if sel.Form == "old" && day.Observed.Layer == "temporal" && date.Weekday() != time.Sunday { sun := date.AddDate(0, 0, -int(date.Weekday())) // preceding Sunday (Sunday = 0) return TemporalReadings(sel.Form, calendar.TemporalSlug(sun, sel)) } return nil } // efBVMSaturdayReadings returns the Mass of Our Lady on Saturday, when that is // the day's office. // // RG 78 keeps the office on a Saturday whose feria is IV class (see // temporal_ef.go, which gives the day its white colour). RG 309(a) then says // which Mass is said: "Missae de sancta Maria in sabbato ... iuxta temporum // diversitatem" -- the Missal prints five formularies and the season selects // among them. Without this the day was kept as Our Lady's but read the feria's // Mass, so a Saturday in Septuagesima announced Our Lady and then read the // parable of the labourers. // // Citations are colitur's, which verified each against both photographic scans // of the 1962 Missal; see its lib/rites/rite_ef/lectionary_ef.ml, // bvm_saturday_citations, for the per-formulary scan references. // // The Advent formulary is included and is STRUCTURALLY UNREACHABLE as the // Saturday office: Advent has no IV-class ferias, so the office never falls // there. It is kept because RG 309's subject is the Masses themselves, not // only the days they land on -- the Mass exists -- and because a missing // branch would silently fall through if the rank rules ever changed. func efBVMSaturdayReadings(day calendar.LiturgicalDay) []calendar.Reading { // The office, not merely the day: a Saturday IV-class feria that nothing // outranked. A sanctoral feast that won the day keeps its own readings, so // the observed slug must still be the temporal one -- which the office // deliberately reuses rather than replacing (see temporal_ef.go). if day.Weekday != time.Saturday || day.Observed.Rank != calendar.RankClass4 { return nil } if !strings.HasPrefix(day.Observed.Slug, "ef-") || !strings.HasSuffix(day.Observed.Slug, "-saturday") { return nil } first, gospel := "", "" switch day.Season { case calendar.Advent: first, gospel = "Isa 7:10-15", "Luke 1:26-38" case calendar.Christmas: first, gospel = "Titus 3:4-7", "Luke 2:15-20" case calendar.TimeAfterEpiphany: // The Christmastide formulary runs on until the Purification (2 // February), not merely to the end of Christmas Time. if m := day.Date.Month(); m == time.January || (m == time.February && day.Date.Day() <= 2) { first, gospel = "Titus 3:4-7", "Luke 2:15-20" } else { first, gospel = "Ecclus 24:14-16", "Luke 11:27-28" } case calendar.Easter_: first, gospel = "Ecclus 24:14-16", "John 19:25-27" default: // Septuagesima, Lent, Passiontide, Time after Pentecost first, gospel = "Ecclus 24:14-16", "Luke 11:27-28" } return []calendar.Reading{ {Part: "first", Citation: first}, {Part: "gospel", Citation: gospel}, } } // efNativityOctaveReadings returns the Mass of a WEEKDAY within the Octave of // the Nativity -- 29, 30 and 31 December. // // The Missal gives those days a direct formulary, "Diebus infra octavam // Nativitatis Domini, II classis": Titus 3:4-7 / Luke 2:15-20, and each date's // own rubric points at it ("Die 29 decembris ... Missa Puer natus est nobis, // ut supra", repeated verbatim on the 30th and 31st). They are named days in // the calendarium too -- "De V die infra octavam Nativitatis Domini, II // classis" and so on -- not ordinary ferias. // // lectio treated them as ordinary Christmas ferias, so they fell back to the // preceding Sunday and served "Dominica infra octavam Nativitatis Domini" // (Gal 4:1-7 / Luke 2:33-40). That is a DIFFERENT heading, for the Sunday // specifically. colitur made the identical mistake once, measured it against // real years and reverted it; its bootstrap_lectionary.ml records the // rejection so it is not re-attempted, with the scan references for both. // // Only the WEEKDAYS. When 29-31 December falls on a Sunday the day really is // "Dominica infra octavam" and keeps the Sunday's Mass, which is the // distinction the wrong citation blurred -- so the Sunday is excluded here // rather than merely happening not to match. func efNativityOctaveReadings(day calendar.LiturgicalDay) []calendar.Reading { if day.Date.Month() != time.December || day.Date.Day() < 29 { return nil } if day.Weekday == time.Sunday { return nil } // The day within the octave, not a feast that outranks it: the observed // office must still be the temporal one. A sanctoral feast keeps its own // readings, as it did before this. if !strings.HasPrefix(day.Observed.Slug, "ef-christmas-") { return nil } return []calendar.Reading{ {Part: "first", Citation: "Titus 3:4-7"}, {Part: "gospel", Citation: "Luke 2:15-20"}, } } // efEpiphanytideOpeningReadings returns the Mass of a FERIAL day from 2 to 5 // January. // // The Missal's rubric at the Most Holy Name of Jesus says so directly: // "Diebus ferialibus a 2 ad 5 ianuarii Missa dicitur ut die 1 ianuarii" -- on // the ferial days from the 2nd to the 5th of January, the Mass of 1 January // (the Circumcision) is said. Titus 2:11-15 / Luke 2:21. // // Without this the days fell back through the walk-back to the Sunday within // the Octave of the Nativity and read Gal 4:1-7 instead. // // FERIAL days only, which excludes two things on purpose. The Sunday in that // window carries the Holy Name (RG 17(a)) and has its own Mass; and an // otherwise-unoccupied Saturday carries Our Lady's office (RG 78) and hers, // which is why the BVM check runs before this one and why 3 January 2026 -- // a Saturday -- correctly reads Titus 3:4-7 rather than 2:11-15. func efEpiphanytideOpeningReadings(day calendar.LiturgicalDay) []calendar.Reading { if day.Date.Month() != time.January || day.Date.Day() < 2 || day.Date.Day() > 5 { return nil } // The feria, not a feast or a Sunday that outranks it: the observed office // must still be the temporal ferial one. if !strings.HasPrefix(day.Observed.Slug, "ef-christmas-") || day.Weekday == time.Sunday { return nil } return []calendar.Reading{ {Part: "first", Citation: "Titus 2:11-15"}, {Part: "gospel", Citation: "Luke 2:21"}, } } // efEpiphanyWeekReadings returns the Mass of a FERIAL day between Epiphany and // the first Sunday after it. // // Those days repeat the Epiphany's own Mass, Isa 60:1-6 / Matt 2:1-12. The // ferias AFTER that Sunday take the Sunday's Mass instead (Rom 12:1-5 / // Luke 2:42-52), which is what the walk-back already gives them correctly. // // lectio cannot tell the two groups apart by slug: both are // ef-time-after-epiphany-1-, because the week numbering starts at the // Sunday and the days before it fall in the same numbered week. So the split // is made here, on the date, against the same anchor efWeek uses. // // Saturdays are excluded by the BVM check running before this one -- an // unoccupied Saturday in the window carries Our Lady's office and her Mass. // colitur reaches the same result by listing only the five non-Saturday // weekdays in its own table. func efEpiphanyWeekReadings(day calendar.LiturgicalDay) []calendar.Reading { if day.Date.Month() != time.January || day.Date.Day() < 7 { return nil } if !day.Date.Before(firstSundayAfterEpiphanyForReadings(day.Date.Year())) { return nil } if !strings.HasPrefix(day.Observed.Slug, "ef-") || day.Weekday == time.Sunday { return nil } if !strings.Contains(day.Observed.Slug, "-epiphany-") && !strings.Contains(day.Observed.Slug, "-christmas-") { return nil } return []calendar.Reading{ {Part: "first", Citation: "Isa 60:1-6"}, {Part: "gospel", Citation: "Matt 2:1-12"}, } } // firstSundayAfterEpiphanyForReadings mirrors calendar's own anchor. Duplicated // rather than exported because the two packages are separate and one date // helper is a smaller cost than widening an API for it. func firstSundayAfterEpiphanyForReadings(y int) time.Time { d := time.Date(y, time.January, 7, 0, 0, 0, 0, time.UTC) for d.Weekday() != time.Sunday { d = d.AddDate(0, 0, 1) } return d }