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
|
//go:build ignore
// gen-sanctoral generates the OF (Ordinary Form) universal sanctoral
// (internal/caldata/roman-calendar.ini) from the authoritative General Roman
// Calendar published by calapi.inadiutorium.cz. For each fixed date it takes
// the saint/Marian/Lord celebrations (memorial/optional-memorial/feast/
// solemnity) that the temporal engine does NOT already compute, with:
// - English name + rank + colour from calapi general-en,
// - Latin name from calapi general-la,
// - Polish name from niedziela.pl (DayInfo, obligatory celebrations).
//
// Multiple reference years are unioned so a saint whose date is a Sunday in one
// year is still captured from another. One-time; requires network + curl-ish UA.
//
// go run scripts/gen-sanctoral.go
//
// Writes internal/caldata/roman-calendar.ini.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/lukaszkasprzak/lectio/internal/calendar"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/readings"
)
const ua = "Mozilla/5.0 (lectio sanctoral generator)"
type calDay struct {
Date string `json:"date"`
Celebrations []struct {
Title string `json:"title"`
Colour string `json:"colour"`
Rank string `json:"rank"`
RankNum float64 `json:"rank_num"`
} `json:"celebrations"`
}
func fetchMonth(cal string, y, m int) ([]calDay, error) {
url := fmt.Sprintf("http://calapi.inadiutorium.cz/api/v0/en/calendars/%s/%d/%d", cal, y, m)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", ua)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var days []calDay
if err := json.NewDecoder(resp.Body).Decode(&days); err != nil {
return nil, err
}
return days, nil
}
var (
rankMap = map[string]calendar.Rank{"solemnity": calendar.RankSolemnity, "feast": calendar.RankFeast, "memorial": calendar.RankMemorial, "optional memorial": calendar.RankOptional}
slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
plPrefixRe = regexp.MustCompile(`^(?:Uroczystość|Święto|Wspomnienie(?: obowiązkowe)?)\s+`)
)
func slugify(title string) string {
s := strings.ToLower(title)
s = strings.NewReplacer("ł", "l", "æ", "ae", "é", "e", "è", "e", "ô", "o", "ç", "c").Replace(s)
s = slugStripRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
s = strings.TrimPrefix(s, "saint-")
s = strings.TrimPrefix(s, "st-")
return s
}
// classOf infers the celebration class from its English title (precedence hint).
// Feasts of the Lord (class lord) outrank Ordinary Sundays; the BVM class carries
// Marian-commemoration precedence. Order matters: the Lord tests run before the
// saint default; "holy cross" (the feast) is distinguished from "of the cross"
// (saints like John of the Cross), which stay saints.
func classOf(en string) string {
l := strings.ToLower(en)
switch {
case strings.Contains(l, "holy cross"), strings.Contains(l, "transfiguration of the lord"),
strings.Contains(l, "presentation of the lord"), strings.Contains(l, "holy name of jesus"),
strings.Contains(l, "sacred heart"), strings.Contains(l, "holy trinity"),
strings.Contains(l, "body and blood"), strings.Contains(l, "dedication of the lateran"),
strings.HasSuffix(l, "of the lord"), strings.Contains(l, "of the lord,"):
return "lord"
case strings.Contains(l, "blessed virgin mary"), strings.Contains(l, "our lady"),
strings.Contains(l, "of mary"), strings.Contains(l, "queenship"),
strings.Contains(l, "immaculate heart"), strings.Contains(l, "the annunciation"):
return "bvm"
default:
return "saint"
}
}
type entry struct {
slug, date, rank, colour, class, en, la, pl string
rankNum float64
}
func main() {
sel := calendar.DefaultSelection()
sel.Form = "new"
cfg := config.Default()
cfg.Lectionary = "new"
entries := map[string]entry{} // slug -> entry (first year wins per slug)
// Genuinely MOVABLE celebrations tied to the temporal cycle (no fixed date):
// a fixed-date sanctoral cannot represent them; they need temporal-engine
// support. Skipped here (tracked as a known gap).
movableSkip := map[string]bool{
"mary-mother-of-the-church": true, // Monday after Pentecost
"the-immaculate-heart-of-mary": true, // Saturday after the Sacred Heart
"the-memorial-of-the-blessed-virgin-mary-on-saturday": true, // any free Saturday
}
// Reference years: cover every weekday so a saint whose date is a Sunday in
// one year is captured in another. 2025 is past -> niedziela gives pl names.
for _, y := range []int{2025, 2026, 2027} {
for m := 1; m <= 12; m++ {
en, err := fetchMonth("general-en", y, m)
if err != nil {
fmt.Fprintf(os.Stderr, "en %d/%d: %v\n", y, m, err)
continue
}
la, _ := fetchMonth("general-la", y, m)
laByDate := map[string][]string{}
for _, d := range la {
for _, c := range d.Celebrations {
laByDate[d.Date] = append(laByDate[d.Date], c.Title)
}
}
for _, d := range en {
t, _ := time.Parse("2006-01-02", d.Date)
// Skip dates the temporal engine owns (its own solemnities/feasts:
// Christmas, Easter cycle, etc.). Sanctoral dates are ferial (or a
// plain Sunday) in the pure temporal.
temp := calendar.Compute(t, sel, nil)
// Skip dates the temporal engine owns: Sundays and its own
// solemnities/feasts (Christmas & Easter cycles, Christ the King,
// Trinity, Corpus Christi, …). Sanctoral saints are captured on their
// weekday (ferial) occurrences -- and since a fixed date lands on a
// weekday in most years, one reference year suffices per saint.
if temp.Observed.Rank == calendar.RankSolemnity || temp.Observed.Rank == calendar.RankFeast {
continue
}
// Polish name for the observed (obligatory) celebration, 2025 only.
plName := ""
if y == 2025 {
if _, info, e := readings.Load(cfg, readings.Options{Date: d.Date, All: false}); e == nil {
plName = strings.TrimSpace(plPrefixRe.ReplaceAllString(info.Name, ""))
}
}
for i, c := range d.Celebrations {
rk, ok := rankMap[c.Rank]
if !ok {
continue // "ferial" or unknown -> not a sanctoral saint
}
slug := slugify(c.Title)
if slug == "" || movableSkip[slug] {
continue
}
if _, seen := entries[slug]; seen {
continue
}
laTitle := ""
if l := laByDate[d.Date]; i < len(l) {
laTitle = l[i]
}
pl := ""
if i == 0 { // the observed celebration niedziela reports
pl = plName
}
entries[slug] = entry{
slug: slug, date: t.Format("01-02"), rank: string(rk),
colour: c.Colour, class: classOf(c.Title), en: c.Title, la: laTitle, pl: pl,
rankNum: c.RankNum,
}
}
}
fmt.Fprintf(os.Stderr, "%d/%02d done (%d entries so far)\n", y, m, len(entries))
}
}
es := make([]entry, 0, len(entries))
for _, e := range entries {
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].rankNum < es[j].rankNum // higher rank (lower num) first
})
var b strings.Builder
b.WriteString("; General Roman Calendar (Ordinary Form) — universal sanctoral.\n")
b.WriteString("; The temporal cycle (Sundays, seasons, Easter/Christmas cycles, Christ the King,\n")
b.WriteString("; Baptism, Holy Family, Trinity, Corpus Christi, Sacred Heart) is computed by\n")
b.WriteString("; internal/calendar and is NOT listed here.\n")
b.WriteString("; Generated by scripts/gen-sanctoral.go from calapi.inadiutorium.cz (General Roman\n")
b.WriteString("; Calendar; en+la names) and niedziela.pl (pl names). See NOTICE.\n\n")
b.WriteString("[layer]\nid = universal\nname = General Roman Calendar\ntype = universal\n")
for _, e := range es {
fmt.Fprintf(&b, "\n[%s]\ndate = %s\nrank = %s\n", e.slug, e.date, e.rank)
if e.class != "" {
fmt.Fprintf(&b, "class = %s\n", e.class)
}
fmt.Fprintf(&b, "colour = %s\n", e.colour)
fmt.Fprintf(&b, "name.en = %s\n", e.en)
if e.pl != "" {
fmt.Fprintf(&b, "name.pl = %s\n", e.pl)
}
if e.la != "" {
fmt.Fprintf(&b, "name.la = %s\n", e.la)
}
}
if err := os.WriteFile("internal/caldata/roman-calendar.ini", []byte(b.String()), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "\nwrote %d sanctoral celebrations\n", len(es))
}
|