-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscripture-parser.js
More file actions
216 lines (198 loc) · 9.03 KB
/
Copy pathscripture-parser.js
File metadata and controls
216 lines (198 loc) · 9.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
// scripture-parser.js
//
// Turns a chunk of live-transcribed speech into a structured Bible
// reference ({ book, chapter, verse }) if one is present.
//
// This is a best-effort parser for natural speech, not a perfect one —
// pastors phrase references in a lot of different ways. Expect to tune
// BOOKS aliases and the number-word handling as you use it in real
// services. It handles:
// "John 3:16" (digits, colon)
// "John 3 16" (digits, no colon)
// "John chapter 3 verse 16" (keywords)
// "John three sixteen" (spoken numbers)
// "First Corinthians chapter 13 verse 4"
// "Psalm 23" / "Psalm twenty three" (chapter-only reference)
const ONES = {
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7,
eight: 8, nine: 9,
// Common Chrome speech-recognition mishearing: "three" often comes out
// as "free". Tolerated here rather than in the general transcript
// because it's specific to a number-word context (right after a book
// name), keeping the risk of misreading ordinary sentences low.
free: 3,
};
const TEENS = {
ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15,
sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19,
};
const TENS = {
twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70,
eighty: 80, ninety: 90,
};
const NUMBER_WORDS = { ...ONES, ...TEENS, ...TENS };
// Converts a run of spoken number-words in a string into digits.
// "twenty eight" -> "28". "one hundred nineteen" -> "119".
// "three sixteen" -> "3 16" (two separate numbers, NOT combined) because
// "three" isn't a tens-word, so it can't absorb the following number.
function wordsToNumbers(text) {
const tokens = text.split(/\s+/);
const out = [];
let i = 0;
while (i < tokens.length) {
const word = tokens[i].replace(/[.,]/g, "");
if (word in TENS) {
let value = TENS[word];
if (i + 1 < tokens.length && tokens[i + 1] in ONES) {
value += ONES[tokens[i + 1]];
i++;
}
out.push(String(value));
i++;
continue;
}
if (word in ONES || word in TEENS) {
let value = NUMBER_WORDS[word];
// "one hundred nineteen" style
if (i + 1 < tokens.length && tokens[i + 1] === "hundred") {
value *= 100;
i += 2;
if (i < tokens.length && tokens[i] in TENS) {
let rest = TENS[tokens[i]];
i++;
if (i < tokens.length && tokens[i] in ONES) {
rest += ONES[tokens[i]];
i++;
}
value += rest;
} else if (i < tokens.length && (tokens[i] in ONES || tokens[i] in TEENS)) {
value += NUMBER_WORDS[tokens[i]];
i++;
}
out.push(String(value));
continue;
}
out.push(String(value));
i++;
continue;
}
out.push(tokens[i]);
i++;
}
return out.join(" ");
}
// Canonical book list. `aliases` should all be lowercase.
// `apiName` is how bible-api.com expects the book in its URL.
const BOOKS = [
{ apiName: "genesis", aliases: ["genesis"] },
{ apiName: "exodus", aliases: ["exodus"] },
{ apiName: "leviticus", aliases: ["leviticus"] },
{ apiName: "numbers", aliases: ["numbers"] },
{ apiName: "deuteronomy", aliases: ["deuteronomy"] },
{ apiName: "joshua", aliases: ["joshua"] },
{ apiName: "judges", aliases: ["judges"] },
{ apiName: "ruth", aliases: ["ruth"] },
{ apiName: "1samuel", aliases: ["1 samuel", "first samuel", "i samuel"] },
{ apiName: "2samuel", aliases: ["2 samuel", "second samuel", "ii samuel"] },
{ apiName: "1kings", aliases: ["1 kings", "first kings", "i kings"] },
{ apiName: "2kings", aliases: ["2 kings", "second kings", "ii kings"] },
{ apiName: "1chronicles", aliases: ["1 chronicles", "first chronicles", "i chronicles"] },
{ apiName: "2chronicles", aliases: ["2 chronicles", "second chronicles", "ii chronicles"] },
{ apiName: "ezra", aliases: ["ezra"] },
{ apiName: "nehemiah", aliases: ["nehemiah"] },
{ apiName: "esther", aliases: ["esther"] },
{ apiName: "job", aliases: ["job"] },
{ apiName: "psalm", aliases: ["psalm", "psalms"] },
{ apiName: "proverbs", aliases: ["proverbs"] },
{ apiName: "ecclesiastes", aliases: ["ecclesiastes"] },
{ apiName: "songofsolomon", aliases: ["song of solomon", "song of songs"] },
{ apiName: "isaiah", aliases: ["isaiah"] },
{ apiName: "jeremiah", aliases: ["jeremiah"] },
{ apiName: "lamentations", aliases: ["lamentations"] },
{ apiName: "ezekiel", aliases: ["ezekiel"] },
{ apiName: "daniel", aliases: ["daniel"] },
{ apiName: "hosea", aliases: ["hosea"] },
{ apiName: "joel", aliases: ["joel"] },
{ apiName: "amos", aliases: ["amos"] },
{ apiName: "obadiah", aliases: ["obadiah"] },
{ apiName: "jonah", aliases: ["jonah"] },
{ apiName: "micah", aliases: ["micah"] },
{ apiName: "nahum", aliases: ["nahum"] },
{ apiName: "habakkuk", aliases: ["habakkuk"] },
{ apiName: "zephaniah", aliases: ["zephaniah"] },
{ apiName: "haggai", aliases: ["haggai"] },
{ apiName: "zechariah", aliases: ["zechariah"] },
{ apiName: "malachi", aliases: ["malachi"] },
{ apiName: "matthew", aliases: ["matthew"] },
{ apiName: "mark", aliases: ["mark"] },
{ apiName: "luke", aliases: ["luke"] },
{ apiName: "john", aliases: ["john"] }, // matched after 1/2/3 john below
{ apiName: "acts", aliases: ["acts"] },
{ apiName: "romans", aliases: ["romans"] },
{ apiName: "1corinthians", aliases: ["1 corinthians", "first corinthians", "i corinthians"] },
{ apiName: "2corinthians", aliases: ["2 corinthians", "second corinthians", "ii corinthians"] },
{ apiName: "galatians", aliases: ["galatians"] },
{ apiName: "ephesians", aliases: ["ephesians"] },
{ apiName: "philippians", aliases: ["philippians"] },
{ apiName: "colossians", aliases: ["colossians"] },
{ apiName: "1thessalonians", aliases: ["1 thessalonians", "first thessalonians", "i thessalonians"] },
{ apiName: "2thessalonians", aliases: ["2 thessalonians", "second thessalonians", "ii thessalonians"] },
{ apiName: "1timothy", aliases: ["1 timothy", "first timothy", "i timothy"] },
{ apiName: "2timothy", aliases: ["2 timothy", "second timothy", "ii timothy"] },
{ apiName: "titus", aliases: ["titus"] },
{ apiName: "philemon", aliases: ["philemon"] },
{ apiName: "hebrews", aliases: ["hebrews"] },
{ apiName: "james", aliases: ["james"] },
{ apiName: "1peter", aliases: ["1 peter", "first peter", "i peter"] },
{ apiName: "2peter", aliases: ["2 peter", "second peter", "ii peter"] },
{ apiName: "1john", aliases: ["1 john", "first john", "i john"] },
{ apiName: "2john", aliases: ["2 john", "second john", "ii john"] },
{ apiName: "3john", aliases: ["3 john", "third john", "iii john"] },
{ apiName: "jude", aliases: ["jude"] },
{ apiName: "revelation", aliases: ["revelation", "revelations"] },
];
// Sort aliases longest-first so "first corinthians" matches before "corinthians"
// would even be considered, and multi-word aliases win over shorter ones.
const ALL_ALIASES = BOOKS
.flatMap((b) => b.aliases.map((a) => ({ alias: a, book: b })))
.sort((a, b) => b.alias.length - a.alias.length);
// Finds the first scripture reference in a transcript chunk.
// Returns { book, apiName, chapter, verse } or null.
function findReference(rawText) {
const normalized = wordsToNumbers(
rawText.toLowerCase().replace(/[.,!?]/g, " ").replace(/\s+/g, " ").trim()
);
for (const { alias, book } of ALL_ALIASES) {
const idx = normalized.indexOf(alias);
if (idx === -1) continue;
const after = normalized.slice(idx + alias.length).trim();
// "chapter 3 verse 16"
let m = after.match(/^chapter\s+(\d+)\s+verse\s+(\d+)/);
if (m) return { book: alias, apiName: book.apiName, chapter: m[1], verse: m[2] };
// "3:16" or "3 : 16"
m = after.match(/^(\d+)\s*:\s*(\d+)/);
if (m) return { book: alias, apiName: book.apiName, chapter: m[1], verse: m[2] };
// "3 16" (two bare numbers back to back = chapter, verse)
m = after.match(/^(\d+)\s+(\d+)/);
if (m) return { book: alias, apiName: book.apiName, chapter: m[1], verse: m[2] };
// "316" or "1116" — STT sometimes drops the colon entirely, running
// chapter and verse into one number. Heuristic: a 3-digit cluster
// splits as 1-digit chapter + 2-digit verse ("316" -> 3:16); a 4-digit
// cluster splits as 2-digit chapter + 2-digit verse ("1116" -> 11:16).
// Ambiguous by nature (no way to know the "real" split from digits
// alone) but covers the common case correctly, and is far better than
// treating the whole cluster as one enormous chapter number.
m = after.match(/^(\d{3,4})\b/);
if (m) {
const digits = m[1];
const splitPoint = digits.length === 3 ? 1 : 2;
const chapter = digits.slice(0, splitPoint);
const verse = digits.slice(splitPoint);
return { book: alias, apiName: book.apiName, chapter, verse };
}
// "23" (chapter only, e.g. reading a whole Psalm)
m = after.match(/^(\d+)\b/);
if (m) return { book: alias, apiName: book.apiName, chapter: m[1], verse: null };
}
return null;
}