451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
export const timelineLimit = 200
|
|
export const defaultTranslationId = 'kjv-eng-kjv2006'
|
|
export const workspaceSchemaVersion = 1
|
|
|
|
const workspaceStorageKey = 'libre-study.workspace.v1'
|
|
const sessionStorageKey = 'libre-study.sessions.v1'
|
|
const activeSessionStorageKey = 'libre-study.active-session.v1'
|
|
const studySettingsStorageKey = 'libre-study.study-settings.v1'
|
|
const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true'
|
|
const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
|
|
|
|
export type ViewMode = 'page' | 'study' | 'notes' | 'compare'
|
|
export type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
|
export type ThemeMode = 'light' | 'dark'
|
|
export type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
|
|
|
|
export type TimelineEntry = {
|
|
id: string
|
|
at: string
|
|
type: TimelineEntryType
|
|
label: string
|
|
reference?: string
|
|
detail?: string
|
|
}
|
|
|
|
export type VerseAnchor = {
|
|
book: string
|
|
chapter: number
|
|
startVerse: number
|
|
endVerse: number
|
|
reference: string
|
|
translationId: string
|
|
}
|
|
|
|
export type SessionNote = {
|
|
id: string
|
|
kind: 'draft' | 'margin'
|
|
body: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
reference?: string
|
|
translationId?: string
|
|
anchor?: VerseAnchor
|
|
markers?: string[]
|
|
}
|
|
|
|
export type StudySession = {
|
|
id: string
|
|
title: string
|
|
baseTranslation: string
|
|
footnotesEnabled: boolean
|
|
strongsEnabled: boolean
|
|
commentaryEnabled: boolean
|
|
dictionaryEnabled: boolean
|
|
mapsEnabled: boolean
|
|
audioEnabled: boolean
|
|
timelineEnabled: boolean
|
|
createdAt: string
|
|
updatedAt: string
|
|
selectedId: number | null
|
|
pageStartId: number | null
|
|
viewMode: ViewMode
|
|
strongDisplay: StrongDisplayMode
|
|
noteDraft: string
|
|
notes: SessionNote[]
|
|
timeline: TimelineEntry[]
|
|
}
|
|
|
|
export type StudySettings = {
|
|
lemmaFontSize: number
|
|
linkedFontSize: number
|
|
originalCollapsed: boolean
|
|
linkedCollapsed: boolean
|
|
definitionCollapsed: boolean
|
|
studyNotesCollapsed: boolean
|
|
commentaryCollapsed: boolean
|
|
dictionaryCollapsed: boolean
|
|
mapCollapsed: boolean
|
|
audioCollapsed: boolean
|
|
showOriginalLine: boolean
|
|
showLinkedLine: boolean
|
|
showDefinitionPanel: boolean
|
|
showCommentaryPanel: boolean
|
|
showDictionaryPanel: boolean
|
|
splitPercent: number
|
|
notesPinned: boolean
|
|
bibleColumnPercent: number
|
|
marginColumnPercent: number
|
|
studyStackPinned: boolean
|
|
activeStudyBook: StudyBook
|
|
parallelTranslationId: string | null
|
|
marginPanelOrder: MarginPanelId[]
|
|
themeMode: ThemeMode
|
|
}
|
|
|
|
export type StudyBook = 'notebook' | 'dictionary' | 'lexicon' | 'atlas' | 'commentary' | 'audio' | 'parallel' | 'search'
|
|
export type MarginPanelId = 'margin-note' | 'original' | 'linked' | 'definition' | 'study-notes'
|
|
|
|
export const defaultMarginPanelOrder: MarginPanelId[] = [
|
|
'margin-note',
|
|
'original',
|
|
'linked',
|
|
'definition',
|
|
'study-notes',
|
|
]
|
|
|
|
export type WorkspaceSnapshot = {
|
|
schemaVersion: number
|
|
sessions: StudySession[]
|
|
activeSessionId: string
|
|
studySettings: StudySettings
|
|
}
|
|
|
|
export type PersistedWorkspaceSnapshot = Partial<WorkspaceSnapshot> | null | undefined
|
|
|
|
export type HostedUser = {
|
|
id: number
|
|
username: string
|
|
}
|
|
|
|
export type HostedSession = {
|
|
authenticated: boolean
|
|
user: HostedUser | null
|
|
}
|
|
|
|
export type WorkspaceClient = {
|
|
loadSnapshot(): Promise<PersistedWorkspaceSnapshot>
|
|
saveSnapshot(snapshot: WorkspaceSnapshot): Promise<void>
|
|
}
|
|
|
|
export type AuthClient = {
|
|
enabled: boolean
|
|
loadSession(): Promise<HostedSession>
|
|
login(username: string, password: string): Promise<HostedSession>
|
|
logout(): Promise<void>
|
|
}
|
|
|
|
export function hostedWorkspaceEnabled() {
|
|
return remoteWorkspaceEnabled
|
|
}
|
|
|
|
export function defaultStudySettings(): StudySettings {
|
|
return {
|
|
lemmaFontSize: 17,
|
|
linkedFontSize: 15,
|
|
originalCollapsed: false,
|
|
linkedCollapsed: false,
|
|
definitionCollapsed: false,
|
|
studyNotesCollapsed: false,
|
|
commentaryCollapsed: false,
|
|
dictionaryCollapsed: false,
|
|
mapCollapsed: false,
|
|
audioCollapsed: false,
|
|
showOriginalLine: true,
|
|
showLinkedLine: true,
|
|
showDefinitionPanel: true,
|
|
showCommentaryPanel: true,
|
|
showDictionaryPanel: true,
|
|
splitPercent: 50,
|
|
notesPinned: false,
|
|
bibleColumnPercent: 40,
|
|
marginColumnPercent: 20,
|
|
studyStackPinned: true,
|
|
activeStudyBook: 'notebook',
|
|
parallelTranslationId: null,
|
|
marginPanelOrder: defaultMarginPanelOrder,
|
|
themeMode: 'light',
|
|
}
|
|
}
|
|
|
|
export function createSession(title = 'Study Session', baseTranslation = defaultTranslationId): StudySession {
|
|
const now = new Date().toISOString()
|
|
const id =
|
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
|
|
return {
|
|
id,
|
|
title,
|
|
baseTranslation,
|
|
footnotesEnabled: true,
|
|
strongsEnabled: true,
|
|
commentaryEnabled: true,
|
|
dictionaryEnabled: true,
|
|
mapsEnabled: true,
|
|
audioEnabled: true,
|
|
timelineEnabled: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
selectedId: null,
|
|
pageStartId: null,
|
|
viewMode: 'notes',
|
|
strongDisplay: 'highlight',
|
|
noteDraft: '',
|
|
notes: [],
|
|
timeline: [],
|
|
}
|
|
}
|
|
|
|
export function timelineId() {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
}
|
|
|
|
export function normalizeStudySettings(settings?: Partial<StudySettings> | null): StudySettings {
|
|
return {
|
|
...defaultStudySettings(),
|
|
...(settings ?? {}),
|
|
marginPanelOrder: normalizeMarginPanelOrder(settings?.marginPanelOrder),
|
|
}
|
|
}
|
|
|
|
function normalizeMarginPanelOrder(order?: MarginPanelId[] | null): MarginPanelId[] {
|
|
if (!Array.isArray(order)) return defaultMarginPanelOrder
|
|
const known = new Set(defaultMarginPanelOrder)
|
|
const next = order.filter((panel): panel is MarginPanelId => known.has(panel))
|
|
for (const panel of defaultMarginPanelOrder) {
|
|
if (!next.includes(panel)) next.push(panel)
|
|
}
|
|
return next
|
|
}
|
|
|
|
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
|
|
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
|
|
|
|
return source.map((session) => {
|
|
const normalized = {
|
|
...createSession(session.title),
|
|
...session,
|
|
baseTranslation: session.baseTranslation ?? defaultTranslationId,
|
|
footnotesEnabled: session.footnotesEnabled ?? true,
|
|
strongsEnabled: session.strongsEnabled ?? true,
|
|
commentaryEnabled: session.commentaryEnabled ?? true,
|
|
dictionaryEnabled: session.dictionaryEnabled ?? true,
|
|
mapsEnabled: session.mapsEnabled ?? true,
|
|
audioEnabled: session.audioEnabled ?? true,
|
|
timelineEnabled: session.timelineEnabled ?? true,
|
|
notes: normalizeSessionNotes(session),
|
|
}
|
|
const draft = normalized.notes.find((note) => note.kind === 'draft')?.body ?? normalized.noteDraft
|
|
return {
|
|
...normalized,
|
|
noteDraft: draft,
|
|
}
|
|
})
|
|
}
|
|
|
|
function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
|
|
if (Array.isArray(session.notes) && session.notes.length) {
|
|
return session.notes.map((note) => ({
|
|
id: note.id || timelineId(),
|
|
kind: note.kind === 'margin' ? 'margin' : 'draft',
|
|
body: note.body ?? '',
|
|
createdAt: note.createdAt ?? session.createdAt ?? new Date().toISOString(),
|
|
updatedAt: note.updatedAt ?? session.updatedAt ?? new Date().toISOString(),
|
|
reference: note.reference,
|
|
translationId: note.translationId ?? session.baseTranslation,
|
|
anchor: note.anchor,
|
|
markers: Array.isArray(note.markers) ? note.markers : [],
|
|
}))
|
|
}
|
|
|
|
if (!session.noteDraft) return []
|
|
const now = session.updatedAt ?? new Date().toISOString()
|
|
return [
|
|
{
|
|
id: timelineId(),
|
|
kind: 'draft',
|
|
body: session.noteDraft,
|
|
createdAt: session.createdAt ?? now,
|
|
updatedAt: now,
|
|
translationId: session.baseTranslation,
|
|
},
|
|
]
|
|
}
|
|
|
|
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
|
|
if (!sessions.length) return ''
|
|
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
|
|
}
|
|
|
|
export function normalizeWorkspaceSnapshot(source?: PersistedWorkspaceSnapshot): WorkspaceSnapshot {
|
|
const sessions = normalizeSessions(source?.sessions)
|
|
const activeSessionId = selectActiveSessionId(sessions, source?.activeSessionId)
|
|
|
|
return {
|
|
schemaVersion: workspaceSchemaVersion,
|
|
sessions,
|
|
activeSessionId,
|
|
studySettings: normalizeStudySettings(source?.studySettings),
|
|
}
|
|
}
|
|
|
|
export async function loadWorkspaceSnapshot(): Promise<WorkspaceSnapshot> {
|
|
return normalizeWorkspaceSnapshot(await getWorkspaceClient().loadSnapshot())
|
|
}
|
|
|
|
export function saveWorkspaceSnapshot(snapshot: PersistedWorkspaceSnapshot): Promise<void> {
|
|
return getWorkspaceClient().saveSnapshot(normalizeWorkspaceSnapshot(snapshot))
|
|
}
|
|
|
|
function loadLocalWorkspaceSnapshot(): PersistedWorkspaceSnapshot {
|
|
const currentSnapshot = readJson<WorkspaceSnapshot>(workspaceStorageKey)
|
|
if (currentSnapshot) return currentSnapshot
|
|
|
|
return {
|
|
sessions: readJson<StudySession[]>(sessionStorageKey) ?? undefined,
|
|
activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined,
|
|
studySettings: readJson<StudySettings>(studySettingsStorageKey) ?? undefined,
|
|
}
|
|
}
|
|
|
|
function saveLocalWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
|
|
localStorage.setItem(workspaceStorageKey, JSON.stringify(snapshot))
|
|
// Keep legacy keys current until every adapter reads the versioned snapshot.
|
|
localStorage.setItem(sessionStorageKey, JSON.stringify(snapshot.sessions))
|
|
localStorage.setItem(activeSessionStorageKey, snapshot.activeSessionId)
|
|
localStorage.setItem(studySettingsStorageKey, JSON.stringify(snapshot.studySettings))
|
|
}
|
|
|
|
function readJson<T>(key: string): T | null {
|
|
const raw = localStorage.getItem(key)
|
|
if (!raw) return null
|
|
|
|
try {
|
|
return JSON.parse(raw) as T
|
|
} catch {
|
|
localStorage.removeItem(key)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function loadRemoteWorkspaceSnapshot(): Promise<PersistedWorkspaceSnapshot> {
|
|
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
|
|
credentials: 'include',
|
|
})
|
|
if (response.status === 204 || response.status === 404) return null
|
|
if (response.status === 401) {
|
|
throw new Error('Login is required for the hosted workspace.')
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to load hosted workspace: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
return response.json() as Promise<Partial<WorkspaceSnapshot>>
|
|
}
|
|
|
|
async function saveRemoteWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
|
|
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(snapshot),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
|
|
}
|
|
}
|
|
|
|
const localWorkspaceClient: WorkspaceClient = {
|
|
loadSnapshot: async () => loadLocalWorkspaceSnapshot(),
|
|
saveSnapshot: async (snapshot) => {
|
|
saveLocalWorkspaceSnapshot(snapshot)
|
|
},
|
|
}
|
|
|
|
const hostedWorkspaceClient: WorkspaceClient = {
|
|
loadSnapshot: async () => {
|
|
const remoteSnapshot = await loadRemoteWorkspaceSnapshot()
|
|
return remoteSnapshot ?? loadLocalWorkspaceSnapshot()
|
|
},
|
|
saveSnapshot: async (snapshot) => {
|
|
saveLocalWorkspaceSnapshot(snapshot)
|
|
await saveRemoteWorkspaceSnapshot(snapshot)
|
|
},
|
|
}
|
|
|
|
const inactiveAuthClient: AuthClient = {
|
|
enabled: false,
|
|
loadSession: async () => ({ authenticated: false, user: null }),
|
|
login: async () => {
|
|
throw new Error('Hosted login is not enabled in this workspace mode.')
|
|
},
|
|
logout: async () => undefined,
|
|
}
|
|
|
|
const hostedAuthClient: AuthClient = {
|
|
enabled: true,
|
|
loadSession: loadRemoteHostedSession,
|
|
login: loginRemoteHostedWorkspace,
|
|
logout: logoutRemoteHostedWorkspace,
|
|
}
|
|
|
|
export function getWorkspaceClient(): WorkspaceClient {
|
|
return remoteWorkspaceEnabled ? hostedWorkspaceClient : localWorkspaceClient
|
|
}
|
|
|
|
export function getAuthClient(): AuthClient {
|
|
return remoteWorkspaceEnabled ? hostedAuthClient : inactiveAuthClient
|
|
}
|
|
|
|
async function loadRemoteHostedSession(): Promise<HostedSession> {
|
|
const response = await fetch(`${remoteWorkspaceApiBase}/session`, {
|
|
credentials: 'include',
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to check hosted login: ${response.status} ${response.statusText}`)
|
|
}
|
|
return response.json() as Promise<HostedSession>
|
|
}
|
|
|
|
async function loginRemoteHostedWorkspace(username: string, password: string): Promise<HostedSession> {
|
|
const response = await fetch(`${remoteWorkspaceApiBase}/login`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ username, password }),
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(response.status === 401 ? 'Invalid username or password.' : `Unable to log in: ${response.status} ${response.statusText}`)
|
|
}
|
|
return response.json() as Promise<HostedSession>
|
|
}
|
|
|
|
async function logoutRemoteHostedWorkspace(): Promise<void> {
|
|
const response = await fetch(`${remoteWorkspaceApiBase}/logout`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to log out: ${response.status} ${response.statusText}`)
|
|
}
|
|
}
|
|
|
|
export function loadHostedSession(): Promise<HostedSession> {
|
|
return getAuthClient().loadSession()
|
|
}
|
|
|
|
export function loginHostedWorkspace(username: string, password: string): Promise<HostedSession> {
|
|
return getAuthClient().login(username, password)
|
|
}
|
|
|
|
export function logoutHostedWorkspace(): Promise<void> {
|
|
return getAuthClient().logout()
|
|
}
|