diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 48 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/export/calendar.go | 140 | ||||
| -rw-r--r-- | internal/i18n/i18n.go | 4 | ||||
| -rw-r--r-- | internal/readings/readings.go | 25 | ||||
| -rw-r--r-- | internal/web/server.go | 36 | ||||
| -rw-r--r-- | internal/web/server_test.go | 18 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 1 |
8 files changed, 270 insertions, 4 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 88d3a30..24d1fec 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -52,7 +52,8 @@ Flags: --rand-ch print a random chapter and exit --md export the readings as Markdown (to --out or stdout) --pdf export the readings as PDF (to --out or stdout) - --out FILE write --md/--pdf output to FILE + --out FILE write --md/--pdf/--calendar output to FILE + --calendar YYYY-MM export the month as a printable A4 PDF calendar -v, --version print the version and exit -h, --help this help @@ -112,7 +113,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var all, raw, refresh, offline, update, clean, pagerFlag, noPager, citation, week bool var randV, randCh bool var expMD, expPDF bool - var output string + var output, calendar string var bibleVer, compareList, lectionary, lang string var width int var list bool @@ -159,6 +160,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.BoolVar(&expPDF, "pdf", false, "export the readings as PDF") fs.StringVar(&output, "out", "", "write --md/--pdf output to FILE (default: stdout)") fs.StringVar(&output, "output", "", "write --md/--pdf output to FILE (default: stdout)") + fs.StringVar(&calendar, "calendar", "", "export a month (YYYY-MM) as a printable A4 PDF calendar") if err := fs.Parse(rest); err != nil { return 2 @@ -230,6 +232,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } return runExport(cfg, date, ver, effAll, refresh, expPDF, output, stdout, stderr) } + if calendar != "" { + return runCalendar(cfg, calendar, output, stdout, stderr) + } effWidth := width if effWidth == 0 { effWidth = cfg.Width @@ -394,6 +399,45 @@ func runExport(cfg config.Config, date, version string, all, refresh, asPDF bool return 0 } +// runCalendar handles --calendar YYYY-MM: gather each day's celebration name + +// gospel reference + liturgical colour for the month (gospel-only loads, offline +// -aware) and render a printable A4 PDF calendar to --out FILE (or stdout). +func runCalendar(cfg config.Config, ym, output string, stdout, stderr io.Writer) int { + t, err := time.Parse("2006-01", ym) + if err != nil { + fmt.Fprintf(stderr, "lectio: invalid --calendar %q (want YYYY-MM)\n", ym) + return 2 + } + year, month := t.Year(), int(t.Month()) + 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"), Offline: cfg.Offline, All: false}); lerr == nil { + cd.Name = info.Name + cd.Colour = info.Colour + cd.Citation = readings.GospelCitation(secs) + } + days = append(days, cd) + } + + data, err := export.CalendarPDF(year, month, days, cfg.Lectionary, cfg.UILanguage) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + if output != "" { + if err := os.WriteFile(output, data, 0o644); err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + return 0 + } + _, _ = stdout.Write(data) + return 0 +} + // runCitation handles --citation: fetch the day's gospel (gospel-only) and // print just its scripture reference in the configured sigla dialect (see // dialectCitation), for scripts/cron/prompt use. Honors the resolved cfg diff --git a/internal/config/config.go b/internal/config/config.go index 0ddbaa1..ee63754 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,7 @@ var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). -const Version = "0.22.0" +const Version = "0.23.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ diff --git a/internal/export/calendar.go b/internal/export/calendar.go new file mode 100644 index 0000000..8828892 --- /dev/null +++ b/internal/export/calendar.go @@ -0,0 +1,140 @@ +package export + +import ( + "bytes" + "fmt" + "strconv" + "time" + + "github.com/go-pdf/fpdf" +) + +// CalendarDay is one day's entry in a month calendar: the day-of-month, the +// celebration name, the gospel reference, and the liturgical colour (a name +// like "white"/"green"/"violet"/"red"/"rose"; "" = uncoloured). +type CalendarDay struct { + Day int + Name string + Citation string + Colour string +} + +// liturgicalRGB maps a normalized liturgical colour to a soft printable RGB for +// the coloured strip atop each calendar cell. +var liturgicalRGB = map[string][3]int{ + "white": {235, 235, 230}, + "green": {90, 160, 90}, + "violet": {130, 90, 165}, + "red": {200, 70, 70}, + "rose": {235, 165, 195}, +} + +// weekdayAbbr are the Monday-first column headers per UI language. +var weekdayAbbr = map[string][7]string{ + "en": {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, + "pl": {"Pn", "Wt", "Śr", "Cz", "Pt", "So", "Nd"}, +} + +// truncate shortens s to at most n runes, appending "…" when it had to cut. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n < 1 { + return "" + } + return string(r[:n-1]) + "…" +} + +// CalendarPDF renders a printable A4 landscape month calendar: a Monday-first +// grid where each day shows its number, a coloured liturgical strip, the +// celebration name and the gospel reference. lectionary/lang label the header; +// days is the month's CalendarDay entries (day 1..N). +func CalendarPDF(year, month int, days []CalendarDay, lectionary, lang string) ([]byte, error) { + if len(days) == 0 { + return nil, fmt.Errorf("export: empty month") + } + + pdf := fpdf.New("L", "mm", "A4", "") + pdf.AddUTF8FontFromBytes(fontFamily, "", fontRegular) + pdf.AddUTF8FontFromBytes(fontFamily, "B", fontBold) + const margin = 10.0 + pdf.SetMargins(margin, margin, margin) + pdf.SetAutoPageBreak(false, margin) + pdf.AddPage() + pw, ph := pdf.GetPageSize() + + lect := lectionary + if lect == "new" { + lect = "modern" + } + pdf.SetFont(fontFamily, "B", 16) + pdf.CellFormat(0, 9, fmt.Sprintf("%s %d — %s", time.Month(month).String(), year, lect), "", 1, "L", false, 0, "") + + const gridY0 = 22.0 + const weekdayH = 6.0 + const cols = 7 + cellW := (pw - 2*margin) / float64(cols) + + hdr := weekdayAbbr[lang] + if hdr[0] == "" { + hdr = weekdayAbbr["en"] + } + pdf.SetFont(fontFamily, "B", 9) + for i, h := range hdr { + pdf.SetXY(margin+float64(i)*cellW, gridY0) + pdf.CellFormat(cellW, weekdayH, h, "", 0, "C", false, 0, "") + } + gridY := gridY0 + weekdayH + + byDay := map[int]CalendarDay{} + daysInMonth := 0 + for _, d := range days { + byDay[d.Day] = d + if d.Day > daysInMonth { + daysInMonth = d.Day + } + } + first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + firstCol := (int(first.Weekday()) + 6) % 7 // Monday = 0 + rows := (firstCol + daysInMonth + 6) / 7 + cellH := (ph - gridY - margin) / float64(rows) + + // per-cell text budgets (approximate, keeps content inside the cell) + nameChars := int((cellW - 3) / 1.35) + for day := 1; day <= daysInMonth; day++ { + idx := firstCol + day - 1 + x := margin + float64(idx%7)*cellW + y := gridY + float64(idx/7)*cellH + + pdf.SetDrawColor(150, 150, 150) + pdf.Rect(x, y, cellW, cellH, "D") + + cd := byDay[day] + if rgb, ok := liturgicalRGB[cd.Colour]; ok { + pdf.SetFillColor(rgb[0], rgb[1], rgb[2]) + pdf.Rect(x, y, cellW, 2.4, "F") + } + + pdf.SetTextColor(0, 0, 0) + pdf.SetFont(fontFamily, "B", 10) + pdf.SetXY(x+1.5, y+3) + pdf.CellFormat(cellW-3, 5, strconv.Itoa(day), "", 0, "L", false, 0, "") + + pdf.SetFont(fontFamily, "", 7) + pdf.SetXY(x+1.5, y+8.5) + pdf.MultiCell(cellW-3, 3.1, truncate(cd.Name, nameChars*2), "", "L", false) + if cd.Citation != "" { + pdf.SetX(x + 1.5) + pdf.SetFont(fontFamily, "B", 7) + pdf.MultiCell(cellW-3, 3.1, truncate(cd.Citation, nameChars), "", "L", false) + } + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index cf1f550..4c343f9 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -54,7 +54,7 @@ type UI struct { NavReader, WebBook, WebChapter string // Export is the web daily-page download label (txt/md/pdf links). - Export string + Export, Calendar string // NoReadingsDay is the web error fragment shown when readings.Load // returns no sections at all for the requested date. @@ -135,6 +135,7 @@ var enUI = UI{ WebBook: "book", WebChapter: "chapter", Export: "download", + Calendar: "month calendar", NoReadingsDay: "no readings for this day", NoVersion: "(not in %s)", NoVersionPartial: "(not in %s: %s)", @@ -198,6 +199,7 @@ var plUI = UI{ WebBook: "księga", WebChapter: "rozdział", Export: "pobierz", + Calendar: "kalendarz miesiąca", NoReadingsDay: "brak czytań na ten dzień", NoVersion: "(brak w „%s”)", NoVersionPartial: "(brak w „%s”: %s)", diff --git a/internal/readings/readings.go b/internal/readings/readings.go index 047e901..49c9adc 100644 --- a/internal/readings/readings.go +++ b/internal/readings/readings.go @@ -57,6 +57,31 @@ func Load(cfg config.Config, opts Options) ([]liturgy.Section, liturgy.DayInfo, return filterParts(secs, cfg, opts.All), info, nil } +// GospelCitation returns the gospel's scripture reference from loaded sections: +// the section tagged "ewangelia" (modern) or "evangelium" (traditional), else +// the first section; preferring its Citation, else the reference parsed from its +// heading. Used by the month-calendar export (and mirrors the CLI helper). +func GospelCitation(secs []liturgy.Section) string { + pick := func(s liturgy.Section) string { + if s.Citation != "" { + return s.Citation + } + if c, err := liturgy.ExtractCitation(s.Heading); err == nil { + return c + } + return "" + } + for _, s := range secs { + if s.PartID == "ewangelia" || s.PartID == "evangelium" { + return pick(s) + } + } + if len(secs) > 0 { + return pick(secs[0]) + } + return "" +} + // filterParts keeps only the gospel when !all (PartID "ewangelia" modern or // "evangelium" traditional). When all and the lectionary is traditional, it // keeps only the scripture readings (isTraditionalReading) -- dropping diff --git a/internal/web/server.go b/internal/web/server.go index 45ab06f..5ebed38 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -69,6 +69,7 @@ func NewServer(cfg config.Config) http.Handler { 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 /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)) @@ -381,6 +382,41 @@ func exportHandler(cfg config.Config) http.HandlerFunc { } } +// 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) { + 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"), Offline: cfg.Offline, 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 diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 43508ba..284a454 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -645,3 +645,21 @@ func TestExportNoReadings(t *testing.T) { t.Errorf("export with no readings status=%d (want 404/500)", rec.Code) } } + +func TestCalendarPDF(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + cfg := config.Default() + cfg.Offline = true // no network; empty cells are fine, PDF still renders + srv := NewServer(cfg) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/calendar?month=2026-07", nil)) + if rec.Code != 200 { + t.Fatalf("calendar status %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" { + t.Errorf("content-type %q", ct) + } + if b := rec.Body.Bytes(); len(b) < 1000 || string(b[:4]) != "%PDF" { + t.Errorf("not a pdf (len=%d)", len(b)) + } +} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index cd30208..1e43df4 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -84,6 +84,7 @@ <a href="#" onclick="location='/export?fmt=txt&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">txt</a> <a href="#" onclick="location='/export?fmt=md&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">md</a> <a href="#" onclick="location='/export?fmt=pdf&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">pdf</a> + · <a href="#" onclick="var d=(document.querySelector('#controls [name=date]').value||'').slice(0,7);location='/calendar?month='+d+'&lectionary='+document.querySelector('#controls [name=lectionary]').value;return false;">{{.L.Calendar}}</a> </span> <div id="pane">{{.Reading}}</div> |
