aboutsummaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral-ef.go
blob: 75ee70d38ee180f1aa35fef9e1554228d1629caf (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
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
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
//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 Exaltation of the Holy Cross (Sep 14), the Dedication of the
// Archbasilica (Nov 9), the Commemoration of the Baptism (Jan 13). Saint/BVM
// feasts of the same class are only commemorated on a Sunday, so they need no
// marker.
//
// Two substring checks were narrowed, and one is new, found by checking the
// calendarium's own verbatim titles (missale-romanum-1962.pdf) against what
// this function produced -- but "purification" is a THIRD, DELIBERATELY
// DIFFERENT case, kept matching rather than narrowed, and the reason is
// itself worth recording:
//   - "purification" IS kept as a lord match, even though the calendarium's
//     own title is "IN PURIFICATIONE B. MARIAE VIRG." (a feast of the BLESSED
//     VIRGIN by name). The tag's actual job here is not naming/colour
//     categorisation -- it exists solely to drive the occurrence rule
//     ("a II-class feast of the Lord takes an occurring Sunday's place
//     outright"), and on THAT question the calendarium's title is not the
//     decisive evidence: missalemeum -- the same oracle this generator's own
//     data is bootstrapped from -- shows the Purification taking a II-class
//     Sunday's place OUTRIGHT with commemorations EMPTY (confirmed live,
//     2014-02-02, 2020-02-02, 2025-02-02), the exact "festum Domini" pattern,
//     not the ordinary-BVM-feast pattern (control: the Nativity of the BVM,
//     8 September, on a Sunday -- 2019-09-08 -- shows the SUNDAY observed,
//     the feast merely commemorated, the opposite shape). An earlier version
//     of this comment (and this codebase) untagged it on the calendarium's
//     title alone; that was reversed after the same live check colitur's own
//     review independently ran (colitur git history: cab8b07 retags it BVM,
//     then 7d3b5ec reverses that "follow the oracle... a different project
//     could reasonably rule the other way on the same evidence" -- recorded
//     as a genuinely contested point, not a clean-cut error, but the
//     occurrence-behaviour evidence is what this tag is FOR).
//   - "holy name" alone is ambiguous: it matches BOTH "Holy Name of Jesus"
//     (a feast of the Lord) and "Most Holy Name of Mary"/"Holy Name of Mary"
//     (a feast of the BVM, calendarium: "Sanctissimi Nominis Mariae") --
//     wrongly matching the latter too. Excluded whenever the title also
//     names Mary. Unlike the Purification, this one is NOT contested: its
//     occurrence behaviour matches the ordinary-BVM pattern too.
//   - "baptism" is a new case: "Commemoration of the Baptism of the Lord" (13
//     January, calendarium: "IN COMMEMORATIONE BAPTISMATIS D. N. I. C.") did
//     not match any existing case -- the HasSuffix check below requires "of
//     OUR Lord", but this title's own wording is "of THE Lord" -- so it was
//     missing the marker entirely.
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 -- occurrence-behaviour evidence, see doc comment
		strings.Contains(l, "precious blood"),
		strings.Contains(l, "baptism"),
		strings.Contains(l, "holy name") && !strings.Contains(l, "mary"),
		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"),
		strings.HasSuffix(l, "of the lord"):
		return "lord"
	}
	return ""
}

// isSaintTitle rejects the purely temporal offices that share the sancti:
// namespace (octave days, ferias, Ember days) so only genuine celebrations are
// harvested. Vigils of fixed feasts (Christmas, the Nativity of St John the
// Baptist, Sts Peter & Paul, St Lawrence, the Assumption, All Saints, the
// Immaculate Conception) ARE kept: they are proper liturgical days on a fixed
// date. The movable vigils (Ascension, Pentecost) live in the tempora namespace
// and are computed by the temporal engine, not harvested here.
func isSaintTitle(t string) bool {
	l := strings.ToLower(t)
	for _, bad := range []string{"octave", "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])
}

// idHomeDate extracts the "MM-DD" home date embedded in a missalemeum
// info.id ("sancti:MM-DD[sfx]:RANK:COLOUR" -- sfx is an internal
// disambiguator missalemeum sometimes appends, e.g. "01-28t", "11-09cc",
// "11-02m1"; the first 5 characters are always the date). Returns "" if id
// is too short to contain one.
//
// This is the fix for a class of bug the slug-collision fix above (in
// main()) exposed rather than caused: a MOVABLE-transfer feast displayed on
// whatever civil date it actually landed on in a given reference year (St
// Joseph, 19 March, impeded by a Sunday of Lent and shown on the 20th; the
// Annunciation deferred past Holy Week; All Souls moved to the Monday when 2
// November is a Sunday; even St Matthias' fixed 24 February shown on the
// 25th in a leap year) still carries its OWN proper date in info.id, not the
// civil date queried. Comparing the two lets harvestDate recognise "this is
// not really this date's own office" and skip it, instead of harvesting a
// phantom fixed-date entry at the transferred civil date -- confirmed
// live (`curl .../api/v5/proper/2023-03-20`): id "sancti:03-19:1:w" while
// the date queried is 2023-03-20.
func idHomeDate(id string) string {
	p := strings.SplitN(id, ":", 2)
	if len(p) < 2 || len(p[1]) < 5 {
		return ""
	}
	return p[1][:5]
}

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
}

// knownSpuriousComm excludes specific missalemeum commemorations, keyed
// "MM-DD/slug", that the 1962 calendarium's own row for that date does NOT
// support -- confirmed by checking the primary text directly, not inferred.
// 9 August's row reads only "Vigilia, III classis.", with no "Com." line, so
// missalemeum's own "St. Romanus" commemoration that day has no calendarium
// backing. This is a genuine upstream (missalemeum) data quirk, not something
// derivable from the API response itself, so it is recorded here rather than
// silently reproduced -- see the report for the primary-source citation.
var knownSpuriousComm = map[string]bool{
	"08-09/romanus": true,
	// 14 August's row in the calendarium reads only "Vigilia, II classis.",
	// no "Com." line; the genuine St Eusebius (Bishop and Martyr) is
	// commemorated 16 December instead, where he is correctly present.
	"08-14/eusebius": true,
}

// slugOverride gives a proper, distinct slug to a small number of
// commemorations whose title slugifies IDENTICALLY to an unrelated feast on
// a different fixed date. Confirmed against the calendarium: 28 January's
// "St. Agnes" is the traditional SECOND commemoration of the 21 January
// feast (the same saint, repeated, not a coincidence); 14 May's "St.
// Boniface" is a different early martyr from 5 June's Boniface of Mainz, an
// entirely different person whose title happens to abbreviate to the same
// English string. Keyed "MM-DD/original-slug" -> replacement slug.
var slugOverride = map[string]string{
	"01-28/agnes":    "agnes-secundo",
	"05-14/boniface": "boniface-martyr",
}

// refYearExplainsAbsence reports whether the TEMPORAL day alone (no
// sanctoral data at all -- an empty layer stack) on this specific reference
// date was already strong enough that ANY class-1..4 saint would lose there
// regardless of its own merit: a Sunday or a named I/II-class feast, an
// Ember day, the late-Advent or Christmas-octave privilege (I or II class),
// or a privileged Lent/Passiontide feria (III class, but still privileged
// over an equal-or-lower-class saint per RG 109(e)).
//
// This is the discriminator between two different reasons a saint is never
// OBSERVED in any of the six reference years:
//
//  1. Genuinely without an independent Mass in the 1960-reformed books --
//     reduced to an added commemoration on ANY day, including an ordinary,
//     unprivileged one. Confirmed live: St Blaise (3 Feb, an ordinary
//     Septuagesima-season feria, non-privileged), St Canute (19 Jan, an
//     ordinary Time-after-Epiphany feria) and others are shown by
//     missalemeum as mere commemorations even then -- this is real 1962
//     data, not a sampling artefact, and RankCommemoration is the correct,
//     honest rank for them.
//  2. A real class-1..4 feast that merely never won in these six
//     PARTICULAR reference years because its fixed date happens to fall,
//     in every one of them, on a day already strong enough to beat any
//     saint of its class -- the 15-entry, 6 March-5 April case this fix
//     originally targeted (every one of those dates falls within Lent in
//     all six reference years). Here the id's own embedded rank is
//     trustworthy.
//
// harvestDate keeps a saint's commemoration id rank ONLY if every reference
// year in which it was seen was case 1 above (i.e. this function returned
// true every time) -- a single unprivileged-day counter-example is enough
// to fall back to RankCommemoration.
func refYearExplainsAbsence(date time.Time) bool {
	sel := calendar.DefaultSelection()
	sel.Form = "old"
	day := calendar.Compute(date, sel, nil)
	if day.Observed.Rank == calendar.RankClass1 || day.Observed.Rank == calendar.RankClass2 {
		return true
	}
	return day.Season == calendar.Lent || day.Season == calendar.Passiontide
}

// commTracker accumulates one commemoration slug's data across reference
// years: the entry itself (first sighting's title/colour/id-rank), and
// whether EVERY year it was seen in was "explained" by refYearExplainsAbsence.
type commTracker struct {
	entry        entry
	allExplained bool
}

// 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.
//
// Every one of the `years` reference years is scanned for BOTH the observed
// office and commemorations -- neither loop exits early on the first hit.
// Two real bugs lived in an earlier version that DID exit early:
//
//  1. Returning as soon as the FIRST reference year showed an observed
//     office discarded every commemoration that only showed up in a LATER
//     year (e.g. 9 November: 2025, the first year tried, happens to be the
//     one year of six with no "St. Theodore" commemoration alongside the
//     Dedication of the Archbasilica; a `return` there drops Theodore for
//     good).
//  2. The tempFeastSkip/Christ-the-King check used to `return nil, comms`
//     outright -- correct for tempFeastSkip's three permanently-fixed dates
//     (every year behaves the same, so nothing is lost), but wrong for
//     Christ the King, which occupies a given MM-DD only in the one
//     reference year it happens to be the last Sunday of October (2025 for
//     26 October, in this generator's own reference years): returning
//     immediately there discarded "St. Evaristus", visible only in the OTHER
//     five years. `continue` fixes both: the loop keeps trying every
//     remaining year regardless of what any single year showed.
func harvestDate(mmdd string) (*entry, []entry) {
	commTrack := map[string]*commTracker{}
	var obs *entry
	for _, y := range years {
		date := fmt.Sprintf("%04d-%s", y, mmdd)
		refDate, err := time.Parse("2006-01-02", date)
		if err != nil {
			continue // e.g. 02-29 in a common year
		}
		d, err := fetch(date)
		if err != nil {
			continue
		}
		explained := refYearExplainsAbsence(refDate)
		for _, c := range d.Info.Commemorations {
			if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) {
				continue
			}
			if home := idHomeDate(c.ID); home != "" && home != mmdd {
				continue // a transferred feast's commemoration, not a genuine one for THIS date
			}
			slug := slugify(c.Title)
			if slug == "" || knownSpuriousComm[mmdd+"/"+slug] {
				continue
			}
			if t, ok := commTrack[slug]; ok {
				if !explained {
					t.allExplained = false
				}
				continue // title/colour/id-rank already captured from the first sighting
			}
			// The COMMEMORATION object's own id names THAT SAINT's true rank
			// (unlike the DAY's own info.id, which names the rank of whatever
			// propers are reused that day, not the commemorated saint's) --
			// idParts already extracts it; only the colour half used to be
			// kept. Whether this rank is actually TRUSTED depends on
			// `explained` across every year this slug is seen -- resolved
			// after the year loop, see refYearExplainsAbsence's own doc
			// comment.
			rank, col := idParts(c.ID)
			commTrack[slug] = &commTracker{
				entry:        entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: rank},
				allExplained: explained,
			}
		}
		if obs != nil {
			continue // already have an observed office; keep scanning other years for MORE commemorations
		}
		if strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) {
			if home := idHomeDate(d.Info.ID); home != "" && home != mmdd {
				// A movable-transfer feast displayed on today's civil date in
				// THIS particular reference year (St Joseph pushed to the
				// 20th; the Annunciation deferred past Holy Week; All Souls
				// moved to the Monday; St Matthias shown on the 25th in a
				// leap year) -- not a genuine fixed office for mmdd itself.
				// See idHomeDate's own doc comment for the live-verified
				// evidence. Try the next reference year instead.
				continue
			}
			// 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.
				// `continue`, not `return`: this disqualifies only THIS year's
				// observed-office candidacy, not the whole date (see doc comment).
				continue
			}
			first, gospel := readingsFrom(d)
			col := colourOf(strings.Join(d.Info.Colors, ""))
			obs = &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 := make([]entry, 0, len(commTrack))
	for _, t := range commTrack {
		e := t.entry
		if !t.allExplained {
			// At least one reference year showed this saint demoted even on
			// an ordinary, unprivileged day -- genuinely commemoration-only
			// (see refYearExplainsAbsence), not merely unlucky sampling.
			e.rank = calendar.RankCommemoration
		}
		comms = append(comms, e)
	}
	return obs, 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.date == e.date {
				// Same fixed date: this is the SAME feast, seen again in
				// another reference year or pass -- the existing dedup rules
				// apply (an observed office is never downgraded by a
				// commemoration; the first observed year wins).
				if cur.observed && !e.observed {
					return
				}
				if cur.observed && e.observed {
					return
				}
			} else {
				// A DIFFERENT fixed date slugified to the identical string
				// (e.g. "St. Boniface" on both 14 May and 5 June, or "St.
				// Agnes" on both 21 and 28 January) -- two distinct
				// celebrations, not the same one recurring. The map is keyed
				// by slug, so silently keeping the first and dropping the
				// second here is exactly how St Agnes secundo (28 Jan), St
				// Boniface Martyr (14 May), and their like went missing
				// before this fix. Disambiguate instead of dropping.
				if ov, ok := slugOverride[e.date+"/"+e.slug]; ok {
					e.slug = ov
				} else {
					e.slug = e.slug + "-" + strings.ReplaceAll(e.date, "-", "")
				}
				if _, stillCollides := entries[e.slug]; stillCollides {
					return // extremely unlikely second collision; drop rather than clobber
				}
			}
		}
		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 {
			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)
}