/* * esv: read the Bible from your terminal * * The GPLv2 License (GPLv2) * Copyright (c) 2023 Jeremy Baxter * * This program 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. * * This program 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 this program. If not, see . */ import std.conv : to, ConvException; import std.file : exists, write, FileException; import std.getopt : getopt, GetOptException, config; import std.path : baseName, expandTilde, isValidPath; import std.process : environment, executeShell; import std.regex : regex, matchFirst, replaceFirst; import std.stdio : writef, writeln, writefln, stderr; import std.string : splitLines; import esv; import dini; enum VERSION = "0.1.0"; enum DEFAULT_CONFIGPATH = "~/.config/esv.conf"; enum DEFAULT_PAGER = "less"; enum ENV_CONFIG = "ESV_CONFIG"; enum ENV_PAGER = "ESV_PAGER"; 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) { stderr.writefln("%s: %s", args[0].baseName(), s); } void panic(string s) { import core.runtime : Runtime; import core.stdc.stdlib : exit; msg(s); scope(exit) { Runtime.terminate(); exit(1); } } // Parse command-line options try { args.getopt( config.bundling, config.caseSensitive, "a", &optAudio, "C", &optConfigPath, "F", &optNoFootnotes, "f", &optFootnotes, "H", &optNoHeadings, "h", &optHeadings, "l", &optLineLength, "N", &optNoVerseNumbers, "n", &optVerseNumbers, "P", &optNoPager, "R", &optNoPassageReferences, "r", &optNoPassageReferences, "V", &optVersion, ); } catch (GetOptException e) { if (!e.msg.matchFirst(regex("^Unrecognized option")).empty) panic("unknown option " ~ e.extractOpt()); else if (!e.msg.matchFirst(regex("^Missing value for argument")).empty) panic("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) { writeln("esv version " ~ VERSION); return 0; } if (args.length < 3) { stderr.writefln("usage: %s [-C config] [-l length] [-aFfHhNnPRrV] book verses", args[0].baseName()); return 1; } // Determine configuration file // Options have first priority, then environment variables, then the default path string configPath; string configEnvVar = environment.get(ENV_CONFIG); Ini iniData; try { if (optConfigPath != "") { if (optConfigPath.isValidPath()) configPath = optConfigPath.expandTilde(); else panic(optConfigPath ~ ": invalid file path"); } else if (configEnvVar !is null) { if (configEnvVar.isValidPath()) configPath = configEnvVar.expandTilde(); else panic(configEnvVar ~ ": invalid file path"); } else { configPath = DEFAULT_CONFIGPATH.expandTilde(); if (!configPath.exists()) { configPath.write( "# Default esv configuration file. # An API key is required to access the ESV Bible API. [api] #key = My API key here # If you really need to, you can specify # custom API parameters using `parameters`: #parameters = &my-parameter=value # Some other settings that modify how the passages are displayed: #[passage] #footnotes = false #headings = false #passage_references = false #verse_numbers = false "); } } iniData = Ini.Parse(configPath); } catch (FileException e) { // filesystem syscall errors if (!e.msg.matchFirst(regex("^" ~ configPath ~ ": [Ii]s a directory")).empty || !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 apiKey = iniData["api"].getKey("key"); catch (IniException e) panic("API key not present in configuration file; cannot proceed"); if (apiKey == "") panic("API key not present in configuration file; cannot proceed"); // Initialise API object and validate the book and verse EsvAPI esv = new EsvAPI(apiKey); if (!esv.validateBook(args[1])) panic("book '" ~ args[1] ~ "' does not exist"); if (!esv.validateVerse(args[2])) panic("invalid verse format '" ~ args[2] ~ "'"); if (optAudio) { // check for mpg123 if (executeShell("which mpg123 >/dev/null 2>&1").status > 0) { panic("mpg123 is required for audio mode; cannot continue"); return 1; } else { string tmpf = esv.getAudioVerses(args[1], args[2]); // spawn mpg123 executeShell("mpg123 -q " ~ tmpf); return 0; } } esv.extraParameters = iniData["api"].getKey("parameters"); string returnValid(string def, string val) { if (val == "") return def; else return val; } // Get [passage] keys foreach (string key; ["footnotes", "headings", "passage_references", "verse_numbers"]) { try { esv.opts.boolOpts["include_" ~ key] = returnValid("true", iniData["passage"].getKey(key)).catchConvException( (ConvException ex, string str) { panic(configPath ~ ": value '" ~ str ~ "' is not convertible to a boolean value; must be either 'true' or 'false'"); } ); } catch (IniException e) {} // just do nothing; use the default settings } // Get line_length ([passage]) try esv.opts.intOpts["line_length"] = returnValid("0", iniData["passage"].getKey("line_length")).to!int(); catch (ConvException e) { panic(configPath ~ ": value '" ~ iniData["passage"].getKey("line_length") ~ "' is not convertible to an integer value; must be a non-decimal number"); } catch (IniException e) {} // just do nothing; use the default setting if (optNoFootnotes) esv.opts.boolOpts["include_footnotes"] = false; if (optNoHeadings) esv.opts.boolOpts["include_headings"] = false; if (optNoVerseNumbers) esv.opts.boolOpts["include_verse_numbers"] = false; if (optNoPassageReferences) esv.opts.boolOpts["include_passage_references"] = false; if (optLineLength != 0) esv.opts.intOpts ["line_length"] = optLineLength; string verses = esv.getVerses(args[1], args[2]); int lines; foreach (string line; verses.splitLines()) ++lines; // If the passage is very long, pipe it into a pager if (lines > 32 && !optNoPager) { import std.process : pipeProcess, Redirect, wait, ProcessException; string pager = environment.get(ENV_PAGER, DEFAULT_PAGER); try { auto pipe = pipeProcess(pager, Redirect.stdin); pipe.stdin.writeln(verses); pipe.stdin.flush(); pipe.stdin.close(); pipe.pid.wait(); } catch (ProcessException e) { if (!e.msg.matchFirst(regex("^Executable file not found")).empty) { panic(e.msg .matchFirst(": (.+)$")[0] .replaceFirst(regex("^: "), "") ~ ": command not found" ); } } } else writeln(verses); return 0; } string extractOpt(GetOptException e) { return e.msg.matchFirst("-.")[0]; } bool catchConvException(string sb, void delegate(ConvException ex, string str) catchNet) { try return sb.to!bool(); catch (ConvException e) { catchNet(e, sb); return false; } }