From c9f3bf46f0de09edb796e35f3dcff349febf75c9 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 10:32:28 +0200 Subject: dayinfo: show feast/day name + colour (modern niedziela + traditional missalemeum) in cli/tui/web; v0.5.0 --- internal/liturgy/fetch.go | 84 ++++++++++++++++++++++++++---------------- internal/liturgy/fetch_test.go | 16 ++++++-- internal/liturgy/parse.go | 53 ++++++++++++++++++++++++++ internal/liturgy/parse_test.go | 53 ++++++++++++++++++++++++++ internal/liturgy/section.go | 19 ++++++++++ internal/liturgy/store.go | 2 +- 6 files changed, 190 insertions(+), 37 deletions(-) (limited to 'internal/liturgy') 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)

(.*?)

`) pRe = regexp.MustCompile(`(?s)

(.*?)

`) citationRe = regexp.MustCompile(`\((.+)\)\s*$`) + + // dayNamePRe matches every classed

...

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)

\s*(.*?)\s*

`) + + // dayColourRe matches niedziela.pl's "Kolor szat: " vestment-colour + // line, tolerating the / 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. `
`. 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

NAME

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: " 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

...

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 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("redesigned") + if info != (DayInfo{}) { + t.Errorf("ParseDayInfo(unrecognised) = %+v, want zero value", info) + } +} + func TestParseLayoutChange(t *testing.T) { if _, err := Parse("redesigned"); 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)) } } -- cgit v1.3