// This file wires the package's render helpers (RenderReadings, Themes, // themeCSS, the embedded static/templates FS) into an HTTP server: the full // page (GET /), the HTMX reading-pane partial (GET /readings), a passage // lookup partial (GET /lookup), a theme stylesheet endpoint (GET // /theme.css) and the embedded static assets (GET /static/...). package web import ( "bytes" "fmt" "html/template" "io" "io/fs" "net" "net/http" "os/exec" "runtime" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" ) // bibleVersions is the fixed, Themes-independent list of scripture versions // the web UI's checkboxes offer -- independent of any one cfg.Versions, so // every visitor sees the same five choices regardless of their config file. var bibleVersions = []string{"pl", "wuj", "vul", "grb", "drb"} // NewServer builds lectio-web's route tree. cfg supplies the defaults // (lectionary, versions, theme, offline) that requests can override via // query parameters; it is never mutated. func NewServer(cfg config.Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", indexHandler(cfg)) mux.HandleFunc("GET /readings", readingsHandler(cfg)) mux.HandleFunc("GET /lookup", lookupHandler(cfg)) mux.HandleFunc("GET /theme.css", themeCSSHandler(cfg)) staticSub, err := fs.Sub(staticFS, "static") if err != nil { // Unreachable: "static" is embedded at build time by render.go. panic(err) } mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) return mux } // today is lectio-web's "no ?date=" default, in the YYYY-MM-DD form every // other package's Date fields expect. func today() string { return time.Now().Format("2006-01-02") } // shiftDate adds days to date (YYYY-MM-DD); an unparsable date is returned // unchanged, mirroring internal/tui's shiftDate. func shiftDate(date string, days int) string { t, err := time.Parse("2006-01-02", date) if err != nil { return date } return t.AddDate(0, 0, days).Format("2006-01-02") } // requestVersions returns the versions requested via one or more repeated // ?v= query params, falling back to cfg.Versions when none were given. func requestVersions(cfg config.Config, r *http.Request) []string { if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 { return vs } return append([]string(nil), cfg.Versions...) } // queryBool reads a truthy/falsy query param ("1"/"true"/"on"/"yes" vs. // "0"/"false"/"off"/"no"), falling back to def when the param is absent or // unrecognized. func queryBool(r *http.Request, name string, def bool) bool { if !r.URL.Query().Has(name) { return def } switch strings.ToLower(r.URL.Query().Get(name)) { case "1", "true", "on", "yes": return true case "0", "false", "off", "no": return false default: return def } } // requestLectionary returns the ?lectionary= override, falling back to // cfg.Lectionary. func requestLectionary(cfg config.Config, r *http.Request) string { if l := r.URL.Query().Get("lectionary"); l != "" { return l } return cfg.Lectionary } // requestDisplay returns the ?display= override (normalized the same way // config.Load normalizes cfg.WebDisplay, so a bad/unknown value falls back // to "horizontal" rather than reaching RenderReadings unchecked), falling // back to cfg.WebDisplay when the param is absent. func requestDisplay(cfg config.Config, r *http.Request) string { if d := r.URL.Query().Get("display"); d != "" { return config.NormalizeDisplay(d) } return cfg.WebDisplay } // resolveQuery resolves the date/lectionary/all/versions/display controls // shared by indexHandler and readingsHandler from cfg (the defaults) and // r's query params (the overrides) -- one place for both handlers so they // can't drift. func resolveQuery(cfg config.Config, r *http.Request) (date, lectionary string, all bool, versions []string, display string) { date = r.URL.Query().Get("date") if date == "" { date = today() } lectionary = requestLectionary(cfg, r) all = queryBool(r, "all", cfg.All) versions = requestVersions(cfg, r) display = requestDisplay(cfg, r) return date, lectionary, all, versions, display } // loadSections runs the readings router for one request: date/lectionary // override cfg, all controls part filtering, and (when cfg.Offline) // versions is swapped via render.OfflineVersions before being handed back // to the caller for rendering -- so the caller's column labels always match // what was actually loadable. func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) ([]liturgy.Section, []string, error) { cfg.Lectionary = lectionary if cfg.Offline { versions = render.OfflineVersions(versions) } secs, err := readings.Load(cfg, readings.Options{ Date: date, Offline: cfg.Offline, All: all, }) return secs, versions, err } // indexData is what templates/index.html ranges/branches over. type indexData struct { Date, PrevDate, NextDate string Lectionary string VersionOpts []versionOpt All bool ThemeOpts []themeOpt Theme string Display string Ref string Lookup template.HTML Reading template.HTML } type versionOpt struct { Code string Checked bool } type themeOpt struct { Name string Selected bool } // indexHandler serves the full page: controls reflecting the request's // query (defaulting from cfg) plus the reading pane pre-rendered for that // same query, so a plain (JS-less) GET / already shows today's gospel. func indexHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { date, lectionary, all, versions, display := resolveQuery(cfg, r) theme := r.URL.Query().Get("theme") if theme == "" { theme = cfg.WebTheme } secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) reading := renderOrError(secs, loadVersions, lectionary, display, err) selected := map[string]bool{} for _, v := range versions { selected[v] = true } versionOpts := make([]versionOpt, 0, len(bibleVersions)) for _, v := range bibleVersions { versionOpts = append(versionOpts, versionOpt{Code: v, Checked: selected[v]}) } themes := Themes() themeOpts := make([]themeOpt, 0, len(themes)) for _, name := range themes { themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme}) } ref := r.URL.Query().Get("ref") // echoed into the lookup box on a shared/bookmarked link var lookup template.HTML if ref != "" { lookup = renderLookup(ref, versions) } data := indexData{ Date: date, PrevDate: shiftDate(date, -1), NextDate: shiftDate(date, 1), Lectionary: lectionary, VersionOpts: versionOpts, All: all, ThemeOpts: themeOpts, Theme: theme, Display: display, Ref: ref, Lookup: lookup, Reading: reading, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } // readingsHandler serves the HTMX reading-pane partial: a fragment only, // produced entirely by RenderReadings (never re-implemented here). func readingsHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { date, lectionary, all, versions, display := resolveQuery(cfg, r) secs, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) reading := renderOrError(secs, loadVersions, lectionary, display, err) w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, string(reading)) } } // renderOrError returns RenderReadings' fragment, or (on a readings.Load // error) a small escaped error paragraph -- readings.Load errors are // expected in normal operation (an unpublished date, no network while // online, ...) so the pane should show them, not 500. func renderOrError(secs []liturgy.Section, versions []string, lectionary, display string, err error) template.HTML { if err != nil { return template.HTML(`
` + template.HTMLEscapeString(err.Error()) + `
`) } if len(secs) == 0 { return template.HTML(`brak czytań na ten dzień
`) } return RenderReadings(secs, versions, lectionary, display) } // lookupColumn is what templates/lookup.html ranges over: one per requested // version. type lookupColumn struct { Version string Verses []bible.Verse Missing []string } // renderLookup renders the passage-lookup fragment for ref (looked up as-is // -- already English/kjv-style, e.g. "J 20:1" -- against each of versions // via bible.Lookup directly; no Polish->English conversion, that only // applies to a section's own citation via render.GatherVersion, not a // free-typed lookup) against each requested version. Shared by lookupHandler // (the HTMX partial) and indexHandler (so a bookmarked/shared "/?ref=..." // link shows the same result instead of an empty pane). An empty ref // renders nothing, matching lookupHandler's previous no-op behavior. func renderLookup(ref string, versions []string) template.HTML { if ref == "" { return "" } cols := make([]lookupColumn, 0, len(versions)) for _, v := range versions { verses, missing := bible.Lookup(v, ref) cols = append(cols, lookupColumn{Version: v, Verses: verses, Missing: missing}) } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "lookup.html", cols); err != nil { return template.HTML(`` + template.HTMLEscapeString(err.Error()) + `
`) } return template.HTML(buf.String()) } // lookupHandler serves the passage-lookup fragment via renderLookup (see // its doc comment for the lookup semantics). func lookupHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ref := strings.TrimSpace(r.URL.Query().Get("ref")) w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, string(renderLookup(ref, requestVersions(cfg, r)))) } } // themeCSSHandler serves one theme's stylesheet: the requested name, or // (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in // default -- so an unrecognized ?name= degrades to a working theme instead // of a broken page. func themeCSSHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") css, err := themeCSS(name) if err != nil { css, err = themeCSS(cfg.WebTheme) } if err != nil { css, err = themeCSS(config.Default().WebTheme) } if err != nil { http.Error(w, "web: no theme available", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Write(css) } } // Run starts lectio-web: listens on cfg.WebPort (0 picks a free OS port), // prints the URL, best-effort opens it in a browser, and serves until the // listener errors. func Run(cfg config.Config) error { ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort)) if err != nil { return err } port := ln.Addr().(*net.TCPAddr).Port url := fmt.Sprintf("http://localhost:%d", port) fmt.Println("lectio-web on " + url) openBrowser(url) // best-effort; ignore failure (no browser, headless, ...) return http.Serve(ln, NewServer(cfg)) } // openBrowser best-effort launches the platform's "open a URL" command; // any failure (missing command, no display, ...) is silently ignored, as // lectio-web is fully usable by just visiting the printed URL manually. func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("open", url) case "windows": cmd = exec.Command("cmd", "/c", "start", "", url) default: cmd = exec.Command("xdg-open", url) } _ = cmd.Start() }