Refactor most of the code

- Replace panic()-based error handling in main() with
   Exception-based error handling
 - Put hardcoded defaults in config.di
 - Make general optimisations and readability improvements
   for a lot of the code
 - Change some of the ESVApi's interface
This commit is contained in:
Jeremy Baxter 2023-09-23 17:10:38 +12:00
parent 1b11097ba0
commit 06a4dc286d
3 changed files with 397 additions and 362 deletions

18
config.di Normal file
View file

@ -0,0 +1,18 @@
/* default configuration for esv */
module config;
public:
enum DEFAULT_APIKEY = "abfb7456fa52ec4292c79e435890cfa3df14dc2b";
enum DEFAULT_CONFIGPATH = "~/.config/esv.conf";
enum DEFAULT_MPEGPLAYER = "mpg123";
enum DEFAULT_PAGER = "less";
enum ENV_CONFIG = "ESV_CONFIG";
enum ENV_PAGER = "ESV_PAGER";
enum ENV_PLAYER = "ESV_PLAYER";
enum BUGREPORTURL = "https://codeberg.org/jtbx/esv/issues";
// vi: ft=d

410
esvapi.d
View file

@ -20,31 +20,25 @@
module esvapi; module esvapi;
import std.algorithm : filter, map;
import std.array : appender;
import std.ascii : isAlphaNum;
import std.base64 : Base64;
import std.conv : to; import std.conv : to;
import std.file : mkdirRecurse, tempDir, write; import std.exception : basicExceptionCtors, enforce;
import std.file : tempDir, write;
import std.format : format; import std.format : format;
import std.json : JSONValue, parseJSON; import std.json : JSONValue, parseJSON;
import std.random : rndGen; import std.regex : regex, matchAll, replaceAll;
import std.range : take; import std.stdio : File;
import std.regex : matchAll, replaceAll, replaceFirst, regex;
import std.string : capitalize; import std.string : capitalize;
import std.utf : toUTF8; import std.net.curl : HTTP;
import std.net.curl;
public enum ESVMode enum ESVIndent
{ {
TEXT, SPACE,
AUDIO TAB
} }
const enum ESVAPI_KEY = "abfb7456fa52ec4292c79e435890cfa3df14dc2b"; const enum string ESVAPI_URL = "https://api.esv.org/v3/passage";
const enum ESVAPI_URL = "https://api.esv.org/v3/passage";
const string[] BIBLE_BOOKS = [ const string[] BIBLE_BOOKS = [
// Old Testament /* Old Testament */
"Genesis", "Genesis",
"Exodus", "Exodus",
"Leviticus", "Leviticus",
@ -64,7 +58,7 @@ const string[] BIBLE_BOOKS = [
"Esther", "Esther",
"Job", "Job",
"Psalm", "Psalm",
"Psalms", // both are valid "Psalms", /* both are valid */
"Proverbs", "Proverbs",
"Ecclesiastes", "Ecclesiastes",
"Song of Solomon", "Song of Solomon",
@ -85,7 +79,7 @@ const string[] BIBLE_BOOKS = [
"Haggai", "Haggai",
"Zechariah", "Zechariah",
"Malachi", "Malachi",
// New Testament /* New Testament */
"Matthew", "Matthew",
"Mark", "Mark",
"Luke", "Luke",
@ -114,154 +108,167 @@ const string[] BIBLE_BOOKS = [
"Jude", "Jude",
"Revelation" "Revelation"
]; ];
/* All boolean API parameters */
const string[] ESVAPI_PARAMETERS = [
"include-passage-references",
"include-verse-numbers",
"include-first-verse-numbers",
"include-footnotes",
"include-footnote-body",
"include-headings",
"include-short-copyright",
"include-copyright",
"include-passage-horizontal-lines",
"include-heading-horizontal-lines",
"include-selahs",
"indent-poetry",
"horizontal-line-length",
"indent-paragraphs",
"indent-poetry-lines",
"indent-declares",
"indent-psalm-doxology",
"line-length",
"indent-using",
];
/*
* Returns true if the argument book is a valid book of the Bible.
* Otherwise, returns false.
*/
bool bookValid(in char[] book) nothrow @safe
{
foreach (string b; BIBLE_BOOKS) {
if (book.capitalize() == b.capitalize())
return true;
}
return false;
}
/*
* Returns true if the argument book is a valid verse format.
* Otherwise, returns false.
*/
bool verseValid(in char[] verse) @safe
{
bool vMatch(in string re) @safe
{
return !verse.matchAll(regex(re)).empty;
}
if (vMatch("^\\d{1,3}$") ||
vMatch("^\\d{1,3}-\\d{1,3}$") ||
vMatch("^\\d{1,3}:\\d{1,3}$") ||
vMatch("^\\d{1,3}:\\d{1,3}-\\d{1,3}$"))
return true;
return false;
}
}
class ESVApi class ESVApi
{ {
private { protected {
int _mode;
string _key; string _key;
string _tmp; string _tmp;
string _url; string _url;
} }
ESVApiOptions opts;
string extraParameters; string extraParameters;
int delegate(size_t, size_t, size_t, size_t) onProgress; int delegate(size_t, size_t, size_t, size_t) onProgress;
this(immutable(string) key = ESVAPI_KEY, bool audio = false) ESVApiOptions opts;
this(string key, string tmpName = "esv")
{ {
_key = key; _key = key;
_mode = audio ? ESVMode.AUDIO : ESVMode.TEXT; _tmp = tempDir() ~ tmpName;
_tmp = tempDir() ~ "esv"; _url = ESVAPI_URL;
_url = ESVAPI_URL; opts = ESVApiOptions(true);
opts.defaults();
extraParameters = ""; extraParameters = "";
onProgress = (size_t dlTotal, size_t dlNow, size_t ulTotal, size_t ulNow) { return 0; }; onProgress = (size_t dlTotal, size_t dlNow, size_t ulTotal, size_t ulNow) { return 0; };
tmpName = "esv";
} }
/* /*
* Returns the API authentication key that was given when the API object was instantiated. * Returns the API authentication key that was given when the object
* This authentication key cannot be changed after instantiation. * was constructed. This authentication key cannot be changed.
*/ */
@property string key() const nothrow pure @nogc @safe @property string key() const nothrow pure @safe
{ {
return _key; return _key;
} }
/* /*
* Returns the API authentication key currently in use. * Returns the subdirectory used to store temporary audio passages.
*/ */
@property int mode() const nothrow pure @nogc @safe @property string tmpDir() const nothrow pure @safe
{ {
return _mode; return _tmp;
}
/*
* If the mode argument is either "text" or "html",
* sets the text API mode to the given mode argument.
* If the mode argument is not one of those,
* throws an ESVException.
*/
@property void mode(immutable(int) mode) pure @safe
{
if (mode == ESVMode.TEXT || mode == ESVMode.AUDIO)
_mode = mode;
else
throw new ESVException("Invalid mode");
} }
/* /*
* Returns the API URL currently in use. * Returns the API URL currently in use.
*/ */
@property string url() const nothrow pure @nogc @safe @property string url() const nothrow pure @safe
{ {
return _url; return _url;
} }
/* /*
* If the url argument is a valid HTTP URL, sets the API URL currently in use * Sets the API URL currently in use to the given url argument.
* to the given url argument. Otherwise, throws an ESVException.
*/ */
@property void url(immutable(string) url) @safe @property void url(immutable(string) url) @safe
in (!url.matchAll(`^https?://.+\\..+(/.+)?`).empty, "Invalid URL format")
{ {
if (url.matchAll("^https?://.+\\..+(/.+)?").empty) _url = url;
throw new ESVException("Invalid URL format");
else
_url = url;
} }
/* /*
* Returns the temp directory name. * Requests the verse(s) in text format from the API and returns it.
*/
@property tmpName() const @safe
{
return _tmp.replaceFirst(regex('^' ~ tempDir()), "");
}
/*
* Sets the temp directory name to the given string.
*/
@property void tmpName(immutable(string) name) @safe
{
_tmp = tempDir() ~ name;
}
/*
* Returns true if the argument book is a valid book of the Bible.
* Otherwise, returns false.
*/
bool validateBook(in char[] book) const nothrow @safe
{
foreach (b; BIBLE_BOOKS) {
if (book.capitalize() == b.capitalize())
return true;
}
return false;
}
/*
* Returns true if the argument book is a valid verse format.
* Otherwise, returns false.
*/
bool validateVerse(in char[] verse) const @safe
{
bool attemptRegex(string re) const @safe
{
return !verse.matchAll(re).empty;
}
if (attemptRegex("^\\d{1,3}$") ||
attemptRegex("^\\d{1,3}-\\d{1,3}$") ||
attemptRegex("^\\d{1,3}:\\d{1,3}$") ||
attemptRegex("^\\d{1,3}:\\d{1,3}-\\d{1,3}$"))
return true;
else
return false;
}
/*
* Requests the verse(s) from the API and returns it.
* The (case-insensitive) name of the book being searched are * The (case-insensitive) name of the book being searched are
* contained in the argument book. The verse(s) being looked up are * contained in the argument book. The verse(s) being looked up are
* contained in the argument verses. * contained in the argument verses.
* *
* If the mode is ESVMode.AUDIO, requests an audio passage instead.
* A file path to an MP3 audio track is returned.
* To explicitly get an audio passage without setting the mode,
* use getAudioVerses().
*
* Example: getVerses("John", "3:16-21") * Example: getVerses("John", "3:16-21")
*/ */
string getVerses(in char[] book, in char[] verse) const string getVerses(in char[] book, in char[] verse) const
in (bookValid(book), "Invalid book")
in (verseValid(verse), "Invalid verse format")
{ {
if (_mode == ESVMode.AUDIO) { char[] params, response;
return getAudioVerses(book, verse); HTTP request;
params = [];
{
(char[])[] parambuf;
foreach (string opt; ESVAPI_PARAMETERS) {
bool bo;
int io;
switch (opt) {
case "indent-using":
o = opts.indent_using;
break;
case "indent-poetry":
}
!opt.matchAll("^include-").empty) {
o = opts.b[opt];
} else if (opt == "line-length" ||
opt == "horizontal-line-length" ||
!opt.matchAll("^indent-").empty) {
o = opts.i[opt];
}
params = format!"%s&%s=%s"(params, opt, o.to!string());
}
} }
if (!validateBook(book)) request = HTTP(
throw new ESVException("Invalid book"); format!"%s/text/?q=%s+%s%s%s"(_url, book
if (!validateVerse(verse)) .capitalize()
throw new ESVException("Invalid verse format"); .replaceAll(regex(" "), "+"),
verse, params, extraParameters)
string apiURL = format!"%s/%s/?q=%s+%s%s%s"(_url, _mode, );
book.capitalize().replaceAll(regex(" "), "+"), verse,
assembleParameters(), extraParameters);
auto request = HTTP(apiURL);
string response;
request.onProgress = onProgress; request.onProgress = onProgress;
request.onReceive = (ubyte[] data) request.onReceive =
{ (ubyte[] data)
response = cast(string)data; {
return data.length; response ~= data;
}; return data.length;
};
request.addRequestHeader("Authorization", "Token " ~ _key); request.addRequestHeader("Authorization", "Token " ~ _key);
request.perform(); request.perform();
return response.parseJSON()["passages"][0].str; return response.parseJSON()["passages"][0].str;
@ -273,103 +280,110 @@ class ESVApi
* contained in the argument book. The verse(s) being looked up are * contained in the argument book. The verse(s) being looked up are
* contained in the argument verses. * contained in the argument verses.
* *
* Example: getVerses("John", "3:16-21") * Example: getAudioVerses("John", "3:16-21")
*/ */
string getAudioVerses(in char[] book, in char[] verse) const string getAudioVerses(in char[] book, in char[] verse) const
in (bookValid(book), "Invalid book")
in (verseValid(verse), "Invalid verse format")
{ {
if (!validateBook(book)) char[] response;
throw new ESVException("Invalid book"); File tmpFile;
if (!validateVerse(verse))
throw new ESVException("Invalid verse format");
string apiURL = format!"%s/audio/?q=%s+%s"(_url, book.capitalize().replaceAll(regex(" "), "+"), verse); auto request = HTTP(format!"%s/audio/?q=%s+%s"(
auto request = HTTP(apiURL); _url, book.capitalize().replaceAll(regex(" "), "+"), verse));
ubyte[] response;
request.onProgress = onProgress; request.onProgress = onProgress;
request.onReceive = (ubyte[] data) request.onReceive =
{ (ubyte[] data)
response ~= data; {
return data.length; response ~= data;
}; return data.length;
};
request.addRequestHeader("Authorization", "Token " ~ _key); request.addRequestHeader("Authorization", "Token " ~ _key);
request.perform(); request.perform();
string tmpFile = tempFile(); tmpFile = File(_tmp, "w");
tmpFile.write(response); tmpFile.write(response);
return tmpFile; return _tmp;
} }
private: /*
string assembleParameters() const pure @safe * Requests a passage search for the given query.
* If raw is false, formats the passage search as
* plain text, otherwise returns JSON from the API.
*
* Example: search("It is finished")
*/
char[] search(in string query, in bool raw = true)
{ {
string params = ""; ulong i;
string addParam(string param, string value) const pure @safe char[] response;
{ char[] layout;
return format!"%s&%s=%s"(params, param, value); HTTP request;
JSONValue json;
request = HTTP(format!"%s/search/?q=%s"(
_url, query.replaceAll(regex(" "), "+")));
request.onProgress = onProgress;
request.onReceive =
(ubyte[] data)
{
response ~= cast(char[])data;
return data.length;
};
request.addRequestHeader("Authorization", "Token " ~ _key);
request.perform();
if (raw)
return response;
json = response.parseJSON();
layout = cast(char[])"";
enforce!ESVException(json["total_results"].integer == 0, "No results for search");
foreach (ulong i, JSONValue result; json["results"]) {
layout ~= format!"%s\n %s\n"(
result["reference"].str,
result["content"].str
);
} }
params = addParam("include-passage-references", opts.boolOpts["include_passage_references"].to!string);
params = addParam("include-verse-numbers", opts.boolOpts["include_verse_numbers"].to!string); return layout;
params = addParam("include-first-verse-numbers", opts.boolOpts["include_first_verse_numbers"].to!string);
params = addParam("include-footnotes", opts.boolOpts["include_footnotes"].to!string);
params = addParam("include-footnote-body", opts.boolOpts["include_footnote_body"].to!string);
params = addParam("include-headings", opts.boolOpts["include_headings"].to!string);
params = addParam("include-short-copyright", opts.boolOpts["include_short_copyright"].to!string);
params = addParam("include-copyright", opts.boolOpts["include_copyright"].to!string);
params = addParam("include-passage-horizontal-lines", opts.boolOpts["include_passage_horizontal_lines"].to!string);
params = addParam("include-heading-horizontal-lines", opts.boolOpts["include_heading_horizontal_lines"].to!string);
params = addParam("include-selahs", opts.boolOpts["include_selahs"].to!string);
params = addParam("indent-poetry", opts.boolOpts["indent_poetry"].to!string);
params = addParam("horizontal-line-length", opts.intOpts ["horizontal_line_length"].to!string);
params = addParam("indent-paragraphs", opts.intOpts ["indent_paragraphs"].to!string);
params = addParam("indent-poetry-lines", opts.intOpts ["indent_poetry_lines"].to!string);
params = addParam("indent-declares", opts.intOpts ["indent_declares"].to!string);
params = addParam("indent-psalm-doxology", opts.intOpts ["indent_psalm_doxology"].to!string);
params = addParam("line-length", opts.intOpts ["line_length"].to!string);
params = addParam("indent-using", opts.indent_using.to!string);
return params;
}
string tempFile() const @safe
{
auto rndNums = rndGen().map!(a => cast(ubyte)a)().take(32);
auto result = appender!string();
Base64.encode(rndNums, result);
_tmp.mkdirRecurse();
string f = _tmp ~ "/" ~ result.data.filter!isAlphaNum().to!string();
return f;
} }
} }
struct ESVApiOptions struct ESVApiOptions
{ {
bool[string] boolOpts; bool[string] b;
int[string] intOpts; int[string] i;
string indent_using; ESVIndent indent_using;
void defaults() nothrow @safe
this(bool initialise) nothrow @safe
{ {
boolOpts["include_passage_references"] = true; if (!initialise)
boolOpts["include_verse_numbers"] = true; return;
boolOpts["include_first_verse_numbers"] = true;
boolOpts["include_footnotes"] = true; b["include-passage-references"] = true;
boolOpts["include_footnote_body"] = true; b["include-verse-numbers"] = true;
boolOpts["include_headings"] = true; b["include-first-verse-numbers"] = true;
boolOpts["include_short_copyright"] = true; b["include-footnotes"] = true;
boolOpts["include_copyright"] = false; b["include-footnote-body"] = true;
boolOpts["include_passage_horizontal_lines"] = false; b["include-headings"] = true;
boolOpts["include_heading_horizontal_lines"] = false; b["include-short-copyright"] = true;
boolOpts["include_selahs"] = true; b["include-copyright"] = false;
boolOpts["indent_poetry"] = true; b["include-passage-horizontal-lines"] = false;
intOpts["horizontal_line_length"] = 55; b["include-heading-horizontal-lines"] = false;
intOpts["indent_paragraphs"] = 2; b["include-selahs"] = true;
intOpts["indent_poetry_lines"] = 4; b["indent-poetry"] = true;
intOpts["indent_declares"] = 40; i["horizontal-line-length"] = 55;
intOpts["indent_psalm_doxology"] = 30; i["indent-paragraphs"] = 2;
intOpts["line_length"] = 0; i["indent-poetry-lines"] = 4;
indent_using = "space"; i["indent-declares"] = 40;
i["indent-psalm-doxology"] = 30;
i["line-length"] = 0;
indent_using = ESVIndent.TAB;
} }
} }
class ESVException : Exception class ESVException : Exception
{ {
@safe this(string msg, string file = __FILE__, size_t line = __LINE__) pure mixin basicExceptionCtors;
{
super(msg, file, line);
}
} }

331
main.d
View file

@ -19,123 +19,139 @@
*/ */
import std.conv : to, ConvException; import std.conv : to, ConvException;
import std.file : exists, write, FileException; import std.exception : enforce;
import std.file : exists, mkdirRecurse, write, FileException;
import std.format : format; import std.format : format;
import std.getopt : getopt, GetOptException, config; import std.getopt : getopt, GetOptException;
import std.path : baseName, expandTilde, isValidPath; import std.getopt : getoptConfig = config;
import std.path : baseName, dirName, expandTilde, isValidPath;
import std.process : environment, executeShell; import std.process : environment, executeShell;
import std.regex : regex, matchFirst, replaceAll, replaceFirst; import std.regex : regex, matchFirst, replaceAll, replaceFirst;
import std.stdio : writef, writeln, writefln, stderr; import std.stdio : writef, writeln, writefln, stderr;
import std.string : splitLines; import std.string : splitLines;
import config;
import esvapi; import esvapi;
import dini; import dini;
enum VERSION = "0.2.0"; enum VERSION = "0.2.0";
enum DEFAULT_APIKEY = "abfb7456fa52ec4292c79e435890cfa3df14dc2b"; bool aFlag; /* audio */
enum DEFAULT_CONFIGPATH = "~/.config/esv.conf"; string CFlag; /* config */
enum DEFAULT_MPEGPLAYER = "mpg123"; bool fFlag, FFlag; /* footnotes */
enum DEFAULT_PAGER = "less"; bool hFlag, HFlag; /* headings */
int lFlag; /* line length */
bool nFlag, NFlag; /* verse numbers */
bool PFlag; /* disable pager */
bool rFlag, RFlag; /* passage references */
bool VFlag; /* show version */
enum ENV_CONFIG = "ESV_CONFIG"; int
enum ENV_PAGER = "ESV_PAGER"; main(string[] args)
enum ENV_PLAYER = "ESV_PLAYER";
bool optAudio;
string optConfigPath;
bool optFootnotes;
bool optNoFootnotes;
bool optHeadings;
bool optNoHeadings;
int optLineLength = 0;
bool optVerseNumbers;
bool optNoVerseNumbers;
bool optNoPager;
bool optPassageReferences;
bool optNoPassageReferences;
bool optVersion;
int main(string[] args)
{ {
void msg(string s) bool success;
{
stderr.writefln("%s: %s", args[0].baseName(), s); debug {
return run(args);
} }
void panic(string s) try {
{ success = run(args);
import core.runtime : Runtime; } catch (Exception e) {
import core.stdc.stdlib : exit; if (typeid(e) == typeid(Exception)) {
stderr.writefln("%s: %s", args[0].baseName(), e.msg);
msg(s); } else {
scope(exit) { stderr.writefln("%s: uncaught %s in %s:%d: %s",
Runtime.terminate(); args[0].baseName(),
exit(1); typeid(e).name,
e.file, e.line, e.msg);
stderr.writefln("this is probably a bug; it would be greatly appreciated if you reported it at:\n\n %s",
BUGREPORTURL);
} }
} }
// Parse command-line options return success ? 0 : 1;
}
bool
run(string[] args)
{
ushort lines;
string apiKey;
string configPath;
string configEnvVar;
string pager;
string verses;
Ini iniData;
ESVApi esv;
/* Parse command-line options */
try { try {
args.getopt( args.getopt(
config.bundling, getoptConfig.bundling,
config.caseSensitive, getoptConfig.caseSensitive,
"a", &optAudio, "a", &aFlag,
"C", &optConfigPath, "C", &CFlag,
"F", &optNoFootnotes, "F", &FFlag,
"f", &optFootnotes, "f", &fFlag,
"H", &optNoHeadings, "H", &HFlag,
"h", &optHeadings, "h", &hFlag,
"l", &optLineLength, "l", &lFlag,
"N", &optNoVerseNumbers, "N", &NFlag,
"n", &optVerseNumbers, "n", &nFlag,
"P", &optNoPager, "P", &PFlag,
"R", &optNoPassageReferences, "R", &RFlag,
"r", &optNoPassageReferences, "r", &rFlag,
"V", &optVersion, "V", &VFlag,
); );
} catch (GetOptException e) { } catch (GetOptException e) {
if (!e.msg.matchFirst(regex("^Unrecognized option")).empty) enforce(e.msg.matchFirst(regex("^Unrecognized option")).empty,
panic("unknown option " ~ e.extractOpt()); "unknown option " ~ e.extractOpt());
else if (!e.msg.matchFirst(regex("^Missing value for argument")).empty) enforce(e.msg.matchFirst(regex("^Missing value for argument")).empty,
panic("missing argument for option " ~ e.extractOpt()); "missing argument for option " ~ e.extractOpt());
} catch (ConvException e)
panic("value provided by option -l is not convertible to an integer value; must be a non-decimal number");
if (optVersion) { throw new Exception(e.msg); /* catch-all */
} catch (ConvException e) {
throw new Exception(
"illegal argument to -l option -- integer required");
}
if (VFlag) {
writeln("esv version " ~ VERSION); writeln("esv version " ~ VERSION);
return 0; return true;
} }
if (args.length < 3) { if (args.length < 3) {
stderr.writefln("usage: %s [-C config] [-l length] [-aFfHhNnPRrV] book verses", args[0].baseName()); stderr.writefln("usage: %s [-C config] [-l length] [-aFfHhNnPRrV] book verses", args[0].baseName());
return 1; return false;
} }
// Determine configuration file enforce(bookValid(args[1].parseBook()),
// Options have first priority, then environment variables, then the default path format!"book '%s' does not exist"(args[1]));
string configPath; enforce(verseValid(args[2]),
string configEnvVar = environment.get(ENV_CONFIG); format!"invalid verse format '%s'"(args[2]));
Ini iniData;
/* determine configuration file
* Options have first priority, then environment variables,
* then the default path */
configPath = environment.get(ENV_CONFIG, DEFAULT_CONFIGPATH)
.expandTilde();
try { try {
if (optConfigPath != "") { if (CFlag != "") { /* if -C was given */
if (optConfigPath.isValidPath()) enforce(isValidPath(CFlag), CFlag ~ ": invalid path");
configPath = optConfigPath.expandTilde(); configPath = CFlag.expandTilde();
else
panic(optConfigPath ~ ": invalid file path");
} else { } else {
configPath = environment.get(ENV_CONFIG, DEFAULT_CONFIGPATH); enforce(isValidPath(configPath),
if (configPath.isValidPath()) configPath ~ ": invalid path");
configPath = configPath.expandTilde();
else
panic(configEnvVar ~ ": invalid file path");
if (!configPath.exists()) { if (!configPath.exists()) {
configPath.write( mkdirRecurse(configPath.dirName());
configPath.write(format!
"# Default esv configuration file. "# Default esv configuration file.
# An API key is required to access the ESV Bible API. # An API key is required to access the ESV Bible API.
[api] [api]
key = " ~ DEFAULT_APIKEY ~ " key = %s
# If you really need to, you can specify # If you really need to, you can specify
# custom API parameters using `parameters`: # custom API parameters using `parameters`:
#parameters = &my-parameter=value #parameters = &my-parameter=value
@ -146,136 +162,123 @@ key = " ~ DEFAULT_APIKEY ~ "
#headings = false #headings = false
#passage_references = false #passage_references = false
#verse_numbers = false #verse_numbers = false
"); "(DEFAULT_APIKEY));
} }
} }
iniData = Ini.Parse(configPath); iniData = Ini.Parse(configPath);
} catch (FileException e) { } catch (FileException e) {
// filesystem syscall errors /* filesystem syscall errors */
if (!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Ii]s a directory")).empty || throw new Exception(e.msg);
!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Nn]o such file or directory")).empty ||
!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Pp]ermission denied")).empty)
panic(e.msg);
} }
string apiKey; try {
try
apiKey = iniData["api"].getKey("key"); apiKey = iniData["api"].getKey("key");
catch (IniException e) } catch (IniException e) {
panic("API key not present in configuration file; cannot proceed"); apiKey = "";
if (apiKey == "") }
panic("API key not present in configuration file; cannot proceed"); enforce(apiKey != "",
"API key not present in configuration file; cannot proceed");
// Initialise API object and validate the book and verse esv = new ESVApi(apiKey);
ESVApi esv = new ESVApi(apiKey, optAudio);
if (!esv.validateBook(args[1].extractBook()))
panic("book '" ~ args[1] ~ "' does not exist");
if (!esv.validateVerse(args[2]))
panic("invalid verse format '" ~ args[2] ~ "'");
if (optAudio) { if (aFlag) {
// check for mpg123 string tmpf, mpegPlayer;
if (executeShell("which " ~ DEFAULT_MPEGPLAYER ~ " >/dev/null 2>&1").status > 0) {
panic(DEFAULT_MPEGPLAYER ~ " is required for audio mode; cannot continue"); /* check for mpg123 */
return 1; enforce(executeShell(
} else { format!"command -v %s >/dev/null 2>&1"(DEFAULT_MPEGPLAYER)).status == 0,
string tmpf = esv.getAudioVerses(args[1], args[2]); DEFAULT_MPEGPLAYER ~ " is required for audio mode; cannot continue");
string mpegPlayer = environment.get(ENV_PLAYER, DEFAULT_MPEGPLAYER);
/* tmpf = esv.getAudioVerses(args[1], args[2]);
* esv has built-in support for mpg123 and mpv; mpegPlayer = environment.get(ENV_PLAYER, DEFAULT_MPEGPLAYER);
* other players will work, just recompile with /* esv has built-in support for mpg123 and mpv.
* the DEFAULT_MPEGPLAYER enum set differently * other players will work, just recompile with
* or use the ESV_PLAYER environment variable * the DEFAULT_MPEGPLAYER enum set differently
*/ * or use the ESV_PLAYER environment variable */
if (mpegPlayer == "mpg123") mpegPlayer ~=
mpegPlayer = mpegPlayer ~ " -q "; mpegPlayer == "mpg123" ? " -q " :
else if (mpegPlayer == "mpv") mpegPlayer == "mpv" ? " --msg-level=all=no " : " ";
mpegPlayer = mpegPlayer ~ " --msg-level=all=no "; /* spawn mpg123 */
else executeShell(mpegPlayer ~ tmpf);
mpegPlayer = DEFAULT_MPEGPLAYER ~ " "; return true;
// spawn mpg123
executeShell(mpegPlayer ~ tmpf);
}
return 0;
} }
esv.extraParameters = iniData["api"].getKey("parameters"); esv.extraParameters = iniData["api"].getKey("parameters");
string returnValid(string def, string val) string returnValid(string def, string val)
{ {
if (val == "") return val == "" ? def : val;
return def;
else
return val;
} }
// Get [passage] keys /* Get [passage] keys */
foreach (string key; ["footnotes", "headings", "passage_references", "verse_numbers"]) { foreach (string key; ["footnotes", "headings", "passage_references", "verse_numbers"]) {
try { try {
esv.opts.boolOpts["include_" ~ key] = esv.opts.b["include_" ~ key] =
returnValid("true", iniData["passage"].getKey(key)).to!bool(); returnValid("true", iniData["passage"].getKey(key)).to!bool();
} catch (ConvException e) { } catch (ConvException e) {
panic( throw new Exception(format!
format!"%s: key '%s' of section 'passage' is not a boolean value (must be either 'true' or 'false')"( "%s: key '%s' of section 'passage' is not a boolean value (must be either 'true' or 'false')"
configPath, key (configPath, key)
)
); );
} catch (IniException e) {} // just do nothing; use the default settings } catch (IniException e) {} // just do nothing; use the default settings
} }
// Get line_length ([passage]) /* Get line_length ([passage]) */
try try esv.opts.i["line_length"] = returnValid("0", iniData["passage"].getKey("line_length")).to!int();
esv.opts.intOpts["line_length"] = returnValid("0", iniData["passage"].getKey("line_length")).to!int();
catch (ConvException e) { catch (ConvException e) {
panic(configPath ~ ": value '" ~ iniData["passage"].getKey("line_length") throw new Exception(
~ "' is not convertible to an integer value; must be a non-decimal number"); format!"%s: illegal value '%s' -- must be an integer"(
configPath,
iniData["passage"].getKey("line_length"))
);
} catch (IniException e) {} // just do nothing; use the default setting } catch (IniException e) {} // just do nothing; use the default setting
if (optFootnotes) esv.opts.boolOpts["include_footnotes"] = true; if (fFlag) esv.opts.b["include_footnotes"] = true;
if (optHeadings) esv.opts.boolOpts["include_headings"] = true; if (hFlag) esv.opts.b["include_headings"] = true;
if (optVerseNumbers) esv.opts.boolOpts["include_verse_numbers"] = true; if (nFlag) esv.opts.b["include_verse_numbers"] = true;
if (optPassageReferences) esv.opts.boolOpts["include_passage_references"] = true; if (rFlag) esv.opts.b["include_passage_references"] = true;
if (optNoFootnotes) esv.opts.boolOpts["include_footnotes"] = false; if (FFlag) esv.opts.b["include_footnotes"] = false;
if (optNoHeadings) esv.opts.boolOpts["include_headings"] = false; if (HFlag) esv.opts.b["include_headings"] = false;
if (optNoVerseNumbers) esv.opts.boolOpts["include_verse_numbers"] = false; if (NFlag) esv.opts.b["include_verse_numbers"] = false;
if (optNoPassageReferences) esv.opts.boolOpts["include_passage_references"] = false; if (RFlag) esv.opts.b["include_passage_references"] = false;
if (optLineLength != 0) esv.opts.intOpts ["line_length"] = optLineLength; if (lFlag != 0) esv.opts.i["line_length"] = lFlag;
string verses = esv.getVerses(args[1].extractBook(), args[2]); verses = esv.getVerses(args[1].parseBook(), args[2]);
int lines;
foreach (string line; verses.splitLines()) foreach (string line; verses.splitLines())
++lines; ++lines;
// If the passage is very long, pipe it into a pager /* If the passage is very long, pipe it into a pager */
if (lines > 32 && !optNoPager) { if (lines > 32 && !PFlag) {
import std.process : pipeProcess, Redirect, wait, ProcessException; import std.process : pipeProcess, Redirect, wait, ProcessException;
string pager = environment.get(ENV_PAGER, DEFAULT_PAGER);
pager = environment.get(ENV_PAGER, DEFAULT_PAGER);
try { try {
auto pipe = pipeProcess(pager, Redirect.stdin); auto pipe = pipeProcess(pager, Redirect.stdin);
pipe.stdin.writeln(verses); pipe.stdin.writeln(verses);
pipe.stdin.flush(); pipe.stdin.flush();
pipe.stdin.close(); pipe.stdin.close();
pipe.pid.wait(); pipe.pid.wait();
} } catch (ProcessException e) {
catch (ProcessException e) { enforce(e.msg.matchFirst(regex("^Executable file not found")).empty,
if (!e.msg.matchFirst(regex("^Executable file not found")).empty) { format!"%s: command not found"(e.msg
panic(e.msg
.matchFirst(": (.+)$")[0] .matchFirst(": (.+)$")[0]
.replaceFirst(regex("^: "), "") .replaceFirst(regex("^: "), "")
~ ": command not found" ));
);
}
}
} else
writeln(verses);
return 0; throw new Exception(e.msg); /* catch-all */
}
return true;
}
writeln(verses);
return true;
} }
string extractOpt(in GetOptException e) @safe private string extractOpt(in GetOptException e) @safe
{ {
return e.msg.matchFirst("-.")[0]; return e.msg.matchFirst("-.")[0];
} }
string extractBook(in string book) @safe private string parseBook(in string book) @safe
{ {
return book.replaceAll(regex("[-_]"), " "); return book.replaceAll(regex("[-_]"), " ");
} }