Extract shared workspace core

This commit is contained in:
2026-07-12 12:54:16 -05:00
parent 2fce849666
commit 8aafd3061b
9 changed files with 281 additions and 214 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 ## 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. - 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.
+14
View File
@@ -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. 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 ## 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. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.24", "version": "0.1.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.24", "version": "0.1.25",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"react": "^19.2.7", "react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.24", "version": "0.1.25",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.24" version = "0.1.25"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.24" version = "0.1.25"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.24", "version": "0.1.25",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+30 -208
View File
@@ -8,77 +8,30 @@ import {
getTranslations, getTranslations,
searchVerses, searchVerses,
} from './studyClient' } from './studyClient'
import {
createSession,
defaultTranslationId,
hostedWorkspaceEnabled,
loadWorkspaceSnapshot,
saveWorkspaceSnapshot,
timelineId,
timelineLimit,
} from './workspace'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types' import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
import type {
StrongDisplayMode,
StudySession,
StudySettings,
ThemeMode,
TimelineEntry,
ViewMode,
} from './workspace'
import './App.css' import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34 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 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) { function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${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 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) { function formatTimelineTime(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} }
async function loadRemoteWorkspaceSnapshot(): Promise<Partial<WorkspaceSnapshot> | 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<Partial<WorkspaceSnapshot>>
}
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) { function abbreviateBook(book: string) {
const abbreviations: Record<string, string> = { const abbreviations: Record<string, string> = {
Genesis: 'Gen', Genesis: 'Gen',
@@ -535,66 +423,14 @@ function App() {
let cancelled = false let cancelled = false
async function restoreWorkspace() { async function restoreWorkspace() {
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null const workspace = await loadWorkspaceSnapshot()
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<StudySettings>)
} 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 selectedSession = const selectedSession =
nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0] workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
if (cancelled) return if (cancelled) return
setSessions(nextSessions) applyStudySettings(workspace.studySettings)
setActiveSessionId(selectedSessionId) setSessions(workspace.sessions)
setActiveSessionId(workspace.activeSessionId)
getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
loadLibrary(selectedSession.baseTranslation) loadLibrary(selectedSession.baseTranslation)
@@ -622,26 +458,7 @@ function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (sessions.length) { if (!workspaceHydrated.current || !sessions.length || !activeSessionId) {
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) {
return return
} }
@@ -650,12 +467,17 @@ function App() {
} }
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => { remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
saveRemoteWorkspaceSnapshot({ saveWorkspaceSnapshot({
sessions, sessions,
activeSessionId, activeSessionId,
studySettings: studySettingsSnapshot, studySettings: studySettingsSnapshot,
}).catch((reason) => { }).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 remoteWorkspaceSaveTimeout.current = null
}, 650) }, 650)
+225
View File
@@ -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<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 async function loadWorkspaceSnapshot(): Promise<WorkspaceSnapshot> {
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<void> {
saveLocalWorkspaceSnapshot(snapshot)
if (!remoteWorkspaceEnabled) return Promise.resolve()
return saveRemoteWorkspaceSnapshot(snapshot)
}
function loadLocalWorkspaceSnapshot(): Partial<WorkspaceSnapshot> {
return {
sessions: readJson<StudySession[]>(sessionStorageKey) ?? undefined,
activeSessionId: localStorage.getItem(activeSessionStorageKey) ?? undefined,
studySettings: readJson<StudySettings>(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<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<Partial<WorkspaceSnapshot> | 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<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}`)
}
}