// 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 theme // stylesheet endpoint (GET /theme.css) and the embedded static assets (GET // /static/...). package web import ( "fmt" "html/template" "io" "io/fs" "net" "net/http" "net/url" "os" "os/exec" "regexp" "runtime" "strconv" "strings" "sync" "time" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/export" "github.com/lukaszkasprzak/lectio/internal/i18n" "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{"wuj", "vul", "grb", "drb"} // server holds the live, mutable config + book table so /settings can apply // changes to the running process. All handlers read a snapshot via get()/table(). type server struct { mu sync.RWMutex cfg config.Config tbl *bible.BookTable bm *bookmarks.Store } func (s *server) get() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg } func (s *server) table() *bible.BookTable { s.mu.RLock(); defer s.mu.RUnlock(); return s.tbl } func (s *server) apply(cfg config.Config, tbl *bible.BookTable) { s.mu.Lock() defer s.mu.Unlock() s.cfg = cfg if tbl != nil { s.tbl = tbl } } // 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 -- /settings applies changes to a // live, mutable copy held by server, which every handler reads per request. func NewServer(cfg config.Config) http.Handler { tbl, _ := bible.LoadBookTable(config.UserBooksINI()) s := &server{cfg: cfg, tbl: tbl, bm: bookmarks.Open()} mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) }) mux.HandleFunc("GET /readings", func(w http.ResponseWriter, r *http.Request) { readingsHandler(s.get())(w, r) }) mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) { exportHandler(s.get())(w, r) }) mux.HandleFunc("GET /calendar", func(w http.ResponseWriter, r *http.Request) { calendarHandler(s.get())(w, r) }) mux.HandleFunc("GET /api/calendar.json", func(w http.ResponseWriter, r *http.Request) { apiCalendarJSONHandler(s.get())(w, r) }) mux.HandleFunc("GET /calendar.ics", func(w http.ResponseWriter, r *http.Request) { calendarICSHandler(s.get())(w, r) }) mux.HandleFunc("GET /reader", func(w http.ResponseWriter, r *http.Request) { readerHandler(s.get(), s.table())(w, r) }) mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) }) mux.HandleFunc("GET /settings", settingsGet(s)) mux.HandleFunc("POST /settings", settingsPost(s)) mux.HandleFunc("POST /reader/bookmark", addBookmark(s)) mux.HandleFunc("GET /bookmarks", listBookmarks(s)) mux.HandleFunc("POST /bookmarks/delete", deleteBookmark(s)) 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 noStore(mux) } // noStore stops the browser caching any lectio-web response, so a rebuilt or // reinstalled server never has an old page, base.css or theme.css served from // cache (which presents as "switching themes stopped working" after an update). func noStore(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") h.ServeHTTP(w, r) }) } // 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") } // dateRe validates a ?date= query param before it is ever handed to // readings.Load/liturgy.Load, which build a filesystem cache path by string // concatenation from it -- an unvalidated date is a path-traversal vector. // Compiled once at package scope (not per request), the same shape as // internal/cli's dateRe. See resolveQuery. var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // 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. The controls form always submits a hidden "vset" marker, // so a request that carries "vset" but no "v" means the user unchecked EVERY // version -- return none (the pane then shows nothing). A request with neither // (a fresh visit or a bare link) falls back to cfg.WebVersions, else a single // cfg.DefaultVersion, so a plain visit checks exactly one box. func requestVersions(cfg config.Config, r *http.Request) []string { if vs, ok := r.URL.Query()["v"]; ok && len(vs) > 0 { return vs } if r.URL.Query().Has("vset") { return nil // form submitted with every box unchecked } if len(cfg.WebVersions) > 0 { return append([]string(nil), cfg.WebVersions...) } return []string{cfg.DefaultVersion} } // 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 == "" || !dateRe.MatchString(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 computes one request's readings offline: date/lectionary // override cfg, all controls part filtering, and versions is passed through // render.EffectiveVersions (which maps any legacy "bt" to "wuj") so the // caller's column labels always match what was actually loadable. dayInfo is // the day's celebration identity (see liturgy.DayInfo), zero when none. func loadSections(cfg config.Config, lectionary, date string, all bool, versions []string) (secs []liturgy.Section, dayInfo liturgy.DayInfo, effVersions []string, err error) { cfg.Lectionary = lectionary effVersions = render.EffectiveVersions(versions, lectionary, cfg.Offline) secs, dayInfo, err = readings.Load(cfg, readings.Options{Date: date, All: all}) return secs, dayInfo, effVersions, 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 Mono bool Reading template.HTML // L holds the localised control labels (lectionary/layout/theme/...), // set from i18n.Get(cfg.UILanguage) -- index.html references its // fields (e.g. {{.L.Lectionary}}) instead of hardcoded Polish text. L i18n.UI // Lang is cfg.UILanguage, rendered into . Lang string } 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 } // No version selected -> fetch nothing and show nothing (empty pane, no // day-info/headings); loadVersions stays nil so no box is checked. var reading template.HTML var loadVersions []string if len(versions) > 0 { secs, dayInfo, lv, err := loadSections(cfg, lectionary, date, all, versions) loadVersions = lv reading = renderOrError(secs, lv, lectionary, display, cfg.UILanguage, dayInfo, err) } // Check the boxes for the versions actually rendered (loadVersions), // not the raw request: traditional/offline substitute pl->wuj, so the // pl box must not show checked while Wujek is what's displayed. selected := map[string]bool{} for _, v := range loadVersions { 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}) } data := indexData{ Date: date, PrevDate: shiftDate(date, -1), NextDate: shiftDate(date, 1), Lectionary: lectionary, VersionOpts: versionOpts, All: all, ThemeOpts: themeOpts, Theme: theme, Display: display, Mono: queryBool(r, "mono", cfg.WebMono), Reading: reading, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage, } 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) var reading template.HTML if len(versions) > 0 { secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) reading = renderOrError(secs, loadVersions, lectionary, display, cfg.UILanguage, dayInfo, err) } w.Header().Set("Content-Type", "text/html; charset=utf-8") io.WriteString(w, string(reading)) } } // exportHandler serves GET /export?fmt=txt|md|pdf: the current day's readings // as a downloadable file (Content-Disposition attachment), reusing the same // date/lectionary/all/version resolution as the reading pane. func exportHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if l := r.URL.Query().Get("lang"); l != "" { cfg.UILanguage = config.NormalizeUILanguage(l) } date, lectionary, all, versions, _ := resolveQuery(cfg, r) secs, dayInfo, loadVersions, err := loadSections(cfg, lectionary, date, all, versions) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if len(secs) == 0 { http.Error(w, i18n.Get(cfg.UILanguage).NoReadingsDay, http.StatusNotFound) return } version := cfg.DefaultVersion if len(loadVersions) > 0 { version = loadVersions[0] } switch r.URL.Query().Get("fmt") { case "pdf": data, perr := export.ReadingsPDF(date, dayInfo, secs, version, lectionary, cfg.UILanguage) if perr != nil { http.Error(w, perr.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.pdf"`) w.Write(data) case "md": w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.md"`) io.WriteString(w, export.Markdown(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) default: w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="lectio-`+date+`.txt"`) io.WriteString(w, export.Text(date, dayInfo, secs, version, lectionary, cfg.UILanguage)) } } } // calendarHandler serves GET /calendar?month=YYYY-MM&lectionary=: a printable // A4 PDF month calendar of the celebration names + gospel references. Missing/ // bad month defaults to the current month. func calendarHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if l := r.URL.Query().Get("lang"); l != "" { cfg.UILanguage = config.NormalizeUILanguage(l) } t, err := time.Parse("2006-01", r.URL.Query().Get("month")) if err != nil { t = time.Now() } year, month := t.Year(), int(t.Month()) cfg.Lectionary = requestLectionary(cfg, r) first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) var days []export.CalendarDay for d := first; int(d.Month()) == month; d = d.AddDate(0, 0, 1) { cd := export.CalendarDay{Day: d.Day()} if secs, info, lerr := readings.Load(cfg, readings.Options{Date: d.Format("2006-01-02"), All: false}); lerr == nil { cd.Name = info.Name cd.Colour = info.Colour cd.Citation = readings.GospelCitation(secs) } days = append(days, cd) } data, perr := export.CalendarPDF(year, month, days, cfg.Lectionary, cfg.UILanguage) if perr != nil { http.Error(w, perr.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="lectio-%04d-%02d-calendar.pdf"`, year, month)) w.Write(data) } } // renderOrError returns RenderReadings' fragment, or (on a readings.Load // error, or no sections at all for the date) a small escaped error // paragraph, localised via lang -- 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, lang string, dayInfo liturgy.DayInfo, err error) template.HTML { if err != nil { return template.HTML(`
` + template.HTMLEscapeString(err.Error()) + `
`) } if len(secs) == 0 { return template.HTML(`` + template.HTMLEscapeString(i18n.Get(lang).NoReadingsDay) + `
`) } return RenderReadings(secs, versions, lectionary, display, lang, dayInfo) } // requestReaderVersions returns the corpus versions the reader request asks for // (repeated ?v=), dropping "bt" (no corpus) and anything invalid; falling back // to the configured default (or "wuj" when that is bt/invalid) so a plain visit // shows exactly one column. func requestReaderVersions(cfg config.Config, r *http.Request) []string { var out []string for _, v := range r.URL.Query()["v"] { if v != "bt" && config.ValidVersion(v) { out = append(out, v) } } if len(out) == 0 { d := cfg.DefaultVersion if d == "bt" || !config.ValidVersion(d) { d = "wuj" } out = []string{d} } return out } // findBook returns the BookInfo whose Canonical matches, and whether found. func findBook(books []bible.BookInfo, canonical string) (bible.BookInfo, bool) { for _, b := range books { if b.Canonical == canonical { return b, true } } return bible.BookInfo{}, false } // clampChap keeps chap within the book's available chapters (contiguous in // practice); 0 when the book has none in any corpus version. func clampChap(chap int, chaps []int) int { if len(chaps) == 0 { return 0 } if chap < chaps[0] { return chaps[0] } last := chaps[len(chaps)-1] if chap > last { return last } return chap } // atoiDefault parses s as an int, returning def when it is empty/unparsable. func atoiDefault(s string, def int) int { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { return n } return def } type readerData struct { BookOpts []bookOpt Book string // canonical name of the selected book (for the bookmark-add form) Chap int PrevChap, NextChap int ChapOpts []chapOpt VersionOpts []versionOpt Display string ThemeOpts []themeOpt Theme string Mono bool Reading template.HTML L i18n.UI Lang string } type bookOpt struct { Value string // canonical name (corpus/query value) Label string // dialect display name Selected bool } type chapOpt struct { N int Selected bool } // readerHandler serves GET /reader: a book picker (dialect names), chapter // navigation and corpus-version compare, rendering the same reading-pane // templates/themes as the daily view. The controls form re-fetches /reader and // hx-selects #reader-root, so book/chapter/version selects stay in sync. func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { books := tbl.Books(cfg.SiglaLang()) if len(books) == 0 { // defensive: embedded table always has books http.Error(w, "web: no books", http.StatusInternalServerError) return } info, ok := findBook(books, r.URL.Query().Get("book")) if !ok { info = books[0] } versions := requestReaderVersions(cfg, r) chaps := UnionChapters(info.Canonical) first := 1 if len(chaps) > 0 { first = chaps[0] } chap := clampChap(atoiDefault(r.URL.Query().Get("chap"), first), chaps) display := requestDisplay(cfg, r) theme := r.URL.Query().Get("theme") if theme == "" { theme = cfg.WebTheme } reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage) bookOpts := make([]bookOpt, 0, len(books)) for _, b := range books { bookOpts = append(bookOpts, bookOpt{Value: b.Canonical, Label: b.Name, Selected: b.Canonical == info.Canonical}) } chapOpts := make([]chapOpt, 0, len(chaps)) for _, c := range chaps { chapOpts = append(chapOpts, chapOpt{N: c, Selected: c == chap}) } selected := map[string]bool{} for _, v := range versions { selected[v] = true } versionOpts := make([]versionOpt, 0, len(readerCorpusVersions)) for _, v := range readerCorpusVersions { 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}) } prev, next := chap-1, chap+1 if len(chaps) > 0 { prev = clampChap(chap-1, chaps) next = clampChap(chap+1, chaps) } data := readerData{ BookOpts: bookOpts, Book: info.Canonical, Chap: chap, PrevChap: prev, NextChap: next, ChapOpts: chapOpts, VersionOpts: versionOpts, Display: display, ThemeOpts: themeOpts, Theme: theme, Mono: queryBool(r, "mono", cfg.WebMono), Reading: reading, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "reader.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } // addBookmark saves the current reader book+chapter with a note and tags, then // returns to the reader at that location. func addBookmark(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } book := r.PostForm.Get("book") chap := atoiOr(r.PostForm.Get("chap"), 0) verse := atoiOr(r.PostForm.Get("verse"), 0) if verse < 0 { verse = 0 } if book != "" && chap > 0 { _, _ = s.bm.Add(bookmarks.Bookmark{ Book: book, Chapter: chap, Verse: verse, Note: strings.TrimSpace(r.PostForm.Get("note")), Tags: bookmarks.ParseTags(r.PostForm.Get("tags")), }) } http.Redirect(w, r, "/reader?book="+url.QueryEscape(book)+"&chap="+strconv.Itoa(chap), http.StatusSeeOther) } } // bookmarkView is one row of templates/bookmarks.html. type bookmarkView struct { ID string Book string // canonical (for the ?book= open link) Display string // dialect display name Chapter int Verse int // 0 = whole chapter Note string Tags []string Created string } type bookmarksData struct { Items []bookmarkView AllTags []string Tag string // active filter ("" = all) L i18n.UI Lang string } // listBookmarks renders the bookmarks page, optionally filtered by ?tag=. func listBookmarks(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cfg := s.get() tag := r.URL.Query().Get("tag") items, err := s.bm.List(tag) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } names := map[string]string{} for _, b := range s.table().Books(cfg.SiglaLang()) { names[b.Canonical] = b.Name } views := make([]bookmarkView, 0, len(items)) for _, b := range items { disp := names[b.Book] if disp == "" { disp = b.Book } views = append(views, bookmarkView{ID: b.ID, Book: b.Book, Display: disp, Chapter: b.Chapter, Verse: b.Verse, Note: b.Note, Tags: b.Tags, Created: b.Created}) } allTags, _ := s.bm.Tags() data := bookmarksData{Items: views, AllTags: allTags, Tag: tag, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage} w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "bookmarks.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } // deleteBookmark removes a bookmark and returns to the list. func deleteBookmark(s *server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } _ = s.bm.Delete(r.PostForm.Get("id")) http.Redirect(w, r, "/bookmarks", http.StatusSeeOther) } } // 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) } } // settingsData drives templates/settings.html. type settingsData struct { Cfg config.Config Books string // current books.ini text (user override, else the embedded default) ThemeOpts []themeOpt // for the web_theme