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:
parent
1b11097ba0
commit
06a4dc286d
3 changed files with 397 additions and 362 deletions
18
config.di
Normal file
18
config.di
Normal 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
|
428
esvapi.d
428
esvapi.d
|
@ -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,152 +108,165 @@ 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",
|
||||||
|
];
|
||||||
|
|
||||||
class ESVApi
|
/*
|
||||||
{
|
|
||||||
private {
|
|
||||||
int _mode;
|
|
||||||
string _key;
|
|
||||||
string _tmp;
|
|
||||||
string _url;
|
|
||||||
}
|
|
||||||
ESVApiOptions opts;
|
|
||||||
string extraParameters;
|
|
||||||
int delegate(size_t, size_t, size_t, size_t) onProgress;
|
|
||||||
this(immutable(string) key = ESVAPI_KEY, bool audio = false)
|
|
||||||
{
|
|
||||||
_key = key;
|
|
||||||
_mode = audio ? ESVMode.AUDIO : ESVMode.TEXT;
|
|
||||||
_tmp = tempDir() ~ "esv";
|
|
||||||
_url = ESVAPI_URL;
|
|
||||||
opts.defaults();
|
|
||||||
extraParameters = "";
|
|
||||||
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.
|
|
||||||
* This authentication key cannot be changed after instantiation.
|
|
||||||
*/
|
|
||||||
@property string key() const nothrow pure @nogc @safe
|
|
||||||
{
|
|
||||||
return _key;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* Returns the API authentication key currently in use.
|
|
||||||
*/
|
|
||||||
@property int mode() const nothrow pure @nogc @safe
|
|
||||||
{
|
|
||||||
return _mode;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
@property string url() const nothrow pure @nogc @safe
|
|
||||||
{
|
|
||||||
return _url;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* If the url argument is a valid HTTP URL, sets the API URL currently in use
|
|
||||||
* to the given url argument. Otherwise, throws an ESVException.
|
|
||||||
*/
|
|
||||||
@property void url(immutable(string) url) @safe
|
|
||||||
{
|
|
||||||
if (url.matchAll("^https?://.+\\..+(/.+)?").empty)
|
|
||||||
throw new ESVException("Invalid URL format");
|
|
||||||
else
|
|
||||||
_url = url;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* Returns the temp directory name.
|
|
||||||
*/
|
|
||||||
@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.
|
* Returns true if the argument book is a valid book of the Bible.
|
||||||
* Otherwise, returns false.
|
* Otherwise, returns false.
|
||||||
*/
|
*/
|
||||||
bool validateBook(in char[] book) const nothrow @safe
|
bool bookValid(in char[] book) nothrow @safe
|
||||||
{
|
{
|
||||||
foreach (b; BIBLE_BOOKS) {
|
foreach (string b; BIBLE_BOOKS) {
|
||||||
if (book.capitalize() == b.capitalize())
|
if (book.capitalize() == b.capitalize())
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Returns true if the argument book is a valid verse format.
|
* Returns true if the argument book is a valid verse format.
|
||||||
* Otherwise, returns false.
|
* Otherwise, returns false.
|
||||||
*/
|
*/
|
||||||
bool validateVerse(in char[] verse) const @safe
|
bool verseValid(in char[] verse) @safe
|
||||||
|
{
|
||||||
|
bool vMatch(in string re) @safe
|
||||||
{
|
{
|
||||||
bool attemptRegex(string re) const @safe
|
return !verse.matchAll(regex(re)).empty;
|
||||||
{
|
|
||||||
return !verse.matchAll(re).empty;
|
|
||||||
}
|
}
|
||||||
if (attemptRegex("^\\d{1,3}$") ||
|
if (vMatch("^\\d{1,3}$") ||
|
||||||
attemptRegex("^\\d{1,3}-\\d{1,3}$") ||
|
vMatch("^\\d{1,3}-\\d{1,3}$") ||
|
||||||
attemptRegex("^\\d{1,3}:\\d{1,3}$") ||
|
vMatch("^\\d{1,3}:\\d{1,3}$") ||
|
||||||
attemptRegex("^\\d{1,3}:\\d{1,3}-\\d{1,3}$"))
|
vMatch("^\\d{1,3}:\\d{1,3}-\\d{1,3}$"))
|
||||||
return true;
|
return true;
|
||||||
else
|
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class ESVApi
|
||||||
|
{
|
||||||
|
protected {
|
||||||
|
string _key;
|
||||||
|
string _tmp;
|
||||||
|
string _url;
|
||||||
|
}
|
||||||
|
|
||||||
|
string extraParameters;
|
||||||
|
int delegate(size_t, size_t, size_t, size_t) onProgress;
|
||||||
|
ESVApiOptions opts;
|
||||||
|
|
||||||
|
this(string key, string tmpName = "esv")
|
||||||
|
{
|
||||||
|
_key = key;
|
||||||
|
_tmp = tempDir() ~ tmpName;
|
||||||
|
_url = ESVAPI_URL;
|
||||||
|
opts = ESVApiOptions(true);
|
||||||
|
extraParameters = "";
|
||||||
|
onProgress = (size_t dlTotal, size_t dlNow, size_t ulTotal, size_t ulNow) { return 0; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns the API authentication key that was given when the object
|
||||||
|
* was constructed. This authentication key cannot be changed.
|
||||||
|
*/
|
||||||
|
@property string key() const nothrow pure @safe
|
||||||
|
{
|
||||||
|
return _key;
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* Requests the verse(s) from the API and returns it.
|
* Returns the subdirectory used to store temporary audio passages.
|
||||||
|
*/
|
||||||
|
@property string tmpDir() const nothrow pure @safe
|
||||||
|
{
|
||||||
|
return _tmp;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Returns the API URL currently in use.
|
||||||
|
*/
|
||||||
|
@property string url() const nothrow pure @safe
|
||||||
|
{
|
||||||
|
return _url;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Sets the API URL currently in use to the given url argument.
|
||||||
|
*/
|
||||||
|
@property void url(immutable(string) url) @safe
|
||||||
|
in (!url.matchAll(`^https?://.+\\..+(/.+)?`).empty, "Invalid URL format")
|
||||||
|
{
|
||||||
|
_url = url;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Requests the verse(s) in text format 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;
|
response ~= data;
|
||||||
return data.length;
|
return data.length;
|
||||||
};
|
};
|
||||||
request.addRequestHeader("Authorization", "Token " ~ _key);
|
request.addRequestHeader("Authorization", "Token " ~ _key);
|
||||||
|
@ -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;
|
response ~= data;
|
||||||
return data.length;
|
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;
|
||||||
|
HTTP request;
|
||||||
|
JSONValue json;
|
||||||
|
|
||||||
|
request = HTTP(format!"%s/search/?q=%s"(
|
||||||
|
_url, query.replaceAll(regex(" "), "+")));
|
||||||
|
request.onProgress = onProgress;
|
||||||
|
request.onReceive =
|
||||||
|
(ubyte[] data)
|
||||||
{
|
{
|
||||||
return format!"%s&%s=%s"(params, param, value);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
325
main.d
325
main.d
|
@ -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);
|
||||||
|
/* esv has built-in support for mpg123 and mpv.
|
||||||
* other players will work, just recompile with
|
* other players will work, just recompile with
|
||||||
* the DEFAULT_MPEGPLAYER enum set differently
|
* the DEFAULT_MPEGPLAYER enum set differently
|
||||||
* or use the ESV_PLAYER environment variable
|
* or use the ESV_PLAYER environment variable */
|
||||||
*/
|
mpegPlayer ~=
|
||||||
if (mpegPlayer == "mpg123")
|
mpegPlayer == "mpg123" ? " -q " :
|
||||||
mpegPlayer = mpegPlayer ~ " -q ";
|
mpegPlayer == "mpv" ? " --msg-level=all=no " : " ";
|
||||||
else if (mpegPlayer == "mpv")
|
/* spawn mpg123 */
|
||||||
mpegPlayer = mpegPlayer ~ " --msg-level=all=no ";
|
|
||||||
else
|
|
||||||
mpegPlayer = DEFAULT_MPEGPLAYER ~ " ";
|
|
||||||
// spawn mpg123
|
|
||||||
executeShell(mpegPlayer ~ tmpf);
|
executeShell(mpegPlayer ~ tmpf);
|
||||||
}
|
return true;
|
||||||
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("[-_]"), " ");
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue