Files
2026-10-26 20:30:43 -07:00

431 lines
12 KiB
Go

package kjv
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
)
// App is the whole web application. Everything it serves comes from the
// companion database (see assets.go), loaded once at startup.
type App struct {
store *Store
res *Resolver
speller *Speller
started time.Time
dictionary map[string][]string // 1828 Webster
variants map[string][]string
synonyms map[string][]string
concepts map[string]concept
bookDescs map[string]string
chapSums map[string][]string
served map[string]servedAsset // versioned static files: /styles/page.css ...
pages map[string]string // simple html pages, asset urls versioned
htmlResponse, htmlNavbar, htmlChapterNav string
cssNavbar, cssCollapsible string
favicon []byte
indexHTML string
sermonDB *sql.DB
secretKey string
semantic string // host:port of the optional semantic search service
}
type concept struct {
Query string `json:"query"`
Explanation string `json:"explanation"`
SearchTerms []string `json:"search_terms"`
}
type servedAsset struct {
body []byte
mime string
version string
etag string
}
var servedPaths = map[string]string{ // url -> asset path
"/styles/page.css": "styles/page.css",
"/js/collapsible.js": "js/collapsible.js",
"/js/shortcode.js": "js/shortcode.js",
"/js/verse_menu.js": "js/verse_menu.js",
"/js/book_suggest.js": "js/book_suggest.js",
}
func NewApp(store *Store) (*App, error) {
assets, err := store.LoadAssets()
if err != nil {
return nil, err
}
need := func(p string) ([]byte, error) {
b, ok := assets[p]
if !ok {
return nil, fmt.Errorf("asset %s is missing from the database (run: kjv-api import-assets <source dir>)", p)
}
return b, nil
}
a := &App{store: store, res: NewResolver(store), started: time.Now(), pages: map[string]string{}, served: map[string]servedAsset{}}
loadJSON := func(p string, into any) error {
b, err := need(p)
if err != nil {
return err
}
if err := json.Unmarshal(b, into); err != nil {
return fmt.Errorf("%s: %w", p, err)
}
return nil
}
if err := loadJSON("data/1828_Webster_KJV.json", &a.dictionary); err != nil {
return nil, err
}
if err := loadJSON("data/book_descriptions.json", &a.bookDescs); err != nil {
return nil, err
}
if err := loadJSON("data/chapter_summaries.json", &a.chapSums); err != nil {
return nil, err
}
var groups [][]string
if err := loadJSON("data/grammatical_variants.json", &groups); err != nil {
return nil, err
}
a.variants = groupMap(groups)
if err := loadJSON("data/theological_synonyms.json", &groups); err != nil {
return nil, err
}
a.synonyms = groupMap(groups)
var concepts []concept
if err := loadJSON("data/concept_mappings.json", &concepts); err != nil {
return nil, err
}
a.concepts = map[string]concept{}
for _, c := range concepts {
a.concepts[strings.ToLower(c.Query)] = c
}
dic, err := need("data/kjv.dic")
if err != nil {
return nil, err
}
aff, err := need("data/kjv.aff")
if err != nil {
return nil, err
}
a.speller = NewSpeller(dic, aff)
// static assets, each URL carrying a hash of its own contents
for u, p := range servedPaths {
b, err := need(p)
if err != nil {
return nil, err
}
if p == "js/book_suggest.js" {
names, _ := json.Marshal(store.bookNames)
b = bytes.ReplaceAll(b, []byte("__BOOKS__"), names)
}
sum := sha256.Sum256(b)
mime := assetMIME[p[strings.LastIndexByte(p, '.'):]]
a.served[u] = servedAsset{b, mime, fmt.Sprintf("%x", sum[:4]), fmt.Sprintf(`"%x"`, sum[:8])}
}
text := func(p string) (string, error) { b, err := need(p); return string(b), err }
if a.htmlResponse, err = text("html/response.html"); err != nil {
return nil, err
}
if a.htmlChapterNav, err = text("html/chapter_nav.html"); err != nil {
return nil, err
}
if a.cssNavbar, err = text("styles/header.css"); err != nil {
return nil, err
}
if a.cssCollapsible, err = text("styles/collapsible.css"); err != nil {
return nil, err
}
nav, err := text("html/navbar.html")
if err != nil {
return nil, err
}
a.htmlNavbar = a.loadHTML(nav)
if a.favicon, err = need("static/favicon.svg"); err != nil {
return nil, err
}
for _, p := range []string{"html/find.html", "html/show.html", "html/search.html", "html/seq.html", "html/dictionary.html",
"html/saved.html", "html/plan.html", "html/heaven.html", "kjv_fe.html"} {
t, err := text(p)
if err != nil {
return nil, err
}
a.pages[p] = a.loadHTML(t)
}
index, err := text("html/index.html")
if err != nil {
return nil, err
}
a.indexHTML = a.renderIndex(a.loadHTML(index))
return a, nil
}
func groupMap(groups [][]string) map[string][]string {
m := map[string][]string{}
for _, g := range groups {
for _, w := range g {
var others []string
for _, o := range g {
if o != w {
others = append(others, o)
}
}
m[w] = others
}
}
return m
}
var assetRefRE = regexp.MustCompile(`(/(?:js|styles)/[\w.\-]+)\?v=__V__`)
// loadHTML fills each '/js/x.js?v=__V__' with that file's own version.
func (a *App) loadHTML(s string) string {
return assetRefRE.ReplaceAllStringFunc(s, func(m string) string {
u := assetRefRE.FindStringSubmatch(m)[1]
if as, ok := a.served[u]; ok {
return u + "?v=" + as.version
}
return m
})
}
func (a *App) assetURL(u string) string { return u + "?v=" + a.served[u].version }
var indexLoopRE = regexp.MustCompile(`(?s)\{% for category, books in testament_data\.(OT|NT)\.items\(\) %\}.*?\n \{% endfor %\}`)
// renderIndex fills the book lists of the home page.
func (a *App) renderIndex(tmpl string) string {
esc := html.EscapeString
return indexLoopRE.ReplaceAllStringFunc(tmpl, func(m string) string {
testament := indexLoopRE.FindStringSubmatch(m)[1]
var order []string
byCat := map[string][]BookInfo{}
for _, b := range a.store.bookInfo {
if b.OTNT != testament {
continue
}
if _, ok := byCat[b.Category]; !ok {
order = append(order, b.Category)
}
byCat[b.Category] = append(byCat[b.Category], b)
}
var sb strings.Builder
for _, cat := range order {
fmt.Fprintf(&sb, "\n <h3 class=\"category-header\">%s</h3>\n <div class=\"book-grid\">", esc(cat))
for _, b := range byCat[cat] {
fmt.Fprintf(&sb, "\n<div><button class=\"book-button collapsible\" data-book=\"%s\">%s</button>\n<div class=\"chapter-grid content\">", esc(b.Title), esc(b.Title))
if about := a.bookDescs[b.Title]; about != "" {
fmt.Fprintf(&sb, "<p class=\"book-about\">%s</p>", esc(about))
}
sums := a.chapSums[b.Title]
for c := 1; c <= b.Chapters; c++ {
fmt.Fprintf(&sb, "\n<a href=\"/show?kw=%s %d&view=rich\" class=\"chapter-link\"><span class=\"chapter-num\">%d</span>", esc(b.Title), c, c)
if len(sums) >= c {
fmt.Fprintf(&sb, "<span class=\"chapter-sum\">%s</span>", esc(sums[c-1]))
}
sb.WriteString("</a>")
}
sb.WriteString("\n</div></div>")
}
sb.WriteString("\n </div>")
}
return sb.String()
})
}
// ---------------------------------------------------------------------------
// responses
type Resp struct {
Body string
Type string
Status int
Header map[string]string
}
func htmlR(s string) Resp { return Resp{Body: s, Type: "text/html; charset=utf-8"} }
func jsonR(v any) Resp { return jsonStatus(v, 0) }
func jsonStatus(v any, status int) Resp {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(v)
return Resp{Body: buf.String(), Type: "application/json", Status: status}
}
func errJSON(status int, msg string) Resp { return jsonStatus(map[string]string{"error": msg}, status) }
func (r Resp) write(w http.ResponseWriter) {
h := w.Header()
h.Set("Content-Type", r.Type)
if strings.HasPrefix(r.Type, "text/html") && r.Header["Cache-Control"] == "" {
h.Set("Cache-Control", "no-cache")
}
for k, v := range r.Header {
h.Set(k, v)
}
if r.Status != 0 {
w.WriteHeader(r.Status)
}
w.Write([]byte(r.Body))
}
func (a *App) staticPage(p string) Resp { return htmlR(a.pages[p]) }
// staticHTMLDoc wraps a small html snippet in the standard page shell.
func (a *App) staticHTMLDoc(title, body string) string {
return "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>" +
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>" +
"<title>" + title + " - KJV API</title>" +
"<link rel='stylesheet' href='" + a.assetURL("/styles/page.css") + "'></head><body>" +
"<header><a href='/'>Home</a> <a href='/find'>Quick Search</a></header>" +
body + "</body></html>"
}
// ---------------------------------------------------------------------------
// verse rendering
func (a *App) responseRich(v Verse) string {
passage := fmt.Sprintf("%s %d:%d", a.store.BookName(v.Book), v.Chapter, v.Verse)
return strings.NewReplacer(
"{chapter}", passage[:strings.LastIndexByte(passage, ':')],
"{passage}", passage,
"{text}", v.Text,
"{verse_id}", fmt.Sprint(v.ID),
).Replace(a.htmlResponse)
}
func chapterFindLink(name string) string { return "/find?kw=" + url.PathEscape(name) + "&view=rich" }
type chapterNav struct{ current, prev, next string }
func (a *App) chapterName(key int64) string {
return fmt.Sprintf("%s %d", a.store.BookName(int(key/1000)), key%1000)
}
func (a *App) nextPrevChapter(verseID int64) chapterNav {
key := verseID / 1000
n := len(a.store.chapterList)
i := sort.Search(n, func(i int) bool { return a.store.chapterList[i] >= key })
return chapterNav{
current: a.chapterName(key),
prev: a.chapterName(a.store.chapterList[(i-1+n)%n]),
next: a.chapterName(a.store.chapterList[(i+1)%n]),
}
}
// renderNavbar opens the rich html document: <head>, site header and the
// chapter bar. The caller adds the body content after it.
func (a *App) renderNavbar(query string, nav *chapterNav, barHTML string) string {
esc := html.EscapeString
title := strings.TrimSpace(query)
if title == "" && nav != nil {
title = nav.current
}
if title == "" {
title = "Scripture"
}
chapterHTML := barHTML
if nav != nil {
chapterHTML = strings.NewReplacer(
"{current_chapter_name}", esc(nav.current),
"{prev_chapter_name}", esc(nav.prev),
"{prev_chapter_link}", esc("/show?kw="+nav.prev+"&view=rich"),
"{next_chapter_name}", esc(nav.next),
"{next_chapter_link}", esc("/show?kw="+nav.next+"&view=rich"),
"{current_chapter_link}", esc(chapterFindLink(nav.current)),
).Replace(a.htmlChapterNav)
}
return strings.NewReplacer(
"{title}", esc(title), "{css}", a.cssNavbar, "{query}", esc(query), "{chapter_nav}", chapterHTML,
).Replace(a.htmlNavbar)
}
type rendered struct {
text string // plain and rich
list []map[string]any
isJSON bool
}
func (r rendered) resp() Resp {
if r.isJSON {
return jsonR(r.list)
}
return htmlR(r.text)
}
// render lays out verses as view=plain, view=rich or (anything else) json.
func (a *App) render(verses []Verse, view, kw string) rendered {
switch view {
case "plain":
parts := make([]string, len(verses))
for i, v := range verses {
parts[i] = fmt.Sprintf("%s %d:%d %s\n", a.store.BookName(v.Book), v.Chapter, v.Verse, v.Text)
}
return rendered{text: strings.Join(parts, "<br>")}
case "rich":
if len(verses) == 0 {
lg.Error("Malformed query: %s", kw)
return rendered{text: "Malformed Query - if you believe this to be in error (your query should resolve), email tyler@dinsmoor.us"}
}
// Prev/next chapter links only make sense when reading a single chapter,
// not for a keyword search that spans books.
var nav *chapterNav
first := verses[0].ID / 1000
single := true
for _, v := range verses {
if v.ID/1000 != first {
single = false
break
}
}
if single {
n := a.nextPrevChapter(verses[0].ID)
nav = &n
}
var sb strings.Builder
sb.WriteString(a.renderNavbar(kw, nav, ""))
sb.WriteString("<div class='responses'>")
fmt.Fprintf(&sb, "<div class='results-count'>%d Results</div>", len(verses))
for _, v := range verses {
sb.WriteString(a.responseRich(v))
}
sb.WriteString("</div>")
return rendered{text: sb.String()}
}
list := make([]map[string]any, len(verses))
for i, v := range verses {
list[i] = map[string]any{"bookname": a.store.BookName(v.Book), "chapter": v.Chapter, "verse": v.Verse, "text": v.Text}
}
return rendered{list: list, isJSON: true}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func itoa64(n int64) string { return fmt.Sprint(n) }
func urlQuery(s string) string { return url.QueryEscape(s) }
func htmlEsc(s string) string { return html.EscapeString(s) }