Complete notes export foundation
This commit is contained in:
@@ -4,6 +4,14 @@
|
||||
|
||||
- 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
|
||||
|
||||
- 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 4: Shared app core. 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.
|
||||
|
||||
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
|
||||
|
||||
Status: complete for one-way export/import in HotView.
|
||||
|
||||
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:
|
||||
|
||||
- Save notes as structured session data first.
|
||||
- Add export formats: JSON session, Markdown notes, HTML notes, and later sermon/study document formats.
|
||||
- Add Nextcloud integration through WebDAV as an optional sync/export target.
|
||||
- Support one-way export first, then explicit import, then optional sync.
|
||||
- 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 export target.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "libre-study",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"private": true,
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Libre Study",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"identifier": "org.librestudy.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+23
@@ -1529,6 +1529,29 @@ textarea:focus-visible {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
|
||||
+194
-9
@@ -12,6 +12,13 @@ import {
|
||||
getTranslations,
|
||||
searchVerses,
|
||||
} from './studyClient'
|
||||
import {
|
||||
exportFileName,
|
||||
parseSessionImport,
|
||||
putNextcloudExport,
|
||||
renderSessionExport,
|
||||
} from './sessionPortability'
|
||||
import type { ExportFormat } from './sessionPortability'
|
||||
import {
|
||||
createSession,
|
||||
defaultTranslationId,
|
||||
@@ -24,7 +31,7 @@ import {
|
||||
timelineId,
|
||||
timelineLimit,
|
||||
} 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 {
|
||||
AudioBibleContext,
|
||||
AudioBibleSegment,
|
||||
@@ -47,6 +54,7 @@ import './App.css'
|
||||
|
||||
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
||||
const pageSize = 34
|
||||
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
|
||||
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||
|
||||
function reference(verse: Verse) {
|
||||
@@ -232,6 +240,14 @@ function App() {
|
||||
const [newSessionCommentary, setNewSessionCommentary] = useState(true)
|
||||
const [newSessionDictionary, setNewSessionDictionary] = 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 [linkedFontSize, setLinkedFontSize] = useState(15)
|
||||
const [originalCollapsed, setOriginalCollapsed] = useState(false)
|
||||
@@ -275,6 +291,7 @@ function App() {
|
||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||
const readerRef = useRef<HTMLElement | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const importFileRef = useRef<HTMLInputElement | null>(null)
|
||||
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
||||
const audioQueueIndexRef = useRef(0)
|
||||
const hydratedNoteChapters = useRef(new Set<string>())
|
||||
@@ -505,6 +522,8 @@ function App() {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
loadNextcloudSettings()
|
||||
|
||||
restoreWorkspace(() => cancelled).catch((reason) => {
|
||||
if (!cancelled) {
|
||||
setError(String(reason))
|
||||
@@ -1028,7 +1047,22 @@ function App() {
|
||||
|
||||
function updateNoteDraft(value: string) {
|
||||
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) {
|
||||
@@ -1096,17 +1130,101 @@ function App() {
|
||||
|
||||
function exportActiveSession() {
|
||||
if (!activeSession) return
|
||||
const blob = new Blob([JSON.stringify(activeSession, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const rendered = renderSessionExport(activeSession, exportFormat)
|
||||
const blob = new Blob([rendered.body], { type: rendered.mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${activeSession.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'study-session'}.json`
|
||||
link.download = exportFileName(activeSession, exportFormat)
|
||||
link.click()
|
||||
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>) {
|
||||
event.preventDefault()
|
||||
setLoginBusy(true)
|
||||
@@ -2189,9 +2307,38 @@ function App() {
|
||||
Record timeline
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" onClick={exportActiveSession}>
|
||||
Export session
|
||||
</button>
|
||||
<div className="session-portability">
|
||||
<label>
|
||||
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 className="modal-section">
|
||||
@@ -2259,6 +2406,44 @@ function App() {
|
||||
Create session
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
</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
|
||||
}
|
||||
|
||||
export type SessionNote = {
|
||||
id: string
|
||||
kind: 'draft'
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
reference?: string
|
||||
translationId?: string
|
||||
}
|
||||
|
||||
export type StudySession = {
|
||||
id: string
|
||||
title: string
|
||||
@@ -39,6 +49,7 @@ export type StudySession = {
|
||||
viewMode: ViewMode
|
||||
strongDisplay: StrongDisplayMode
|
||||
noteDraft: string
|
||||
notes: SessionNote[]
|
||||
timeline: TimelineEntry[]
|
||||
}
|
||||
|
||||
@@ -140,6 +151,7 @@ export function createSession(title = 'Study Session', baseTranslation = default
|
||||
viewMode: 'notes',
|
||||
strongDisplay: 'highlight',
|
||||
noteDraft: '',
|
||||
notes: [],
|
||||
timeline: [],
|
||||
}
|
||||
}
|
||||
@@ -158,16 +170,51 @@ export function normalizeStudySettings(settings?: Partial<StudySettings> | null)
|
||||
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,
|
||||
}))
|
||||
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,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user