513 lines
16 KiB
Go
513 lines
16 KiB
Go
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 = "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>" +
|
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>" +
|
|
"<meta name='color-scheme' content='dark light'>" +
|
|
"<title>" + html.EscapeString(capitalize(ref)) + " - KJV API</title>" +
|
|
a.cssNavbar + "</head><body><div class='responses'>"
|
|
}
|
|
text := ""
|
|
for _, d := range defs {
|
|
text += d + "<br><br>"
|
|
}
|
|
out := head + strings.NewReplacer(
|
|
"{chapter}", strings.SplitN(ref, ":", 2)[0], "{passage}", capitalize(ref), "{text}", text, "{verse_id}", "",
|
|
).Replace(a.htmlResponse)
|
|
if !simple {
|
|
out += "</div></body></html>"
|
|
}
|
|
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 + "<br><br>"
|
|
}
|
|
return htmlR(capitalize(ref) + "<br><br>" + 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, "<div class='responses'>", "<div class='responses'>"+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<br>" + 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("<a href='/search?kw=%s&view=rich'><strong>%s</strong></a>", strings.ReplaceAll(t, " ", "+"), t))
|
|
}
|
|
conceptNotice = fmt.Sprintf("<div class='correction-notice'><strong>%s</strong>: %s<br>You may find what you are looking for with these search terms: %s</div>",
|
|
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("<div class='variant-notice'>Showing results for '<strong>%s</strong>' (grammatical variant of '%s')</div>", 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("<div class='correction-notice'>Showing results for '<strong>%s</strong>' (corrected from '%s')</div>", 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("<div class='theological-notice'>Showing results for '<strong>%s</strong>' (theological equivalent of '%s')<br><em>%s</em></div>", 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("<div class='responses'>")
|
|
fmt.Fprintf(&sb, "<div class='correction-notice'><strong>No results found</strong> for '%s'.</div>", cleaned)
|
|
if len(suggestions) > 0 {
|
|
sb.WriteString("<div class='theological-suggestions'><h3>Did you mean?</h3>")
|
|
sb.WriteString("<p>We couldn't find any verses matching your search. Here are some spelling suggestions:</p>")
|
|
for _, s := range suggestions {
|
|
fmt.Fprintf(&sb, "<div class='suggestion-item'><a href='/search?kw=%s&view=rich'><strong>%s</strong></a></div>", url.QueryEscape(s), esc(s))
|
|
}
|
|
sb.WriteString("</div>")
|
|
} else {
|
|
sb.WriteString("<div class='theological-suggestions'><h3>Search Tips</h3>")
|
|
sb.WriteString("<p>We couldn't find any verses matching your search. Try:</p>")
|
|
sb.WriteString("<ul style='font-family: var(--font-ui); color: var(--color-text-secondary);'>")
|
|
sb.WriteString("<li>Checking your spelling</li><li>Using different keywords</li>")
|
|
sb.WriteString("<li>Searching for <a href='/search?kw=love&view=rich'>love</a>, <a href='/search?kw=faith&view=rich'>faith</a>, or <a href='/search?kw=hope&view=rich'>hope</a></li>")
|
|
sb.WriteString("</ul></div>")
|
|
}
|
|
sb.WriteString("</div>")
|
|
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 + "<br>" + out
|
|
}
|
|
if len(related) > 0 {
|
|
out += "<br><br>Also consider searching: "
|
|
for _, s := range related {
|
|
out += s + "<br>"
|
|
}
|
|
}
|
|
return htmlR(out)
|
|
case "rich":
|
|
out := r.text
|
|
if conceptNotice != "" {
|
|
if start := strings.Index(out, "<div class='responses'>"); start != -1 {
|
|
if end := strings.Index(out[start+len("<div class='responses'>"):], "</div>"); end != -1 {
|
|
at := start + len("<div class='responses'>") + end + len("</div>")
|
|
out = out[:at] + conceptNotice + out[at:]
|
|
}
|
|
}
|
|
}
|
|
if len(related) > 0 {
|
|
var sb strings.Builder
|
|
sb.WriteString("<div class='theological-suggestions'><h3>Related Theological Concepts</h3>")
|
|
sb.WriteString("<p>In the KJV, these terms often refer to similar theological concepts. Consider also searching for related verses.</p>")
|
|
for _, s := range related {
|
|
fmt.Fprintf(&sb, "<div class='suggestion-item'><a href='/search?kw=%s&view=rich'><strong>%s</strong></a></div>", url.QueryEscape(s), capitalize(s))
|
|
}
|
|
sb.WriteString("</div>")
|
|
if at := strings.LastIndex(out, "</div>"); 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("<div class='correction-notice'>Showing <strong>%s</strong> (interpreted from '%s')</div>",
|
|
html.EscapeString(n.Name), html.EscapeString(n.Original))
|
|
}
|
|
if resolved.Bare {
|
|
keyword := url.QueryEscape(searchClean(query))
|
|
notices += fmt.Sprintf("<div class='correction-notice'>Showing <strong>%s</strong>, chapter 1. Looking for the word instead? "+
|
|
"<a href='/search?kw=%s&view=rich'>Search the text for '%s'</a></div>",
|
|
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, "</div>") // 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("<button type='button' class='collapsible'>" + html.EscapeString(capitalize(word)) + "</button>")
|
|
defs.WriteString("<div class='definition'>" + a.defineRich(strings.ToLower(word), entry, true) + "</div>")
|
|
}
|
|
if defs.Len() > 0 {
|
|
body += "<style>" + a.cssCollapsible + "</style>"
|
|
body += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
|
|
body += defs.String()
|
|
body += "<script src='" + a.assetURL("/js/collapsible.js") + "'></script>"
|
|
}
|
|
return htmlR(body + "</div>")
|
|
}
|