aboutsummaryrefslogtreecommitdiff
path: root/internal/liturgy/parse.go
blob: 64e6928997203fb80deb46bbfbb111dfd1b3a373 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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)<br\s*/?>`)
	tagRe      = regexp.MustCompile(`<[^>]+>`)
	wsRe       = regexp.MustCompile(`[ \t]+`)
	h2Re       = regexp.MustCompile(`(?s)<h2>(.*?)</h2>`)
	h4Re       = regexp.MustCompile(`(?s)<h4>(.*?)</h4>`)
	pRe        = regexp.MustCompile(`(?s)<p>(.*?)</p>`)
	citationRe = regexp.MustCompile(`\((.+)\)\s*$`)
)

// 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 {
	return regexp.MustCompile(`<div class="tab-pane[^"]*"\s+id="` + regexp.QuoteMeta(tab) + `">`)
}

// htmlToLines turns an HTML fragment into a list of non-empty text lines.
// <br> 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, `<div class="tab-pane`); nxt != -1 {
		block = rest[:nxt]
	}

	heads := h2Re.FindAllStringSubmatchIndex(block, -1)
	var sections []Section
	czytanieCount := 0
	for i, h := range heads {
		bodyEnd := len(block)
		if i+1 < len(heads) {
			bodyEnd = heads[i+1][0]
		}
		body := block[h[1]:bodyEnd]

		heading := strings.Join(htmlToLines(block[h[2]:h[3]]), " ")

		subtitle := ""
		if sub := h4Re.FindStringSubmatch(body); sub != nil {
			subtitle = strings.Join(htmlToLines(sub[1]), " ")
		}

		var paragraphs [][]string
		for _, p := range pRe.FindAllStringSubmatch(body, -1) {
			if lines := htmlToLines(p[1]); len(lines) > 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
}

// 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
}