aboutsummaryrefslogtreecommitdiff
path: root/internal/liturgy
diff options
context:
space:
mode:
Diffstat (limited to 'internal/liturgy')
-rw-r--r--internal/liturgy/fetch.go84
-rw-r--r--internal/liturgy/fetch_test.go16
-rw-r--r--internal/liturgy/parse.go53
-rw-r--r--internal/liturgy/parse_test.go53
-rw-r--r--internal/liturgy/section.go19
-rw-r--r--internal/liturgy/store.go2
6 files changed, 190 insertions, 37 deletions
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go
index 8c887f4..e66b77f 100644
--- a/internal/liturgy/fetch.go
+++ b/internal/liturgy/fetch.go
@@ -76,25 +76,29 @@ func CacheDir() string {
return filepath.Join(base, "lectio")
}
-// Load returns the day's reading sections, from the modern (niedziela.pl)
-// source, preferring cache over network:
+// Load returns the day's reading sections and DayInfo, from the modern
+// (niedziela.pl) source, preferring cache over network:
//
-// 0. If Offline, skip straight to LoadOffline (the harvested sigla TSV).
-// 1. Unless Refresh, the parsed JSON cache ({date}.json).
-// 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed, and the
-// result written to the JSON cache for next time.
+// 0. If Offline, skip straight to LoadOffline (the harvested sigla TSV) --
+// that store carries citations only, so DayInfo comes back zero.
+// 1. Unless Refresh, the parsed JSON cache ({date}.json), which round-trips
+// DayInfo alongside the sections (see cachedDay/loadCache).
+// 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed (both
+// sections and DayInfo re-derived from the same HTML via Parse/
+// ParseDayInfo), and the result written to the JSON cache for next time.
// 3. Otherwise, fetch the page over the network, parse it, and -- only if
// the page is fully published -- write both cache layers. A fetch error
// here (e.g. no network) falls back to LoadOffline for this date if it
// has been harvested, and only surfaces the original fetch error if
// that fallback also fails.
-func Load(opts Options) ([]Section, error) {
+func Load(opts Options) ([]Section, DayInfo, error) {
if !dateRe.MatchString(opts.Date) {
- return nil, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
+ return nil, DayInfo{}, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
}
if opts.Offline {
- return LoadOffline(opts.Date)
+ secs, err := LoadOffline(opts.Date)
+ return secs, DayInfo{}, err
}
dir := CacheDir()
@@ -102,27 +106,30 @@ func Load(opts Options) ([]Section, error) {
htmlPath := filepath.Join(dir, opts.Date+".html")
if !opts.Refresh {
- if secs, err := loadJSONCache(jsonPath); err == nil {
- return secs, nil
+ if secs, info, err := loadCache(jsonPath); err == nil {
+ return secs, info, nil
}
if page, err := os.ReadFile(htmlPath); err == nil {
- secs, err := Parse(string(page))
+ pageStr := string(page)
+ secs, err := Parse(pageStr)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
- writeJSONCache(jsonPath, secs)
- return secs, nil
+ info := ParseDayInfo(pageStr)
+ writeCache(jsonPath, secs, info)
+ return secs, info, nil
}
}
page, err := fetch(opts.Date)
if err != nil {
// Network is unreachable: fall back to a prior harvest of this date
- // if there is one, rather than failing outright.
+ // if there is one, rather than failing outright. The sigla store
+ // carries no DayInfo, so this path always reports it zero.
if secs, offErr := LoadOffline(opts.Date); offErr == nil {
- return secs, nil
+ return secs, DayInfo{}, nil
}
- return nil, err
+ return nil, DayInfo{}, err
}
// Parse itself reports an unpublished date as "no reading published for
@@ -130,19 +137,20 @@ func Load(opts Options) ([]Section, error) {
// page is neither cached nor returned as sections here.
secs, err := Parse(page)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
+ info := ParseDayInfo(page)
// Cache only fully-published pages, so an as-yet-unpublished future date
// keeps being retried instead of caching a "no reading" placeholder.
if publishedRe.MatchString(page) {
if err := os.MkdirAll(dir, 0o755); err == nil {
_ = os.WriteFile(htmlPath, []byte(page), 0o644)
- writeJSONCache(jsonPath, secs)
+ writeCache(jsonPath, secs, info)
}
}
- return secs, nil
+ return secs, info, nil
}
// CleanCache removes cached readings whose date is before `before` from
@@ -189,23 +197,35 @@ func CleanCache(before time.Time) (removed int, freed int64, err error) {
return removed, freed, nil
}
-// loadJSONCache reads and unmarshals the parsed-sections cache file.
-func loadJSONCache(path string) ([]Section, error) {
+// cachedDay is the on-disk shape of the parsed-sections JSON cache
+// ({date}.json): the sections plus the day's liturgical identity, so a
+// cache hit round-trips DayInfo without re-parsing the HTML cache. (An
+// older cache file written before DayInfo existed was a bare JSON array,
+// not an object -- json.Unmarshal into cachedDay then fails, which
+// loadCache treats as an ordinary cache miss, falling through to the HTML
+// cache or the network like any other stale/missing cache entry.)
+type cachedDay struct {
+ Sections []Section
+ DayInfo DayInfo
+}
+
+// loadCache reads and unmarshals the parsed-sections + DayInfo cache file.
+func loadCache(path string) ([]Section, DayInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
- return nil, err
+ return nil, DayInfo{}, err
}
- var secs []Section
- if err := json.Unmarshal(data, &secs); err != nil {
- return nil, err
+ var cd cachedDay
+ if err := json.Unmarshal(data, &cd); err != nil {
+ return nil, DayInfo{}, err
}
- return secs, nil
+ return cd.Sections, cd.DayInfo, nil
}
-// writeJSONCache best-effort writes the parsed sections cache; a failure to
-// cache should never fail the load itself.
-func writeJSONCache(path string, secs []Section) {
- data, err := json.Marshal(secs)
+// writeCache best-effort writes the parsed sections + DayInfo cache; a
+// failure to cache should never fail the load itself.
+func writeCache(path string, secs []Section, info DayInfo) {
+ data, err := json.Marshal(cachedDay{Sections: secs, DayInfo: info})
if err != nil {
return
}
diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go
index 738126d..c06f0f8 100644
--- a/internal/liturgy/fetch_test.go
+++ b/internal/liturgy/fetch_test.go
@@ -19,17 +19,25 @@ func TestLoadCaches(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
baseURL = srv.URL + "/liturgia/%s/Ewangelia" // test hook
- secs1, err := Load(Options{Date: "2026-06-22"})
+ secs1, info1, err := Load(Options{Date: "2026-06-22"})
if err != nil || len(secs1) == 0 {
t.Fatalf("load1: %v", err)
}
- secs2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache
+ if info1.Name == "" {
+ t.Error("load1: DayInfo.Name empty, want it populated from the freshly-parsed HTML")
+ }
+ secs2, info2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache
if hits != 1 {
t.Errorf("server hit %d times, want 1 (cache miss on repeat)", hits)
}
if len(secs2) != len(secs1) {
t.Error("cache returned different section count")
}
+ // The JSON cache round-trips DayInfo (see cachedDay/loadCache), so a
+ // cache-hit repeat load must not lose it.
+ if info2 != info1 {
+ t.Errorf("cache-hit DayInfo = %+v, want it to match the first load's %+v", info2, info1)
+ }
}
// TestLoadRejectsInvalidDate is the liturgy-layer defense-in-depth check for
@@ -39,7 +47,7 @@ func TestLoadCaches(t *testing.T) {
//
// The planted "passwd.json" sits one level *above* CacheDir() -- reachable
// only via a "../" date -- so if Load ever built jsonPath from the raw date
-// unchecked, loadJSONCache would read it back and return its section instead
+// unchecked, loadCache would read it back and return its section instead
// of an error.
func TestLoadRejectsInvalidDate(t *testing.T) {
dir := t.TempDir()
@@ -50,7 +58,7 @@ func TestLoadRejectsInvalidDate(t *testing.T) {
t.Fatal(err)
}
- secs, err := Load(Options{Date: "../passwd"})
+ secs, _, err := Load(Options{Date: "../passwd"})
if err == nil {
t.Fatalf("Load(Date=%q) = (%v, nil), want a non-nil error", "../passwd", secs)
}
diff --git a/internal/liturgy/parse.go b/internal/liturgy/parse.go
index 64e6928..4c1a935 100644
--- a/internal/liturgy/parse.go
+++ b/internal/liturgy/parse.go
@@ -23,8 +23,32 @@ var (
h4Re = regexp.MustCompile(`(?s)<h4>(.*?)</h4>`)
pRe = regexp.MustCompile(`(?s)<p>(.*?)</p>`)
citationRe = regexp.MustCompile(`\((.+)\)\s*$`)
+
+ // dayNamePRe matches every classed <p><em>...</em></p> on the page; only
+ // the one whose class carries both "fw-bold" and a "color-" role (see
+ // dayNameParaMatches) is the day's celebration name -- the page also
+ // carries a plain fw-bold (no color-) lookalike higher up that must not
+ // win instead.
+ dayNamePRe = regexp.MustCompile(`(?s)<p class="([^"]*)">\s*<em>(.*?)</em>\s*</p>`)
+
+ // dayColourRe matches niedziela.pl's "Kolor szat: <word>" vestment-colour
+ // line, tolerating the <span>/<strong> markup wrapped around the colour
+ // word on the page (see internal/liturgy/testdata/2026-07-22.html).
+ dayColourRe = regexp.MustCompile(`Kolor szat:\s*(?:<[^>]+>\s*)*([\p{L}]+)`)
)
+// modernColours maps niedziela.pl's Polish vestment-colour words to
+// DayInfo's normalized colour names; anything not listed here (including a
+// multi-option line like "zielony albo biały albo czerwony", which matches
+// only its first word) is left for the caller to treat as "" if absent.
+var modernColours = map[string]string{
+ "biały": "white",
+ "zielony": "green",
+ "fioletowy": "violet",
+ "czerwony": "red",
+ "różowy": "rose",
+}
+
// panePattern matches the opening tag of the tab-pane div carrying the given
// tab id, e.g. `<div class="tab-pane fade " id="tabnowy0all">`.
func panePattern(tab string) *regexp.Regexp {
@@ -122,6 +146,35 @@ func Parse(pageHTML string) ([]Section, error) {
return sections, nil
}
+// ParseDayInfo extracts the day's celebration name and liturgical colour
+// from a niedziela.pl page: Name is the inner text of the <p class="...
+// fw-bold color-XXX"><em>NAME</em></p> paragraph (there is also an earlier,
+// plain fw-bold-but-no-color- lookalike on the page -- see dayNamePRe --
+// which must not match instead), and Colour comes from the page's "Kolor
+// szat: <word>" line, mapped via modernColours (case-insensitive; unknown
+// word -> ""). Season is always "" -- the modern lectionary folds its
+// temporal context into Name on temporal days rather than carrying it
+// separately. A page whose markup doesn't match either pattern (a layout
+// change, or a fixture with neither) yields a zero DayInfo, never an error:
+// the readings are the load-bearing content, the header is a nice-to-have.
+func ParseDayInfo(pageHTML string) DayInfo {
+ var info DayInfo
+
+ for _, m := range dayNamePRe.FindAllStringSubmatch(pageHTML, -1) {
+ class := m[1]
+ if strings.Contains(class, "fw-bold") && strings.Contains(class, "color-") {
+ info.Name = strings.Join(htmlToLines(m[2]), " ")
+ break
+ }
+ }
+
+ if m := dayColourRe.FindStringSubmatch(pageHTML); m != nil {
+ info.Colour = modernColours[strings.ToLower(m[1])]
+ }
+
+ return info
+}
+
// partID assigns the stable liturgical-part identifier for a section heading.
// A second "1. czytanie" heading on the same day (a split feast offering two
// alternative first readings) becomes "drugie_czytanie" instead of colliding
diff --git a/internal/liturgy/parse_test.go b/internal/liturgy/parse_test.go
index 13732f9..8fe2eb0 100644
--- a/internal/liturgy/parse_test.go
+++ b/internal/liturgy/parse_test.go
@@ -56,6 +56,59 @@ func TestParse(t *testing.T) {
}
}
+// TestParseDayInfo checks the modern (niedziela.pl) day-info extraction
+// against the split-feast fixture: Name comes from the color-classed,
+// fw-bold <p><em>...</em></p> near id="dzien" (not the earlier, plain
+// fw-bold lookalike higher up the page), and Colour from "Kolor szat:
+// biały" -> "white". Season is always "" for the modern lectionary.
+func TestParseDayInfo(t *testing.T) {
+ html, err := os.ReadFile("testdata/2026-07-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := ParseDayInfo(string(html))
+ if !strings.Contains(info.Name, "Marii Magdaleny") {
+ t.Errorf("Name = %q, want it to contain %q", info.Name, "Marii Magdaleny")
+ }
+ if info.Colour != "white" {
+ t.Errorf("Colour = %q, want %q", info.Colour, "white")
+ }
+ if info.Season != "" {
+ t.Errorf("Season = %q, want empty for the modern lectionary", info.Season)
+ }
+}
+
+// TestParseDayInfoMultilineName checks the day name is cleanly joined when
+// the source <em> body itself carries an embedded line break (a long
+// commemoration name wrapped across lines in the page's own markup), and
+// that a multi-option colour line ("zielony albo biały albo czerwony")
+// maps by its first word.
+func TestParseDayInfoMultilineName(t *testing.T) {
+ html, err := os.ReadFile("testdata/2026-06-22.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := ParseDayInfo(string(html))
+ if !strings.Contains(info.Name, "Dzień Powszedni") || !strings.Contains(info.Name, "Jana Fishera") {
+ t.Errorf("Name = %q, want it to contain both wrapped-line fragments", info.Name)
+ }
+ if strings.Contains(info.Name, "\n") {
+ t.Errorf("Name = %q, should not contain a raw newline", info.Name)
+ }
+ if info.Colour != "green" {
+ t.Errorf("Colour = %q, want %q (first of \"zielony albo...\")", info.Colour, "green")
+ }
+}
+
+// TestParseDayInfoNoMatch checks an unrecognised page shape yields a zero
+// DayInfo rather than an error -- the header is simply omitted by callers.
+func TestParseDayInfoNoMatch(t *testing.T) {
+ info := ParseDayInfo("<html><body>redesigned</body></html>")
+ if info != (DayInfo{}) {
+ t.Errorf("ParseDayInfo(unrecognised) = %+v, want zero value", info)
+ }
+}
+
func TestParseLayoutChange(t *testing.T) {
if _, err := Parse("<html><body>redesigned</body></html>"); err == nil {
t.Error("expected error on missing reading tab")
diff --git a/internal/liturgy/section.go b/internal/liturgy/section.go
index 498b201..cacefb0 100644
--- a/internal/liturgy/section.go
+++ b/internal/liturgy/section.go
@@ -10,3 +10,22 @@ type Section struct {
Heading, Subtitle, Citation, PartID string
Paragraphs [][]string
}
+
+// DayInfo is the day's liturgical identity, source-language (Polish for the
+// modern niedziela.pl lectionary, English/Latin for the traditional
+// missalemeum) -- never translated, like Section's own Heading/Citation.
+// A source that carries no such data (or an unrecognised page/response
+// shape) yields a zero DayInfo; callers must treat that as "omit the
+// header", never as an error.
+type DayInfo struct {
+ // Name is the celebration, e.g. "Święto św. Marii Magdaleny" (modern) or
+ // "St. Mary Magdalene" (traditional).
+ Name string
+ // Season is the temporal context, e.g. "Feria IV after VIII Sunday
+ // after Pentecost" (traditional); always "" for the modern lectionary,
+ // whose temporal is folded into Name on temporal days.
+ Season string
+ // Colour is the normalized liturgical colour: "white", "green",
+ // "violet", "red", "rose", or "" when unknown/unmapped.
+ Colour string
+}
diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go
index 69e45f2..5bf7ef0 100644
--- a/internal/liturgy/store.go
+++ b/internal/liturgy/store.go
@@ -170,7 +170,7 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro
if publishedRe.MatchString(page) {
if mkErr := os.MkdirAll(dir, 0o755); mkErr == nil {
_ = os.WriteFile(filepath.Join(dir, dateStr+".html"), []byte(page), 0o644)
- writeJSONCache(filepath.Join(dir, dateStr+".json"), secs)
+ writeCache(filepath.Join(dir, dateStr+".json"), secs, ParseDayInfo(page))
}
}