// Package tradlit fetches and parses the Traditional (1962 Missal) Latin // propers from the missalemeum JSON API into the shared liturgy.Section // type, so the rest of the app can treat the traditional and modern // (niedziela.pl) lectionaries interchangeably. package tradlit import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) // baseURL is the missalemeum proper-of-the-day API template ("%s" is lang, // then date, YYYY-MM-DD). It is a package var so tests can point it at an // httptest server. var baseURL = "https://www.missalemeum.com/%s/api/v5/proper/%s" // userAgent is sent on every fetch; matches the browser UA used elsewhere // in this project (see the plan's Global Constraints). const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" // citationRe matches the first *...* marker in a section's body text, e.g. // "*Luke 7:36-50*" or "*Ps 44:2*". var citationRe = regexp.MustCompile(`\*([^*\n]+)\*`) // apiResponse mirrors the shape of the missalemeum proper-of-the-day API: // a single-element list of { info, sections }. type apiResponse struct { Info apiInfo `json:"info"` Sections []apiSection `json:"sections"` } // apiInfo mirrors the missalemeum API's "info" object: the day's celebration // title, its temporal context ("tempora"), and its liturgical colour code(s) // -- see dayInfoFromAPI, which turns it into a liturgy.DayInfo. type apiInfo struct { Title string `json:"title"` Tempora string `json:"tempora"` Colors []string `json:"colors"` } type apiSection struct { ID string `json:"id"` Label string `json:"label"` Body [][]string `json:"body"` } // tradColours maps missalemeum's single-letter liturgical colour codes to // DayInfo's normalized colour names; a code not listed here (or an empty // Colors list) leaves DayInfo.Colour "". var tradColours = map[string]string{ "w": "white", "r": "red", "v": "violet", "g": "green", "p": "rose", } // dayInfoFromAPI turns a response's info object into a liturgy.DayInfo: // Title -> Name, Tempora -> Season, and the first Colors code -> Colour via // tradColours (unknown/missing -> ""). A zero-value apiInfo (no "info" key // in the response) yields a zero DayInfo. func dayInfoFromAPI(info apiInfo) liturgy.DayInfo { colour := "" if len(info.Colors) > 0 { colour = tradColours[strings.ToLower(info.Colors[0])] } return liturgy.DayInfo{ Name: info.Title, Season: info.Tempora, Colour: colour, } } // Parse decodes a missalemeum proper-of-the-day API response body into // liturgy.Sections plus the day's liturgical identity (its "info" object, // see dayInfoFromAPI). Sections with an empty id or empty body are skipped. func Parse(jsonBody []byte) ([]liturgy.Section, liturgy.DayInfo, error) { var resp []apiResponse if err := json.Unmarshal(jsonBody, &resp); err != nil { return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: parse: %w", err) } if len(resp) == 0 { return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: parse: empty response") } info := dayInfoFromAPI(resp[0].Info) var out []liturgy.Section for _, sec := range resp[0].Sections { if sec.ID == "" || len(sec.Body) == 0 || len(sec.Body[0]) == 0 { continue } text := strings.Join(sec.Body[0], "\n") var lines []string for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line != "" { lines = append(lines, line) } } citation := "" if m := citationRe.FindStringSubmatch(text); m != nil { citation = strings.TrimSpace(m[1]) } out = append(out, liturgy.Section{ Heading: sec.Label, Citation: citation, PartID: strings.ToLower(sec.ID), Paragraphs: [][]string{lines}, }) } return out, info, nil } // cachePath returns where Load caches a (date, lang) day's raw API response: // /.trad..json -- date-prefixed (like the modern // lectionary's own cache files) so liturgy.CleanCache can prune it by date. func cachePath(date, lang string) string { return filepath.Join(liturgy.CacheDir(), date+".trad."+lang+".json") } // Load returns a day's traditional propers for lang, either read from the // on-disk cache (offline) or fetched live from missalemeum and cached for // next time (online). Both paths share Parse, so cached and live results // are identical. // // - offline: reads the cache file written by a prior online Load (see // cachePath); if it doesn't exist, returns a clear error telling the // caller to go online or run 'lectio update' first. // - online: fetches https://www.missalemeum.com/{lang}/api/v5/proper/{date} // as before. On a successful 200, the raw response body is written to // the cache path (best-effort -- a cache-write failure never fails the // request) before being parsed. On HTTP 404 (no propers published for // that date) it returns a clear error and writes nothing to the cache. func Load(date, lang string, offline bool) ([]liturgy.Section, liturgy.DayInfo, error) { if offline { return loadCached(date, lang) } return loadLive(date, lang) } // loadCached implements Load's offline path. func loadCached(date, lang string) ([]liturgy.Section, liturgy.DayInfo, error) { body, err := os.ReadFile(cachePath(date, lang)) if err != nil { return nil, liturgy.DayInfo{}, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang) } return Parse(body) } // loadLive implements Load's online path: fetch, best-effort cache the raw // body, then parse. func loadLive(date, lang string) ([]liturgy.Section, liturgy.DayInfo, error) { body, err := fetch(date, lang) if err != nil { return nil, liturgy.DayInfo{}, err } writeCache(date, lang, body) return Parse(body) } // fetch GETs the day's proper-of-the-day JSON from missalemeum and returns // the raw response body. On HTTP 404 (no propers published for that date) // it returns a clear error rather than the raw 404 body. func fetch(date, lang string) ([]byte, error) { url := fmt.Sprintf(baseURL, lang, date) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("tradlit: load: %w", err) } req.Header.Set("User-Agent", userAgent) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("tradlit: load %s: %w", date, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("tradlit: no propers published for %s", date) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("tradlit: load %s: unexpected status %s", date, resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("tradlit: load %s: %w", date, err) } return body, nil } // writeCache best-effort writes a day's raw API response body to its cache // path; a failure to cache (e.g. an unwritable cache dir) must never fail // the live request that produced body. func writeCache(date, lang string, body []byte) { path := cachePath(date, lang) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return } _ = os.WriteFile(path, body, 0o644) }