defmodule Exisbn do
require Integer
alias Exisbn.Regions
@moduledoc """
Documentation for `Exisbn`.
"""
@doc """
Takes an ISBN 10 code as string, returns its check digit.
## Examples
iex> Exisbn.isbn10_checkdigit("85-359-0277")
{:ok, "5"}
iex> Exisbn.isbn10_checkdigit("5-02-013850")
{:ok, "9"}
iex> Exisbn.isbn10_checkdigit("0str")
{:error, :invalid_isbn}
iex> Exisbn.isbn10_checkdigit("887385107")
{:ok, "X"}
"""
@spec isbn10_checkdigit(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn10_checkdigit(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 8..10 do
nsum =
isbn
|> normalize()
|> String.slice(0..8)
|> String.split("", trim: true)
|> Enum.map(&String.to_integer/1)
|> Enum.with_index()
|> Enum.map(fn {val, ind} ->
(10 - ind) * val
end)
|> Enum.reduce(&+/2)
digit = Integer.mod(11 - Integer.mod(nsum, 11), 11)
result = if digit == 10, do: "X", else: to_string(digit)
{:ok, result}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn10_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.isbn10_checkdigit!("85-359-0277")
"5"
iex> Exisbn.isbn10_checkdigit!("5-02-013850")
"9"
iex> Exisbn.isbn10_checkdigit!("0str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.isbn10_checkdigit!("887385107")
"X"
"""
@spec isbn10_checkdigit!(String.t()) :: String.t()
def isbn10_checkdigit!(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 8..10 do
nsum =
isbn
|> normalize()
|> String.slice(0..8)
|> String.split("", trim: true)
|> Enum.map(&String.to_integer/1)
|> Enum.with_index()
|> Enum.map(fn {val, ind} ->
(10 - ind) * val
end)
|> Enum.reduce(&+/2)
digit = Integer.mod(11 - Integer.mod(nsum, 11), 11)
if digit == 10, do: "X", else: to_string(digit)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN 13 code as string, returns its check digit.
## Examples
iex> Exisbn.isbn13_checkdigit("978-5-12345-678")
{:ok, "1"}
iex> Exisbn.isbn13_checkdigit("978-0-306-40615")
{:ok, "7"}
iex> Exisbn.isbn13_checkdigit("0str")
{:error, :invalid_isbn}
"""
@spec isbn13_checkdigit(binary) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn13_checkdigit(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 11..13 do
nsum =
isbn
|> normalize()
|> String.slice(0..11)
|> String.split("", trim: true)
|> Enum.map(&String.to_integer/1)
|> Enum.with_index()
|> Enum.map(fn {val, ind} ->
if Integer.is_odd(ind), do: val * 3, else: val
end)
|> Enum.reduce(&+/2)
digit = 10 - Integer.mod(nsum, 10)
result = if digit == 10, do: 0, else: digit
{:ok, to_string(result)}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn13_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.isbn13_checkdigit!("978-5-12345-678")
"1"
iex> Exisbn.isbn13_checkdigit!("978-0-306-40615")
"7"
iex> Exisbn.isbn13_checkdigit!("0str")
** (ArgumentError) Invalid ISBN
"""
@spec isbn13_checkdigit!(binary) :: String.t()
def isbn13_checkdigit!(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 11..13 do
nsum =
isbn
|> normalize()
|> String.slice(0..11)
|> String.split("", trim: true)
|> Enum.map(&String.to_integer/1)
|> Enum.with_index()
|> Enum.map(fn {val, ind} ->
if Integer.is_odd(ind), do: val * 3, else: val
end)
|> Enum.reduce(&+/2)
digit = 10 - Integer.mod(nsum, 10)
result = if digit == 10, do: 0, else: digit
to_string(result)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN (10 or 13) and checks its validity by its check digit
## Examples
iex> Exisbn.checkdigit_correct?("85-359-0277-5")
true
iex> Exisbn.checkdigit_correct?("978-5-12345-678-1")
true
iex> Exisbn.checkdigit_correct?("978-5-12345-678")
false
"""
@spec checkdigit_correct?(String.t()) :: boolean
def checkdigit_correct?(isbn) when is_bitstring(isbn) do
normalized = normalize(isbn)
result =
if String.length(normalized) == 10 do
isbn10_checkdigit(normalized)
else
isbn13_checkdigit(normalized)
end
case result do
{:ok, digit} -> digit == String.last(normalized)
{:error, _} -> false
end
end
@doc """
Takes an ISBN (10 or 13) and checks its validity by checking the checkdigit, length and characters.
## Examples
iex> Exisbn.valid?("978-5-12345-678-1")
true
iex> Exisbn.valid?("978-5-12345-678")
false
iex> Exisbn.valid?("85-359-0277-5")
true
iex> Exisbn.valid?("85-359-0277")
false
"""
@spec valid?(String.t()) :: boolean
def valid?(isbn) do
Enum.all?([
correct_length?(isbn),
without_incorrect_chars?(isbn),
checkdigit_correct?(isbn)
])
end
@doc """
Takes an ISBN 10 and converts it to ISBN 13.
## Examples
iex> Exisbn.isbn10_to_13("85-359-0277-5")
{:ok, "9788535902778"}
iex> Exisbn.valid?("9788535902778")
true
iex> Exisbn.isbn10_to_13("0306406152")
{:ok, "9780306406157"}
iex> Exisbn.valid?("9780306406157")
true
iex> Exisbn.isbn10_to_13("0-19-853453123")
{:error, :invalid_isbn}
"""
@spec isbn10_to_13(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn10_to_13(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
first_chars = "978#{String.slice(normalize(isbn), 0..8)}"
{:ok, checkdigit} = isbn13_checkdigit(first_chars)
{:ok, "#{first_chars}#{checkdigit}"}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn10_to_13/1`, but raises exception.
## Examples
iex> Exisbn.isbn10_to_13!("85-359-0277-5")
"9788535902778"
iex> Exisbn.valid?("9788535902778")
true
iex> Exisbn.isbn10_to_13!("0306406152")
"9780306406157"
iex> Exisbn.valid?("9780306406157")
true
iex> Exisbn.isbn10_to_13!("0-19-853453123")
** (ArgumentError) Invalid ISBN
"""
@spec isbn10_to_13!(String.t()) :: String.t()
def isbn10_to_13!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
first_chars = "978#{String.slice(normalize(isbn), 0..8)}"
{:ok, checkdigit} = isbn13_checkdigit(first_chars)
"#{first_chars}#{checkdigit}"
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN 13 and converts it to ISBN 10.
## Examples
iex> Exisbn.isbn13_to_10("9788535902778")
{:ok, "8535902775"}
iex> Exisbn.valid?("8535902775")
true
iex> Exisbn.isbn13_to_10("9780306406157")
{:ok, "0306406152"}
iex> Exisbn.valid?("0306406152")
true
iex> Exisbn.isbn13_to_10("str")
{:error, :invalid_isbn}
"""
@spec isbn13_to_10(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn13_to_10(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
first_chars =
isbn
|> normalize()
|> drop_chars(3)
|> String.slice(0..8)
{:ok, checkdigit} = isbn10_checkdigit(first_chars)
{:ok, "#{first_chars}#{checkdigit}"}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn13_to_10/1`, but raises exception.
## Examples
iex> Exisbn.isbn13_to_10!("9788535902778")
"8535902775"
iex> Exisbn.valid?("8535902775")
true
iex> Exisbn.isbn13_to_10!("9780306406157")
"0306406152"
iex> Exisbn.valid?("0306406152")
true
iex> Exisbn.isbn13_to_10!("str")
** (ArgumentError) Invalid ISBN
"""
@spec isbn13_to_10!(String.t()) :: String.t()
def isbn13_to_10!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
first_chars =
isbn
|> normalize()
|> drop_chars(3)
|> String.slice(0..8)
{:ok, checkdigit} = isbn10_checkdigit(first_chars)
"#{first_chars}#{checkdigit}"
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN and returns its publisher zone.
## Examples
iex> Exisbn.publisher_zone("9788535902778")
{:ok, "Brazil"}
iex> Exisbn.publisher_zone("2-1234-5680-2")
{:ok, "French language"}
iex> Exisbn.publisher_zone("str")
{:error, :invalid_isbn}
"""
@spec publisher_zone(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def publisher_zone(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, converted} = isbn10_to_13(isbn)
converted
else
normalize(isbn)
end
{:ok, Map.get(fetch_info(prepared_isbn), "name")}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `publisher_zone/1`, but raises exception.
## Examples
iex> Exisbn.publisher_zone!("9788535902778")
"Brazil"
iex> Exisbn.publisher_zone!("2-1234-5680-2")
"French language"
iex> Exisbn.publisher_zone!("str")
** (ArgumentError) Invalid ISBN
"""
@spec publisher_zone!(String.t()) :: String.t()
def publisher_zone!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, converted} = isbn10_to_13(isbn)
converted
else
normalize(isbn)
end
Map.get(fetch_info(prepared_isbn), "name")
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN and returns its prefix.
## Examples
iex> Exisbn.fetch_prefix("9788535902778")
{:ok, "978-85"}
iex> Exisbn.fetch_prefix("2-1234-5680-2")
{:ok, "978-2"}
iex> Exisbn.fetch_prefix("str")
{:error, :invalid_isbn}
"""
@spec fetch_prefix(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def fetch_prefix(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, result} = isbn10_to_13(isbn)
result
else
normalize(isbn)
end
{:ok, fetch_prefix(String.slice(prepared_isbn, 0..2), drop_chars(prepared_isbn, 3), 0)}
else
{:error, :invalid_isbn}
end
end
@doc """
Takes an ISBN and returns its checkdigit.
## Examples
iex> Exisbn.fetch_checkdigit("9788535902778")
{:ok, "8"}
iex> Exisbn.fetch_checkdigit("2-1234-5680-2")
{:ok, "2"}
iex> Exisbn.fetch_checkdigit("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_checkdigit("887385107X")
{:ok, "X"}
"""
@spec fetch_checkdigit(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def fetch_checkdigit(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
{:ok, String.last(isbn)}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.fetch_checkdigit!("9788535902778")
"8"
iex> Exisbn.fetch_checkdigit!("2-1234-5680-2")
"2"
iex> Exisbn.fetch_checkdigit!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.fetch_checkdigit!("887385107X")
"X"
"""
@spec fetch_checkdigit!(String.t()) :: String.t()
def fetch_checkdigit!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
String.last(isbn)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN and returns its registrant element.
## Examples
iex> Exisbn.fetch_registrant_element("9788535902778")
{:ok, "359"}
iex> Exisbn.fetch_registrant_element("978-1-86197-876-9")
{:ok, "86197"}
iex> Exisbn.fetch_registrant_element("9789529351787")
{:ok, "93"}
iex> Exisbn.fetch_registrant_element("str")
{:error, :invalid_isbn}
"""
@spec fetch_registrant_element(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def fetch_registrant_element(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, translated} = isbn10_to_13(isbn)
translated
else
normalize(isbn)
end
{:ok, prefix} = fetch_prefix(prepared_isbn)
ranges = fetch_ranges(prepared_isbn)
body = fetch_body(prepared_isbn, prefix)
Enum.reduce_while(ranges, "", fn range, _ ->
beg = String.to_integer(List.first(range))
ending = String.to_integer(List.last(range))
length = String.length(List.last(range)) - 1
range_part = String.slice(body, 0..length)
area = String.to_integer(range_part)
if beg <= area && area <= ending,
do: {:halt, {:ok, range_part}},
else: {:cont, {:error, :invalid_isbn}}
end)
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_registrant_element/1`, but raises exception.
## Examples
iex> Exisbn.fetch_registrant_element!("9788535902778")
"359"
iex> Exisbn.fetch_registrant_element!("978-1-86197-876-9")
"86197"
iex> Exisbn.fetch_registrant_element!("9789529351787")
"93"
iex> Exisbn.fetch_registrant_element!("str")
** (ArgumentError) Invalid ISBN
"""
@spec fetch_registrant_element!(String.t()) :: String.t()
def fetch_registrant_element!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, translated} = isbn10_to_13(isbn)
translated
else
normalize(isbn)
end
{:ok, prefix} = fetch_prefix(prepared_isbn)
ranges = fetch_ranges(prepared_isbn)
body = fetch_body(prepared_isbn, prefix)
Enum.reduce_while(ranges, "", fn range, _ ->
beg = String.to_integer(List.first(range))
ending = String.to_integer(List.last(range))
length = String.length(List.last(range)) - 1
range_part = String.slice(body, 0..length)
area = String.to_integer(range_part)
if beg <= area && area <= ending,
do: {:halt, range_part},
else: {:cont, {:error, :invalid_isbn}}
end)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN and returns its publication element.
## Examples
iex> Exisbn.fetch_publication_element("978-1-86197-876-9")
{:ok, "876"}
iex> Exisbn.fetch_publication_element("9789529351787")
{:ok, "5178"}
iex> Exisbn.fetch_publication_element("str")
{:error, :invalid_isbn}
"""
@spec fetch_publication_element(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def fetch_publication_element(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, converted} = isbn10_to_13(isbn)
converted
else
normalize(isbn)
end
{:ok, prefix} = fetch_prefix(prepared_isbn)
normalized_prefix = normalize(prefix)
body = fetch_body(prepared_isbn, normalized_prefix)
{:ok, registrant} = fetch_registrant_element(prepared_isbn)
{:ok, drop_chars(body, String.length(registrant) + 1)}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_publication_element/1`, but raises exception.
## Examples
iex> Exisbn.fetch_publication_element!("978-1-86197-876-9")
"876"
iex> Exisbn.fetch_publication_element!("9789529351787")
"5178"
iex> Exisbn.fetch_publication_element!("str")
** (ArgumentError) Invalid ISBN
"""
@spec fetch_publication_element!(String.t()) :: String.t()
def fetch_publication_element!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn =
if isbn10?(isbn) do
{:ok, converted} = isbn10_to_13(isbn)
converted
else
normalize(isbn)
end
{:ok, prefix} = fetch_prefix(prepared_isbn)
normalized_prefix = normalize(prefix)
body = fetch_body(prepared_isbn, normalized_prefix)
{:ok, registrant} = fetch_registrant_element(prepared_isbn)
drop_chars(body, String.length(registrant) + 1)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Takes an ISBN (10 or 13) and hyphenates it.
## Examples
iex> Exisbn.hyphenate("9788535902778")
{:ok, "978-85-359-0277-8"}
iex> Exisbn.hyphenate("0306406152")
{:ok, "0-306-40615-2"}
iex> Exisbn.hyphenate("str")
{:error, :invalid_isbn}
"""
@spec hyphenate(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def hyphenate(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
result = if isbn10?(isbn), do: hyphenate_isbn10(isbn), else: hyphenate_isbn13(isbn)
{:ok, result}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `hyphenate/1`, but raises exception.
## Examples
iex> Exisbn.hyphenate!("9788535902778")
"978-85-359-0277-8"
iex> Exisbn.hyphenate!("0306406152")
"0-306-40615-2"
iex> Exisbn.hyphenate!("str")
** (ArgumentError) Invalid ISBN
"""
@spec hyphenate!(String.t()) :: String.t()
def hyphenate!(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
if isbn10?(isbn), do: hyphenate_isbn10(isbn), else: hyphenate_isbn13(isbn)
else
raise(ArgumentError, "Invalid ISBN")
end
end
@doc """
Checks if an ISBN (10 or 13) code is correctly hyphenated. If ISBN incorrect, that count as no.
## Examples
iex> Exisbn.correct_hyphens?("978-85-359-0277-8")
true
iex> Exisbn.correct_hyphens?("97-8853590277-8")
false
iex> Exisbn.correct_hyphens?("0-306-40615-2")
true
iex> Exisbn.correct_hyphens?("03-064-06152")
false
iex> Exisbn.correct_hyphens?("str")
false
"""
@spec correct_hyphens?(binary) :: boolean
def correct_hyphens?(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
case hyphenate(isbn) do
{:ok, hyphenated} -> isbn == hyphenated
{:error, _} -> false
end
else
false
end
end
defp normalize(isbn) do
isbn
|> String.split("", trim: true)
|> Enum.filter(fn ch -> is_digit(ch) || ch == "X" end)
|> Enum.join()
end
defp correct_length?(isbn) do
size = String.length(isbn)
size == 10 || size == 13 || size == 17
end
defp without_incorrect_chars?(isbn) do
isbn
|> normalize()
|> correct_length?()
end
defp isbn10?(isbn) do
length = isbn |> normalize() |> String.length()
length == 10
end
defp correct?(isbn) do
valid?(normalize(isbn))
end
defp drop_chars(str, amount) do
String.slice(str, amount..String.length(str))
end
defp is_digit(ch) do
String.contains?("0123456789", ch)
end
defp fetch_prefix(prefix, body, search_length) do
search_prefix = "#{prefix}-#{String.slice(body, 0..search_length)}"
if Map.has_key?(Regions.dataset(), search_prefix) do
search_prefix
else
fetch_prefix(prefix, body, search_length + 1)
end
end
defp fetch_body(isbn, prefix) do
isbn
|> drop_chars(String.length(prefix) - 1)
|> String.reverse()
|> drop_chars(1)
|> String.reverse()
end
defp fetch_info(isbn) do
{:ok, prefix} = fetch_prefix(isbn)
Map.get(Regions.dataset(), prefix)
end
defp fetch_ranges(isbn) do
Map.get(fetch_info(isbn), "ranges")
end
defp hyphenate_isbn13(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
{:ok, prefix} = fetch_prefix(isbn)
{:ok, registrant_element} = fetch_registrant_element(isbn)
{:ok, publication_element} = fetch_publication_element(isbn)
{:ok, checkdigit} = fetch_checkdigit(isbn)
Enum.join(
[
prefix,
registrant_element,
publication_element,
checkdigit
],
"-"
)
else
nil
end
end
defp hyphenate_isbn10(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
{:ok, converted} = isbn10_to_13(isbn)
{:ok, full_prefix} = fetch_prefix(converted)
{:ok, registrant_element} = fetch_registrant_element(isbn)
{:ok, publication_element} = fetch_publication_element(isbn)
{:ok, checkdigit} = fetch_checkdigit(isbn)
isbn10_prefix = String.split(full_prefix, "-", trim: true) |> List.last()
Enum.join(
[
isbn10_prefix,
registrant_element,
publication_element,
checkdigit
],
"-"
)
else
nil
end
end
end