aboutsummaryrefslogtreecommitdiff
path: root/internal/caldata/caldata.go
blob: 92f9327b799a7d1ab84f43f977476f788f939f45 (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
// Package caldata embeds and parses lectio's owned universal General Roman
// Calendar data into a calendar.Layer. It imports only internal/ini and
// internal/calendar (types).
package caldata

import (
	_ "embed"
	"fmt"
	"os"
	"path/filepath"

	"github.com/lukaszkasprzak/lectio/internal/calendar"
	"github.com/lukaszkasprzak/lectio/internal/ini"
)

//go:embed roman-calendar.ini
var romanCalendar []byte

//go:embed tridentine-calendar.ini
var tridentineCalendar []byte

//go:embed tridentine-lectionary.ini
var tridentineLectionary []byte

// efTempReadings maps an EF temporal-day slug to its readings (parsed once).
var efTempReadings = parseLectionary(tridentineLectionary)

func parseLectionary(data []byte) map[string][]calendar.Reading {
	l, err := ParseLayer(data)
	if err != nil {
		panic(fmt.Sprintf("caldata: tridentine-lectionary.ini invalid: %v", err))
	}
	out := map[string][]calendar.Reading{}
	for slug, rc := range l.Cels {
		var rs []calendar.Reading
		for _, part := range []string{"first", "psalm", "second", "gospel"} {
			if c := rc.Fields[part]; c != "" {
				rs = append(rs, calendar.Reading{Part: part, Citation: c})
			}
		}
		out[slug] = rs
	}
	return out
}

// TemporalReadings returns the temporal-cycle readings for a computed day slug,
// or nil. Only the EF ("old") temporal lectionary exists so far.
func TemporalReadings(form, slug string) []calendar.Reading {
	if form == "old" {
		return efTempReadings[slug]
	}
	return nil
}

// Universal parses the embedded Ordinary Form universal calendar. It panics on a
// malformed embedded file (a build-time bug caught by tests).
func Universal() calendar.Layer {
	l, err := ParseLayer(romanCalendar)
	if err != nil {
		panic(fmt.Sprintf("caldata: embedded roman-calendar.ini invalid: %v", err))
	}
	return l
}

// Tridentine parses the embedded Extraordinary Form (1962) universal calendar.
func Tridentine() calendar.Layer {
	l, err := ParseLayer(tridentineCalendar)
	if err != nil {
		panic(fmt.Sprintf("caldata: embedded tridentine-calendar.ini invalid: %v", err))
	}
	return l
}

// Base returns the universal base layer for a form: the 1962 calendar for
// "old" (EF), the General Roman Calendar otherwise (OF).
func Base(form string) calendar.Layer {
	if form == "old" {
		return Tridentine()
	}
	return Universal()
}

// LoadLayer reads and parses a user calendar layer file. id (the filename stem)
// is used as the layer's ID when the file's [layer] header omits one.
func LoadLayer(path, id string) (calendar.Layer, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return calendar.Layer{}, err
	}
	l, err := ParseLayer(data)
	if err != nil {
		return calendar.Layer{}, fmt.Errorf("%s: %w", path, err)
	}
	if l.ID == "" {
		l.ID = id
	}
	return l, nil
}

// Stack returns the ordered layer stack for the engine: the form's universal
// base first, then each user layer named in use (matched to "<dir>/<id>.ini"),
// in order. A layer that fails to load is skipped and reported in the error
// slice so a single bad file never breaks calendar computation.
func Stack(form, dir string, use []string) ([]calendar.Layer, []error) {
	layers := []calendar.Layer{Base(form)}
	var errs []error
	for _, id := range use {
		l, err := LoadLayer(filepath.Join(dir, id+".ini"), id)
		if err != nil {
			errs = append(errs, err)
			continue
		}
		layers = append(layers, l)
	}
	return layers, errs
}

// ParseLayer parses INI bytes (a [layer] header + [slug]/[slug/variant]
// celebration sections) into a calendar.Layer.
func ParseLayer(data []byte) (calendar.Layer, error) {
	secs, err := ini.Parse(data)
	if err != nil {
		return calendar.Layer{}, err
	}
	layer := calendar.Layer{Cels: map[string]calendar.RawCelebration{}}
	for _, s := range secs {
		fields := map[string]string{}
		for _, p := range s.Pairs {
			fields[p.Key] = p.Val
		}
		switch {
		case s.Name == "" && len(fields) == 0:
			continue
		case s.Name == "layer":
			layer.ID, layer.Name, layer.Type = fields["id"], fields["name"], fields["type"]
		default:
			base, variant, isVariant := cutVariant(s.Name)
			rc := layer.Cels[base]
			if rc.Fields == nil {
				rc = calendar.RawCelebration{Fields: map[string]string{}, Variants: map[string]map[string]string{}}
			}
			if isVariant {
				rc.Variants[variant] = fields
			} else {
				for k, v := range fields {
					rc.Fields[k] = v
				}
			}
			layer.Cels[base] = rc
		}
	}
	return layer, nil
}

func cutVariant(name string) (base, variant string, ok bool) {
	for i := 0; i < len(name); i++ {
		if name[i] == '/' {
			return name[:i], name[i+1:], true
		}
	}
	return name, "", false
}