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 | 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, 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 | 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): 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 { 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() }