summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:56:07 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:56:07 +0200
commit378926240f34759116b19e078d2b2504965f5329 (patch)
treec8e8f2288869882ccc01845eaead8f3c11f067dd
parent29e244c48222278c2f255ff2adf45e3af7c9f895 (diff)
downloadlectio-378926240f34759116b19e078d2b2504965f5329.tar.gz
lectio-378926240f34759116b19e078d2b2504965f5329.zip
export: month calendar PDF (A4 landscape, Monday-first grid, feast name + gospel ref + liturgical colour per day); cli --calendar YYYY-MM; web /calendar + link; readings.GospelCitation; v0.23.0
-rw-r--r--internal/cli/cli.go48
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/export/calendar.go140
-rw-r--r--internal/i18n/i18n.go4
-rw-r--r--internal/readings/readings.go25
-rw-r--r--internal/web/server.go36
-rw-r--r--internal/web/server_test.go18
-rw-r--r--internal/web/templates/index.html1
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>