Created
December 2, 2025 19:58
-
-
Save izakfilmalter/6e699073805209c69f207f413368f1ba to your computer and use it in GitHub Desktop.
verses helpers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Bible verse utilities for parsing and normalizing verse ranges | |
| */ | |
| import { Array, Option, pipe, Record, String } from 'effect' | |
| // ============================================================================ | |
| // Core Types | |
| // ============================================================================ | |
| export type VerseReference = { | |
| book: string | |
| chapter: number | |
| verse: number | |
| } | |
| export type VerseRange = { | |
| start: VerseReference | |
| end: VerseReference | |
| } | |
| export type Testament = 'old' | 'new' | |
| /** | |
| * Type for the compressed verse data stored in verses.json. | |
| * Each entry represents a chapter with its verse range. | |
| */ | |
| export type ChapterData = { | |
| book: BibleBookName | |
| bookOrder: number | |
| testament: Testament | |
| chapter: number | |
| startVerse: number | |
| endVerse: number | |
| } | |
| /** | |
| * Type for a fully expanded verse record as stored in the database. | |
| */ | |
| export type VerseRecord = { | |
| id: string | |
| reference: string | |
| book: string | |
| chapter: number | |
| verse: number | |
| bookOrder: number | |
| testament: Testament | |
| } | |
| // ============================================================================ | |
| // Bible Book Data | |
| // ============================================================================ | |
| /** | |
| * Canonical book order and metadata for all 66 books of the Bible. | |
| * Order follows the standard Protestant canon. | |
| */ | |
| export const BIBLE_BOOKS = [ | |
| // Old Testament (1-39) | |
| { order: 1, name: 'Genesis', testament: 'old' }, | |
| { order: 2, name: 'Exodus', testament: 'old' }, | |
| { order: 3, name: 'Leviticus', testament: 'old' }, | |
| { order: 4, name: 'Numbers', testament: 'old' }, | |
| { order: 5, name: 'Deuteronomy', testament: 'old' }, | |
| { order: 6, name: 'Joshua', testament: 'old' }, | |
| { order: 7, name: 'Judges', testament: 'old' }, | |
| { order: 8, name: 'Ruth', testament: 'old' }, | |
| { order: 9, name: '1 Samuel', testament: 'old' }, | |
| { order: 10, name: '2 Samuel', testament: 'old' }, | |
| { order: 11, name: '1 Kings', testament: 'old' }, | |
| { order: 12, name: '2 Kings', testament: 'old' }, | |
| { order: 13, name: '1 Chronicles', testament: 'old' }, | |
| { order: 14, name: '2 Chronicles', testament: 'old' }, | |
| { order: 15, name: 'Ezra', testament: 'old' }, | |
| { order: 16, name: 'Nehemiah', testament: 'old' }, | |
| { order: 17, name: 'Esther', testament: 'old' }, | |
| { order: 18, name: 'Job', testament: 'old' }, | |
| { order: 19, name: 'Psalms', testament: 'old' }, | |
| { order: 20, name: 'Proverbs', testament: 'old' }, | |
| { order: 21, name: 'Ecclesiastes', testament: 'old' }, | |
| { order: 22, name: 'Song of Solomon', testament: 'old' }, | |
| { order: 23, name: 'Isaiah', testament: 'old' }, | |
| { order: 24, name: 'Jeremiah', testament: 'old' }, | |
| { order: 25, name: 'Lamentations', testament: 'old' }, | |
| { order: 26, name: 'Ezekiel', testament: 'old' }, | |
| { order: 27, name: 'Daniel', testament: 'old' }, | |
| { order: 28, name: 'Hosea', testament: 'old' }, | |
| { order: 29, name: 'Joel', testament: 'old' }, | |
| { order: 30, name: 'Amos', testament: 'old' }, | |
| { order: 31, name: 'Obadiah', testament: 'old' }, | |
| { order: 32, name: 'Jonah', testament: 'old' }, | |
| { order: 33, name: 'Micah', testament: 'old' }, | |
| { order: 34, name: 'Nahum', testament: 'old' }, | |
| { order: 35, name: 'Habakkuk', testament: 'old' }, | |
| { order: 36, name: 'Zephaniah', testament: 'old' }, | |
| { order: 37, name: 'Haggai', testament: 'old' }, | |
| { order: 38, name: 'Zechariah', testament: 'old' }, | |
| { order: 39, name: 'Malachi', testament: 'old' }, | |
| // New Testament (40-66) | |
| { order: 40, name: 'Matthew', testament: 'new' }, | |
| { order: 41, name: 'Mark', testament: 'new' }, | |
| { order: 42, name: 'Luke', testament: 'new' }, | |
| { order: 43, name: 'John', testament: 'new' }, | |
| { order: 44, name: 'Acts', testament: 'new' }, | |
| { order: 45, name: 'Romans', testament: 'new' }, | |
| { order: 46, name: '1 Corinthians', testament: 'new' }, | |
| { order: 47, name: '2 Corinthians', testament: 'new' }, | |
| { order: 48, name: 'Galatians', testament: 'new' }, | |
| { order: 49, name: 'Ephesians', testament: 'new' }, | |
| { order: 50, name: 'Philippians', testament: 'new' }, | |
| { order: 51, name: 'Colossians', testament: 'new' }, | |
| { order: 52, name: '1 Thessalonians', testament: 'new' }, | |
| { order: 53, name: '2 Thessalonians', testament: 'new' }, | |
| { order: 54, name: '1 Timothy', testament: 'new' }, | |
| { order: 55, name: '2 Timothy', testament: 'new' }, | |
| { order: 56, name: 'Titus', testament: 'new' }, | |
| { order: 57, name: 'Philemon', testament: 'new' }, | |
| { order: 58, name: 'Hebrews', testament: 'new' }, | |
| { order: 59, name: 'James', testament: 'new' }, | |
| { order: 60, name: '1 Peter', testament: 'new' }, | |
| { order: 61, name: '2 Peter', testament: 'new' }, | |
| { order: 62, name: '1 John', testament: 'new' }, | |
| { order: 63, name: '2 John', testament: 'new' }, | |
| { order: 64, name: '3 John', testament: 'new' }, | |
| { order: 65, name: 'Jude', testament: 'new' }, | |
| { order: 66, name: 'Revelation', testament: 'new' }, | |
| ] as const satisfies ReadonlyArray<{ | |
| order: number | |
| name: string | |
| testament: Testament | |
| }> | |
| export type BibleBook = (typeof BIBLE_BOOKS)[number] | |
| export type BibleBookName = BibleBook['name'] | |
| /** | |
| * Mapping of various book name formats to their canonical names. | |
| * Includes abbreviations, Roman numeral variants, and alternate names. | |
| */ | |
| const BOOK_NAME_ALIASES: Record<string, BibleBookName> = { | |
| // Genesis | |
| gen: 'Genesis', | |
| ge: 'Genesis', | |
| gn: 'Genesis', | |
| // Exodus | |
| exod: 'Exodus', | |
| exo: 'Exodus', | |
| ex: 'Exodus', | |
| // Leviticus | |
| lev: 'Leviticus', | |
| le: 'Leviticus', | |
| lv: 'Leviticus', | |
| // Numbers | |
| num: 'Numbers', | |
| nu: 'Numbers', | |
| nm: 'Numbers', | |
| nb: 'Numbers', | |
| // Deuteronomy | |
| deut: 'Deuteronomy', | |
| de: 'Deuteronomy', | |
| dt: 'Deuteronomy', | |
| // Joshua | |
| josh: 'Joshua', | |
| jos: 'Joshua', | |
| jsh: 'Joshua', | |
| // Judges | |
| judg: 'Judges', | |
| jdg: 'Judges', | |
| jg: 'Judges', | |
| jdgs: 'Judges', | |
| // Ruth | |
| rut: 'Ruth', | |
| ru: 'Ruth', | |
| // 1 Samuel | |
| '1sam': '1 Samuel', | |
| '1sa': '1 Samuel', | |
| '1sm': '1 Samuel', | |
| '1 sam': '1 Samuel', | |
| '1 sa': '1 Samuel', | |
| 'i samuel': '1 Samuel', | |
| 'i sam': '1 Samuel', | |
| '1st samuel': '1 Samuel', | |
| 'first samuel': '1 Samuel', | |
| // 2 Samuel | |
| '2sam': '2 Samuel', | |
| '2sa': '2 Samuel', | |
| '2sm': '2 Samuel', | |
| '2 sam': '2 Samuel', | |
| '2 sa': '2 Samuel', | |
| 'ii samuel': '2 Samuel', | |
| 'ii sam': '2 Samuel', | |
| '2nd samuel': '2 Samuel', | |
| 'second samuel': '2 Samuel', | |
| // 1 Kings | |
| '1kgs': '1 Kings', | |
| '1ki': '1 Kings', | |
| '1k': '1 Kings', | |
| '1 kgs': '1 Kings', | |
| '1 ki': '1 Kings', | |
| 'i kings': '1 Kings', | |
| 'i kgs': '1 Kings', | |
| '1st kings': '1 Kings', | |
| 'first kings': '1 Kings', | |
| // 2 Kings | |
| '2kgs': '2 Kings', | |
| '2ki': '2 Kings', | |
| '2k': '2 Kings', | |
| '2 kgs': '2 Kings', | |
| '2 ki': '2 Kings', | |
| 'ii kings': '2 Kings', | |
| 'ii kgs': '2 Kings', | |
| '2nd kings': '2 Kings', | |
| 'second kings': '2 Kings', | |
| // 1 Chronicles | |
| '1chr': '1 Chronicles', | |
| '1ch': '1 Chronicles', | |
| '1 chr': '1 Chronicles', | |
| '1 ch': '1 Chronicles', | |
| 'i chronicles': '1 Chronicles', | |
| 'i chr': '1 Chronicles', | |
| '1st chronicles': '1 Chronicles', | |
| 'first chronicles': '1 Chronicles', | |
| // 2 Chronicles | |
| '2chr': '2 Chronicles', | |
| '2ch': '2 Chronicles', | |
| '2 chr': '2 Chronicles', | |
| '2 ch': '2 Chronicles', | |
| 'ii chronicles': '2 Chronicles', | |
| 'ii chr': '2 Chronicles', | |
| '2nd chronicles': '2 Chronicles', | |
| 'second chronicles': '2 Chronicles', | |
| // Ezra | |
| ezr: 'Ezra', | |
| // Nehemiah | |
| neh: 'Nehemiah', | |
| ne: 'Nehemiah', | |
| // Esther | |
| esth: 'Esther', | |
| est: 'Esther', | |
| es: 'Esther', | |
| // Job | |
| jb: 'Job', | |
| // Psalms | |
| ps: 'Psalms', | |
| psa: 'Psalms', | |
| psm: 'Psalms', | |
| pss: 'Psalms', | |
| psalm: 'Psalms', | |
| // Proverbs | |
| prov: 'Proverbs', | |
| pro: 'Proverbs', | |
| prv: 'Proverbs', | |
| pr: 'Proverbs', | |
| // Ecclesiastes | |
| eccl: 'Ecclesiastes', | |
| ecc: 'Ecclesiastes', | |
| ec: 'Ecclesiastes', | |
| qoh: 'Ecclesiastes', | |
| // Song of Solomon | |
| song: 'Song of Solomon', | |
| sos: 'Song of Solomon', | |
| 'song of songs': 'Song of Solomon', | |
| 'songs of solomon': 'Song of Solomon', | |
| canticles: 'Song of Solomon', | |
| cant: 'Song of Solomon', | |
| // Isaiah | |
| isa: 'Isaiah', | |
| is: 'Isaiah', | |
| // Jeremiah | |
| jer: 'Jeremiah', | |
| je: 'Jeremiah', | |
| jr: 'Jeremiah', | |
| // Lamentations | |
| lam: 'Lamentations', | |
| la: 'Lamentations', | |
| // Ezekiel | |
| ezek: 'Ezekiel', | |
| eze: 'Ezekiel', | |
| ezk: 'Ezekiel', | |
| // Daniel | |
| dan: 'Daniel', | |
| da: 'Daniel', | |
| dn: 'Daniel', | |
| // Hosea | |
| hos: 'Hosea', | |
| ho: 'Hosea', | |
| // Joel | |
| joe: 'Joel', | |
| jl: 'Joel', | |
| // Amos | |
| am: 'Amos', | |
| // Obadiah | |
| obad: 'Obadiah', | |
| ob: 'Obadiah', | |
| // Jonah | |
| jon: 'Jonah', | |
| jnh: 'Jonah', | |
| // Micah | |
| mic: 'Micah', | |
| mc: 'Micah', | |
| // Nahum | |
| nah: 'Nahum', | |
| na: 'Nahum', | |
| // Habakkuk | |
| hab: 'Habakkuk', | |
| hb: 'Habakkuk', | |
| // Zephaniah | |
| zeph: 'Zephaniah', | |
| zep: 'Zephaniah', | |
| zp: 'Zephaniah', | |
| // Haggai | |
| hag: 'Haggai', | |
| hg: 'Haggai', | |
| // Zechariah | |
| zech: 'Zechariah', | |
| zec: 'Zechariah', | |
| zc: 'Zechariah', | |
| // Malachi | |
| mal: 'Malachi', | |
| ml: 'Malachi', | |
| // Matthew | |
| matt: 'Matthew', | |
| mat: 'Matthew', | |
| mt: 'Matthew', | |
| // Mark | |
| mrk: 'Mark', | |
| mar: 'Mark', | |
| mk: 'Mark', | |
| mr: 'Mark', | |
| // Luke | |
| luk: 'Luke', | |
| lk: 'Luke', | |
| // John (Gospel) | |
| joh: 'John', | |
| jhn: 'John', | |
| jn: 'John', | |
| // Acts | |
| act: 'Acts', | |
| ac: 'Acts', | |
| // Romans | |
| rom: 'Romans', | |
| ro: 'Romans', | |
| rm: 'Romans', | |
| // 1 Corinthians | |
| '1cor': '1 Corinthians', | |
| '1co': '1 Corinthians', | |
| '1 cor': '1 Corinthians', | |
| '1 co': '1 Corinthians', | |
| 'i corinthians': '1 Corinthians', | |
| 'i cor': '1 Corinthians', | |
| '1st corinthians': '1 Corinthians', | |
| 'first corinthians': '1 Corinthians', | |
| // 2 Corinthians | |
| '2cor': '2 Corinthians', | |
| '2co': '2 Corinthians', | |
| '2 cor': '2 Corinthians', | |
| '2 co': '2 Corinthians', | |
| 'ii corinthians': '2 Corinthians', | |
| 'ii cor': '2 Corinthians', | |
| '2nd corinthians': '2 Corinthians', | |
| 'second corinthians': '2 Corinthians', | |
| // Galatians | |
| gal: 'Galatians', | |
| ga: 'Galatians', | |
| // Ephesians | |
| eph: 'Ephesians', | |
| ephes: 'Ephesians', | |
| // Philippians | |
| phil: 'Philippians', | |
| php: 'Philippians', | |
| pp: 'Philippians', | |
| // Colossians | |
| col: 'Colossians', | |
| // 1 Thessalonians | |
| '1thess': '1 Thessalonians', | |
| '1thes': '1 Thessalonians', | |
| '1th': '1 Thessalonians', | |
| '1 thess': '1 Thessalonians', | |
| '1 thes': '1 Thessalonians', | |
| '1 th': '1 Thessalonians', | |
| 'i thessalonians': '1 Thessalonians', | |
| 'i thess': '1 Thessalonians', | |
| '1st thessalonians': '1 Thessalonians', | |
| 'first thessalonians': '1 Thessalonians', | |
| // 2 Thessalonians | |
| '2thess': '2 Thessalonians', | |
| '2thes': '2 Thessalonians', | |
| '2th': '2 Thessalonians', | |
| '2 thess': '2 Thessalonians', | |
| '2 thes': '2 Thessalonians', | |
| '2 th': '2 Thessalonians', | |
| 'ii thessalonians': '2 Thessalonians', | |
| 'ii thess': '2 Thessalonians', | |
| '2nd thessalonians': '2 Thessalonians', | |
| 'second thessalonians': '2 Thessalonians', | |
| // 1 Timothy | |
| '1tim': '1 Timothy', | |
| '1ti': '1 Timothy', | |
| '1 tim': '1 Timothy', | |
| '1 ti': '1 Timothy', | |
| 'i timothy': '1 Timothy', | |
| 'i tim': '1 Timothy', | |
| '1st timothy': '1 Timothy', | |
| 'first timothy': '1 Timothy', | |
| // 2 Timothy | |
| '2tim': '2 Timothy', | |
| '2ti': '2 Timothy', | |
| '2 tim': '2 Timothy', | |
| '2 ti': '2 Timothy', | |
| 'ii timothy': '2 Timothy', | |
| 'ii tim': '2 Timothy', | |
| '2nd timothy': '2 Timothy', | |
| 'second timothy': '2 Timothy', | |
| // Titus | |
| tit: 'Titus', | |
| ti: 'Titus', | |
| // Philemon | |
| phm: 'Philemon', | |
| philem: 'Philemon', | |
| pm: 'Philemon', | |
| // Hebrews | |
| heb: 'Hebrews', | |
| // James | |
| jas: 'James', | |
| jm: 'James', | |
| // 1 Peter | |
| '1pet': '1 Peter', | |
| '1pe': '1 Peter', | |
| '1pt': '1 Peter', | |
| '1p': '1 Peter', | |
| '1 pet': '1 Peter', | |
| '1 pe': '1 Peter', | |
| 'i peter': '1 Peter', | |
| 'i pet': '1 Peter', | |
| '1st peter': '1 Peter', | |
| 'first peter': '1 Peter', | |
| // 2 Peter | |
| '2pet': '2 Peter', | |
| '2pe': '2 Peter', | |
| '2pt': '2 Peter', | |
| '2p': '2 Peter', | |
| '2 pet': '2 Peter', | |
| '2 pe': '2 Peter', | |
| 'ii peter': '2 Peter', | |
| 'ii pet': '2 Peter', | |
| '2nd peter': '2 Peter', | |
| 'second peter': '2 Peter', | |
| // 1 John | |
| '1john': '1 John', | |
| '1joh': '1 John', | |
| '1jhn': '1 John', | |
| '1jn': '1 John', | |
| '1j': '1 John', | |
| '1 john': '1 John', | |
| '1 joh': '1 John', | |
| '1 jhn': '1 John', | |
| '1 jn': '1 John', | |
| 'i john': '1 John', | |
| 'i joh': '1 John', | |
| '1st john': '1 John', | |
| 'first john': '1 John', | |
| // 2 John | |
| '2john': '2 John', | |
| '2joh': '2 John', | |
| '2jhn': '2 John', | |
| '2jn': '2 John', | |
| '2j': '2 John', | |
| '2 john': '2 John', | |
| '2 joh': '2 John', | |
| '2 jhn': '2 John', | |
| '2 jn': '2 John', | |
| 'ii john': '2 John', | |
| 'ii joh': '2 John', | |
| '2nd john': '2 John', | |
| 'second john': '2 John', | |
| // 3 John | |
| '3john': '3 John', | |
| '3joh': '3 John', | |
| '3jhn': '3 John', | |
| '3jn': '3 John', | |
| '3j': '3 John', | |
| '3 john': '3 John', | |
| '3 joh': '3 John', | |
| '3 jhn': '3 John', | |
| '3 jn': '3 John', | |
| 'iii john': '3 John', | |
| 'iii joh': '3 John', | |
| '3rd john': '3 John', | |
| 'third john': '3 John', | |
| // Jude | |
| jud: 'Jude', | |
| jde: 'Jude', | |
| // Revelation | |
| rev: 'Revelation', | |
| re: 'Revelation', | |
| 'revelation of john': 'Revelation', | |
| 'the revelation': 'Revelation', | |
| apocalypse: 'Revelation', | |
| apoc: 'Revelation', | |
| } | |
| // Build lookup maps for efficient access | |
| const bookByName = pipe( | |
| BIBLE_BOOKS, | |
| Array.map((book) => [book.name.toLowerCase(), book] as const), | |
| Record.fromEntries, | |
| ) | |
| const bookByOrder: ReadonlyMap<number, BibleBook> = pipe( | |
| BIBLE_BOOKS, | |
| Array.map((book) => [book.order, book] as const), | |
| (entries) => new Map(entries), | |
| ) | |
| // ============================================================================ | |
| // Book Normalization Functions | |
| // ============================================================================ | |
| /** | |
| * Normalizes a book name to its canonical form. | |
| * Handles abbreviations, Roman numerals, alternate names, etc. | |
| * | |
| * @example | |
| * normalizeBookName("Gen") // => Option.some("Genesis") | |
| * normalizeBookName("1 Cor") // => Option.some("1 Corinthians") | |
| * normalizeBookName("Revelation of John") // => Option.some("Revelation") | |
| * normalizeBookName("I Samuel") // => Option.some("1 Samuel") | |
| * normalizeBookName("invalid") // => Option.none() | |
| */ | |
| export const normalizeBookName = (input: string): Option.Option<BibleBookName> => { | |
| const normalized = pipe(input, String.trim, String.toLowerCase) | |
| // Check exact match first (lowercase canonical names) | |
| const exactMatch = pipe( | |
| bookByName, | |
| Record.get(normalized), | |
| Option.map((book) => book.name), | |
| ) | |
| if (Option.isSome(exactMatch)) { | |
| return exactMatch | |
| } | |
| // Check aliases | |
| const aliasMatch = pipe(BOOK_NAME_ALIASES, Record.get(normalized)) | |
| if (Option.isSome(aliasMatch)) { | |
| return aliasMatch | |
| } | |
| return Option.none() | |
| } | |
| /** | |
| * Gets book metadata by canonical name. | |
| * | |
| * @example | |
| * getBookByName("Genesis") // => Option.some({ order: 1, name: "Genesis", testament: "old" }) | |
| */ | |
| export const getBookByName = (name: string): Option.Option<BibleBook> => | |
| pipe( | |
| normalizeBookName(name), | |
| Option.flatMap((canonicalName) => pipe(bookByName, Record.get(canonicalName.toLowerCase()))), | |
| ) | |
| /** | |
| * Gets book metadata by order number (1-66). | |
| * | |
| * @example | |
| * getBookByOrder(1) // => Option.some({ order: 1, name: "Genesis", testament: "old" }) | |
| * getBookByOrder(66) // => Option.some({ order: 66, name: "Revelation", testament: "new" }) | |
| */ | |
| export const getBookByOrder = (order: number): Option.Option<BibleBook> => | |
| pipe(bookByOrder.get(order), Option.fromNullable) | |
| // ============================================================================ | |
| // Verse ID Generation & Formatting | |
| // ============================================================================ | |
| /** | |
| * Generates a verse ID from book, chapter, and verse. | |
| * The ID is deterministic and can be regenerated from any valid input. | |
| * | |
| * @example | |
| * generateVerseId("Genesis", 1, 1) // => "genesis-1-1" | |
| * generateVerseId("1 Corinthians", 13, 4) // => "1-corinthians-13-4" | |
| * generateVerseId("Song of Solomon", 2, 4) // => "song-of-solomon-2-4" | |
| */ | |
| export const generateVerseId = (book: string, chapter: number, verse: number): string => | |
| pipe( | |
| normalizeBookName(book), | |
| Option.map((canonicalName) => | |
| pipe( | |
| canonicalName, | |
| String.toLowerCase, | |
| String.replaceAll(' ', '-'), | |
| (bookSlug) => `${bookSlug}-${chapter}-${verse}`, | |
| ), | |
| ), | |
| Option.getOrElse(() => { | |
| // Fallback: slugify the input directly | |
| const bookSlug = pipe(book, String.toLowerCase, String.trim, String.replaceAll(' ', '-')) | |
| return `${bookSlug}-${chapter}-${verse}` | |
| }), | |
| ) | |
| /** | |
| * Formats a human-readable reference from book, chapter, and verse. | |
| * | |
| * @example | |
| * formatReference("genesis", 1, 1) // => "Genesis 1:1" | |
| * formatReference("1 cor", 13, 4) // => "1 Corinthians 13:4" | |
| */ | |
| export const formatReference = (book: string, chapter: number, verse: number): string => | |
| pipe( | |
| normalizeBookName(book), | |
| Option.map((canonicalName) => `${canonicalName} ${chapter}:${verse}`), | |
| Option.getOrElse(() => `${book} ${chapter}:${verse}`), | |
| ) | |
| /** | |
| * Parses a verse reference string into its components. | |
| * Handles various formats like "Genesis 1:1", "Gen 1:1", "1 Cor 13:4", etc. | |
| * | |
| * @example | |
| * parseReference("Genesis 1:1") // => Option.some({ book: "Genesis", chapter: 1, verse: 1 }) | |
| * parseReference("1 Cor 13:4") // => Option.some({ book: "1 Corinthians", chapter: 13, verse: 4 }) | |
| * parseReference("invalid") // => Option.none() | |
| */ | |
| export const parseReference = ( | |
| reference: string, | |
| ): Option.Option<{ book: BibleBookName; chapter: number; verse: number }> => { | |
| // Match patterns like "Genesis 1:1", "1 Cor 13:4", "Song of Solomon 2:4" | |
| // The regex captures: (book name) (chapter):(verse) | |
| const match = reference.match(/^(.+?)\s+(\d+):(\d+)$/) | |
| if (!match) { | |
| return Option.none() | |
| } | |
| const [, bookPart, chapterStr, verseStr] = match | |
| if (!bookPart || !chapterStr || !verseStr) { | |
| return Option.none() | |
| } | |
| return pipe( | |
| normalizeBookName(bookPart), | |
| Option.map((book) => ({ | |
| book, | |
| chapter: Number.parseInt(chapterStr, 10), | |
| verse: Number.parseInt(verseStr, 10), | |
| })), | |
| ) | |
| } | |
| // ============================================================================ | |
| // Chapter/Verse Expansion Functions | |
| // ============================================================================ | |
| /** | |
| * Expands compressed chapter data into individual verse records. | |
| * | |
| * @example | |
| * expandChapterToVerses({ book: "Genesis", bookOrder: 1, testament: "old", chapter: 1, startVerse: 1, endVerse: 31 }) | |
| * // => [ | |
| * // { id: "genesis-1-1", reference: "Genesis 1:1", book: "Genesis", chapter: 1, verse: 1, bookOrder: 1, testament: "old" }, | |
| * // { id: "genesis-1-2", reference: "Genesis 1:2", book: "Genesis", chapter: 1, verse: 2, bookOrder: 1, testament: "old" }, | |
| * // ... (31 total) | |
| * // ] | |
| */ | |
| export const expandChapterToVerses = (chapter: ChapterData): ReadonlyArray<VerseRecord> => | |
| pipe( | |
| Array.range(chapter.startVerse, chapter.endVerse), | |
| Array.map((verseNum) => ({ | |
| id: generateVerseId(chapter.book, chapter.chapter, verseNum), | |
| reference: formatReference(chapter.book, chapter.chapter, verseNum), | |
| book: chapter.book, | |
| chapter: chapter.chapter, | |
| verse: verseNum, | |
| bookOrder: chapter.bookOrder, | |
| testament: chapter.testament, | |
| })), | |
| ) | |
| /** | |
| * Expands all compressed chapter data into individual verse records. | |
| * This is used by the seed script to generate the full 31,102 verse records. | |
| */ | |
| export const expandAllChaptersToVerses = ( | |
| chapters: ReadonlyArray<ChapterData>, | |
| ): ReadonlyArray<VerseRecord> => pipe(chapters, Array.flatMap(expandChapterToVerses)) | |
| // ============================================================================ | |
| // Legacy Functions (kept for backwards compatibility) | |
| // ============================================================================ | |
| // Regex for parsing verse references - defined at module level for performance | |
| const VERSE_REFERENCE_REGEX = /^([\d\s]*[A-Za-z\s]+?)\s*(\d+):(\d+)$/ | |
| /** | |
| * Parse a verse reference like "Matthew 25:31" or "1 John 3:16" | |
| */ | |
| export function parseVerseReference(reference: string): Option.Option<VerseReference> { | |
| return pipe( | |
| reference, | |
| String.trim, | |
| (trimmed) => { | |
| // Match patterns like: | |
| // "Matthew 25:31" | |
| // "1 John 3:16" | |
| // "2 Corinthians 5:10" | |
| const match = trimmed.match(VERSE_REFERENCE_REGEX) | |
| return Option.fromNullable(match) | |
| }, | |
| Option.flatMap((match) => { | |
| const book = match[1] | |
| const chapterStr = match[2] | |
| const verseStr = match[3] | |
| if (!(book && chapterStr && verseStr)) { | |
| return Option.none() | |
| } | |
| const chapter = Number.parseInt(chapterStr, 10) | |
| const verse = Number.parseInt(verseStr, 10) | |
| if (Number.isNaN(chapter) || Number.isNaN(verse)) { | |
| return Option.none() | |
| } | |
| return Option.some({ | |
| book: pipe(book, String.trim), | |
| chapter, | |
| verse, | |
| }) | |
| }), | |
| ) | |
| } | |
| /** | |
| * Format a verse reference back to string | |
| */ | |
| export function formatVerseReference(ref: VerseReference): string { | |
| return `${ref.book} ${ref.chapter}:${ref.verse}` | |
| } | |
| /** | |
| * Check if two verse references are equal | |
| */ | |
| export function areVerseReferencesEqual(a: VerseReference, b: VerseReference): boolean { | |
| return ( | |
| pipe(a.book, String.toLowerCase) === pipe(b.book, String.toLowerCase) && | |
| a.chapter === b.chapter && | |
| a.verse === b.verse | |
| ) | |
| } | |
| /** | |
| * Expand a verse range into individual verse references | |
| * Returns None if the range is invalid (different books, invalid chapter/verse progression) | |
| */ | |
| export function expandVerseRange( | |
| start: VerseReference, | |
| end: VerseReference, | |
| ): Option.Option<Array<VerseReference>> { | |
| // Must be same book | |
| if (pipe(start.book, String.toLowerCase) !== pipe(end.book, String.toLowerCase)) { | |
| return Option.none() | |
| } | |
| // Same chapter - expand verses | |
| if (start.chapter === end.chapter) { | |
| if (start.verse > end.verse) { | |
| return Option.none() // Invalid range | |
| } | |
| return pipe( | |
| Array.range(start.verse, end.verse), | |
| Array.map((verse) => ({ | |
| book: start.book, | |
| chapter: start.chapter, | |
| verse, | |
| })), | |
| Option.some, | |
| ) | |
| } | |
| // Multiple chapters | |
| if (start.chapter > end.chapter) { | |
| return Option.none() // Invalid range | |
| } | |
| // We can't know how many verses are in each chapter without a Bible structure database | |
| // For now, we'll just return the start and end verses as-is | |
| // A more complete implementation would need verse count data per chapter | |
| return Option.some([start, end]) | |
| } | |
| /** | |
| * Parse a verse range string like "Matthew 25:31 - Matthew 25:34" | |
| * Also handles single verses like "Matthew 25:31" | |
| */ | |
| export function parseVerseRange(rangeStr: string): Option.Option<VerseRange> { | |
| const rangeSeparators = ['-', '–', '—'] // Regular dash, en-dash, em-dash | |
| return pipe(rangeStr, String.trim, (trimmed) => { | |
| // Check if it's a range (contains a dash/hyphen) | |
| const separator = pipe( | |
| rangeSeparators, | |
| Array.findFirst((sep) => pipe(trimmed, String.includes(sep))), | |
| ) | |
| return pipe( | |
| separator, | |
| Option.match({ | |
| onNone: () => { | |
| // It's a single verse | |
| return pipe( | |
| parseVerseReference(trimmed), | |
| Option.map((verse) => ({ | |
| end: verse, | |
| start: verse, | |
| })), | |
| ) | |
| }, | |
| onSome: (sep) => { | |
| // It's a range | |
| const parts = pipe(trimmed, String.split(sep), Array.map(String.trim)) | |
| if (pipe(parts, Array.length) !== 2) { | |
| return Option.none() | |
| } | |
| const start = pipe(parts, Array.unsafeGet(0), parseVerseReference) | |
| const end = pipe(parts, Array.unsafeGet(1), parseVerseReference) | |
| return pipe( | |
| start, | |
| Option.flatMap((s) => | |
| pipe( | |
| end, | |
| Option.map((e) => ({ end: e, start: s })), | |
| ), | |
| ), | |
| ) | |
| }, | |
| }), | |
| ) | |
| }) | |
| } | |
| /** | |
| * Check if a verse range represents a single verse | |
| */ | |
| export function isSingleVerse(range: VerseRange): boolean { | |
| return areVerseReferencesEqual(range.start, range.end) | |
| } | |
| /** | |
| * Expand verse range strings into individual verse strings | |
| * Example: "Matthew 25:31 - Matthew 25:34" -> ["Matthew 25:31", "Matthew 25:32", "Matthew 25:33", "Matthew 25:34"] | |
| * Returns None if the input is invalid | |
| */ | |
| export function expandVerseRangeString(rangeStr: string): Option.Option<Array<string>> { | |
| return pipe( | |
| parseVerseRange(rangeStr), | |
| Option.flatMap((range) => { | |
| // If it's a single verse, return as-is | |
| if (isSingleVerse(range)) { | |
| return Option.some([formatVerseReference(range.start)]) | |
| } | |
| // Expand the range | |
| return pipe( | |
| expandVerseRange(range.start, range.end), | |
| Option.map(Array.map(formatVerseReference)), | |
| ) | |
| }), | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment