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 } let browserCatalogCache: Promise | null = null const browserLibraryCache = new Map>() const browserStrongDataCache = new Map>() const browserChapterStrongDataCache = new Map>() const browserStrongSearchIndexCache = new Map>>() let browserLexiconCache: Promise> | null = null const browserFootnotesCache = new Map>>() const browserStudyNotesCache = new Map>>() const browserChapterFootnotesCache = new Map>>() const browserChapterStudyNotesCache = new Map>>() const browserSearchIndexCache = new Map>>() let browserCommentaryCache: Promise> | null = null let browserDictionaryCache: Promise> | null = null let browserVersePlacesCache: Promise> | null = null let browserRoutesCache: Promise | null = null let browserAudioPlaylistCache: Promise> | null = null const browserVerseSourceIds = new Map() const browserVerseIdsBySource = new Map() const browserAudioZipCache = new Map>() const browserAudioObjectUrlCache = new Map>() 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 { const raw = localStorage.getItem(browserTagKey) if (!raw) return {} try { return JSON.parse(raw) as Record } catch { return {} } } function writeBrowserTags(tagsByVerse: Record) { 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() } }), ), ) 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() 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 { 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 { return { verses: await loadBrowserVersesForTranslation(translationId), tags: allBrowserTags(), } } async function getBrowserPageVerses( translationId: string, pageStartId: number, pageSize: number, ): Promise { 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 { 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() 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 { 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 { 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 { const dictionary = await loadBrowserDictionary() const seen = new Set() 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 { 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 { 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 { 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('get_library_state'), getPageVerses: async (_translationId, pageStartId, pageSize) => { const state = await invoke('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('search_verses', { query }), addTagToVerse: (verseId, label, color) => invoke('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 { return getStudyDataClient().getTranslations() } export function getLibraryState(translationId = defaultTranslationId): Promise { return getStudyDataClient().getLibraryState(translationId) } export function getPageVerses( translationId: string, pageStartId: number, pageSize: number, ): Promise { return getStudyDataClient().getPageVerses(translationId, pageStartId, pageSize) } export function getTranslationNotesForVerses( translationId: string, verses: Verse[], ): Promise { return getStudyDataClient().getTranslationNotesForVerses(translationId, verses) } export function searchVerses(query: string, translationId = defaultTranslationId): Promise { 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 { return getStudyDataClient().getCommentaryForVerse(verse) } export function getDictionaryEntriesForTerms(terms: string[]): Promise { return getStudyDataClient().getDictionaryEntriesForTerms(terms) } export function getMapContextForVerses(selectedVerse: Verse, pageVerses: Verse[]): Promise { return getStudyDataClient().getMapContextForVerses(selectedVerse, pageVerses) } export function getAudioContextForVerse( translationId: string, verse: Verse, ): Promise { return getStudyDataClient().getAudioContextForVerse(translationId, verse) } export function getAudioObjectUrl(segment: AudioBibleSegment): Promise { return getStudyDataClient().getAudioObjectUrl(segment) } async function loadBrowserCatalog(): Promise { browserCatalogCache ??= fetchJson(`${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(`${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() 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 { const importedVerses = await fetchJsonl( `${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> { browserCommentaryCache ??= loadLibreBibleDevCommentary() return browserCommentaryCache } async function loadLibreBibleDevCommentary(): Promise> { const importedEntries = await fetchJsonl( `${libreBibleDevRoot}/matthew-henry-concise-ccel/commentary.jsonl`, ) const commentary = new Map() 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> { browserDictionaryCache ??= loadLibreBibleDevDictionary() return browserDictionaryCache } async function loadBrowserVersePlaces(): Promise> { browserVersePlacesCache ??= loadLibreBibleDevVersePlaces() return browserVersePlacesCache } async function loadLibreBibleDevVersePlaces(): Promise> { const importedEntries = await fetchJsonl( `${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`, ) const places = new Map() 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 { browserRoutesCache ??= loadLibreBibleDevRoutes() return browserRoutesCache } async function loadBrowserAudioPlaylists(): Promise> { browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists() return browserAudioPlaylistCache } async function loadLibreBibleDevAudioPlaylists(): Promise> { const importedEntries = await fetchJsonl( `${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 { 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 { const importedEntries = await fetchJsonl( `${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 { 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() 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> { const importedEntries = await fetchJsonl( `${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> { if (!browserFootnotesCache.has(translationId)) { browserFootnotesCache.set(translationId, loadLibreBibleDevFootnotes(translationId)) } return browserFootnotesCache.get(translationId)! } async function loadBrowserStudyNotes(translationId = defaultTranslationId): Promise> { if (!browserStudyNotesCache.has(translationId)) { browserStudyNotesCache.set(translationId, loadLibreBibleDevStudyNotes(translationId)) } return browserStudyNotesCache.get(translationId)! } async function loadLibreBibleDevFootnotes(translationId = defaultTranslationId): Promise> { const importedFootnotes = await fetchJsonl( `${libreBibleDevRoot}/${translationId}/footnotes.jsonl`, ) return mapImportedNotes(importedFootnotes) } async function loadLibreBibleDevStudyNotes(translationId = defaultTranslationId): Promise> { const importedFootnotes = await fetchJsonl( `${libreBibleDevRoot}/${translationId}/study-notes.jsonl`, ) return mapImportedNotes(importedFootnotes) } async function loadBrowserChapterNotes( translationId: string, chapterKey: string, kind: 'footnotes' | 'study-notes', ): Promise> { 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() 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> { const importedNotes = await fetchJsonl( `${libreBibleDevRoot}/${translationId}/notes-by-chapter/${chapterKey}.${kind}.jsonl`, ) return mapImportedNotes(importedNotes) } function mapImportedNotes(importedFootnotes: ImportedFootnote[]): Map { const footnotes = new Map() 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 { 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() 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 { const [importedLinks, lexicon] = await Promise.all([ fetchJsonl( `${libreBibleDevRoot}/${translationId}/strongs-by-chapter/${chapterKey}.strongs.jsonl`, ), loadBrowserLexicon(), ]) return mapStrongLinks(importedLinks, lexicon) } async function loadBrowserStrongData(translationId = defaultTranslationId): Promise { if (!browserStrongDataCache.has(translationId)) { browserStrongDataCache.set(translationId, loadLibreBibleDevStrongData(translationId)) } return browserStrongDataCache.get(translationId)! } async function loadLibreBibleDevStrongData(translationId = defaultTranslationId): Promise { const [importedLinks, importedEntries] = await Promise.all([ fetchJsonl(`${libreBibleDevRoot}/${translationId}/strongs-links.jsonl`), fetchJsonl(`${libreBibleDevRoot}/strongs-open-scriptures/entries.jsonl`), ]) return mapStrongLinks(importedLinks, mapBrowserLexicon(importedEntries)) } async function loadBrowserLexicon(): Promise> { browserLexiconCache ??= fetchJsonl( `${libreBibleDevRoot}/strongs-open-scriptures/entries.jsonl`, ).then(mapBrowserLexicon) return browserLexiconCache } function mapBrowserLexicon(importedEntries: ImportedStrongEntry[]) { return new Map( 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, ): BrowserStrongData { const linksByVerse = new Map() 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(url: string): Promise { 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(url: string): Promise { const response = await fetch(url) if (!response.ok) { throw new Error(`Unable to load ${url}: ${response.status} ${response.statusText}`) } return response.json() as Promise }