1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
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()
}
|