aboutsummaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral.go
blob: db2e49071b6c19ca79d37a67231146423553d285 (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
258
259
260
261
262
263
264
265
266
267
//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 (
	// "commemoration" is calapi's rank for a saint reduced to a commemoration by a
	// privileged season (Lent, late Advent, the Christmas octave). Such a saint is
	// never the observed day, so it maps to an optional memorial for lectio; its
	// true (higher) rank is recovered from a year where its date is an ordinary
	// weekday (see rankBand + the highest-rank-wins merge below).
	rankMap     = map[string]calendar.Rank{"solemnity": calendar.RankSolemnity, "feast": calendar.RankFeast, "memorial": calendar.RankMemorial, "optional memorial": calendar.RankOptional, "commemoration": calendar.RankOptional}
	slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
	plPrefixRe  = regexp.MustCompile(`^(?:Uroczystość|Święto|Wspomnienie(?: obowiązkowe)?)\s+`)
)

// rankBand orders OF ranks for the highest-rank-wins merge (higher = higher rank).
func rankBand(r calendar.Rank) int {
	switch r {
	case calendar.RankSolemnity:
		return 5
	case calendar.RankFeast:
		return 4
	case calendar.RankMemorial:
		return 3
	case calendar.RankOptional:
		return 2
	default:
		return 0
	}
}

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: enough spread that every fixed date falls on an ordinary
	// weekday (not a Sunday, not inside a privileged season) in at least one year,
	// so each saint is seen with its true rank. Start at 2025 (past -> niedziela
	// gives pl names; also the date of a transferred feast is taken from the first
	// year it is observed, and 2025 observes Joseph/Annunciation on their proper
	// fixed dates).
	for _, y := range []int{2025, 2026, 2027, 2028, 2029} {
		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
					}
					// Highest-rank-wins: a saint seen as a commemoration in a
					// privileged season one year and as its full rank on an ordinary
					// weekday another year keeps the higher rank. Date/name/pl are
					// kept from the first (proper-date) occurrence; rank/colour/class
					// are upgraded.
					if cur, seen := entries[slug]; seen {
						if rankBand(rk) > rankBand(calendar.Rank(cur.rank)) {
							cur.rank = string(rk)
							cur.colour = c.Colour
							cur.class = classOf(c.Title)
							cur.rankNum = c.RankNum
							entries[slug] = cur
						}
						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))
}