aboutsummaryrefslogtreecommitdiff
path: root/internal/readings/offline.go
blob: 546d3b0b102a84352d8c35566aa633ad5df48747 (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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
package readings

import (
	"fmt"
	"time"

	"github.com/lukaszkasprzak/lectio/internal/bible"
	"github.com/lukaszkasprzak/lectio/internal/caldata"
	"github.com/lukaszkasprzak/lectio/internal/calendar"
	"github.com/lukaszkasprzak/lectio/internal/config"
	"github.com/lukaszkasprzak/lectio/internal/i18n"
	"github.com/lukaszkasprzak/lectio/internal/liturgy"
	"github.com/lukaszkasprzak/lectio/internal/naming"
)

// Prepared holds the date-independent setup offlineLoad otherwise redoes on
// every call: the stacked calendar layers (caldata.Stack) and the book table
// (bible.LoadBookTable). Both depend only on cfg -- never on the date -- so a
// caller resolving many dates against the same cfg (mobile.Days's 7-day loop,
// an eventual month view) should build one Prepared with Prepare and reuse it
// via LoadWith for every date, instead of paying Stack's INI parsing and
// LoadBookTable's file read + parse once per date. See Prepare and LoadWith.
type Prepared struct {
	layers []calendar.Layer
	tbl    *bible.BookTable
}

// Prepare builds a Prepared for cfg: the layer stack for cfg.Selection().Form
// stacked with cfg.Use (caldata.Stack), and the book table for the user's
// books.ini override, if any (bible.LoadBookTable). Both calls already
// tolerate their own failure (Stack falls back to the embedded calendar on a
// bad user layer; LoadBookTable falls back to the embedded book table on a
// bad user override) exactly as offlineLoad always has -- Prepare changes
// only when this work happens, never what it computes or how it degrades.
func Prepare(cfg config.Config) Prepared {
	sel := cfg.Selection()
	dir, _ := config.CalendarsDir()
	layers, _ := caldata.Stack(sel.Form, dir, cfg.Use)   // Stack falls back to embedded data on error
	tbl, _ := bible.LoadBookTable(config.UserBooksINI()) // nil on error -> citations shown as authored
	return Prepared{layers: layers, tbl: tbl}
}

// offlineLoad resolves a day's readings entirely from the embedded calendar
// engine and lectionary data -- no network. It returns the same source-agnostic
// liturgy.Section / liturgy.DayInfo the CLI/TUI/web already render, so the daily
// view is unchanged apart from where its data comes from. Citations are
// lectio's English-canonical authored form; the render localises each one to
// the chosen corpus's Psalter and the user's sigla dialect (see
// render.GatherVersion, bible.OFRef).
//
// offlineLoad is Prepare(cfg) followed by offlineLoadWith -- a single call's
// worth of convenience for Load, which has no date to amortize Prepare's cost
// over. A caller with several dates should call Prepare once and use
// offlineLoadWith/LoadWith directly instead (see Prepared's doc comment).
func offlineLoad(cfg config.Config, date string) ([]liturgy.Section, liturgy.DayInfo, error) {
	return offlineLoadWith(Prepare(cfg), cfg, date)
}

// offlineLoadWith is offlineLoad, given an already-built Prepared instead of
// building its own. Computing the day itself (calendar.Compute, the readings
// it resolves) still happens once per call, exactly as before -- only the
// layer stack and book table are reused.
func offlineLoadWith(p Prepared, cfg config.Config, date string) ([]liturgy.Section, liturgy.DayInfo, error) {
	d, err := time.Parse("2006-01-02", date)
	if err != nil {
		return nil, liturgy.DayInfo{}, fmt.Errorf("bad date %q (want YYYY-MM-DD)", date)
	}
	sel := cfg.Selection()
	day := calendar.Compute(d.UTC(), sel, p.layers)
	rs := caldata.Readings(sel, p.layers, d.UTC(), day)
	return sectionsFor(rs, sel.Form, cfg.UILanguage, cfg.SiglaLang(), p.tbl), dayInfo(cfg, day), nil
}

// citationForms renders a reading's authored (English) citation into its
// display form (the reader's sigla dialect) and its English-canonical lookup
// reference. When the book table is missing or cannot parse the citation, the
// authored form is used verbatim for both.
func citationForms(raw, siglaLang string, tbl *bible.BookTable) (display, ref string) {
	if tbl == nil {
		return raw, raw
	}
	canonical, ok := tbl.ParseRef("en", raw)
	if !ok {
		return raw, raw
	}
	return tbl.FormatRef(siglaLang, canonical), canonical
}

// ofPart maps a computed reading Part to the modern-lectionary section id and
// its Polish heading label. The heading is always the Polish label: the render
// (render.LocalizeHeading) rewrites it to English for an English UI, mirroring
// how the niedziela sections were shaped.
var ofPart = map[string]struct{ id, heading string }{
	"first":       {"pierwsze_czytanie", "1. czytanie"},
	"psalm":       {"psalm", "Psalm"},
	"second":      {"drugie_czytanie", "2. czytanie"},
	"acclamation": {"aklamacja", "Aklamacja"},
	"gospel":      {"ewangelia", "Ewangelia"},
}

// efPartHeading gives the traditional (1962) section's ID and heading per UI
// language; the EF has only an epistle/lesson and a gospel. The label words are
// i18n data, like the modern ones. Unlike the OF headings these are not
// translated downstream (render.LocalizeHeading only handles modern IDs), so
// they are resolved in the target language here.
func efPartHeading(part, lang string) (id, heading string) {
	switch part {
	case "first":
		id = "epistola"
	case "gospel":
		id = "evangelium"
	default:
		return "", ""
	}
	heading = i18n.Get(lang).PartLabel[id]
	if heading == "" {
		heading = id
	}
	return id, heading
}

// ofPartOrder and efPartOrder are the display orders of each lectionary's
// sections. They are the single source of truth for which part IDs exist.
//
// ofPartOrder's five modern IDs must remain the same *set* as
// internal/render/render.go's modernPartOrder, which lists them in a
// different, deliberate order (prefix-match determinism for
// render.LocalizeHeading, unrelated to display order). Nothing enforces
// that agreement mechanically -- internal/readings and internal/render do
// not import each other (adding a cross-package test would create a new
// dependency edge that does not exist today) -- so if a sixth modern part
// is ever added here, add it to modernPartOrder too, by hand.
var (
	ofPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"}
	efPartOrder = []string{"epistola", "evangelium"}
)

// ofEmittedPartOrder is the subset of ofPartOrder the offline engine can
// actually produce, in display order. It excludes "aklamacja":
// internal/caldata/caldata.go:42 parses only "first", "psalm", "second" and
// "gospel" out of the lectionary data, so no computed OF reading ever carries
// Part == "acclamation" and ofPart's "aklamacja" mapping above is never
// reached. ofPartOrder stays the full five-ID set on purpose -- it also
// drives render.LocalizeHeading's *label* matching, where a scraped heading
// can still read "Aklamacja" even though this engine's own readings never
// produce that section -- so PartIDs, which promises IDs an app can filter
// on, needs this narrower list rather than reusing or shrinking ofPartOrder.
var ofEmittedPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "ewangelia"}

// PartIDs returns the part IDs the given lectionary can emit, in display order.
// lect takes config.Config.Lectionary's values: "new" or "traditional". An
// unknown lectionary returns nil. Callers that build per-part UI (the dlectio
// app's reading filters) must derive their list from this rather than
// hardcoding IDs.
func PartIDs(lect string) []string {
	switch lect {
	case "new":
		return append([]string(nil), ofEmittedPartOrder...)
	case "traditional":
		return append([]string(nil), efPartOrder...)
	}
	return nil
}

// sectionsFor turns computed readings into render-ready sections, tagging each
// with the section id and heading its form expects and rendering its citation
// into display (sigla dialect) and lookup (English-canonical) forms.
func sectionsFor(rs []calendar.Reading, form, lang, siglaLang string, tbl *bible.BookTable) []liturgy.Section {
	var out []liturgy.Section
	for _, r := range rs {
		if r.Citation == "" {
			continue
		}
		var id, heading string
		if form == "old" {
			id, heading = efPartHeading(r.Part, lang)
		} else if p, ok := ofPart[r.Part]; ok {
			id, heading = p.id, p.heading
		}
		if id == "" {
			continue // an unknown part carries no section
		}
		display, ref := citationForms(r.Citation, siglaLang, tbl)
		out = append(out, liturgy.Section{
			PartID:   id,
			Heading:  heading,
			Citation: display,
			Ref:      ref,
		})
	}
	return out
}

// dayInfo builds the header (celebration name, liturgical colour, rank) for
// the computed day. Season is left empty: the celebration name already
// carries the temporal identity for temporal days, and the header is a
// nice-to-have.
func dayInfo(cfg config.Config, day calendar.LiturgicalDay) liturgy.DayInfo {
	return liturgy.DayInfo{
		Name:   celebrationName(cfg, day.Observed),
		Colour: string(day.Colour),
		Rank:   displayRank(cfg, day),
	}
}

// displayRank is the Ordinary Form's REPORTED rank for the observed
// celebration -- it never changes calendar.Celebration.Rank (the engine's own
// field, which drives precedence via ofRankOrder); it only relabels what is
// handed to a caller here, after the engine has already finished computing.
//
// In the 1969 Universal Norms' Table of Liturgical Days, Sunday is its own
// category, not a solemnity. The calendar engine's temporal.go builds every
// "Nth Sunday of <season>" day (Ordinary Time, Advent, Lent, the Easter
// season, Christmas time, and Palm Sunday) with sundayDay(), which sets
// Rank=solemnity but leaves Class at its zero value (calendar.ClassNone) --
// solemnity is a placeholder there, not a real classification. A genuinely
// NAMED solemnity of the Lord that happens to fall on a Sunday (Easter Sunday
// itself, Pentecost, Ascension/Corpus Christi when transferred, Trinity,
// Christ the King, Christmas, Epiphany) is built by solemn(), which does set
// Class=ClassLord, and must keep reporting "solemnity"; a feast of the Lord
// (Holy Family, Baptism of the Lord) is Rank=feast already and is untouched.
//
// Investigated first: temporal.go's own Sunday bool (sundayDay's
// `Sunday: !priv`) looked like the natural signal, but it does not reach
// calendar.LiturgicalDay at all (LiturgicalDay carries no such field, only
// the aggregate ObservedBand), and even if plumbed through it would be the
// wrong signal here -- by its own doc comment it marks only the band-6
// Christmas-time/Ordinary-time Sundays, not the band-2 Advent/Lent/Easter
// Sundays this change must ALSO relabel (the 1st Sunday of Advent from the
// original bug report is band 2, Privileged=true, Sunday=false). Using
// Celebration.Class instead of that flag, or of temporalDay.Privileged,
// covers exactly the sundayDay()-built set in both bands without adding any
// new field: RankSolemnity + Layer=="temporal" + Class!=ClassLord occurs only
// from sundayDay(), and (checked against every call site in temporal.go) only
// ever on an actual Sunday, so the weekday check below is defensive, not
// load-bearing.
//
// The Extraordinary Form (1962) has no such category and is untouched: this
// only ever fires when the modern form is selected.
func displayRank(cfg config.Config, day calendar.LiturgicalDay) string {
	obs := day.Observed
	if cfg.Selection().Form != "old" &&
		day.Weekday == time.Sunday &&
		obs.Layer == "temporal" &&
		obs.Rank == calendar.RankSolemnity &&
		obs.Class != calendar.ClassLord {
		return string(calendar.RankSunday)
	}
	return string(obs.Rank)
}

// celebrationName is the observed celebration's name in the UI language,
// resolved by naming.CelebrationName (name.<lang> -> English -> Latin ->
// humanized slug). An empty result (unnamed feria) omits the header line.
func celebrationName(cfg config.Config, c calendar.Celebration) string {
	return naming.CelebrationName(cfg.UILanguage, c)
}