431 lines
12 KiB
Go
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) }
|