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 | null | undefined export type HostedUser = { id: number username: string } export type HostedSession = { authenticated: boolean user: HostedUser | null } export type WorkspaceClient = { loadSnapshot(): Promise saveSnapshot(snapshot: WorkspaceSnapshot): Promise } export type AuthClient = { enabled: boolean loadSession(): Promise login(username: string, password: string): Promise logout(): Promise } 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 | 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 { return normalizeWorkspaceSnapshot(await getWorkspaceClient().loadSnapshot()) } export function saveWorkspaceSnapshot(snapshot: PersistedWorkspaceSnapshot): Promise { return getWorkspaceClient().saveSnapshot(normalizeWorkspaceSnapshot(snapshot)) } function loadLocalWorkspaceSnapshot(): PersistedWorkspaceSnapshot { const currentSnapshot = readJson(workspaceStorageKey) if (currentSnapshot) return currentSnapshot return { sessions: readJson(sessionStorageKey) ?? undefined, activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined, studySettings: readJson(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(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 { 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> } 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 { 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 } async function loginRemoteHostedWorkspace(username: string, password: string): Promise { 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 } async function logoutRemoteHostedWorkspace(): Promise { 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 { return getAuthClient().loadSession() } export function loginHostedWorkspace(username: string, password: string): Promise { return getAuthClient().login(username, password) } export function logoutHostedWorkspace(): Promise { return getAuthClient().logout() }