From 17f953882fd6cf2865b299f7dd2f752b255c5850 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Sat, 13 Jun 2026 07:12:14 -0500 Subject: [PATCH] Add API.Bible translation support --- .gitignore | 1 + src/api_bible.nim | 103 ++++++++++++++++++++++++++++++++++++++++++++++ src/bibleref.nim | 80 ++++++++++++++++++++++++++--------- src/esv.nim | 13 ++++++ 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 src/api_bible.nim create mode 100644 src/esv.nim diff --git a/.gitignore b/.gitignore index 2dea328..442727a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ esv_api +bibleref *.sw? diff --git a/src/api_bible.nim b/src/api_bible.nim new file mode 100644 index 0000000..7182911 --- /dev/null +++ b/src/api_bible.nim @@ -0,0 +1,103 @@ +import std/[httpclient, json, logging, strutils, uri] + +const apiBibleRoot* = "https://rest.api.bible/v1" + +proc configBibleIdKey(translation: string): string = + let normalizedTranslation = translation.toLowerAscii + "apiBible" & normalizedTranslation[0].toUpperAscii & + normalizedTranslation[1..^1] & "BibleId" + +proc defaultBibleId*(translation: string): string = + case translation.toLowerAscii + of "niv": "78a9f6124f344018-01" + else: "" + +proc apiGet(apiRoot, path, query, apiKey: string): JsonNode = + var root = apiRoot + while root.endsWith("/"): root.setLen(root.len - 1) + + var urlPath = root & path + if query.len > 0: + urlPath &= "?" & query + + debug "requesting " & urlPath + + let http = newHttpClient() + http.headers = newHttpHeaders({"api-key": apiKey}) + parseJson(http.getContent(urlPath)) + +proc resolveBibleId(translation, apiKey, apiRoot, configuredBibleId: string): string = + let normalizedTranslation = translation.toLowerAscii + + if configuredBibleId.strip.len > 0: + return configuredBibleId.strip + + let defaultId = defaultBibleId(normalizedTranslation) + if defaultId.len > 0: + return defaultId + + let translationCode = normalizedTranslation.toUpperAscii + let respJson = apiGet( + apiRoot, + "/bibles", + "language=eng&abbreviation=" & encodeUrl(translationCode) & + "&include-full-details=false", + apiKey) + + for bible in respJson["data"].getElems: + let abbreviation = + if bible.hasKey("abbreviation"): bible["abbreviation"].getStr else: "" + let abbreviationLocal = + if bible.hasKey("abbreviationLocal"): bible["abbreviationLocal"].getStr else: "" + + if abbreviation.toLowerAscii == normalizedTranslation or + abbreviationLocal.toLowerAscii == normalizedTranslation: + return bible["id"].getStr + + if respJson["data"].getElems.len > 0: + return respJson["data"].getElems[0]["id"].getStr + + raise newException(ValueError, + "could not find an API.Bible Bible ID for '" & translation & + "'; configure " & configBibleIdKey(normalizedTranslation)) + +proc resolvePassageId(reference, bibleId, apiKey, apiRoot: string): string = + let respJson = apiGet( + apiRoot, + "/bibles/" & encodeUrl(bibleId) & "/search", + "query=" & encodeUrl(reference) & "&limit=1&sort=canonical", + apiKey) + + if respJson["data"].hasKey("passages"): + let passages = respJson["data"]["passages"].getElems + if passages.len > 0: + return passages[0]["id"].getStr + + if respJson["data"].hasKey("verses"): + let verses = respJson["data"]["verses"].getElems + if verses.len == 1: + return verses[0]["id"].getStr + + raise newException(ValueError, + "could not resolve passage reference '" & reference & "' using API.Bible") + +proc fetchPassages*( + reference, + translation, + apiKey, + apiRoot, + configuredBibleId: string): seq[string] = + + let bibleId = resolveBibleId(translation, apiKey, apiRoot, configuredBibleId) + let passageId = resolvePassageId(reference, bibleId, apiKey, apiRoot) + + let respJson = apiGet( + apiRoot, + "/bibles/" & encodeUrl(bibleId) & "/passages/" & encodeUrl(passageId), + "content-type=text&include-notes=false&include-titles=true" & + "&include-chapter-numbers=false&include-verse-numbers=true" & + "&include-verse-spans=false", + apiKey) + + let passage = respJson["data"] + @[passage["reference"].getStr & "\n" & passage["content"].getStr] diff --git a/src/bibleref.nim b/src/bibleref.nim index a6856a5..9590a30 100644 --- a/src/bibleref.nim +++ b/src/bibleref.nim @@ -3,10 +3,13 @@ ## Simple command-line tool for retrieving Biblical passages. -import std/[httpclient, json, logging, os, re, strutils, uri, wordwrap] +import std/[json, logging, os, re, strutils, wordwrap] import cliutils, docopt, zero_functional -proc formatMarkdown(raw: string): string = +import ./api_bible +import ./esv + +proc formatMarkdown(raw, translation: string): string = var reference = "" var inVerse = false var verseLines = newSeq[string]() @@ -30,9 +33,14 @@ proc formatMarkdown(raw: string): string = map(wrapWords(it, maxLineWidth = 74, newLine = "\p"))).join("\p") result = (wrapped.splitLines --> map("> " & it)). - join("\p") & "\p> -- *" & reference & " (ESV)*" + join("\p") & "\p> -- *" & reference & " (" & + translation.toUpperAscii & ")*" + +proc formatPlain( + raw, + translation: string, + keepVerseNumbers = true): string = -proc formatPlain(raw: string, keepVerseNumbers = true): string = var reference = "" var inVerse = false var verseLines = newSeq[string]() @@ -60,7 +68,28 @@ proc formatPlain(raw: string, keepVerseNumbers = true): string = map(wrapWords(it, maxLineWidth = 74, newLine = "\p"))).join("\p") result = (wrapped.splitLines --> map(it)). - join("\p") & "\p– " & reference & " (ESV)" + join("\p") & "\p– " & reference & " (" & translation.toUpperAscii & ")" + +proc fetchPassages(reference, translation: string, cfg: CombinedConfig): seq[string] = + case translation + of "esv": + esv.fetchPassages( + reference, + cfg.getVal("esv-api-token"), + cfg.getVal("esv-api-root", "https://api.esv.org")) + of "amp", "nkjv", "niv": + api_bible.fetchPassages( + reference, + translation, + cfg.getVal("api-bible-api-key"), + cfg.getVal("api-bible-root", api_bible.apiBibleRoot), + cfg.getVal( + "api-bible-" & translation & "-bible-id", + api_bible.defaultBibleId(translation))) + else: + raise newException(ValueError, + "unsupported translation '" & translation & + "'; supported translations: amp, esv, nkjv, niv") when isMainModule: const USAGE = """Usage: @@ -76,10 +105,27 @@ Options: -f, --output-format Select a specific output format. Valid values are 'raw', 'markdown', 'plain', 'reading'. + --translation Select a specific translation. Supported values + are 'amp', 'esv', 'nkjv', and 'niv'. Defaults + to 'esv'. + -t, --esv-api-token Provide the API token on the command line. By default this will be read either from the .bibleref.cfg.json file or the ESV_API_TOKEN envionment variable. + + --api-bible-api-key Provide the API.Bible API key for translations + backed by api.bible. + + --api-bible-root Override the API.Bible API root. Defaults to + https://rest.api.bible/v1. + + --api-bible-amp-bible-id Override the API.Bible Bible ID for AMP. + + --api-bible-niv-bible-id Override the API.Bible Bible ID for NIV. + + --api-bible-nkjv-bible-id + Override the API.Bible Bible ID for NKJV. """ let consoleLogger = newConsoleLogger( @@ -103,30 +149,24 @@ Options: cfgFileJson = parseFile(cfgFilePath) let cfg = CombinedConfig(docopt: args, json: cfgFileJson) - let apiToken = cfg.getVal("esv-api-token") - let apiRoot = cfg.getVal("esv-api-root", "https://api.esv.org") + let translation = cfg.getVal("translation", "esv").strip.toLowerAscii let reference = $args[""] - let http = newHttpClient() - http.headers = newHttpHeaders({"Authorization": "Token " & apiToken}) - - let urlPath = apiRoot & "/v3/passage/text/?q=" & encodeUrl(reference) - debug "requesting " & urlPath - let respJson = parseJson(http.getContent(urlPath)) + let passages = fetchPassages(reference, translation, cfg) let formattedPassages = case $args["--output-format"]: of "plain": - respJson["passages"].getElems --> map(formatPlain(it.getStr)) + passages --> map(formatPlain(it, translation)) of "reading": - respJson["passages"].getElems --> - map(formatPlain(it.getStr, keepVerseNumbers = false)) + passages --> + map(formatPlain(it, translation, keepVerseNumbers = false)) of "text": - respJson["passages"].getElems --> - map(it.getStr.multiReplace([(re"\[(\d+)\]", "$1")])) - of "raw": respJson["passages"].getElems --> map(it.getStr) + passages --> + map(it.multiReplace([(re"\[(\d+)\]", "$1")])) + of "raw": passages else: - respJson["passages"].getElems --> map(formatMarkdown(it.getStr)) + passages --> map(formatMarkdown(it, translation)) echo formattedPassages.join("\p\p") diff --git a/src/esv.nim b/src/esv.nim new file mode 100644 index 0000000..ceed1c0 --- /dev/null +++ b/src/esv.nim @@ -0,0 +1,13 @@ +import std/[httpclient, json, logging, uri] + +proc fetchPassages*(reference, apiToken, apiRoot: string): seq[string] = + let http = newHttpClient() + http.headers = newHttpHeaders({"Authorization": "Token " & apiToken}) + + let urlPath = apiRoot & "/v3/passage/text/?q=" & encodeUrl(reference) + debug "requesting " & urlPath + + let respJson = parseJson(http.getContent(urlPath)) + result = @[] + for passage in respJson["passages"].getElems: + result.add(passage.getStr)