aboutsummaryrefslogtreecommitdiff
path: root/mobile/mobile.go
blob: bc380d29ac20c5f3a5b6ad010389d2deca2303a9 (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
// Package mobile is lectio's engine facade for the dlectio Android app. It is
// bound to a native Android library with gomobile:
//
//	gomobile bind -target=android/arm64,android/amd64 -androidapi 26 \
//	  -javapkg=xyz.labunix.dlectio.engine -tags fullbible \
//	  -o dlectio-engine.aar github.com/lukaszkasprzak/lectio/mobile
//
// -javapkg gives the bound Mobile class its xyz.labunix.dlectio.engine.mobile
// package (the app imports it from there); the two -target ABIs match the
// app's arm64-v8a and x86_64 device/emulator targets; -androidapi 26 matches
// the app's minSdk. Omitting any of the three still produces an .aar, just
// one the app cannot import or that lacks an ABI it needs.
//
// The app calls Day() with a plain date/form/version and receives one JSON
// document with the day's identity and rendered Mass readings. All liturgical
// computation and text resolution happens here, in the exact validated lectio
// engine — the app does none of it. Keep every exported signature to types
// gomobile can marshal -- strings and ints only -- with JSON as the payload
// format for everything structured. A Go int parameter (e.g. Days' count)
// surfaces on the Kotlin side as a long, not an Int.
package mobile

import (
	"encoding/json"
	"strings"
	"time"

	"github.com/lukaszkasprzak/lectio/internal/config"
	"github.com/lukaszkasprzak/lectio/internal/i18n"
	"github.com/lukaszkasprzak/lectio/internal/readings"
	"github.com/lukaszkasprzak/lectio/internal/render"
)

// reading is one Mass reading, rendered in the requested corpus.
type reading struct {
	Ord      int    `json:"ord"`
	Part     string `json:"part"`    // machine key, e.g. "ewangelium" / "psalm"
	Heading  string `json:"heading"` // human label in the interface language, e.g. "Gospel"
	Citation string `json:"citation"`
	Text     string `json:"text"`
}

// dayDoc is the JSON returned by Day.
type dayDoc struct {
	Date      string    `json:"date"`
	Form      string    `json:"form"`
	Lang      string    `json:"lang"`
	Version   string    `json:"version"`
	Name      string    `json:"name"`
	Season    string    `json:"season"`
	Colour    string    `json:"colour"`
	Rank      string    `json:"rank"`
	RankLabel string    `json:"rank_label"`
	Readings  []reading `json:"readings"`
	Error     string    `json:"error,omitempty"`
}

// lectByForm maps the app's form code to lectio's lectionary value.
func lectByForm(form string) string {
	if form == "ef" {
		return "traditional"
	}
	return "new"
}

// Day computes one day's identity and Mass readings and returns them as JSON.
//
//	date    - "YYYY-MM-DD"
//	form    - "of" (Ordinary) or "ef" (1962)
//	version - corpus: "drb" | "wuj" | "vul" | "grb"
//	lang    - interface language for the celebration name: "en" | "pl"
//
// It never panics: any engine error is reported in the JSON "error" field so the
// JNI boundary always returns a well-formed string.
func Day(date, form, version, lang string) string {
	lect := lectByForm(form)
	out := dayDoc{Date: date, Form: form, Lang: lang, Version: version}

	// Day identity (name/season/colour) in the interface language.
	cfgID := config.Config{UILanguage: lang, Lectionary: lect, All: true}
	// cfgID and cfgR below differ only in UILanguage/ReadingVersion, never in
	// Lectionary or Use -- the two inputs Prepare's cost depends on -- so one
	// Prepare (keyed off cfgID; either config would do) serves both Load
	// calls. See readings.Prepared's doc comment.
	p := readings.Prepare(cfgID)
	if _, info, err := readings.LoadWith(p, cfgID, readings.Options{Date: date, All: true}); err == nil {
		out.Name = info.Name
		out.Season = info.Season
		out.Colour = info.Colour
		out.Rank = info.Rank
		out.RankLabel = i18n.Get(lang).Rank[info.Rank]
	} else {
		out.Error = err.Error()
	}

	// Readings, resolved and rendered in the requested corpus. The interface
	// language localizes the structural labels (Heading) and citation dialect;
	// the corpus (version) alone decides the scripture text.
	cfgR := config.Config{UILanguage: lang, Lectionary: lect, ReadingVersion: version, All: true}
	secs, _, err := readings.LoadWith(p, cfgR, readings.Options{Date: date, All: true})
	if err != nil {
		if out.Error == "" {
			out.Error = err.Error()
		}
	} else {
		for i, sec := range secs {
			// GatherVersion's lang localizes only the label/error wording, never
			// the verse text (which the corpus alone decides), so the interface
			// language is correct here. HeadingWithRef gives the same localized
			// "label (citation)" heading lectio shows in its cli/tui/web.
			_, blocks := render.GatherVersion(version, sec, lect, lang)
			out.Readings = append(out.Readings, reading{
				Ord:      i,
				Part:     sec.PartID,
				Heading:  render.HeadingWithRef(sec, lang),
				Citation: sec.Citation,
				Text:     strings.Join(blocks, "\n"),
			})
		}
	}

	b, err := json.Marshal(out)
	if err != nil {
		// Should be impossible with these types; degrade gracefully.
		return `{"error":"marshal failed"}`
	}
	return string(b)
}

// summaryPart is one section of a day, citation only -- no rendered text.
type summaryPart struct {
	Part     string `json:"part"`
	Citation string `json:"citation"`
}

// daySummary is one day's identity and its reading citations. It is Day's
// cheaper projection: the calendar view needs the day's shape, not its text.
type daySummary struct {
	Date      string        `json:"date"`
	Name      string        `json:"name"`
	Rank      string        `json:"rank"`
	RankLabel string        `json:"rank_label"`
	Colour    string        `json:"colour"`
	Parts     []summaryPart `json:"parts"`
	Error     string        `json:"error,omitempty"`
}

// Days returns count consecutive days from start as a JSON array, each with its
// identity and reading citations but no reading text. It is the calendar view's
// source.
//
//	start - "YYYY-MM-DD", taken literally: aligning to a week is the caller's job
//	count - 1..366; anything else yields "[]"
//	form  - "of" or "ef"
//	lang  - interface language for the name, rank word and citation dialect
//
// No version parameter: the corpus decides only scripture text, which this never
// renders, while the citation's sigla dialect follows lang.
//
// A day that fails to compute carries its own "error" and does not abort the
// rest, so one bad date cannot blank a whole week. Malformed input yields "[]"
// rather than an error string, so the caller always parses an array.
func Days(start string, count int, form, lang string) string {
	if count < 1 || count > 366 {
		return "[]"
	}
	d, err := time.Parse("2006-01-02", start)
	if err != nil {
		return "[]"
	}
	lect := lectByForm(form)
	ui := i18n.Get(lang)
	cfg := config.Config{UILanguage: lang, Lectionary: lect, All: true}
	// cfg is identical for every date in the loop below, so the layer stack
	// and book table it implies are too -- Prepare once here rather than
	// letting each readings.Load call re-stack/re-parse them. See
	// readings.Prepared's doc comment.
	p := readings.Prepare(cfg)

	out := make([]daySummary, 0, count)
	for i := 0; i < count; i++ {
		date := d.AddDate(0, 0, i).Format("2006-01-02")
		row := daySummary{Date: date, Parts: []summaryPart{}}
		secs, info, err := readings.LoadWith(p, cfg, readings.Options{Date: date, All: true})
		if err != nil {
			row.Error = err.Error()
		} else {
			row.Name = info.Name
			row.Colour = info.Colour
			row.Rank = info.Rank
			row.RankLabel = ui.Rank[info.Rank]
			for _, sec := range secs {
				row.Parts = append(row.Parts, summaryPart{Part: sec.PartID, Citation: sec.Citation})
			}
		}
		out = append(out, row)
	}

	b, err := json.Marshal(out)
	if err != nil {
		return "[]"
	}
	return string(b)
}

// partLabel is one section's machine ID and its human label.
type partLabel struct {
	Part  string `json:"part"`
	Label string `json:"label"`
}

// PartLabels returns the form's section IDs and their labels in lang, in display
// order, as a JSON array. The app builds its reading filters from this instead
// of hardcoding part IDs -- which is how it came to ship seven 1962 checkboxes
// the engine never emits.
//
// An array, not an object: JSON object key order is not guaranteed and the app
// renders these in order.
func PartLabels(form, lang string) string {
	ids := readings.PartIDs(lectByForm(form))
	ui := i18n.Get(lang)
	out := make([]partLabel, 0, len(ids))
	for _, id := range ids {
		label := ui.PartLabel[id]
		if label == "" {
			label = id
		}
		out = append(out, partLabel{Part: id, Label: label})
	}
	b, err := json.Marshal(out)
	if err != nil {
		return "[]"
	}
	return string(b)
}

// Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the
// native engine loaded before issuing a real query.
func Ping() string { return "pong" }