Files
libre-study/src/studyClient.ts
T

417 lines
11 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core'
import type { Footnote, LibraryState, StrongLink, Tag, Verse } from './types'
type TauriWindow = Window &
typeof globalThis & {
__TAURI_INTERNALS__?: unknown
}
const browserTagKey = 'libre-study-browser-tags'
const libreBibleDevRoot = '/@fs/W:/libre-bible-data/packages/json'
type ImportedVerse = {
id: string
book: string
chapter: number
verse: number
text: string
}
type ImportedStrongLink = {
verse_id: string
position: number
surface: string
strong: string
}
type ImportedFootnote = {
id: number
verse_id: string
note_index: number
caller: string
reference: string
text: string
}
type ImportedStrongEntry = {
strongs_number: string
language: 'hebrew' | 'greek'
lemma?: string
transliteration?: string
pronunciation?: string
strongs_definition?: string
kjv_definition?: string
}
type BrowserLexiconEntry = {
lemma: string
transliteration: string
definition: string
language: 'Hebrew' | 'Greek'
}
type BrowserStrongData = {
linksByVerse: Map<string, StrongLink[]>
verseIdsByStrong: Map<string, Set<string>>
}
let browserLibraryCache: Promise<Verse[]> | null = null
let browserStrongDataCache: Promise<BrowserStrongData> | null = null
let browserFootnotesCache: Promise<Map<string, Footnote[]>> | null = null
const browserVerseSourceIds = new Map<number, 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 loadBrowserVerses() {
browserLibraryCache ??= loadLibreBibleDevVerses().catch((error) => {
console.warn('Falling back to browser seed data.', error)
return fallbackBrowserVerses
})
const verses = await browserLibraryCache
const strongData = await loadBrowserStrongData().catch((error) => {
console.warn('Unable to load browser Strong data.', error)
return null
})
const footnotes = await loadBrowserFootnotes().catch((error) => {
console.warn('Unable to load browser footnotes.', error)
return new Map<string, Footnote[]>()
})
const tagsByVerse = readBrowserTags()
return verses.map((verse) => ({
...verse,
strongs: strongData?.linksByVerse.get(browserVerseKey(verse)) ?? verse.strongs,
footnotes: footnotes.get(browserVerseKey(verse)) ?? verse.footnotes ?? [],
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))
}
export async function getLibraryState(): Promise<LibraryState> {
if (inTauri()) {
return invoke<LibraryState>('get_library_state')
}
return {
verses: await loadBrowserVerses(),
tags: allBrowserTags(),
}
}
export async function searchVerses(query: string): Promise<Verse[]> {
if (inTauri()) {
return invoke<Verse[]>('search_verses', { query })
}
const trimmed = query.trim().toLowerCase()
const verses = await loadBrowserVerses()
if (!trimmed) return verses
if (trimmed.startsWith('tag:')) {
const label = trimmed.slice(4)
return verses.filter((verse) =>
verse.tags.some((tag) => tag.label.toLowerCase() === label),
)
}
if (/^[gh]\d+$/.test(trimmed)) {
const strongData = await loadBrowserStrongData()
const matchingVerseIds = strongData.verseIdsByStrong.get(trimmed.toUpperCase()) ?? new Set()
return verses.filter((verse) =>
matchingVerseIds.has(browserVerseKey(verse)),
)
}
return verses.filter((verse) =>
`${verse.book} ${verse.chapter}:${verse.verse} ${verse.text}`
.toLowerCase()
.includes(trimmed),
)
}
export async function addTagToVerse(verseId: number, label: string, color: string) {
if (inTauri()) {
return invoke<Verse>('add_tag_to_verse', { verseId, label, color })
}
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)
const verse = (await loadBrowserVerses()).find((item) => item.id === verseId)
if (!verse) throw new Error(`Verse ${verseId} was not found.`)
return verse
}
async function loadLibreBibleDevVerses(): Promise<Verse[]> {
const importedVerses = await fetchJsonl<ImportedVerse>(
`${libreBibleDevRoot}/kjv-eng-kjv2006/verses.jsonl`,
)
browserVerseSourceIds.clear()
return importedVerses.map((verse, index) => {
const id = index + 1
browserVerseSourceIds.set(id, verse.id)
return {
id,
book: verse.book,
chapter: verse.chapter,
verse: verse.verse,
text: verse.text,
strongs: [],
footnotes: [],
tags: [],
}
})
}
async function loadBrowserFootnotes(): Promise<Map<string, Footnote[]>> {
browserFootnotesCache ??= loadLibreBibleDevFootnotes()
return browserFootnotesCache
}
async function loadLibreBibleDevFootnotes(): Promise<Map<string, Footnote[]>> {
const importedFootnotes = await fetchJsonl<ImportedFootnote>(
`${libreBibleDevRoot}/kjv-eng-kjv2006/footnotes.jsonl`,
)
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,
})
footnotes.set(note.verse_id, current)
}
return footnotes
}
async function loadBrowserStrongData(): Promise<BrowserStrongData> {
browserStrongDataCache ??= loadLibreBibleDevStrongData()
return browserStrongDataCache
}
async function loadLibreBibleDevStrongData(): Promise<BrowserStrongData> {
const [importedLinks, importedEntries] = await Promise.all([
fetchJsonl<ImportedStrongLink>(`${libreBibleDevRoot}/kjv-eng-kjv2006/strongs-links.jsonl`),
fetchJsonl<ImportedStrongEntry>(`${libreBibleDevRoot}/strongs-open-scriptures/entries.jsonl`),
])
const lexicon = 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',
},
]),
)
const linksByVerse = new Map<string, StrongLink[]>()
const verseIdsByStrong = new Map<string, Set<string>>()
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)
const strongMatches = verseIdsByStrong.get(link.strong) ?? new Set<string>()
strongMatches.add(link.verse_id)
verseIdsByStrong.set(link.strong, strongMatches)
}
for (const links of linksByVerse.values()) {
links.sort((a, b) => a.position - b.position)
}
return { linksByVerse, verseIdsByStrong }
}
function strongLanguage(strong: string): 'Hebrew' | 'Greek' {
return strong.startsWith('H') ? 'Hebrew' : 'Greek'
}
function browserVerseKey(verse: Verse) {
return browserVerseSourceIds.get(verse.id) ?? ''
}
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)
}