// Package render turns a liturgy.Section, one version at a time, into text // output: the deduped Polish paragraphs or a bible tool's verse lines, and // lays several versions out side by side for comparison. It is shared by // the CLI and the TUI (which re-styles the same gathered data). package render import ( "fmt" "strings" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) // versionLabel returns version's column-header/prose label in lang (see // internal/i18n.Get), falling back to the bare version code if lang has no // entry for it. lang="pl" reproduces ewangelia.py's original VERSION_LABELS // exactly. func versionLabel(version, lang string) string { if l, ok := i18n.Get(lang).Version[version]; ok { return l } return version } // modernPartOrder is the fixed, deterministic order LocalizeHeading tries // the known modern (niedziela.pl) pl part labels in -- a plain slice rather // than ranging over i18n.UI.PartLabel (a map, so Go randomises its // iteration order). None of the five labels is a prefix of another, so the // order never changes which one matches, only determinism. var modernPartOrder = []string{"pierwsze_czytanie", "drugie_czytanie", "psalm", "aklamacja", "ewangelia"} // LocalizeHeading swaps a modern (niedziela.pl) section heading's leading // label word for its lang translation, keeping the rest of the heading (the // parenthetical citation, exactly as scraped) untouched: e.g. // "Ewangelia (J 20, 1. 11-18)" with lang "en" becomes "Gospel (J 20, 1. // 11-18)". It only ever localises the label word, never the citation or the // verse text. // // It matches against heading's own leading text, not partID: a split // (two-reading) feast day scrapes PartID="drugie_czytanie" onto a heading // that niedziela.pl still literally titles "1. czytanie ..." (its own // numbering quirk carries over from the undivided day), so looking up the // Polish label by partID and checking heading's prefix against only that // one label misses it. Instead every known pl label (modernPartOrder) is // tried against heading in turn; partID itself is unused -- kept in the // signature for callers, which all have it in hand already (sec.PartID). // // It is a safe no-op (returns heading unchanged) unless lang is "en" and // heading actually starts with one of the known pl labels -- which excludes // traditional (missalemeum) headings, already in the requested language, // and anything unrecognised. func LocalizeHeading(heading, partID, lang string) string { if lang != "en" { return heading } plUI, enUI := i18n.Get("pl"), i18n.Get("en") for _, id := range modernPartOrder { plLabel := plUI.PartLabel[id] if strings.HasPrefix(heading, plLabel) { return enUI.PartLabel[id] + heading[len(plLabel):] } } return heading } // versionSystem maps a bible version to the Psalter system bible.ToEnglishRef // expects: vul/grb/wuj follow the Vulgate/Septuagint numbering, drb follows // the Hebrew chapter with the DRB title-fold verse shift. Ported from // ewangelia.py PSALM_SYSTEM. var versionSystem = map[string]string{ "vul": "vulgate", "grb": "vulgate", "wuj": "vulgate", "drb": "drb", } func system(version string) string { if s, ok := versionSystem[version]; ok { return s } return "vulgate" } const incipit = "Słowa Ewangelii" // GatherVersion returns (label, blocks) for one version of one reading // section. Each block is a paragraph (Polish) or a verse line (bible // versions); on any failure the blocks hold a single short note instead. // // lectionary selects how sec.Citation is read: "new" (modern/niedziela.pl) // citations are Polish and go through bible.ToEnglishRef; "traditional" // (missalemeum) citations are already English kjv-style and are used as-is. // lang selects the UI chrome language the label comes from (see // internal/i18n); it never affects the verse text/citation itself. func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) (label string, blocks []string) { label = versionLabel(version, lang) if version == "pl" { return label, gatherPL(sec) } ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, []string{err.Error()} } verses, missing := bible.Lookup(version, ref) if len(verses) == 0 { return label, []string{fmt.Sprintf(i18n.Get(lang).NoVersion, version)} } blocks = make([]string, 0, len(verses)) for _, v := range verses { blocks = append(blocks, fmt.Sprintf("%d:%d %s", v.Chapter, v.Verse, v.Text)) } if len(missing) > 0 { blocks = append(blocks, fmt.Sprintf(i18n.Get(lang).NoVersionPartial, version, strings.Join(missing, ", "))) } return label, blocks } // resolveRef resolves a non-"pl" version's bible.Lookup reference for sec: // sec.Citation (or, failing that, the citation extracted from sec.Heading), // converted to English/kjv-style via bible.ToEnglishRef when // lectionary=="new" (a "traditional" citation is already English-style and // used as-is). Shared by GatherVersion and GatherVerses so both apply the // exact same resolution. lang selects the wording of the two failure // messages it can return (see internal/i18n.UI.NoReference/NoReferenceErr); // it never affects which reference is resolved. func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (string, error) { ui := i18n.Get(lang) citation := sec.Citation if citation == "" { if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { citation = c } } if citation == "" { return "", fmt.Errorf("%s", ui.NoReference) } if lectionary != "new" { return citation, nil } ref, err := bible.ToEnglishRef(citation, system(version)) if err != nil { return "", fmt.Errorf(ui.NoReferenceErr, err) } return ref, nil } // GatherVerses returns one version's verses for a section as raw bible.Verse // structs (for column/interlinear alignment). versified is false for "pl" // (paragraph text, no verse numbers) and on any resolution/lookup failure -- // callers fall back to GatherVersion's string blocks for those. lang selects // the UI chrome language the label comes from, same as GatherVersion. func GatherVerses(version string, sec liturgy.Section, lectionary, lang string) (label string, verses []bible.Verse, versified bool) { label = versionLabel(version, lang) if version == "pl" { return label, nil, false } ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, nil, false } verses, _ = bible.Lookup(version, ref) if len(verses) == 0 { return label, nil, false } return label, verses, true } // gatherPL returns the section's paragraphs, one block per paragraph, with // the liturgical incipit ("Słowa Ewangelii według ...") dropped and repeated // blocks (a responsorial psalm's refrain) deduped to their first occurrence. func gatherPL(sec liturgy.Section) []string { var blocks []string for _, p := range sec.Paragraphs { b := strings.Join(p, " ") if strings.HasPrefix(b, incipit) { continue } blocks = append(blocks, b) } seen := map[string]bool{} deduped := make([]string, 0, len(blocks)) for _, b := range blocks { if seen[b] { continue } seen[b] = true deduped = append(deduped, b) } return deduped } // OfflineVersions drops "pl" (which needs the network fetch of the liturgy // page) from versions. If "pl" was present but "wuj" (the Polish-language // bible version) was not, "wuj" takes pl's place, so the offline set still // carries a Polish column. func OfflineVersions(versions []string) []string { hadWuj := false for _, v := range versions { if v == "wuj" { hadWuj = true break } } out := make([]string, 0, len(versions)) for _, v := range versions { if v == "pl" { if hadWuj { continue } out = append(out, "wuj") continue } out = append(out, v) } return out } // EffectiveVersions is the version set actually loadable for a request: "pl" // (the niedziela.pl modern scrape) is dropped -- substituting "wuj" if it was // the only Polish column, via OfflineVersions -- whenever the scrape is // unavailable: offline (never fetch) OR the traditional lectionary // (missalemeum, which has no niedziela.pl scrape). Shared by cli, tui and web // so all three binaries treat traditional/offline versions identically. func EffectiveVersions(versions []string, lectionary string, offline bool) []string { if offline || lectionary == "traditional" { return OfflineVersions(versions) } return versions } // Compare lays the versions of one reading section out as parallel columns, // side by side, wrapped to fit width. Ports render_compare. lang selects the // column-header label language (see GatherVersion). func Compare(secs []liturgy.Section, versions []string, width int, lectionary, lang string) string { var out []string for _, sec := range secs { out = append(out, compareSection(sec, versions, width, lectionary, lang)) } return strings.Join(out, "\n\n") } func compareSection(sec liturgy.Section, versions []string, width int, lectionary, lang string) string { type column struct { label string lines []string } n := len(versions) if n == 0 { return "" } const gap = " " w := (width - len(gap)*(n-1)) / n if w < 24 { w = 24 } cols := make([]column, 0, n) height := 0 for _, v := range versions { label, blocks := GatherVersion(v, sec, lectionary, lang) var lines []string for _, b := range blocks { lines = append(lines, strings.Split(wrap(b, w), "\n")...) lines = append(lines, "") } if len(lines) > 0 { lines = lines[:len(lines)-1] // drop trailing blank } if len(lines) > height { height = len(lines) } cols = append(cols, column{label: label, lines: lines}) } labelCells := make([]string, n) dashCells := make([]string, n) for i, c := range cols { labelCells[i] = ljust(truncate(c.label, w), w) dashCells[i] = strings.Repeat("-", w) } rows := []string{ strings.TrimRight(strings.Join(labelCells, gap), " "), strings.Join(dashCells, gap), } for i := 0; i < height; i++ { cells := make([]string, n) for ci, c := range cols { cell := "" if i < len(c.lines) { cell = c.lines[i] } cells[ci] = ljust(cell, w) } rows = append(rows, strings.TrimRight(strings.Join(cells, gap), " ")) } return strings.Join(rows, "\n") } // wrap wraps line to width, greedily packing whole words onto each output // line and never splitting a word (even one longer than width), matching // ewangelia.py's textwrap.fill(..., break_long_words=False, // break_on_hyphens=False). func wrap(line string, width int) string { words := strings.Fields(line) if len(words) == 0 { return "" } var out []string cur := words[0] for _, word := range words[1:] { if len([]rune(cur))+1+len([]rune(word)) <= width { cur += " " + word } else { out = append(out, cur) cur = word } } out = append(out, cur) return strings.Join(out, "\n") } func ljust(s string, w int) string { if n := w - len([]rune(s)); n > 0 { return s + strings.Repeat(" ", n) } return s } func truncate(s string, w int) string { r := []rune(s) if len(r) <= w { return s } return string(r[:w]) }