package bible import ( "regexp" "strconv" "strings" "github.com/lukaszkasprzak/lectio/internal/psalter" ) var ( // ofCiteRe splits an English-canonical citation into book, chapter and the // 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. // // Every citation's verse tail is normalized (see normalizeOFVerses). Only the // Psalms differ between Psalters, so their chapter/verse is additionally // renumbered: // // - "vulgate" (vul/grb/wuj): shift the Hebrew chapter to its Vulgate // equivalent (Ps 27 -> 26, Ps 147 -> 146). Verse numbers are kept, so the // rare intra-psalm verse-boundary shifts are not corrected here. // - "drb": the Douay-Rheims corpus keeps modern (Hebrew) chapter numbers, so // the chapter is kept; only the DRB title-fold verse shift applies (see // psalter.DrbVerse). // - "hebrew" / anything else: already in the corpus's numbering. // // 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 } book, chap, verses := m[1], m[2], normalizeOFVerses(m[3]) if book == "Psalms" { heb, err := strconv.Atoi(chap) if err == nil { switch system { case "vulgate": return "Psalms " + psalmVulgate(heb, verses) case "drb": verses = digitsRe.ReplaceAllStringFunc(verses, func(s string) string { n, _ := strconv.Atoi(s) return strconv.Itoa(psalter.DrbVerse(heb, n)) }) } } } 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() }