package kjv import ( "fmt" "html" "math/rand/v2" "net/url" "sort" "strconv" "strings" "time" "unicode" ) const ( maxSearchWords = 20 maxSearchLen = 200 ) const searchChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+^* " // cleanInput keeps only the allowed characters. func cleanInput(s, valid string) string { return strings.Map(func(r rune) rune { if strings.ContainsRune(valid, r) { return r } return -1 }, s) } func searchClean(s string) string { return cleanInput(s, searchChars) } // --------------------------------------------------------------------------- // /show func (a *App) show(kw, view string, all bool) (Resp, bool) { resolved := a.res.Resolve(kw, true) if resolved == nil { return htmlR("Invalid Query"), false } var verses []Verse for _, r := range resolved.Refs { end := r.End if all { // ?all= : from the given verse to the end of its chapter end = r.Start/1000*1000 + maxChapterIDSpan } verses = append(verses, a.store.Range(r.Start, end)...) } return a.render(verses, view, kw).resp(), true } func viewOf(q url.Values, def string) string { if q.Has("view") { return q.Get("view") } return def } func (a *App) handleShow(q url.Values) Resp { kw := q.Get("kw") if kw == "" { return a.staticPage("html/show.html") } resp, _ := a.show(kw, viewOf(q, "json"), q.Get("all") != "") return resp } // --------------------------------------------------------------------------- // /random and /votd func (a *App) singleVerse(id int64, q url.Values, defView string) Resp { return a.render(a.store.Range(id, 0), viewOf(q, defView), "").resp() } func (a *App) handleRandom(q url.Values, defView string) Resp { id := a.store.verseIDs[rand.IntN(len(a.store.verseIDs))] lg.Info("Random served: %d", id) return a.singleVerse(id, q, defView) } // votdID is deterministic: the date seeds the generator, so everyone gets the same verse that day. func (a *App) votdID(t time.Time) int64 { seed, _ := strconv.Atoi(t.Format("20060102")) r := newMTRandom(uint32(seed)) return a.store.verseIDs[r.choiceIndex(len(a.store.verseIDs))] } func (a *App) handleVotd(q url.Values, defView string) Resp { lg.Info("VOTD served") return a.singleVerse(a.votdID(time.Now()), q, defView) } // --------------------------------------------------------------------------- // dictionary func (a *App) defineRich(ref string, defs []string, simple bool) string { head := "" if !simple { head = "" + "" + "" + "" + html.EscapeString(capitalize(ref)) + " - KJV API" + a.cssNavbar + "
" } text := "" for _, d := range defs { text += d + "

" } out := head + strings.NewReplacer( "{chapter}", strings.SplitN(ref, ":", 2)[0], "{passage}", capitalize(ref), "{text}", text, "{verse_id}", "", ).Replace(a.htmlResponse) if !simple { out += "
" } return out } func (a *App) handleDefine(q url.Values) Resp { ref := strings.ToLower(strings.TrimSpace(q.Get("kw"))) if ref == "" { return a.staticPage("html/dictionary.html") } defs, ok := a.dictionary[ref] if !ok { lg.Warn("WEBSTER MISSING: %s", ref) return htmlR(html.EscapeString(ref) + " not found.") } switch q.Get("view") { case "plain": text := "" for _, d := range defs { text += d + "

" } return htmlR(capitalize(ref) + "

" + text + "\n") case "rich": return htmlR(a.defineRich(ref, defs, false)) } return jsonR(map[string][]string{ref: defs}) } var dictStopwords = map[string]bool{} func init() { for _, w := range strings.Fields(`the and that for him his her but not with are was were unto thou thee thy ye you all from they them this have had shall will which into upon then`) { dictStopwords[w] = true } } // /defs?words=grace,loved : several definitions at once func (a *App) handleDefs(q url.Values) Resp { words := strings.Fields(strings.ReplaceAll(strings.ToLower(q.Get("words")), ",", " ")) if len(words) > 40 { words = words[:40] } found := map[string][]string{} for _, w := range words { w = strings.Map(func(r rune) rune { if unicode.IsLetter(r) { return r } return -1 }, w) if d, ok := a.dictionary[w]; ok && w != "" && !dictStopwords[w] { found[w] = d } } return jsonR(found) } // --------------------------------------------------------------------------- // /search // proximityScore: higher when the query words appear closer together. func proximityScore(text string, words []string) float64 { lower := strings.ToLower(text) type hit struct{ pos, list int } var hits []hit seen := map[string]int{} for _, w := range words { if _, dup := seen[w]; dup { continue } li := len(seen) seen[w] = li w = strings.ToLower(w) n := 0 for at := 0; ; { i := strings.Index(lower[at:], w) if i < 0 { break } hits = append(hits, hit{at + i, li}) n++ at += i + 1 } if n == 0 { return 0 } } lists := len(seen) sort.SliceStable(hits, func(i, j int) bool { return hits[i].pos < hits[j].pos }) // smallest window holding at least one position of every word counts := make([]int, lists) have, left, best := 0, 0, -1 for right := range hits { if counts[hits[right].list]++; counts[hits[right].list] == 1 { have++ } for have == lists { if span := hits[right].pos - hits[left].pos; best < 0 || span < best { best = span } if counts[hits[left].list]--; counts[hits[left].list] == 0 { have-- } left++ } } if best < 0 { return 0 } return 1000.0 / float64(best+1) } func sortVariants(original string, variants []string) []string { type sv struct { score int v string } var scored []sv orig := strings.ToLower(original) for _, v := range variants { lv := strings.ToLower(v) prefix := 0 for prefix < len(orig) && prefix < len(lv) && orig[prefix] == lv[prefix] { prefix++ } if prefix < min(2, len(orig)/2) { // doesn't share the stem continue } scored = append(scored, sv{prefix*1000 + len(v), v}) } sort.SliceStable(scored, func(i, j int) bool { return scored[i].score > scored[j].score }) out := make([]string, len(scored)) for i, s := range scored { out[i] = s.v } return out } func (a *App) fts(q string) []Verse { v, err := a.store.FTS(q) if err != nil { // FTS5 syntax the user typed by accident: just no results lg.Info("fts %q: %v", q, err) } return v } func insertAfterResponsesOpen(page, notice string) string { return strings.Replace(page, "
", "
"+notice, 1) } // fallbackResp shows the results of a rewritten query with an explanation. func (a *App) fallbackResp(results []Verse, view, kw, plainNote, richNotice string, jsonObj map[string]any) Resp { r := a.render(results, view, kw) switch view { case "plain": return htmlR(plainNote + "\n
" + r.text) case "rich": return htmlR(insertAfterResponsesOpen(r.text, richNotice)) } jsonObj["results"] = r.list return jsonR(jsonObj) } func (a *App) handleSearch(kw, view string) Resp { cleaned := searchClean(kw) if words := strings.Fields(cleaned); len(words) > maxSearchWords || len(cleaned) > maxSearchLen { return htmlR("Your query is too long. 20 words or less.") } if cleaned == "" { return a.staticPage("html/search.html") } esc := html.EscapeString // a common theological concept that is not in the KJV? conceptNotice := "" if c, ok := a.concepts[strings.ToLower(cleaned)]; ok { var links []string for _, t := range c.SearchTerms { links = append(links, fmt.Sprintf("%s", strings.ReplaceAll(t, " ", "+"), t)) } conceptNotice = fmt.Sprintf("
%s: %s
You may find what you are looking for with these search terms: %s
", capitalize(cleaned), c.Explanation, strings.Join(links, ", ")) cleaned = c.SearchTerms[0] } results := a.fts(cleaned) words := strings.Fields(cleaned) if len(results) > 0 && len(words) > 1 { scores := make(map[int64]float64, len(results)) for _, v := range results { scores[v.ID] = proximityScore(v.Text, words) } sort.SliceStable(results, func(i, j int) bool { return scores[results[i].ID] > scores[results[j].ID] }) } if len(results) == 0 { // 1. grammatical variants for _, word := range words { for _, variant := range sortVariants(word, a.variants[strings.ToLower(word)]) { vq := strings.Replace(cleaned, word, variant, 1) if r := a.fts(vq); len(r) > 0 { return a.fallbackResp(r, view, kw, fmt.Sprintf("Showing results for '%s' (grammatical variant of '%s')", vq, cleaned), fmt.Sprintf("
Showing results for '%s' (grammatical variant of '%s')
", vq, cleaned), map[string]any{"original": cleaned, "variant": vq}) } } } // 2. spell check corrected := make([]string, len(words)) changed := false for i, w := range words { corrected[i] = w if !a.speller.Spell(w) { if sug := a.speller.Suggest(w); len(sug) > 0 { corrected[i], changed = sug[0], true } } } if changed { ck := strings.Join(corrected, " ") if r := a.fts(ck); len(r) > 0 { return a.fallbackResp(r, view, kw, fmt.Sprintf("Showing results for '%s' (corrected from '%s')", ck, cleaned), fmt.Sprintf("
Showing results for '%s' (corrected from '%s')
", esc(ck), cleaned), map[string]any{"corrected_from": cleaned, "corrected_to": ck}) } } // 3. theological equivalents for _, word := range words { for _, syn := range a.synonyms[strings.ToLower(word)] { sq := strings.Replace(cleaned, word, syn, 1) if r := a.fts(sq); len(r) > 0 { explanation := fmt.Sprintf("In the KJV, '%s' and '%s' often refer to similar theological concepts. Consider also searching for related verses.", word, syn) return a.fallbackResp(r, view, kw, fmt.Sprintf("Showing results for '%s' (theological equivalent of '%s')\n%s", sq, cleaned, explanation), fmt.Sprintf("
Showing results for '%s' (theological equivalent of '%s')
%s
", sq, cleaned, explanation), map[string]any{"original": cleaned, "theological_equivalent": sq, "explanation": explanation}) } } } // 4. nothing: offer spelling suggestions var suggestions []string for i, w := range words { if a.speller.Spell(w) { continue } for _, s := range a.speller.Suggest(w) { if len(suggestions) >= 5 { break } alt := append([]string(nil), words...) alt[i] = s suggestions = append(suggestions, strings.Join(alt, " ")) } } if view != "rich" { list := "No suggestions available" if len(suggestions) > 0 { list = strings.Join(suggestions, ", ") } return htmlR(fmt.Sprintf("%s not found, try one of the following: %s", cleaned, list)) } var sb strings.Builder sb.WriteString(a.renderNavbar(cleaned, nil, "")) sb.WriteString("
") fmt.Fprintf(&sb, "
No results found for '%s'.
", cleaned) if len(suggestions) > 0 { sb.WriteString("

Did you mean?

") sb.WriteString("

We couldn't find any verses matching your search. Here are some spelling suggestions:

") for _, s := range suggestions { fmt.Fprintf(&sb, "", url.QueryEscape(s), esc(s)) } sb.WriteString("
") } else { sb.WriteString("

Search Tips

") sb.WriteString("

We couldn't find any verses matching your search. Try:

") sb.WriteString("
    ") sb.WriteString("
  • Checking your spelling
  • Using different keywords
  • ") sb.WriteString("
  • Searching for love, faith, or hope
  • ") sb.WriteString("
") } sb.WriteString("
") return htmlR(sb.String()) } r := a.render(results, view, kw) var related []string for _, w := range words { related = append(related, a.synonyms[strings.ToLower(w)]...) } switch view { case "plain": out := r.text if conceptNotice != "" { out = conceptNotice + "
" + out } if len(related) > 0 { out += "

Also consider searching: " for _, s := range related { out += s + "
" } } return htmlR(out) case "rich": out := r.text if conceptNotice != "" { if start := strings.Index(out, "
"); start != -1 { if end := strings.Index(out[start+len("
"):], "
"); end != -1 { at := start + len("
") + end + len("
") out = out[:at] + conceptNotice + out[at:] } } } if len(related) > 0 { var sb strings.Builder sb.WriteString("

Related Theological Concepts

") sb.WriteString("

In the KJV, these terms often refer to similar theological concepts. Consider also searching for related verses.

") for _, s := range related { fmt.Fprintf(&sb, "", url.QueryEscape(s), capitalize(s)) } sb.WriteString("
") if at := strings.LastIndex(out, "
"); at != -1 { out = out[:at] + sb.String() + out[at:] } } return htmlR(out) } return r.resp() } // --------------------------------------------------------------------------- // /find : scripture if it reads as scripture, otherwise a keyword search with definitions func (a *App) handleFind(q url.Values) Resp { query := strings.TrimSpace(q.Get("kw")) if viewOf(q, "rich") != "rich" { return jsonR(map[string]string{"Error": "/find endpoint only supports rich responses"}) } lg.Info("Query: %s", query) if query == "" { return a.staticPage("html/find.html") } query = strings.TrimSuffix(query, ",") // A query that reads as scripture references ("Exodus", "1 jn 3.16") is shown // as scripture; anything else is a keyword search. if resolved := a.res.Resolve(query, false); resolved != nil { if resp, ok := a.show(query, "rich", false); ok { notices := "" for _, n := range resolved.Notes { notices += fmt.Sprintf("
Showing %s (interpreted from '%s')
", html.EscapeString(n.Name), html.EscapeString(n.Original)) } if resolved.Bare { keyword := url.QueryEscape(searchClean(query)) notices += fmt.Sprintf("
Showing %s, chapter 1. Looking for the word instead? "+ "Search the text for '%s'
", html.EscapeString(resolved.Label), keyword, html.EscapeString(query)) } if notices != "" { resp.Body = insertAfterResponsesOpen(resp.Body, notices) } return resp } } if strings.TrimSpace(searchClean(query)) == "" { return a.staticPage("html/find.html") // nothing searchable left (punctuation, accents) } var tokens []string for _, w := range strings.Split(strings.ReplaceAll(query, ",", " "), " ") { if w != "" { tokens = append(tokens, w) } } if len(tokens) > 20 { return htmlR("Your Query is too long. 20 Words or less.") } result := a.handleSearch(q.Get("kw"), "rich") body := strings.TrimSuffix(result.Body, "
") // reopen .responses to add the dictionary inside it var defs strings.Builder for _, word := range tokens { entry, ok := a.dictionary[strings.ToLower(word)] if !ok { continue } defs.WriteString("") defs.WriteString("
" + a.defineRich(strings.ToLower(word), entry, true) + "
") } if defs.Len() > 0 { body += "" body += "

1828 Webster Definitions

Click on the word to get the definition.

" body += defs.String() body += "" } return htmlR(body + "
") }