bible-reference-formatter
Version:
Utility that converts Bible references from OSIS to human-readable formats and Paratext.
689 lines (652 loc) • 27.2 kB
JavaScript
/* @flow */
type ContextType = {
b: string;
c: number;
v: number;
}
type TokenType = {
osis: string;
type: string;
parts: Array<PartType>;
laters: Array<string>;
bookRange?: string;
bookSequence?: Array<string>;
}
type PartType = {
type: string;
subType: string;
b: string;
c?: string;
v?: string;
laters: Array<string>;
}
function getDefaults() : OptionsType {
return {
"b": "$b",
"c": "$c",
"v": "$v",
"-": "-",
",": ", ",
".": " ",
"c.v": ":",
"$chapters": ["ch", "chs"],
"$verses": ["v", "vv"],
"singleChapterFormat": "bv", // or `b` or `bcv`
"singleChapterBooks": ["Obad", "Phlm", "2John", "3John", "Jude", "PrAzar", "SgThree", "Sus", "Bel", "EpJer", "PrMan", "Ps151", "AddPs"],
"Ps151Format": "bc", // or `b`
"maxPs": 150,
}
}
function OsisToReadable(): OsisToReadableInterface {
// Some subset of "Matt.1.2-Mark.3.4"
const osisFormat: RegExp = /^[1-5A-Za-z]{2,}(?:\.\d{1,3}(?:\.\d{1,3})?)?(?:-[1-5A-Za-z]{2,}(?:\.\d{1,3}(?:\.\d{1,3})?)?)?$/
let books: BooksType = {}
let options: OptionsType = getDefaults()
// Convert an OSIS string, and an optional OSIS context, to human-readable form. Aim for the shortest understandable string: `Matt.1-Matt.2` might become `Matthew 1-2`.
function toReadable(osisString: string, osisContext: ?string) : string {
if (typeof osisString !== "string") {
throw "osisToReadable: first argument must be a string."
}
// Create a context object, using the supplied context (if available).
const context: ContextType = setContext(osisContext)
// Separate the supplied OSISes into individual OSIS references.
const osises: Array<string> = osisString.split(",")
const tokens: Array<TokenType> = []
while (osises.length > 0) {
// Make sure we're dealing with a valid OSIS string. It throws an exception if there's a problem.
const osis: string = normalizeOsis(osises.shift())
// Tokenize the OSIS string.
tokens.push(osisToToken(osis, context))
// Add a separator token if there are more OSIS strings to tokenize. We don't want to add it at the end of the loop, which is why we use `while (true)` at the top.
if (osises.length > 0) {
tokens.push({
osis: ",",
type: ",",
parts: [],
laters: []
})
}
}
return tokensToReadable(tokens)
}
// Format an array of tokens in a sequence.
function tokensToReadable(tokens: Array<TokenType>) : string {
// Calculate data that we may need about future tokens in the sequence.
tokens = annotateTokens(tokens)
const out: Array<string> = []
// First iterate over each token.
while (tokens.length > 0) {
const token: TokenType = tokens.shift()
if (typeof token.bookSequence !== "undefined") {
out.push(formatBookSequence(token, tokens))
} else {
out.push(tokenToReadable(token))
}
}
return out.join("")
}
// If given a sequence like `1John,2John`, we may want to turn that into `1 and 2 John`.
function formatBookSequence(token: TokenType, tokens: Array<TokenType>) : string {
// Really, we've already checked this, but we'll make Flow happy at the cost of losing 100% code coverage because the following `if` statement is never false. Most of the time the `while` loop won't return, so we still drop down to `tokenToReadable()`.
const sequenceArray: void | Array<string> = token.bookSequence
if (typeof sequenceArray !== "undefined") {
// The `sequenceArray` includes the current token. If `length === 1`, then the only item left in the array is the current token, which we pass to `tokenToReadable()`.
while (sequenceArray.length > 1) {
// Look for a comma-joined sequence in `books`.
const bookSequence: string = sequenceArray.join(",")
if (typeof books[bookSequence] !== "undefined" && typeof books[bookSequence][0] === "string") {
// First remove future tokens that we've already taken care of in this sequence.
removeBookSequenceTokens(sequenceArray.length, tokens)
// And then return the desired book string.
return books[bookSequence][0]
}
sequenceArray.pop()
}
}
// Otherwise, there was no match, so handle the token as usual.
return tokenToReadable(token)
}
// Remove the number of items in the sequence. Let's say there are three items: `["1John", "2John", "3John"]`. `1John` is the current token, which is already gone from the array. That means we need to hop ahead two books, or `3 - 1`, to prevent us from stringifying the token again.
function removeBookSequenceTokens(numberToRemove: number, tokens: Array<TokenType>) : void {
while (numberToRemove > 1) {
// Just remove sequence tokens (`type: ","`); we don't need them.
tokens.shift()
// Next is the `b` token we want to remove.
tokens.shift()
numberToRemove--
}
}
// Format a single token.
function tokenToReadable(token: TokenType) : string {
// First check to see if we have a book range to handle specially (`1John-2John` might become `1-2 John`).
if (typeof token.bookRange === "string" && typeof books[token.bookRange] !== "undefined" && typeof books[token.bookRange][0] === "string") {
return books[token.bookRange][0]
}
// Most of the time, iterate over its `parts` to build the output string.
const out: Array<string> = []
for (let i: number = 0, max: number = token.parts.length; i < max; i++) {
out.push(formatPart(token, token.parts[i]))
}
return out.join("")
}
// Format a `part` in a `token.parts`.
function formatPart(token: TokenType, part: PartType) : string {
let prefix: string = ""
switch (part.type) {
case "c":
case "v":
// If we specify a specific format for this `subType`, then calculate the value to prepend to the final output. For example, `^v` when a verse appears first in a string (when `osisToReadable()` has a `context`) might want a `vv. ` prefix.
if (part.subType !== "" && typeof options[part.subType] !== "undefined") {
prefix = formatVariable(part.subType, part, token)
}
// Fall through to `b`, which uses the same logic.
case "b":
return prefix + formatVariable(part.type, part, token)
// Punctuation tokens all use the same logic. They never have prefixes because they can never appear first in a string.
case ".":
case "-":
case ",":
// Falls through. `default` is only here to satisfy code coverage. There are no other cases.
default:
return formatVariable(getBestOption(part.type, part.subType), part, token)
}
}
// `options` can contain partial matches: `bc-bc`, `bc-b`, `c-bc`, `c-b`, `-bc`, `-b`, and `-` all match a `bc-bc` string. Take the best match that exists in options.
function getBestOption(splitChar: string, option: string) : string {
let [pre, post]: Array<string> = option.split(splitChar)
const postChars: string = post
// Start by matching the full string. Progressively remove ending possibilities and then beginning possibilities. For `bc-bc`, it tries to find options in the following order, knowing that the last one, the `splitChar` on its own, will always match: `bc-bc`, `bc-b`, `c-bc`, `c-b`, `-bc`, `-`
for (let i: number = 0, length: number = pre.length; i <= length; i++) {
post = postChars
while (post.length > 0) {
if (typeof options[`${pre}${splitChar}${post}`] === "string") {
return `${pre}${splitChar}${post}`
}
// Remove the last character so that we match characters closer to `splitChar` (in `-bcv`, the `v` is the least-important part).
post = post.substr(0, post.length - 1)
}
pre = pre.substr(1)
}
return splitChar
}
// Some values can have variables indicated by a `$`. Replace them if we can.
function formatVariable(optionsKey: string, part: PartType, token: TokenType) : string {
let pattern: string = options[optionsKey]
// We can short-circuit all this logic if there are no `$`.
if (pattern.indexOf("$") === -1) {
return pattern
}
// Replace `$chapters` with a literal value like `ch.` or `chs.` It's a RegExp rather than a string to allow the `/g` flag.
pattern = pattern.replace(/\$chapters/g, function() : string {
// If `books` defines a string to use (e.g., with `Ps.$chapters`, maybe you want "Ps. 3" rather than "ch. 3"), use that instead.
const arrayToUse: Array<string> = (typeof books[`${part.b}.$chapters`] === "undefined") ? options.$chapters : books[`${part.b}.$chapters`]
// Only retun the plural if there's a plural variant to use and there are later chapters.
if (arrayToUse.length > 1 && multipleChaptersPosition(part, token) > 0) {
return arrayToUse[1]
}
return arrayToUse[0]
})
// It's a RegExp rather than a string to allow the `/g` flag.
pattern = pattern.replace(/\$verses/g, function() : string {
// Unlike `$chapters`, `$verses` doesn't require a check inside `books` since `Ps.1.2-Ps.1.3` is going to be `vv. 2-3`.
if (options.$verses.length > 1 && hasMultipleVerses(part, token) === true) {
return options.$verses[1]
}
return options.$verses[0]
})
pattern = pattern.replace(/\$b/g, function() : string {
// We know that `part.b` exists in `books` because it would have already thrown an exception in `osisWithContext` or `setContext`.
const maxPosition: number = books[part.b].length - 1
// If there's only one possible book name, use that.
if (maxPosition === 0) {
return books[part.b][0]
}
// Otherwise, calculate the one that we prefer to use. [0] = singular; [1] = plural; [2] = book on its own.
const preferredPosition: number = multipleChaptersPosition(part, token)
if (preferredPosition > maxPosition) {
return books[part.b][maxPosition]
}
return books[part.b][preferredPosition]
})
// Don't accidentally insert `undefined` into a string.
pattern = pattern.replace(/\$c/g, (typeof part.c === "string") ? part.c : "")
pattern = pattern.replace(/\$v/g, (typeof part.v === "string") ? part.v : "")
return pattern
}
// If a range or sequence has multiple chapters, we may want to change the output: `chapters 1-2` rather than `chapter 1-2`, for example. The return value is 0-2, indicating which `books[osis][]` is preferred.
function multipleChaptersPosition(part: PartType, token: TokenType) : number {
// If we're looking at a whole book and there are multiple chapters in the book, we know there are multiple chapters.
if (token.type === "b" && !isSingleChapterBook(part.b)) {
// It's a book on its own, so prefer the full-book abbreviation if available.
return 2
}
// If we're currently looking at a chapter, include it in our calculations.
let later: string = (part.type === "c") ? "c" : ""
// All the part types until the next book.
later += part.laters.join("") + "," + token.laters.join(",")
// A chapter range always has multiple chapters.
if (later.indexOf("-c") >= 0) {
return 1
}
// An unusual situation, a Psalms range into another book (e.g., `Ps.149-Prov.1` or `Ps.150-Prov.1`). In the first case, we want to use the plural; in the second, we want to use the singular. `later` only has a `-b` if it's in the current `part`--`token.laters` ends before it reaches the next book in a sequence.
if (part.b === "Ps" && later.indexOf("-b") >= 0) {
// Find the chapter number.
for (let i: number = 0, max: number = token.parts.length; i < max; i++) {
const otherPart: PartType = token.parts[i]
if (otherPart.type === "c") {
// If it's less than the number of Psalms, we know that more than one chapter is involved.
if (parseInt(otherPart.c, 10) < options.maxPs) {
return 1
}
// Once we've looked at the chapter, we don't need to look any further. We know this situation doesn't apply.
break
}
}
return 0
}
// If there are two or more chapter instances in a sequence, we know there are multiple chapters. "cc" = "", "", ""
if (later.split("c").length > 2) {
return 1
}
return 0
}
// Possibly handle `verse 1` and `verses 1-2` differently.
function hasMultipleVerses(part: PartType, token: TokenType) : boolean {
// If we're currently looking at a verse, include it in our calculations.
let later: string = (part.type === "v") ? "v" : ""
later += part.laters.join("") + "," + token.laters.join(",")
// We only care about the current chapter.
const [thisChapter]: [string] = later.split("c")
// If there's a range, we know there are multiple verses.
if (thisChapter.indexOf("-") >= 0) {
return true
}
// "vv" = "", "", ""
if (thisChapter.split("v").length > 2) {
return true
}
return false
}
// Add data we'll use later to construct the final output.
function annotateTokens(tokens: Array<TokenType>) : Array<TokenType> {
const out: Array<TokenType> = []
// We only need `prevToken` in a sequence, which is never first, so it's OK that this value is wrong on the first loop run.
let prevToken: TokenType = tokens[0]
let i: number = 0
while (tokens.length > 0) {
const token: TokenType = tokens.shift()
// Never first.
if (token.type === ",") {
annotateSequenceToken(token, prevToken, tokens)
} else {
annotateToken(token, tokens, i)
}
// Add future context.
annotateTokenLaters(token, tokens)
annotateTokenParts(token.parts)
out.push(token)
prevToken = token
i++
}
return out
}
// Add data to a single token that we can't derive elsewehre.
function annotateToken(token: TokenType, tokens: Array<TokenType>, i: number) : void {
// The first `part.type` could be `c` or `v` if `osisToReadable()` is provided a start context.
if (i === 0 && token.parts[0].type !== "b") {
// `c` or `v` or `cv`
const [pre]: Array<string> = token.type.split("-")
let prefix: string = ""
// Note that we're dealing with a single-chapter book in case we want to handle it differently.
if (isSingleChapterBook(token.parts[0].b)) {
prefix = "b1"
}
// Set the `subType` (`c` and `v` don't normally have a `subType`) for later use.
token.parts[0].subType = `${prefix}^${pre}`
}
}
// Create a sequence `token.parts` with the keys we'll need later.
function annotateSequenceToken(token: TokenType, prevToken: TokenType, tokens: Array<TokenType>) : void {
const prevPart: PartType = prevToken.parts[prevToken.parts.length - 1]
// If preceded or followed by a range, only use the parts closest to the sequence token: `bcv-cv,bc-v` returns the subType `cv,bc`.
const prevTypes: Array<string> = prevToken.type.split("-")
const nextTypes: Array<string> = tokens[0].type.split("-")
token.parts = [{
type: ",",
// Indicates the preceding and following token types so that we can join different types differently if we want.
subType: `${prevTypes.pop()},${nextTypes[0]}`,
b: prevPart.b,
c: prevPart.c,
v: prevPart.v,
laters: []
}]
}
// Fill in the `laters` array for each token.
function annotateTokenLaters(token: TokenType, tokens: Array<TokenType>) : void {
const currentType: string = token.type
const bookSequence: Array<string> = []
// If we have a `b` sequence, we want to continue past when we would normally stop.
let keepCheckingBooks: boolean = false
// But we still want to stop adding `token.laters`.
let latersDone: boolean = false
if (currentType === "b") {
bookSequence.push(token.parts[0].b)
keepCheckingBooks = true
}
for (let i: number = 0, max: number = tokens.length; i < max; i++) {
const laterToken: TokenType = tokens[i]
const laterType: string = laterToken.type
// Doing it this way avoids possible leading and trailing `,`.
if (laterType === ",") {
continue
}
// If we're still in a `b` sequence, keep going.
if (keepCheckingBooks === true) {
if (laterType === "b") {
bookSequence.push(laterToken.parts[0].b)
} else {
keepCheckingBooks = false
}
}
// Stop here if we've already encountered a book and don't need to go further.
if (latersDone === true) {
continue
}
// Only go as far as the next book.
if (laterType.indexOf("b") >= 0) {
const [pre]: Array<string> = laterType.split("b")
// Only add it to the array if there's something to add. We don't care about empty strings.
if (pre.length > 0) {
token.laters.push(pre)
}
if (keepCheckingBooks === false) {
break
}
latersDone = true
// If there's not a book in `laterType`, then we know we need to keep looking until we find one or reach the end of the array.
} else {
token.laters.push(laterType)
}
}
if (bookSequence.length > 1) {
token.bookSequence = bookSequence
}
}
// Add `laters` to each `token.parts`.
function annotateTokenParts(parts: Array<PartType>) : void {
const laters: Array<string> = []
const max: number = parts.length
// First we need to know what all the `laters` are.
for (let i: number = 0; i < max; i++) {
laters.push(parts[i].type)
}
// Then we can add them to each `part`.
for (let i: number = 0; i < max; i++) {
// The first element is the current type.
laters.shift()
// Create a copy rather than a reference and remove spacing parts (`.`).
parts[i].laters = laters.filter(function(value: string) : boolean {
return value !== "."
})
}
}
// Convert an OSIS string to a single token.
function osisToToken(osis: string, context: ContextType) : TokenType {
// `end` may be undefined.
const [start, end]: Array<string> = osis.split("-")
const startToken: TokenType = osisWithContext(start, context)
// If there's no `end`, we don't need to do any further processing. `startToken` is itself a complete token.
if (end === undefined) {
return startToken
}
const endToken: TokenType = osisWithContext(end, context)
// Construct a unified set of `parts`, including the range.
const parts: Array<PartType> = startToken.parts
parts.push(makeRangePart(startToken, endToken))
const token: TokenType = {
osis,
type: `${startToken.type}-${endToken.type}`,
// Add the end `parts` to the array.
parts: parts.concat(endToken.parts),
laters: []
}
// We may want to handle certain book-only ranges differently (`1John-2John` might be `1-2 John`).
if (token.type === "b-b") {
token.bookRange = startToken.parts[0].b + "-" + endToken.parts[0].b
}
return token
}
// Construct a range token with data from one preceding and following token.
function makeRangePart(startToken: TokenType, endToken: TokenType) : PartType {
const prevPart: PartType = startToken.parts[startToken.parts.length - 1]
const range: PartType = {
type: "-",
// We may need to know what kind of objects it's joining.
subType: `${startToken.type}-${endToken.type}`,
b: prevPart.b,
laters: []
}
// Only add these if they exist in the previous part. These values reflect the current context, not the future context.
if (typeof prevPart.c !== "undefined") {
range.c = prevPart.c
}
if (typeof prevPart.v !== "undefined") {
range.v = prevPart.v
}
return range
}
// Tokenize a non-range OSIS string, taking context into account. The goal is to omit needless parts: if your context is `Matt.1` and the current OSIS is `Matt.2`, we can omit the book from the return token.
function osisWithContext(osis: string, context: ContextType) : TokenType {
// `c` and `v` may be undefined. `c` always exists if `v` does.
const [b, c, v]: Array<string> = osis.split(".")
// Don't try to guess if we encounter an unexpected book.
if (typeof books[b] === "undefined") {
throw `Unknown OSIS book: "${b}" (${osis})"`
}
const out: TokenType = {
osis,
type: "",
parts: [],
laters: []
}
const isSingleChapter: boolean = isSingleChapterBook(b)
// If there's an end verse, if we've set the relevant option to `bv` rather than `bcv`, and if the book only has one chapter, then we want to omit the chapter: `Phlm.1.1` = `Philemon 1`.
const omitChapter: boolean = isSingleChapter && typeof v === "string" && (options.singleChapterFormat === "bv" || options.singleChapterFormat === "b")
// Returns true if there's a chapter. It modifies `out` in-place.
if (osisBookWithContext(b, c, v, isSingleChapter, omitChapter, context, out) === false) {
return out
}
// Returns true if there's a verse. It modifies `out` in-place.
if (osisChapterWithContext(b, c, v, omitChapter, context, out) === false) {
return out
}
// We know there's a verse.
out.type += "v"
out.parts.push({
type: "v",
subType: "",
b,
c,
v,
laters: []
})
context.v = parseInt(v, 10)
return out
}
// Handle a "book" part.
function osisBookWithContext(b: string, c: ?string, v: ?string, isSingleChapter: boolean, omitChapter: boolean, context: ContextType, out: TokenType) : boolean {
// If we're looking at something like `Phlm-Phlm.1` or `Matt-Phlm.1`, we may want to treat `Phlm.1` as a book rather than include a chapter. This is unusual.
if (v === undefined && isSingleChapter === true && options.singleChapterFormat === "b") {
context.b = b, context.c = 0, context.v = 0
out.type = "b"
out.parts.push({
type: "b",
subType: "",
b,
laters: []
})
return false
}
// Gen.1,Exod = "Exod" || Gen.1,Gen = "Gen"
if (b !== context.b || c === undefined) {
// Do it this way to keep the reference to the original object.
context.b = b, context.c = 0, context.v = 0
out.parts.push({
type: "b",
subType: "",
b,
laters: []
})
out.type = "b"
// There's no chapter, so we don't need to continue.
if (c === undefined) {
return false
}
// In general, the `subType` will be `b.c`.
let subType: string = "b.c"
// If we want to omit the chapter reference in a single-chapter book.
if (omitChapter === true) {
subType = "b.v"
// It's a single-chapter book, but there's no end verse.
} else if (isSingleChapter === true) {
// `b1` means a single-chapter book.
subType = "b1.c"
}
// We know there's a chapter, so insert the joiner.
out.parts.push({
type: ".",
subType,
b,
laters: []
})
}
// Otherwise, we know that it's the same book as in `context` and there's a chapter.
return true
}
// Handle a "chapter" part.
function osisChapterWithContext(b: string, c: string, v: ?string, omitChapter: boolean, context: ContextType, out: TokenType) : boolean {
// We already know that the context book and current book are the same.
if (parseInt(c, 10) !== context.c || v === undefined) {
// We need to set `context.v` because we don't know that `osisBookWithContext()` reset it.
context.c = parseInt(c, 10), context.v = 0
// If only `Phlm.1`, we want to include the chapter if `options.singleChapterFormat === "bv"`, but omit the chapter if it's `=== "b"`. `omitChapter` is always `false` when `v === undefined`.
if (omitChapter === false) {
out.parts.push({
type: "c",
subType: "",
b,
c,
laters: []
})
out.type += "c"
// There's no verse, so we don't need any further processing.
if (v === undefined) {
return false
}
// We know there's a verse, so insert the joiner.
out.parts.push({
type: ".",
subType: "c.v",
b,
laters: []
})
}
}
// Otherwise, we know that it's the same book and chapter as in `context` and that there's a verse.
return true
}
// Only checks if the book is in an array in `options`.
function isSingleChapterBook(b: string) : boolean {
return options.singleChapterBooks.indexOf(b) >= 0
}
// Confirm that the string matches the expected format of an OSIS string. Throw an exception if not. Also handle Ps151 quirks.
function normalizeOsis(osis: string) : string {
if (osisFormat.test(osis) === false) {
throw `Invalid osis format: '${osis}'`
}
// If we want to treat Ps151 as just another Psalm (so `Ps151.1.2` might output `Psalm 151:2`).
if (options.Ps151Format === "bc") {
// Remove the chapter and treat it as `Ps.151`.
osis = osis.replace(/(?:Ps151|AddPs)(?:\.\d+\b)?/g, "Ps.151")
}
return osis
}
// Given an optional string context, create a `context` obect.
function setContext(osis: ?string) : ContextType {
// We always only want these three keys. Flow doesn't like calling `Object.seal`, however.
const out: ContextType = {
b: "",
c: 0,
v: 0
}
// There's no provided context.
if (osis == null) {
return out
}
// Don't allow sequences.
osis = normalizeOsis(osis)
// Use the end value of the range if there is one.
if (osis.indexOf("-") >= 0) {
const [, end]: Array<string> = osis.split("-")
return setContext(end)
}
const [b, c, v]: Array<string> = osis.split(".")
if (typeof books[b] === "undefined") {
throw `Unknown OSIS book provided for "context": "${b}" (${osis})"`
}
// `b` always exists.
out.b = b
// `c` and `v` don't necessarily exist. `v` only exists if `c` does.
if (typeof c === "string") {
out.c = parseInt(c, 10)
if (typeof v === "string") {
out.v = parseInt(v, 10)
}
}
return out
}
// Override any default parameters with user-supplied parameters. Also make sure `userOptions` has all the keys we need. Each call is independent, which means that `userOptions` should contain all the keys to override defaults.
function setOptions(userOptions: OptionsType) : void {
// Reset them to the default.
options = getDefaults()
// No need to match properties if there's no argument.
if (userOptions == null) {
return
}
// We want to ensure type consistency, so we don't just use `Object.assign`. Flow complains if we just iterate over `Object.keys()`.
const userKeys: Array<string> = Object.keys(userOptions)
for (let i: number = 0, max: number = userKeys.length; i < max; i++) {
const key: string = userKeys[i]
const defaultType: string = typeof options[key]
// If it's not in `defaults`, or if its type matches, set it.
if (defaultType === "undefined" || typeof userOptions[key] === defaultType) {
options[key] = userOptions[key]
// Otherwise, the types don't match.
} else {
throw `Invalid type for options["${key}"]. It should be "${defaultType}".`
}
}
}
// Set valid books and abbreviations. It takes an object where each key is the OSIS book (e.g., `Matt`), and each value is a one- or two-item array. The first item is the book name to use, and the second item is the book name to use for plural cases. For example: `{"Ps": ["Psalm", "Psalms"]`. You can also use a special key of the type `OSIS.$chapters` (e.g., `Ps.$chapters`), which overrides any chapter abbreviations. For example, `{"Ps": ["Ps.", "Pss."]` could result in `Psalms 1:2, Pss. 3, 4` if given the OSIS `Ps.1.2,Ps.3,Ps.4`.
function setBooks(userBooks: BooksType) : void {
books = {}
Object.keys(userBooks).forEach(function(key: string) : void {
const value: Array<string> = userBooks[key]
if (Array.isArray(value) === false) {
throw `books["${key}"] should be an array: ${Object.prototype.toString.call(value)}.`
}
if (value.length < 1 || value.length > 3) {
throw `books["${key}"] should have exactly 1, 2, or 3 items. `
}
books[key] = value
})
}
return {
toReadable,
setOptions,
setBooks
}
}
/* global module */
module.exports = OsisToReadable