Files
libre-study/src/workspace.ts
T

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()
}