aboutsummaryrefslogtreecommitdiff
path: root/internal/tradlit/tradlit.go
blob: a7b53bca56a3017e5356dd485bbf08e19579ac88 (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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
// Package tradlit fetches and parses the Traditional (1962 Missal) Latin
// propers from the missalemeum JSON API into the shared liturgy.Section
// type, so the rest of the app can treat the traditional and modern
// (niedziela.pl) lectionaries interchangeably.
package tradlit

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/lukaszkasprzak/lectio/internal/liturgy"
)

// baseURL is the missalemeum proper-of-the-day API template ("%s" is lang,
// then date, YYYY-MM-DD). It is a package var so tests can point it at an
// httptest server.
var baseURL = "https://www.missalemeum.com/%s/api/v5/proper/%s"

// userAgent is sent on every fetch; matches the browser UA used elsewhere
// in this project (see the plan's Global Constraints).
const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " +
	"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"

// citationRe matches the first *...* marker in a section's body text, e.g.
// "*Luke 7:36-50*" or "*Ps 44:2*".
var citationRe = regexp.MustCompile(`\*([^*\n]+)\*`)

// apiResponse mirrors the shape of the missalemeum proper-of-the-day API:
// a single-element list of { info, sections }.
type apiResponse struct {
	Info struct {
		Title string `json:"title"`
	} `json:"info"`
	Sections []apiSection `json:"sections"`
}

type apiSection struct {
	ID    string     `json:"id"`
	Label string     `json:"label"`
	Body  [][]string `json:"body"`
}

// Parse decodes a missalemeum proper-of-the-day API response body into
// liturgy.Sections. Sections with an empty id or empty body are skipped.
func Parse(jsonBody []byte) ([]liturgy.Section, error) {
	var resp []apiResponse
	if err := json.Unmarshal(jsonBody, &resp); err != nil {
		return nil, fmt.Errorf("tradlit: parse: %w", err)
	}
	if len(resp) == 0 {
		return nil, fmt.Errorf("tradlit: parse: empty response")
	}

	var out []liturgy.Section
	for _, sec := range resp[0].Sections {
		if sec.ID == "" || len(sec.Body) == 0 || len(sec.Body[0]) == 0 {
			continue
		}

		text := strings.Join(sec.Body[0], "\n")

		var lines []string
		for _, line := range strings.Split(text, "\n") {
			line = strings.TrimSpace(line)
			if line != "" {
				lines = append(lines, line)
			}
		}

		citation := ""
		if m := citationRe.FindStringSubmatch(text); m != nil {
			citation = strings.TrimSpace(m[1])
		}

		out = append(out, liturgy.Section{
			Heading:    sec.Label,
			Citation:   citation,
			PartID:     strings.ToLower(sec.ID),
			Paragraphs: [][]string{lines},
		})
	}
	return out, nil
}

// cachePath returns where Load caches a (date, lang) day's raw API response:
// <CacheDir>/<date>.trad.<lang>.json -- date-prefixed (like the modern
// lectionary's own cache files) so liturgy.CleanCache can prune it by date.
func cachePath(date, lang string) string {
	return filepath.Join(liturgy.CacheDir(), date+".trad."+lang+".json")
}

// Load returns a day's traditional propers for lang, either read from the
// on-disk cache (offline) or fetched live from missalemeum and cached for
// next time (online). Both paths share Parse, so cached and live results
// are identical.
//
//   - offline: reads the cache file written by a prior online Load (see
//     cachePath); if it doesn't exist, returns a clear error telling the
//     caller to go online or run 'lectio update' first.
//   - online: fetches https://www.missalemeum.com/{lang}/api/v5/proper/{date}
//     as before. On a successful 200, the raw response body is written to
//     the cache path (best-effort -- a cache-write failure never fails the
//     request) before being parsed. On HTTP 404 (no propers published for
//     that date) it returns a clear error and writes nothing to the cache.
func Load(date, lang string, offline bool) ([]liturgy.Section, error) {
	if offline {
		return loadCached(date, lang)
	}
	return loadLive(date, lang)
}

// loadCached implements Load's offline path.
func loadCached(date, lang string) ([]liturgy.Section, error) {
	body, err := os.ReadFile(cachePath(date, lang))
	if err != nil {
		return nil, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang)
	}
	return Parse(body)
}

// loadLive implements Load's online path: fetch, best-effort cache the raw
// body, then parse.
func loadLive(date, lang string) ([]liturgy.Section, error) {
	body, err := fetch(date, lang)
	if err != nil {
		return nil, err
	}
	writeCache(date, lang, body)
	return Parse(body)
}

// fetch GETs the day's proper-of-the-day JSON from missalemeum and returns
// the raw response body. On HTTP 404 (no propers published for that date)
// it returns a clear error rather than the raw 404 body.
func fetch(date, lang string) ([]byte, error) {
	url := fmt.Sprintf(baseURL, lang, date)

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("tradlit: load: %w", err)
	}
	req.Header.Set("User-Agent", userAgent)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("tradlit: load %s: %w", date, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return nil, fmt.Errorf("tradlit: no propers published for %s", date)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("tradlit: load %s: unexpected status %s", date, resp.Status)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("tradlit: load %s: %w", date, err)
	}
	return body, nil
}

// writeCache best-effort writes a day's raw API response body to its cache
// path; a failure to cache (e.g. an unwritable cache dir) must never fail
// the live request that produced body.
func writeCache(date, lang string, body []byte) {
	path := cachePath(date, lang)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return
	}
	_ = os.WriteFile(path, body, 0o644)
}