package liturgy import ( "fmt" "html" "regexp" "strings" ) // lectionaryTabs lists the two reading sets carried on the page: the // lectionary in force since Advent 2015 ("nowy") and the one it replaced // ("stary"). They are not interchangeable -- the acclamation can cite a // different book entirely, and the older set contains malformed citations. // Pick by id, never by position in the markup, or a reordering silently // switches edition. var lectionaryTabs = []string{"tabnowy0all", "tabstary0all"} var ( brRe = regexp.MustCompile(`(?i)`) tagRe = regexp.MustCompile(`<[^>]+>`) wsRe = regexp.MustCompile(`[ \t]+`) h2Re = regexp.MustCompile(`(?s)

(.*?)

`) 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 { return regexp.MustCompile(`
`) } // htmlToLines turns an HTML fragment into a list of non-empty text lines. //
marks a verse line break (used in psalms/acclamations); other tags // are dropped, entities decoded, and intra-line whitespace collapsed. func htmlToLines(fragment string) []string { fragment = brRe.ReplaceAllString(fragment, "\n") fragment = tagRe.ReplaceAllString(fragment, "") text := html.UnescapeString(fragment) var lines []string for _, ln := range strings.Split(text, "\n") { ln = strings.TrimSpace(wsRe.ReplaceAllString(ln, " ")) if ln != "" { lines = append(lines, ln) } } return lines } // Parse extracts every reading section from the page's preferred lectionary // tab (falling back to the superseded one), erroring loudly if neither tab is // present on the page, or the tab is found but carries no sections -- a // layout change should never be mistaken for a quiet day with no readings. func Parse(pageHTML string) ([]Section, error) { var loc []int for _, tab := range lectionaryTabs { if m := panePattern(tab).FindStringIndex(pageHTML); m != nil { loc = m break } } if loc == nil { if strings.Contains(pageHTML, "Przykro nam") { return nil, fmt.Errorf("no reading published for this date yet") } return nil, fmt.Errorf( "no reading tab (%s) found on page -- the site layout may have changed", strings.Join(lectionaryTabs, "/"), ) } rest := pageHTML[loc[1]:] block := rest if nxt := strings.Index(rest, `
0 { paragraphs = append(paragraphs, lines) } } // A heading is expected to always carry a parenthetical citation; // if one is somehow missing, leave Citation empty rather than // failing the whole parse over one section. citation, _ := ExtractCitation(heading) sections = append(sections, Section{ Heading: heading, Subtitle: subtitle, Citation: citation, PartID: partID(heading, &czytanieCount), Paragraphs: paragraphs, }) } // A layout change can leave the tab findable but empty; say so rather // than returning nothing and looking like a quiet day. if len(sections) == 0 { return nil, fmt.Errorf("reading tab found but no sections in it -- the site layout may have changed") } 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 // with the first ("pierwsze_czytanie"). Anything unrecognised is "". func partID(heading string, czytanieCount *int) string { switch { case strings.HasPrefix(heading, "1. czytanie"): *czytanieCount++ if *czytanieCount == 1 { return "pierwsze_czytanie" } return "drugie_czytanie" case strings.HasPrefix(heading, "Psalm"): return "psalm" case strings.HasPrefix(heading, "Aklamacja"): return "aklamacja" case strings.HasPrefix(heading, "Ewangelia"): return "ewangelia" default: return "" } } // ExtractCitation pulls the citation from a section heading: // "Ewangelia (Mt 7, 1-5)" -> "Mt 7, 1-5". func ExtractCitation(heading string) (string, error) { m := citationRe.FindStringSubmatch(heading) if m == nil { return "", fmt.Errorf("no reference found in heading: %q", heading) } return strings.TrimSpace(m[1]), nil }