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(?:(?:[1-3]|(?:iii|ii|i)[\s.]|1st|2nd|3rd|first|second|third)\.?\s*)?[a-z][a-z.\s]*?)` + `\s*(?P\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 }