// Package cli is lectio's flag-driven command dispatcher: it wires config, // the readings router, liturgy.Harvest and render together into the // `lectio` binary's Run entry point. package cli import ( "flag" "fmt" "io" "math/rand" "os" "regexp" "strconv" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/export" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" "github.com/lukaszkasprzak/lectio/internal/tradlit" ) const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions) Usage: lectio [DATE] [flags] DATE = YYYY-MM-DD (default: today), any position Flags: -a, --all all readings, not just the gospel -b, --bible VER one version's text: bt,wuj,vul,grb,drb -c, --compare LIST versions side by side (comma-separated) -p, --ref REF look up a passage (e.g. "J 3:16") with -b/-c -r, --raw text only, no banner/headings (for piping) -w, --width N wrap width; 0 = detect terminal -R, --refresh ignore cache, re-download -o, --offline cache/sigla only, no network -l, --lectionary WHICH new|trad (trad -> traditional) -g, --lang LANG traditional lectionary language: pl|en --ui-lang LANG override interface/export language: pl|en -u, --update harvest sigla maximally to the horizon (idempotent) -C, --clean prune cached readings older than a year -P, --pager page reading output (like git); default from config --no-pager never page, even if config sets one --list list all books + abbreviations (dialect from sigla_style) --citation print the day's gospel reference (scripts/cron) and exit --week list the coming week's gospel references and exit -L, --liturgy print the computed liturgical day (offline, no network) and exit --cal-new NAME scaffold a custom calendar layer ~/.config/lectio/calendars/NAME.ini --cal-check NAME validate a custom calendar layer NAME.ini and exit --corpus-check X validate a bible corpus (code, e.g. drb, or a path to a .tsv) and exit; errors fail, coverage gaps warn --json with --corpus-check, print the report as JSON --rand, --rand-v print a random verse and exit (uses default_version or -b VER; bt has no corpus, so it falls back to a corpus version) --rand-ch print a random chapter and exit --md export the readings as Markdown (to --out or stdout) --pdf export the readings as PDF (to --out or stdout) --out FILE write --md/--pdf/--calendar output to FILE --calendar YYYY-MM export the month as a printable A4 PDF calendar --format WHICH json|ical: emit the computed calendar and exit --from DATE range start YYYY-MM-DD (with --to; needs --format) --to DATE range end YYYY-MM-DD (with --from; needs --format) --year YYYY emit the whole year YYYY (needs --format) --form WHICH override calendar form old|new (needs --format) -v, --version print the version and exit -h, --help this help Versions: bt (Biblia Tysiąclecia) wuj (Wujek) vul (Wulgata) grb (Grecki) drb (Douay-Rheims) Examples: lectio today's gospel lectio 2026-07-22 -a all readings for a date lectio -c bt,wuj 2026-07-22 compare two versions for a date lectio -b vul -a Vulgate text, all readings lectio -l trad -a traditional lectionary, all readings lectio -u harvest sigla to the horizon lectio -p "Jn 3:16" -b vul look up a passage (English sigla) lectio -p "J 3,16" -c wuj,drb Polish sigla when sigla_style=polish/auto+pl UI lectio --list list every book + abbreviations lectio --citation today's gospel reference lectio --week 2026-07-22 a week of gospel references from a date lectio --rand-v a random verse (for cron/prompt) Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang). ` const ( defaultWidth = 80 compareDefaultWidth = 120 ) var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) func today() string { return time.Now().Format("2006-01-02") } // Run is the CLI entry point; returns a process exit code. func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if wantsHelp(args) { fmt.Fprint(stdout, helpText) return 0 } if wantsVersion(args) { fmt.Fprintln(stdout, "lectio "+config.Version) return 0 } if len(args) > 0 && args[0] == "help" { fmt.Fprint(stdout, helpText) return 0 } date, rest, err := extractDate(args) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 2 } var all, raw, refresh, offline, update, clean, pagerFlag, noPager, citation, week bool var randV, randCh bool var expMD, expPDF bool var output, calendar, uiLang string var bibleVer, compareList, lectionary, lang string var width int var list bool var ref string var liturgy bool var calNew, calCheck string var corpusCheck string var jsonOut bool var format, fromFlag, toFlag, yearFlag, formFlag string fs := flag.NewFlagSet("lectio", flag.ContinueOnError) fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, helpText) } fs.BoolVar(&all, "a", false, "all readings, not just the gospel") fs.BoolVar(&all, "all", false, "all readings, not just the gospel") fs.StringVar(&bibleVer, "b", "", "one version's text: bt,wuj,vul,grb,drb") fs.StringVar(&bibleVer, "bible", "", "one version's text: bt,wuj,vul,grb,drb") fs.StringVar(&compareList, "c", "", "versions side by side (comma list)") fs.StringVar(&compareList, "compare", "", "versions side by side (comma list)") fs.BoolVar(&raw, "r", false, "text only, no banner/headings") fs.BoolVar(&raw, "raw", false, "text only, no banner/headings") fs.IntVar(&width, "w", 0, "wrap width (0 = detect terminal)") fs.IntVar(&width, "width", 0, "wrap width (0 = detect terminal)") fs.BoolVar(&refresh, "R", false, "ignore cache, re-download") fs.BoolVar(&refresh, "refresh", false, "ignore cache, re-download") fs.BoolVar(&offline, "o", false, "cache/sigla only, no network") fs.BoolVar(&offline, "offline", false, "cache/sigla only, no network") fs.StringVar(&lectionary, "l", "", "new|trad") fs.StringVar(&lectionary, "lectionary", "", "new|trad") fs.StringVar(&lang, "g", "", "pl|en") fs.StringVar(&lang, "lang", "", "pl|en") fs.StringVar(&uiLang, "ui-lang", "", "override interface/export language: pl|en") fs.BoolVar(&update, "u", false, "harvest sigla maximally to the horizon") fs.BoolVar(&update, "update", false, "harvest sigla maximally to the horizon") fs.BoolVar(&clean, "C", false, "prune cached readings older than a year") fs.BoolVar(&clean, "clean", false, "prune cached readings older than a year") fs.BoolVar(&pagerFlag, "P", false, "page reading output (like git)") fs.BoolVar(&pagerFlag, "pager", false, "page reading output (like git)") fs.BoolVar(&noPager, "no-pager", false, "never page, even if config sets one") fs.StringVar(&ref, "p", "", "look up a passage, e.g. \"J 3:16\" (with -b/-c)") fs.StringVar(&ref, "ref", "", "look up a passage, e.g. \"J 3:16\" (with -b/-c)") fs.BoolVar(&list, "list", false, "list all books + abbreviations (in your sigla_style)") fs.BoolVar(&citation, "citation", false, "print the day's gospel reference and exit") fs.BoolVar(&week, "week", false, "list the coming week's gospel references and exit") fs.BoolVar(&randV, "rand", false, "print a random verse from the corpus and exit") fs.BoolVar(&randV, "rand-v", false, "print a random verse from the corpus and exit") fs.BoolVar(&randCh, "rand-ch", false, "print a random chapter from the corpus and exit") fs.BoolVar(&expMD, "md", false, "export the readings as Markdown") fs.BoolVar(&expPDF, "pdf", false, "export the readings as PDF") fs.StringVar(&output, "out", "", "write --md/--pdf output to FILE (default: stdout)") fs.StringVar(&output, "output", "", "write --md/--pdf output to FILE (default: stdout)") fs.StringVar(&calendar, "calendar", "", "export a month (YYYY-MM) as a printable A4 PDF calendar") fs.BoolVar(&liturgy, "L", false, "print the computed liturgical day (offline calendar engine) and exit") fs.BoolVar(&liturgy, "liturgy", false, "print the computed liturgical day (offline calendar engine) and exit") fs.StringVar(&calNew, "cal-new", "", "scaffold a new calendar-layer file NAME.ini and exit") fs.StringVar(&calCheck, "cal-check", "", "validate the calendar-layer file NAME.ini and exit") fs.StringVar(&corpusCheck, "corpus-check", "", "validate a bible corpus (code or path to .tsv) and exit") fs.BoolVar(&jsonOut, "json", false, "with --corpus-check, print the report as JSON") fs.StringVar(&format, "format", "", "json|ical: emit the computed calendar and exit") fs.StringVar(&fromFlag, "from", "", "range start YYYY-MM-DD (with --to; needs --format)") fs.StringVar(&toFlag, "to", "", "range end YYYY-MM-DD (with --from; needs --format)") fs.StringVar(&yearFlag, "year", "", "emit the whole year YYYY (needs --format)") fs.StringVar(&formFlag, "form", "", "override calendar form: old|new (needs --format)") if err := fs.Parse(rest); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(stderr, "lectio: unexpected argument %q; see 'lectio -h'\n", fs.Arg(0)) return 2 } lectionary, err = normalizeLectionary(lectionary) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 2 } if lang != "" && lang != "pl" && lang != "en" { fmt.Fprintf(stderr, "lectio: invalid --lang %q (want pl|en)\n", lang) return 2 } if clean { return runClean(stdout, stderr) } if update { return runHarvest(date, stdout, stderr) } if calNew != "" { return runCalNew(calNew, stdout, stderr) } if calCheck != "" { return runCalCheck(calCheck, stdout, stderr) } if corpusCheck != "" { return runCorpusCheck(corpusCheck, jsonOut, stdout, stderr) } cfg, err := config.Load() if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if dir, err := config.CorporaDir(); err == nil { bible.SetUserCorporaDir(dir) } if offline { cfg.Offline = true } if lectionary != "" { cfg.Lectionary = lectionary } if lang != "" { cfg.TraditionalLang = lang } if uiLang != "" { cfg.UILanguage = config.NormalizeUILanguage(uiLang) } if format != "" || fromFlag != "" || toFlag != "" || yearFlag != "" || formFlag != "" { return runFeed(cfg, format, date, fromFlag, toFlag, yearFlag, formFlag, stdout, stderr) } if liturgy { return runLiturgy(cfg, date, stdout, stderr) } if citation || week || randV || randCh { tbl, _ := bible.LoadBookTable(userBooksINI()) switch { case randV || randCh: ver, verr := randVersion(cfg, bibleVer) if verr != nil { fmt.Fprintln(stderr, "lectio:", verr) return 2 } return runRand(cfg, tbl, ver, randCh, raw, width, stdout, stderr) case citation: return runCitation(cfg, tbl, date, refresh, stdout, stderr) default: return runWeek(cfg, tbl, date, refresh, stdout, stderr) } } effAll := all || cfg.All if expMD || expPDF { ver := bibleVer if ver == "" { ver = cfg.DefaultVersion } if !config.ValidVersion(ver) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", ver) return 2 } return runExport(cfg, date, ver, effAll, refresh, expPDF, output, stdout, stderr) } if calendar != "" { return runCalendar(cfg, calendar, output, stdout, stderr) } effWidth := width if effWidth == 0 { effWidth = cfg.Width } if effWidth == 0 { // Detect the real terminal width now, from stdout, before it may be // replaced by the pager's pipe below -- otherwise paging would wrap at // the default width instead of the terminal's. if tw := termWidth(stdout); tw > 0 { effWidth = tw } } // Validate versions before any pager starts -- don't page an error. var refVersions []string if ref != "" { rv, verr := refLookupVersions(cfg, bibleVer, compareList) if verr != nil { fmt.Fprintln(stderr, "lectio:", verr) return 2 } refVersions = rv } else { // Day-reading paths: bt is a valid version here (compareList wins over // bibleVer, as before, so bibleVer is left unvalidated when a list is given). if bibleVer != "" && compareList == "" && !config.ValidVersion(bibleVer) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", bibleVer) return 2 } if compareList != "" { for _, v := range strings.Split(compareList, ",") { if v = strings.TrimSpace(v); v != "" && !config.ValidVersion(v) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", v) return 2 } } } } var bookTbl *bible.BookTable if list || ref != "" { tbl, terr := bible.LoadBookTable(userBooksINI()) if terr != nil { fmt.Fprintln(stderr, "lectio:", terr) // warn; tbl is still a usable defaults table } bookTbl = tbl } out := stdout finish := func() {} if pagerRequested(pagerFlag, noPager, cfg) && isTerminalWriter(stdout) { if o, f, ok := startPager(pagerCommand(cfg), stdout, stderr); ok { out = o finish = f } } var code int switch { case list: code = runList(bookTbl, cfg.SiglaLang(), out) case ref != "": code = lookupRef(cfg, bookTbl, ref, refVersions, raw, effWidth, out, stderr) case compareList != "": code = renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, out, stderr) case bibleVer != "": code = fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, out, stderr) default: code = fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, out, stderr) } finish() return code } // gospelSection returns the gospel section from a (gospel-only, All=false) // load: the section tagged "ewangelia" (modern) or "evangelium" (traditional), // else the first section present. ok is false only when secs is empty. func gospelSection(secs []liturgy.Section) (liturgy.Section, bool) { for _, s := range secs { if s.PartID == "ewangelia" || s.PartID == "evangelium" { return s, true } } if len(secs) > 0 { return secs[0], true } return liturgy.Section{}, false } // gospelCitation returns a section's scripture reference: its Citation field if // set, else the reference parsed out of its Heading (e.g. "Ewangelia (Mt 7, // 1-5)" -> "Mt 7, 1-5"), else "" (source-form, never translated). func gospelCitation(sec liturgy.Section) string { if sec.Citation != "" { return sec.Citation } if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { return c } return "" } // dialectCitation renders a gospel section's reference in the configured sigla // dialect (cfg.SiglaLang(): sigla_style, or -- "auto" -- ui_language). The // source citation's language is the lectionary's: Polish for the modern // (niedziela) lectionary, cfg.TraditionalLang for the traditional one. The ref // is parsed to canonical and re-rendered in the target dialect; a citation // whose book can't be parsed comes back in its source form unchanged. func dialectCitation(cfg config.Config, tbl *bible.BookTable, sec liturgy.Section) string { raw := gospelCitation(sec) if raw == "" || tbl == nil { return raw } sourceLang := "pl" if cfg.Lectionary == "traditional" { sourceLang = cfg.TraditionalLang } canonical, ok := tbl.ParseRef(sourceLang, raw) if !ok { return raw } return tbl.FormatRef(cfg.SiglaLang(), canonical) } // runExport handles --md/--pdf: load the day's readings and write them as // Markdown or PDF to --out FILE (or stdout). Honors date/lectionary/all/offline // and the version (bt is fine -- it is the niedziela text -- but is swapped for // traditional/offline via EffectiveVersions). func runExport(cfg config.Config, date, version string, all, refresh, asPDF bool, output string, stdout, stderr io.Writer) int { secs, info, err := readings.Load(cfg, readings.Options{Date: date, Refresh: refresh, Offline: cfg.Offline, All: all}) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { version = vs[0] } var data []byte if asPDF { data, err = export.ReadingsPDF(date, info, secs, version, cfg.Lectionary, cfg.UILanguage) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } } else { data = []byte(export.Markdown(date, info, secs, version, cfg.Lectionary, cfg.UILanguage)) } if output != "" { if err := os.WriteFile(output, data, 0o644); err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } return 0 } _, _ = stdout.Write(data) return 0 } // runCalendar handles --calendar YYYY-MM: gather each day's celebration name + // gospel reference + liturgical colour for the month (gospel-only loads, offline // -aware) and render a printable A4 PDF calendar to --out FILE (or stdout). func runCalendar(cfg config.Config, ym, output string, stdout, stderr io.Writer) int { t, err := time.Parse("2006-01", ym) if err != nil { fmt.Fprintf(stderr, "lectio: invalid --calendar %q (want YYYY-MM)\n", ym) return 2 } year, month := t.Year(), int(t.Month()) first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) var days []export.CalendarDay for d := first; int(d.Month()) == month; d = d.AddDate(0, 0, 1) { cd := export.CalendarDay{Day: d.Day()} if secs, info, lerr := readings.Load(cfg, readings.Options{Date: d.Format("2006-01-02"), Offline: cfg.Offline, All: false}); lerr == nil { cd.Name = info.Name cd.Colour = info.Colour cd.Citation = readings.GospelCitation(secs) } days = append(days, cd) } data, err := export.CalendarPDF(year, month, days, cfg.Lectionary, cfg.UILanguage) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if output != "" { if err := os.WriteFile(output, data, 0o644); err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } return 0 } _, _ = stdout.Write(data) return 0 } // runCitation handles --citation: fetch the day's gospel (gospel-only) and // print just its scripture reference in the configured sigla dialect (see // dialectCitation), for scripts/cron/prompt use. Honors the resolved cfg // (lectionary/lang/offline) and date. Exit 1 if the day has no gospel // reference (unpublished date, no network while offline, ...). func runCitation(cfg config.Config, tbl *bible.BookTable, date string, refresh bool, stdout, stderr io.Writer) int { secs, _, err := readings.Load(cfg, readings.Options{Date: date, Refresh: refresh, Offline: cfg.Offline, All: false}) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } sec, ok := gospelSection(secs) if !ok { fmt.Fprintln(stderr, "lectio: no gospel for", date) return 1 } cit := dialectCitation(cfg, tbl, sec) if cit == "" { fmt.Fprintln(stderr, "lectio: no gospel reference for", date) return 1 } fmt.Fprintln(stdout, cit) return 0 } // runWeek handles --week: print the gospel reference for each of the seven days // starting at date, one "YYYY-MM-DD " line per day. A day that // can't be loaded (unpublished, offline gap, no gospel) shows "—" rather than // aborting the run, so the list is always seven lines. Honors cfg // (lectionary/lang/offline). func runWeek(cfg config.Config, tbl *bible.BookTable, date string, refresh bool, stdout, stderr io.Writer) int { start, err := time.Parse("2006-01-02", date) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 2 } for i := 0; i < 7; i++ { d := start.AddDate(0, 0, i).Format("2006-01-02") cit := "—" secs, _, err := readings.Load(cfg, readings.Options{Date: d, Refresh: refresh, Offline: cfg.Offline, All: false}) if err == nil { if sec, ok := gospelSection(secs); ok { if c := dialectCitation(cfg, tbl, sec); c != "" { cit = c } } } fmt.Fprintf(stdout, "%s %s\n", d, cit) } return 0 } // userBooksTOML returns the bytes of the optional user books.toml, or nil if // it is absent/unreadable (built-in defaults are used). func userBooksINI() []byte { return config.UserBooksINI() } // wantsHelp reports whether -h/--help appears anywhere in args. func wantsHelp(args []string) bool { for _, a := range args { if a == "-h" || a == "--help" { return true } } return false } // wantsVersion reports whether -v/--version appears anywhere in args (like // wantsHelp), so `lectio DATE -v` prints the version rather than erroring on // an "undefined flag". func wantsVersion(args []string) bool { for _, a := range args { if a == "-v" || a == "--version" { return true } } return false } // rangeDateFlags are the flags whose own value is itself a YYYY-MM-DD date // (--from/--to, in either one- or two-dash spelling -- Go's flag package // treats them the same). extractDate must not mistake such a value for the // positional DATE token. var rangeDateFlags = map[string]bool{"--from": true, "-from": true, "--to": true, "-to": true} // extractDate pulls the single positional DATE token (YYYY-MM-DD, matching // dateRe) out of args, wherever it appears, and returns it along with the // remaining tokens for flag.FlagSet to parse. No other flag's own value can // match dateRe's shape (width is an int, versions/lists/lang/lectionary are // short codes) except --from/--to's, which are skipped explicitly via // rangeDateFlags, so scanning raw tokens this way is unambiguous. Defaults // to today() when no date token is present; errors if more than one is // found. func extractDate(args []string) (date string, rest []string, err error) { found := false skipNext := false for _, a := range args { if skipNext { rest = append(rest, a) skipNext = false continue } if rangeDateFlags[a] { rest = append(rest, a) skipNext = true continue } if dateRe.MatchString(a) { if found { return "", nil, fmt.Errorf("multiple dates given (%q and %q)", date, a) } date = a found = true continue } rest = append(rest, a) } if !found { date = today() } return date, rest, nil } // normalizeLectionary maps -l/--lectionary's accepted spellings ("new", // "trad", "traditional") onto the canonical config.Config.Lectionary values // ("new", "traditional") via config.NormalizeLectionary; "" (flag not given) // passes through unchanged so the caller knows to leave config's own // setting alone. func normalizeLectionary(lectionary string) (string, error) { if lectionary == "" { return "", nil } v, ok := config.NormalizeLectionary(lectionary) if !ok { return "", fmt.Errorf("invalid --lectionary %q (want new|trad)", lectionary) } return v, nil } // runHarvest handles -u/--update: harvest sigla maximally (to the // unpublished horizon) from date for the modern lectionary, printing the // outcome or -- on a genuine interruption (see liturgy.Harvest) -- the // error. On a successful harvest it also best-effort pre-caches the // traditional lectionary's propers (internal/tradlit) for every date in // that same [date, furthest] window, so 'lectio update' prepares both // lectionaries for offline use in one run. The 1962 calendar has no // "unpublished horizon" (every date has propers), so this is a plain // date-range fetch; a per-date failure is not fatal here -- it never // prevented the traditional lectionary from working live before, and the // modern-harvest outcome is still reported either way. func runHarvest(date string, stdout, stderr io.Writer) int { added, furthest, err := liturgy.Harvest(date, 0) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } lang := config.Default().TraditionalLang cachedTrad := 0 if furthest != "" { if cfg, cfgErr := config.Load(); cfgErr == nil { lang = cfg.TraditionalLang cachedTrad = cacheTraditionalRange(date, furthest, lang) } } fmt.Fprintf(stdout, "harvested %d day(s), furthest %s; cached traditional propers (%s) for %d day(s)\n", added, furthest, lang, cachedTrad) return 0 } // cacheTraditionalRange best-effort pre-caches tradlit's traditional propers // for lang for every date in [from, to] inclusive (walking forward a day at // a time), returning how many dates succeeded. A per-date failure (e.g. a // transient network hiccup) is ignored -- the traditional 1962 calendar has // propers for every date, so there is no "unpublished horizon" to stop at // the way liturgy.Harvest has for the modern lectionary. func cacheTraditionalRange(from, to, lang string) int { start, err := time.Parse("2006-01-02", from) if err != nil { return 0 } end, err := time.Parse("2006-01-02", to) if err != nil { return 0 } cached := 0 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { if _, _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil { cached++ } } return cached } // runClean handles -C/--clean: prune cached readings older than one year // (relative to today()) and print a human-readable summary of what was // removed. Like -u/--update, this is a maintenance mode -- it ignores DATE // and every render flag, and is dispatched before the render paths. If both // --clean and -u/--update are given, --clean takes precedence (see Run). func runClean(stdout, stderr io.Writer) int { now, err := time.Parse("2006-01-02", today()) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } before := now.AddDate(-1, 0, 0) removed, freed, err := liturgy.CleanCache(before) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } cutoff := before.Format("2006-01-02") if removed == 0 { fmt.Fprintf(stdout, "cache already clean (nothing older than %s)\n", cutoff) return 0 } entries := "entries" if removed == 1 { entries = "entry" } fmt.Fprintf(stdout, "cleaned %d cache %s older than %s (freed %s)\n", removed, entries, cutoff, formatFreed(freed)) return 0 } // formatFreed renders a byte count the way -C/--clean's summary line wants // it: megabytes with one decimal once it's a meaningful size, kilobytes // (also one decimal) for anything smaller. func formatFreed(bytes int64) string { const mb = 1024 * 1024 if bytes >= mb { return fmt.Sprintf("%.1f MB", float64(bytes)/mb) } return fmt.Sprintf("%.1f KB", float64(bytes)/1024) } // fetchAndPrint is the shared single-version render path (default version or // -b/--bible): fetch via the readings router, apply the offline version // swap, then render each section's heading and render.GatherVersion blocks. func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { secs, dayInfo, err := readings.Load(cfg, readings.Options{ Date: date, Refresh: refresh, Offline: cfg.Offline, All: all, }) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { version = vs[0] } w := resolveWidth(width, false, stdout) if !raw { printDayInfo(stdout, dayInfo) banner := bannerFor(cfg.UILanguage, all, date) fmt.Fprintln(stdout, banner) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w))) fmt.Fprintln(stdout) } pieces := make([]string, 0, len(secs)) for _, sec := range secs { pieces = append(pieces, renderSection(sec, version, cfg.Lectionary, cfg.UILanguage, w, raw)) } fmt.Fprintln(stdout, strings.Join(pieces, "\n\n")) return 0 } // printDayInfo prints the day's celebration name -- and, if the source // carries one, its temporal Season on its own line -- above the banner. // Name/Season are never translated (source-language, like the readings/ // citations themselves; see liturgy.DayInfo). A source that yielded no // DayInfo (info.Name == "") prints nothing: the header is a nice-to-have, // never an error condition. Callers only reach this when !raw; --raw skips // it entirely, keeping piped output text-only. // // The CLI has no ANSI styling of its own (unlike the TUI's Faint/dim // styles), so "dim" here is expressed structurally: the Season, if any, // gets its own line under Name rather than sharing emphasis with it. func printDayInfo(stdout io.Writer, info liturgy.DayInfo) { if info.Name == "" { return } name := info.Name if info.Colour != "" { name += " · " + info.Colour } fmt.Fprintln(stdout, name) if info.Season != "" { fmt.Fprintln(stdout, info.Season) } } // bannerFor builds the " DATE" banner: the // "readings" word when every part is shown, "gospel" for the gospel-only // default, and the connective between word and date, all localised via // i18n.Get(lang) -- lang="pl" reproduces ewangelia.py's original Polish // wording exactly ("Ewangelia na DATE" / "Czytania na DATE"); lang="en" // gives "Gospel for DATE" / "Readings for DATE". func bannerFor(lang string, all bool, date string) string { ui := i18n.Get(lang) word := ui.BannerGospel if all { word = ui.BannerReadings } return word + " " + ui.BannerConnective + " " + date } // renderSection formats one section as its heading (unless raw) followed by // render.GatherVersion's blocks, each wrapped to width. The heading's part // label is localised via render.LocalizeHeading (lang); the citation/verse // text is never touched. func renderSection(sec liturgy.Section, version, lectionary, lang string, width int, raw bool) string { var lines []string if !raw { lines = append(lines, render.HeadingWithRef(sec, lang)) lines = append(lines, "") } _, blocks := render.GatherVersion(version, sec, lectionary, lang) for _, b := range blocks { lines = append(lines, wrapText(b, width)) } return strings.TrimRight(strings.Join(lines, "\n"), "\n") } // renderCompare handles -c/--compare LIST: LIST is a comma-separated list of // version codes (falling back to cfg.Versions when empty), rendered side by // side via render.Compare. func renderCompare(cfg config.Config, list, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { var versions []string if list == "" { versions = append(versions, cfg.Versions...) } else { for _, v := range strings.Split(list, ",") { v = strings.TrimSpace(v) if v == "" { continue } versions = append(versions, v) } } for _, v := range versions { if !config.ValidVersion(v) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", v) return 2 } } versions = render.EffectiveVersions(versions, cfg.Lectionary, cfg.Offline) secs, dayInfo, err := readings.Load(cfg, readings.Options{ Date: date, Refresh: refresh, Offline: cfg.Offline, All: all, }) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } w := resolveWidth(width, true, stdout) if !raw { printDayInfo(stdout, dayInfo) banner := bannerFor(cfg.UILanguage, all, date) fmt.Fprintln(stdout, banner) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w))) fmt.Fprintln(stdout) } fmt.Fprintln(stdout, render.Compare(secs, versions, w, cfg.Lectionary, cfg.UILanguage)) return 0 } // randVersion picks the corpus version for --rand: an explicit -b VER (any of // the five is accepted), else the configured default_version. "bt" is allowed // but has no embedded corpus (it is the niedziela.pl scrape), so it -- like an // unset default -- transparently falls back to the first corpus version in // cfg.Versions (else "wuj"), so --rand always yields a passage. Only an unknown // version code is an error. func randVersion(cfg config.Config, bibleVer string) (string, error) { v := bibleVer if v == "" { v = cfg.DefaultVersion } if v != "" && !config.ValidVersion(v) { return "", fmt.Errorf("unknown version %q (want one of bt, wuj, vul, grb, drb)", v) } if isCorpusVersion(v) { return v, nil } for _, c := range cfg.Versions { if isCorpusVersion(c) { return c, nil } } return "wuj", nil } // runRand prints a random verse (chapter=false) or whole chapter (chapter=true) // from version's embedded corpus, for a "verse of the day" cron/pipe. It picks a // random book -> chapter -> verse (not perfectly uniform across all verses, but // fine for the purpose). Non-raw prints the reference (in cfg's sigla dialect) // then the text; raw prints just the text (verse-numbered for a chapter). func runRand(cfg config.Config, tbl *bible.BookTable, version string, chapter, raw bool, width int, stdout, stderr io.Writer) int { books := bible.CorpusBooks(version) if len(books) == 0 { fmt.Fprintf(stderr, "lectio: no corpus for version %q\n", version) return 1 } rng := rand.New(rand.NewSource(time.Now().UnixNano())) var book string var chap int var verses []bible.Verse for tries := 0; tries < 20 && len(verses) == 0; tries++ { book = books[rng.Intn(len(books))] chaps := bible.Chapters(version, book) if len(chaps) == 0 { continue } chap = chaps[rng.Intn(len(chaps))] verses = bible.Verses(version, book, chap) } if len(verses) == 0 { fmt.Fprintln(stderr, "lectio: could not pick a random passage") return 1 } w := resolveWidth(width, false, stdout) dialect := cfg.SiglaLang() if chapter { if !raw { fmt.Fprintln(stdout, fmt.Sprintf("%s %d", tbl.Abbrev(dialect, book), chap)) fmt.Fprintln(stdout) } lines := make([]string, 0, len(verses)) for _, v := range verses { lines = append(lines, wrapText(fmt.Sprintf("%d %s", v.Verse, v.Text), w)) } fmt.Fprintln(stdout, strings.Join(lines, "\n")) return 0 } v := verses[rng.Intn(len(verses))] if !raw { fmt.Fprintln(stdout, tbl.FormatRef(dialect, fmt.Sprintf("%s %d:%d", book, chap, v.Verse))) fmt.Fprintln(stdout) } fmt.Fprintln(stdout, wrapText(v.Text, w)) return 0 } // isCorpusVersion reports whether v is a scripture-text version with an // embedded corpus (wuj/vul/grb/drb) -- i.e. a valid version that is not "bt" // (the niedziela.pl scrape, which has no full text to look a passage up in). func isCorpusVersion(v string) bool { return config.ValidVersion(v) && v != "bt" } // refLookupVersions resolves which corpus version(s) `--ref` should look a // passage up in, rejecting "bt" (no corpus). Precedence: an explicit -c LIST, // then a single -b VER, then a sensible default (the configured default_version // if it has a corpus, else the first corpus version in cfg.Versions). It errors // (exit 2 in the caller) on any non-corpus version or if nothing usable is // configured. func refLookupVersions(cfg config.Config, bibleVer, compareList string) ([]string, error) { if compareList != "" { var versions []string for _, v := range strings.Split(compareList, ",") { if v = strings.TrimSpace(v); v == "" { continue } if !isCorpusVersion(v) { return nil, fmt.Errorf("--ref cannot use version %q; pass a corpus version (wuj, vul, grb, drb)", v) } versions = append(versions, v) } if len(versions) == 0 { return nil, fmt.Errorf("--ref needs at least one corpus version in -c") } return versions, nil } if bibleVer != "" { if !isCorpusVersion(bibleVer) { return nil, fmt.Errorf("--ref cannot use version %q; pass a corpus version (wuj, vul, grb, drb)", bibleVer) } return []string{bibleVer}, nil } if isCorpusVersion(cfg.DefaultVersion) { return []string{cfg.DefaultVersion}, nil } for _, v := range cfg.Versions { if isCorpusVersion(v) { return []string{v}, nil } } return nil, fmt.Errorf("--ref needs a corpus version; pass -b wuj|vul|grb|drb") } // lookupRef renders a passage lookup (-p/--ref). It parses the typed reference // in the resolved sigla dialect (bookTbl.ParseRef -- dialect-scoped book names // and number syntax) into an English colon-style reference, then reuses the // same render path as the readings with lectionary="traditional" (the ref is // already in target form, looked up literally per version -- no niedziela // conversion, no cross-version psalm renumbering). versions is the validated // corpus set (never "bt"). raw omits the header. Exit 2 on an unparseable ref, // exit 1 if no requested version has the passage. func lookupRef(cfg config.Config, tbl *bible.BookTable, ref string, versions []string, raw bool, width int, stdout, stderr io.Writer) int { ref = strings.TrimSpace(ref) engRef, ok := tbl.ParseRef(cfg.SiglaLang(), ref) if !ok { fmt.Fprintf(stderr, "lectio: could not read reference %q in the %s dialect; see 'lectio --list'\n", ref, cfg.SiglaLang()) return 2 } sec := liturgy.Section{Citation: engRef, Heading: engRef} found := false for _, v := range versions { if vs, _ := bible.Lookup(v, engRef); len(vs) > 0 { found = true break } } if !found { fmt.Fprintf(stderr, "lectio: no text found for %q in %s\n", ref, strings.Join(versions, ", ")) return 1 } w := width if w <= 0 { if len(versions) > 1 { w = compareDefaultWidth } else { w = defaultWidth } } if !raw { fmt.Fprintln(stdout, ref) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len([]rune(ref)), w))) fmt.Fprintln(stdout) } if len(versions) == 1 { label, blocks := render.GatherVersion(versions[0], sec, "traditional", cfg.UILanguage) if !raw { fmt.Fprintln(stdout, label) fmt.Fprintln(stdout) } lines := make([]string, 0, len(blocks)) for _, b := range blocks { lines = append(lines, render.Wrap(b, w)) } fmt.Fprintln(stdout, strings.Join(lines, "\n")) return 0 } fmt.Fprintln(stdout, render.Compare([]liturgy.Section{sec}, versions, w, "traditional", cfg.UILanguage)) return 0 } // runList handles --list: print every book of the sigla dialect (scriptural // order) as " ", followed by a "Versions:" block listing // every known bible corpus -- built-in and user/drop-in alike (see // bible.Corpora, which SetUserCorporaDir keeps current) -- as " //