Complete notes export foundation
This commit is contained in:
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
- No unreleased changes.
|
- No unreleased changes.
|
||||||
|
|
||||||
|
## 0.1.33 - 2026-07-15
|
||||||
|
|
||||||
|
- Completed Phase 6 notes/export foundation for the active HotView workflow.
|
||||||
|
- Added structured session notes while keeping the legacy note draft field as a compatibility mirror.
|
||||||
|
- Added session export formats for JSON, Markdown, HTML, and CSV.
|
||||||
|
- Added explicit JSON session import that creates a new imported session instead of overwriting the current one.
|
||||||
|
- Added optional one-way Nextcloud export through WebDAV with the password kept out of workspace persistence.
|
||||||
|
|
||||||
## 0.1.32 - 2026-07-15
|
## 0.1.32 - 2026-07-15
|
||||||
|
|
||||||
- Completed Phase 5 storage/search foundation for the active HotView workflow.
|
- Completed Phase 5 storage/search foundation for the active HotView workflow.
|
||||||
|
|||||||
+9
-5
@@ -54,7 +54,7 @@ Work one phase at a time. A helper, spike, or supporting infrastructure task for
|
|||||||
- Phase 3: HotView development loop and Gitea sync discipline. Complete.
|
- Phase 3: HotView development loop and Gitea sync discipline. Complete.
|
||||||
- Phase 4: Shared app core. Complete.
|
- Phase 4: Shared app core. Complete.
|
||||||
- Phase 5: Storage and search foundation. Complete.
|
- Phase 5: Storage and search foundation. Complete.
|
||||||
- Phase 6: Notes, exports, and optional sync.
|
- Phase 6: Notes, exports, and optional sync. Complete.
|
||||||
- Phase 7: Study resource depth and polish.
|
- Phase 7: Study resource depth and polish.
|
||||||
|
|
||||||
Supporting infrastructure already landed but not currently active as a phase: hosted web preview, hosted login, and deploy scripting. These remain parked until the user explicitly asks for a release/deploy pass.
|
Supporting infrastructure already landed but not currently active as a phase: hosted web preview, hosted login, and deploy scripting. These remain parked until the user explicitly asks for a release/deploy pass.
|
||||||
@@ -153,19 +153,23 @@ Definition of done:
|
|||||||
|
|
||||||
## Phase 6: Notes, Exports, And Nextcloud
|
## Phase 6: Notes, Exports, And Nextcloud
|
||||||
|
|
||||||
|
Status: complete for one-way export/import in HotView.
|
||||||
|
|
||||||
Goal: make study work portable without making Nextcloud the primary database.
|
Goal: make study work portable without making Nextcloud the primary database.
|
||||||
|
|
||||||
|
Current status: session notes are stored as structured note records while preserving the legacy draft field for compatibility. The session modal can export JSON, Markdown, HTML, and CSV, import JSON session exports as new sessions, and send a one-way export to a user-configured Nextcloud WebDAV folder. Nextcloud password input is not saved to the workspace snapshot.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Save notes as structured session data first.
|
- Save notes as structured session data first.
|
||||||
- Add export formats: JSON session, Markdown notes, HTML notes, and later sermon/study document formats.
|
- Add export formats: JSON session, Markdown notes, HTML notes, CSV notes, and later sermon/study document formats.
|
||||||
- Add Nextcloud integration through WebDAV as an optional sync/export target.
|
- Add Nextcloud integration through WebDAV as an optional export target.
|
||||||
- Support one-way export first, then explicit import, then optional sync.
|
- Support one-way export first, then explicit import, then optional sync. Complete through one-way export and explicit JSON import.
|
||||||
- Keep Nextcloud credentials out of public resource packages and out of desktop builds unless the user configures them locally.
|
- Keep Nextcloud credentials out of public resource packages and out of desktop builds unless the user configures them locally.
|
||||||
|
|
||||||
Definition of done:
|
Definition of done:
|
||||||
|
|
||||||
- A session's notes can be exported and optionally synced to a Nextcloud folder without breaking normal local or web persistence.
|
- A session's notes can be exported, imported from JSON, and optionally sent to a Nextcloud folder without breaking normal local or web persistence.
|
||||||
|
|
||||||
## Phase 7: Study Resource Depth
|
## Phase 7: Study Resource Depth
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.32",
|
"version": "0.1.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.32",
|
"version": "0.1.33",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.32",
|
"version": "0.1.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.32"
|
version = "0.1.33"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.32"
|
version = "0.1.33"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -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.32",
|
"version": "0.1.33",
|
||||||
"identifier": "org.librestudy.desktop",
|
"identifier": "org.librestudy.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+23
@@ -1529,6 +1529,29 @@ textarea:focus-visible {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-portability,
|
||||||
|
.nextcloud-export {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-status {
|
||||||
|
margin: 0;
|
||||||
|
color: #657061;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-toggles label {
|
.resource-toggles label {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
|
|||||||
+194
-9
@@ -12,6 +12,13 @@ import {
|
|||||||
getTranslations,
|
getTranslations,
|
||||||
searchVerses,
|
searchVerses,
|
||||||
} from './studyClient'
|
} from './studyClient'
|
||||||
|
import {
|
||||||
|
exportFileName,
|
||||||
|
parseSessionImport,
|
||||||
|
putNextcloudExport,
|
||||||
|
renderSessionExport,
|
||||||
|
} from './sessionPortability'
|
||||||
|
import type { ExportFormat } from './sessionPortability'
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
defaultTranslationId,
|
defaultTranslationId,
|
||||||
@@ -24,7 +31,7 @@ import {
|
|||||||
timelineId,
|
timelineId,
|
||||||
timelineLimit,
|
timelineLimit,
|
||||||
} from './workspace'
|
} from './workspace'
|
||||||
import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
import type { ChangeEvent, DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
AudioBibleContext,
|
AudioBibleContext,
|
||||||
AudioBibleSegment,
|
AudioBibleSegment,
|
||||||
@@ -47,6 +54,7 @@ import './App.css'
|
|||||||
|
|
||||||
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
||||||
const pageSize = 34
|
const pageSize = 34
|
||||||
|
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
|
||||||
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||||
|
|
||||||
function reference(verse: Verse) {
|
function reference(verse: Verse) {
|
||||||
@@ -232,6 +240,14 @@ function App() {
|
|||||||
const [newSessionCommentary, setNewSessionCommentary] = useState(true)
|
const [newSessionCommentary, setNewSessionCommentary] = useState(true)
|
||||||
const [newSessionDictionary, setNewSessionDictionary] = useState(true)
|
const [newSessionDictionary, setNewSessionDictionary] = useState(true)
|
||||||
const [newSessionTimeline, setNewSessionTimeline] = useState(true)
|
const [newSessionTimeline, setNewSessionTimeline] = useState(true)
|
||||||
|
const [exportFormat, setExportFormat] = useState<ExportFormat>('json')
|
||||||
|
const [importStatus, setImportStatus] = useState('')
|
||||||
|
const [nextcloudUrl, setNextcloudUrl] = useState('')
|
||||||
|
const [nextcloudUsername, setNextcloudUsername] = useState('')
|
||||||
|
const [nextcloudPassword, setNextcloudPassword] = useState('')
|
||||||
|
const [nextcloudFolder, setNextcloudFolder] = useState('Libre Study')
|
||||||
|
const [nextcloudStatus, setNextcloudStatus] = useState('')
|
||||||
|
const [nextcloudBusy, setNextcloudBusy] = useState(false)
|
||||||
const [lemmaFontSize, setLemmaFontSize] = useState(17)
|
const [lemmaFontSize, setLemmaFontSize] = useState(17)
|
||||||
const [linkedFontSize, setLinkedFontSize] = useState(15)
|
const [linkedFontSize, setLinkedFontSize] = useState(15)
|
||||||
const [originalCollapsed, setOriginalCollapsed] = useState(false)
|
const [originalCollapsed, setOriginalCollapsed] = useState(false)
|
||||||
@@ -275,6 +291,7 @@ function App() {
|
|||||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||||
const readerRef = useRef<HTMLElement | null>(null)
|
const readerRef = useRef<HTMLElement | null>(null)
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const importFileRef = useRef<HTMLInputElement | null>(null)
|
||||||
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
||||||
const audioQueueIndexRef = useRef(0)
|
const audioQueueIndexRef = useRef(0)
|
||||||
const hydratedNoteChapters = useRef(new Set<string>())
|
const hydratedNoteChapters = useRef(new Set<string>())
|
||||||
@@ -505,6 +522,8 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
|
loadNextcloudSettings()
|
||||||
|
|
||||||
restoreWorkspace(() => cancelled).catch((reason) => {
|
restoreWorkspace(() => cancelled).catch((reason) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(String(reason))
|
setError(String(reason))
|
||||||
@@ -1028,7 +1047,22 @@ function App() {
|
|||||||
|
|
||||||
function updateNoteDraft(value: string) {
|
function updateNoteDraft(value: string) {
|
||||||
setNoteDraft(value)
|
setNoteDraft(value)
|
||||||
patchActiveSession({ noteDraft: value })
|
if (!activeSession) return
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const existingDraft = activeSession.notes.find((note) => note.kind === 'draft')
|
||||||
|
const draftNote = {
|
||||||
|
id: existingDraft?.id ?? timelineId(),
|
||||||
|
kind: 'draft' as const,
|
||||||
|
body: value,
|
||||||
|
createdAt: existingDraft?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference,
|
||||||
|
translationId: activeSession.baseTranslation,
|
||||||
|
}
|
||||||
|
patchActiveSession({
|
||||||
|
noteDraft: value,
|
||||||
|
notes: [draftNote, ...activeSession.notes.filter((note) => note.id !== draftNote.id)],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendNoteClip(value: string) {
|
function appendNoteClip(value: string) {
|
||||||
@@ -1096,17 +1130,101 @@ function App() {
|
|||||||
|
|
||||||
function exportActiveSession() {
|
function exportActiveSession() {
|
||||||
if (!activeSession) return
|
if (!activeSession) return
|
||||||
const blob = new Blob([JSON.stringify(activeSession, null, 2)], {
|
const rendered = renderSessionExport(activeSession, exportFormat)
|
||||||
type: 'application/json',
|
const blob = new Blob([rendered.body], { type: rendered.mimeType })
|
||||||
})
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${activeSession.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'study-session'}.json`
|
link.download = exportFileName(activeSession, exportFormat)
|
||||||
link.click()
|
link.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importSessionFile(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedSession = parseSessionImport(await file.text())
|
||||||
|
const importedAt = new Date().toISOString()
|
||||||
|
const sessionWithImportEvent = {
|
||||||
|
...importedSession,
|
||||||
|
updatedAt: importedAt,
|
||||||
|
timeline:
|
||||||
|
importedSession.timelineEnabled === false
|
||||||
|
? importedSession.timeline
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: timelineId(),
|
||||||
|
at: importedAt,
|
||||||
|
type: 'setting' as const,
|
||||||
|
label: 'Imported session',
|
||||||
|
detail: file.name,
|
||||||
|
},
|
||||||
|
...importedSession.timeline,
|
||||||
|
].slice(0, timelineLimit),
|
||||||
|
}
|
||||||
|
setSessions((current) => [sessionWithImportEvent, ...current])
|
||||||
|
setActiveSessionId(sessionWithImportEvent.id)
|
||||||
|
setSessionModalOpen(false)
|
||||||
|
setImportStatus(`Imported ${sessionWithImportEvent.title}.`)
|
||||||
|
await loadLibrary(sessionWithImportEvent.baseTranslation)
|
||||||
|
} catch (reason) {
|
||||||
|
setImportStatus(`Import failed: ${String(reason)}`)
|
||||||
|
} finally {
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportToNextcloud() {
|
||||||
|
if (!activeSession) return
|
||||||
|
setNextcloudBusy(true)
|
||||||
|
setNextcloudStatus('')
|
||||||
|
try {
|
||||||
|
await putNextcloudExport(activeSession, exportFormat, {
|
||||||
|
webdavUrl: nextcloudUrl.trim(),
|
||||||
|
username: nextcloudUsername.trim(),
|
||||||
|
password: nextcloudPassword,
|
||||||
|
folder: nextcloudFolder.trim(),
|
||||||
|
})
|
||||||
|
saveNextcloudSettings()
|
||||||
|
setNextcloudStatus(`Exported ${exportFileName(activeSession, exportFormat)}.`)
|
||||||
|
recordTimeline({
|
||||||
|
type: 'setting',
|
||||||
|
label: 'Exported session to Nextcloud',
|
||||||
|
detail: exportFormat.toUpperCase(),
|
||||||
|
})
|
||||||
|
} catch (reason) {
|
||||||
|
setNextcloudStatus(String(reason))
|
||||||
|
} finally {
|
||||||
|
setNextcloudBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNextcloudSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(nextcloudSettingsKey)
|
||||||
|
if (!raw) return
|
||||||
|
const settings = JSON.parse(raw) as { webdavUrl?: string; username?: string; folder?: string }
|
||||||
|
setNextcloudUrl(settings.webdavUrl ?? '')
|
||||||
|
setNextcloudUsername(settings.username ?? '')
|
||||||
|
setNextcloudFolder(settings.folder ?? 'Libre Study')
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(nextcloudSettingsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNextcloudSettings() {
|
||||||
|
localStorage.setItem(
|
||||||
|
nextcloudSettingsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
webdavUrl: nextcloudUrl.trim(),
|
||||||
|
username: nextcloudUsername.trim(),
|
||||||
|
folder: nextcloudFolder.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function submitHostedLogin(event: FormEvent<HTMLFormElement>) {
|
async function submitHostedLogin(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setLoginBusy(true)
|
setLoginBusy(true)
|
||||||
@@ -2189,9 +2307,38 @@ function App() {
|
|||||||
Record timeline
|
Record timeline
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={exportActiveSession}>
|
<div className="session-portability">
|
||||||
Export session
|
<label>
|
||||||
</button>
|
Export format
|
||||||
|
<select
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(event) => setExportFormat(event.target.value as ExportFormat)}
|
||||||
|
>
|
||||||
|
<option value="json">JSON session</option>
|
||||||
|
<option value="markdown">Markdown notes</option>
|
||||||
|
<option value="html">HTML notes</option>
|
||||||
|
<option value="csv">CSV notes</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button type="button" onClick={exportActiveSession}>
|
||||||
|
Export file
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => importFileRef.current?.click()}>
|
||||||
|
Import JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden-file-input"
|
||||||
|
onChange={(event) => {
|
||||||
|
importSessionFile(event).catch((reason) => setImportStatus(String(reason)))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{importStatus ? <p className="export-status">{importStatus}</p> : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="modal-section">
|
<section className="modal-section">
|
||||||
@@ -2259,6 +2406,44 @@ function App() {
|
|||||||
Create session
|
Create session
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="modal-section nextcloud-export">
|
||||||
|
<h3>Nextcloud Export</h3>
|
||||||
|
<label>
|
||||||
|
WebDAV URL
|
||||||
|
<input
|
||||||
|
value={nextcloudUrl}
|
||||||
|
onChange={(event) => setNextcloudUrl(event.target.value)}
|
||||||
|
placeholder="https://cloud.example.com/remote.php/dav/files/user"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input value={nextcloudUsername} onChange={(event) => setNextcloudUsername(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password or app password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={nextcloudPassword}
|
||||||
|
onChange={(event) => setNextcloudPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Folder
|
||||||
|
<input value={nextcloudFolder} onChange={(event) => setNextcloudFolder(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportToNextcloud().catch((reason) => setNextcloudStatus(String(reason)))}
|
||||||
|
disabled={nextcloudBusy || !nextcloudUrl.trim() || !nextcloudUsername.trim() || !nextcloudPassword}
|
||||||
|
>
|
||||||
|
{nextcloudBusy ? 'Exporting...' : 'Export to Nextcloud'}
|
||||||
|
</button>
|
||||||
|
<p className="export-status">
|
||||||
|
{nextcloudStatus || 'Password stays in this browser session and is not saved to the workspace.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { normalizeSessions, timelineId, workspaceSchemaVersion } from './workspace'
|
||||||
|
import type { SessionNote, StudySession } from './workspace'
|
||||||
|
|
||||||
|
export type ExportFormat = 'json' | 'markdown' | 'html' | 'csv'
|
||||||
|
|
||||||
|
export type SessionExport = {
|
||||||
|
schema: 'libre-study.session-export.v1'
|
||||||
|
exportedAt: string
|
||||||
|
app: 'Libre Study'
|
||||||
|
session: StudySession
|
||||||
|
notes: SessionNote[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NextcloudExportConfig = {
|
||||||
|
webdavUrl: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
folder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionExport(session: StudySession): SessionExport {
|
||||||
|
return {
|
||||||
|
schema: 'libre-study.session-export.v1',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
app: 'Libre Study',
|
||||||
|
session,
|
||||||
|
notes: session.notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSessionExport(session: StudySession, format: ExportFormat) {
|
||||||
|
const exportData = sessionExport(session)
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'json':
|
||||||
|
return {
|
||||||
|
body: JSON.stringify(exportData, null, 2),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
extension: 'json',
|
||||||
|
}
|
||||||
|
case 'markdown':
|
||||||
|
return {
|
||||||
|
body: renderMarkdown(exportData),
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
extension: 'md',
|
||||||
|
}
|
||||||
|
case 'html':
|
||||||
|
return {
|
||||||
|
body: renderHtml(exportData),
|
||||||
|
mimeType: 'text/html',
|
||||||
|
extension: 'html',
|
||||||
|
}
|
||||||
|
case 'csv':
|
||||||
|
return {
|
||||||
|
body: renderCsv(exportData),
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
extension: 'csv',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFileName(session: StudySession, format: ExportFormat) {
|
||||||
|
const rendered = renderSessionExport(session, format)
|
||||||
|
const slug = session.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
return `${slug || 'study-session'}.${rendered.extension}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionImport(text: string): StudySession {
|
||||||
|
const parsed = JSON.parse(text) as Partial<SessionExport> | Partial<StudySession>
|
||||||
|
const session = isSessionExport(parsed) ? parsed.session : parsed
|
||||||
|
const normalized = normalizeSessions([session as StudySession])[0]
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
id: timelineId(),
|
||||||
|
title: `${normalized.title} Import`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionExport(value: Partial<SessionExport> | Partial<StudySession>): value is SessionExport {
|
||||||
|
return 'schema' in value && value.schema === 'libre-study.session-export.v1' && 'session' in value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putNextcloudExport(
|
||||||
|
session: StudySession,
|
||||||
|
format: ExportFormat,
|
||||||
|
config: NextcloudExportConfig,
|
||||||
|
) {
|
||||||
|
const rendered = renderSessionExport(session, format)
|
||||||
|
const url = nextcloudFileUrl(config, exportFileName(session, format))
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${btoa(`${config.username}:${config.password}`)}`,
|
||||||
|
'Content-Type': rendered.mimeType,
|
||||||
|
},
|
||||||
|
body: rendered.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to export to Nextcloud: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextcloudFileUrl(config: NextcloudExportConfig, fileName: string) {
|
||||||
|
const base = config.webdavUrl.replace(/\/$/, '')
|
||||||
|
const folder = config.folder.trim().replace(/^\/|\/$/g, '')
|
||||||
|
return `${base}/${[folder, fileName].filter(Boolean).map(encodePathSegment).join('/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathSegment(path: string) {
|
||||||
|
return path.split('/').map(encodeURIComponent).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(exportData: SessionExport) {
|
||||||
|
const session = exportData.session
|
||||||
|
const notes = exportData.notes.filter((note) => note.body.trim())
|
||||||
|
return [
|
||||||
|
`# ${session.title}`,
|
||||||
|
'',
|
||||||
|
`- Exported: ${exportData.exportedAt}`,
|
||||||
|
`- Translation: ${session.baseTranslation}`,
|
||||||
|
`- Session schema: ${workspaceSchemaVersion}`,
|
||||||
|
'',
|
||||||
|
'## Notes',
|
||||||
|
'',
|
||||||
|
notes.length
|
||||||
|
? notes.map((note) => note.body.trim()).join('\n\n---\n\n')
|
||||||
|
: '_No notes recorded._',
|
||||||
|
'',
|
||||||
|
'## Timeline',
|
||||||
|
'',
|
||||||
|
session.timeline.length
|
||||||
|
? session.timeline.map((entry) => `- ${entry.at} - ${entry.label}${entry.reference ? ` (${entry.reference})` : ''}${entry.detail ? `: ${entry.detail}` : ''}`).join('\n')
|
||||||
|
: '_No timeline events recorded._',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtml(exportData: SessionExport) {
|
||||||
|
const session = exportData.session
|
||||||
|
const notes = exportData.notes.filter((note) => note.body.trim())
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${escapeHtml(session.title)}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escapeHtml(session.title)}</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>Exported</dt><dd>${escapeHtml(exportData.exportedAt)}</dd>
|
||||||
|
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
|
||||||
|
</dl>
|
||||||
|
<h2>Notes</h2>
|
||||||
|
${notes.length ? notes.map((note) => `<article><pre>${escapeHtml(note.body.trim())}</pre></article>`).join('\n') : '<p>No notes recorded.</p>'}
|
||||||
|
<h2>Timeline</h2>
|
||||||
|
${session.timeline.length ? `<ol>${session.timeline.map((entry) => `<li><time>${escapeHtml(entry.at)}</time> ${escapeHtml(entry.label)}${entry.reference ? ` (${escapeHtml(entry.reference)})` : ''}${entry.detail ? `: ${escapeHtml(entry.detail)}` : ''}</li>`).join('')}</ol>` : '<p>No timeline events recorded.</p>'}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCsv(exportData: SessionExport) {
|
||||||
|
const rows = [
|
||||||
|
['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'body'],
|
||||||
|
...exportData.notes.map((note) => [
|
||||||
|
exportData.session.title,
|
||||||
|
note.id,
|
||||||
|
note.kind,
|
||||||
|
note.createdAt,
|
||||||
|
note.updatedAt,
|
||||||
|
note.reference ?? '',
|
||||||
|
note.translationId ?? '',
|
||||||
|
note.body,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
return rows.map((row) => row.map(csvCell).join(',')).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
+57
-10
@@ -23,6 +23,16 @@ export type TimelineEntry = {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionNote = {
|
||||||
|
id: string
|
||||||
|
kind: 'draft'
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
reference?: string
|
||||||
|
translationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type StudySession = {
|
export type StudySession = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -39,6 +49,7 @@ export type StudySession = {
|
|||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
strongDisplay: StrongDisplayMode
|
strongDisplay: StrongDisplayMode
|
||||||
noteDraft: string
|
noteDraft: string
|
||||||
|
notes: SessionNote[]
|
||||||
timeline: TimelineEntry[]
|
timeline: TimelineEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +151,7 @@ export function createSession(title = 'Study Session', baseTranslation = default
|
|||||||
viewMode: 'notes',
|
viewMode: 'notes',
|
||||||
strongDisplay: 'highlight',
|
strongDisplay: 'highlight',
|
||||||
noteDraft: '',
|
noteDraft: '',
|
||||||
|
notes: [],
|
||||||
timeline: [],
|
timeline: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,16 +170,51 @@ export function normalizeStudySettings(settings?: Partial<StudySettings> | null)
|
|||||||
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
|
export function normalizeSessions(sessions?: StudySession[] | null): StudySession[] {
|
||||||
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
|
const source = Array.isArray(sessions) && sessions.length ? sessions : [createSession('KJV Study Session')]
|
||||||
|
|
||||||
return source.map((session) => ({
|
return source.map((session) => {
|
||||||
...createSession(session.title),
|
const normalized = {
|
||||||
...session,
|
...createSession(session.title),
|
||||||
baseTranslation: session.baseTranslation ?? defaultTranslationId,
|
...session,
|
||||||
footnotesEnabled: session.footnotesEnabled ?? true,
|
baseTranslation: session.baseTranslation ?? defaultTranslationId,
|
||||||
strongsEnabled: session.strongsEnabled ?? true,
|
footnotesEnabled: session.footnotesEnabled ?? true,
|
||||||
commentaryEnabled: session.commentaryEnabled ?? true,
|
strongsEnabled: session.strongsEnabled ?? true,
|
||||||
dictionaryEnabled: session.dictionaryEnabled ?? true,
|
commentaryEnabled: session.commentaryEnabled ?? true,
|
||||||
timelineEnabled: session.timelineEnabled ?? true,
|
dictionaryEnabled: session.dictionaryEnabled ?? 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<StudySession>): SessionNote[] {
|
||||||
|
if (Array.isArray(session.notes) && session.notes.length) {
|
||||||
|
return session.notes.map((note) => ({
|
||||||
|
id: note.id || timelineId(),
|
||||||
|
kind: '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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user