Files
libre-study/src/studyClient.ts
T

1239 lines
38 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core'
import JSZip from 'jszip'
import { buildVerseSearchIndex } from './searchIndex'
import type { StudyDataClient, TranslationNotesByVerse } from './studyDataClient'
import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry,
BiblePlace,
BibleRoute,
DictionaryEntry,
Footnote,
LibraryState,
MapContext,
StrongLink,
Tag,
TranslationResource,
Verse,
} from './types'
type TauriWindow = Window &
typeof globalThis & {
__TAURI_INTERNALS__?: unknown
}
const browserTagKey = 'libre-study-browser-tags'
const libreBibleDevRoot = import.meta.env.VITE_LIBREBIBLE_BASE_URL ?? '/@fs/W:/libre-bible-data/packages/json'
const defaultTranslationId = 'kjv-eng-kjv2006'
type ImportedVerse = {
id: string
translation_id: string
book: string
chapter: number
verse: number
text: string
}
type ImportedStrongLink = {
verse_id: string
book_id?: string
chapter?: number
position: number
surface: string
strong: string
}
type ImportedFootnote = {
id: number
verse_id: string
note_index: number
note_type?: string
label?: string
caller: string
reference: string
text: string
}
type ImportedCatalog = {
resources: Array<{
id: string
resource_type: string
title: string
short_title?: string
abbreviation?: string
license?: {
name?: string
attribution?: string
restricted_notes?: string
}
}>
}
type ImportedAudioChapterPlaylist = {
id: string
resource_id: string
translation_id: string
book_id: string
book: string
chapter: number
reference: string
audio_zip_file: string
audio_zip_url: string
segment_ids: string[]
audio_entry_paths: string[]
segment_count: number
}
type ImportedStrongEntry = {
strongs_number: string
language: 'hebrew' | 'greek'
lemma?: string
transliteration?: string
pronunciation?: string
strongs_definition?: string
kjv_definition?: string
}
type ImportedStrongIndexEntry = {
strong: string
verse_ids: string[]
surfaces: string[]
}
type ImportedCommentaryEntry = {
id: string
resource_id: string
source: string
book: string
chapter: number
verse_start: number
verse_end: number
reference: string
title: string
text: string
}
type ImportedDictionaryEntry = {
id: string
resource_id: string
term: string
slug: string
definitions: Array<{
source: string
source_name: string
text: string
}>
scripture_refs: Array<{
reference: string
original: string
}>
sources: string[]
}
type ImportedVersePlace = {
verse_id: string
readable: string
place_id: string
place_name: string
place_types: string[]
modern_location_id: string
modern_location_name: string
lon: number | null
lat: number | null
confidence: number | null
confidence_label: string
}
type ImportedBibleRoute = {
id: string
route_number: number
title: string
section_number: string
section_title: string
map_number: string
geojson_file: string
bounds: {
min_lon: number
min_lat: number
max_lon: number
max_lat: number
} | null
}
type BrowserLexiconEntry = {
lemma: string
transliteration: string
definition: string
language: 'Hebrew' | 'Greek'
}
type BrowserStrongData = {
linksByVerse: Map<string, StrongLink[]>
}
let browserCatalogCache: Promise<ImportedCatalog> | null = null
const browserLibraryCache = new Map<string, Promise<Verse[]>>()
const browserStrongDataCache = new Map<string, Promise<BrowserStrongData>>()
const browserChapterStrongDataCache = new Map<string, Promise<BrowserStrongData>>()
const browserStrongSearchIndexCache = new Map<string, Promise<Map<string, string[]>>>()
let browserLexiconCache: Promise<Map<string, BrowserLexiconEntry>> | null = null
const browserFootnotesCache = new Map<string, Promise<Map<string, Footnote[]>>>()
const browserStudyNotesCache = new Map<string, Promise<Map<string, Footnote[]>>>()
const browserChapterFootnotesCache = new Map<string, Promise<Map<string, Footnote[]>>>()
const browserChapterStudyNotesCache = new Map<string, Promise<Map<string, Footnote[]>>>()
const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | null = null
let browserVersePlacesCache: Promise<Map<string, BiblePlace[]>> | null = null
let browserRoutesCache: Promise<BibleRoute[]> | null = null
let browserAudioPlaylistCache: Promise<Map<string, ImportedAudioChapterPlaylist>> | null = null
const browserVerseSourceIds = new Map<number, string>()
const browserVerseIdsBySource = new Map<string, number>()
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
const fallbackBrowserVerses: Verse[] = [
{
id: 1,
book: 'Genesis',
chapter: 1,
verse: 1,
text: 'In the beginning God created the heaven and the earth.',
tags: [],
footnotes: [],
strongs: [
{
strong: 'H7225',
surface: 'beginning',
position: 1,
lemma: 'רֵאשִׁית',
transliteration: 'reshith',
definition: 'Beginning, first, chief, the first part.',
language: 'Hebrew',
},
{
strong: 'H430',
surface: 'God',
position: 2,
lemma: 'אֱלֹהִים',
transliteration: 'elohim',
definition: 'God, gods, judges, rulers; plural form often used of the one true God.',
language: 'Hebrew',
},
],
},
{
id: 2,
book: 'Psalm',
chapter: 23,
verse: 1,
text: 'The LORD is my shepherd; I shall not want.',
tags: [],
footnotes: [],
strongs: [
{
strong: 'H3068',
surface: 'LORD',
position: 1,
lemma: 'יְהוָֹה',
transliteration: 'YHWH',
definition: 'The covenant name of the LORD.',
language: 'Hebrew',
},
{
strong: 'H7462',
surface: 'shepherd',
position: 2,
lemma: 'רָעָה',
transliteration: 'raah',
definition: 'To shepherd, pasture, feed, tend a flock.',
language: 'Hebrew',
},
],
},
{
id: 3,
book: 'John',
chapter: 3,
verse: 16,
text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.',
tags: [],
footnotes: [],
strongs: [
{
strong: 'G25',
surface: 'loved',
position: 1,
lemma: 'ἀγαπάω',
transliteration: 'agapao',
definition: 'To love, value, welcome, or take pleasure in.',
language: 'Greek',
},
{
strong: 'G4100',
surface: 'believeth',
position: 3,
lemma: 'πιστεύω',
transliteration: 'pisteuo',
definition: 'To believe, trust, have faith in.',
language: 'Greek',
},
],
},
{
id: 4,
book: 'Romans',
chapter: 8,
verse: 28,
text: 'And we know that all things work together for good to them that love God, to them who are the called according to his purpose.',
tags: [],
footnotes: [],
strongs: [
{
strong: 'G2822',
surface: 'called',
position: 2,
lemma: 'κλητός',
transliteration: 'kletos',
definition: 'Called, invited, appointed.',
language: 'Greek',
},
{
strong: 'G4286',
surface: 'purpose',
position: 3,
lemma: 'πρόθεσις',
transliteration: 'prothesis',
definition: 'Purpose, setting forth, deliberate plan.',
language: 'Greek',
},
],
},
]
function inTauri() {
return Boolean((window as TauriWindow).__TAURI_INTERNALS__)
}
function readBrowserTags(): Record<number, Tag[]> {
const raw = localStorage.getItem(browserTagKey)
if (!raw) return {}
try {
return JSON.parse(raw) as Record<number, Tag[]>
} catch {
return {}
}
}
function writeBrowserTags(tagsByVerse: Record<number, Tag[]>) {
localStorage.setItem(browserTagKey, JSON.stringify(tagsByVerse))
}
async function loadBrowserVersesForTranslation(translationId = defaultTranslationId) {
if (!browserLibraryCache.has(translationId)) {
browserLibraryCache.set(translationId, loadLibreBibleDevVerses(translationId).catch((error) => {
console.warn('Falling back to browser seed data.', error)
return fallbackBrowserVerses
}))
}
const verses = await browserLibraryCache.get(translationId)!
const tagsByVerse = readBrowserTags()
return verses.map((verse) => ({
...verse,
footnotes: verse.footnotes ?? [],
studyNotes: verse.studyNotes ?? [],
tags: tagsByVerse[verse.id] ?? [],
}))
}
async function hydrateBrowserVerses(translationId: string, verses: Verse[]) {
const chapterKeys = [
...new Set(
verses
.map((verse) => `${bookIdFromVerse(verse)}.${verse.chapter}`)
.filter((key) => !key.startsWith('unknown.')),
),
]
const strongDataMaps = await Promise.all(
chapterKeys.map((chapterKey) =>
loadBrowserChapterStrongData(translationId, chapterKey).catch((error) => {
console.warn(`Unable to load browser Strong data for ${chapterKey}.`, error)
return { linksByVerse: new Map<string, StrongLink[]>() }
}),
),
)
const tagsByVerse = readBrowserTags()
return verses.map((verse) => {
const verseKey = browserVerseKey(verse)
const strongs =
strongDataMaps.find((data) => data.linksByVerse.has(verseKey))?.linksByVerse.get(verseKey) ??
verse.strongs
return {
...verse,
strongs,
footnotes: verse.footnotes ?? [],
studyNotes: verse.studyNotes ?? [],
tags: tagsByVerse[verse.id] ?? [],
}
})
}
function allBrowserTags() {
const unique = new Map<string, Tag>()
for (const tags of Object.values(readBrowserTags())) {
for (const tag of tags) {
unique.set(tag.label.toLowerCase(), tag)
}
}
return [...unique.values()].sort((a, b) => a.label.localeCompare(b.label))
}
async function getBrowserTranslations(): Promise<TranslationResource[]> {
const catalog = await loadBrowserCatalog()
return catalog.resources
.filter((resource) => resource.resource_type === 'translation')
.map((resource) => ({
id: resource.id,
title: resource.title,
shortTitle: resource.short_title ?? resource.title,
abbreviation: resource.abbreviation ?? resource.short_title ?? resource.title,
licenseName: resource.license?.name ?? '',
attribution: resource.license?.attribution ?? '',
restrictions: resource.license?.restricted_notes ?? '',
}))
}
async function getBrowserLibraryState(translationId = defaultTranslationId): Promise<LibraryState> {
return {
verses: await loadBrowserVersesForTranslation(translationId),
tags: allBrowserTags(),
}
}
async function getBrowserPageVerses(
translationId: string,
pageStartId: number,
pageSize: number,
): Promise<Verse[]> {
const verses = await loadBrowserVersesForTranslation(translationId)
const startIndex = verses.findIndex((verse) => verse.id === pageStartId)
if (startIndex < 0) return []
const book = verses[startIndex].book
const pageVerses = verses
.slice(startIndex, startIndex + pageSize)
.filter((verse) => verse.book === book)
return hydrateBrowserVerses(translationId, pageVerses)
}
async function getBrowserTranslationNotesForVerses(
translationId: string,
verses: Verse[],
): Promise<TranslationNotesByVerse> {
if (verses.length === 0) return new Map()
const chapterKeys = [
...new Set(
verses
.map((verse) => `${bookIdFromVerse(verse)}.${verse.chapter}`)
.filter((key) => !key.startsWith('unknown.')),
),
]
const [footnoteMaps, studyNoteMaps] = await Promise.all([
Promise.all(chapterKeys.map((key) => loadBrowserChapterNotes(translationId, key, 'footnotes'))),
Promise.all(chapterKeys.map((key) => loadBrowserChapterNotes(translationId, key, 'study-notes'))),
])
const notesByVerse = new Map<string, { footnotes: Footnote[]; studyNotes: Footnote[] }>()
for (const verse of verses) {
const verseKey = browserVerseKey(verse)
notesByVerse.set(String(verse.id), {
footnotes: footnoteMaps.flatMap((map) => map.get(verseKey) ?? []),
studyNotes: studyNoteMaps.flatMap((map) => map.get(verseKey) ?? []),
})
}
return notesByVerse
}
async function searchBrowserVerses(query: string, translationId = defaultTranslationId): Promise<Verse[]> {
if (/^[gh]\d+$/i.test(query.trim())) {
const verses = await loadBrowserVersesForTranslation(translationId)
const strongIndex = await loadBrowserStrongSearchIndex(translationId)
const sourceIds = strongIndex.get(query.trim().toUpperCase()) ?? []
const verseIds = new Set(sourceIds.map((sourceId) => browserVerseIdsBySource.get(sourceId)))
return verses.filter((verse) => verseIds.has(verse.id))
}
const searchIndex = await loadBrowserSearchIndex(translationId)
return searchIndex.search(query)
}
async function addBrowserTagToVerse(
verseId: number,
label: string,
color: string,
translationId = defaultTranslationId,
) {
const tagsByVerse = readBrowserTags()
const current = tagsByVerse[verseId] ?? []
const existing = current.find((tag) => tag.label.toLowerCase() === label.toLowerCase())
const tag = existing ?? { id: Date.now(), label, color }
tagsByVerse[verseId] = existing ? current : [...current, tag]
writeBrowserTags(tagsByVerse)
browserSearchIndexCache.delete(translationId)
const verse = (await loadBrowserVersesForTranslation(translationId)).find((item) => item.id === verseId)
if (!verse) throw new Error(`Verse ${verseId} was not found.`)
return verse
}
async function getBrowserCommentaryForVerse(verse: Verse): Promise<CommentaryEntry[]> {
const commentary = await loadBrowserCommentary()
const chapterEntries = commentary.get(commentaryKey(verse.book, verse.chapter)) ?? []
return chapterEntries.filter(
(entry) => verse.verse >= entry.verseStart && verse.verse <= entry.verseEnd,
)
}
async function getBrowserDictionaryEntriesForTerms(terms: string[]): Promise<DictionaryEntry[]> {
const dictionary = await loadBrowserDictionary()
const seen = new Set<string>()
const entries: DictionaryEntry[] = []
for (const term of terms) {
const normalized = normalizeTerm(term)
if (!normalized || seen.has(normalized)) continue
const entry = dictionary.get(normalized)
if (!entry) continue
seen.add(normalized)
entries.push(entry)
}
return entries
}
async function getBrowserMapContextForVerses(
selectedVerse: Verse,
pageVerses: Verse[],
): Promise<MapContext> {
const versePlaces = await loadBrowserVersePlaces()
const selectedVerseId = browserVerseKey(selectedVerse)
const pageVerseIds = new Set(pageVerses.map((verse) => browserVerseKey(verse)).filter(Boolean))
const selectedPlaces = uniquePlaces(versePlaces.get(selectedVerseId) ?? [])
const pagePlaces = uniquePlaces(
[...pageVerseIds].flatMap((verseId) => versePlaces.get(verseId) ?? []),
)
const relatedRoutes = await relatedRoutesForPlaces(pagePlaces)
return {
selectedPlaces,
pagePlaces,
relatedRoutes,
}
}
async function getBrowserAudioContextForVerse(
translationId: string,
verse: Verse,
): Promise<AudioBibleContext | null> {
const catalog = await loadBrowserCatalog()
const resource = catalog.resources.find(
(item) =>
item.resource_type === 'audio_bible' &&
item.id === 'kjv-audio-eliranwong' &&
translationId === defaultTranslationId,
)
if (!resource) return null
const verseId = browserVerseKey(verse)
const [bookId, chapter] = verseId.split('.')
if (!bookId || !chapter) return null
const playlists = await loadBrowserAudioPlaylists()
const playlist = playlists.get(`${bookId}.${chapter}`) ?? null
const selectedIndex = playlist?.segment_ids.findIndex((id) => id.endsWith(`:${verseId}`)) ?? -1
const licenseName = resource.license?.name ?? ''
const attribution = resource.license?.attribution ?? ''
return {
resourceId: resource.id,
title: resource.title,
shortTitle: resource.short_title ?? resource.title,
licenseName,
attribution,
selectedSegment:
playlist && selectedIndex >= 0
? mapAudioSegment(playlist, selectedIndex, licenseName, attribution)
: null,
chapterPlaylist: playlist
? {
id: playlist.id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
book: playlist.book,
chapter: playlist.chapter,
reference: playlist.reference,
segments: playlist.segment_ids.map((_, index) =>
mapAudioSegment(playlist, index, licenseName, attribution),
),
}
: null,
}
}
async function getBrowserAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
const cacheKey = `${segment.audioZipUrl}#${segment.audioEntryPath}`
if (!browserAudioObjectUrlCache.has(cacheKey)) {
browserAudioObjectUrlCache.set(
cacheKey,
loadAudioZip(segment.audioZipUrl).then(async (zip) => {
const file = zip.file(segment.audioEntryPath)
if (!file) {
throw new Error(`Audio entry not found in ${segment.audioZipFile}: ${segment.audioEntryPath}`)
}
const blob = await file.async('blob')
return URL.createObjectURL(blob)
}),
)
}
return browserAudioObjectUrlCache.get(cacheKey)!
}
const emptyMapContext: MapContext = {
selectedPlaces: [],
pagePlaces: [],
relatedRoutes: [],
}
const browserStudyDataClient: StudyDataClient = {
getTranslations: getBrowserTranslations,
getLibraryState: getBrowserLibraryState,
getPageVerses: getBrowserPageVerses,
getTranslationNotesForVerses: getBrowserTranslationNotesForVerses,
searchVerses: searchBrowserVerses,
addTagToVerse: addBrowserTagToVerse,
getCommentaryForVerse: getBrowserCommentaryForVerse,
getDictionaryEntriesForTerms: getBrowserDictionaryEntriesForTerms,
getMapContextForVerses: getBrowserMapContextForVerses,
getAudioContextForVerse: getBrowserAudioContextForVerse,
getAudioObjectUrl: getBrowserAudioObjectUrl,
}
const tauriStudyDataClient: StudyDataClient = {
getTranslations: async () => [],
getLibraryState: () => invoke<LibraryState>('get_library_state'),
getPageVerses: async (_translationId, pageStartId, pageSize) => {
const state = await invoke<LibraryState>('get_library_state')
const startIndex = state.verses.findIndex((verse) => verse.id === pageStartId)
if (startIndex < 0) return []
const book = state.verses[startIndex].book
return state.verses.slice(startIndex, startIndex + pageSize).filter((verse) => verse.book === book)
},
getTranslationNotesForVerses: async () => new Map(),
searchVerses: (query) => invoke<Verse[]>('search_verses', { query }),
addTagToVerse: (verseId, label, color) => invoke<Verse>('add_tag_to_verse', { verseId, label, color }),
getCommentaryForVerse: async () => [],
getDictionaryEntriesForTerms: async () => [],
getMapContextForVerses: async () => emptyMapContext,
getAudioContextForVerse: async () => null,
getAudioObjectUrl: async () => {
throw new Error('Audio object URLs are not available in the Tauri data adapter yet.')
},
}
export function getStudyDataClient(): StudyDataClient {
return inTauri() ? tauriStudyDataClient : browserStudyDataClient
}
export function getTranslations(): Promise<TranslationResource[]> {
return getStudyDataClient().getTranslations()
}
export function getLibraryState(translationId = defaultTranslationId): Promise<LibraryState> {
return getStudyDataClient().getLibraryState(translationId)
}
export function getPageVerses(
translationId: string,
pageStartId: number,
pageSize: number,
): Promise<Verse[]> {
return getStudyDataClient().getPageVerses(translationId, pageStartId, pageSize)
}
export function getTranslationNotesForVerses(
translationId: string,
verses: Verse[],
): Promise<TranslationNotesByVerse> {
return getStudyDataClient().getTranslationNotesForVerses(translationId, verses)
}
export function searchVerses(query: string, translationId = defaultTranslationId): Promise<Verse[]> {
return getStudyDataClient().searchVerses(query, translationId)
}
export function addTagToVerse(
verseId: number,
label: string,
color: string,
translationId = defaultTranslationId,
) {
return getStudyDataClient().addTagToVerse(verseId, label, color, translationId)
}
export function getCommentaryForVerse(verse: Verse): Promise<CommentaryEntry[]> {
return getStudyDataClient().getCommentaryForVerse(verse)
}
export function getDictionaryEntriesForTerms(terms: string[]): Promise<DictionaryEntry[]> {
return getStudyDataClient().getDictionaryEntriesForTerms(terms)
}
export function getMapContextForVerses(selectedVerse: Verse, pageVerses: Verse[]): Promise<MapContext> {
return getStudyDataClient().getMapContextForVerses(selectedVerse, pageVerses)
}
export function getAudioContextForVerse(
translationId: string,
verse: Verse,
): Promise<AudioBibleContext | null> {
return getStudyDataClient().getAudioContextForVerse(translationId, verse)
}
export function getAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
return getStudyDataClient().getAudioObjectUrl(segment)
}
async function loadBrowserCatalog(): Promise<ImportedCatalog> {
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
return browserCatalogCache
}
async function loadBrowserSearchIndex(translationId = defaultTranslationId) {
if (!browserSearchIndexCache.has(translationId)) {
browserSearchIndexCache.set(
translationId,
loadBrowserVersesForTranslation(translationId).then((verses) => buildVerseSearchIndex(verses)),
)
}
return browserSearchIndexCache.get(translationId)!
}
async function loadBrowserStrongSearchIndex(translationId = defaultTranslationId) {
if (!browserStrongSearchIndexCache.has(translationId)) {
browserStrongSearchIndexCache.set(
translationId,
fetchJsonl<ImportedStrongIndexEntry>(`${libreBibleDevRoot}/${translationId}/strongs-index.jsonl`)
.then((entries) => new Map(entries.map((entry) => [entry.strong.toUpperCase(), entry.verse_ids])))
.catch(async () => {
const strongData = await loadBrowserStrongData(translationId)
const index = new Map<string, string[]>()
for (const [verseId, links] of strongData.linksByVerse) {
for (const link of links) {
const key = link.strong.toUpperCase()
const current = index.get(key) ?? []
if (!current.includes(verseId)) current.push(verseId)
index.set(key, current)
}
}
return index
}),
)
}
return browserStrongSearchIndexCache.get(translationId)!
}
async function loadLibreBibleDevVerses(translationId = defaultTranslationId): Promise<Verse[]> {
const importedVerses = await fetchJsonl<ImportedVerse>(
`${libreBibleDevRoot}/${translationId}/verses.jsonl`,
)
browserVerseSourceIds.clear()
browserVerseIdsBySource.clear()
return importedVerses.map((verse, index) => {
const id = index + 1
browserVerseSourceIds.set(id, verse.id)
browserVerseIdsBySource.set(verse.id, id)
return {
id,
book: verse.book,
chapter: verse.chapter,
verse: verse.verse,
text: verse.text,
strongs: [],
footnotes: [],
studyNotes: [],
tags: [],
}
})
}
async function loadBrowserCommentary(): Promise<Map<string, CommentaryEntry[]>> {
browserCommentaryCache ??= loadLibreBibleDevCommentary()
return browserCommentaryCache
}
async function loadLibreBibleDevCommentary(): Promise<Map<string, CommentaryEntry[]>> {
const importedEntries = await fetchJsonl<ImportedCommentaryEntry>(
`${libreBibleDevRoot}/matthew-henry-concise-ccel/commentary.jsonl`,
)
const commentary = new Map<string, CommentaryEntry[]>()
for (const entry of importedEntries) {
const mapped: CommentaryEntry = {
id: entry.id,
resourceId: entry.resource_id,
source: entry.source,
book: entry.book,
chapter: entry.chapter,
verseStart: entry.verse_start,
verseEnd: entry.verse_end,
reference: entry.reference,
title: entry.title,
text: entry.text,
}
const key = commentaryKey(mapped.book, mapped.chapter)
const current = commentary.get(key) ?? []
current.push(mapped)
commentary.set(key, current)
}
for (const entries of commentary.values()) {
entries.sort((left, right) => left.verseStart - right.verseStart)
}
return commentary
}
async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
browserDictionaryCache ??= loadLibreBibleDevDictionary()
return browserDictionaryCache
}
async function loadBrowserVersePlaces(): Promise<Map<string, BiblePlace[]>> {
browserVersePlacesCache ??= loadLibreBibleDevVersePlaces()
return browserVersePlacesCache
}
async function loadLibreBibleDevVersePlaces(): Promise<Map<string, BiblePlace[]>> {
const importedEntries = await fetchJsonl<ImportedVersePlace>(
`${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`,
)
const places = new Map<string, BiblePlace[]>()
for (const entry of importedEntries) {
const mapped: BiblePlace = {
id: `${entry.verse_id}:${entry.place_id}`,
verseId: entry.verse_id,
readable: entry.readable,
placeId: entry.place_id,
placeName: entry.place_name,
placeTypes: entry.place_types,
modernLocationId: entry.modern_location_id,
modernLocationName: entry.modern_location_name,
lon: entry.lon,
lat: entry.lat,
confidence: entry.confidence,
confidenceLabel: entry.confidence_label,
}
const current = places.get(mapped.verseId) ?? []
current.push(mapped)
places.set(mapped.verseId, current)
}
return places
}
async function loadBrowserRoutes(): Promise<BibleRoute[]> {
browserRoutesCache ??= loadLibreBibleDevRoutes()
return browserRoutesCache
}
async function loadBrowserAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists()
return browserAudioPlaylistCache
}
async function loadLibreBibleDevAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
const importedEntries = await fetchJsonl<ImportedAudioChapterPlaylist>(
`${libreBibleDevRoot}/kjv-audio-eliranwong/chapter-playlists.jsonl`,
)
return new Map(importedEntries.map((entry) => [`${entry.book_id}.${entry.chapter}`, entry]))
}
function mapAudioSegment(
playlist: ImportedAudioChapterPlaylist,
index: number,
licenseName: string,
attribution: string,
): AudioBibleSegment {
const id = playlist.segment_ids[index]
const verseId = id?.split(':').at(-1) ?? ''
const verseNumber = Number(verseId.split('.').at(-1) ?? index + 1)
return {
id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
verseId,
reference: `${playlist.book} ${playlist.chapter}:${verseNumber}`,
audioZipUrl: playlist.audio_zip_url,
audioZipFile: playlist.audio_zip_file,
audioEntryPath: playlist.audio_entry_paths[index],
attribution,
licenseName,
}
}
async function loadAudioZip(url: string): Promise<JSZip> {
if (!browserAudioZipCache.has(url)) {
browserAudioZipCache.set(
url,
fetch(url)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Unable to load audio package: ${response.status} ${response.statusText}`)
}
return response.arrayBuffer()
})
.then((data) => JSZip.loadAsync(data)),
)
}
return browserAudioZipCache.get(url)!
}
async function loadLibreBibleDevRoutes(): Promise<BibleRoute[]> {
const importedEntries = await fetchJsonl<ImportedBibleRoute>(
`${libreBibleDevRoot}/bible-routes-ubs-marble/routes.jsonl`,
)
return importedEntries.map((entry) => ({
id: entry.id,
routeNumber: entry.route_number,
title: entry.title,
sectionNumber: entry.section_number,
sectionTitle: entry.section_title,
mapNumber: entry.map_number,
geojsonFile: entry.geojson_file,
bounds: entry.bounds
? {
minLon: entry.bounds.min_lon,
minLat: entry.bounds.min_lat,
maxLon: entry.bounds.max_lon,
maxLat: entry.bounds.max_lat,
}
: null,
}))
}
async function relatedRoutesForPlaces(places: BiblePlace[]): Promise<BibleRoute[]> {
if (places.length === 0) return []
const routes = await loadBrowserRoutes()
const placeTerms = new Set(
places.flatMap((place) => [place.placeName, place.modernLocationName])
.map(normalizeTerm)
.filter((term) => term.length >= 4),
)
const matches = routes
.map((route) => {
const haystack = normalizeTerm(`${route.title} ${route.sectionTitle}`)
const score = [...placeTerms].filter((term) => haystack.includes(term)).length
return { route, score }
})
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || left.route.routeNumber - right.route.routeNumber)
return matches.slice(0, 6).map((entry) => entry.route)
}
function uniquePlaces(places: BiblePlace[]) {
const unique = new Map<string, BiblePlace>()
for (const place of places) {
if (place.lon == null || place.lat == null) continue
unique.set(place.placeId, place)
}
return [...unique.values()].sort(
(left, right) =>
(right.confidence ?? 0) - (left.confidence ?? 0) ||
left.placeName.localeCompare(right.placeName),
)
}
async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
)
return new Map(
importedEntries.map((entry) => [
normalizeTerm(entry.term),
{
id: entry.id,
resourceId: entry.resource_id,
term: entry.term,
slug: entry.slug,
definitions: entry.definitions.map((definition) => ({
source: definition.source,
sourceName: definition.source_name,
text: definition.text,
})),
scriptureRefs: entry.scripture_refs,
sources: entry.sources,
},
]),
)
}
async function loadBrowserFootnotes(translationId = defaultTranslationId): Promise<Map<string, Footnote[]>> {
if (!browserFootnotesCache.has(translationId)) {
browserFootnotesCache.set(translationId, loadLibreBibleDevFootnotes(translationId))
}
return browserFootnotesCache.get(translationId)!
}
async function loadBrowserStudyNotes(translationId = defaultTranslationId): Promise<Map<string, Footnote[]>> {
if (!browserStudyNotesCache.has(translationId)) {
browserStudyNotesCache.set(translationId, loadLibreBibleDevStudyNotes(translationId))
}
return browserStudyNotesCache.get(translationId)!
}
async function loadLibreBibleDevFootnotes(translationId = defaultTranslationId): Promise<Map<string, Footnote[]>> {
const importedFootnotes = await fetchJsonl<ImportedFootnote>(
`${libreBibleDevRoot}/${translationId}/footnotes.jsonl`,
)
return mapImportedNotes(importedFootnotes)
}
async function loadLibreBibleDevStudyNotes(translationId = defaultTranslationId): Promise<Map<string, Footnote[]>> {
const importedFootnotes = await fetchJsonl<ImportedFootnote>(
`${libreBibleDevRoot}/${translationId}/study-notes.jsonl`,
)
return mapImportedNotes(importedFootnotes)
}
async function loadBrowserChapterNotes(
translationId: string,
chapterKey: string,
kind: 'footnotes' | 'study-notes',
): Promise<Map<string, Footnote[]>> {
const cacheKey = `${translationId}:${chapterKey}:${kind}`
const cache =
kind === 'footnotes' ? browserChapterFootnotesCache : browserChapterStudyNotesCache
if (!cache.has(cacheKey)) {
cache.set(
cacheKey,
loadLibreBibleDevChapterNotes(translationId, chapterKey, kind).catch(async () => {
const fullNotes =
kind === 'footnotes'
? await loadBrowserFootnotes(translationId)
: await loadBrowserStudyNotes(translationId)
const filtered = new Map<string, Footnote[]>()
for (const [verseId, notes] of fullNotes) {
if (verseId.startsWith(`${chapterKey}.`)) filtered.set(verseId, notes)
}
return filtered
}),
)
}
return cache.get(cacheKey)!
}
async function loadLibreBibleDevChapterNotes(
translationId: string,
chapterKey: string,
kind: 'footnotes' | 'study-notes',
): Promise<Map<string, Footnote[]>> {
const importedNotes = await fetchJsonl<ImportedFootnote>(
`${libreBibleDevRoot}/${translationId}/notes-by-chapter/${chapterKey}.${kind}.jsonl`,
)
return mapImportedNotes(importedNotes)
}
function mapImportedNotes(importedFootnotes: ImportedFootnote[]): Map<string, Footnote[]> {
const footnotes = new Map<string, Footnote[]>()
for (const note of importedFootnotes) {
const current = footnotes.get(note.verse_id) ?? []
current.push({
id: note.id,
verseId: note.verse_id,
noteIndex: note.note_index,
caller: note.caller,
reference: note.reference,
text: note.text,
label: note.label,
noteType: note.note_type,
})
footnotes.set(note.verse_id, current)
}
return footnotes
}
async function loadBrowserChapterStrongData(
translationId: string,
chapterKey: string,
): Promise<BrowserStrongData> {
const cacheKey = `${translationId}:${chapterKey}`
if (!browserChapterStrongDataCache.has(cacheKey)) {
browserChapterStrongDataCache.set(
cacheKey,
loadLibreBibleDevChapterStrongData(translationId, chapterKey).catch(async () => {
const fullStrongData = await loadBrowserStrongData(translationId)
const linksByVerse = new Map<string, StrongLink[]>()
for (const [verseId, links] of fullStrongData.linksByVerse) {
if (verseId.startsWith(`${chapterKey}.`)) linksByVerse.set(verseId, links)
}
return { linksByVerse }
}),
)
}
return browserChapterStrongDataCache.get(cacheKey)!
}
async function loadLibreBibleDevChapterStrongData(
translationId: string,
chapterKey: string,
): Promise<BrowserStrongData> {
const [importedLinks, lexicon] = await Promise.all([
fetchJsonl<ImportedStrongLink>(
`${libreBibleDevRoot}/${translationId}/strongs-by-chapter/${chapterKey}.strongs.jsonl`,
),
loadBrowserLexicon(),
])
return mapStrongLinks(importedLinks, lexicon)
}
async function loadBrowserStrongData(translationId = defaultTranslationId): Promise<BrowserStrongData> {
if (!browserStrongDataCache.has(translationId)) {
browserStrongDataCache.set(translationId, loadLibreBibleDevStrongData(translationId))
}
return browserStrongDataCache.get(translationId)!
}
async function loadLibreBibleDevStrongData(translationId = defaultTranslationId): Promise<BrowserStrongData> {
const [importedLinks, importedEntries] = await Promise.all([
fetchJsonl<ImportedStrongLink>(`${libreBibleDevRoot}/${translationId}/strongs-links.jsonl`),
fetchJsonl<ImportedStrongEntry>(`${libreBibleDevRoot}/strongs-open-scriptures/entries.jsonl`),
])
return mapStrongLinks(importedLinks, mapBrowserLexicon(importedEntries))
}
async function loadBrowserLexicon(): Promise<Map<string, BrowserLexiconEntry>> {
browserLexiconCache ??= fetchJsonl<ImportedStrongEntry>(
`${libreBibleDevRoot}/strongs-open-scriptures/entries.jsonl`,
).then(mapBrowserLexicon)
return browserLexiconCache
}
function mapBrowserLexicon(importedEntries: ImportedStrongEntry[]) {
return new Map<string, BrowserLexiconEntry>(
importedEntries.map((entry) => [
entry.strongs_number,
{
lemma: entry.lemma ?? '',
transliteration: entry.transliteration ?? '',
definition: entry.strongs_definition ?? entry.kjv_definition ?? '',
language: entry.language === 'hebrew' ? 'Hebrew' : 'Greek',
},
]),
)
}
function mapStrongLinks(
importedLinks: ImportedStrongLink[],
lexicon: Map<string, BrowserLexiconEntry>,
): BrowserStrongData {
const linksByVerse = new Map<string, StrongLink[]>()
for (const link of importedLinks) {
const entry = lexicon.get(link.strong)
const strongLink: StrongLink = {
strong: link.strong,
surface: link.surface,
position: link.position,
lemma: entry?.lemma ?? link.surface,
transliteration: entry?.transliteration ?? '',
definition: entry?.definition ?? '',
language: entry?.language ?? strongLanguage(link.strong),
}
const current = linksByVerse.get(link.verse_id) ?? []
current.push(strongLink)
linksByVerse.set(link.verse_id, current)
}
for (const links of linksByVerse.values()) {
links.sort((a, b) => a.position - b.position)
}
return { linksByVerse }
}
function strongLanguage(strong: string): 'Hebrew' | 'Greek' {
return strong.startsWith('H') ? 'Hebrew' : 'Greek'
}
function bookIdFromVerse(verse: Verse): string {
const sourceId = browserVerseSourceIds.get(verse.id)
return sourceId?.split('.')[0] ?? 'unknown'
}
function browserVerseKey(verse: Verse) {
return browserVerseSourceIds.get(verse.id) ?? ''
}
function commentaryKey(book: string, chapter: number) {
return `${book}|${chapter}`
}
function normalizeTerm(term: string) {
return term.toLowerCase().replace(/[^a-z0-9\s'-]/g, '').trim()
}
async function fetchJsonl<T>(url: string): Promise<T[]> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Unable to load ${url}: ${response.status} ${response.statusText}`)
}
const text = await response.text()
return text
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as T)
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Unable to load ${url}: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}