codexparser 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1713 +0,0 @@
1
- /**
2
- * CodexParser.js
3
- * A modern ES6+ class for scanning and parsing scripture references from text.
4
- * Supports various formats (single verses, ranges, multi-chapter references) with
5
- * validation and version-specific versification.
6
- */
7
-
8
- const bible = require("./bible");
9
- const { bookRegex, chapterRegex, verseRegex, scripturesRegex } = require("./regex");
10
- const ScriptureScanner = require("./ScriptureScanner");
11
- const ReferenceParser = require("./ReferenceParser");
12
- const VersificationHandler = require("./VersificationHandler");
13
- const PassageCollection = require("./PassageCollection");
14
- const PassageUtils = require("./PassageUtils");
15
- const VersionHandler = require("./VersionHandler");
16
- const chapter_verses = require("./chapterVerseCombine");
17
-
18
- /**
19
- * Main class for parsing and validating scripture references
20
- * @class
21
- */
22
- class CodexParser {
23
- // Private fields using ES6+ syntax
24
- #scanner;
25
- #parser;
26
- #versificationHandler;
27
- #config;
28
- #version;
29
- #found;
30
- #passages;
31
-
32
- /**
33
- * Initializes the parser with configuration
34
- * @param {Object} config - Configuration options
35
- */
36
- constructor(config = {}) {
37
- this.#config = {
38
- booksOnly: config.booksOnly ?? false,
39
- invalid_sequence_strategy: config.invalid_sequence_strategy ?? "include",
40
- invalid_passage_strategy: config.invalid_passage_strategy ?? "include",
41
- };
42
-
43
- this.#scanner = new ScriptureScanner(this.#config);
44
- this.#parser = new ReferenceParser(this.#config);
45
- this.#versificationHandler = new VersificationHandler();
46
- this.#version = null;
47
- this.#found = [];
48
- this.#passages = [];
49
-
50
- // Legacy public properties for backward compatibility
51
- this.bible = bible;
52
- this.bookRegex = bookRegex;
53
- this.chapterRegex = chapterRegex;
54
- this.verseRegex = verseRegex;
55
- this.scripturesRegex = scripturesRegex;
56
- this.chapterVerses = chapter_verses;
57
- this.error = false;
58
-
59
- // Legacy constants
60
- this.SINGLE_CHAPTER = "single_chapter";
61
- this.CHAPTER_VERSE = "chapter_verse";
62
- this.CHAPTER_VERSE_RANGE = "chapter_verse_range";
63
- this.COMMA_SEPARATED = "comma_separated_verses";
64
- this.CHAPTER_RANGE = "chapter_range";
65
- this.MULTI_CHAPTER_RANGE = "multi_chapter_verse_range";
66
- }
67
-
68
- /**
69
- * Gets the found references (legacy getter)
70
- */
71
- get found() {
72
- return this.#found;
73
- }
74
-
75
- /**
76
- * Sets the found references (legacy setter)
77
- */
78
- set found(value) {
79
- this.#found = value;
80
- }
81
-
82
- /**
83
- * Gets the parsed passages (legacy getter)
84
- */
85
- get passages() {
86
- return this.#passages;
87
- }
88
-
89
- /**
90
- * Sets the parsed passages (legacy setter)
91
- */
92
- set passages(value) {
93
- this.#passages = value;
94
- }
95
-
96
- /**
97
- * Gets the current version (legacy getter)
98
- */
99
- get version() {
100
- return this.#version;
101
- }
102
-
103
- /**
104
- * Sets the current version (legacy setter)
105
- */
106
- set version(value) {
107
- this.#version = value;
108
- }
109
-
110
- /**
111
- * Gets the current config (legacy getter)
112
- */
113
- get config() {
114
- return this.#config;
115
- }
116
-
117
- /**
118
- * Sets the current config (legacy setter)
119
- */
120
- set config(value) {
121
- this.#config = value;
122
- }
123
-
124
- /**
125
- * Sets configuration options for the parser
126
- * @param {Object} config - Configuration options
127
- * @param {boolean} [config.booksOnly=false] - Whether to capture book names without references
128
- * @returns {CodexParser} The parser instance for method chaining
129
- */
130
- options(config) {
131
- this.#config = {
132
- booksOnly: config.booksOnly ?? this.#config.booksOnly,
133
- invalid_sequence_strategy: config.invalid_sequence_strategy ?? this.#config.invalid_sequence_strategy,
134
- invalid_passage_strategy: config.invalid_passage_strategy ?? this.#config.invalid_passage_strategy,
135
- };
136
-
137
- // Update scanner and parser configs
138
- this.#scanner = new ScriptureScanner(this.#config);
139
- this.#parser = new ReferenceParser(this.#config);
140
-
141
- return this;
142
- }
143
-
144
- /**
145
- * Retrieves available verses for a given book and chapter (legacy method)
146
- * @param {string} book - The book name
147
- * @param {number} chapter - The chapter number
148
- * @returns {number[]} Array of valid verse numbers
149
- */
150
- getChapterVerses(book, chapter) {
151
- return PassageUtils.getChapterVerses(book, chapter);
152
- }
153
-
154
- /**
155
- * Scans text for scripture references
156
- * @param {string} text - The text to scan
157
- * @returns {CodexParser} The parser instance for method chaining
158
- */
159
- scan(text) {
160
- this.#found = this.#scanner.scan(text);
161
- return this;
162
- }
163
-
164
- /**
165
- * Sets the Bible version for parsing
166
- * @param {string} version - The version (e.g., "lxx", "mt", "eng")
167
- * @returns {CodexParser} The parser instance
168
- */
169
- bibleVersion(version) {
170
- this.#version = VersionHandler.normalizeVersion(version);
171
- return this;
172
- }
173
-
174
- /**
175
- * Parses a scripture reference into structured passage objects
176
- * @param {string} reference - The reference to parse (e.g., "John 3:16")
177
- * @returns {CodexParser} The parser instance
178
- */
179
- parse(reference) {
180
- this.scan(reference);
181
- this.#passages = this.#parser.parse(this.#found, this.#version);
182
- this.#passages = this.#versificationHandler.applyVersification(this.#passages);
183
- return this;
184
- }
185
-
186
- /**
187
- * Returns parsed passages with utility methods
188
- * @returns {PassageCollection} Collection of passages with utility methods
189
- */
190
- getPassages() {
191
- return PassageCollection.from(this.#passages);
192
- }
193
-
194
- /**
195
- * Returns the first parsed passage
196
- * @returns {Object|null} The first passage or null
197
- */
198
- first() {
199
- return this.#passages.length > 0 ? this.#passages[0] : null;
200
- }
201
-
202
- /**
203
- * Normalizes book names using abbreviations or full names (legacy method)
204
- * @param {string|Array} book - The book name or array
205
- * @returns {string} Normalized book name
206
- */
207
- bookify(book) {
208
- return this.#parser.constructor.prototype.normalizeBookName?.(book) || book;
209
- }
210
-
211
- /**
212
- * Combines multiple passages into a single reference
213
- * @param {Object[]} [passages=this.passages] - Array of passages to combine
214
- * @returns {Object} Combined passage object
215
- */
216
- combine(passages = this.#passages) {
217
- return PassageCollection.combinePassages(passages);
218
- }
219
-
220
- /**
221
- * Merges verses into ranges or comma-separated lists (legacy method)
222
- * @param {number[]} verses - Array of verse numbers
223
- * @returns {string[]} Array of verse strings
224
- */
225
- mergeRanges(verses) {
226
- return PassageUtils.mergeRanges(verses);
227
- }
228
-
229
- /**
230
- * Generates a table of contents for the Bible
231
- * @param {string} [version="ESV"] - The Bible version
232
- * @returns {Object} TOC with book-chapter-verse mappings
233
- */
234
- getToc(version = "ESV") {
235
- const toc = {};
236
-
237
- bible.old.forEach((book) => {
238
- if (chapter_verses[book]) {
239
- toc[book] = chapter_verses[book];
240
- }
241
- });
242
-
243
- bible.new.forEach((book) => {
244
- if (chapter_verses[book]) {
245
- toc[book] = chapter_verses[book];
246
- }
247
- });
248
-
249
- PassageUtils.SINGLE_CHAPTER_BOOKS.forEach((item) => {
250
- Object.keys(item).forEach((book) => {
251
- if (!toc[book]) {
252
- toc[book] = item[book];
253
- }
254
- });
255
- });
256
-
257
- const orderedToc = {};
258
- const canonicalOrder = [...bible.old, ...bible.new];
259
- canonicalOrder.forEach((book) => {
260
- if (toc[book]) {
261
- orderedToc[book] = toc[book];
262
- }
263
- });
264
-
265
- return orderedToc;
266
- }
267
-
268
- /**
269
- * Replaces scripture references in text with formatted references
270
- * @param {string} text - The original text
271
- * @param {boolean} useAbbreviations - Whether to use abbreviated book names
272
- * @returns {string} Text with replaced references
273
- */
274
- replace(text, useAbbreviations = true) {
275
- if (!this.#passages.length) {
276
- return text;
277
- }
278
-
279
- let result = text;
280
- for (let i = this.#passages.length - 1; i >= 0; i--) {
281
- const passage = this.#passages[i];
282
- const { originalText, abbr, original } = passage;
283
- const newReference = useAbbreviations ? abbr : original;
284
-
285
- const regex = new RegExp(`${originalText.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")}`, "g");
286
-
287
- const matches = [...result.matchAll(regex)];
288
- if (matches.length > 0) {
289
- for (let j = matches.length - 1; j >= 0; j--) {
290
- const match = matches[j];
291
- const startIndex = match.index;
292
- const endIndex = startIndex + match[0].length;
293
- const leadingSpace = match[1] || "";
294
- const hasOpeningParen = match[2] === "(";
295
- const hasClosingParen = match[3] === ")";
296
- const trailingSpace = match[4] || " ";
297
- const replacement =
298
- hasOpeningParen && hasClosingParen
299
- ? `${leadingSpace}(${newReference})${trailingSpace}`
300
- : `${leadingSpace}${newReference}${trailingSpace}`;
301
- result = result.slice(0, startIndex) + replacement + result.slice(endIndex);
302
- }
303
- }
304
- }
305
-
306
- return result;
307
- }
308
-
309
- /**
310
- * Checks if all references in the passages array are from the same book
311
- * @returns {boolean} True if all passages are from the same book
312
- */
313
- same() {
314
- if (this.#passages.length <= 1) {
315
- return true;
316
- }
317
-
318
- const firstBook = this.#passages[0].book.toLowerCase();
319
- return this.#passages.every((passage) => passage.book.toLowerCase() === firstBook);
320
- }
321
-
322
- // Legacy methods for backward compatibility
323
-
324
- /**
325
- * Expands verse references into individual verse objects (legacy)
326
- */
327
- expandVerses(book, chapter, verses) {
328
- return PassageUtils.expandVerses(book, chapter, verses);
329
- }
330
-
331
- /**
332
- * Populates passage with expanded verse objects (legacy)
333
- */
334
- populate(passage) {
335
- return this.#parser.constructor.prototype._populatePassage?.(passage) || [];
336
- }
337
-
338
- /**
339
- * Formats a passage into a human-readable reference (legacy)
340
- */
341
- scripturize(passage) {
342
- return this.#parser.constructor.prototype._formatScripture?.(passage) || {};
343
- }
344
-
345
- /**
346
- * Applies versification differences to parsed passages (legacy)
347
- */
348
- versification() {
349
- this.#passages = this.#versificationHandler.applyVersification(this.#passages);
350
- }
351
- }
352
-
353
- module.exports = CodexParser;
354
-
355
-
356
-
357
- testament,
358
- startIndex: passage.startIndex,
359
- endIndex: passage.endIndex,
360
- originalText: passage.originalText,
361
- version: this._handleVersion(passage.version, testament),
362
- passages: [],
363
- scripture: null,
364
- valid: true,
365
- start: null,
366
- end: null,
367
- abbr: null,
368
- }
369
-
370
- // Clean reference for parsing
371
- let cleanReference = passage.reference.replace(/\s*(LXX|MT)$/i, "").trim()
372
- if (cleanReference.endsWith(",")) {
373
- cleanReference = cleanReference.slice(0, -1).trim()
374
- }
375
-
376
- // Handle book-only or empty references
377
- if (!cleanReference && this.config.booksOnly) {
378
- parsedPassage.type = "book_only"
379
- } else if (!cleanReference || cleanReference.match(/^\d+\s*[:;]?\s*$/)) {
380
- const chapterMatch = cleanReference.match(/\d+/) || ["1"]
381
- const chapter = Number(chapterMatch[0])
382
- parsedPassage.chapter = chapter
383
- parsedPassage.type = this.SINGLE_CHAPTER
384
- const chapterVerses = this.getChapterVerses(book, chapter)
385
- if (chapterVerses.length) {
386
- const startVerse = chapterVerses[0]
387
- const endVerse = chapterVerses[chapterVerses.length - 1]
388
- parsedPassage.verses = [`${startVerse}-${endVerse}`]
389
- }
390
- } else if (passage.type === "comma_separated_verses") {
391
- // Handle comma-separated verses (e.g., "1:7,18")
392
- const [chapter, verses] = cleanReference.split(":")
393
- parsedPassage.chapter = Number(chapter)
394
- parsedPassage.verses = verses.split(",").map((v) => v.trim())
395
- } else {
396
- this.parseReferenceParts(parsedPassage, cleanReference)
397
- }
398
-
399
- parsedPassage.passages = this.populate(parsedPassage)
400
- parsedPassage.scripture = this.scripturize(parsedPassage)
401
- parsedPassage.valid = this._isValid(parsedPassage, cleanReference)
402
-
403
- // Set abbr property using SBL-style abbreviations
404
- const sblEntry = Object.entries(this.sblAbbreviations).find(
405
- ([key]) => key.toLowerCase() === book.toLowerCase()
406
- )
407
- if (sblEntry) {
408
- const { value, abbr } = sblEntry[1]
409
- const ref = passage.reference.replace(/\s*(LXX|MT)$/i, "").trim()
410
- parsedPassage.abbr = abbr
411
- ? `${value}. ${ref}${
412
- parsedPassage.version.value !== "ENG" ? " " + parsedPassage.version.value : ""
413
- }`
414
- : `${value} ${ref}${parsedPassage.version.value !== "ENG" ? " " + parsedPassage.version.value : ""}`
415
- } else {
416
- parsedPassage.abbr = parsedPassage.original
417
- }
418
-
419
- if (parsedPassage.type === this.MULTI_CHAPTER_RANGE) {
420
- this.handleMultiChapterRange(parsedPassage, cleanReference)
421
- } else {
422
- delete parsedPassage.to
423
- }
424
-
425
- // Calculate start and end based on passages array
426
- if (parsedPassage.passages.length > 0) {
427
- const sortedPassages = parsedPassage.passages.slice().sort((a, b) => {
428
- if (a.chapter !== b.chapter) return a.chapter - b.chapter
429
- return a.verse - b.verse
430
- })
431
- const firstPassage = sortedPassages[0]
432
- const lastPassage = sortedPassages[sortedPassages.length - 1]
433
- parsedPassage.start = {
434
- book: firstPassage.book,
435
- chapter: firstPassage.chapter,
436
- verse: firstPassage.verse,
437
- }
438
- parsedPassage.end = {
439
- book: lastPassage.book,
440
- chapter: lastPassage.chapter,
441
- verse: lastPassage.verse,
442
- }
443
- }
444
-
445
- if (!parsedPassage.version) {
446
- parsedPassage.version = {
447
- name: "English",
448
- value: "ENG",
449
- abbreviation: "eng",
450
- }
451
- }
452
-
453
- return parsedPassage
454
- })
455
-
456
- this.versification()
457
- return this
458
- }
459
- /**
460
- * Parses reference parts into chapter and verse components.
461
- * @param {Object} passage - The passage object to populate.
462
- * @param {string} reference - The reference string.
463
- * @private
464
- */
465
- parseReferenceParts(passage, reference) {
466
- const singleChapterBook = this.singleChapterBook.find((b) => Object.keys(b)[0] === passage.book)
467
- const parts = reference
468
- .split(",")
469
- .map((part) => part.trim())
470
- .filter((part) => part)
471
-
472
- parts.forEach((part, index) => {
473
- const isFirstPart = index === 0
474
-
475
- // Handle multi-chapter ranges (e.g., "2:1-3:19")
476
- if (part.includes("-") && part.includes(":")) {
477
- const [start, end] = part.split("-").map((s) => s.trim())
478
- const startParts = start.split(/[:.]/).map((s) => s.trim())
479
- const endParts = end.split(/[:.]/).map((s) => s.trim())
480
- if (startParts.length > 1 && endParts.length > 1 && startParts[0] !== endParts[0]) {
481
- passage.type = this.MULTI_CHAPTER_RANGE
482
- passage.chapter = Number(startParts[0])
483
- passage.verses = [startParts[1] || "1"]
484
- passage.to = {
485
- book: passage.book,
486
- chapter: Number(endParts[0]),
487
- verses: [endParts[1] || "1"],
488
- }
489
- return
490
- }
491
- }
492
-
493
- // Handle chapter-only references (e.g., "3")
494
- if (!part.includes(":") && !part.includes("-") && !singleChapterBook) {
495
- const chapter = Number(part.replace(/[^0-9]/g, ""))
496
- if (chapter > 0) {
497
- passage.chapter = chapter
498
- passage.type = this.SINGLE_CHAPTER
499
- const chapterVerses = this.getChapterVerses(passage.book, chapter)
500
- if (chapterVerses.length) {
501
- const startVerse = chapterVerses[0]
502
- const endVerse = chapterVerses[chapterVerses.length - 1]
503
- passage.verses = [`${startVerse}-${endVerse}`]
504
- }
505
- return
506
- }
507
- }
508
-
509
- if (part.includes(":")) {
510
- this.parseChapterVerse(passage, part, isFirstPart)
511
- } else if (singleChapterBook) {
512
- this.parseSingleChapterBook(passage, part, isFirstPart && parts.length === 1)
513
- } else if (part.includes("-")) {
514
- this.parseRange(passage, part, isFirstPart)
515
- } else {
516
- this.parseSingleNumber(passage, part, isFirstPart)
517
- }
518
- })
519
- }
520
-
521
- /**
522
- * Parses chapter-verse references (e.g., "3:16").
523
- * @param {Object} passage - The passage object.
524
- * @param {string} part - The reference part.
525
- * @param {boolean} isFirstPart - Whether this is the first part.
526
- * @private
527
- */
528
- parseChapterVerse(passage, part, isFirstPart) {
529
- const [chapter, verse] = part.split(/[:.]/).map((s) => s.trim())
530
- if (isFirstPart) passage.chapter = Number(chapter)
531
- passage.type = verse.includes("-")
532
- ? this.CHAPTER_VERSE_RANGE
533
- : verse.includes(",")
534
- ? this.COMMA_SEPARATED
535
- : this.CHAPTER_VERSE
536
- if (verse.includes(",")) {
537
- passage.verses.push(...verse.split(",").map((v) => v.trim()))
538
- } else {
539
- passage.verses.push(verse)
540
- }
541
- }
542
-
543
- /**
544
- * Parses references for single-chapter books (e.g., "Jude 5").
545
- * @param {Object} passage - The passage object.
546
- * @param {string} part - The reference part.
547
- * @param {boolean} isWholeChapter - Whether the reference is for the whole chapter.
548
- * @private
549
- */
550
- parseSingleChapterBook(passage, part, isWholeChapter) {
551
- const verseCount = this.getChapterVerses(passage.book, 1).length
552
- if (part === "1" && isWholeChapter) {
553
- passage.chapter = 1
554
- passage.type = this.SINGLE_CHAPTER
555
- passage.verses = [`1-${verseCount}`]
556
- } else if (part.includes("-")) {
557
- passage.chapter = 1
558
- passage.verses.push(part)
559
- passage.type = this.CHAPTER_VERSE_RANGE
560
- } else if (part.includes(",")) {
561
- passage.chapter = 1
562
- passage.verses.push(...part.split(",").map((v) => v.trim()))
563
- passage.type = this.COMMA_SEPARATED
564
- } else {
565
- const num = Number(part)
566
- if (num > 0) {
567
- passage.chapter = 1
568
- passage.verses.push(num)
569
- passage.type = this.CHAPTER_VERSE
570
- }
571
- }
572
- }
573
-
574
- /**
575
- * Parses range references (e.g., "1-3").
576
- * @param {Object} passage - The passage object.
577
- * @param {string} part - The reference part.
578
- * @param {boolean} isFirstPart - Whether this is the first part.
579
- * @private
580
- */
581
- parseRange(passage, part, isFirstPart) {
582
- if (!passage.chapter && isFirstPart) {
583
- const [start, end] = part.split("-").map(Number)
584
- passage.chapter = start
585
- const startVerses = this.getChapterVerses(passage.book, start)
586
- passage.verses = [`${startVerses[0]}-${startVerses[startVerses.length - 1]}`]
587
- passage.to = {
588
- book: passage.book,
589
- chapter: end,
590
- verses: [
591
- `${this.getChapterVerses(passage.book, end)[0]}-${
592
- this.getChapterVerses(passage.book, end).slice(-1)[0]
593
- }`,
594
- ],
595
- }
596
- passage.type = this.CHAPTER_RANGE
597
- } else {
598
- passage.verses.push(part)
599
- passage.type = this.CHAPTER_VERSE_RANGE
600
- }
601
- }
602
-
603
- /**
604
- * Parses single number references (e.g., "3" for chapter or verse).
605
- * @param {Object} passage - The passage object.
606
- * @param {string} part - The reference part.
607
- * @param {boolean} isFirstPart - Whether this is the first part.
608
- * @private
609
- */
610
- parseSingleNumber(passage, part, isFirstPart) {
611
- if (isFirstPart && !passage.chapter) {
612
- passage.chapter = Number(part)
613
- passage.type = this.SINGLE_CHAPTER
614
- const chapterVerses = this.getChapterVerses(passage.book, passage.chapter)
615
- if (chapterVerses.length) {
616
- passage.verses = [`${chapterVerses[0]}-${chapterVerses[chapterVerses.length - 1]}`]
617
- }
618
- } else {
619
- passage.verses.push(Number(part))
620
- passage.type = this.COMMA_SEPARATED
621
- }
622
- }
623
-
624
- /**
625
- * Handles multi-chapter range references (e.g., "3:16-4:5").
626
- * @param {Object} passage - The passage object.
627
- * @param {string} reference - The full reference string.
628
- * @private
629
- */
630
- handleMultiChapterRange(passage, reference) {
631
- const parts = reference.split(",")
632
- const lastPart = parts[parts.length - 1].trim()
633
- const [endChapter, endVerse] = lastPart.split(/[:.]/).map((s) => s.trim())
634
- if (endChapter !== String(passage.chapter)) {
635
- passage.to = {
636
- book: passage.book,
637
- chapter: Number(endChapter),
638
- verses: endVerse ? [endVerse] : ["1"],
639
- }
640
- }
641
- }
642
-
643
- /**
644
- * Generates a range of numbers.
645
- * @param {number} start - Start number.
646
- * @param {number} end - End number.
647
- * @returns {number[]} Array of numbers.
648
- * @private
649
- */
650
- _generateRange(start, end) {
651
- const range = []
652
- for (let i = start; i <= end; i++) {
653
- range.push(i)
654
- }
655
- return range
656
- }
657
-
658
- /**
659
- * Searches for versification differences for a book and chapter.
660
- * @param {string} book - The book name.
661
- * @param {number} chapter - The chapter number.
662
- * @param {string} version - The Bible version.
663
- * @returns {Object|undefined} Updated chapter verses or undefined.
664
- * @private
665
- */
666
- _searchVersificationDifferences(book, chapter, version) {
667
- version = version.toLowerCase()
668
-
669
- // Handle single-chapter book "Obadiah"
670
- if (book === "Obadiah") {
671
- const singleChapterBook = this.singleChapterBook.find((b) => Object.keys(b)[0] === "Obadiah")
672
- if (!singleChapterBook || !singleChapterBook[book][chapter]) {
673
- return // No data for Obadiah or chapter
674
- }
675
- if (!this.versificationDifferences[book]) {
676
- return // No versification differences for Obadiah
677
- }
678
- // Process versification differences
679
- for (const [key, value] of Object.entries(this.versificationDifferences[book])) {
680
- if (value[version].startsWith(`${chapter}:`)) {
681
- if (value[version]) {
682
- const verse = value[version].split(":")[1]
683
- singleChapterBook[book][chapter].push(Number(verse))
684
- }
685
- }
686
- }
687
- singleChapterBook[book][chapter] = Array.from(new Set(singleChapterBook[book][chapter]))
688
- return singleChapterBook[book]
689
- }
690
-
691
- // Handle all other books using chapterVerses
692
- if (!this.chapterVerses[book][chapter]) {
693
- return
694
- }
695
- if (!this.versificationDifferences[book]) {
696
- return
697
- }
698
- for (const [key, value] of Object.entries(this.versificationDifferences[book])) {
699
- if (value[version].startsWith(`${chapter}:`)) {
700
- if (value[version]) {
701
- const verse = value[version].split(":")[1]
702
- this.chapterVerses[book][chapter].push(Number(verse))
703
- }
704
- }
705
- }
706
- this.chapterVerses[book][chapter] = Array.from(new Set(this.chapterVerses[book][chapter]))
707
- return this.chapterVerses
708
- }
709
-
710
- /**
711
- * Sets the Bible version and applies versification differences.
712
- * @param {string} book - The book name.
713
- * @param {number} chapter - The chapter number.
714
- * @param {string} version - The Bible version.
715
- * @private
716
- */
717
- _setVersion(book, chapter, version) {
718
- this.version = version ? version : "eng"
719
- if (this.version !== "eng") {
720
- this._searchVersificationDifferences(book, chapter, version)
721
- }
722
- }
723
-
724
- /**
725
- * Applies versification differences to parsed passages.
726
- */
727
- versification() {
728
- this.passages.forEach((passage) => {
729
- const hasVersification = this.versificationDifferences[passage.book]
730
- passage.passages.forEach((subPassage) => {
731
- if (hasVersification) {
732
- const key = `${subPassage.chapter}:${subPassage.verse}`
733
- if (this.versificationDifferences[passage.book][key]) {
734
- subPassage.versification = this.versificationDifferences[passage.book][key]
735
- }
736
- }
737
- if (passage.version) {
738
- const versionAbbreviation = passage.version.abbreviation
739
- const versionType =
740
- versionAbbreviation === "lxx" ? "lxx" : versionAbbreviation === "mt" ? "mt" : null
741
- if (versionType) {
742
- const versionReference = `${subPassage.chapter}:${subPassage.verse}`
743
- for (const versification in this.versificationDifferences[passage.book]) {
744
- if (
745
- this.versificationDifferences[passage.book][versification][versionType] ===
746
- versionReference
747
- ) {
748
- subPassage.versification = this.versificationDifferences[passage.book][versification]
749
- break
750
- }
751
- }
752
- }
753
- }
754
- })
755
- })
756
- }
757
-
758
- /**
759
- * Populates passage with expanded verse objects.
760
- * @param {Object} passage - The passage object.
761
- * @returns {Object[]} Array of verse objects.
762
- */
763
- populate(passage) {
764
- const { book, chapter, verses, type, to } = passage
765
- const version = passage.version?.abbreviation || "eng"
766
- this._setVersion(book, chapter, version)
767
-
768
- if (type === this.SINGLE_CHAPTER) {
769
- const chapterVerses = this.getChapterVerses(book, chapter)
770
- return this.expandVerses(book, chapter, [`${chapterVerses[0]}-${chapterVerses[chapterVerses.length - 1]}`])
771
- }
772
-
773
- if (type === this.CHAPTER_VERSE || type === this.COMMA_SEPARATED || type === this.CHAPTER_VERSE_RANGE) {
774
- return this.expandVerses(book, chapter, verses)
775
- }
776
-
777
- if (type === this.CHAPTER_RANGE) {
778
- const passages = []
779
- if (to && to.chapter) {
780
- for (let ch = chapter; ch <= to.chapter; ch++) {
781
- const chapterVerses = this.getChapterVerses(book, ch)
782
- passages.push(
783
- ...this.expandVerses(book, ch, [
784
- `${chapterVerses[0]}-${chapterVerses[chapterVerses.length - 1]}`,
785
- ])
786
- )
787
- }
788
- }
789
- return passages
790
- }
791
-
792
- if (type === this.MULTI_CHAPTER_RANGE) {
793
- const passages = []
794
- const startVerse = verses[0]?.includes("-") ? Number(verses[0].split("-")[0]) : Number(verses[0]) || 1
795
- const endVerse = to?.verses?.[0]?.includes("-")
796
- ? Number(to.verses[0].split("-")[1])
797
- : Number(to?.verses?.[0]) || 1
798
- const endChapter = to?.chapter || chapter
799
-
800
- for (let ch = chapter; ch <= endChapter; ch++) {
801
- const chapterVerses = this.getChapterVerses(book, ch)
802
- const from = ch === chapter ? startVerse : chapterVerses[0]
803
- const toVerse = ch === endChapter ? endVerse : chapterVerses[chapterVerses.length - 1]
804
- passages.push(...this.expandVerses(book, ch, [`${from}-${toVerse}`]))
805
- }
806
- return passages
807
- }
808
-
809
- return []
810
- }
811
-
812
- /**
813
- * Expands verse references into individual verse objects.
814
- * @param {string} book - The book name.
815
- * @param {number} chapter - The chapter number.
816
- * @param {Array<string|number>} verses - Array of verses or ranges.
817
- * @returns {Object[]} Array of verse objects.
818
- */
819
- expandVerses(book, chapter, verses) {
820
- const passages = []
821
- const chapterVerses = this.getChapterVerses(book, chapter)
822
-
823
- verses.forEach((verse) => {
824
- if (typeof verse === "string" && verse.includes("-")) {
825
- const [start, end] = verse.split("-").map(Number)
826
- for (let i = start; i <= end && i <= chapterVerses[chapterVerses.length - 1]; i++) {
827
- passages.push({ book, chapter, verse: i })
828
- }
829
- } else {
830
- const verseNum = Number(verse)
831
- if (!isNaN(verseNum) && verseNum > 0) {
832
- passages.push({ book, chapter, verse: verseNum })
833
- }
834
- }
835
- })
836
- return passages
837
- }
838
-
839
- /**
840
- * Normalizes book names using abbreviations or full names.
841
- * @param {string|Array} book - The book name or array.
842
- * @returns {string} Normalized book name.
843
- */
844
- bookify(book) {
845
- if (typeof book !== "string") {
846
- book = book[0]
847
- }
848
- book = book.toLowerCase()
849
- let bookified = this.abbreviations[Object.keys(this.abbreviations).find((abbr) => abbr.toLowerCase() === book)]
850
- if (bookified) {
851
- return bookified
852
- }
853
- bookified =
854
- this.bible.new.find((b) => b.toLowerCase() === book) || this.bible.old.find((b) => b.toLowerCase() === book)
855
- return bookified || book
856
- }
857
-
858
- /**
859
- * Returns parsed passages with utility methods.
860
- * @returns {Object[]} Array of passages with methods.
861
- */
862
- getPassages() {
863
- const passagesArray = [...this.passages]
864
- const self = this
865
-
866
- passagesArray.first = function () {
867
- return this.length > 0 ? this[0] : null
868
- }
869
-
870
- passagesArray.oldTestament = function () {
871
- return this.filter((passage) => passage.testament === "old")
872
- }
873
-
874
- passagesArray.newTestament = function () {
875
- return this.filter((passage) => passage.testament === "new")
876
- }
877
-
878
- passagesArray.combine = function (options = {}) {
879
- const { book = true, chapter = true } = options
880
-
881
- if (!book) {
882
- return [...this]
883
- }
884
-
885
- const groupedByBook = new Map()
886
-
887
- this.forEach((passage) => {
888
- const bookKey = passage.book
889
- if (!groupedByBook.has(bookKey)) {
890
- groupedByBook.set(bookKey, [])
891
- }
892
- groupedByBook.get(bookKey).push(passage)
893
- })
894
-
895
- const combinedPassages = []
896
-
897
- for (const [book, bookPassages] of groupedByBook) {
898
- if (chapter) {
899
- const groupedByChapter = new Map()
900
- bookPassages.forEach((passage) => {
901
- const chapterKey = `${passage.book}-${passage.chapter}`
902
- if (!groupedByChapter.has(chapterKey)) {
903
- groupedByChapter.set(chapterKey, [])
904
- }
905
- groupedByChapter.get(chapterKey).push(passage)
906
- })
907
-
908
- for (const passages of groupedByChapter.values()) {
909
- if (passages.length === 1) {
910
- combinedPassages.push({ ...passages[0] })
911
- } else {
912
- const combined = self.combine(passages)
913
- combinedPassages.push(combined)
914
- }
915
- }
916
- } else {
917
- const combined = self.combine(bookPassages)
918
- combinedPassages.push(combined)
919
- }
920
- }
921
-
922
- return combinedPassages
923
- }
924
-
925
- passagesArray.getVersion = function (targetVersion) {
926
- const targetAbbr = targetVersion.toLowerCase() === "bhs" ? "mt" : targetVersion.toLowerCase()
927
- let versionObj
928
- if (targetAbbr === "eng") {
929
- versionObj = { name: "English", value: "ENG", abbreviation: "eng" }
930
- } else if (targetAbbr === "mt") {
931
- versionObj = { name: "Masoretic Text", value: "MT", abbreviation: "mt" }
932
- } else if (targetAbbr === "lxx") {
933
- versionObj = { name: "Septuagint", value: "LXX", abbreviation: "lxx" }
934
- } else {
935
- throw new Error("Invalid version: must be one of 'eng', 'mt', 'bhs', 'lxx'")
936
- }
937
-
938
- const converted = this.map((passage) => {
939
- const cloned = JSON.parse(JSON.stringify(passage))
940
- cloned.version = versionObj
941
-
942
- cloned.passages.forEach((sub) => {
943
- if (sub.versification && sub.versification[targetAbbr]) {
944
- const [ch, v] = sub.versification[targetAbbr].split(":").map(Number)
945
- sub.chapter = ch
946
- sub.verse = v
947
- }
948
- // else remain unchanged
949
- })
950
-
951
- // Recompute summary fields
952
- cloned.passages.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse)
953
-
954
- if (cloned.passages.length > 0) {
955
- cloned.start = {
956
- book: cloned.book,
957
- chapter: cloned.passages[0].chapter,
958
- verse: cloned.passages[0].verse,
959
- }
960
- cloned.end = {
961
- book: cloned.book,
962
- chapter: cloned.passages[cloned.passages.length - 1].chapter,
963
- verse: cloned.passages[cloned.passages.length - 1].verse,
964
- }
965
- }
966
-
967
- const chapterVersesMap = {}
968
- cloned.passages.forEach((p) => {
969
- if (!chapterVersesMap[p.chapter]) chapterVersesMap[p.chapter] = new Set()
970
- chapterVersesMap[p.chapter].add(p.verse)
971
- })
972
-
973
- const sortedChs = Object.keys(chapterVersesMap)
974
- .map(Number)
975
- .sort((a, b) => a - b)
976
- const chapterStrs = []
977
-
978
- const mergeFunc = (verses) => {
979
- const sorted = [...verses].sort((a, b) => a - b)
980
- const merged = []
981
- if (sorted.length === 0) return merged
982
- let start = sorted[0]
983
- let end = sorted[0]
984
- for (let i = 1; i < sorted.length; i++) {
985
- if (sorted[i] === end + 1) {
986
- end = sorted[i]
987
- } else {
988
- merged.push(start === end ? `${start}` : `${start}-${end}`)
989
- start = end = sorted[i]
990
- }
991
- }
992
- merged.push(start === end ? `${start}` : `${start}-${end}`)
993
- return merged
994
- }
995
-
996
- sortedChs.forEach((ch) => {
997
- const vs = Array.from(chapterVersesMap[ch])
998
- .filter((v) => v > 0)
999
- .sort((a, b) => a - b)
1000
- if (vs.length > 0) {
1001
- const merged = mergeFunc(vs)
1002
- chapterStrs.push(`${ch}:${merged.join(",")}`)
1003
- }
1004
- })
1005
-
1006
- if (chapterStrs.length === 0) {
1007
- return cloned // no verses, perhaps error but return as is
1008
- }
1009
-
1010
- const firstCh = sortedChs[0]
1011
- const lastCh = sortedChs[sortedChs.length - 1]
1012
- cloned.chapter = firstCh
1013
-
1014
- const mergedFirst = mergeFunc(chapterVersesMap[firstCh] || new Set())
1015
- cloned.verses = mergedFirst
1016
-
1017
- if (firstCh !== lastCh) {
1018
- cloned.type = this.MULTI_CHAPTER_RANGE
1019
- cloned.to = {
1020
- book: cloned.book,
1021
- chapter: lastCh,
1022
- verses: mergeFunc(chapterVersesMap[lastCh] || new Set()),
1023
- }
1024
- cloned.original = `${cloned.book} ${chapterStrs.join("; ")}`
1025
- } else {
1026
- const hasRangeOrMultiple =
1027
- mergedFirst.length > 1 || (mergedFirst.length === 1 && mergedFirst[0].includes("-"))
1028
- cloned.type = hasRangeOrMultiple ? this.CHAPTER_VERSE_RANGE : this.CHAPTER_VERSE
1029
- if (cloned.to) delete cloned.to
1030
- cloned.original = `${cloned.book} ${chapterStrs[0]}`
1031
- }
1032
-
1033
- const chString = chapterStrs.join("; ")
1034
- cloned.scripture = {
1035
- passage: `${cloned.book} ${chString}`,
1036
- cv: chString,
1037
- hash: `${cloned.book.toLowerCase()}_${chString
1038
- .replace(/:/g, ".")
1039
- .replace(/-/g, ".")
1040
- .replace(/[,; ]/g, ".")}`,
1041
- }
1042
-
1043
- // Set abbr
1044
- const sblEntry = Object.entries(self.sblAbbreviations).find(
1045
- ([key]) => key.toLowerCase() === cloned.book.toLowerCase()
1046
- )
1047
- const suffix = versionObj.abbreviation === "eng" ? "" : ` ${versionObj.value}`
1048
- if (sblEntry) {
1049
- const { value, abbr } = sblEntry[1]
1050
- cloned.abbr = abbr
1051
- ? `${value}. ${cloned.scripture.cv}${suffix}`
1052
- : `${value} ${cloned.scripture.cv}${suffix}`
1053
- } else {
1054
- cloned.abbr = `${cloned.book} ${cloned.scripture.cv}${suffix}`
1055
- }
1056
-
1057
- return cloned
1058
- })
1059
-
1060
- const newArray = [...converted]
1061
-
1062
- newArray.first = function () {
1063
- return this.length > 0 ? this[0] : null
1064
- }
1065
-
1066
- newArray.oldTestament = function () {
1067
- return this.filter((passage) => passage.testament === "old")
1068
- }
1069
-
1070
- newArray.newTestament = function () {
1071
- return this.filter((passage) => passage.testament === "new")
1072
- }
1073
-
1074
- newArray.combine = function (options = {}) {
1075
- const { book = true, chapter = true } = options
1076
-
1077
- if (!book) {
1078
- return [...this]
1079
- }
1080
-
1081
- const groupedByBook = new Map()
1082
-
1083
- this.forEach((passage) => {
1084
- const bookKey = passage.book
1085
- if (!groupedByBook.has(bookKey)) {
1086
- groupedByBook.set(bookKey, [])
1087
- }
1088
- groupedByBook.get(bookKey).push(passage)
1089
- })
1090
-
1091
- const combinedPassages = []
1092
-
1093
- for (const [book, bookPassages] of groupedByBook) {
1094
- if (chapter) {
1095
- const groupedByChapter = new Map()
1096
- bookPassages.forEach((passage) => {
1097
- const chapterKey = `${passage.book}-${passage.chapter}`
1098
- if (!groupedByChapter.has(chapterKey)) {
1099
- groupedByChapter.set(chapterKey, [])
1100
- }
1101
- groupedByChapter.get(chapterKey).push(passage)
1102
- })
1103
-
1104
- for (const passages of groupedByChapter.values()) {
1105
- if (passages.length === 1) {
1106
- combinedPassages.push({ ...passages[0] })
1107
- } else {
1108
- const combined = self.combine(passages)
1109
- combinedPassages.push(combined)
1110
- }
1111
- }
1112
- } else {
1113
- const combined = self.combine(bookPassages)
1114
- combinedPassages.push(combined)
1115
- }
1116
- }
1117
-
1118
- return combinedPassages
1119
- }
1120
-
1121
- newArray.getVersion = passagesArray.getVersion
1122
-
1123
- return newArray
1124
- }
1125
-
1126
- return passagesArray
1127
- }
1128
-
1129
- /**
1130
- * Returns the first parsed passage.
1131
- * @returns {Object|null} The first passage or null.
1132
- */
1133
- first() {
1134
- return this.passages.length > 0 ? this.passages[0] : null
1135
- }
1136
-
1137
- /**
1138
- * Formats a passage into a human-readable reference.
1139
- * @param {Object} passage - The passage object.
1140
- * @returns {Object} Formatted passage data.
1141
- */
1142
- scripturize(passage) {
1143
- const formatChapterVerse = (chapter, verses) => {
1144
- if (!chapter || !verses || verses.length === 0) return ""
1145
- if (verses.length === 1) {
1146
- return `${chapter}:${verses[0]}`
1147
- }
1148
- const isRange = verses.every((v, i, arr) => i === 0 || Number(v) === Number(arr[i - 1]) + 1)
1149
- if (isRange) {
1150
- return `${chapter}:${verses[0]}-${verses[verses.length - 1]}`
1151
- }
1152
- return `${chapter}:${verses.join(",")}`
1153
- }
1154
-
1155
- let combined = `${passage.book}`
1156
- if (passage.type === "multi_chapter_verse_range" && passage.to) {
1157
- combined += ` ${formatChapterVerse(passage.chapter, passage.verses)}-${formatChapterVerse(
1158
- passage.to.chapter,
1159
- passage.to.verses
1160
- )}`
1161
- } else if (passage.type === "chapter_verse_range" || passage.type === "comma_separated_verses") {
1162
- combined += ` ${formatChapterVerse(passage.chapter, passage.verses)}`
1163
- } else if (passage.type === "chapter_range" && passage.to) {
1164
- combined += ` ${passage.chapter}-${passage.to.chapter}`
1165
- } else {
1166
- combined += ` ${formatChapterVerse(passage.chapter, passage.verses)}`
1167
- }
1168
-
1169
- const cv = passage.to
1170
- ? `${formatChapterVerse(passage.chapter, passage.verses)}-${formatChapterVerse(
1171
- passage.to.chapter,
1172
- passage.to.verses
1173
- )}`
1174
- : formatChapterVerse(passage.chapter, passage.verses)
1175
-
1176
- const hash = `${passage.book.toLowerCase()}_${cv.replace(/:/g, ".").replace(/-/g, ".")}`
1177
-
1178
- return {
1179
- passage: combined,
1180
- cv: cv,
1181
- hash: hash,
1182
- }
1183
- }
1184
-
1185
- /**
1186
- * Combines multiple passages into a single reference.
1187
- * @param {Object[]} [passages=this.passages] - Array of passages to combine.
1188
- * @returns {Object} Combined passage object.
1189
- */
1190
- combine(passages = this.passages) {
1191
- if (!passages || passages.length === 0) {
1192
- throw new Error("No passages provided to join.")
1193
- }
1194
-
1195
- const uniqueBooks = [...new Set(passages.map((p) => p.book))]
1196
- if (uniqueBooks.length > 1) {
1197
- throw new Error("Passages must be from the same book to join.")
1198
- }
1199
-
1200
- const versions = new Set(passages.map((p) => p.version.abbreviation))
1201
- if (versions.size > 1) {
1202
- throw new Error("Cannot combine passages from different versions.")
1203
- }
1204
-
1205
- const combined = {
1206
- ...passages[0],
1207
- verses: [],
1208
- passages: [],
1209
- to: null,
1210
- scripture: {},
1211
- type: null,
1212
- start: null,
1213
- end: null,
1214
- }
1215
-
1216
- const chapterVerses = {}
1217
- let firstChapter = null
1218
- let lastChapter = null
1219
- let firstVerse = null
1220
- let lastVerse = null
1221
-
1222
- passages.forEach((passage) => {
1223
- passage.passages.forEach((p) => {
1224
- const chapter = p.chapter
1225
- const verse = p.verse
1226
- if (!chapterVerses[chapter]) {
1227
- chapterVerses[chapter] = new Set()
1228
- }
1229
- chapterVerses[chapter].add(verse)
1230
- combined.passages.push({
1231
- book: p.book,
1232
- chapter: p.chapter,
1233
- verse: p.verse,
1234
- versification: p.versification,
1235
- })
1236
-
1237
- if (firstChapter === null || chapter < firstChapter) {
1238
- firstChapter = chapter
1239
- firstVerse = verse
1240
- } else if (chapter === firstChapter && (firstVerse === null || verse < firstVerse)) {
1241
- firstVerse = verse
1242
- }
1243
- if (lastChapter === null || chapter > lastChapter) {
1244
- lastChapter = chapter
1245
- lastVerse = verse
1246
- } else if (chapter === lastChapter && (lastVerse === null || verse > lastVerse)) {
1247
- lastVerse = verse
1248
- }
1249
- })
1250
- })
1251
-
1252
- combined.passages = Array.from(new Set(combined.passages.map(JSON.stringify))).map(JSON.parse)
1253
-
1254
- const chapterStrings = []
1255
- const sortedChapters = Object.keys(chapterVerses)
1256
- .map(Number)
1257
- .sort((a, b) => a - b)
1258
-
1259
- sortedChapters.forEach((chapter) => {
1260
- const verses = Array.from(chapterVerses[chapter])
1261
- .map(Number)
1262
- .filter((verse) => verse > 0)
1263
- .sort((a, b) => a - b)
1264
- if (verses.length > 0) {
1265
- const mergedVerses = this.mergeRanges(verses)
1266
- chapterStrings.push(`${chapter}:${mergedVerses.join(",")}`)
1267
- if (chapter === firstChapter) {
1268
- combined.verses = mergedVerses
1269
- }
1270
- }
1271
- })
1272
-
1273
- if (chapterStrings.length === 0) {
1274
- throw new Error("No valid verses found in passages.")
1275
- }
1276
-
1277
- combined.chapter = firstChapter
1278
-
1279
- if (firstChapter !== lastChapter) {
1280
- combined.type = this.MULTI_CHAPTER_RANGE
1281
- combined.to = {
1282
- book: combined.book,
1283
- chapter: lastChapter,
1284
- verses: this.mergeRanges(
1285
- Array.from(chapterVerses[lastChapter])
1286
- .filter((verse) => verse > 0)
1287
- .sort((a, b) => a - b)
1288
- ),
1289
- }
1290
- combined.original = `${combined.book} ${chapterStrings.join("; ")}`
1291
- } else {
1292
- combined.type = combined.verses.length > 1 ? this.CHAPTER_VERSE_RANGE : this.CHAPTER_VERSE
1293
- combined.original = `${combined.book} ${chapterStrings[0]}`
1294
- }
1295
-
1296
- const chapterString = chapterStrings.join(";")
1297
- combined.scripture = {
1298
- passage: `${combined.book} ${chapterString}`,
1299
- cv: chapterString,
1300
- hash: `${combined.book.toLowerCase()}_${chapterString
1301
- .replace(/:/g, ".")
1302
- .replace(/-/g, ".")
1303
- .replace(/[,;]/g, ".")}`,
1304
- }
1305
-
1306
- combined.start = {
1307
- book: combined.book,
1308
- chapter: firstChapter,
1309
- verse:
1310
- firstVerse > 0
1311
- ? firstVerse
1312
- : Math.min(...Array.from(chapterVerses[firstChapter]).filter((verse) => verse > 0)),
1313
- }
1314
- combined.end = {
1315
- book: combined.book,
1316
- chapter: lastChapter,
1317
- verse:
1318
- lastVerse > 0
1319
- ? lastVerse
1320
- : Math.max(...Array.from(chapterVerses[lastChapter]).filter((verse) => verse > 0)),
1321
- }
1322
-
1323
- combined.reference = function () {
1324
- return this.scripture.passage
1325
- }
1326
-
1327
- if (combined.to === null) {
1328
- delete combined.to
1329
- }
1330
-
1331
- // Respect the common version
1332
- combined.version = passages[0].version
1333
-
1334
- // Set abbr with version suffix if not ENG
1335
- const sblEntry = Object.entries(this.sblAbbreviations).find(
1336
- ([key]) => key.toLowerCase() === combined.book.toLowerCase()
1337
- )
1338
- const suffix = combined.version.abbreviation === "eng" ? "" : ` ${combined.version.value}`
1339
- if (sblEntry) {
1340
- const { value, abbr } = sblEntry[1]
1341
- combined.abbr = abbr
1342
- ? `${value}. ${combined.scripture.cv}${suffix}`
1343
- : `${value} ${combined.scripture.cv}${suffix}`
1344
- } else {
1345
- combined.abbr = `${combined.book} ${combined.scripture.cv}${suffix}`
1346
- }
1347
-
1348
- const self = this
1349
- combined.getVersion = function (targetVersion) {
1350
- const cloned = JSON.parse(JSON.stringify(this))
1351
- const targetAbbr = targetVersion.toLowerCase() === "bhs" ? "mt" : targetVersion.toLowerCase()
1352
- let versionObj
1353
- if (targetAbbr === "eng") {
1354
- versionObj = { name: "English", value: "ENG", abbreviation: "eng" }
1355
- } else if (targetAbbr === "mt") {
1356
- versionObj = { name: "Masoretic Text", value: "MT", abbreviation: "mt" }
1357
- } else if (targetAbbr === "lxx") {
1358
- versionObj = { name: "Septuagint", value: "LXX", abbreviation: "lxx" }
1359
- } else {
1360
- throw new Error("Invalid version: must be one of 'eng', 'mt', 'bhs', 'lxx'")
1361
- }
1362
-
1363
- cloned.version = versionObj
1364
-
1365
- cloned.passages.forEach((sub) => {
1366
- if (sub.versification && sub.versification[targetAbbr]) {
1367
- const [ch, v] = sub.versification[targetAbbr].split(":").map(Number)
1368
- sub.chapter = ch
1369
- sub.verse = v
1370
- }
1371
- // else remain unchanged
1372
- })
1373
-
1374
- // Recompute summary fields
1375
- cloned.passages.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse)
1376
-
1377
- if (cloned.passages.length > 0) {
1378
- cloned.start = {
1379
- book: cloned.book,
1380
- chapter: cloned.passages[0].chapter,
1381
- verse: cloned.passages[0].verse,
1382
- }
1383
- cloned.end = {
1384
- book: cloned.book,
1385
- chapter: cloned.passages[cloned.passages.length - 1].chapter,
1386
- verse: cloned.passages[cloned.passages.length - 1].verse,
1387
- }
1388
- }
1389
-
1390
- const chapterVersesMap = {}
1391
- cloned.passages.forEach((p) => {
1392
- if (!chapterVersesMap[p.chapter]) chapterVersesMap[p.chapter] = new Set()
1393
- chapterVersesMap[p.chapter].add(p.verse)
1394
- })
1395
-
1396
- const sortedChs = Object.keys(chapterVersesMap)
1397
- .map(Number)
1398
- .sort((a, b) => a - b)
1399
- const chapterStrs = []
1400
-
1401
- const mergeFunc = (verses) => {
1402
- const sorted = [...verses].sort((a, b) => a - b)
1403
- const merged = []
1404
- if (sorted.length === 0) return merged
1405
- let start = sorted[0]
1406
- let end = sorted[0]
1407
- for (let i = 1; i < sorted.length; i++) {
1408
- if (sorted[i] === end + 1) {
1409
- end = sorted[i]
1410
- } else {
1411
- merged.push(start === end ? `${start}` : `${start}-${end}`)
1412
- start = end = sorted[i]
1413
- }
1414
- }
1415
- merged.push(start === end ? `${start}` : `${start}-${end}`)
1416
- return merged
1417
- }
1418
-
1419
- sortedChs.forEach((ch) => {
1420
- const vs = Array.from(chapterVersesMap[ch])
1421
- .filter((v) => v > 0)
1422
- .sort((a, b) => a - b)
1423
- if (vs.length > 0) {
1424
- const merged = mergeFunc(vs)
1425
- chapterStrs.push(`${ch}:${merged.join(",")}`)
1426
- }
1427
- })
1428
-
1429
- if (chapterStrs.length === 0) {
1430
- return cloned // no verses, perhaps error but return as is
1431
- }
1432
-
1433
- const firstCh = sortedChs[0]
1434
- const lastCh = sortedChs[sortedChs.length - 1]
1435
- cloned.chapter = firstCh
1436
-
1437
- const mergedFirst = mergeFunc(chapterVersesMap[firstCh] || new Set())
1438
- cloned.verses = mergedFirst
1439
-
1440
- if (firstCh !== lastCh) {
1441
- cloned.type = "multi_chapter_verse_range"
1442
- cloned.to = {
1443
- book: cloned.book,
1444
- chapter: lastCh,
1445
- verses: mergeFunc(chapterVersesMap[lastCh] || new Set()),
1446
- }
1447
- cloned.original = `${cloned.book} ${chapterStrs.join("; ")}`
1448
- } else {
1449
- const hasRangeOrMultiple =
1450
- mergedFirst.length > 1 || (mergedFirst.length === 1 && mergedFirst[0].includes("-"))
1451
- cloned.type = hasRangeOrMultiple ? "chapter_verse_range" : "chapter_verse"
1452
- if (cloned.to) delete cloned.to
1453
- cloned.original = `${cloned.book} ${chapterStrs[0]}`
1454
- }
1455
-
1456
- const chString = chapterStrs.join("; ")
1457
- cloned.scripture = {
1458
- passage: `${cloned.book} ${chString}`,
1459
- cv: chString,
1460
- hash: `${cloned.book.toLowerCase()}_${chString
1461
- .replace(/:/g, ".")
1462
- .replace(/-/g, ".")
1463
- .replace(/[,; ]/g, ".")}`,
1464
- }
1465
-
1466
- // Set abbr
1467
- const sblEntry = Object.entries(self.sblAbbreviations).find(
1468
- ([key]) => key.toLowerCase() === cloned.book.toLowerCase()
1469
- )
1470
- const suffix = versionObj.abbreviation === "eng" ? "" : ` ${versionObj.value}`
1471
- if (sblEntry) {
1472
- const { value, abbr } = sblEntry[1]
1473
- cloned.abbr = abbr
1474
- ? `${value}. ${cloned.scripture.cv}${suffix}`
1475
- : `${value} ${cloned.scripture.cv}${suffix}`
1476
- } else {
1477
- cloned.abbr = `${cloned.book} ${cloned.scripture.cv}${suffix}`
1478
- }
1479
-
1480
- cloned.getVersion = this.getVersion
1481
-
1482
- return cloned
1483
- }
1484
-
1485
- return combined
1486
- }
1487
-
1488
- /**
1489
- * Merges verses into ranges or comma-separated lists.
1490
- * @param {number[]} verses - Array of verse numbers.
1491
- * @returns {string[]} Array of verse strings.
1492
- */
1493
- mergeRanges(verses) {
1494
- const sortedVerses = [...new Set(verses)].sort((a, b) => a - b)
1495
- const merged = []
1496
- let start = sortedVerses[0]
1497
- let end = sortedVerses[0]
1498
-
1499
- for (let i = 1; i < sortedVerses.length; i++) {
1500
- if (sortedVerses[i] === end + 1) {
1501
- end = sortedVerses[i]
1502
- } else {
1503
- if (start === end) {
1504
- merged.push(`${start}`)
1505
- } else {
1506
- merged.push(`${start}-${end}`)
1507
- }
1508
- start = sortedVerses[i]
1509
- end = sortedVerses[i]
1510
- }
1511
- }
1512
-
1513
- if (start === end) {
1514
- merged.push(`${start}`)
1515
- } else {
1516
- merged.push(`${start}-${end}`)
1517
- }
1518
-
1519
- return merged
1520
- }
1521
-
1522
- /**
1523
- * Generates a table of contents for the Bible.
1524
- * @param {string} [version="ESV"] - The Bible version.
1525
- * @returns {Object} TOC with book-chapter-verse mappings.
1526
- */
1527
- getToc(version = "ESV") {
1528
- const toc = {}
1529
- this.bible.old.forEach((book) => {
1530
- if (this.chapterVerses[book]) {
1531
- toc[book] = this.chapterVerses[book]
1532
- }
1533
- })
1534
- this.bible.new.forEach((book) => {
1535
- if (this.chapterVerses[book]) {
1536
- toc[book] = this.chapterVerses[book]
1537
- }
1538
- })
1539
- this.singleChapterBook.forEach((item) => {
1540
- Object.keys(item).forEach((book) => {
1541
- if (!toc[book]) {
1542
- toc[book] = item[book]
1543
- }
1544
- })
1545
- })
1546
- const orderedToc = {}
1547
- const canonicalOrder = [...this.bible.old, ...this.bible.new]
1548
- canonicalOrder.forEach((book) => {
1549
- if (toc[book]) {
1550
- orderedToc[book] = toc[book]
1551
- }
1552
- })
1553
- return orderedToc
1554
- }
1555
-
1556
- /**
1557
- * Validates a passage for correctness.
1558
- * @param {Object} passage - The passage object.
1559
- * @param {string} reference - The original reference.
1560
- * @returns {boolean|Object} True if valid, error object if invalid.
1561
- * @private
1562
- */
1563
- _isValid(passage, reference) {
1564
- const { book, chapter, verses, type } = passage
1565
-
1566
- if (!verses.length && type !== this.SINGLE_CHAPTER) {
1567
- return this.validationError(101, `Possible invalid chapter: ${reference}`)
1568
- }
1569
-
1570
- const chapterVerses = this.getChapterVerses(book, chapter)
1571
- if (!chapterVerses.length) {
1572
- return this.validationError(102, `Chapter ${chapter} does not exist in ${book}`)
1573
- }
1574
-
1575
- if (type === this.SINGLE_CHAPTER) {
1576
- const [range] = verses
1577
- if (range) {
1578
- const [start, end] = range.split("-").map(Number)
1579
- if (start < 1 || end > chapterVerses[chapterVerses.length - 1]) {
1580
- return this.validationError(
1581
- 104,
1582
- `Verse range ${start}-${end} exceeds available verses (1-${
1583
- chapterVerses[chapterVerses.length - 1]
1584
- }) in ${book} ${chapter}`
1585
- )
1586
- }
1587
- }
1588
- return true
1589
- }
1590
-
1591
- return this.validateVerses(book, chapter, verses, reference)
1592
- }
1593
-
1594
- /**
1595
- * Validates verse numbers for a chapter.
1596
- * @param {string} book - The book name.
1597
- * @param {number} chapter - The chapter number.
1598
- * @param {Array<string|number>} verses - Array of verses or ranges.
1599
- * @param {string} reference - The original reference.
1600
- * @returns {boolean|Object} True if valid, error object if invalid.
1601
- * @private
1602
- */
1603
- validateVerses(book, chapter, verses, reference) {
1604
- const chapterVerses = this.getChapterVerses(book, chapter)
1605
- for (const verse of verses) {
1606
- const verseRange = String(verse)
1607
- const verseNumbers = verseRange.includes("-")
1608
- ? Array.from(
1609
- { length: Number(verseRange.split("-")[1]) - Number(verseRange.split("-")[0]) + 1 },
1610
- (_, i) => Number(verseRange.split("-")[0]) + i
1611
- )
1612
- : [Number(verseRange)]
1613
-
1614
- for (const v of verseNumbers) {
1615
- if (isNaN(v) || v <= 0 || !chapterVerses.includes(v)) {
1616
- return this.validationError(104, `Verse number ${v} does not exist in ${book} ${chapter}`)
1617
- }
1618
- }
1619
- }
1620
- return true
1621
- }
1622
-
1623
- /**
1624
- * Creates an error object for validation failures.
1625
- * @param {number} code - Error code.
1626
- * @param {string} message - Error message.
1627
- * @returns {Object} Error object.
1628
- * @private
1629
- */
1630
- validationError(code, message) {
1631
- return {
1632
- error: true,
1633
- code,
1634
- message: { verse_exists: code === 104, chapter_exists: code !== 104, content: message },
1635
- }
1636
- }
1637
-
1638
- /**
1639
- * Determines the Bible version for a passage.
1640
- * @param {string} version - The version (e.g., "lxx").
1641
- * @param {string} testament - The testament ("old" or "new").
1642
- * @returns {Object} Version object.
1643
- * @private
1644
- */
1645
- _handleVersion(version, testament) {
1646
- const effectiveVersion = this.version || version || "eng"
1647
- const lowerVersion = effectiveVersion.toLowerCase()
1648
-
1649
- if (lowerVersion === "lxx" && testament === "old") {
1650
- return { name: "Septuagint", value: "LXX", abbreviation: "lxx" }
1651
- }
1652
- if (lowerVersion === "mt" && testament === "old") {
1653
- return { name: "Masoretic Text", value: "MT", abbreviation: "mt" }
1654
- }
1655
- return { name: "English", value: "ENG", abbreviation: "eng" }
1656
- }
1657
-
1658
- /**
1659
- * Replaces scripture references in text with formatted references.
1660
- * @param {string} text - The original text.
1661
- * @param {boolean} useAbbreviations - Whether to use abbreviated book names.
1662
- * @returns {string} Text with replaced references.
1663
- */
1664
- replace(text, useAbbreviations = true) {
1665
- if (!this.passages.length) {
1666
- return text
1667
- }
1668
-
1669
- let result = text
1670
- for (let i = this.passages.length - 1; i >= 0; i--) {
1671
- const passage = this.passages[i]
1672
- const { originalText, abbr, original } = passage
1673
- const newReference = useAbbreviations ? abbr : original
1674
-
1675
- const regex = new RegExp(`${originalText.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")}`, "g")
1676
-
1677
- const matches = [...result.matchAll(regex)]
1678
- if (matches.length > 0) {
1679
- for (let j = matches.length - 1; j >= 0; j--) {
1680
- const match = matches[j]
1681
- const startIndex = match.index
1682
- const endIndex = startIndex + match[0].length
1683
- const leadingSpace = match[1] || ""
1684
- const hasOpeningParen = match[2] === "("
1685
- const hasClosingParen = match[3] === ")"
1686
- const trailingSpace = match[4] || " "
1687
- const replacement =
1688
- hasOpeningParen && hasClosingParen
1689
- ? `${leadingSpace}(${newReference})${trailingSpace}`
1690
- : `${leadingSpace}${newReference}${trailingSpace}`
1691
- result = result.slice(0, startIndex) + replacement + result.slice(endIndex)
1692
- }
1693
- }
1694
- }
1695
-
1696
- return result
1697
- }
1698
-
1699
- /**
1700
- * Checks if all references in the passages array are from the same book.
1701
- * @returns {boolean} True if all passages are from the same book, false otherwise.
1702
- */
1703
- same() {
1704
- if (this.passages.length <= 1) {
1705
- return true
1706
- }
1707
-
1708
- const firstBook = this.passages[0].book.toLowerCase()
1709
- return this.passages.every((passage) => passage.book.toLowerCase() === firstBook)
1710
- }
1711
- }
1712
-
1713
- module.exports = CodexParser