defmodule BibleEx.Librarian do @moduledoc """ Internal helper functions for looking up Bible metadata and validating references. This module is used by `BibleEx.Reference` and `BibleEx.RefParser` to: * Resolve book names into canonical numbers and name variants. * Compute last chapters and verses for a given book. * Build lists of `BibleEx.Chapter` and `BibleEx.Verse` structs. * Infer reference types and validate that references are in range. * Render human-readable reference strings. """ require Logger alias BibleEx.BibleData @doc ~S""" Returns the book number (1–66) for a given book identifier. The `book` argument may be: * The full lowercased book name, e.g. `"genesis"`. * An OSIS identifier, e.g. `"gen"`. * A shortened/abbreviated key from `BibleEx.BibleData`. * A variant key, e.g. `"mat"` for Matthew. If the book cannot be resolved, `nil` is returned. ## Examples iex> alias BibleEx.Librarian iex> Librarian.find_book_number(book: "Genesis") 1 iex> Librarian.find_book_number(book: "rom") 45 iex> Librarian.find_book_number(book: "NotABook") nil """ def find_book_number(book: book) do book_lower = String.downcase(book) cond do book_lower == "" -> nil BibleData.books()[book_lower] -> BibleData.books()[book_lower] BibleData.osis_books()[book_lower] -> BibleData.osis_books()[book_lower] BibleData.shortened_books()[book_lower] -> BibleData.shortened_books()[book_lower] BibleData.variants()[book_lower] -> BibleData.variants()[book_lower] true -> nil end end @doc ~S""" Returns `true` if the given book string is a recognized Bible book. This check uses the canonical, OSIS, and shortened maps in `BibleEx.BibleData` and does not attempt to correct misspellings. ## Examples iex> alias BibleEx.Librarian iex> Librarian.check_book(book: "Genesis") true iex> Librarian.check_book(book: "Gen") true iex> Librarian.check_book(book: "NotABook") false """ def check_book(book: book) do book_lower = String.downcase(book) Map.has_key?(BibleData.books(), book_lower) || Map.has_key?(BibleData.osis_books(), book_lower) || Map.has_key?(BibleData.shortened_books(), book_lower) end @doc ~S""" Returns all name variants for a book as a map. The result map contains: * `:osis` – OSIS identifier (e.g. `"Gen"`) * `:abbr` – Paratext-style abbreviation (e.g. `"GEN"`) * `:name` – Full book name (e.g. `"Genesis"`) * `:short` – Shortened form (e.g. `"Gn"`) The `book` argument may be a name/abbreviation string or a 1-based book number. If the book cannot be resolved, an empty map is returned. ## Examples iex> alias BibleEx.Librarian iex> Librarian.get_book_names(book: "Genesis")[:name] "Genesis" iex> Librarian.get_book_names(book: 45)[:osis] "Rom" iex> Librarian.get_book_names(book: "NotABook") %{} """ def get_book_names(book: book) do found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book "nil" -> nil "atom" -> nil end case found_book do nil -> %{} found_book -> list = Enum.at(BibleData.book_names(), found_book - 1) %{ osis: Enum.at(list, 0), abbr: Enum.at(list, 1), name: Enum.at(list, 2), short: Enum.at(list, 3) } end end @doc ~S""" Returns the last verse number for a given book, or for a specific chapter. When only `book` is given, the last verse in the final chapter is returned. When `chapter` is given, the last verse in that chapter is returned, or `nil` if the chapter is out of range. ## Examples iex> alias BibleEx.Librarian iex> Librarian.get_last_verse_number(book: "Genesis", chapter: 1) > 0 true iex> Librarian.get_last_verse_number(book: "Revelation") v when is_integer(v) and v > 0 iex> Librarian.get_last_verse_number(book: "Genesis", chapter: 999) nil """ def get_last_verse_number(book: book) do get_last_verse_number(book: book, chapter: nil) end def get_last_verse_number(book: book, chapter: chapter) do found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end case found_book do nil -> nil _found_book_not_nil -> found_chapter = case chapter do nil -> length(Enum.at(BibleData.last_verse(), found_book - 1)) found -> found end if length(Enum.at(BibleData.last_verse(), found_book - 1)) < found_chapter || found_chapter < 1 do nil else verses_in_book = Enum.at(BibleData.last_verse(), found_book - 1) Enum.at(verses_in_book, found_chapter - 1) end end end @doc ~S""" Returns the number of chapters in a book. The `book` argument may be a name/abbreviation string or a book number. If the book is not recognized, `nil` is returned. ## Examples iex> alias BibleEx.Librarian iex> Librarian.get_last_chapter_number(book: "Genesis") 50 iex> Librarian.get_last_chapter_number(book: "NotABook") nil """ def get_last_chapter_number(book: book) do found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end if found_book > BibleData.last_verse() do nil else length(Enum.at(BibleData.last_verse(), found_book - 1)) end end @doc ~S""" Builds a `BibleEx.Verse` struct for the last verse of a book or chapter. When only `book` is given, the verse is the last verse of the last chapter. When `chapter` is given, the verse is the last verse of that chapter. Returns `nil` if the book or chapter cannot be resolved. ## Examples iex> alias BibleEx.Librarian iex> last = Librarian.get_last_verse(book: "Jude") iex> {last.chapter_number, last.verse_number} {1, v} when v > 0 iex> Librarian.get_last_verse(book: "NotABook") nil """ def get_last_verse(book: book) do get_last_verse(book: book, chapter: nil) end def get_last_verse(book: book, chapter: chapter) do book_number = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end book_chapter = case chapter do nil -> length(Enum.at(BibleData.last_verse(), book_number - 1)) chapter -> chapter end book_names = get_book_names(book: book) if is_nil(book_number) do nil else case Map.get(book_names, :name, nil) do nil -> nil book_name -> BibleEx.Verse.new( book: book_name, chapter_number: book_chapter, verse_number: get_last_verse_number(book: book_number, chapter: book_chapter) ) end end end @doc ~S""" Returns all verses in a chapter as a list of `BibleEx.Verse` structs. When only `book` and `chapter` are given, the entire chapter is returned. When `start_verse` and/or `end_verse` are given, a subrange of verses is returned. Returns `nil` if the book or chapter is invalid, or if the requested range is out of bounds (e.g. `start_verse > end_verse`). ## Examples iex> alias BibleEx.Librarian iex> verses = Librarian.get_verses(book: "Genesis", chapter: 1) iex> hd(verses).reference "Genesis 1:1" iex> range = Librarian.get_verses(book: "Genesis", chapter: 1, start_verse: 1, end_verse: 3) iex> Enum.map(range, & &1.verse_number) [1, 2, 3] """ def get_verses(book: book, chapter: chapter) do get_verses(book: book, chapter: chapter, start_verse: nil, end_verse: nil) end def get_verses(book: book, chapter: chapter, start_verse: start_verse, end_verse: end_verse) do book_number = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end if is_nil(book_number) do nil else case chapter do nil -> nil chapter -> start_verse = if !is_nil(start_verse), do: start_verse, else: 1 end_verse = if !is_nil(end_verse) do end_verse else BibleData.last_verse() |> Enum.at(book_number - 1) |> Enum.at(chapter - 1) end case start_verse > end_verse do true -> nil false -> case end_verse do nil -> nil last_verse -> book_names = get_book_names(book: book) case Map.get(book_names, :name, nil) do nil -> nil book_name -> Enum.map(start_verse..last_verse, fn x -> BibleEx.Verse.new( book: book_name, chapter_number: chapter, verse_number: x ) end) end end end end end end @doc ~S""" Returns a `BibleEx.Chapter` for the last chapter of a book. Returns `nil` if the book cannot be resolved. ## Examples iex> alias BibleEx.Librarian iex> ch = Librarian.get_last_chapter(book: "Psalms") iex> ch.chapter_number 150 """ def get_last_chapter(book: book) do found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end if found_book > BibleData.last_verse() do nil else book_names = get_book_names(book: book) case Map.get(book_names, :name, nil) do nil -> nil book_name -> last_chapter = length(Enum.at(BibleData.last_verse(), found_book - 1)) BibleEx.Chapter.new(book: book_name, chapter_number: last_chapter) end end end @doc ~S""" Returns a list of `BibleEx.Chapter` structs for a book. Supported forms: * `get_chapters/1` – all chapters in a book. * `get_chapters/2` – a single chapter. * `get_chapters/3` – a range of chapters. Chapters outside the valid range are clamped to the book's first/last chapter. Returns `nil` if the book is invalid. ## Examples iex> alias BibleEx.Librarian iex> chs = Librarian.get_chapters(book: "Genesis") iex> length(chs) 50 iex> [ch] = Librarian.get_chapters(book: "Genesis", start_chapter: 1) iex> ch.chapter_number 1 iex> range = Librarian.get_chapters(book: "Genesis", start_chapter: 1, end_chapter: 3) iex> Enum.map(range, & &1.chapter_number) [1, 2, 3] """ def get_chapters(book: book) do get_chapters(book: book, start_chapter: nil, end_chapter: nil) end def get_chapters(book: book, start_chapter: start_chapter) do get_chapters(book: book, start_chapter: start_chapter, end_chapter: nil) end def get_chapters(book: book, start_chapter: start_chapter, end_chapter: end_chapter) do found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end if found_book > BibleData.last_verse() do nil else book_names = get_book_names(book: book) if is_nil(found_book) do nil else case Map.get(book_names, :name, nil) do nil -> nil book_name -> case start_chapter > end_chapter do true -> nil false -> start_chapter = if is_nil(start_chapter), do: 1, else: start_chapter end_chapter = if is_nil(end_chapter), do: start_chapter, else: end_chapter start_chapter = if start_chapter < 1, do: 1, else: start_chapter end_chapter = if end_chapter > length(Enum.at(BibleData.last_verse(), found_book - 1)) do length(Enum.at(BibleData.last_verse(), found_book - 1)) else end_chapter end Enum.map(start_chapter..end_chapter, fn x -> BibleEx.Chapter.new(book: book_name, chapter_number: x) end) end end end end end @doc ~S""" Infers the type of a reference (`:book`, `:chapter`, `:verse`, `:chapter_range`, `:verse_range`). This set of overloads allows calling with increasing specificity: * `identify_reference_type/1` – book only. * `identify_reference_type/2` – book and start chapter. * `identify_reference_type/3` – book, start chapter, start verse. * `identify_reference_type/4` – plus end chapter. * `identify_reference_type/5` – plus end verse. ## Examples iex> alias BibleEx.Librarian iex> Librarian.identify_reference_type(book: "Genesis") :book iex> Librarian.identify_reference_type(book: "Genesis", start_chapter: 1) :chapter iex> Librarian.identify_reference_type(book: "Genesis", start_chapter: 1, start_verse: 1) :verse iex> Librarian.identify_reference_type(book: "Genesis", start_chapter: 1, end_chapter: 2) :chapter_range iex> Librarian.identify_reference_type(book: "Genesis", start_chapter: 1, start_verse: 1, end_chapter: 1, end_verse: 5) :verse_range """ def identify_reference_type(book: book) do identify_reference_type( book: book, start_chapter: nil, start_verse: nil, end_chapter: nil, end_verse: nil ) end def identify_reference_type( book: book, start_chapter: start_chapter ) do identify_reference_type( book: book, start_chapter: start_chapter, start_verse: nil, end_chapter: nil, end_verse: nil ) end def identify_reference_type( book: book, start_chapter: start_chapter, start_verse: start_verse ) do identify_reference_type( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: nil, end_verse: nil ) end def identify_reference_type( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter ) do identify_reference_type( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter, end_verse: nil ) end def identify_reference_type( book: _book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter, end_verse: end_verse ) do cond do is_nil(start_chapter) and is_nil(end_chapter) -> if !is_nil(start_verse) do if !is_nil(end_verse) do :verse_range else :verse end else :book end !is_nil(start_chapter) and !is_nil(end_chapter) and start_chapter != end_chapter -> :chapter_range !is_nil(start_chapter) and (is_nil(end_chapter) or end_chapter == start_chapter) -> if !is_nil(start_verse) do if !is_nil(end_verse) do :verse_range else :verse end else :chapter end !is_nil(start_verse) and !is_nil(end_verse) -> :verse_range !is_nil(start_verse) -> :verse true -> nil end end @doc ~S""" Validates that a book identifier refers to a real Bible book. The `book` argument may be a name/abbreviation string or a numeric book index. Only the book is checked; chapters and verses are not considered in this arity. ## Examples iex> alias BibleEx.Librarian iex> Librarian.verify_reference(book: "Genesis") true iex> Librarian.verify_reference(book: "NotABook") false """ def verify_reference(book: book) when not is_nil(book) do verified = true found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end verified = if !(found_book > 0 and length(BibleEx.BibleData.last_verse()) >= found_book) do false else verified end verified end @doc ~S""" Validates a reference consisting of a book and an optional starting chapter. Checks that: * The book exists. * The `start_chapter`, if present, is within the book's chapter range. ## Examples iex> alias BibleEx.Librarian iex> Librarian.verify_reference(book: "Genesis", start_chapter: 1) true iex> Librarian.verify_reference(book: "Genesis", start_chapter: 999) false """ def verify_reference(book: book, start_chapter: start_chapter) when not is_nil(book) do verified = true found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end verified = if !(found_book > 0 and length(BibleEx.BibleData.last_verse()) >= found_book) do false else verified end # if book is not verified at this point, short-circuit verified = case verified do true -> if !is_nil(start_chapter) do books_last_verse = length(BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1)) if !(start_chapter > 0 and books_last_verse >= start_chapter) do false else verified end else verified end false -> false end verified end @doc ~S""" Validates a reference with book, starting chapter, and starting verse. Checks that: * The book exists. * The starting chapter exists for that book. * The starting verse exists within that chapter. ## Examples iex> alias BibleEx.Librarian iex> Librarian.verify_reference(book: "Genesis", start_chapter: 1, start_verse: 1) true iex> Librarian.verify_reference(book: "Genesis", start_chapter: 1, start_verse: 999) false """ def verify_reference(book: book, start_chapter: start_chapter, start_verse: start_verse) when not is_nil(book) do verified = true found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end verified = if !(found_book > 0 and length(BibleEx.BibleData.last_verse()) >= found_book) do false else verified end # if book is not verified at this point, short-circuit verified = case verified do true -> if !is_nil(start_chapter) do books_last_verse = length(BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1)) if !(start_chapter > 0 and books_last_verse >= start_chapter) do false else verified end else verified end false -> false end # check verse range within the chapter verified = if is_nil(found_book) do false else case !is_nil(start_verse) do true -> if !is_nil(start_chapter) do books_last_verse_books_start_chapter = BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1) |> Enum.at(start_chapter - 1) if !(start_verse > 0 and books_last_verse_books_start_chapter >= start_verse) do false else verified end else false end false -> verified end end verified end @doc ~S""" Validates a reference with book, starting chapter, and ending verse. This form is used when a verse range is specified in a single chapter but only the `end_verse` is provided alongside the chapter. Checks that: * The book exists. * The chapter exists. * The ending verse is within the chapter's verse range. ## Examples iex> alias BibleEx.Librarian iex> Librarian.verify_reference(book: "Genesis", start_chapter: 1, end_verse: 3) true iex> Librarian.verify_reference(book: "Genesis", start_chapter: 1, end_verse: 999) false """ def verify_reference(book: book, start_chapter: start_chapter, end_verse: end_verse) when not is_nil(book) do verified = true found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end verified = if !(found_book > 0 and length(BibleEx.BibleData.last_verse()) >= found_book) do false else verified end # if book is not verified at this point, short-circuit verified = case verified do true -> if !is_nil(start_chapter) do books_last_verse = length(BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1)) if !(start_chapter > 0 and books_last_verse >= start_chapter) do false else verified end else verified end false -> false end verified = case !is_nil(end_verse) do true -> very_last_verse = BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1) |> Enum.at(start_chapter - 1) if end_verse <= 0 or very_last_verse < end_verse do false else verified end false -> verified end verified end @doc ~S""" Validates a fully-specified reference, including optional start and end chapters and verses. This is the most general form and checks that: * The book exists. * The start and end chapters are within range and ordered correctly. * The start and end verses, if present, are within their chapter ranges and ordered correctly relative to each other and the chapter range. It returns `true` for structurally valid references and `false` otherwise; it does not check theological or translation-specific constraints. ## Examples iex> alias BibleEx.Librarian iex> Librarian.verify_reference(book: "John", start_chapter: 3, start_verse: 16, end_chapter: 4, end_verse: 3) true iex> Librarian.verify_reference(book: "Genesis", start_chapter: 50, start_verse: 1, end_chapter: 49, end_verse: 10) false """ def verify_reference( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter, end_verse: end_verse ) when not is_nil(book) do verified = true found_book = case BibleEx.typeof(book) do "binary" -> find_book_number(book: book) "number" -> book end verified = if !(found_book < 1 or (found_book > 0 and length(BibleEx.BibleData.last_verse()) >= found_book)) do false else verified end verified = if is_nil(found_book) do false else if !is_nil(start_chapter) do books_last_verse = length(BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1)) if !(start_chapter > 0 and books_last_verse >= start_chapter) do false else verified end if !is_nil(end_chapter) and start_chapter > end_chapter do false else verified end else if !is_nil(end_chapter) or !is_nil(end_verse) do false else verified end end verified = case !is_nil(start_verse) do true -> books_last_verse_books_start_chapter = BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1) |> Enum.at(start_chapter - 1) if !(start_verse > 0 and books_last_verse_books_start_chapter >= start_verse) do false else verified end if !is_nil(end_verse) and !is_nil(start_verse) do if start_verse > end_verse do false else verified end else verified end false -> verified end verified = case !is_nil(end_chapter) do true -> books_last_verse = length(BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1)) if !(books_last_verse >= end_chapter) do false else verified end false -> verified end verified = case !is_nil(end_verse) do true -> if is_nil(end_chapter) do false else very_last_verse = BibleEx.BibleData.last_verse() |> Enum.at(found_book - 1) |> Enum.at(end_chapter - 1) if end_verse > 0 and very_last_verse >= end_verse do verified else if end_verse < start_verse do false else verified end end end false -> verified end verified end verified end @doc ~S""" Renders a human-readable reference string from its components. This family of functions builds strings such as: * `"Genesis 1"` * `"Genesis 1:1"` * `"John 3:16 - 4:3"` * `"Genesis 1-2"` Depending on which arguments are provided, the output adjusts to match a conventional Bible reference format. If the inputs are not sufficient to build a reference (for example, missing book and chapter), `nil` is returned. ## Examples iex> alias BibleEx.Librarian iex> Librarian.create_reference_string(book: "Genesis", start_chapter: 1) "Genesis 1" iex> Librarian.create_reference_string(book: "Genesis", start_chapter: 1, start_verse: 1) "Genesis 1:1" iex> Librarian.create_reference_string(book: "John", start_chapter: 3, start_verse: 16, end_chapter: 4, end_verse: 3) "John 3:16 - 4:3" """ def create_reference_string(book: _book) do nil end def create_reference_string(book: book, start_chapter: start_chapter) do reference = "" reference = case !is_nil(book) and !is_nil(start_chapter) do true -> reference <> book <> " #{start_chapter}" false -> reference end if(reference == "", do: nil, else: reference) end def create_reference_string(book: book, start_chapter: start_chapter, start_verse: start_verse) do reference = "" reference = case !is_nil(book) and !is_nil(start_chapter) do true -> reference = reference <> book <> " #{start_chapter}" case !is_nil(start_verse) do true -> reference <> ":#{start_verse}" false -> reference end false -> reference end if(reference == "", do: nil, else: reference) end def create_reference_string( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter ) do reference = "" reference = case !is_nil(book) and !is_nil(start_chapter) do true -> reference = reference <> book <> " #{start_chapter}" if !is_nil(start_verse) and !is_nil(end_chapter) and end_chapter != start_chapter do reference = reference <> ":#{start_verse}" reference = reference <> " - #{end_chapter}" end_chapter_last_verse = get_last_verse_number(book: book, chapter: end_chapter) if !is_nil(end_chapter_last_verse) do reference = reference <> ":#{end_chapter_last_verse}" reference else reference end else reference = if !is_nil(end_chapter) and end_chapter != start_chapter do reference = reference <> "-#{end_chapter}" reference else reference end reference end false -> reference end if(reference == "", do: nil, else: reference) end def create_reference_string( book: book, start_chapter: start_chapter, start_verse: start_verse, end_chapter: end_chapter, end_verse: end_verse ) do reference = "" reference = case !is_nil(book) and !is_nil(start_chapter) do true -> reference = reference <> book <> " #{start_chapter}" if !is_nil(start_verse) and !is_nil(end_chapter) and end_chapter != start_chapter do reference = reference <> ":#{start_verse}" reference = reference <> " - #{end_chapter}" if !is_nil(end_verse) do reference = reference <> ":#{end_verse}" reference else end_chapter_last_verse = get_last_verse_number(book: book, chapter: end_chapter) if !is_nil(end_chapter_last_verse) do reference = reference <> ":#{end_chapter_last_verse}" reference else reference end end else reference = if !is_nil(end_verse) and is_nil(start_verse) do reference = reference <> ":1" reference else if !is_nil(start_verse) do reference = reference <> ":#{start_verse}" reference else reference end end reference = if !is_nil(end_chapter) do reference = if is_nil(start_verse) and is_nil(end_verse) do reference = if start_chapter != end_chapter do reference <> "-#{end_chapter}" else reference end reference else if start_chapter != end_chapter do reference <> " - #{end_chapter}" else reference end reference end reference else reference end reference = if !is_nil(end_verse) do reference = if !is_nil(start_verse) do reference <> "-#{end_verse}" else if !is_nil(end_chapter) do reference <> " - #{end_chapter}:#{end_verse}" else reference <> ":#{end_verse}" end end reference else reference end reference end false -> reference = if is_nil(start_chapter) and is_nil(start_verse) do book else reference end reference end if(reference == "", do: nil, else: reference) end end