1239 lines
38 KiB
TypeScript
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>
|
|
}
|