Files
kjv_api/internal/kjv/refs.go
T
2026-10-26 20:30:43 -07:00

358 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}