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

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>")
}