summaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral.go
blob: b179b675acb09f4a5bfe979b76ef44e9b284961f (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
//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).
func classOf(en string) string {
	l := strings.ToLower(en)
	switch {
	case strings.Contains(l, "blessed virgin mary"), strings.Contains(l, "our lady"), strings.Contains(l, "of mary"):
		return "bvm"
	case strings.Contains(l, "the lord"), strings.Contains(l, "of the cross"), strings.Contains(l, "transfiguration"), strings.Contains(l, "holy trinity"):
		return "lord"
	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)

	// 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)
				if temp.Observed.Rank == calendar.RankSolemnity && t.Weekday() != time.Sunday {
					continue
				}
				if 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 == "" {
						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))
}