Complete phase 10 notebook clipping workflow
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Completed Phase 10 notebook and clipping workflow with structured notebook blocks for verse/resource clips, links, pasted screenshots/images, and export metadata.
|
||||||
- Added a Parallel study-stack book that follows the pinned verse, compares it against an alternate translation, and shows alternate translation notes with previous/next context.
|
- Added a Parallel study-stack book that follows the pinned verse, compares it against an alternate translation, and shows alternate translation notes with previous/next context.
|
||||||
- Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered.
|
- Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered.
|
||||||
- Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order.
|
- Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order.
|
||||||
|
|||||||
+2
-1
@@ -83,12 +83,13 @@ Goal: make the reader, study margin, notes, search, and resource layout feel lik
|
|||||||
Current milestone:
|
Current milestone:
|
||||||
|
|
||||||
- Phase 9: Bible desk layout and verse-anchored notes. Complete.
|
- Phase 9: Bible desk layout and verse-anchored notes. Complete.
|
||||||
|
- Phase 10: Notebook and clipping workflow. Complete.
|
||||||
|
|
||||||
Near candidates:
|
Near candidates:
|
||||||
|
|
||||||
- Improve search result navigation and saved searches.
|
- Improve search result navigation and saved searches.
|
||||||
- Add cross-reference display once a LibreBible package exists.
|
- Add cross-reference display once a LibreBible package exists.
|
||||||
- Improve note clipping, highlights, tags, and sermon/study workflows.
|
- Improve highlights, tags, and sermon/study document workflows.
|
||||||
- Polish page layout, responsive behavior, and accessibility.
|
- Polish page layout, responsive behavior, and accessibility.
|
||||||
|
|
||||||
### Resource Ecosystem
|
### Resource Ecosystem
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Current foundation:
|
|||||||
- Session-aware resource toggles.
|
- Session-aware resource toggles.
|
||||||
- Strong's links and definitions.
|
- Strong's links and definitions.
|
||||||
- Verse-anchored margin notes with markers and export metadata.
|
- Verse-anchored margin notes with markers and export metadata.
|
||||||
- Notebook book in the study stack.
|
- Notebook book in the study stack with structured text/link/resource/image clip blocks.
|
||||||
- Timeline.
|
- Timeline.
|
||||||
- Search.
|
- Search.
|
||||||
- Maps and audio controls where resources exist.
|
- Maps and audio controls where resources exist.
|
||||||
@@ -22,7 +22,7 @@ Good next milestones:
|
|||||||
|
|
||||||
- Cross-reference UX after a LibreBible package exists.
|
- Cross-reference UX after a LibreBible package exists.
|
||||||
- Better search result navigation and saved searches.
|
- Better search result navigation and saved searches.
|
||||||
- Highlights, richer note clipping, and image/link notebook blocks.
|
- Highlights, clip organization, and richer notebook editing.
|
||||||
- Sermon/study document workflows.
|
- Sermon/study document workflows.
|
||||||
- Responsive layout and accessibility pass.
|
- Responsive layout and accessibility pass.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Working HotView behavior includes:
|
|||||||
- KJV audio controls from the packaged Eliran Wong source.
|
- KJV audio controls from the packaged Eliran Wong source.
|
||||||
- Session-aware resource toggles.
|
- Session-aware resource toggles.
|
||||||
- Notes, timeline, imports, exports, and one-way Nextcloud WebDAV export.
|
- Notes, timeline, imports, exports, and one-way Nextcloud WebDAV export.
|
||||||
|
- Structured notebook clips for verses, study resources, links, and pasted/dropped images.
|
||||||
|
|
||||||
Architecture now includes:
|
Architecture now includes:
|
||||||
|
|
||||||
@@ -97,3 +98,13 @@ Phase 9 started the Study Experience track by changing the workspace into a Bibl
|
|||||||
- The middle margin now focuses on the hovered or clicked verse and keeps compact verse material there.
|
- The middle margin now focuses on the hovered or clicked verse and keeps compact verse material there.
|
||||||
- Margin notes can be written directly against a verse, carry markers like `!` and `?`, and preserve book/chapter/verse plus translation context for export.
|
- Margin notes can be written directly against a verse, carry markers like `!` and `?`, and preserve book/chapter/verse plus translation context for export.
|
||||||
- Session exports now include margin-note anchor metadata in JSON, Markdown, HTML, and CSV.
|
- Session exports now include margin-note anchor metadata in JSON, Markdown, HTML, and CSV.
|
||||||
|
|
||||||
|
## Phase 10 Result
|
||||||
|
|
||||||
|
Phase 10 continued the Study Experience track by making the study-stack notebook a real clipping destination:
|
||||||
|
|
||||||
|
- Notebook notes now preserve structured blocks alongside the legacy draft text.
|
||||||
|
- Verses and study resources can carry clip metadata when dragged into the notebook.
|
||||||
|
- The notebook accepts dropped text, links, resource clips, image files, and pasted screenshots/images.
|
||||||
|
- Clips retain source type, title, timestamp, translation, and verse anchor when available.
|
||||||
|
- Session exports include notebook block data in JSON, Markdown, HTML, and CSV.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"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.36",
|
"version": "0.1.37",
|
||||||
"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.36"
|
version = "0.1.37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.36"
|
version = "0.1.37"
|
||||||
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.36",
|
"version": "0.1.37",
|
||||||
"identifier": "org.librestudy.desktop",
|
"identifier": "org.librestudy.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+131
@@ -1367,6 +1367,118 @@ textarea:focus-visible {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-current-verse {
|
||||||
|
justify-self: start;
|
||||||
|
border-color: #2f6f5e;
|
||||||
|
background: #eef6f1;
|
||||||
|
color: #1f5145;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-drop-target {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed #b8ae9b;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fbf8ef;
|
||||||
|
color: #657061;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-drop-target:hover,
|
||||||
|
.clip-drop-target:focus-within {
|
||||||
|
border-color: #2f6f5e;
|
||||||
|
background: #eef6f1;
|
||||||
|
color: #1f5145;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e1d8c8;
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fffdf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block.clip {
|
||||||
|
border-left-color: #2f6f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block.link {
|
||||||
|
border-left-color: #5977a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block.image {
|
||||||
|
border-left-color: #a05d49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block > header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block > header span {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8dfcf;
|
||||||
|
color: #657061;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block > header strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #202124;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block time,
|
||||||
|
.notebook-block small {
|
||||||
|
color: #657061;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block p {
|
||||||
|
margin: 0;
|
||||||
|
color: #3f443d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block a {
|
||||||
|
color: #365f91;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-block img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
border: 1px solid #e4dccd;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.article-book article {
|
.article-book article {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -2133,6 +2245,8 @@ textarea:focus-visible {
|
|||||||
.theme-dark .original-language-verse,
|
.theme-dark .original-language-verse,
|
||||||
.theme-dark .english-strong-line,
|
.theme-dark .english-strong-line,
|
||||||
.theme-dark .resource-card,
|
.theme-dark .resource-card,
|
||||||
|
.theme-dark .clip-drop-target,
|
||||||
|
.theme-dark .notebook-block,
|
||||||
.theme-dark .margin-order-list div,
|
.theme-dark .margin-order-list div,
|
||||||
.theme-dark .parallel-current-verse,
|
.theme-dark .parallel-current-verse,
|
||||||
.theme-dark .parallel-context section,
|
.theme-dark .parallel-context section,
|
||||||
@@ -2145,6 +2259,19 @@ textarea:focus-visible {
|
|||||||
background: #20251f;
|
background: #20251f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-dark .clip-current-verse,
|
||||||
|
.theme-dark .clip-drop-target:hover,
|
||||||
|
.theme-dark .clip-drop-target:focus-within {
|
||||||
|
border-color: #527b68;
|
||||||
|
background: #203329;
|
||||||
|
color: #dfe8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .notebook-block > header span {
|
||||||
|
background: #343b31;
|
||||||
|
color: #aaa996;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-dark .context-map {
|
.theme-dark .context-map {
|
||||||
border-color: #3f493f;
|
border-color: #3f493f;
|
||||||
background:
|
background:
|
||||||
@@ -2205,6 +2332,8 @@ textarea:focus-visible {
|
|||||||
.theme-dark .parallel-controls label,
|
.theme-dark .parallel-controls label,
|
||||||
.theme-dark .parallel-context strong,
|
.theme-dark .parallel-context strong,
|
||||||
.theme-dark .parallel-note-sections h3,
|
.theme-dark .parallel-note-sections h3,
|
||||||
|
.theme-dark .notebook-block time,
|
||||||
|
.theme-dark .notebook-block small,
|
||||||
.theme-dark .session-facts dt,
|
.theme-dark .session-facts dt,
|
||||||
.theme-dark .about-list dt,
|
.theme-dark .about-list dt,
|
||||||
.theme-dark .modal-section label,
|
.theme-dark .modal-section label,
|
||||||
@@ -2232,6 +2361,8 @@ textarea:focus-visible {
|
|||||||
.theme-dark .parallel-current-verse p:last-child,
|
.theme-dark .parallel-current-verse p:last-child,
|
||||||
.theme-dark .parallel-context p,
|
.theme-dark .parallel-context p,
|
||||||
.theme-dark .parallel-note-sections p,
|
.theme-dark .parallel-note-sections p,
|
||||||
|
.theme-dark .notebook-block p,
|
||||||
|
.theme-dark .notebook-block > header strong,
|
||||||
.theme-dark .parallel-controls output {
|
.theme-dark .parallel-controls output {
|
||||||
color: #eee8d8 !important;
|
color: #eee8d8 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
+236
-15
@@ -46,6 +46,7 @@ import type {
|
|||||||
StrongDisplayMode,
|
StrongDisplayMode,
|
||||||
HostedUser,
|
HostedUser,
|
||||||
MarginPanelId,
|
MarginPanelId,
|
||||||
|
NotebookBlock,
|
||||||
SessionNote,
|
SessionNote,
|
||||||
StudyBook,
|
StudyBook,
|
||||||
StudySession,
|
StudySession,
|
||||||
@@ -62,6 +63,15 @@ const pageSize = 34
|
|||||||
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
|
const nextcloudSettingsKey = 'libre-study.nextcloud-export.v1'
|
||||||
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
||||||
type DeskResizeTarget = 'bible-margin' | 'margin-stack'
|
type DeskResizeTarget = 'bible-margin' | 'margin-stack'
|
||||||
|
type ClipPayload = {
|
||||||
|
text: string
|
||||||
|
title?: string
|
||||||
|
url?: string
|
||||||
|
type?: NotebookBlock['type']
|
||||||
|
sourceKind?: NonNullable<NotebookBlock['source']>['kind']
|
||||||
|
dataUrl?: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
type WindowedItems<T> = {
|
type WindowedItems<T> = {
|
||||||
items: T[]
|
items: T[]
|
||||||
start: number
|
start: number
|
||||||
@@ -131,6 +141,10 @@ function verseAnchor(verse: Verse, translationId: string, endVerse = verse.verse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyUrl(value: string) {
|
||||||
|
return /^https?:\/\/\S+$/i.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) {
|
function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) {
|
||||||
let changed = false
|
let changed = false
|
||||||
const next = verses.map((verse) => {
|
const next = verses.map((verse) => {
|
||||||
@@ -1356,6 +1370,7 @@ function App() {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference,
|
reference: selectedVerse ? reference(selectedVerse) : existingDraft?.reference,
|
||||||
translationId: activeSession.baseTranslation,
|
translationId: activeSession.baseTranslation,
|
||||||
|
blocks: existingDraft?.blocks ?? [],
|
||||||
}
|
}
|
||||||
patchActiveSession({
|
patchActiveSession({
|
||||||
noteDraft: value,
|
noteDraft: value,
|
||||||
@@ -1363,6 +1378,28 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertDraftNote(patch: Partial<SessionNote>) {
|
||||||
|
if (!activeSession) return
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const existingDraft = activeSession.notes.find((note) => note.kind === 'draft')
|
||||||
|
const draftNote: SessionNote = {
|
||||||
|
id: existingDraft?.id ?? timelineId(),
|
||||||
|
kind: 'draft',
|
||||||
|
body: patch.body ?? existingDraft?.body ?? noteDraft,
|
||||||
|
createdAt: existingDraft?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
reference: patch.reference ?? existingDraft?.reference ?? (pinnedVerse ? reference(pinnedVerse) : undefined),
|
||||||
|
translationId: patch.translationId ?? existingDraft?.translationId ?? activeSession.baseTranslation,
|
||||||
|
blocks: patch.blocks ?? existingDraft?.blocks ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
patchActiveSession({
|
||||||
|
noteDraft: draftNote.body,
|
||||||
|
notes: [draftNote, ...activeSession.notes.filter((note) => note.id !== draftNote.id)],
|
||||||
|
})
|
||||||
|
setNoteDraft(draftNote.body)
|
||||||
|
}
|
||||||
|
|
||||||
function updateMarginNote(value: string) {
|
function updateMarginNote(value: string) {
|
||||||
if (!activeSession || !selectedAnchor) return
|
if (!activeSession || !selectedAnchor) return
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -1432,19 +1469,105 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendNoteClip(value: string) {
|
function appendNotebookBlock(payload: ClipPayload) {
|
||||||
const clip = value.trim()
|
if (!activeSession) return
|
||||||
|
const clip = payload.text.trim()
|
||||||
if (!clip) return
|
if (!clip) return
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const existingDraft = activeSession.notes.find((note) => note.kind === 'draft')
|
||||||
|
const anchor = pinnedVerse ? verseAnchor(pinnedVerse, activeSession.baseTranslation) : undefined
|
||||||
|
const block: NotebookBlock = {
|
||||||
|
id: timelineId(),
|
||||||
|
type: payload.type ?? (payload.dataUrl ? 'image' : payload.url || isLikelyUrl(clip) ? 'link' : 'clip'),
|
||||||
|
body: clip,
|
||||||
|
title: payload.title,
|
||||||
|
url: payload.url ?? (isLikelyUrl(clip) ? clip : undefined),
|
||||||
|
dataUrl: payload.dataUrl,
|
||||||
|
mimeType: payload.mimeType,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
reference: anchor?.reference,
|
||||||
|
translationId: activeSession.baseTranslation,
|
||||||
|
anchor,
|
||||||
|
source: {
|
||||||
|
label: payload.title ?? anchor?.reference ?? 'Notebook clip',
|
||||||
|
kind: payload.sourceKind ?? (payload.dataUrl ? 'image' : payload.url || isLikelyUrl(clip) ? 'web' : 'resource'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const nextBlocks = [block, ...(existingDraft?.blocks ?? [])]
|
||||||
const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip
|
const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip
|
||||||
updateNoteDraft(nextValue)
|
upsertDraftNote({
|
||||||
|
body: nextValue,
|
||||||
|
blocks: nextBlocks,
|
||||||
|
reference: anchor?.reference,
|
||||||
|
translationId: activeSession.baseTranslation,
|
||||||
|
})
|
||||||
setActiveStudyBook('notebook')
|
setActiveStudyBook('notebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTextDrag(event: DragEvent<HTMLElement>, value: string) {
|
function appendNoteClip(value: string) {
|
||||||
|
appendNotebookBlock({ text: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendDroppedFiles(files: FileList | null) {
|
||||||
|
if (!files?.length) return false
|
||||||
|
let handled = false
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!file.type.startsWith('image/')) continue
|
||||||
|
const dataUrl = await readFileAsDataUrl(file)
|
||||||
|
appendNotebookBlock({
|
||||||
|
text: file.name,
|
||||||
|
title: file.name,
|
||||||
|
type: 'image',
|
||||||
|
sourceKind: 'image',
|
||||||
|
dataUrl,
|
||||||
|
mimeType: file.type,
|
||||||
|
})
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error(`Unable to read ${file.name}.`))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTextDrag(event: DragEvent<HTMLElement>, value: string, payload?: Partial<ClipPayload>) {
|
||||||
event.dataTransfer.setData('text/plain', value)
|
event.dataTransfer.setData('text/plain', value)
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-libre-study-clip',
|
||||||
|
JSON.stringify({
|
||||||
|
text: value,
|
||||||
|
...payload,
|
||||||
|
}),
|
||||||
|
)
|
||||||
event.dataTransfer.effectAllowed = 'copy'
|
event.dataTransfer.effectAllowed = 'copy'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clipPayloadFromDrop(event: DragEvent<HTMLElement>): ClipPayload {
|
||||||
|
const raw = event.dataTransfer.getData('application/x-libre-study-clip')
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ClipPayload>
|
||||||
|
if (parsed.text) return { text: parsed.text, ...parsed }
|
||||||
|
} catch {
|
||||||
|
// Fall back to plain text.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uri = event.dataTransfer.getData('text/uri-list')
|
||||||
|
const text = event.dataTransfer.getData('text/plain')
|
||||||
|
return {
|
||||||
|
text: text || uri,
|
||||||
|
url: uri || (isLikelyUrl(text) ? text : undefined),
|
||||||
|
sourceKind: uri || isLikelyUrl(text) ? 'web' : 'resource',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleNotesDragOpen() {
|
function scheduleNotesDragOpen() {
|
||||||
if (studyStackPinned || notesDragOpenTimeout.current) return
|
if (studyStackPinned || notesDragOpenTimeout.current) return
|
||||||
notesDragOpenTimeout.current = window.setTimeout(() => {
|
notesDragOpenTimeout.current = window.setTimeout(() => {
|
||||||
@@ -1941,7 +2064,12 @@ function App() {
|
|||||||
<p
|
<p
|
||||||
key={note.id}
|
key={note.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${note.reference} ${note.text}`, {
|
||||||
|
title: note.reference,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<strong>{note.reference}</strong>
|
<strong>{note.reference}</strong>
|
||||||
<span>{note.text}</span>
|
<span>{note.text}</span>
|
||||||
@@ -2091,6 +2219,8 @@ function App() {
|
|||||||
|
|
||||||
function renderNotebookBook() {
|
function renderNotebookBook() {
|
||||||
if (!selectedVerse) return null
|
if (!selectedVerse) return null
|
||||||
|
const draftNote = activeSession?.notes.find((note) => note.kind === 'draft')
|
||||||
|
const notebookBlocks = draftNote?.blocks ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack-book notebook-book" aria-label="Notebook">
|
<section className="stack-book notebook-book" aria-label="Notebook">
|
||||||
@@ -2101,16 +2231,78 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<span>{activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes</span>
|
<span>{activeSession?.notes.filter((note) => note.kind === 'margin').length ?? 0} margin notes</span>
|
||||||
</header>
|
</header>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clip-current-verse"
|
||||||
|
onClick={() => {
|
||||||
|
if (!pinnedVerse) return
|
||||||
|
appendNotebookBlock({
|
||||||
|
text: `${reference(pinnedVerse)} ${pinnedVerse.text}`,
|
||||||
|
title: reference(pinnedVerse),
|
||||||
|
type: 'clip',
|
||||||
|
sourceKind: 'verse',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clip current verse
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
value={noteDraft}
|
value={noteDraft}
|
||||||
onChange={(event) => updateNoteDraft(event.target.value)}
|
onChange={(event) => updateNoteDraft(event.target.value)}
|
||||||
|
onPaste={(event) => {
|
||||||
|
const imageFile = Array.from(event.clipboardData.files).find((file) => file.type.startsWith('image/'))
|
||||||
|
if (!imageFile) return
|
||||||
|
event.preventDefault()
|
||||||
|
appendDroppedFiles(event.clipboardData.files).catch((reason) => setError(String(reason)))
|
||||||
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
appendNoteClip(event.dataTransfer.getData('text/plain'))
|
appendDroppedFiles(event.dataTransfer.files)
|
||||||
|
.then((handled) => {
|
||||||
|
if (!handled) appendNotebookBlock(clipPayloadFromDrop(event))
|
||||||
|
})
|
||||||
|
.catch((reason) => setError(String(reason)))
|
||||||
}}
|
}}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
placeholder="Write sermon notes, study observations, paste links, or drop clippings here."
|
placeholder="Write sermon notes, study observations, paste links, or drop clippings here."
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="clip-drop-target"
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
appendDroppedFiles(event.dataTransfer.files)
|
||||||
|
.then((handled) => {
|
||||||
|
if (!handled) appendNotebookBlock(clipPayloadFromDrop(event))
|
||||||
|
})
|
||||||
|
.catch((reason) => setError(String(reason)))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Drop text, links, verses, notes, or images here.
|
||||||
|
</div>
|
||||||
|
{notebookBlocks.length ? (
|
||||||
|
<div className="notebook-block-list" aria-label="Notebook clips">
|
||||||
|
{notebookBlocks.map((block) => (
|
||||||
|
<article key={block.id} className={`notebook-block ${block.type}`}>
|
||||||
|
<header>
|
||||||
|
<span>{block.type}</span>
|
||||||
|
<strong>{block.title || block.reference || block.source?.label || 'Notebook clip'}</strong>
|
||||||
|
<time>{new Date(block.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</time>
|
||||||
|
</header>
|
||||||
|
{block.dataUrl ? (
|
||||||
|
<img src={block.dataUrl} alt={block.title || block.body || 'Notebook image'} />
|
||||||
|
) : null}
|
||||||
|
{block.url ? (
|
||||||
|
<a href={block.url}>{block.url}</a>
|
||||||
|
) : null}
|
||||||
|
<p>{block.body}</p>
|
||||||
|
{block.reference ? <small>{block.reference}</small> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="resource-disabled">No structured clips yet.</p>
|
||||||
|
)}
|
||||||
<div className="tag-editor">
|
<div className="tag-editor">
|
||||||
<input
|
<input
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
@@ -2219,6 +2411,10 @@ function App() {
|
|||||||
`${entry.term}\n${entry.definitions
|
`${entry.term}\n${entry.definitions
|
||||||
.map((definition) => `${definition.sourceName}: ${definition.text}`)
|
.map((definition) => `${definition.sourceName}: ${definition.text}`)
|
||||||
.join('\n\n')}`,
|
.join('\n\n')}`,
|
||||||
|
{
|
||||||
|
title: entry.term,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -2257,7 +2453,12 @@ function App() {
|
|||||||
<article
|
<article
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`, {
|
||||||
|
title: entry.reference,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="article-kicker">{entry.source}</p>
|
<p className="article-kicker">{entry.source}</p>
|
||||||
<h3>{entry.title || entry.reference}</h3>
|
<h3>{entry.title || entry.reference}</h3>
|
||||||
@@ -2461,7 +2662,12 @@ function App() {
|
|||||||
<p
|
<p
|
||||||
key={note.id}
|
key={note.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${note.reference} ${note.text}`, {
|
||||||
|
title: note.reference,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<strong>{note.reference}</strong>
|
<strong>{note.reference}</strong>
|
||||||
<span>{note.text}</span>
|
<span>{note.text}</span>
|
||||||
@@ -2478,7 +2684,12 @@ function App() {
|
|||||||
<p
|
<p
|
||||||
key={note.id}
|
key={note.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${note.reference} ${note.text}`, {
|
||||||
|
title: note.reference,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<strong>{note.reference}</strong>
|
<strong>{note.reference}</strong>
|
||||||
<span>{note.text}</span>
|
<span>{note.text}</span>
|
||||||
@@ -2698,7 +2909,12 @@ function App() {
|
|||||||
draggable
|
draggable
|
||||||
onMouseEnter={() => setHoveredId(verse.id)}
|
onMouseEnter={() => setHoveredId(verse.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
onDragStart={(event) => startTextDrag(event, `${reference(verse)} ${verse.text}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${reference(verse)} ${verse.text}`, {
|
||||||
|
title: reference(verse),
|
||||||
|
sourceKind: 'verse',
|
||||||
|
})
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHoveredId(null)
|
setHoveredId(null)
|
||||||
selectVerse(verse)
|
selectVerse(verse)
|
||||||
@@ -2735,7 +2951,7 @@ function App() {
|
|||||||
aria-label="Show previous chapters"
|
aria-label="Show previous chapters"
|
||||||
onClick={() => setChapterTabOffset((current) => current - Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
onClick={() => setChapterTabOffset((current) => current - Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
||||||
>
|
>
|
||||||
‹
|
<
|
||||||
</button>
|
</button>
|
||||||
{visibleChapterTabs.items.map((chapter) => (
|
{visibleChapterTabs.items.map((chapter) => (
|
||||||
<button
|
<button
|
||||||
@@ -2754,7 +2970,7 @@ function App() {
|
|||||||
aria-label="Show next chapters"
|
aria-label="Show next chapters"
|
||||||
onClick={() => setChapterTabOffset((current) => current + Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
onClick={() => setChapterTabOffset((current) => current + Math.max(1, chapterTabCapacity.visibleCount - 2))}
|
||||||
>
|
>
|
||||||
›
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2866,7 +3082,12 @@ function App() {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => recordFootnote(note.reference, note.text)}
|
onClick={() => recordFootnote(note.reference, note.text)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
onDragStart={(event) =>
|
||||||
|
startTextDrag(event, `${note.reference} ${note.text}`, {
|
||||||
|
title: note.reference,
|
||||||
|
sourceKind: 'resource',
|
||||||
|
})
|
||||||
|
}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -2898,7 +3119,7 @@ function App() {
|
|||||||
aria-label="Show previous books"
|
aria-label="Show previous books"
|
||||||
onClick={() => setBookTabOffset((current) => current - Math.max(1, bookTabCapacity.visibleCount - 2))}
|
onClick={() => setBookTabOffset((current) => current - Math.max(1, bookTabCapacity.visibleCount - 2))}
|
||||||
>
|
>
|
||||||
‹
|
<
|
||||||
</button>
|
</button>
|
||||||
{visibleBookTabs.items.map((book) => (
|
{visibleBookTabs.items.map((book) => (
|
||||||
<button
|
<button
|
||||||
@@ -2918,7 +3139,7 @@ function App() {
|
|||||||
aria-label="Show next books"
|
aria-label="Show next books"
|
||||||
onClick={() => setBookTabOffset((current) => current + Math.max(1, bookTabCapacity.visibleCount - 2))}
|
onClick={() => setBookTabOffset((current) => current + Math.max(1, bookTabCapacity.visibleCount - 2))}
|
||||||
>
|
>
|
||||||
›
|
>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { normalizeSessions, timelineId, workspaceSchemaVersion } from './workspace'
|
import { normalizeSessions, timelineId, workspaceSchemaVersion } from './workspace'
|
||||||
import type { SessionNote, StudySession } from './workspace'
|
import type { NotebookBlock, SessionNote, StudySession } from './workspace'
|
||||||
|
|
||||||
export type ExportFormat = 'json' | 'markdown' | 'html' | 'csv'
|
export type ExportFormat = 'json' | 'markdown' | 'html' | 'csv'
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ function renderMarkdown(exportData: SessionExport) {
|
|||||||
'## Notes',
|
'## Notes',
|
||||||
'',
|
'',
|
||||||
notes.length
|
notes.length
|
||||||
? notes.map((note) => [`### ${noteHeading(note)}`, '', note.body.trim() || markerSummary(note)].join('\n')).join('\n\n---\n\n')
|
? notes.map(renderMarkdownNote).join('\n\n---\n\n')
|
||||||
: '_No notes recorded._',
|
: '_No notes recorded._',
|
||||||
'',
|
'',
|
||||||
'## Timeline',
|
'## Timeline',
|
||||||
@@ -154,7 +154,7 @@ function renderHtml(exportData: SessionExport) {
|
|||||||
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
|
<dt>Translation</dt><dd>${escapeHtml(session.baseTranslation)}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<h2>Notes</h2>
|
<h2>Notes</h2>
|
||||||
${notes.length ? notes.map((note) => `<article><h3>${escapeHtml(noteHeading(note))}</h3><pre>${escapeHtml(note.body.trim() || markerSummary(note))}</pre></article>`).join('\n') : '<p>No notes recorded.</p>'}
|
${notes.length ? notes.map(renderHtmlNote).join('\n') : '<p>No notes recorded.</p>'}
|
||||||
<h2>Timeline</h2>
|
<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>'}
|
${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>
|
</body>
|
||||||
@@ -163,7 +163,7 @@ function renderHtml(exportData: SessionExport) {
|
|||||||
|
|
||||||
function renderCsv(exportData: SessionExport) {
|
function renderCsv(exportData: SessionExport) {
|
||||||
const rows = [
|
const rows = [
|
||||||
['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'book', 'chapter', 'start_verse', 'end_verse', 'markers', 'body'],
|
['session_title', 'note_id', 'kind', 'created_at', 'updated_at', 'reference', 'translation_id', 'book', 'chapter', 'start_verse', 'end_verse', 'markers', 'block_count', 'blocks_json', 'body'],
|
||||||
...exportData.notes.map((note) => [
|
...exportData.notes.map((note) => [
|
||||||
exportData.session.title,
|
exportData.session.title,
|
||||||
note.id,
|
note.id,
|
||||||
@@ -177,12 +177,73 @@ function renderCsv(exportData: SessionExport) {
|
|||||||
note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '',
|
note.anchor?.startVerse != null ? String(note.anchor.startVerse) : '',
|
||||||
note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '',
|
note.anchor?.endVerse != null ? String(note.anchor.endVerse) : '',
|
||||||
note.markers?.join(' ') ?? '',
|
note.markers?.join(' ') ?? '',
|
||||||
|
String(note.blocks?.length ?? 0),
|
||||||
|
note.blocks?.length ? JSON.stringify(note.blocks) : '',
|
||||||
note.body,
|
note.body,
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
return rows.map((row) => row.map(csvCell).join(',')).join('\n')
|
return rows.map((row) => row.map(csvCell).join(',')).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMarkdownNote(note: SessionNote) {
|
||||||
|
const sections = [`### ${noteHeading(note)}`, '']
|
||||||
|
const body = note.body.trim()
|
||||||
|
if (body) {
|
||||||
|
sections.push(body, '')
|
||||||
|
} else if (markerSummary(note)) {
|
||||||
|
sections.push(markerSummary(note), '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.blocks?.length) {
|
||||||
|
sections.push('#### Notebook Blocks', '')
|
||||||
|
sections.push(note.blocks.map(renderMarkdownBlock).join('\n\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join('\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdownBlock(block: NotebookBlock) {
|
||||||
|
const lines = [
|
||||||
|
`- Type: ${block.type}`,
|
||||||
|
block.title ? `- Title: ${block.title}` : '',
|
||||||
|
block.reference ? `- Reference: ${block.reference}` : '',
|
||||||
|
block.translationId ? `- Translation: ${block.translationId}` : '',
|
||||||
|
block.url ? `- URL: ${block.url}` : '',
|
||||||
|
`- Created: ${block.createdAt}`,
|
||||||
|
'',
|
||||||
|
block.dataUrl ? `` : '',
|
||||||
|
block.body,
|
||||||
|
].filter(Boolean)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtmlNote(note: SessionNote) {
|
||||||
|
const body = note.body.trim() || markerSummary(note)
|
||||||
|
const blocks = note.blocks?.length
|
||||||
|
? `<section><h4>Notebook Blocks</h4>${note.blocks.map(renderHtmlBlock).join('\n')}</section>`
|
||||||
|
: ''
|
||||||
|
return `<article><h3>${escapeHtml(noteHeading(note))}</h3>${body ? `<pre>${escapeHtml(body)}</pre>` : ''}${blocks}</article>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtmlBlock(block: NotebookBlock) {
|
||||||
|
const image = block.dataUrl
|
||||||
|
? `<img src="${escapeHtml(block.dataUrl)}" alt="${escapeHtml(block.title || block.body || 'Notebook image')}">`
|
||||||
|
: ''
|
||||||
|
const link = block.url ? `<p><a href="${escapeHtml(block.url)}">${escapeHtml(block.url)}</a></p>` : ''
|
||||||
|
return `<article>
|
||||||
|
<h5>${escapeHtml(block.title || block.reference || block.source?.label || block.type)}</h5>
|
||||||
|
<dl>
|
||||||
|
<dt>Type</dt><dd>${escapeHtml(block.type)}</dd>
|
||||||
|
${block.reference ? `<dt>Reference</dt><dd>${escapeHtml(block.reference)}</dd>` : ''}
|
||||||
|
${block.translationId ? `<dt>Translation</dt><dd>${escapeHtml(block.translationId)}</dd>` : ''}
|
||||||
|
<dt>Created</dt><dd>${escapeHtml(block.createdAt)}</dd>
|
||||||
|
</dl>
|
||||||
|
${image}
|
||||||
|
${link}
|
||||||
|
<p>${escapeHtml(block.body)}</p>
|
||||||
|
</article>`
|
||||||
|
}
|
||||||
|
|
||||||
function noteHeading(note: SessionNote) {
|
function noteHeading(note: SessionNote) {
|
||||||
const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook'
|
const kind = note.kind === 'margin' ? 'Margin note' : 'Notebook'
|
||||||
const reference = note.anchor?.reference ?? note.reference
|
const reference = note.anchor?.reference ?? note.reference
|
||||||
@@ -196,7 +257,7 @@ function markerSummary(note: SessionNote) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasExportableNoteContent(note: SessionNote) {
|
function hasExportableNoteContent(note: SessionNote) {
|
||||||
return Boolean(note.body.trim() || note.markers?.length)
|
return Boolean(note.body.trim() || note.markers?.length || note.blocks?.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function csvCell(value: string) {
|
function csvCell(value: string) {
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ export type SessionNote = {
|
|||||||
translationId?: string
|
translationId?: string
|
||||||
anchor?: VerseAnchor
|
anchor?: VerseAnchor
|
||||||
markers?: string[]
|
markers?: string[]
|
||||||
|
blocks?: NotebookBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotebookBlockType = 'paragraph' | 'clip' | 'link' | 'image'
|
||||||
|
|
||||||
|
export type NotebookBlock = {
|
||||||
|
id: string
|
||||||
|
type: NotebookBlockType
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
title?: string
|
||||||
|
url?: string
|
||||||
|
mimeType?: string
|
||||||
|
dataUrl?: string
|
||||||
|
reference?: string
|
||||||
|
translationId?: string
|
||||||
|
anchor?: VerseAnchor
|
||||||
|
source?: {
|
||||||
|
label: string
|
||||||
|
kind: 'verse' | 'resource' | 'web' | 'image' | 'manual'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudySession = {
|
export type StudySession = {
|
||||||
@@ -257,6 +279,7 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
|
|||||||
translationId: note.translationId ?? session.baseTranslation,
|
translationId: note.translationId ?? session.baseTranslation,
|
||||||
anchor: note.anchor,
|
anchor: note.anchor,
|
||||||
markers: Array.isArray(note.markers) ? note.markers : [],
|
markers: Array.isArray(note.markers) ? note.markers : [],
|
||||||
|
blocks: normalizeNotebookBlocks(note.blocks),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +293,30 @@ function normalizeSessionNotes(session: Partial<StudySession>): SessionNote[] {
|
|||||||
createdAt: session.createdAt ?? now,
|
createdAt: session.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
translationId: session.baseTranslation,
|
translationId: session.baseTranslation,
|
||||||
|
blocks: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNotebookBlocks(blocks?: NotebookBlock[] | null): NotebookBlock[] {
|
||||||
|
if (!Array.isArray(blocks)) return []
|
||||||
|
return blocks.map((block) => ({
|
||||||
|
id: block.id || timelineId(),
|
||||||
|
type: ['paragraph', 'clip', 'link', 'image'].includes(block.type) ? block.type : 'clip',
|
||||||
|
body: block.body ?? '',
|
||||||
|
createdAt: block.createdAt ?? new Date().toISOString(),
|
||||||
|
updatedAt: block.updatedAt ?? block.createdAt ?? new Date().toISOString(),
|
||||||
|
title: block.title,
|
||||||
|
url: block.url,
|
||||||
|
mimeType: block.mimeType,
|
||||||
|
dataUrl: block.dataUrl,
|
||||||
|
reference: block.reference,
|
||||||
|
translationId: block.translationId,
|
||||||
|
anchor: block.anchor,
|
||||||
|
source: block.source,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
|
export function selectActiveSessionId(sessions: StudySession[], requestedId?: string | null): string {
|
||||||
if (!sessions.length) return ''
|
if (!sessions.length) return ''
|
||||||
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
|
return sessions.some((session) => session.id === requestedId) ? requestedId ?? sessions[0].id : sessions[0].id
|
||||||
|
|||||||
Reference in New Issue
Block a user