/* * esvapi.d: a reusable interface to the ESV HTTP API * * The GPLv2 License (GPLv2) * Copyright (c) 2023-2025 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 esvapi; import std.conv : to; import std.exception : basicExceptionCtors, enforce; import std.file : tempDir, write; import std.format : format; import std.json : JSONValue, parseJSON; import std.regex : regex, matchAll; import std.stdio : File; import std.string : capitalize, tr, wrap; import std.net.curl : HTTP; public import std.net.curl : CurlException; @safe: /++ Indentation style to use when formatting passages. +/ enum ESVIndent { SPACE, TAB } /++ Constant array of all books in the Bible. +/ immutable string[] bibleBooks = [ /* Old Testament */ "Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy", "Joshua", "Judges", "Ruth", "1 Samuel", "2 Samuel", "1 Kings", "2 Kings", "1 Chronicles", "2 Chronicles", "Ezra", "Nehemiah", "Esther", "Job", "Psalm", "Proverbs", "Ecclesiastes", "Song of Solomon", "Isaiah", "Jeremiah", "Lamentations", "Ezekiel", "Daniel", "Hosea", "Joel", "Amos", "Obadiah", "Jonah", "Micah", "Nahum", "Habakkuk", "Zephaniah", "Haggai", "Zechariah", "Malachi", /* New Testament */ "Matthew", "Mark", "Luke", "John", "Acts", "Romans", "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians", "Philippians", "Colossians", "1 Thessalonians", "2 Thessalonians", "1 Timothy", "2 Timothy", "Titus", "Philemon", "Hebrews", "James", "1 Peter", "2 Peter", "1 John", "2 John", "3 John", "Jude", "Revelation" ]; /++ All allowed API parameters (for text passages). +/ immutable string[] esvapiParameters = [ "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 { foreach (string b; bibleBooks) { if (book.capitalize() == b.capitalize()) return true; } return false; } /++ + Returns true if the argument verse is a valid verse format. + Otherwise, returns false. +/ bool verseValid(in char[] verse) { foreach (string re; [ "^\\d{1,3}$", "^\\d{1,3}-\\d{1,3}$", "^\\d{1,3}:\\d{1,3}$", "^\\d{1,3}:\\d{1,3}-\\d{1,3}$" ]) { if (!verse.matchAll(regex(re)).empty) return true; } return false; } @safe unittest { assert(verseValid("1")); assert(verseValid("5-7")); assert(verseValid("15:13")); assert(verseValid("15:12-17")); } string defaultSearchFmt(string reference, string content) pure { return format!"\033[1m%s\033[0m\n %s\n"(reference, content.wrap(80)); } /++ + Structure containing the authentication key, API URL, + any parameters to use when making a request as well as the + temporary directory to use when fetching audio passages. +/ struct ESVApi { ESVApiOptions opts; string key; /++ API key +/ string tmp; /++ Tempfile directory +/ string url; /++ API URL +/ string extraParameters; /++ Additional request parameters +/ this(string apiKey) { key = apiKey; tmp = tempDir() ~ "esv"; url = "https://api.esv.org/v3/passage"; opts = ESVApiOptions(true); } /++ + Requests the passage in text format from the API and returns it. + The (case-insensitive) name of the book being searched is + contained in the argument book. The verses being looked up are + contained in the argument verses. + + Example: getPassage("John", "3:16-21") +/ string getPassage(in char[] book, in char[] verse) in (bookValid(book), "Invalid book") in (verseValid(verse), "Invalid verse format") { char[] params, response; params = []; { string[] parambuf; void addParams(R)(R item) { parambuf.length++; parambuf[parambuf.length - 1] = format!"&%s=%s"(item.key, item.value); } parambuf = new string[opts.i.length + opts.b.length + 1]; foreach (item; opts.i.byKeyValue()) addParams(item); foreach (item; opts.b.byKeyValue()) addParams(item); parambuf[parambuf.length - 1] = format!"&indent-using=%s"( opts.indent_using == ESVIndent.TAB ? "tab" : "space"); /* assemble string from string buffer */ foreach (string param; parambuf) { params ~= param; } } response = makeRequest(format!"text/?q=%s+%s"( book.capitalize().tr(" ", "+"), verse) ~ params ~ extraParameters); return response.parseJSON()["passages"][0].str; } /++ + Requests an audio track of the verse(s) from the API and + returns a file path containing an MP3 sound track. + 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 verses. + + Example: getAudioPassage("John", "3:16-21") +/ string getAudioPassage(in char[] book, in char[] verse) in (bookValid(book), "Invalid book") in (verseValid(verse), "Invalid verse format") { File tmpFile; tmpFile = File(tmp, "w"); tmpFile.write(makeRequest(format!"audio/?q=%s+%s"( book.capitalize().tr(" ", "+"), verse))); return tmp; } /++ + Requests a passage search for the given query. + Returns a string containing JSON data representing + the results of the search. + + Example: search("It is finished") +/ string search(in string query) @trusted { JSONValue[] pages, results; JSONValue result; JSONValue makeQuery(long page) { return parseJSON(makeRequest("search/?page-size=100" ~ "&page=" ~ page.to!string() ~ "&q=" ~ query.tr(" ", "+"))); } pages ~= makeQuery(1); if (pages[0]["total_pages"].integer == 1) { result = JSONValue([ "results": pages[0]["results"], "total": JSONValue(pages[0]["results"].array.length) ]); return result.toString(); } foreach (long i; 2 .. pages[0]["total_pages"].integer + 1) { pages ~= makeQuery(i); } foreach (JSONValue page; pages) { results ~= page["results"].array; } result = JSONValue([ "results": JSONValue(results), "total": JSONValue(results.length) ]); return result.toString(); } /++ + Calls search() and formats the results nicely as plain text, + unless a custom function is provided. +/ string searchFormat(in string query, string function(string, string) fmt = &defaultSearchFmt) { char[] layout; JSONValue resp; resp = parseJSON(search(query)); layout = []; enforce!ESVException(resp["total"].integer != 0, "No results for search"); () @trusted { foreach (JSONValue item; resp["results"].array) { layout ~= fmt(item["reference"].str, item["content"].str); } }(); return layout.idup(); } protected char[] makeRequest(in char[] query) @trusted { char[] response; HTTP request; response = []; request = HTTP(url ~ "/" ~ query); request.onReceive = (ubyte[] data) { response ~= data; return data.length; }; request.addRequestHeader("Authorization", "Token " ~ key); request.perform(); return response; } } /++ + Structure containing parameters passed to the ESV API. +/ struct ESVApiOptions { bool[string] b; int[string] i; ESVIndent indent_using; /++ + If initialise is true, initialise an ESVApiOptions + structure with the default values. +/ this(bool initialise) nothrow { if (!initialise) return; b["include-passage-references"] = true; b["include-verse-numbers"] = true; b["include-first-verse-numbers"] = true; b["include-footnotes"] = true; b["include-footnote-body"] = true; b["include-headings"] = true; b["include-short-copyright"] = true; b["include-copyright"] = false; b["include-passage-horizontal-lines"] = false; b["include-heading-horizontal-lines"] = false; b["include-selahs"] = true; b["indent-poetry"] = true; i["horizontal-line-length"] = 55; i["indent-paragraphs"] = 2; i["indent-poetry-lines"] = 4; i["indent-declares"] = 40; i["indent-psalm-doxology"] = 30; i["line-length"] = 0; indent_using = ESVIndent.SPACE; } } /++ + Exception thrown on API errors. + + Currently only used when there is no search results + following a call of searchFormat. +/ class ESVException : Exception { mixin basicExceptionCtors; }