diff --git a/CHANGELOG.md b/CHANGELOG.md index f924265..7b952b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.25 - 2026-07-12 + +- Started Phase 4 shared app core cleanup by extracting workspace/session/settings/timeline schema and persistence adapters into `src/workspace.ts`. +- Kept local preview and hosted workspace persistence behind the same load/save interface. +- Added the roadmap phase index so later resource-depth work such as maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes remains visible. + ## 0.1.24 - 2026-07-12 - Fixed the hosted deploy script to use a separate `dist-hosted` output, user-writable remote runtime paths, reliable WSL path conversion, explicit rsync/PM2 failure checks, and rsynced deploy metadata. diff --git a/ROADMAP.md b/ROADMAP.md index 408fa94..01c2c96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,20 @@ Keep one application, not separate apps. Split the code by adapters: The hosted web version is the lead product lane and may add login and collaboration. The desktop version must remain usable without accounts when that lane resumes. +## Phase Index + +- Phase 1: Private hosted preview. +- Phase 2: Server persistence and login. +- Phase 3: Deploy from Gitea. +- Phase 4: Shared app core. +- Phase 5: Storage and search foundation. +- Phase 6: Notes, exports, and Nextcloud. +- Phase 7: Study resource depth: maps, audio Bible, pronunciation audio, commentaries, dictionaries, lexicons, cross-references, and study notes. +- Phase 8: Collaboration. +- Phase 9: Desktop hardening. +- Phase 10: Public web release. +- Phase 11: Mobile and future platforms. + ## Phase 1: Private Hosted Preview Goal: get the current working app online privately on Christ Unscripted as fast as possible and establish the hosted app as the main development target. diff --git a/package-lock.json b/package-lock.json index 529a9ce..1f13dc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libre-study", - "version": "0.1.24", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.1.24", + "version": "0.1.25", "dependencies": { "@tauri-apps/api": "^2.11.1", "react": "^19.2.7", diff --git a/package.json b/package.json index 4db7fb8..eb271ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libre-study", "private": true, - "version": "0.1.24", + "version": "0.1.25", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cbf402..f230508 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.24" +version = "0.1.25" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 979703d..83acdee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.24" +version = "0.1.25" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d83798..90cf1dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Libre Study", - "version": "0.1.24", + "version": "0.1.25", "identifier": "org.librestudy.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index a155eea..1c9efde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,77 +8,30 @@ import { getTranslations, searchVerses, } from './studyClient' +import { + createSession, + defaultTranslationId, + hostedWorkspaceEnabled, + loadWorkspaceSnapshot, + saveWorkspaceSnapshot, + timelineId, + timelineLimit, +} from './workspace' import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types' +import type { + StrongDisplayMode, + StudySession, + StudySettings, + ThemeMode, + TimelineEntry, + ViewMode, +} from './workspace' import './App.css' const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const pageSize = 34 -const sessionStorageKey = 'libre-study.sessions.v1' -const activeSessionStorageKey = 'libre-study.active-session.v1' -const studySettingsStorageKey = 'libre-study.study-settings.v1' -const timelineLimit = 200 -const defaultTranslationId = 'kjv-eng-kjv2006' -const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true' -const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '') -type ViewMode = 'page' | 'study' | 'notes' | 'compare' -type StrongDisplayMode = 'off' | 'highlight' | 'numbers' type PageMotion = 'idle' | 'turn-forward' | 'turn-back' -type ThemeMode = 'light' | 'dark' -type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting' - -type TimelineEntry = { - id: string - at: string - type: TimelineEntryType - label: string - reference?: string - detail?: string -} - -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[] -} - -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 -} - -type WorkspaceSnapshot = { - sessions: StudySession[] - activeSessionId: string - studySettings: StudySettings -} function reference(verse: Verse) { return `${verse.book} ${verse.chapter}:${verse.verse}` @@ -94,75 +47,10 @@ function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) return changed ? next : verses } -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: [], - } -} - -function timelineId() { - return `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - function formatTimelineTime(value: string) { return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } -async function loadRemoteWorkspaceSnapshot(): Promise | null> { - if (!remoteWorkspaceEnabled) return null - - 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) { - if (!remoteWorkspaceEnabled) return - - 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}`) - } -} - function abbreviateBook(book: string) { const abbreviations: Record = { Genesis: 'Gen', @@ -535,66 +423,14 @@ function App() { let cancelled = false async function restoreWorkspace() { - let remoteSnapshot: Partial | null = null - if (remoteWorkspaceEnabled) { - try { - remoteSnapshot = await loadRemoteWorkspaceSnapshot() - } catch (reason) { - setError(String(reason)) - } - } - - const savedStudySettings = remoteSnapshot?.studySettings - ? JSON.stringify(remoteSnapshot.studySettings) - : localStorage.getItem(studySettingsStorageKey) - const savedSessions = remoteSnapshot?.sessions - ? JSON.stringify(remoteSnapshot.sessions) - : localStorage.getItem(sessionStorageKey) - const savedActiveSessionId = - remoteSnapshot?.activeSessionId ?? localStorage.getItem(activeSessionStorageKey) - let nextSessions: StudySession[] = [] - - if (savedStudySettings) { - try { - applyStudySettings(JSON.parse(savedStudySettings) as Partial) - } catch { - localStorage.removeItem(studySettingsStorageKey) - } - } - - if (savedSessions) { - try { - const parsed = JSON.parse(savedSessions) as StudySession[] - nextSessions = Array.isArray(parsed) ? parsed : [] - } catch { - nextSessions = [] - } - } - - if (!nextSessions.length) { - nextSessions = [createSession('KJV Study Session')] - } - - nextSessions = nextSessions.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, - })) - - const selectedSessionId = nextSessions.some((session) => session.id === savedActiveSessionId) - ? savedActiveSessionId ?? nextSessions[0].id - : nextSessions[0].id + const workspace = await loadWorkspaceSnapshot() const selectedSession = - nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0] + workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0] if (cancelled) return - setSessions(nextSessions) - setActiveSessionId(selectedSessionId) + applyStudySettings(workspace.studySettings) + setSessions(workspace.sessions) + setActiveSessionId(workspace.activeSessionId) getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) loadLibrary(selectedSession.baseTranslation) @@ -622,26 +458,7 @@ function App() { }, []) useEffect(() => { - if (sessions.length) { - localStorage.setItem(sessionStorageKey, JSON.stringify(sessions)) - } - }, [sessions]) - - useEffect(() => { - if (activeSessionId) { - localStorage.setItem(activeSessionStorageKey, activeSessionId) - } - }, [activeSessionId]) - - useEffect(() => { - localStorage.setItem( - studySettingsStorageKey, - JSON.stringify(studySettingsSnapshot), - ) - }, [studySettingsSnapshot]) - - useEffect(() => { - if (!remoteWorkspaceEnabled || !workspaceHydrated.current || !sessions.length || !activeSessionId) { + if (!workspaceHydrated.current || !sessions.length || !activeSessionId) { return } @@ -650,12 +467,17 @@ function App() { } remoteWorkspaceSaveTimeout.current = window.setTimeout(() => { - saveRemoteWorkspaceSnapshot({ + saveWorkspaceSnapshot({ sessions, activeSessionId, studySettings: studySettingsSnapshot, }).catch((reason) => { - console.warn('Unable to save hosted workspace.', reason) + console.warn( + hostedWorkspaceEnabled() + ? 'Unable to save hosted workspace.' + : 'Unable to save local workspace.', + reason, + ) }) remoteWorkspaceSaveTimeout.current = null }, 650) diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..3570fbb --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,225 @@ +export const timelineLimit = 200 +export const defaultTranslationId = 'kjv-eng-kjv2006' + +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 = { + sessions: StudySession[] + activeSessionId: string + studySettings: StudySettings +} + +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 async function loadWorkspaceSnapshot(): Promise { + const remoteSnapshot = remoteWorkspaceEnabled ? await loadRemoteWorkspaceSnapshot() : null + const localSnapshot = remoteSnapshot ? null : loadLocalWorkspaceSnapshot() + const source = remoteSnapshot ?? localSnapshot + const sessions = normalizeSessions(source?.sessions) + const activeSessionId = selectActiveSessionId(sessions, source?.activeSessionId) + + return { + sessions, + activeSessionId, + studySettings: normalizeStudySettings(source?.studySettings), + } +} + +export function saveWorkspaceSnapshot(snapshot: WorkspaceSnapshot): Promise { + saveLocalWorkspaceSnapshot(snapshot) + if (!remoteWorkspaceEnabled) return Promise.resolve() + return saveRemoteWorkspaceSnapshot(snapshot) +} + +function loadLocalWorkspaceSnapshot(): Partial { + return { + sessions: readJson(sessionStorageKey) ?? undefined, + activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined, + studySettings: readJson(studySettingsStorageKey) ?? undefined, + } +} + +function saveLocalWorkspaceSnapshot(snapshot: WorkspaceSnapshot) { + 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 | null> { + 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}`) + } +}