Add study data client adapter

This commit is contained in:
2026-07-15 16:13:51 -05:00
parent 7f917b9560
commit de383596c7
4 changed files with 132 additions and 37 deletions
+1
View File
@@ -3,6 +3,7 @@
## Unreleased
- Recorded the active development direction: use the local HotView/hot-reload setup for iterative app work and defer web/local builds and hosted deployment unless explicitly requested.
- Started Phase 4 shared app core cleanup by adding an explicit `StudyDataClient` contract and browser/Tauri data adapters behind the existing study client API.
## 0.1.31 - 2026-07-15
+3 -1
View File
@@ -118,9 +118,11 @@ Definition of done:
Goal: stop treating local preview, hosted web, and Tauri as separate code paths.
Current foundation note: `src/studyDataClient.ts` now defines the app-level resource/query contract, and `src/studyClient.ts` routes existing UI calls through browser and Tauri adapters. The browser adapter still owns the HotView JSON/JSONL package loading path, but the UI no longer needs to know which adapter is active.
Tasks:
- Extract a client/service interface for translations, pages, Strong's links, notes, commentaries, dictionaries, maps, audio, sessions, settings, and timelines.
- Extract a client/service interface for translations, pages, Strong's links, notes, commentaries, dictionaries, maps, audio, sessions, settings, and timelines. Started with `StudyDataClient` for resource/query behavior.
- Keep UI components pointed at that interface instead of hardcoding browser static paths or direct Tauri commands.
- Define the session schema once: active translation, page anchor, selected verse, enabled resources, visual settings, notes, timeline, and exports.
- Add a migration path from `localStorage` sessions to hosted persistence and Tauri SQLite persistence.
+102 -36
View File
@@ -1,6 +1,7 @@
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,
@@ -352,9 +353,7 @@ function allBrowserTags() {
return [...unique.values()].sort((a, b) => a.label.localeCompare(b.label))
}
export async function getTranslations(): Promise<TranslationResource[]> {
if (inTauri()) return []
async function getBrowserTranslations(): Promise<TranslationResource[]> {
const catalog = await loadBrowserCatalog()
return catalog.resources
.filter((resource) => resource.resource_type === 'translation')
@@ -369,22 +368,18 @@ export async function getTranslations(): Promise<TranslationResource[]> {
}))
}
export async function getLibraryState(translationId = defaultTranslationId): Promise<LibraryState> {
if (inTauri()) {
return invoke<LibraryState>('get_library_state')
}
async function getBrowserLibraryState(translationId = defaultTranslationId): Promise<LibraryState> {
return {
verses: await loadBrowserVersesForTranslation(translationId),
tags: allBrowserTags(),
}
}
export async function getTranslationNotesForVerses(
async function getBrowserTranslationNotesForVerses(
translationId: string,
verses: Verse[],
): Promise<Map<string, { footnotes: Footnote[]; studyNotes: Footnote[] }>> {
if (inTauri() || verses.length === 0) return new Map()
): Promise<TranslationNotesByVerse> {
if (verses.length === 0) return new Map()
const chapterKeys = [
...new Set(
@@ -411,25 +406,17 @@ export async function getTranslationNotesForVerses(
return notesByVerse
}
export async function searchVerses(query: string, translationId = defaultTranslationId): Promise<Verse[]> {
if (inTauri()) {
return invoke<Verse[]>('search_verses', { query })
}
async function searchBrowserVerses(query: string, translationId = defaultTranslationId): Promise<Verse[]> {
const searchIndex = await loadBrowserSearchIndex(translationId)
return searchIndex.search(query)
}
export async function addTagToVerse(
async function addBrowserTagToVerse(
verseId: number,
label: string,
color: string,
translationId = defaultTranslationId,
) {
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())
@@ -443,9 +430,7 @@ export async function addTagToVerse(
return verse
}
export async function getCommentaryForVerse(verse: Verse): Promise<CommentaryEntry[]> {
if (inTauri()) return []
async function getBrowserCommentaryForVerse(verse: Verse): Promise<CommentaryEntry[]> {
const commentary = await loadBrowserCommentary()
const chapterEntries = commentary.get(commentaryKey(verse.book, verse.chapter)) ?? []
return chapterEntries.filter(
@@ -453,9 +438,7 @@ export async function getCommentaryForVerse(verse: Verse): Promise<CommentaryEnt
)
}
export async function getDictionaryEntriesForTerms(terms: string[]): Promise<DictionaryEntry[]> {
if (inTauri()) return []
async function getBrowserDictionaryEntriesForTerms(terms: string[]): Promise<DictionaryEntry[]> {
const dictionary = await loadBrowserDictionary()
const seen = new Set<string>()
const entries: DictionaryEntry[] = []
@@ -472,14 +455,10 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
return entries
}
export async function getMapContextForVerses(
async function getBrowserMapContextForVerses(
selectedVerse: Verse,
pageVerses: Verse[],
): Promise<MapContext> {
if (inTauri()) {
return { selectedPlaces: [], pagePlaces: [], relatedRoutes: [] }
}
const versePlaces = await loadBrowserVersePlaces()
const selectedVerseId = browserVerseKey(selectedVerse)
const pageVerseIds = new Set(pageVerses.map((verse) => browserVerseKey(verse)).filter(Boolean))
@@ -496,12 +475,10 @@ export async function getMapContextForVerses(
}
}
export async function getAudioContextForVerse(
async function getBrowserAudioContextForVerse(
translationId: string,
verse: Verse,
): Promise<AudioBibleContext | null> {
if (inTauri()) return null
const catalog = await loadBrowserCatalog()
const resource = catalog.resources.find(
(item) =>
@@ -547,7 +524,7 @@ export async function getAudioContextForVerse(
}
}
export async function getAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
async function getBrowserAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
const cacheKey = `${segment.audioZipUrl}#${segment.audioEntryPath}`
if (!browserAudioObjectUrlCache.has(cacheKey)) {
browserAudioObjectUrlCache.set(
@@ -566,6 +543,95 @@ export async function getAudioObjectUrl(segment: AudioBibleSegment): Promise<str
return browserAudioObjectUrlCache.get(cacheKey)!
}
const emptyMapContext: MapContext = {
selectedPlaces: [],
pagePlaces: [],
relatedRoutes: [],
}
const browserStudyDataClient: StudyDataClient = {
getTranslations: getBrowserTranslations,
getLibraryState: getBrowserLibraryState,
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'),
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 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
+26
View File
@@ -0,0 +1,26 @@
import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry,
DictionaryEntry,
Footnote,
LibraryState,
MapContext,
TranslationResource,
Verse,
} from './types'
export type TranslationNotesByVerse = Map<string, { footnotes: Footnote[]; studyNotes: Footnote[] }>
export type StudyDataClient = {
getTranslations(): Promise<TranslationResource[]>
getLibraryState(translationId?: string): Promise<LibraryState>
getTranslationNotesForVerses(translationId: string, verses: Verse[]): Promise<TranslationNotesByVerse>
searchVerses(query: string, translationId?: string): Promise<Verse[]>
addTagToVerse(verseId: number, label: string, color: string, translationId?: string): Promise<Verse>
getCommentaryForVerse(verse: Verse): Promise<CommentaryEntry[]>
getDictionaryEntriesForTerms(terms: string[]): Promise<DictionaryEntry[]>
getMapContextForVerses(selectedVerse: Verse, pageVerses: Verse[]): Promise<MapContext>
getAudioContextForVerse(translationId: string, verse: Verse): Promise<AudioBibleContext | null>
getAudioObjectUrl(segment: AudioBibleSegment): Promise<string>
}