diff --git a/bibleref.nimble b/bibleref.nimble index 54e5580..8a5f141 100644 --- a/bibleref.nimble +++ b/bibleref.nimble @@ -1,6 +1,6 @@ # Package -version = "1.1.1" +version = "1.2.0" author = "Jonathan Bernard" description = "Simple Nim CLI for retrieving Biblical passages" license = "MIT" diff --git a/src/bibleref.nim b/src/bibleref.nim index 3e337fb..8efaae2 100644 --- a/src/bibleref.nim +++ b/src/bibleref.nim @@ -106,9 +106,15 @@ proc fetchPlainPassages( case translation of "akjv", "kjv": - result = kjv.fetchPlainPassages(reference, keepVerseNumbers) + result = kjv.fetchPlainPassages( + reference, + keepVerseNumbers, + cfg.getVal("annotations", "all")) of "mev": - result = mev.fetchPlainPassages(reference, keepVerseNumbers) + result = mev.fetchPlainPassages( + reference, + keepVerseNumbers, + cfg.getVal("annotations", "all")) else: for passage in fetchPassages(reference, translation, cfg): result.add(formatPlain(passage, translation, keepVerseNumbers)) @@ -120,13 +126,38 @@ proc fetchMarkdownPassages( case translation of "akjv", "kjv": - result = kjv.fetchMarkdownPassages(reference) + result = kjv.fetchMarkdownPassages( + reference, + cfg.getVal("annotations", "all")) of "mev": - result = mev.fetchMarkdownPassages(reference) + result = mev.fetchMarkdownPassages( + reference, + cfg.getVal("annotations", "all")) else: for passage in fetchPassages(reference, translation, cfg): result.add(formatMarkdown(passage, translation)) +proc fetchAnsiPassages( + reference, + translation: string, + keepVerseNumbers: bool, + cfg: CombinedConfig): seq[string] = + + case translation + of "akjv", "kjv": + result = kjv.fetchAnsiPassages( + reference, + keepVerseNumbers, + cfg.getVal("annotations", "all")) + of "mev": + result = mev.fetchAnsiPassages( + reference, + keepVerseNumbers, + cfg.getVal("annotations", "all")) + else: + for passage in fetchPassages(reference, translation, cfg): + result.add(formatPlain(passage, translation, keepVerseNumbers)) + when isMainModule: const USAGE = """Usage: bibleref [options] @@ -139,7 +170,13 @@ Options: command line for debugging purposes. -f, --output-format Select a specific output format. Valid values - are 'raw', 'markdown', 'plain', 'reading'. + are 'ansi', 'raw', 'markdown', 'plain', + 'reading'. + + --annotations Select embedded Bible annotations to render. + Valid values are 'none', 'all', or a + comma-separated list of annotation kinds such + as 'note,crossReference'. Defaults to 'all'. -t, --translation Select a specific translation. Supported values @@ -167,7 +204,7 @@ Options: --api-bible-nkjv-bible-id Override the API.Bible Bible ID for NKJV. """ - const VERSION = "1.1.1" + const VERSION = "1.2.0" let consoleLogger = newConsoleLogger( levelThreshold=lvlInfo, @@ -197,6 +234,12 @@ Options: var formattedPassages: seq[string] = @[] for query in queries: case $args["--output-format"] + of "ansi": + formattedPassages.add(fetchAnsiPassages( + query.referenceText, + query.translation, + keepVerseNumbers = true, + cfg)) of "plain": formattedPassages.add(fetchPlainPassages( query.referenceText, diff --git a/src/embedded_bible.nim b/src/embedded_bible.nim index ec40e08..1fef304 100644 --- a/src/embedded_bible.nim +++ b/src/embedded_bible.nim @@ -17,6 +17,7 @@ type indent: int text: string annotations: seq[BibleAnnotation] + wordsOfChrist: bool BibleAnnotation = object kind: string @@ -25,8 +26,18 @@ type EmbeddedOutputFormat = enum eofPlain + eofAnsi eofMarkdown + AnnotationFilterKind = enum + afNone + afAll + afKinds + + AnnotationFilter = object + kind: AnnotationFilterKind + kinds: seq[string] + BibleIndex = object segments: seq[BibleSegment] verses: Table[string, bool] @@ -34,6 +45,12 @@ type lastChapterByBook: Table[string, int] translationName: string +const + ansiReset = "\x1b[0m" + ansiDarkRed = "\x1b[31m" + ansiGray = "\x1b[90m" + ansiCyan = "\x1b[36m" + proc verseKey(code: string, chapter, verse: int): string = code & "\t" & $chapter & "\t" & $verse @@ -59,12 +76,20 @@ proc parseSegmentBreak(s: string): SegmentBreak = else: raise newException(ValueError, "invalid embedded Bible segment break: " & s) -proc parseAnnotations(metadata: string): seq[BibleAnnotation] = +proc parseMetadata(metadata: string): + tuple[annotations: seq[BibleAnnotation], wordsOfChrist: bool] = + if metadata.strip.len == 0: return let node = parseJson(metadata) - if node.kind != JObject or not node.hasKey("annotations"): + if node.kind != JObject: + return + + result.wordsOfChrist = + node.hasKey("wordsOfChrist") and node["wordsOfChrist"].getBool + + if not node.hasKey("annotations"): return for item in node["annotations"].getElems: @@ -75,7 +100,7 @@ proc parseAnnotations(metadata: string): seq[BibleAnnotation] = if targetText.strip.len == 0: continue - result.add(BibleAnnotation( + result.annotations.add(BibleAnnotation( kind: if item.hasKey("kind"): item["kind"].getStr else: "", @@ -120,6 +145,10 @@ proc loadBibleIndex(rows, translationName: string): BibleIndex = indent: 0, text: legacy.text)) else: + let metadata = + if parts.len == 8: parseMetadata(parts[7]) + else: (annotations: newSeq[BibleAnnotation](), wordsOfChrist: false) + result.addSegment(BibleSegment( code: parts[0], chapter: parseInt(parts[1]), @@ -128,9 +157,8 @@ proc loadBibleIndex(rows, translationName: string): BibleIndex = breakKind: parseSegmentBreak(parts[4]), indent: parseInt(parts[5]), text: parts[6], - annotations: - if parts.len == 8: parseAnnotations(parts[7]) - else: @[])) + annotations: metadata.annotations, + wordsOfChrist: metadata.wordsOfChrist)) proc requireLastChapter(index: BibleIndex, code: string): int = if not index.lastChapterByBook.hasKey(code): @@ -284,6 +312,41 @@ proc fetchPassages*(rows, reference, translationName: string): seq[string] = for parsedReference in parseReferences(reference): result.add(fetchReference(index, parsedReference)) +proc normalizeAnnotationKind(kind: string): string = + case kind.strip + of "note": + "note" + of "crossReference", "crossreference", "cross-reference", "cross_reference": + "crossReference" + else: + raise newException(ValueError, + "unsupported annotation kind '" & kind & + "'; supported annotation kinds: note, crossReference") + +proc parseAnnotationFilter(value: string): AnnotationFilter = + let normalized = value.strip + + case normalized + of "", "all": + return AnnotationFilter(kind: afAll) + of "none": + return AnnotationFilter(kind: afNone) + else: + result.kind = afKinds + for part in normalized.split(','): + let annotationKind = normalizeAnnotationKind(part) + if annotationKind notin result.kinds: + result.kinds.add(annotationKind) + +proc includes(filter: AnnotationFilter, annotation: BibleAnnotation): bool = + case filter.kind + of afNone: + false + of afAll: + true + of afKinds: + annotation.kind in filter.kinds + proc footnoteLabel(index: int): string = var n = index while true: @@ -292,12 +355,66 @@ proc footnoteLabel(index: int): string = if n < 0: break +proc annotationMarker( + label: string, + outputFormat: EmbeddedOutputFormat, + resumeWordsOfChrist: bool): string = + + case outputFormat + of eofPlain: + "[" & label & "]" + of eofAnsi: + ansiCyan & "[" & label & "]" & ansiReset & + (if resumeWordsOfChrist: ansiDarkRed else: "") + of eofMarkdown: + "[^" & label & "]" + +proc visibleLen(s: string): int = + var idx = 0 + while idx < s.len: + if s[idx] == '\x1b' and idx + 1 < s.len and s[idx + 1] == '[': + idx += 2 + while idx < s.len and not s[idx].isAlphaAscii: + inc idx + if idx < s.len: + inc idx + else: + inc result + inc idx + +proc wrapAnsiWords(line: string, maxLineWidth = 74): string = + let indentLen = line.len - line.strip(leading = true, trailing = false).len + let indent = repeat(" ", indentLen) + let words = line.strip.splitWhitespace + if words.len == 0: + return "" + + var lines: seq[string] + var current = indent + + for word in words: + let separator = + if current.strip.len == 0: "" + else: " " + if current.strip.len > 0 and + current.visibleLen + separator.len + word.visibleLen > maxLineWidth: + lines.add(current) + current = word + else: + current.add(separator & word) + + if current.len > 0: + lines.add(current) + + lines.join("\p") + proc addSegmentWithFootnotes( line: var string, segment: BibleSegment, keepVerseNumbers: bool, notes: var seq[string], - outputFormat: EmbeddedOutputFormat) = + outputFormat: EmbeddedOutputFormat, + annotationFilter: AnnotationFilter) = var text = segment.text var annotations = segment.annotations @@ -305,21 +422,28 @@ proc addSegmentWithFootnotes( var inserted = 0 for annotation in annotations: + if not annotationFilter.includes(annotation): + continue + let label = footnoteLabel(notes.len) notes.add(annotation.targetText) let insertAt = max(0, min(text.len, annotation.offset + inserted)) - let marker = - case outputFormat - of eofPlain: "[" & label & "]" - of eofMarkdown: "[^" & label & "]" + let marker = annotationMarker( + label, + outputFormat, + resumeWordsOfChrist = segment.wordsOfChrist) text.insert(marker, insertAt) inserted += marker.len + if outputFormat == eofAnsi and segment.wordsOfChrist: + text = ansiDarkRed & text & ansiReset + let marker = if keepVerseNumbers: case outputFormat of eofPlain: $segment.verse & " " + of eofAnsi: ansiGray & $segment.verse & ansiReset & " " of eofMarkdown: "**" & $segment.verse & "** " else: "" @@ -345,6 +469,7 @@ proc addFootnotes( lines.add( case outputFormat of eofPlain: label & ". " & note + of eofAnsi: ansiCyan & label & ". " & note & ansiReset of eofMarkdown: "[^" & label & "]: " & note) proc selectedChapters(index: BibleIndex, reference: PassageReference): @@ -377,7 +502,8 @@ proc formattedChapter( code: string, selected: Table[string, bool], keepVerseNumbers: bool, - outputFormat: EmbeddedOutputFormat): string = + outputFormat: EmbeddedOutputFormat, + annotationFilter: AnnotationFilter): string = var lines: seq[string] var currentLine = "" @@ -428,12 +554,18 @@ proc formattedChapter( renderSegment, keepVerseNumbers, notes, - outputFormat) + outputFormat, + annotationFilter) else: var noVerseSegment = renderSegment noVerseSegment.text = renderSegment.text noVerseSegment.breakKind = sbSame - currentLine.addSegmentWithFootnotes(noVerseSegment, false, notes, outputFormat) + currentLine.addSegmentWithFootnotes( + noVerseSegment, + false, + notes, + outputFormat, + annotationFilter) firstSelected = false @@ -449,7 +581,10 @@ proc formattedChapter( let prepared = if line.len > 90: line.strip else: line & " " - wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p")) + if outputFormat == eofAnsi: + wrapped.add(wrapAnsiWords(prepared)) + else: + wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p")) wrapped.join("\p") proc fetchFormattedPassages( @@ -457,9 +592,12 @@ proc fetchFormattedPassages( reference, translationName: string, keepVerseNumbers: bool, - outputFormat: EmbeddedOutputFormat): seq[string] = + outputFormat: EmbeddedOutputFormat, + annotations: string): seq[string] = let index = loadBibleIndex(rows, translationName) + let annotationFilter = parseAnnotationFilter(annotations) + for parsedReference in parseReferences(reference): var parts: seq[string] for chapter in index.selectedChapters(parsedReference): @@ -467,13 +605,15 @@ proc fetchFormattedPassages( parsedReference.book.code, chapter.verses, keepVerseNumbers, - outputFormat)) + outputFormat, + annotationFilter)) let body = parts.join("\p\p") let referenceSeparator = case outputFormat - of eofPlain: + of eofPlain, eofAnsi: if body.contains("\pa. "): "\p\p" + elif body.contains("\p" & ansiCyan & "a. "): "\p\p" else: "\p" of eofMarkdown: if body.contains("\p[^a]: "): "\p\p" @@ -481,7 +621,7 @@ proc fetchFormattedPassages( let referenceLine = case outputFormat - of eofPlain: + of eofPlain, eofAnsi: "– " & $parsedReference & " (" & translationName.toUpperAscii & ")" of eofMarkdown: "> -- *" & $parsedReference & " (" & @@ -489,7 +629,7 @@ proc fetchFormattedPassages( let formattedBody = case outputFormat - of eofPlain: + of eofPlain, eofAnsi: body of eofMarkdown: var markdownLines: seq[string] @@ -508,19 +648,42 @@ proc fetchPlainPassages*( rows, reference, translationName: string, - keepVerseNumbers = true): seq[string] = + keepVerseNumbers = true, + annotations = "all"): seq[string] = fetchFormattedPassages( rows, reference, translationName, keepVerseNumbers, - eofPlain) + eofPlain, + annotations) + +proc fetchAnsiPassages*( + rows, + reference, + translationName: string, + keepVerseNumbers = true, + annotations = "all"): seq[string] = + + fetchFormattedPassages( + rows, + reference, + translationName, + keepVerseNumbers, + eofAnsi, + annotations) + +proc fetchMarkdownPassages*( + rows, + reference, + translationName: string, + annotations = "all"): seq[string] = -proc fetchMarkdownPassages*(rows, reference, translationName: string): seq[string] = fetchFormattedPassages( rows, reference, translationName, keepVerseNumbers = true, - eofMarkdown) + eofMarkdown, + annotations) diff --git a/src/kjv.nim b/src/kjv.nim index 6135806..ecd2004 100644 --- a/src/kjv.nim +++ b/src/kjv.nim @@ -6,8 +6,29 @@ const kjvRows = embeddedTranslationData("kjv") proc fetchPassages*(reference: string): seq[string] = embedded_bible.fetchPassages(kjvRows, reference, "KJV") -proc fetchPlainPassages*(reference: string, keepVerseNumbers = true): seq[string] = - embedded_bible.fetchPlainPassages(kjvRows, reference, "KJV", keepVerseNumbers) +proc fetchPlainPassages*( + reference: string, + keepVerseNumbers = true, + annotations = "all"): seq[string] = -proc fetchMarkdownPassages*(reference: string): seq[string] = - embedded_bible.fetchMarkdownPassages(kjvRows, reference, "KJV") + embedded_bible.fetchPlainPassages( + kjvRows, + reference, + "KJV", + keepVerseNumbers, + annotations) + +proc fetchMarkdownPassages*(reference: string, annotations = "all"): seq[string] = + embedded_bible.fetchMarkdownPassages(kjvRows, reference, "KJV", annotations) + +proc fetchAnsiPassages*( + reference: string, + keepVerseNumbers = true, + annotations = "all"): seq[string] = + + embedded_bible.fetchAnsiPassages( + kjvRows, + reference, + "KJV", + keepVerseNumbers, + annotations) diff --git a/src/mev.nim b/src/mev.nim index ecfd161..bf50ee0 100644 --- a/src/mev.nim +++ b/src/mev.nim @@ -12,16 +12,41 @@ proc fetchPassages*(reference: string): seq[string] = raise newException(ValueError, "MEV data is not embedded; generate data/private/mev.tsv and rebuild") -proc fetchPlainPassages*(reference: string, keepVerseNumbers = true): seq[string] = +proc fetchPlainPassages*( + reference: string, + keepVerseNumbers = true, + annotations = "all"): seq[string] = + when hasEmbeddedTranslationData("mev"): - embedded_bible.fetchPlainPassages(mevRows, reference, "MEV", keepVerseNumbers) + embedded_bible.fetchPlainPassages( + mevRows, + reference, + "MEV", + keepVerseNumbers, + annotations) else: raise newException(ValueError, "MEV data is not embedded; generate data/private/mev.tsv and rebuild") -proc fetchMarkdownPassages*(reference: string): seq[string] = +proc fetchMarkdownPassages*(reference: string, annotations = "all"): seq[string] = when hasEmbeddedTranslationData("mev"): - embedded_bible.fetchMarkdownPassages(mevRows, reference, "MEV") + embedded_bible.fetchMarkdownPassages(mevRows, reference, "MEV", annotations) + else: + raise newException(ValueError, + "MEV data is not embedded; generate data/private/mev.tsv and rebuild") + +proc fetchAnsiPassages*( + reference: string, + keepVerseNumbers = true, + annotations = "all"): seq[string] = + + when hasEmbeddedTranslationData("mev"): + embedded_bible.fetchAnsiPassages( + mevRows, + reference, + "MEV", + keepVerseNumbers, + annotations) else: raise newException(ValueError, "MEV data is not embedded; generate data/private/mev.tsv and rebuild") diff --git a/tests/test_offline_kjv.nim b/tests/test_offline_kjv.nim index b1372f5..345ca87 100644 --- a/tests/test_offline_kjv.nim +++ b/tests/test_offline_kjv.nim @@ -187,6 +187,71 @@ HEB 4 7 2 line 1 do not harden your hearts." "\n" & "– John 8:1-2 (TEST)" + test "plain output can suppress annotation footnotes": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tJesus spoke.\t" & + """{"annotations":[{"kind":"crossReference","offset":12,"targetText":"Mt 21:1"}]}""" & "\n" + let passages = embedded_bible.fetchPlainPassages( + rows, + "John 8:1", + "TEST", + annotations = "none") + + check passages[0] == "1 Jesus spoke.\n" & + "– John 8:1 (TEST)" + + test "plain output filters annotation footnotes by kind": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tJesus spoke plainly.\t" & + """{"annotations":[{"kind":"note","offset":20,"targetText":"A note."}]}""" & "\n" & + "JHN\t8\t2\t0\tsame\t0\tHe taught.\t" & + """{"annotations":[{"kind":"crossReference","offset":10,"targetText":"Mt 26:55"}]}""" & "\n" + + let notesOnly = embedded_bible.fetchPlainPassages( + rows, + "John 8:1-2", + "TEST", + annotations = "note") + let crossReferencesOnly = embedded_bible.fetchPlainPassages( + rows, + "John 8:1-2", + "TEST", + annotations = "crossReference") + let both = embedded_bible.fetchPlainPassages( + rows, + "John 8:1-2", + "TEST", + annotations = "note,crossReference") + + check notesOnly[0] == "1 Jesus spoke plainly.[a] 2 He taught.\n" & + "\n" & + "a. A note.\n" & + "\n" & + "– John 8:1-2 (TEST)" + check crossReferencesOnly[0] == "1 Jesus spoke plainly. 2 He taught.[a]\n" & + "\n" & + "a. Mt 26:55\n" & + "\n" & + "– John 8:1-2 (TEST)" + check both[0] == "1 Jesus spoke plainly.[a] 2 He taught.[b]\n" & + "\n" & + "a. A note.\n" & + "b. Mt 26:55\n" & + "\n" & + "– John 8:1-2 (TEST)" + + test "rejects unknown annotation filter kinds": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tJesus spoke.\t" & + """{"annotations":[{"kind":"note","offset":12,"targetText":"A note."}]}""" & "\n" + + expect ValueError: + discard embedded_bible.fetchPlainPassages( + rows, + "John 8:1", + "TEST", + annotations = "gloss") + test "plain output resets footnote labels per chapter": const rows = "JHN\t8\t59\t0\tparagraph\t0\tThey took up stones.\t" & @@ -238,3 +303,25 @@ HEB 4 7 2 line 1 do not harden your hearts." "[^a]: Jn 8:12\n" & "\n" & "> -- *John 8:59-9:1 (TEST)*" + + test "ansi output renders verse numbers in gray and words of Christ in dark red": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tJesus said.\t" & + """{"wordsOfChrist":true}""" & "\n" + let passages = embedded_bible.fetchAnsiPassages(rows, "John 8:1", "TEST") + + check passages[0] == "\x1b[90m1\x1b[0m \x1b[31mJesus said.\x1b[0m\n" & + "– John 8:1 (TEST)" + + test "ansi output renders footnotes in cyan": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tJesus said.\t" & + """{"wordsOfChrist":true,"annotations":[{"kind":"crossReference","offset":11,"targetText":"Mt 21:1"}]}""" & + "\n" + let passages = embedded_bible.fetchAnsiPassages(rows, "John 8:1", "TEST") + + check passages[0] == "\x1b[90m1\x1b[0m \x1b[31mJesus said.\x1b[36m[a]\x1b[0m\x1b[31m\x1b[0m\n" & + "\n" & + "\x1b[36ma. Mt 21:1\x1b[0m\n" & + "\n" & + "– John 8:1 (TEST)"