358 lines
11 KiB
Go
358 lines
11 KiB
Go
package kjv
|
||
|
||
// Scripture reference resolver. Understands the many ways people type a
|
||
// reference ("1 jn 3.16", "II Kings 2:1-4", "song of songs 2", "Exodus ") and
|
||
// turns it into ranges of verse ids (book*1000000 + chapter*1000 + verse).
|
||
|
||
import (
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Extra spellings people use that the abbreviations table does not know.
|
||
var extraBookAliases = map[string][]string{
|
||
"Song of Solomon": {"song of songs", "songs of solomon", "songs of songs", "song of sol",
|
||
"song of sol", "canticles", "canticle of canticles", "sos", "sng", "ss"},
|
||
"Psalms": {"psalm", "psalter", "pslm", "psa", "pss", "psm", "ps"},
|
||
"Revelation": {"revelations", "revelation of john", "revelation of st john", "the revelation", "apocalypse"},
|
||
"Ecclesiastes": {"ecclesiasts", "eccles", "ecc", "qoheleth"},
|
||
"Philemon": {"phlm", "phm", "philem"},
|
||
"Judges": {"jdg", "jdgs", "jg"},
|
||
"Mark": {"mrk", "mk", "mr"},
|
||
"John": {"jhn", "jn"},
|
||
"Acts": {"acts of the apostles", "act"},
|
||
"James": {"jas", "jm"},
|
||
}
|
||
|
||
// Words that are books but also ordinary search words: a bare "numbers" or
|
||
// "mark" still shows the book, but /find offers a keyword search as well.
|
||
var bareBookExtra = map[string]bool{
|
||
"psalm": true, "songofsongs": true, "songsofsolomon": true, "songofsol": true, "canticles": true,
|
||
"canticleofcanticles": true, "revelations": true, "revelationofjohn": true, "therevelation": true,
|
||
}
|
||
|
||
const maxChapterIDSpan = 999
|
||
|
||
// A reference is never this long; longer text goes to the keyword search. Bounds the fuzzy matching work.
|
||
const maxRefQueryLen = 300
|
||
|
||
// Go's regexp has no lookaheads, so they are written as a consumed [\s.] here, which matches the same text.
|
||
var (
|
||
bookPieceRE = regexp.MustCompile(`(?i)^\s*(?P<book>(?:(?:[1-3]|(?:iii|ii|i)[\s.]|1st|2nd|3rd|first|second|third)\.?\s*)?[a-z][a-z.\s]*?)` +
|
||
`\s*(?P<rest>\d[\d\s:.\-–—]*)?\s*$`)
|
||
continuationRE = regexp.MustCompile(`^\s*\d[\d\s:.\-–—]*$`)
|
||
|
||
reBookOf = regexp.MustCompile(`^(?:the\s+)?(?:book\s+of\s+)`)
|
||
reFirst = regexp.MustCompile(`^(?:1st|first)([\s.a-z]|$)`)
|
||
reSecond = regexp.MustCompile(`^(?:2nd|second)([\s.a-z]|$)`)
|
||
reThird = regexp.MustCompile(`^(?:3rd|third)([\s.a-z]|$)`)
|
||
reRoman3 = regexp.MustCompile(`^iii([\s.]|$)`)
|
||
reRoman2 = regexp.MustCompile(`^ii([\s.]|$)`)
|
||
reRoman1 = regexp.MustCompile(`^i([\s.])`)
|
||
reNotAlnum = regexp.MustCompile(`[^a-z0-9]`)
|
||
reSepSpace = regexp.MustCompile(`\s*([:\-])\s*`)
|
||
|
||
reChapter = regexp.MustCompile(`^(\d+)$`)
|
||
reChapterAll = regexp.MustCompile(`^(\d+)(?::-|-)$`)
|
||
reChapterRange = regexp.MustCompile(`^(\d+)-(\d+)$`)
|
||
reVerse = regexp.MustCompile(`^(\d+)[:.\s](\d+)$`)
|
||
reVerseOn = regexp.MustCompile(`^(\d+)[:.\s](\d+)-$`)
|
||
reVerseRange = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)$`)
|
||
reVerseAcrossCh = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)[:.](\d+)$`)
|
||
)
|
||
|
||
// normalizeBookText: 'I Cor.' / '1st Corinthians' / '1 corinthians' -> '1corinthians'
|
||
func normalizeBookText(text string) string {
|
||
text = strings.ToLower(strings.TrimSpace(text))
|
||
text = reBookOf.ReplaceAllString(text, "")
|
||
text = reFirst.ReplaceAllString(text, "1${1}")
|
||
text = reSecond.ReplaceAllString(text, "2${1}")
|
||
text = reThird.ReplaceAllString(text, "3${1}")
|
||
text = reRoman3.ReplaceAllString(text, "3${1}")
|
||
text = reRoman2.ReplaceAllString(text, "2${1}")
|
||
text = reRoman1.ReplaceAllString(text, "1${1}")
|
||
return reNotAlnum.ReplaceAllString(text, "")
|
||
}
|
||
|
||
type Resolver struct {
|
||
store *Store
|
||
canonical map[string]int
|
||
aliases map[string]int
|
||
aliasKeys []string
|
||
canonKeys []string
|
||
}
|
||
|
||
func NewResolver(s *Store) *Resolver {
|
||
r := &Resolver{store: s, canonical: map[string]int{}, aliases: map[string]int{}}
|
||
for id, name := range s.bookName {
|
||
r.canonical[normalizeBookText(name)] = id
|
||
}
|
||
for k, v := range r.canonical {
|
||
r.aliases[k] = v
|
||
}
|
||
for _, a := range s.abbrevs {
|
||
if k := normalizeBookText(a.Name); r.aliases[k] == 0 {
|
||
r.aliases[k] = a.Book
|
||
}
|
||
}
|
||
for name, extras := range extraBookAliases {
|
||
id := s.bookByName[name]
|
||
for _, extra := range extras {
|
||
r.aliases[normalizeBookText(extra)] = id
|
||
}
|
||
}
|
||
for k := range r.aliases {
|
||
r.aliasKeys = append(r.aliasKeys, k)
|
||
}
|
||
sort.Strings(r.aliasKeys)
|
||
for k := range r.canonical {
|
||
r.canonKeys = append(r.canonKeys, k)
|
||
}
|
||
sort.Strings(r.canonKeys)
|
||
return r
|
||
}
|
||
|
||
type bookMatch struct {
|
||
ID int
|
||
How string // exact | prefix | fuzzy
|
||
}
|
||
|
||
// resolveBook finds a book from a typed name. loose=false only accepts full
|
||
// names and spellings that are unmistakably a book (used for a bare word with
|
||
// no chapter number).
|
||
func (r *Resolver) resolveBook(token string, loose bool) (bookMatch, bool) {
|
||
key := normalizeBookText(token)
|
||
if key == "" {
|
||
return bookMatch{}, false
|
||
}
|
||
if !loose {
|
||
if _, ok := r.canonical[key]; ok || bareBookExtra[key] {
|
||
return bookMatch{r.aliases[key], "exact"}, true
|
||
}
|
||
// a clear misspelling of a long full name ("genisis")
|
||
if len(key) >= 6 && len(key) <= 40 && !(key[0] >= '0' && key[0] <= '9') {
|
||
if close, ok := closestMatch(key, r.canonKeys, 0.85); ok {
|
||
return bookMatch{r.canonical[close], "fuzzy"}, true
|
||
}
|
||
}
|
||
return bookMatch{}, false
|
||
}
|
||
if id, ok := r.aliases[key]; ok {
|
||
return bookMatch{id, "exact"}, true
|
||
}
|
||
// unique (or best) prefix: "deut", "1cor", "matth"
|
||
books := map[int]bool{}
|
||
for _, k := range r.aliasKeys {
|
||
if strings.HasPrefix(k, key) {
|
||
books[r.aliases[k]] = true
|
||
}
|
||
}
|
||
if len(books) > 0 {
|
||
canonHits := map[int]bool{}
|
||
for _, k := range r.canonKeys {
|
||
if strings.HasPrefix(k, key) {
|
||
canonHits[r.canonical[k]] = true
|
||
}
|
||
}
|
||
if len(canonHits) == 1 {
|
||
return bookMatch{anyKey(canonHits), "prefix"}, true
|
||
}
|
||
if len(books) == 1 {
|
||
return bookMatch{anyKey(books), "prefix"}, true
|
||
}
|
||
pool := books
|
||
if len(canonHits) > 0 {
|
||
pool = canonHits
|
||
}
|
||
best := 0
|
||
for id := range pool {
|
||
if id > best {
|
||
best = id // ambiguous: the later book, as before
|
||
}
|
||
}
|
||
return bookMatch{best, "prefix"}, true
|
||
}
|
||
if len(key) >= 4 && len(key) <= 40 {
|
||
if close, ok := closestMatch(key, r.aliasKeys, 0.78); ok {
|
||
return bookMatch{r.aliases[close], "fuzzy"}, true
|
||
}
|
||
}
|
||
return bookMatch{}, false
|
||
}
|
||
|
||
func anyKey(m map[int]bool) int {
|
||
for k := range m {
|
||
return k
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// clampChapter: 0 -> 1, past the end -> the last chapter.
|
||
func (r *Resolver) clampChapter(book, chapter int) int {
|
||
last := r.store.chapterLen[book]
|
||
if chapter > last {
|
||
chapter = last
|
||
}
|
||
if chapter < 1 {
|
||
chapter = 1
|
||
}
|
||
return chapter
|
||
}
|
||
|
||
// atoi never fails: absurdly long numbers saturate.
|
||
func atoi(s string) int {
|
||
n, err := strconv.Atoi(s)
|
||
if err != nil {
|
||
return 1_000_000
|
||
}
|
||
if n > 1_000_000 {
|
||
return 1_000_000
|
||
}
|
||
return n
|
||
}
|
||
|
||
type refPos struct{ book, chapter, verse int }
|
||
|
||
// parseRefRest: '3:16-18', '3 16', '3.16', '3-4', '3:16-4:2', '3', '3:-' -> (start, end or 0, position)
|
||
func (r *Resolver) parseRefRest(rest string, book int, previous *refPos) (start, end int64, pos refPos, ok bool) {
|
||
rest = strings.NewReplacer("–", "-", "—", "-").Replace(rest)
|
||
rest = reSepSpace.ReplaceAllString(strings.TrimSpace(rest), "$1")
|
||
base := int64(book) * 1_000_000
|
||
at := func(chapter, verse int) int64 { return base + int64(chapter)*1000 + int64(verse) }
|
||
pos.book = book
|
||
if m := reChapter.FindStringSubmatch(rest); m != nil {
|
||
n := atoi(m[1])
|
||
if previous != nil && previous.verse != 0 { // "John 3:16, 18": a verse of the same chapter
|
||
pos.chapter, pos.verse = previous.chapter, n
|
||
return at(previous.chapter, n), 0, pos, true
|
||
}
|
||
ch := r.clampChapter(book, n)
|
||
pos.chapter = ch
|
||
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
|
||
}
|
||
if m := reChapterAll.FindStringSubmatch(rest); m != nil { // "3:-" or "3-": the whole chapter
|
||
ch := r.clampChapter(book, atoi(m[1]))
|
||
pos.chapter = ch
|
||
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
|
||
}
|
||
if m := reChapterRange.FindStringSubmatch(rest); m != nil { // chapters 3 to 4
|
||
first, last := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[2]))
|
||
if last < first {
|
||
first, last = last, first
|
||
}
|
||
pos.chapter = first
|
||
return at(first, 1), at(last, maxChapterIDSpan), pos, true
|
||
}
|
||
if m := reVerse.FindStringSubmatch(rest); m != nil {
|
||
ch := r.clampChapter(book, atoi(m[1]))
|
||
verse := max(1, atoi(m[2]))
|
||
pos.chapter, pos.verse = ch, verse
|
||
return at(ch, verse), 0, pos, true
|
||
}
|
||
if m := reVerseOn.FindStringSubmatch(rest); m != nil { // "3:16-": from verse 16 to the end
|
||
ch := r.clampChapter(book, atoi(m[1]))
|
||
pos.chapter, pos.verse = ch, atoi(m[2])
|
||
return at(ch, max(1, atoi(m[2]))), at(ch, maxChapterIDSpan), pos, true
|
||
}
|
||
if m := reVerseRange.FindStringSubmatch(rest); m != nil {
|
||
ch := r.clampChapter(book, atoi(m[1]))
|
||
first, last := max(1, atoi(m[2])), max(1, atoi(m[3]))
|
||
if last < first {
|
||
first, last = last, first
|
||
}
|
||
pos.chapter, pos.verse = ch, first
|
||
return at(ch, first), at(ch, last), pos, true
|
||
}
|
||
if m := reVerseAcrossCh.FindStringSubmatch(rest); m != nil { // 3:16-4:2 across chapters
|
||
c1, c2 := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[3]))
|
||
s, e := at(c1, max(1, atoi(m[2]))), at(c2, max(1, atoi(m[4])))
|
||
if e < s {
|
||
return 0, 0, pos, false
|
||
}
|
||
pos.chapter, pos.verse = c1, atoi(m[2])
|
||
return s, e, pos, true
|
||
}
|
||
return 0, 0, pos, false
|
||
}
|
||
|
||
type RefRange struct{ Start, End int64 } // End == 0: a single verse
|
||
|
||
type Note struct{ Original, Name string }
|
||
|
||
// Resolved is a whole parsed query.
|
||
type Resolved struct {
|
||
Refs []RefRange
|
||
Notes []Note // short explanations of guesses that were made
|
||
Bare bool // the query was just a book name (shows its first chapter)
|
||
Label string // the name of the first book, for messages
|
||
}
|
||
|
||
// Resolve parses a whole query ('John 3:16, Romans 5:8') into verse id ranges.
|
||
// It returns nil when the text does not read as scripture references, so the
|
||
// caller can treat it as a keyword search.
|
||
func (r *Resolver) Resolve(text string, looseBare bool) *Resolved {
|
||
if strings.TrimSpace(text) == "" || len(text) > maxRefQueryLen {
|
||
return nil
|
||
}
|
||
var pieces []string
|
||
for _, p := range regexp.MustCompile(`[,;\n]+`).Split(text, -1) {
|
||
if strings.TrimSpace(p) != "" {
|
||
pieces = append(pieces, p)
|
||
}
|
||
}
|
||
if len(pieces) == 0 || len(pieces) > 20 {
|
||
return nil
|
||
}
|
||
out := &Resolved{}
|
||
var previous *refPos
|
||
for _, piece := range pieces {
|
||
var start, end int64
|
||
var pos refPos
|
||
var name string
|
||
if m := bookPieceRE.FindStringSubmatchIndex(piece); m != nil {
|
||
bi, ri := bookPieceRE.SubexpIndex("book"), bookPieceRE.SubexpIndex("rest")
|
||
token := piece[m[2*bi]:m[2*bi+1]]
|
||
hasRest := m[2*ri] >= 0
|
||
found, ok := r.resolveBook(token, hasRest || looseBare)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
name = r.store.BookName(found.ID)
|
||
if found.How == "fuzzy" {
|
||
out.Notes = append(out.Notes, Note{strings.TrimSpace(token), name})
|
||
}
|
||
if !hasRest {
|
||
if len(pieces) == 1 {
|
||
out.Bare = true
|
||
}
|
||
base := int64(found.ID) * 1_000_000
|
||
start, end, pos = base+1001, base+1999, refPos{found.ID, 1, 0}
|
||
} else {
|
||
var ok bool
|
||
start, end, pos, ok = r.parseRefRest(piece[m[2*ri]:m[2*ri+1]], found.ID, nil)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
}
|
||
} else if continuationRE.MatchString(piece) && previous != nil {
|
||
name = r.store.BookName(previous.book)
|
||
var ok bool
|
||
start, end, pos, ok = r.parseRefRest(piece, previous.book, previous)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
} else {
|
||
return nil
|
||
}
|
||
p := pos
|
||
previous = &p
|
||
if out.Label == "" {
|
||
out.Label = name
|
||
}
|
||
out.Refs = append(out.Refs, RefRange{start, end})
|
||
}
|
||
return out
|
||
}
|