aboutsummaryrefslogtreecommitdiff
path: root/internal/bible/ofref.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 14:35:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 14:35:24 +0200
commita80e61963756a28a3963a0442a8ccc1572518373 (patch)
tree602540ee619ce1d64c16e7e9ca0adb7a7cfb2194 /internal/bible/ofref.go
parent0586599fc6020df996c4278230eeea99b9d070cb (diff)
downloadlectio-a80e61963756a28a3963a0442a8ccc1572518373.tar.gz
lectio-a80e61963756a28a3963a0442a8ccc1572518373.zip
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.
Diffstat (limited to 'internal/bible/ofref.go')
-rw-r--r--internal/bible/ofref.go88
1 files changed, 87 insertions, 1 deletions
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()
+}