// Package cli is lectio's subcommand 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" "os" "regexp" "strconv" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" ) // versionString is printed by --version/-v. const versionString = "0.1.0" const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions) Usage: lectio [today] [--all] [--raw] [--width N] [--refresh] lectio date D [--all] [--raw] [--width N] [--refresh] D = YYYY-MM-DD lectio show VERSION [--date D] [--all] [--raw] [--width N] [--refresh] one version's text lectio compare LIST [--date D] [--all] [--width N] [--refresh] [--raw] LIST = comma-separated versions (default: config "versions") lectio update [--days N] [--from D] harvest future sigla to the TSV lectio --version | lectio -v lectio help | lectio -h | lectio -h Global flags (any subcommand, any position): --offline skip the network, use cached/harvested data only --lectionary new|traditional override config "lectionary" for this run --lang pl|en override config "traditional_lang" for this run Per-command flags: --all show every reading part (1st/2nd, psalm, acclamation, gospel); default is the gospel only, unless config all = true --raw omit the banner/headings (for piping) --width N wrap width; 0 = detect terminal, else a sane default (80 normally, 120 for compare) --refresh bypass the cache and re-fetch --date D date as YYYY-MM-DD (show/compare; default: today) Versions: pl (Polski/niedziela.pl) wuj (Wujek) vul (Wulgata) grb (Grecki) drb (Douay-Rheims) Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error (unknown command/version, bad date, missing argument). ` const ( defaultWidth = 80 compareDefaultWidth = 120 ) var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) var validVersions = map[string]bool{ "pl": true, "wuj": true, "vul": true, "grb": true, "drb": true, } 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 len(args) > 0 && (args[0] == "--version" || args[0] == "-v") { fmt.Fprintln(stdout, "lectio "+versionString) return 0 } if len(args) > 0 && args[0] == "help" { fmt.Fprint(stdout, helpText) return 0 } offline, lectionary, lang, rest := extractGlobal(args) if lectionary != "" && lectionary != "new" && lectionary != "traditional" { fmt.Fprintf(stderr, "lectio: invalid --lectionary %q (want new|traditional)\n", lectionary) return 2 } if lang != "" && lang != "pl" && lang != "en" { fmt.Fprintf(stderr, "lectio: invalid --lang %q (want pl|en)\n", lang) return 2 } cmd := "today" cmdArgs := rest if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { cmd = rest[0] cmdArgs = rest[1:] } switch cmd { case "today", "date", "show", "compare": cfg, err := config.Load() if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if offline { cfg.Offline = true } if lectionary != "" { cfg.Lectionary = lectionary } if lang != "" { cfg.TraditionalLang = lang } switch cmd { case "today": return runToday(cmdArgs, cfg, stdout, stderr) case "date": return runDate(cmdArgs, cfg, stdout, stderr) case "show": return runShow(cmdArgs, cfg, stdout, stderr) case "compare": return runCompare(cmdArgs, cfg, stdout, stderr) } return 1 // unreachable case "update": return runUpdate(cmdArgs, stdout, stderr) default: fmt.Fprintf(stderr, "lectio: unknown command %q; see 'lectio help'\n", cmd) return 2 } } // 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 } // extractGlobal pulls the global --offline/--lectionary/--lang flags out of // args (they may appear anywhere, before or after the subcommand), leaving // rest for subcommand dispatch and per-command flag parsing. func extractGlobal(args []string) (offline bool, lectionary, lang string, rest []string) { for i := 0; i < len(args); i++ { a := args[i] switch { case a == "--offline": offline = true case a == "--lectionary" && i+1 < len(args): lectionary = args[i+1] i++ case strings.HasPrefix(a, "--lectionary="): lectionary = strings.TrimPrefix(a, "--lectionary=") case a == "--lang" && i+1 < len(args): lang = args[i+1] i++ case strings.HasPrefix(a, "--lang="): lang = strings.TrimPrefix(a, "--lang=") default: rest = append(rest, a) } } return offline, lectionary, lang, rest } // runToday handles the (default) "today" subcommand: --all --raw --width // --refresh, date = today. func runToday(args []string, cfg config.Config, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("today", flag.ContinueOnError) fs.SetOutput(stderr) all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") raw := fs.Bool("raw", false, "omit banner/headings (for piping)") width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") if err := fs.Parse(args); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(stderr, "lectio: today takes no date argument (use: lectio date %s)\n", fs.Arg(0)) return 2 } return fetchAndPrint(cfg, cfg.DefaultVersion, today(), *all, *raw, *width, *refresh, stdout, stderr) } // runDate handles "date D": D is the required first positional argument, // flags as in runToday. func runDate(args []string, cfg config.Config, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprintln(stderr, "lectio: date requires an argument (YYYY-MM-DD)") return 2 } date := args[0] if !dateRe.MatchString(date) { fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", date) return 2 } fs := flag.NewFlagSet("date", flag.ContinueOnError) fs.SetOutput(stderr) all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") raw := fs.Bool("raw", false, "omit banner/headings (for piping)") width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") if err := fs.Parse(args[1:]); err != nil { return 2 } return fetchAndPrint(cfg, cfg.DefaultVersion, date, *all, *raw, *width, *refresh, stdout, stderr) } // runShow handles "show VERSION": same as today/date but the version is // fixed by the caller instead of cfg.DefaultVersion. func runShow(args []string, cfg config.Config, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprintln(stderr, "lectio: show requires a version argument (pl|wuj|vul|grb|drb)") return 2 } ver := args[0] if !validVersions[ver] { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", ver) return 2 } fs := flag.NewFlagSet("show", flag.ContinueOnError) fs.SetOutput(stderr) all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") raw := fs.Bool("raw", false, "omit banner/headings (for piping)") width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") date := fs.String("date", today(), "date as YYYY-MM-DD") if err := fs.Parse(args[1:]); err != nil { return 2 } if !dateRe.MatchString(*date) { fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date) return 2 } return fetchAndPrint(cfg, ver, *date, *all, *raw, *width, *refresh, stdout, stderr) } // fetchAndPrint is the shared today/date/show path: 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, 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 cfg.Offline { if vs := render.OfflineVersions([]string{version}); len(vs) > 0 { version = vs[0] } } w := resolveWidth(width, false, stdout) if !raw { banner := bannerFor(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, w, raw)) } fmt.Fprintln(stdout, strings.Join(pieces, "\n\n")) return 0 } // bannerFor mirrors ewangelia.py's banner text: "Czytania na D" when every // part is shown, "Ewangelia na D" for the gospel-only default. func bannerFor(all bool, date string) string { if all { return "Czytania na " + date } return "Ewangelia na " + date } // renderSection formats one section as its heading (unless raw) followed by // render.GatherVersion's blocks, each wrapped to width. func renderSection(sec liturgy.Section, version, lectionary string, width int, raw bool) string { var lines []string if !raw { lines = append(lines, sec.Heading) if sec.Subtitle != "" { lines = append(lines, sec.Subtitle) } lines = append(lines, "") } _, blocks := render.GatherVersion(version, sec, lectionary) for _, b := range blocks { lines = append(lines, wrapText(b, width)) } return strings.TrimRight(strings.Join(lines, "\n"), "\n") } // runCompare handles "compare LIST": LIST is the required first positional // argument (falling back to cfg.Versions when omitted entirely). func runCompare(args []string, cfg config.Config, stdout, stderr io.Writer) int { var list string rest := args if len(args) > 0 && !strings.HasPrefix(args[0], "-") { list = args[0] rest = args[1:] } fs := flag.NewFlagSet("compare", flag.ContinueOnError) fs.SetOutput(stderr) all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") raw := fs.Bool("raw", false, "omit banner/headings (for piping)") width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal; compare default 120)") refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") date := fs.String("date", today(), "date as YYYY-MM-DD") if err := fs.Parse(rest); err != nil { return 2 } if !dateRe.MatchString(*date) { fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date) return 2 } 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 !validVersions[v] { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", v) return 2 } } if cfg.Offline { versions = render.OfflineVersions(versions) } secs, 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 { banner := bannerFor(*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)) return 0 } // runUpdate handles "update [--days N] [--from D]": it does not touch // config -- liturgy.Harvest owns its own cache/sigla-store paths. func runUpdate(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("update", flag.ContinueOnError) fs.SetOutput(stderr) days := fs.Int("days", 0, "max days to harvest (0 = until the unpublished horizon)") from := fs.String("from", today(), "start date as YYYY-MM-DD (default: today)") if err := fs.Parse(args); err != nil { return 2 } if !dateRe.MatchString(*from) { fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *from) return 2 } added, furthest, err := liturgy.Harvest(*from, *days) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest) return 0 } // resolveWidth applies the --width 0 = detect-terminal rule: an explicit // positive width wins, then a real terminal width if stdout is a tty, then // a sane per-mode default. func resolveWidth(flagWidth int, isCompare bool, out io.Writer) int { if flagWidth > 0 { return flagWidth } if tw := termWidth(out); tw > 0 { return tw } if isCompare { return compareDefaultWidth } return defaultWidth } // termWidth checks $COLUMNS first (a common shell/CLI convention), then // falls back to a TIOCGWINSZ ioctl when out is a real terminal file. It // returns 0 (meaning: use the caller's default) when neither works. func termWidth(out io.Writer) int { if v := os.Getenv("COLUMNS"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } f, ok := out.(*os.File) if !ok { return 0 } return ttyWidth(f) } // wrapText greedily wraps line to width without breaking words, matching // ewangelia.py's textwrap.fill(..., break_long_words=False, // break_on_hyphens=False). Duplicated (in miniature) from render.wrap, // which is unexported: cli needs the same wrapping for single-version // output, render.Compare does its own internally. func wrapText(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 minInt(a, b int) int { if a < b { return a } return b }