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
|
package liturgy
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// harvestRetries is how many times Harvest attempts a single date's fetch
// before treating it as a genuine network failure rather than transient
// hiccup; harvestRetryDelay is the backoff slept between attempts. Both are
// package vars so tests can shrink the delay instead of waiting on it.
var (
harvestRetries = 3
harvestRetryDelay = 2 * time.Second
)
// siglaRow is one line of the sigla TSV: a date's section label and the
// scripture citation extracted from its heading.
type siglaRow struct {
label, citation string
}
// siglaPath is the persistent sigla store written by Harvest and read by
// LoadOffline: ${XDG_DATA_HOME:-~/.local/share}/lectio/sigla.tsv
func siglaPath() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "lectio", "sigla.tsv")
}
// sectionLabel derives the short label Harvest stores alongside a citation
// from a section's full heading, e.g. "Ewangelia (J 20, 1. 11-18)" ->
// "Ewangelia". It strips the same trailing "(...)" citation ExtractCitation
// reads, so the two stay in sync.
func sectionLabel(heading string) string {
loc := citationRe.FindStringIndex(heading)
if loc == nil {
return strings.TrimSpace(heading)
}
return strings.TrimSpace(heading[:loc[0]])
}
// readSigla loads the sigla TSV into date -> rows. A missing file is not an
// error -- it just means nothing has been harvested yet.
func readSigla(path string) (map[string][]siglaRow, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string][]siglaRow{}, nil
}
return nil, err
}
rows := map[string][]siglaRow{}
for _, line := range strings.Split(string(data), "\n") {
if line == "" {
continue
}
fields := strings.SplitN(line, "\t", 3)
if len(fields) != 3 {
continue
}
date := fields[0]
rows[date] = append(rows[date], siglaRow{label: fields[1], citation: fields[2]})
}
return rows, nil
}
// writeSigla writes date -> rows back out as the sigla TSV, sorted by date
// for a deterministic, diffable file.
func writeSigla(path string, byDate map[string][]siglaRow) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
dates := make([]string, 0, len(byDate))
for d := range byDate {
dates = append(dates, d)
}
sort.Strings(dates)
var b strings.Builder
for _, date := range dates {
for _, r := range byDate[date] {
fmt.Fprintf(&b, "%s\t%s\t%s\n", date, r.label, r.citation)
}
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// Harvest walks dates forward from fromDate, fetching and parsing each day's
// page and recording every section's citation to the sigla TSV, for up to
// maxDays days (0 = walk until the unpublished horizon). It stops cleanly
// (nil error) the first time a date fails to PARSE -- niedziela.pl's
// "Przykro nam" placeholder (or any other parse failure) marks the horizon
// the site hasn't published past yet, not an error to report. A date that
// fails to FETCH, by contrast, is a transient network problem, not the
// horizon: Harvest retries it a few times (harvestRetries, backing off
// harvestRetryDelay between attempts) and, if it still fails, stops and
// returns an error -- but only after saving whatever was harvested up to
// that point, so the caller never loses progress to a blip.
//
// Re-harvesting a date replaces its rows in the TSV rather than duplicating
// them, so running Harvest again over an already-harvested range is safe.
// It also warms the HTML/JSON cache for every date it successfully harvests.
//
// It returns how many days were harvested and the furthest (most recent)
// date reached, alongside any fetch error (nil on a clean parse-horizon
// stop or on reaching maxDays).
func Harvest(fromDate string, maxDays int) (added int, furthest string, err error) {
start, err := time.Parse("2006-01-02", fromDate)
if err != nil {
return 0, "", fmt.Errorf("invalid date %q: %w", fromDate, err)
}
path := siglaPath()
byDate, err := readSigla(path)
if err != nil {
return 0, "", err
}
dir := cacheDir()
day := start
var harvestErr error
for i := 0; maxDays == 0 || i < maxDays; i++ {
dateStr := day.Format("2006-01-02")
var page string
var ferr error
for attempt := 1; attempt <= harvestRetries; attempt++ {
page, ferr = fetch(dateStr)
if ferr == nil {
break
}
if attempt < harvestRetries {
time.Sleep(harvestRetryDelay)
}
}
if ferr != nil {
// A genuine network/transport error, not the horizon: don't
// silently stop as if the site simply hadn't published this
// date yet. Record it and stop walking, but writeSigla below
// still runs so progress made so far isn't lost.
harvestErr = fmt.Errorf("harvest interrupted at %s: %w", dateStr, ferr)
break
}
secs, perr := Parse(page)
if perr != nil {
break // unpublished horizon (or unparsable page): stop walking, cleanly
}
var rows []siglaRow
for _, s := range secs {
citation, cerr := ExtractCitation(s.Heading)
if cerr != nil {
continue
}
rows = append(rows, siglaRow{label: sectionLabel(s.Heading), citation: citation})
}
byDate[dateStr] = rows
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)
}
}
added++
furthest = dateStr
day = day.AddDate(0, 0, 1)
}
werr := writeSigla(path, byDate)
if harvestErr != nil {
return added, furthest, harvestErr
}
if werr != nil {
return added, furthest, werr
}
return added, furthest, nil
}
// LoadOffline builds a day's sections purely from the harvested sigla TSV:
// Heading is the stored section label, Citation the stored citation, and
// Paragraphs empty (no reading text is harvested, only the scripture
// reference). It errors clearly if the date has not been harvested.
func LoadOffline(date string) ([]Section, error) {
byDate, err := readSigla(siglaPath())
if err != nil {
return nil, err
}
rows, ok := byDate[date]
if !ok || len(rows) == 0 {
return nil, fmt.Errorf("%s not harvested; run 'lectio update' while online first", date)
}
secs := make([]Section, 0, len(rows))
czytanieCount := 0
for _, r := range rows {
secs = append(secs, Section{
Heading: r.label,
Citation: r.citation,
PartID: partID(r.label, &czytanieCount),
})
}
return secs, nil
}
|