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

%s

\n
", esc(cat)) for _, b := range byCat[cat] { fmt.Fprintf(&sb, "\n
\n
", esc(b.Title), esc(b.Title)) if about := a.bookDescs[b.Title]; about != "" { fmt.Fprintf(&sb, "

%s

", esc(about)) } sums := a.chapSums[b.Title] for c := 1; c <= b.Chapters; c++ { fmt.Fprintf(&sb, "\n%d", esc(b.Title), c, c) if len(sums) >= c { fmt.Fprintf(&sb, "%s", esc(sums[c-1])) } sb.WriteString("") } sb.WriteString("\n
") } sb.WriteString("\n
") } 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 "" + "" + "" + title + " - KJV API" + "" + "
Home Quick Search
" + body + "" } // --------------------------------------------------------------------------- // 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: , 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, "
")} 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("
") fmt.Fprintf(&sb, "
%d Results
", len(verses)) for _, v := range verses { sb.WriteString(a.responseRich(v)) } sb.WriteString("
") 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) }