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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
|
//go:build ignore
// gen-sanctoral-ef generates the EF (Extraordinary Form, 1962) universal
// sanctoral (internal/caldata/tridentine-calendar.ini) from missalemeum's
// per-date proper API (built on Divinum Officium's 1962 data — the same oracle
// lectio's trad view and the EF regression test use).
//
// For each fixed calendar date it takes the sanctoral office missalemeum
// observes (info.id "sancti:MM-DD:RANK:COLOUR"), with its proper Epistle
// (Lectio) and Gospel (Evangelium) citations, plus any sancti co-celebrations
// listed as commemorations. Several reference years are tried per date so a
// saint whose date is a Sunday (or under a higher feast) in one year is still
// captured, observed with its own readings, from another. Existing Latin names
// in the file are preserved (missalemeum has no Latin titles).
//
// One-time; requires network. Run from the repo root:
//
// go run scripts/gen-sanctoral-ef.go
//
// Writes internal/caldata/tridentine-calendar.ini.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/lukaszkasprzak/lectio/internal/caldata"
"github.com/lukaszkasprzak/lectio/internal/calendar"
)
const ua = "Mozilla/5.0 (lectio EF sanctoral generator)"
// client with a hard timeout: missalemeum occasionally stalls a connection
// under concurrent load, and a timeout-less client would hang a worker forever.
var client = &http.Client{Timeout: 25 * time.Second}
// reference years: tried in order per date until the saint is observed (not
// occulted by a Sunday or higher feast). Six years guarantee every fixed date
// falls on a weekday in at least one of them.
var years = []int{2025, 2026, 2027, 2028, 2029, 2024}
// tempFeastSkip: fixed-date Lord's feasts that missalemeum files under sancti:
// but the EF temporal engine (temporalEF) already computes — excluded so they
// are not duplicated in the sanctoral layer.
var tempFeastSkip = map[string]bool{"01-01": true, "01-06": true, "12-25": true}
var (
citeRe = regexp.MustCompile(`\*([^*]+)\*`)
bookCommaRe = regexp.MustCompile(`^([^:]*?),\s+(\d+:)`)
chapDotRe = regexp.MustCompile(`(\d+)\.\s+(\d)`)
slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
)
// cleanCite fixes the two missalemeum citation glitches (verbatim from
// scripts/genlect.go): a stray comma after the book, and a European
// "chapter. verse" separator.
func cleanCite(s string) string {
s = bookCommaRe.ReplaceAllString(strings.TrimSpace(s), "$1 $2")
if !strings.Contains(s, ":") {
s = chapDotRe.ReplaceAllString(s, "$1:$2")
}
return s
}
func slugify(title string) string {
s := strings.ToLower(title)
s = strings.NewReplacer(
"ł", "l", "æ", "ae", "œ", "oe", "é", "e", "è", "e", "ô", "o", "ç", "c",
"ï", "i", "ë", "e", "ü", "u", "ö", "o", "á", "a", "à", "a",
).Replace(s)
s = slugStripRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
for _, p := range []string{"the-", "ss-", "st-", "s-"} {
s = strings.TrimPrefix(s, p)
}
return s
}
var colourWord = map[byte]string{
'w': "white", 'r': "red", 'g': "green",
'v': "violet", 'b': "black", 'p': "rose",
}
func colourOf(joined string) string {
if joined == "" {
return "white"
}
if c, ok := colourWord[joined[0]]; ok {
return c
}
return "white"
}
var rankWord = map[int]calendar.Rank{
1: calendar.RankClass1, 2: calendar.RankClass2,
3: calendar.RankClass3, 4: calendar.RankClass4,
}
func rankOf(n int) calendar.Rank {
if r, ok := rankWord[n]; ok {
return r
}
return calendar.RankClass4
}
// classOf marks the feasts of the Lord (class lord). A II class feast of the
// Lord takes the place of a Sunday of the same class (1960 occurrence rules) —
// e.g. the Purification (Presentation, Feb 2), the Exaltation of the Holy Cross
// (Sep 14), the Dedication of the Lateran (Nov 9). Saint/BVM feasts of the same
// class are only commemorated on a Sunday, so they need no marker.
func classOf(en string) string {
l := strings.ToLower(en)
switch {
case strings.Contains(l, "holy cross"), // Exaltation / Finding of the Holy Cross
strings.Contains(l, "transfiguration"),
strings.Contains(l, "purification"), // the Presentation of the Lord
strings.Contains(l, "precious blood"),
strings.Contains(l, "holy name"),
strings.Contains(l, "dedication of the archbasilica"),
strings.Contains(l, "of our holy savior"),
strings.Contains(l, "of our lord jesus"),
strings.HasSuffix(l, "of our lord"):
return "lord"
}
return ""
}
// isSaintTitle rejects the temporal commemorations that share the sancti:
// namespace (octave days, vigils) so only genuine saints/feasts are harvested.
func isSaintTitle(t string) bool {
l := strings.ToLower(t)
for _, bad := range []string{"octave", "vigil", "feria", "ember", "rogation", "sunday", "within the"} {
if strings.Contains(l, bad) {
return false
}
}
return true
}
type mmInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Rank int `json:"rank"`
Colors []string `json:"colors"`
Commemorations []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"commemorations"`
}
type mmDay struct {
Info mmInfo `json:"info"`
Sections []struct {
ID string `json:"id"`
Body [][]string `json:"body"`
} `json:"sections"`
}
func fetchOnce(date string) (*mmDay, error) {
req, _ := http.NewRequest("GET", "https://www.missalemeum.com/en/api/v5/proper/"+date, nil)
req.Header.Set("User-Agent", ua)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var data []mmDay
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || len(data) == 0 {
return nil, fmt.Errorf("decode/empty")
}
return &data[0], nil
}
// fetch retries transient failures (timeouts, 5xx) a few times before giving up.
func fetch(date string) (*mmDay, error) {
var err error
for attempt := 0; attempt < 4; attempt++ {
var d *mmDay
if d, err = fetchOnce(date); err == nil {
return d, nil
}
}
return nil, err
}
type entry struct {
slug, date, colour, class, en, la, first, gospel string
rank calendar.Rank
observed bool // has readings / reliable rank
}
// idParts splits "sancti:MM-DD[sfx]:RANK:COLOUR" into rank word and colour word.
func idParts(id string) (calendar.Rank, string) {
p := strings.Split(id, ":")
if len(p) < 4 {
return calendar.RankClass4, "white"
}
n, _ := strconv.Atoi(p[2])
return rankOf(n), colourOf(p[3])
}
func readingsFrom(d *mmDay) (first, gospel string) {
for _, s := range d.Sections {
if len(s.Body) == 0 || len(s.Body[0]) == 0 {
continue
}
m := citeRe.FindStringSubmatch(s.Body[0][0])
if m == nil {
continue
}
switch s.ID {
case "Lectio":
first = cleanCite(m[1])
case "Evangelium":
gospel = cleanCite(m[1])
}
}
return
}
// harvestDate returns the observed sanctoral office for a fixed MM-DD (nil if
// the date is always a feria/temporal) plus any sancti commemorations seen.
func harvestDate(mmdd string) (*entry, []entry) {
var comms []entry
seenComm := map[string]bool{}
for _, y := range years {
date := fmt.Sprintf("%04d-%s", y, mmdd)
if _, err := time.Parse("2006-01-02", date); err != nil {
continue // e.g. 02-29 in a common year
}
d, err := fetch(date)
if err != nil {
continue
}
for _, c := range d.Info.Commemorations {
if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) {
continue
}
slug := slugify(c.Title)
if slug == "" || seenComm[slug] {
continue
}
seenComm[slug] = true
_, col := idParts(c.ID)
// A saint never OBSERVED in any harvest year — only ever commemorated —
// is a commemoration in the 1962 universal calendar: it does NOT have
// its own Mass and yields to the ferial office (which is celebrated with
// the saint commemorated). Rank it RankCommemoration, not the id's class
// (missalemeum reuses class-3/4 for these), so a genuine IV class feast
// still outranks the feria while a commemoration does not. If the same
// slug is observed in another year, that observed entry supersedes this.
comms = append(comms, entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: calendar.RankCommemoration})
}
if strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) {
// A few Lord's feasts live in missalemeum's sancti namespace but the
// EF temporal engine already computes them (Nativity, Circumcision,
// Epiphany); exclude them so they aren't duplicated in the sanctoral.
// NOTE: don't skip merely because the temporal is class-1/2 in THIS
// year — a real saint (e.g. Sts Peter & Paul) falling on a Sunday must
// still be captured; another harvest year observes it on a weekday.
if tempFeastSkip[mmdd] || strings.Contains(strings.ToLower(d.Info.Title), "christ the king") {
// Christ the King is movable (last Sunday of October) and computed
// by the temporal engine; missalemeum files it under sancti, so it
// would otherwise leak into the sanctoral at a spurious fixed date.
return nil, comms
}
first, gospel := readingsFrom(d)
col := colourOf(strings.Join(d.Info.Colors, ""))
return &entry{
slug: slugify(d.Info.Title), date: mmdd, colour: col, class: classOf(d.Info.Title),
en: d.Info.Title, first: first, gospel: gospel,
rank: rankOf(d.Info.Rank), observed: true,
}, comms
}
}
return nil, comms
}
func main() {
// Existing Latin names to preserve (missalemeum has no Latin titles).
la := map[string]string{}
for slug, rc := range caldata.Tridentine().Cels {
if v := rc.Fields["name.la"]; v != "" {
la[slug] = v
}
}
// All fixed calendar dates (from a leap year so 02-29 is included).
var dates []string
for d := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC); d.Year() == 2028; d = d.AddDate(0, 0, 1) {
dates = append(dates, d.Format("01-02"))
}
type res struct {
obs *entry
comms []entry
}
results := make([]res, len(dates))
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i, mmdd := range dates {
wg.Add(1)
go func(i int, mmdd string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
obs, comms := harvestDate(mmdd)
results[i] = res{obs, comms}
fmt.Fprintf(os.Stderr, ".")
}(i, mmdd)
}
wg.Wait()
fmt.Fprintln(os.Stderr)
entries := map[string]entry{}
add := func(e entry) {
if cur, ok := entries[e.slug]; ok {
if cur.observed && !e.observed {
return // don't let a commemoration downgrade an observed office
}
if cur.observed && e.observed {
return // first observed year wins
}
}
entries[e.slug] = e
}
for _, r := range results {
if r.obs != nil {
add(*r.obs)
}
}
for _, r := range results { // commemorations after, so observed offices win
for _, c := range r.comms {
if _, ok := entries[c.slug]; !ok {
add(c)
}
}
}
es := make([]entry, 0, len(entries))
for _, e := range entries {
if l := la[e.slug]; l != "" {
e.la = l
}
es = append(es, e)
}
sort.Slice(es, func(i, j int) bool {
if es[i].date != es[j].date {
return es[i].date < es[j].date
}
return es[i].slug < es[j].slug
})
var b strings.Builder
b.WriteString("; General Roman Calendar of 1962 (Extraordinary Form) — universal sanctoral.\n")
b.WriteString("; The temporal cycle (seasons, Sundays, Easter/Christmas/Epiphany, Ascension,\n")
b.WriteString("; Pentecost, Trinity, Corpus Christi, Sacred Heart, Christ the King) is computed\n")
b.WriteString("; by internal/calendar (temporalEF) and is NOT listed here.\n")
b.WriteString("; Ranks use the 1960 Code of Rubrics: class-1..class-4.\n")
b.WriteString("; Generated by scripts/gen-sanctoral-ef.go from missalemeum (Divinum Officium 1962\n")
b.WriteString("; data). Latin names are hand-curated where present. See NOTICE.\n\n")
b.WriteString("[layer]\nid = tridentine\nname = General Roman Calendar of 1962\ntype = universal\n")
for _, e := range es {
fmt.Fprintf(&b, "\n[%s]\ndate = %s\nrank = %s\ncolour = %s\n", e.slug, e.date, e.rank, e.colour)
if e.class != "" {
fmt.Fprintf(&b, "class = %s\n", e.class)
}
fmt.Fprintf(&b, "name.en = %s\n", e.en)
if e.la != "" {
fmt.Fprintf(&b, "name.la = %s\n", e.la)
}
if e.first != "" {
fmt.Fprintf(&b, "reading.first = %s\n", e.first)
}
if e.gospel != "" {
fmt.Fprintf(&b, "reading.gospel = %s\n", e.gospel)
}
}
if err := os.WriteFile("internal/caldata/tridentine-calendar.ini", []byte(b.String()), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
nObs := 0
for _, e := range es {
if e.observed {
nObs++
}
}
fmt.Fprintf(os.Stderr, "wrote %d EF sanctoral celebrations (%d observed w/ readings, %d commemoration-only)\n",
len(es), nObs, len(es)-nObs)
}
|