aboutsummaryrefslogtreecommitdiff
path: root/internal/liturgy/store.go
blob: 6762585b6e5ac00ea814a75e1ad46ed84c6caa24 (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
package liturgy

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// 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 the first
// time a date fails to fetch or 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.
//
// 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.
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
	for i := 0; maxDays == 0 || i < maxDays; i++ {
		dateStr := day.Format("2006-01-02")

		page, ferr := fetch(dateStr)
		if ferr != nil {
			break // unreachable site or network error: stop, not a hard failure
		}
		secs, perr := Parse(page)
		if perr != nil {
			break // unpublished horizon (or unparsable page): stop walking
		}

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

	if err := writeSigla(path, byDate); err != nil {
		return added, furthest, err
	}
	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
}