7 Commits

Author SHA1 Message Date
jdb da6ba66e1c Prepare 1.0.0 release 2026-06-13 07:15:50 -05:00
jdb 17f953882f Add API.Bible translation support 2026-06-13 07:12:14 -05:00
jdb ffe3118ddf Migrate tool versions to mise 2026-06-13 06:44:42 -05:00
jdb 49594610dc Rename CLI to bibleref 2026-06-13 06:42:33 -05:00
jdb 8f83c07693 Pin to Nim 1.6 with asdf. 2024-08-05 08:01:55 -05:00
jdb 8422199d7b Add reading format, rework plain format. 2023-09-17 06:57:52 -05:00
jdb a0c17bcad9 Rework Markdown formatting to handle quoted passages and poem structure. 2023-06-25 18:47:57 -05:00
7 changed files with 300 additions and 90 deletions
+1
View File
@@ -1,2 +1,3 @@
esv_api
bibleref
*.sw?
+4 -4
View File
@@ -1,17 +1,17 @@
# Package
version = "0.2.2"
version = "1.0.0"
author = "Jonathan Bernard"
description = "Simple Nim CLI wrapper around the ESV API (api.esv.org)"
description = "Simple Nim CLI for retrieving Biblical passages"
license = "MIT"
srcDir = "src"
bin = @["esv_api"]
bin = @["bibleref"]
# Dependencies
requires "nim >= 1.6.10"
requires @["docopt", "zero_functional"]
requires @["docopt", "nimquery", "zero_functional"]
# dependencies from git.jdb-software.com/jdb/nim-packages.git
requires @["cliutils"]
+2
View File
@@ -0,0 +1,2 @@
[tools]
nim = "latest"
+103
View File
@@ -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]
+177
View File
@@ -0,0 +1,177 @@
# Nim CLI for retrieving Biblical passages
# © 2023 Jonathan Bernard
## Simple command-line tool for retrieving Biblical passages.
import std/[json, logging, os, re, strutils, wordwrap]
import cliutils, docopt, zero_functional
import ./api_bible
import ./esv
proc formatMarkdown(raw, translation: string): string =
var reference = ""
var inVerse = false
var verseLines = newSeq[string]()
for line in raw.splitLines:
if reference.len == 0: reference = line.strip
if inVerse:
if line.startsWith("Footnotes"): inVerse = false
elif line.isEmptyOrWhitespace and verseLines[^1] != "":
verseLines.add("")
elif not line.match(re"^\s+[^\s]"): continue
elif line.match(re"$(.*)\(ESV\)$"): verseLines.add(line[0 ..< ^5])
else: verseLines.add(line)
elif line.match(re"^\s+\[\d+\]"):
inVerse = true
verseLines.add(line)
let wrapped = (verseLines -->
map(if it.len > 90: it.strip else: it & " ").
map(it.multiReplace([(re"\((\d+)\)", ""), (re"\[(\d+)\]", "**$1**")])).
map(wrapWords(it, maxLineWidth = 74, newLine = "\p"))).join("\p")
result = (wrapped.splitLines --> map("> " & it)).
join("\p") & "\p> -- *" & reference & " (" &
translation.toUpperAscii & ")*"
proc formatPlain(
raw,
translation: string,
keepVerseNumbers = true): string =
var reference = ""
var inVerse = false
var verseLines = newSeq[string]()
for line in raw.splitLines:
if reference.len == 0: reference = line.strip
if inVerse:
if line.startsWith("Footnotes"): inVerse = false
elif line.isEmptyOrWhitespace and verseLines[^1] != "":
verseLines.add("")
elif not line.match(re"^\s+[^\s]"): continue
elif line.match(re"$(.*)\(ESV\)$"): verseLines.add(line[0 ..< ^5])
else: verseLines.add(line)
elif line.match(re"^\s+\[\d+\]"):
inVerse = true
verseLines.add(line)
let wrapped = (verseLines -->
map(if it.len > 90: it.strip else: it & " ").
map(
if keepVerseNumbers:
it.multiReplace([(re"\((\d+)\)", ""), (re"\[(\d+)\]", "$1")])
else:
it.multiReplace([(re"\((\d+)\)", ""), (re"\[(\d+)\]", "")])).
map(wrapWords(it, maxLineWidth = 74, newLine = "\p"))).join("\p")
result = (wrapped.splitLines --> map(it)).
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:
bibleref <reference> [options]
Options:
--debug Log debug information.
--echo-args Echo back the arguments that were passed on the
command line for debugging purposes.
-f, --output-format <format> Select a specific output format. Valid values
are 'raw', 'markdown', 'plain', 'reading'.
-t, --translation <translation>
Select a specific translation. Supported values
are 'amp', 'esv', 'nkjv', and 'niv'. Defaults
to 'esv'.
--esv-api-token <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 <key> Provide the API.Bible API key for translations
backed by api.bible.
--api-bible-root <url> Override the API.Bible API root. Defaults to
https://rest.api.bible/v1.
--api-bible-amp-bible-id <id> Override the API.Bible Bible ID for AMP.
--api-bible-niv-bible-id <id> Override the API.Bible Bible ID for NIV.
--api-bible-nkjv-bible-id <id>
Override the API.Bible Bible ID for NKJV.
"""
let consoleLogger = newConsoleLogger(
levelThreshold=lvlInfo,
fmtStr="bibleref - $levelname: ")
logging.addHandler(consoleLogger)
try:
# Parse arguments
let args = docopt(USAGE, version = "1.0.0")
if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug
if args["--echo-args"]: stderr.writeLine($args)
let cfgFilePath = getEnv("HOME") / ".bibleref.cfg.json"
var cfgFileJson = newJObject()
if fileExists(cfgFilePath):
debug "Loading config from " & cfgFilePath
cfgFileJson = parseFile(cfgFilePath)
let cfg = CombinedConfig(docopt: args, json: cfgFileJson)
let translation = cfg.getVal("translation", "esv").strip.toLowerAscii
let reference = $args["<reference>"]
let passages = fetchPassages(reference, translation, cfg)
let formattedPassages =
case $args["--output-format"]:
of "plain":
passages --> map(formatPlain(it, translation))
of "reading":
passages -->
map(formatPlain(it, translation, keepVerseNumbers = false))
of "text":
passages -->
map(it.multiReplace([(re"\[(\d+)\]", "$1")]))
of "raw": passages
else:
passages --> map(formatMarkdown(it, translation))
echo formattedPassages.join("\p\p")
except CatchableError:
fatal getCurrentExceptionMsg()
debug getCurrentException().getStackTrace()
quit(QuitFailure)
+13
View File
@@ -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)
-86
View File
@@ -1,86 +0,0 @@
# Nim CLI Wrapper for the ESV API
# © 2023 Jonathan Bernard
## Simple command-line wrapper around the ESV API.
import std/[httpclient, json, logging, os, re, strutils, uri, wordwrap]
import cliutils, docopt, zero_functional
proc formatMarkdown(raw: string): string =
let rawLines = raw.splitLines
let wrapped = (raw.splitLines -->
filter(match(it, re"^\s+(\[\d+\]|\w).*")).
map(it.strip.multiReplace([(re"\((\d+)\)", ""), (re"\[(\d+)\]", "**$1**")])).
map(wrapWords(it, maxLineWidth = 74, newLine = "\p"))).
join("\p")
result = (wrapped.splitLines --> map("> " & it)).join("\p") &
"\p>\p> -- *" & rawLines[0].strip & " (ESV)*"
when isMainModule:
const USAGE = """Usage:
esv_api <reference> [options]
Options:
--debug Log debug information.
--echo-args Echo back the arguments that were passed on the
command line for debugging purposes.
-f, --output-format <format> Select a specific output format. Valid values
are 'raw', 'markdown', 'plain'.
-t, --esv-api-token <token> Provide the API token on the command line. By
default this will be read either from the
.esv_api.cfg.json file or the ESV_API_TOKEN
envionment variable.
"""
let consoleLogger = newConsoleLogger(
levelThreshold=lvlInfo,
fmtStr="esv_api - $levelname: ")
logging.addHandler(consoleLogger)
try:
# Parse arguments
let args = docopt(USAGE, version = "0.2.2")
if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug
if args["--echo-args"]: stderr.writeLine($args)
let cfgFilePath = getEnv("HOME") / ".esv_api.cfg.json"
var cfgFileJson = newJObject()
if fileExists(cfgFilePath):
debug "Loading config from " & cfgFilePath
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 reference = $args["<reference>"]
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 formattedPassages =
case $args["--output-format"]:
of "text":
respJson["passages"].getElems -->
map(it.getStr.multiReplace([(re"\[(\d+)\]", "$1")]))
of "raw": respJson["passages"].getElems --> map(it.getStr)
else:
respJson["passages"].getElems --> map(formatMarkdown(it.getStr))
echo formattedPassages.join("\p\p")
except CatchableError:
fatal getCurrentExceptionMsg()
debug getCurrentException().getStackTrace()
quit(QuitFailure)