From a80e61963756a28a3963a0442a8ccc1572518373 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 14:35:24 +0200 Subject: fix(calendar): OF precedence/transfer + readings resolution; EF Pentecost octave Fixes found by validating the offline engine per-day vs the LiturgicalCalendar API (OF) and missalemeum (EF) across 2005-2050. OF calendar is now 0 real errors in the forward window (season/cycle already perfect). OF precedence/transfer (calendar): - Ash Wednesday and Holy Week Mon-Wed are band-2 privileged, so a coinciding feast (Chair of St Peter on Ash Wednesday) is suppressed, not observed. - Solemnities transfer out of Holy Week / the Easter octave: the Annunciation defers to the Monday after the 2nd Sunday of Easter; St Joseph is anticipated to the Saturday before Palm Sunday; the Nativity of St John the Baptist moves to Jun 23 when a Lord's solemnity (Sacred Heart / Corpus Christi) falls Jun 24. - Within a precedence band, a solemnity of the Lord/BVM outranks a saint's (dignity tiebreak) rather than losing an alphabetical slug tie. - Perpetua & Felicity corrected optional -> obligatory memorial. OF readings resolution (bible.OFRef + lectionary data): - Verse-accurate Hebrew->Vulgate psalm mapping incl. the split psalms (9/10, 114/115, 116, 147); "+" verse joins and abbreviated ranges ("127-28") normalized; single-chapter books (2/3 John, Jude) get chapter 1. - Cleaned harvest artifacts from of-lectionary.ini (descriptive-suffix gospels, "or Year A" alternates, "*"/"[Vulg.]"/bracket markers, slash abbreviations); Joel/Malachi/Zechariah/Esther book-versification citations fixed to Vulgate. - Split a merged Ps 15:10/11 row in vul.tsv. Result: 0 unresolvable OF readings over 2557 rendered days (cycles A/B/C, varied Easters). EF: the Octave of Pentecost (Whit Monday-Saturday) is red, not white. --- internal/bible/ofref.go | 88 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) (limited to 'internal/bible/ofref.go') diff --git a/internal/bible/ofref.go b/internal/bible/ofref.go index 8a72342..ffb2376 100644 --- a/internal/bible/ofref.go +++ b/internal/bible/ofref.go @@ -13,22 +13,57 @@ var ( // verse tail: "2 Samuel 6:12b-15,17-19" -> ("2 Samuel", "6", "12b-15,17-19"). // The book is non-greedy so a leading ordinal ("2 Samuel") stays with it. ofCiteRe = regexp.MustCompile(`^(.*?)\s+(\d+):(.+)$`) + // ofBookVerseRe matches a citation with no chapter ("2 John 4-9", + // "Jude 17,20b-25") -> book + verse tail; used to supply chapter 1 for + // single-chapter books. The tail allows sub-verse letters, dropped later. + ofBookVerseRe = regexp.MustCompile(`^(.*?)\s+(\d[\d,\-a-z]*)$`) // ofVerseLetterRe drops sub-verse part letters so lookups resolve at whole // verses: "12b" -> "12", "3ab" -> "3", "3cd" -> "3". ofVerseLetterRe = regexp.MustCompile(`(\d)[a-z]+`) + // ofRangeRe matches a verse range so an abbreviated end can be expanded: + // "127-28" -> "127-128", "129-30" -> "129-130" (the end borrows the start's + // leading digits, the standard citation shorthand). + ofRangeRe = regexp.MustCompile(`(\d+)-(\d+)`) ) +// singleChapterBook lists the one-chapter books the lectionary cites without a +// chapter ("2 John 4-9"); OFRef supplies "1:" so bible.Lookup resolves them. +var singleChapterBook = map[string]bool{ + "Obadiah": true, "Philemon": true, "2 John": true, "3 John": true, "Jude": true, +} + // normalizeOFVerses rewrites a citation's verse tail into the shape // bible.Lookup parses: cross-chapter semicolons become commas, ranges use a // plain hyphen, sub-verse letters are dropped, and spaces are removed. func normalizeOFVerses(tail string) string { tail = strings.ReplaceAll(tail, ";", ",") + tail = strings.ReplaceAll(tail, "+", ",") // responsorial "6+8" join = verses 6 and 8 tail = dashRe.ReplaceAllString(tail, "-") tail = ofVerseLetterRe.ReplaceAllString(tail, "$1") tail = strings.ReplaceAll(tail, " ", "") + tail = expandAbbrevRanges(tail) return tail } +// expandAbbrevRanges expands a range whose end omits the start's leading digits +// ("127-28" -> "127-128"): when the numeric end is below the start and shorter, +// the end borrows the start's high-order digits. +func expandAbbrevRanges(tail string) string { + return ofRangeRe.ReplaceAllStringFunc(tail, func(s string) string { + m := ofRangeRe.FindStringSubmatch(s) + a, b := m[1], m[2] + ai, _ := strconv.Atoi(a) + bi, _ := strconv.Atoi(b) + if bi < ai && len(b) < len(a) { + full := a[:len(a)-len(b)] + b + if fi, err := strconv.Atoi(full); err == nil && fi > ai { + return a + "-" + full + } + } + return s + }) +} + // OFRef turns an English-canonical Ordinary Form citation (lectio's authored // form) into a bible.Lookup-ready reference for the target corpus's Psalter. // @@ -47,6 +82,12 @@ func normalizeOFVerses(tail string) string { // A citation OFRef cannot parse (no "chapter:verse") is returned unchanged for // bible.Lookup to accept or reject. func OFRef(citation, system string) string { + // A single-chapter book cited without a chapter ("2 John 4-9") gets "1:". + if !strings.Contains(citation, ":") { + if bv := ofBookVerseRe.FindStringSubmatch(citation); bv != nil && singleChapterBook[bv[1]] { + citation = bv[1] + " 1:" + bv[2] + } + } m := ofCiteRe.FindStringSubmatch(citation) if m == nil { return citation @@ -58,7 +99,7 @@ func OFRef(citation, system string) string { if err == nil { switch system { case "vulgate": - chap = strconv.Itoa(psalter.HebrewToVulgateChapter(heb)) + return "Psalms " + psalmVulgate(heb, verses) case "drb": verses = digitsRe.ReplaceAllStringFunc(verses, func(s string) string { n, _ := strconv.Atoi(s) @@ -69,3 +110,48 @@ func OFRef(citation, system string) string { } return book + " " + chap + ":" + verses } + +// psalmVulgate renders a Masoretic psalm's verse tail ("12-13,14-15,19-20") in +// Vulgate numbering, converting every verse (see psalter.HebrewToVulgate). The +// split psalms (116, 147) change chapter mid-psalm, so a group emits its own +// "chapter:" prefix whenever it lands in a different Vulgate chapter than the +// group before it -- a form bible.Lookup resolves as separate chapter groups. +func psalmVulgate(heb int, verses string) string { + var b strings.Builder + cur := -1 + for i, group := range strings.Split(verses, ",") { + if group == "" { + continue + } + if i > 0 && b.Len() > 0 { + b.WriteByte(',') + } + lo, hi := group, "" + if k := strings.IndexByte(group, '-'); k >= 0 { + lo, hi = group[:k], group[k+1:] + } + n, err := strconv.Atoi(lo) + if err != nil { + b.WriteString(group) // non-numeric group: pass through + continue + } + c, v := psalter.HebrewToVulgate(heb, n) + if c != cur { + b.WriteString(strconv.Itoa(c)) + b.WriteByte(':') + cur = c + } + b.WriteString(strconv.Itoa(v)) + if hi != "" { + if n2, err := strconv.Atoi(hi); err == nil { + _, v2 := psalter.HebrewToVulgate(heb, n2) + b.WriteByte('-') + b.WriteString(strconv.Itoa(v2)) + } else { + b.WriteByte('-') + b.WriteString(hi) + } + } + } + return b.String() +} -- cgit v1.3