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
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
|
//go:build ignore
// WARNING, READ FIRST (2026-08-18). This generator's output is NO LONGER the
// whole truth of tridentine-calendar.ini. Four fields in that file are hand
// corrections against the 1962 Missale Romanum itself, adopted from the
// sibling project colitur, which validated this data against the Missal and
// found missalemeum wrong on each:
//
// [ubaldus] rank commemoration -> class-3
// [didacus] rank commemoration -> class-3
// [vigil-of-st-lawrence] colour red -> violet
// [vigil-of-the-assumption] colour white -> violet
//
// missalemeum does not carry them. This generator RE-APPLIES all four on every
// run, from the missalOverrides table below, and fails rather than warns if
// one names a slug this run did not harvest -- so a rename upstream stops the
// build instead of quietly dropping a correction. Change the table, not the
// generated file. internal/caldata's TestTridentineMissalOverrides pins the
// same four values from the other side, so neither can rot alone.
//
// 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
// non-English names (Polish, Latin, or any other language already present
// in the file) are preserved verbatim -- missalemeum supplies English
// titles only.
//
// 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) -- a DECISION AGAINST that primary text, made on
// occurrence-behaviour evidence, not a reading of it, and recorded as
// such rather than dressed up as textually clean.
//
// The tag's actual job is not naming/colour categorisation: it exists
// solely to drive the occurrence rule that separates RG 91 entry 14
// ("Festa Domini II classis" -- takes an occurring II-class Sunday's
// place outright) from entry 16 ("Festa II classis Ecclesiae universae,
// quae non [sunt Domini]" -- merely commemorated on one), entry 15
// ("Dominicae II classis") sitting between the two. Checked live against
// missalemeum (this generator's own data source): the Purification
// takes a II-class Sunday's place OUTRIGHT, commemorations EMPTY
// (2014-02-02, 2020-02-02, 2025-02-02, 2031-02-02, 2042-02-02, all
// fetched independently) -- entry 14's pattern, not entry 16's (control:
// the Nativity of the BVM, an undisputed ordinary Marian feast, on a
// Sunday -- 2019-09-08 -- shows the SUNDAY observed, the feast merely
// commemorated, entry 16's own pattern). RG 112(b), "Officium, Missa aut
// commemoratio de dominica excludit commemorationem... de festo vel
// mysterio Domini, et vicissim" (a Sunday's office and a feast/mystery
// OF THE LORD mutually exclude each other as commemorations), backs the
// empty commemoration list independently of RG 91's table position.
//
// Genuine primary-text counter-evidence exists and is not discarded:
// RG 120(b), "Adhibetur color albus... b) B. Mariae Virg., etiam in
// benedictione et processione candelarum die 2 februarii" -- 2 February
// is filed under the WHITE-colour rule's "B. Mariae Virg." heading,
// kept separate there from 120(a)'s own "Domini" heading. Both textual
// tests available (the calendarium's title, RG 120's own taxonomy)
// point BVM; the occurrence-behaviour evidence points Domini. This is a
// live, acknowledged disagreement with the primary text on the
// strength of oracle evidence about what the day actually DOES, not a
// claim that the text is wrong or ambiguous. See
// internal/calendar/precedence_ef_repro_test.go's
// TestPurificationBeatsFebruarySunday for the fixture this rests on --
// committed, not merely asserted, since the deciding years (2 February
// on a Sunday) fall outside this repo's own 2026-2027 oracle snapshot
// window.
//
// - "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"`
}
// fetchOnce fetches one date's proper. KNOWN LIMITATION: missalemeum
// returns one array element PER MASS on a date with more than one (25
// December: three, ids ...m1/...m2/...m3) and this reads only `data[0]`,
// the first -- so a commemoration attached specifically to a second or
// third Mass (the calendarium's own "In secunda Missa: Commemoratio..."
// pattern, e.g. St Anastasia on Christmas Day) is structurally invisible
// to every caller of this function, not just harvestDate's own use of it.
// See the PRIMARY-SOURCE NOTE above harvestDate for the fuller account --
// in Anastasia's specific case this is moot (missalemeum's own
// "commemorations" list is empty on all three of the date's records, not
// just the first, so fixing this would not by itself recover her), but the
// limitation is real and would matter for any date whose SECOND or third
// Mass genuinely does carry a commemoration missalemeum's API records.
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
}
// missalOverride is a field where the 1962 MISSALE ROMANUM ITSELF decides
// against missalemeum, and the Missal wins. Applied after harvesting, just
// before the file is written.
//
// Why an explicit table rather than "preserve whatever the existing file had"
// -- which is how otherNames below works, and would have been less code.
// Preserving from the file cannot tell a deliberate correction from simple
// staleness: if missalemeum ever fixes one of these, a preserve-from-file rule
// would keep overriding it forever and nobody would notice. Naming each
// override with its authority says what we mean, and makes an obsolete one
// findable by re-reading four lines rather than by diffing a calendar.
//
// Each was found by the sibling project colitur, which validates this data
// against the Missal and its Rubricae Generales rather than against another
// calendar program. See its docs/research/rules-register.md sections 6.24 and
// 6.25 for the full account, and internal/calendar/oracle_ef_test.go for the
// allow-list entries these four require, six days of its 730-day window.
type missalOverride struct {
slug string
field string // "rank" or "colour"
want string
why string
}
var missalOverrides = []missalOverride{
{"ubaldus", "rank", "class-3",
`Missal calendarium: "S. Ubaldi Ep. et Conf., III classis" (16 May), no ` +
`commemoration rubric, and a Mass entry of its own in the Proprium ` +
`taking its readings from a Common -- which a bare commemoration never has`},
{"didacus", "rank", "class-3",
`Missal calendarium: "S. Didaci Conf., III classis" (13 Nov), same shape ` +
`as ubaldus above`},
{"vigil-of-st-lawrence", "colour", "violet",
`RG 128: violet for vigils of II and III class outside Paschaltide. ` +
`9 August is a III-class vigil outside Paschaltide`},
{"vigil-of-the-assumption", "colour", "violet",
`RG 128, same rule; 14 August is a II-class vigil outside Paschaltide. ` +
`The rule's sole exception is the Ascension's vigil, INSIDE Paschaltide, ` +
`which is white and is deliberately not listed here`},
}
// applyMissalOverrides rewrites the harvested entries in place and reports
// what it did. It FAILS rather than warns when an override names a slug that
// is not present: a rename upstream would otherwise drop a correction
// silently, which is the exact failure this table exists to prevent.
func applyMissalOverrides(entries map[string]entry) error {
for _, o := range missalOverrides {
e, ok := entries[o.slug]
if !ok {
return fmt.Errorf("missal override names [%s], which this run did not "+
"harvest -- has the slug changed upstream? Fix the override or the "+
"generator; do NOT delete it silently. Reason on record: %s", o.slug, o.why)
}
var had string
switch o.field {
case "rank":
had, e.rank = string(e.rank), calendar.Rank(o.want)
case "colour":
had, e.colour = e.colour, o.want
default:
return fmt.Errorf("missal override for [%s]: unknown field %q", o.slug, o.field)
}
entries[o.slug] = e
if had == o.want {
fmt.Fprintf(os.Stderr, "\nmissal override [%s] %s: already %s upstream -- "+
"missalemeum may have fixed this; consider retiring the override\n",
o.slug, o.field, o.want)
} else {
fmt.Fprintf(os.Stderr, "\nmissal override [%s] %s: %s -> %s\n", o.slug, o.field, had, o.want)
}
}
return nil
}
type entry struct {
slug, date, colour, class, en, first, gospel string
rank calendar.Rank
observed bool // has readings / reliable rank
// otherNames holds every "name.<lang>" field already present in the
// existing file for this slug, EXCLUDING "name.en" (English always
// comes fresh from missalemeum, in `en` above). Keyed by the full
// field name (e.g. "name.pl") so main() can emit it verbatim without
// hardcoding a language list -- see main()'s own preservedNames
// comment for why a hardcoded list is exactly the bug this fixes.
otherNames map[string]string
}
// 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
}
// PRIMARY-SOURCE NOTE (found and corrected during review): of the three
// local scans this generator's citations are checked against
// (docs/research/*.pdf in the sibling colitur repo), ONE --
// "1962-06-23,…LT.pdf", the Archivum Liturgicum ELECTRONIC TRANSCRIPTION --
// silently drops vigil commemorations that the other two, PHOTOGRAPHIC
// scans of the actual 1962 Missale Romanum, both carry. Confirmed on four
// entries: 7 August (Donatus), 9 August (Romanus), 14 August (Eusebius),
// 25 December (Anastasia) -- all present in both photographic scans'
// calendarium AND their own Proprium Sanctorum text ("Et fit
// commemoratio S. Romani Mar-", "...S. Eusebii Con-", etc.), all silently
// absent from the transcription. A `knownSpuriousComm` exclusion list once
// stood here, built by checking ONLY the transcription and concluding two
// of these four ("Romanus", "Eusebius" on 14 August) were spurious -- WRONG,
// on evidence that itself was incomplete, not on a genuine absence. Treat
// the photographic scans as the primary source and the electronic
// transcription as a convenience index only; where they disagree, the scan
// wins. (14 August's "St. Eusebius, Conf." and 16 December's "St. Eusebius,
// Ep. et Mart." are two different people, both genuinely in the calendarium
// -- see slugOverride below, not a reason to drop either.)
//
// Of the four confirming examples above, three (Donatus, Romanus, Eusebius)
// are fixed by this generator as of this note. **25 December's Anastasia is
// NOT, and cannot be from this data source alone** -- a different, NOT
// generator-fixable limitation, recorded so the next person does not
// mistake her continued absence for an oversight of the fix above (she was
// also missing at the branch point, so this is not a regression either):
// the photographic scan places her "In secunda Missa: Commemoratio S.
// Anastasiae Mart." -- specifically the SECOND of Christmas Day's three
// Masses (missalemeum's own "2026-12-25" query independently confirmed to
// return exactly three records, ids ...m1/...m2/...m3). Two compounding
// problems, not one: fetchOnce (below) reads only `data[0]`, the FIRST
// Mass, so this harvester cannot structurally see a commemoration attached
// to a date's second or third Mass at all, for ANY date, not just this
// one -- but ALSO, checked directly (not merely inferred from the
// symptom), missalemeum's OWN "commemorations" list is empty on all THREE
// of the 25 December records, not just the first -- so even a fetchOnce
// rewritten to merge all of a date's Masses would still not recover her:
// missalemeum's own data lacks her here, the same "transcription-shaped"
// gap as Donatus/Romanus/Eusebius, just in the live API rather than the
// static PDF this time. Not fixed: no source this generator reads carries
// her.
// 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; 14 August's "St. Eusebius" (a Confessor, calendarium "S.
// Eusebii Conf.") is likewise a different person from 16 December's "St.
// Eusebius" (a Bishop and Martyr, calendarium "S. Eusebii Ep. et Mart."),
// both genuinely commemorated, missalemeum giving both the same bare
// English title. Keyed "MM-DD/original-slug" -> replacement slug.
var slugOverride = map[string]string{
"01-28/agnes": "agnes-secundo",
"08-14/eusebius": "eusebius-confessor",
"05-14/boniface": "boniface-martyr",
}
// refYearExplainsAbsence is a RANK-BLIND SAMPLING HEURISTIC, not a rubric
// evaluator: it does not know, and cannot know, the true class of the saint
// it is being asked about -- only whether the TEMPORAL day alone (no
// sanctoral data at all) on this one reference date looks privileged enough
// that a saint failing to win there is unsurprising. It reports true for: 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 Lent/Passiontide feria
// (III class, privileged per RG 109(e)).
//
// Two known imprecisions, recorded rather than silently accepted:
//
// - The Lent/Passiontide branch is privilege over an EQUAL-OR-LOWER-class
// candidate only -- RG 109(e) does not let a mere III-class feria beat a
// I- or II-class feast. Nothing in this codebase's actual data currently
// exercises that gap (every saint this heuristic has ever been asked
// about that is demoted throughout Lent is independently III class or
// lower, per the calendarium), but the function does not itself enforce
// it, so a future entry could reach it.
// - It is a proxy for "why was this saint never observed", not a citation.
// 17-31 December is excluded from ever trusting the id-derived rank
// below, REGARDLESS of what this heuristic would otherwise say --
// covering BOTH of round 1's own late-Advent/Christmas-octave rank
// promotions, on two DIFFERENT strengths of evidence, recorded
// separately because they are not the same case:
// -- 26-31 December (RG 68(d)/(e), a positive citation): "die 29
// decembris, fit commemoratio S. Thomae Episcopi et Mart.; die 31
// decembris, fit commemoratio S. Silvestri I Papae et Conf." -- the
// calendarium names each a bare "Commemoratio" with NO class of its
// own, even though the DAY they fall on is II class (within the
// Nativity Octave). Two REAL entries (Thomas Becket, Silvester) are
// live here; see the call site's own comment.
// -- 17-23 December (RG 91 entry 18, a PRECAUTIONARY exclusion, not a
// positive citation): round 1 also promoted these late-Advent
// ferias from class-3 to class-2 (same lineage, same coupling
// shape as 26-31 December's own promotion). No live entry tests
// this range today -- the sole sanctoral entry there, `thomas`
// (21 December), is an OBSERVED class-2 feast reached via the
// harvestDate `obs` path, not this function's id-rank-trust path
// at all -- but unlike 26-31 December, there is no RG citation
// stating that a saint commemorated here has NO independent class;
// RG 91 entry 18 only ranks the FERIA, and (per defect 2's own
// finding) a genuine class-2 FEAST commemorated here would
// actually WIN against it (entry 16 above entry 18), so "the day
// is class-2" does not reliably explain a class-2 saint's absence
// the way it does for 26-31 December's own two named cases.
// Excluded anyway, on the side of the KNOWN-safe default
// (RankCommemoration) rather than risk repeating the identical
// failure shape the day this range's own first real entry arrives.
//
// This is also a warning about a structural hazard, not just a boundary
// fix: this function calls calendar.Compute, i.e. it reads the ENGINE's OWN
// computed temporal ranks to decide what DATA to generate. A change to
// temporal_ef.go's ranking (e.g. round 1's own 17-23/26-31 December
// promotions, RG 91 entry 18 / RG 67-68) can silently flip this function's
// verdict and rewrite generated data with no code change to this file at
// all -- confirmed by direct measurement, not just reasoned about: running
// this function's body against the branch-point engine versus the current
// one over the six reference years flips the verdict on 50 dates across
// exactly these 13 MM-DD values (12-17 through 12-23, 12-26 through
// 12-31), no others. Any future temporal_ef.go rank change should re-check
// this function's own boundary cases, not just its own tests.
//
// harvestDate keeps a saint's commemoration id rank ONLY if every reference
// year in which it was seen returned true here -- a single false (an
// unprivileged day, or the December exclusion) is enough to fall back to
// RankCommemoration, the safe default (see St Blaise, whose own
// commemoration id claims rank 4 and is still correctly overridden to
// RankCommemoration, proving the id's rank is a candidate, not a verdict --
// see the call site's own comment).
func refYearExplainsAbsence(date time.Time) bool {
if date.Month() == time.December && date.Day() >= 17 && date.Day() <= 31 {
// RG 68(d)/(e) (26-31 Dec) and RG 91 entry 18 (17-23 Dec,
// precautionary): "die 29 decembris, fit commemoratio S. Thomae
// Episcopi et Mart.; die 31 decembris, fit commemoratio S.
// Silvestri I Papae et Conf." -- both named as a bare
// "Commemoratio", no class. See this function's own doc comment.
return false
}
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 == "" {
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 is a BETTER rank signal than
// the DAY's own info.id (which names the rank of whatever
// propers are reused that day, not the commemorated saint's) --
// but "better" is not "always correct": St Blaise's own
// commemoration id is "sancti:02-03:4:r" (rank 4), and he is
// still, correctly, ruled RankCommemoration below, because he
// has no independent Mass at all in the 1960 books, not because
// his id's rank is wrong. The id's rank is a CANDIDATE value,
// trusted only when refYearExplainsAbsence's heuristic finds no
// counter-evidence across every reference year this slug is
// seen -- see that function's own doc comment for what the
// heuristic actually checks and its known limits.
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() {
// preservedNames holds every "name.<lang>" field already present in the
// existing file, keyed by slug then by the full field name -- EVERY
// language missalemeum does not itself supply (it has English titles
// only), not a hardcoded whitelist of one or two. A whitelist is
// exactly the bug this replaces: an earlier version of this generator
// preserved only name.la, and -- unnoticed, because the bootstrapped
// file has in fact never carried a name.la value at all, so that
// mechanism was silently inert from the start -- name.pl had no
// preservation mechanism whatsoever. A regeneration deleted all 322
// Polish names outright (measured: name.pl 322 -> 0), reaching
// mobile.Day(date, "ef", version, "pl") -- a shipped dlectio entry
// point -- on the app's next build, with `naming.CelebrationName`'s own
// name[lang] -> name.en fallback silently substituting English and no
// error anywhere. Generalising to every "name.*" key except name.en
// (English is always freshly regenerated from missalemeum, the whole
// point of this tool) means a third, fourth, or Nth language added to
// the file later survives a regeneration without this function ever
// needing to change again.
preservedNames := map[string]map[string]string{}
for slug, rc := range caldata.Tridentine().Cels {
for k, v := range rc.Fields {
if v == "" || !strings.HasPrefix(k, "name.") || k == "name.en" {
continue
}
if preservedNames[slug] == nil {
preservedNames[slug] = map[string]string{}
}
preservedNames[slug][k] = 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)
}
}
// The Missal overrides missalemeum on four fields -- see missalOverrides
// above for each one's authority. Applied HERE, after every harvest and
// collision fix-up has settled and before anything is written, so that the
// value the Missal requires is the last word rather than something a later
// pass could overwrite.
if err := applyMissalOverrides(entries); err != nil {
fmt.Fprintf(os.Stderr, "\n%v\n", err)
os.Exit(1)
}
es := make([]entry, 0, len(entries))
for _, e := range entries {
e.otherNames = preservedNames[e.slug]
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). name.en is always regenerated fresh from missalemeum; every other\n")
b.WriteString("; name.<lang> (missalemeum supplies English only) is preserved verbatim from\n")
b.WriteString("; whatever this file already carried before regeneration. See NOTICE.\n")
b.WriteString(";\n")
b.WriteString("; FOUR FIELDS ARE NOT FROM missalemeum. They are corrections against the 1962\n")
b.WriteString("; Missale Romanum itself, which missalemeum does not carry, re-applied on every\n")
b.WriteString("; run by this generator's own missalOverrides table (see its comment for the\n")
b.WriteString("; authority behind each). Regenerating this file is therefore SAFE and will\n")
b.WriteString("; not revert them; editing them here by hand is not, because the next run\n")
b.WriteString("; puts them back. Change the table instead.\n")
for _, o := range missalOverrides {
fmt.Fprintf(&b, "; [%s] %s = %s\n", o.slug, o.field, o.want)
}
b.WriteString("\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)
otherKeys := make([]string, 0, len(e.otherNames))
for k := range e.otherNames {
otherKeys = append(otherKeys, k)
}
sort.Strings(otherKeys) // deterministic output regardless of map iteration order
for _, k := range otherKeys {
fmt.Fprintf(&b, "%s = %s\n", k, e.otherNames[k])
}
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)
}
|