347 lines
10 KiB
TypeScript
347 lines
10 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 StudySession = {
|
|
id: string
|
|
title: string
|
|
baseTranslation: string
|
|
footnotesEnabled: boolean
|
|
strongsEnabled: boolean
|
|
commentaryEnabled: boolean
|
|
dictionaryEnabled: boolean
|
|
timelineEnabled: boolean
|
|
createdAt: string
|
|
updatedAt: string
|
|
selectedId: number | null
|
|
pageStartId: number | null
|
|
viewMode: ViewMode
|
|
strongDisplay: StrongDisplayMode
|
|
noteDraft: string
|
|
timeline: TimelineEntry[]
|
|
}
|
|
|
|
export type StudySettings = {
|
|
lemmaFontSize: number
|
|
linkedFontSize: number
|
|
originalCollapsed: boolean
|
|
linkedCollapsed: boolean
|
|
definitionCollapsed: boolean
|
|
studyNotesCollapsed: boolean
|
|
commentaryCollapsed: boolean
|
|
dictionaryCollapsed: boolean
|
|
showOriginalLine: boolean
|
|
showLinkedLine: boolean
|
|
showDefinitionPanel: boolean
|
|
showCommentaryPanel: boolean
|
|
showDictionaryPanel: boolean
|
|
splitPercent: number
|
|
notesPinned: boolean
|
|
themeMode: ThemeMode
|
|
}
|
|
|
|
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,
|
|
showOriginalLine: true,
|
|
showLinkedLine: true,
|
|
showDefinitionPanel: true,
|
|
showCommentaryPanel: true,
|
|
showDictionaryPanel: true,
|
|
splitPercent: 50,
|
|
notesPinned: false,
|
|
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,
|
|
timelineEnabled: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
selectedId: null,
|
|
pageStartId: null,
|
|
viewMode: 'notes',
|
|
strongDisplay: 'highlight',
|
|
noteDraft: '',
|
|
timeline: [],
|
|
}
|
|
}
|
|
|
|
export function timelineId() {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
}
|
|
|
|
export function normalizeStudySettings(settings?: Partial<StudySettings> | null): StudySettings {
|
|
return {
|
|
...defaultStudySettings(),
|
|
...(settings ?? {}),
|
|
}
|
|
}
|
|
|
|
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
|
|
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
|
|
|
|
return source.map((session) => ({
|
|
...createSession(session.title),
|
|
...session,
|
|
baseTranslation: session.baseTranslation ?? defaultTranslationId,
|
|
footnotesEnabled: session.footnotesEnabled ?? true,
|
|
strongsEnabled: session.strongsEnabled ?? true,
|
|
commentaryEnabled: session.commentaryEnabled ?? true,
|
|
dictionaryEnabled: session.dictionaryEnabled ?? true,
|
|
timelineEnabled: session.timelineEnabled ?? true,
|
|
}))
|
|
}
|
|
|
|
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()
|
|
}
|