aboutsummaryrefslogtreecommitdiff
path: root/internal/liturgy/fetch.go
blob: 8c887f443c728d0ea532ed5b740a0788b3ca298b (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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package liturgy

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

// baseURL is the niedziela.pl URL template ("%s" is the date, YYYY-MM-DD).
// It is a package var so tests can point it at an httptest server.
var baseURL = "https://niezbednik.niedziela.pl/liturgia/%s/Ewangelia"

// SetBaseURL overrides the fetch URL template used by Load. It exists so
// tests in other packages (e.g. internal/readings) can point Load at an
// httptest server; production code must never call it.
func SetBaseURL(url string) {
	baseURL = url
}

// userAgent is sent on every fetch; the site serves a different (broken)
// page to non-browser clients without it.
const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " +
	"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"

// publishedRe matches the tab-pane id niedziela.pl gives a fully published
// day's page (e.g. "tabnowy0all"). Its absence -- together with a "Przykro
// nam" placeholder -- marks a date that has not been published yet; see
// ewangelia.py's fetch() for the original behaviour this mirrors.
var publishedRe = regexp.MustCompile(`id="\w*0all"`)

// dateRe is the same YYYY-MM-DD shape internal/cli's dateRe validates
// against. Load checks opts.Date against it before building any filesystem
// path (jsonPath/htmlPath below are built by string concatenation, so an
// unvalidated Date is a path-traversal vector) -- defense-in-depth so every
// caller (web, cli, tui) is protected even if a future caller forgets to
// validate its own input first.
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)

// cacheFileRe matches the date-prefixed cache file names Load/Harvest/tradlit
// write into CacheDir(): "<YYYY-MM-DD>.html", "<YYYY-MM-DD>.json", and
// "<YYYY-MM-DD>.trad.<lang>.json" (the traditional-lectionary cache; see
// internal/tradlit). CleanCache uses it to tell cache entries apart from
// anything else that might be sitting in the directory, and to recover the
// date (group 1) for the age check regardless of which cache file it is.
var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.[a-z0-9.]+$`)

// Options controls how Load resolves a day's readings.
type Options struct {
	// Date is the day to load, formatted YYYY-MM-DD.
	Date string
	// Refresh bypasses both cache layers and re-fetches from the network.
	Refresh bool
	// Offline restricts Load to previously cached/harvested data (see
	// LoadOffline), never hitting the network.
	Offline bool
}

// CacheDir is where the HTML/JSON cache layers live:
// ${XDG_CACHE_HOME:-~/.cache}/lectio/
// Exported so internal/tradlit shares the same cache root for the
// traditional lectionary's propers.
func CacheDir() string {
	base := os.Getenv("XDG_CACHE_HOME")
	if base == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			home = "."
		}
		base = filepath.Join(home, ".cache")
	}
	return filepath.Join(base, "lectio")
}

// Load returns the day's reading sections, 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.
//  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) {
	if !dateRe.MatchString(opts.Date) {
		return nil, fmt.Errorf("invalid date %q: want YYYY-MM-DD", opts.Date)
	}

	if opts.Offline {
		return LoadOffline(opts.Date)
	}

	dir := CacheDir()
	jsonPath := filepath.Join(dir, opts.Date+".json")
	htmlPath := filepath.Join(dir, opts.Date+".html")

	if !opts.Refresh {
		if secs, err := loadJSONCache(jsonPath); err == nil {
			return secs, nil
		}
		if page, err := os.ReadFile(htmlPath); err == nil {
			secs, err := Parse(string(page))
			if err != nil {
				return nil, err
			}
			writeJSONCache(jsonPath, secs)
			return secs, 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 secs, offErr := LoadOffline(opts.Date); offErr == nil {
			return secs, nil
		}
		return nil, err
	}

	// Parse itself reports an unpublished date as "no reading published for
	// this date yet" (via the "Przykro nam" placeholder), so an unpublished
	// page is neither cached nor returned as sections here.
	secs, err := Parse(page)
	if err != nil {
		return nil, err
	}

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

	return secs, nil
}

// CleanCache removes cached readings whose date is before `before` from
// CacheDir(). It matches only date-prefixed cache files (see cacheFileRe:
// "<YYYY-MM-DD>.html", ".json", or the traditional lectionary's
// ".trad.<lang>.json"); anything else in the directory (e.g. a stray
// notes.txt, or the sigla store, which lives elsewhere entirely) is left
// alone. A missing cache dir is not an error -- it just means there is
// nothing to clean yet.
func CleanCache(before time.Time) (removed int, freed int64, err error) {
	dir := CacheDir()
	entries, err := os.ReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return 0, 0, nil
		}
		return 0, 0, err
	}

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		m := cacheFileRe.FindStringSubmatch(entry.Name())
		if m == nil {
			continue
		}
		date, perr := time.Parse("2006-01-02", m[1])
		if perr != nil || !date.Before(before) {
			continue
		}

		path := filepath.Join(dir, entry.Name())
		info, serr := os.Stat(path)
		if serr != nil {
			return removed, freed, serr
		}
		if rerr := os.Remove(path); rerr != nil {
			return removed, freed, rerr
		}
		removed++
		freed += info.Size()
	}
	return removed, freed, nil
}

// loadJSONCache reads and unmarshals the parsed-sections cache file.
func loadJSONCache(path string) ([]Section, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var secs []Section
	if err := json.Unmarshal(data, &secs); err != nil {
		return nil, err
	}
	return secs, 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)
	if err != nil {
		return
	}
	_ = os.WriteFile(path, data, 0o644)
}

// fetch GETs the day's page from baseURL with the browser User-Agent.
// Whether the page is actually published is left to the caller (Parse
// detects an unpublished date; Load re-checks the tab id to decide whether
// the result is cache-worthy).
func fetch(dateStr string) (string, error) {
	url := fmt.Sprintf(baseURL, dateStr)
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("User-Agent", userAgent)

	client := &http.Client{Timeout: 20 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("failed to fetch %s: %w", url, err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read response from %s: %w", url, err)
	}
	return string(body), nil
}