/* * esv: read the Bible from your terminal * * The GPLv2 License (GPLv2) * Copyright (c) 2023-2024 Jeremy Baxter * * esv is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * esv is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with esv. If not, see . */ module esv; import std.algorithm : startsWith; import std.conv : to, ConvException; import std.file : exists, mkdirRecurse, write, FileException; import std.format : format; import std.getopt : getopt, GetOptException; import std.path : baseName, dirName, expandTilde, isValidPath; import std.process : environment, executeShell; import std.stdio : writef, writeln, writefln, File; import std.string : splitLines; import esvapi; import initial; import cf = config; @safe: bool aFlag; /* audio */ string CFlag; /* config */ bool fFlag, FFlag; /* footnotes */ bool hFlag, HFlag; /* headings */ int lFlag; /* line length */ bool lFlagSpecified; bool nFlag, NFlag; /* verse numbers */ bool rFlag, RFlag; /* passage references */ string sFlag; /* search passages */ bool VFlag; /* show version */ string[] mainArgs; File stderr; version (OpenBSD) { immutable(char) *promises; } int main(string[] args) { string apiKey; string configPath; INIUnit ini; ESVApi esv; mainArgs = args; version (OpenBSD) () @trusted { import core.sys.openbsd.unistd : pledge; import std.string : toStringz; promises = toStringz("stdio rpath wpath cpath inet dns tty proc exec prot_exec"); pledge(promises, null); }(); /* @safe way of opening stderr on Unix */ stderr = File("/dev/stderr", "w"); /* Parse command-line options */ try { import std.getopt : config; getopt(args, config.bundling, config.caseSensitive, "a", &aFlag, "C", &CFlag, "F", &FFlag, "f", &fFlag, "H", &HFlag, "h", &hFlag, "l", &onLineLength, "N", &NFlag, "n", &nFlag, "R", &RFlag, "r", &rFlag, "s", &sFlag, "V", &VFlag, ); } catch (GetOptException e) { string opt = extractOpt(e); enforceDie(!e.msg.startsWith("Unrecognized option"), "unknown option " ~ opt); enforceDie(!e.msg.startsWith("Missing value for argument"), "missing argument for option " ~ opt); die(e.msg); /* catch-all */ } if (VFlag) { writeln("esv " ~ cf.esvVersion); return 0; } if (sFlag != "") { /* skip argument validation */ goto config; } if (args.length < 3) { stderr.writefln( "usage: %s [-aFfHhNnRrV] [-C config] [-l length] [-s query] book verses", baseName(args[0])); return 1; } enforceDie(bookValid(parseBook(args[1])), "book '%s' does not exist", args[1]); enforceDie(verseValid(args[2]), "invalid verse format '%s'", args[2]); /* determine configuration file: options take first priority, * then environment variables, and then the default path */ config: configPath = environment.get(cf.configEnv, cf.configPath) .expandTilde(); try { if (CFlag != "") { /* if -C was given */ enforceDie(CFlag.isValidPath(), CFlag ~ ": invalid path"); configPath = CFlag.expandTilde(); } else { enforceDie(configPath.isValidPath(), configPath ~ ": invalid path"); if (!configPath.exists()) { mkdirRecurse(dirName(configPath)); configPath.write(format! "## Configuration file for esv. # An API key is required to access the ESV Bible API. [api] key = %s # Settings that modify how passages are displayed: [passage] #footnotes = false #headings = false #passage-references = false #verse-numbers = false "(cf.apiKey)); } } readINIFile(ini, configPath); } catch (FileException e) { /* filesystem syscall errors */ die(e.msg); } enforceDie(!(aFlag && sFlag), "cannot specify both -a and -s flags"); apiKey = ini["api"].key("key"); enforceDie(apiKey != null, "API key not present in configuration file; cannot proceed"); esv = new ESVApi(apiKey); if (aFlag) { string tmpf, player; try tmpf = esv.getAudioPassage(args[1], args[2]); catch (CurlException e) die(e.msg); player = environment.get(cf.playerEnv, cf.mp3Player); /* check for an audio player */ enforceDie( executeShell( format!"command -v %s >/dev/null 2>&1"(player) ).status == 0, player ~ " is required for audio mode; cannot continue"); /* esv has built-in support for mpg123 and mpv. * Other players will work, just set ESV_PLAYER */ player ~= player == "mpg123" ? " -q " : player == "mpv" ? " --msg-level=all=no " : " "; /* spawn the player */ executeShell(player ~ tmpf); return 0; } if (sFlag) { try writeln(esv.searchFormat(sFlag)); catch (ESVException) die("no results for search"); catch (CurlException e) die(e.msg); return 0; } esv.extraParameters = ini["api"].key("parameters", ""); /* Get [passage] keys */ foreach (string key; [ "footnotes", "headings", "passage-references", "verse-numbers" ]) { try esv.opts.b["include-" ~ key] = ini["passage"].keyAs!bool(key, true); catch (INITypeException e) die(configPath ~ ": " ~ e.msg); } /* Get line-length ([passage]) */ try esv.opts.i["line-length"] = lFlagSpecified ? lFlag : ini["passage"].keyAs!int("line-length", terminalColumns()); catch (INITypeException e) die(configPath ~ ": " ~ e.msg); if (fFlag) esv.opts.b["include-footnotes"] = true; if (hFlag) esv.opts.b["include-headings"] = true; if (nFlag) esv.opts.b["include-verse-numbers"] = true; if (rFlag) esv.opts.b["include-passage-references"] = true; if (FFlag) esv.opts.b["include-footnotes"] = false; if (HFlag) esv.opts.b["include-headings"] = false; if (NFlag) esv.opts.b["include-verse-numbers"] = false; if (RFlag) esv.opts.b["include-passage-references"] = false; try writeln(esv.getPassage(parseBook(args[1]), args[2])); catch (CurlException e) die(e.msg); return 0; } private void warn(string mesg) { stderr.writeln(baseName(mainArgs[0]) ~ ": " ~ mesg); } private void die(string mesg) @trusted { import core.runtime : Runtime; import core.stdc.stdlib : exit; warn(mesg); Runtime.terminate(); exit(1); } private ushort terminalColumns() @trusted { import core.sys.posix.sys.ioctl; winsize w; ioctl(1, TIOCGWINSZ, &w); return w.ws_col > 72 ? 72 : w.ws_col; } private void enforceDie(A...)(bool cond, string fmt, A a) { import std.format : format; if (!cond) die(format(fmt, a)); } private string extractOpt(in GetOptException e) { import std.regex : matchFirst; return e.msg.matchFirst("-.")[0]; } private void onLineLength(string flag, string value) { lFlagSpecified = true; try lFlag = value.to!int(); catch (ConvException e) die("illegal argument to -l option -- must be a positive integer"); } private string parseBook(in string book) { import std.string : tr; return book.tr("-_", " "); }