diff --git a/src/bibleref.nim b/src/bibleref.nim index 9eec0ea..86eda08 100644 --- a/src/bibleref.nim +++ b/src/bibleref.nim @@ -98,6 +98,21 @@ proc fetchPassages(reference, translation: string, cfg: CombinedConfig): seq[str "unsupported translation '" & translation & "'; supported translations: " & supportedTranslationsList()) +proc fetchPlainPassages( + reference, + translation: string, + keepVerseNumbers: bool, + cfg: CombinedConfig): seq[string] = + + case translation + of "akjv", "kjv": + result = kjv.fetchPlainPassages(reference, keepVerseNumbers) + of "mev": + result = mev.fetchPlainPassages(reference, keepVerseNumbers) + else: + for passage in fetchPassages(reference, translation, cfg): + result.add(formatPlain(passage, translation, keepVerseNumbers)) + when isMainModule: const USAGE = """Usage: bibleref [options] @@ -167,19 +182,25 @@ Options: var formattedPassages: seq[string] = @[] for query in queries: - for passage in fetchPassages(query.referenceText, query.translation, cfg): - formattedPassages.add( - case $args["--output-format"]: - of "plain": - formatPlain(passage, query.translation) - of "reading": - formatPlain(passage, query.translation, keepVerseNumbers = false) - of "text": - passage.multiReplace([(re"\[(\d+)\]", "$1")]) - of "raw": - passage - else: - formatMarkdown(passage, query.translation)) + case $args["--output-format"] + of "plain": + formattedPassages.add(fetchPlainPassages( + query.referenceText, + query.translation, + keepVerseNumbers = true, + cfg)) + else: + for passage in fetchPassages(query.referenceText, query.translation, cfg): + formattedPassages.add( + case $args["--output-format"]: + of "reading": + formatPlain(passage, query.translation, keepVerseNumbers = false) + of "text": + passage.multiReplace([(re"\[(\d+)\]", "$1")]) + of "raw": + passage + else: + formatMarkdown(passage, query.translation)) echo formattedPassages.join("\p\p") diff --git a/src/embedded_bible.nim b/src/embedded_bible.nim index 6b61b1e..3945eeb 100644 --- a/src/embedded_bible.nim +++ b/src/embedded_bible.nim @@ -1,4 +1,4 @@ -import std/[strutils, tables] +import std/[algorithm, json, strutils, tables, wordwrap] import ./reference_parser @@ -16,6 +16,12 @@ type breakKind: SegmentBreak indent: int text: string + annotations: seq[BibleAnnotation] + + BibleAnnotation = object + kind: string + offset: int + targetText: string BibleIndex = object segments: seq[BibleSegment] @@ -49,6 +55,31 @@ proc parseSegmentBreak(s: string): SegmentBreak = else: raise newException(ValueError, "invalid embedded Bible segment break: " & s) +proc parseAnnotations(metadata: string): seq[BibleAnnotation] = + if metadata.strip.len == 0: + return + + let node = parseJson(metadata) + if node.kind != JObject or not node.hasKey("annotations"): + return + + for item in node["annotations"].getElems: + let targetText = + if item.hasKey("targetText"): item["targetText"].getStr + else: "" + + if targetText.strip.len == 0: + continue + + result.add(BibleAnnotation( + kind: + if item.hasKey("kind"): item["kind"].getStr + else: "", + offset: + if item.hasKey("offset"): item["offset"].getInt + else: 0, + targetText: targetText)) + proc addSegment(index: var BibleIndex, segment: BibleSegment) = index.segments.add(segment) index.verses[verseKey(segment.code, segment.chapter, segment.verse)] = true @@ -92,7 +123,10 @@ proc loadBibleIndex(rows, translationName: string): BibleIndex = segment: parseInt(parts[3]), breakKind: parseSegmentBreak(parts[4]), indent: parseInt(parts[5]), - text: parts[6])) + text: parts[6], + annotations: + if parts.len == 8: parseAnnotations(parts[7]) + else: @[])) proc requireLastChapter(index: BibleIndex, code: string): int = if not index.lastChapterByBook.hasKey(code): @@ -245,3 +279,174 @@ proc fetchPassages*(rows, reference, translationName: string): seq[string] = let index = loadBibleIndex(rows, translationName) for parsedReference in parseReferences(reference): result.add(fetchReference(index, parsedReference)) + +proc footnoteLabel(index: int): string = + var n = index + while true: + result.insert($char(ord('a') + (n mod 26)), 0) + n = (n div 26) - 1 + if n < 0: + break + +proc addSegmentWithFootnotes( + line: var string, + segment: BibleSegment, + keepVerseNumbers: bool, + notes: var seq[string]) = + + var text = segment.text + var annotations = segment.annotations + annotations.sort(proc(a, b: BibleAnnotation): int = cmp(a.offset, b.offset)) + + var inserted = 0 + for annotation in annotations: + let label = footnoteLabel(notes.len) + notes.add(annotation.targetText) + + let insertAt = max(0, min(text.len, annotation.offset + inserted)) + let marker = "[" & label & "]" + text.insert(marker, insertAt) + inserted += marker.len + + let marker = + if keepVerseNumbers: $segment.verse & " " + else: "" + + let content = marker & text + if line.len == 0: + line.add(repeat(" ", segment.indent) & content) + elif segment.breakKind == sbSame: + line.appendSameLine(content) + else: + line.add("\n" & repeat(" ", segment.indent) & content) + +proc addFootnotes(lines: var seq[string], notes: seq[string]) = + if notes.len == 0: + return + + lines.add("") + for idx, note in notes: + lines.add(footnoteLabel(idx) & ". " & note) + +proc selectedChapters(index: BibleIndex, reference: PassageReference): + seq[tuple[chapter: int, verses: Table[string, bool]]] = + + let code = reference.book.code + + if reference.ranges.len == 0: + for chapter in 1 .. index.requireLastChapter(code): + var verses: Table[string, bool] + for verse in 1 .. index.requireLastVerse(code, chapter): + discard index.requireVerse(code, chapter, verse) + verses[verseKey(code, chapter, verse)] = true + result.add((chapter, verses)) + else: + var chapterPositions: Table[int, int] + for range in reference.ranges: + let selected = index.selectedVerses(reference, range) + for key in selected.keys: + let parts = key.split('\t') + let chapter = parseInt(parts[1]) + if not chapterPositions.hasKey(chapter): + chapterPositions[chapter] = result.len + var verses: Table[string, bool] + result.add((chapter, verses)) + result[chapterPositions[chapter]].verses[key] = true + +proc plainChapter( + index: BibleIndex, + code: string, + selected: Table[string, bool], + keepVerseNumbers: bool): string = + + var lines: seq[string] + var currentLine = "" + var notes: seq[string] + var emittedVerses: Table[string, bool] + var firstSelected = true + + proc flushLine() = + if currentLine.len > 0: + lines.add(currentLine) + currentLine = "" + + for segment in index.segments: + if segment.code != code: + continue + + let key = verseKey(segment.code, segment.chapter, segment.verse) + if not selected.hasKey(key): + continue + + var adjusted = segment + if firstSelected: + adjusted = normalizeFirstSelectedSegment(adjusted) + + let startsNewParagraph = + adjusted.breakKind == sbParagraph and + not firstSelected and + (not emittedVerses.hasKey(key)) + + if startsNewParagraph: + flushLine() + lines.add("") + elif adjusted.breakKind == sbLine: + flushLine() + + let marker = + if emittedVerses.hasKey(key): false + else: + emittedVerses[key] = true + true + + var renderSegment = adjusted + if not marker: + renderSegment.verse = 0 + + if marker: + currentLine.addSegmentWithFootnotes(renderSegment, keepVerseNumbers, notes) + else: + var noVerseSegment = renderSegment + noVerseSegment.text = renderSegment.text + noVerseSegment.breakKind = sbSame + currentLine.addSegmentWithFootnotes(noVerseSegment, false, notes) + + firstSelected = false + + flushLine() + lines.addFootnotes(notes) + + var wrapped: seq[string] + for line in lines: + if line.len == 0: + wrapped.add("") + continue + + let prepared = + if line.len > 90: line.strip + else: line & " " + wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p")) + wrapped.join("\p") + +proc fetchPlainPassages*( + rows, + reference, + translationName: string, + keepVerseNumbers = true): seq[string] = + + let index = loadBibleIndex(rows, translationName) + for parsedReference in parseReferences(reference): + var parts: seq[string] + for chapter in index.selectedChapters(parsedReference): + parts.add(index.plainChapter( + parsedReference.book.code, + chapter.verses, + keepVerseNumbers)) + + let body = parts.join("\p\p") + let referenceSeparator = + if body.contains("\pa. "): "\p\p" + else: "\p" + + result.add(body & referenceSeparator & "– " & $parsedReference & " (" & + translationName.toUpperAscii & ")") diff --git a/src/kjv.nim b/src/kjv.nim index 57a8438..313db8e 100644 --- a/src/kjv.nim +++ b/src/kjv.nim @@ -5,3 +5,6 @@ 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) diff --git a/src/mev.nim b/src/mev.nim index 56c5b8c..75d41f0 100644 --- a/src/mev.nim +++ b/src/mev.nim @@ -11,3 +11,10 @@ proc fetchPassages*(reference: string): seq[string] = else: raise newException(ValueError, "MEV data is not embedded; generate data/private/mev.tsv and rebuild") + +proc fetchPlainPassages*(reference: string, keepVerseNumbers = true): seq[string] = + when hasEmbeddedTranslationData("mev"): + embedded_bible.fetchPlainPassages(mevRows, reference, "MEV", keepVerseNumbers) + 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 f5e0e96..1028bb7 100644 --- a/tests/test_offline_kjv.nim +++ b/tests/test_offline_kjv.nim @@ -170,3 +170,37 @@ HEB 4 7 2 line 1 do not harden your hearts." " [31] Then Jesus said. [32] You shall know the truth.\n" & "\n" & " [33] They answered Him." + + test "plain output renders annotation footnotes": + const rows = + "JHN\t8\t1\t0\tparagraph\t0\tBut Jesus went to the Mount of Olives.\t" & + """{"annotations":[{"kind":"crossReference","marker":"a","offset":40,"targetText":"Mt 21:1"}]}""" & "\n" & + "JHN\t8\t2\t0\tsame\t0\tEarly in the morning He returned to the temple.\t" & + """{"annotations":[{"kind":"crossReference","marker":"a","offset":49,"targetText":"Mt 26:55; Lk 4:20"}]}""" & "\n" + let passages = embedded_bible.fetchPlainPassages(rows, "John 8:1-2", "TEST") + + check passages[0] == "1 But Jesus went to the Mount of Olives.[a] 2 Early in the morning He\n" & + "returned to the temple.[b]\n" & + "\n" & + "a. Mt 21:1\n" & + "b. Mt 26:55; Lk 4:20\n" & + "\n" & + "– John 8:1-2 (TEST)" + + test "plain output resets footnote labels per chapter": + const rows = + "JHN\t8\t59\t0\tparagraph\t0\tThey took up stones.\t" & + """{"annotations":[{"kind":"crossReference","marker":"a","offset":21,"targetText":"Lev 24:16"}]}""" & "\n" & + "JHN\t9\t1\t0\tparagraph\t0\tAs Jesus passed by.\t" & + """{"annotations":[{"kind":"crossReference","marker":"a","offset":20,"targetText":"Jn 8:12"}]}""" & "\n" + let passages = embedded_bible.fetchPlainPassages(rows, "John 8:59-9:1", "TEST") + + check passages[0] == "59 They took up stones.[a]\n" & + "\n" & + "a. Lev 24:16\n" & + "\n" & + "1 As Jesus passed by.[a]\n" & + "\n" & + "a. Jn 8:12\n" & + "\n" & + "– John 8:59-9:1 (TEST)" diff --git a/tools/generate_mev_data.nim b/tools/generate_mev_data.nim index 522bd7f..dd13abf 100644 --- a/tools/generate_mev_data.nim +++ b/tools/generate_mev_data.nim @@ -273,15 +273,24 @@ proc annotationKind(marker: string): string = if marker.strip.allCharsInSet({'0'..'9'}): "note" else: "crossReference" +proc cleanTargetText(targetText: string): string = + var parts = targetText.splitWhitespace + if parts.len >= 3 and parts[0].allCharsInSet({'0'..'9'}): + parts.delete(0) + parts.delete(0) + return parts.join(" ") + + targetText + proc annotationNode(marker, href, targetText: string, offset: int): JsonNode = + let kind = annotationKind(marker) + let cleanedTargetText = cleanTargetText(targetText) result = %*{ - "kind": annotationKind(marker), - "marker": marker, - "href": href, + "kind": kind, "offset": offset } - if targetText.len > 0: - result["targetText"] = newJString(targetText) + if cleanedTargetText.len > 0: + result["targetText"] = newJString(cleanedTargetText) proc segmentMetadata(state: ParseState): JsonNode = result = newJObject()