diff --git a/package.json b/package.json
index 99b68b3..474a07a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "2.42.2",
+ "version": "2.43.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/routes/study-account.js b/server/routes/study-account.js
index 195c656..d9d342d 100644
--- a/server/routes/study-account.js
+++ b/server/routes/study-account.js
@@ -1,3 +1,4 @@
+import { noteEntriesToText } from '../../shared/note-format.js'
import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
@@ -87,7 +88,7 @@ export function register(app) {
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
- studyEntries[studySlug][sectionId].noteText = noteText.trim()
+ studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
diff --git a/shared/note-format.d.ts b/shared/note-format.d.ts
new file mode 100644
index 0000000..a5321c0
--- /dev/null
+++ b/shared/note-format.d.ts
@@ -0,0 +1,14 @@
+export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
+export interface NoteEntry {
+ id: string
+ anchor: NoteAnchorId
+ html: string
+ updatedAt: string | null
+}
+export const NOTE_ANCHORS: NoteAnchorId[]
+export const NOTE_ANCHOR_LABELS: Record
+export function newNoteId(): string
+export function parseNoteEntries(raw: string): NoteEntry[]
+export function serializeNoteEntries(entries: NoteEntry[]): string
+export function noteHtmlToText(html: string): string
+export function noteEntriesToText(raw: string): string
diff --git a/shared/note-format.js b/shared/note-format.js
new file mode 100644
index 0000000..0022eb1
--- /dev/null
+++ b/shared/note-format.js
@@ -0,0 +1,79 @@
+// Lesson note storage format, shared by the client and the server.
+//
+// Each lesson's notes are stored as one string. Current format (v2):
+// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
+// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
+// plain text. Both are still read and are upgraded to v2 on the next save.
+
+export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
+
+export const NOTE_ANCHOR_LABELS = {
+ scripture: 'Scripture Text',
+ commentary: 'Commentary',
+ questions: 'Reflection Questions',
+ general: 'General Notes',
+}
+
+export function newNoteId() {
+ return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
+}
+
+export function parseNoteEntries(raw) {
+ if (typeof raw !== 'string' || !raw.trim()) return []
+ let parsed = null
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ // Legacy plain-text note.
+ return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
+ }
+ if (parsed.v === 2 && Array.isArray(parsed.entries)) {
+ return parsed.entries
+ .filter(e => e && typeof e.html === 'string' && e.html.trim())
+ .map(e => ({
+ id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
+ anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
+ html: e.html,
+ updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
+ }))
+ }
+ return Object.entries(parsed)
+ .filter(([, html]) => typeof html === 'string' && html.trim())
+ .map(([anchor, html]) => ({
+ id: `legacy-${anchor}`,
+ anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
+ html,
+ updatedAt: null,
+ }))
+}
+
+export function serializeNoteEntries(entries) {
+ const kept = entries.filter(e => e.html && e.html.trim())
+ if (kept.length === 0) return ''
+ return JSON.stringify({ v: 2, entries: kept })
+}
+
+export function noteHtmlToText(html) {
+ return String(html)
+ .replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
+ .replace(/
/gi, '\n')
+ .replace(/<[^>]*>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
+
+// Plain-text rendering of a whole lesson's notes (used by the Word export).
+export function noteEntriesToText(raw) {
+ return parseNoteEntries(raw)
+ .map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
+ .join('\n\n')
+}
diff --git a/src/App.css b/src/App.css
index f8b3a70..efd09f8 100644
--- a/src/App.css
+++ b/src/App.css
@@ -14165,3 +14165,19 @@ a.sl-pager-link:hover { border-color: rgba(201, 168, 76, 0.6); transform: transl
.notes-other-matches { margin-top: 1rem; }
.notes-export-btn { margin-top: 1rem; width: 100%; }
+
+.note-card-footer { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; }
+.note-card-delete { background: none; border: 0; padding: 0; color: #b5654f; cursor: pointer; font: inherit; }
+.note-card-delete:hover { text-decoration: underline; }
+.note-entry-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: grid; gap: 0.4rem; }
+.note-entry { width: 100%; text-align: left; background: rgba(201, 168, 76, 0.06); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 8px; padding: 0.45rem 0.65rem; cursor: pointer; color: inherit; font: inherit; display: grid; gap: 0.1rem; }
+.note-entry:hover, .note-entry--active { border-color: #c9a84c; background: rgba(201, 168, 76, 0.14); }
+.note-entry-label { font-size: 0.72rem; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.04em; }
+.note-entry-snippet { font-size: 0.85rem; color: #b8ad92; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.note-add-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.75rem; font-size: 0.8rem; color: #7a7060; }
+.note-add-chip { background: none; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.2rem 0.6rem; color: #c9a84c; cursor: pointer; font: inherit; font-size: 0.78rem; }
+.note-add-chip:hover { background: rgba(201, 168, 76, 0.12); }
+.notes-sort { display: flex; gap: 0.4rem; margin: 0.6rem 0; }
+.notes-sort-btn { flex: 1; background: none; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 6px; padding: 0.3rem 0.4rem; color: #b8ad92; cursor: pointer; font: inherit; font-size: 0.78rem; }
+.notes-sort-btn--active { border-color: #c9a84c; color: #c9a84c; background: rgba(201, 168, 76, 0.1); }
+.notes-block-date { font-weight: 400; color: #7a7060; font-size: 0.75rem; }
diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx
index 1342b77..009a597 100644
--- a/src/components/NoteCard.tsx
+++ b/src/components/NoteCard.tsx
@@ -5,12 +5,14 @@ import { useEffect } from 'react'
interface NoteCardProps {
anchorLabel: string
html: string
+ updatedAt?: string | null
+ onDelete?: () => void
autoSaveMsg?: string
onChange: (html: string) => void
onClose: () => void
}
-export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
+export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
const editor = useEditor({
extensions: [StarterKit],
shouldRerenderOnTransaction: true,
@@ -115,7 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
- {wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
+
+ {onDelete && (
+
+ )}
+
+ {updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
+ {wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
+
+
)
}
diff --git a/src/studyPages.tsx b/src/studyPages.tsx
index 5b27f05..7947320 100644
--- a/src/studyPages.tsx
+++ b/src/studyPages.tsx
@@ -9,6 +9,8 @@ import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard'
+import { NOTE_ANCHORS, NOTE_ANCHOR_LABELS, newNoteId, parseNoteEntries, serializeNoteEntries, noteHtmlToText } from '../shared/note-format.js'
+import type { NoteAnchorId, NoteEntry } from '../shared/note-format.js'
import { StudySectionComments } from './components/StudySectionComments'
type Props = { content: SiteContent }
@@ -248,27 +250,8 @@ function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
-type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
+const ANCHOR_LABELS: Record = NOTE_ANCHOR_LABELS
-const ANCHOR_LABELS: Record = {
- scripture: 'Scripture Text',
- commentary: 'Commentary',
- questions: 'Reflection Questions',
- general: 'General Notes',
-}
-
-function parseNoteMap(raw: string): Record {
- if (!raw?.trim()) return {}
- try {
- const parsed = JSON.parse(raw)
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
- return parsed as Record
- }
- } catch {
- // Not valid JSON — treat the raw string as legacy plain-text notes.
- }
- return { general: raw }
-}
function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
@@ -1866,8 +1849,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [enrollBusy, setEnrollBusy] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('')
- const [noteMap, setNoteMap] = useState>({})
- const [openAnchor, setOpenAnchor] = useState(null)
+ const [noteEntries, setNoteEntries] = useState([])
+ const [activeNoteId, setActiveNoteId] = useState(null)
const [noteLoading, setNoteLoading] = useState(false)
const [noteMessage, setNoteMessage] = useState('')
const noteMapDirtyRef = useRef(false)
@@ -1886,7 +1869,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [bsbPassage, setBsbPassage] = useState(null)
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
- const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
+ const activeEntry = noteEntries.find(e => e.id === activeNoteId) ?? null
+ const openAnchor: NoteAnchorId | null = activeEntry?.anchor ?? null
+ const savedEntries = noteEntries.filter(e => e.html.trim())
+ const savedNoteCount = savedEntries.length
+ const hasNoteOn = (anchor: NoteAnchorId) => savedEntries.some(e => e.anchor === anchor)
const audioEmbedTrimmed = section?.audioEmbedUrl?.trim() ?? ''
const lessonAudioEmbedUrl = /^https?:\/\//i.test(audioEmbedTrimmed) ? audioEmbedTrimmed : ''
@@ -1969,7 +1956,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) {
- setNoteMap({})
+ setNoteEntries([])
+ setActiveNoteId(null)
noteMapDirtyRef.current = false
return
}
@@ -1981,7 +1969,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
.then(data => {
if (cancelled) return
- setNoteMap(parseNoteMap(data.note ?? ''))
+ setNoteEntries(parseNoteEntries(data.note ?? ''))
+ setActiveNoteId(null)
noteMapDirtyRef.current = false
})
.catch(err => {
@@ -2025,16 +2014,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}
- function updateNote(anchor: NoteAnchorId, html: string) {
+ function updateNoteEntry(id: string, html: string) {
noteMapDirtyRef.current = true
- setNoteMap(prev => ({ ...prev, [anchor]: html }))
+ setNoteEntries(prev => prev.map(e => e.id === id ? { ...e, html, updatedAt: new Date().toISOString() } : e))
+ }
+
+ function addNoteEntry(anchor: NoteAnchorId, html = '') {
+ const entry: NoteEntry = { id: newNoteId(), anchor, html, updatedAt: html ? new Date().toISOString() : null }
+ if (html) noteMapDirtyRef.current = true
+ setNoteEntries(prev => [...prev, entry])
+ setActiveNoteId(entry.id)
+ }
+
+ function deleteNoteEntry(id: string) {
+ noteMapDirtyRef.current = true
+ setNoteEntries(prev => prev.filter(e => e.id !== id))
+ setActiveNoteId(null)
+ }
+
+ function toggleAnchorNote(anchor: NoteAnchorId) {
+ if (openAnchor === anchor) {
+ setActiveNoteId(null)
+ return
+ }
+ const existing = [...noteEntries].reverse().find(e => e.anchor === anchor)
+ if (existing) setActiveNoteId(existing.id)
+ else addNoteEntry(anchor)
}
useEffect(() => {
if (!openAnchor) return
function onKeyDown(e: KeyboardEvent) {
- if (e.key === 'Escape') setOpenAnchor(null)
+ if (e.key === 'Escape') setActiveNoteId(null)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
@@ -2050,7 +2062,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ note: JSON.stringify(noteMap) }),
+ body: JSON.stringify({ note: serializeNoteEntries(noteEntries) }),
})
setNoteMessage('')
setAutoSaveMsg('Auto-saved.')
@@ -2061,7 +2073,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}, 2000)
return () => clearTimeout(timer)
- }, [noteMap, noteId, auth.authenticated, noteLoading])
+ }, [noteEntries, noteId, auth.authenticated, noteLoading])
useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
@@ -2112,12 +2124,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
if (!selectedText.trim()) return
- const existing = noteMap[anchor] ?? ''
- const appended = existing
- ? `${existing}${selectedText.trim()}
`
- : `${selectedText.trim()}
`
- updateNote(anchor, appended)
- setOpenAnchor(anchor)
+ const safe = selectedText.trim().replace(/&/g, '&').replace(//g, '>')
+ const latest = [...noteEntries].reverse().find(e => e.anchor === anchor)
+ if (latest) {
+ updateNoteEntry(latest.id, `${latest.html}${safe}
`)
+ setActiveNoteId(latest.id)
+ } else {
+ addNoteEntry(anchor, `${safe}
`)
+ }
}
function handleLessonContentContextMenu(e: React.MouseEvent) {
@@ -2354,10 +2368,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && (
+ >{hasNoteOn('scripture') ? '✎ Note' : '+ Note'}
)}
Scripture Text
{bsbPassageLoading
@@ -2377,10 +2391,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && (
+ >{hasNoteOn('commentary') ? '✎ Note' : '+ Note'}
)}
Instructor Commentary
{section.commentary.split(/\n{2,}/).map((para, i) => (
@@ -2396,10 +2410,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
{isEnrolled && (
+ >{hasNoteOn('questions') ? '✎ Note' : '+ Note'}
)}
Reflection Questions
@@ -2621,20 +2635,52 @@ export function ColossiansStudySectionPage({ content }: Props) {
<>
{noteLoading ? (
Loading notes…
- ) : openAnchor ? (
- updateNote(openAnchor as NoteAnchorId, html)}
- onClose={() => setOpenAnchor(null)}
- />
) : (
-
- {savedNoteCount > 0
- ? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
- : 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
-
+ <>
+ {savedEntries.length > 0 && (
+
+ {savedEntries.map(entry => (
+ -
+
+
+ ))}
+
+ )}
+ {activeEntry && (
+ updateNoteEntry(activeEntry.id, html)}
+ onDelete={() => deleteNoteEntry(activeEntry.id)}
+ onClose={() => setActiveNoteId(null)}
+ />
+ )}
+ {isEnrolled && (
+
+ Add a note on:
+ {NOTE_ANCHORS.map(anchor => (
+
+ ))}
+
+ )}
+ {!activeEntry && savedEntries.length === 0 && (
+
+ Click the Note button on Scripture, Commentary, or Reflection Questions, or use the buttons above, to write notes here.
+
+ )}
+ >
)}
{autoSaveMsg && {autoSaveMsg}
}
{noteMessage && {noteMessage}
}
@@ -2730,6 +2776,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
const [message, setMessage] = useState('')
const [activeNoteTab, setActiveNoteTab] = useState('')
const [noteQuery, setNoteQuery] = useState('')
+ const [noteSort, setNoteSort] = useState<'lesson' | 'recent'>('lesson')
usePageMeta(
study ? `My Notes | ${study.title}` : 'My Study Notes',
@@ -2811,8 +2858,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
: []
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
- const noteText = (raw: string) => Object.values(parseNoteMap(raw)).map(value => stripHtml(value ?? '')).filter(Boolean).join(' · ')
+ const noteText = (raw: string) => parseNoteEntries(raw).map(e => stripHtml(e.html)).filter(Boolean).join(' · ')
+ const lastEdited = (raw: string) => parseNoteEntries(raw).reduce((max, e) => (e.updatedAt && e.updatedAt > max ? e.updatedAt : max), '')
const sorted = [...entries].sort((a, b) => {
+ if (noteSort === 'recent') return lastEdited(b.note).localeCompare(lastEdited(a.note))
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
return aNum - bNum
@@ -2837,9 +2886,8 @@ export function ColossiansStudyNotesPage({ content }: Props) {
function downloadStudyNotesMarkdown() {
const body = sorted.map(({ section, sectionId, note }) => {
- const blocks = Object.entries(parseNoteMap(note))
- .filter(([, html]) => html?.trim())
- .map(([key, html]) => `### ${ANCHOR_LABELS[key] ?? key}\n\n${stripHtml(html)}`)
+ const blocks = parseNoteEntries(note)
+ .map(e => `### ${ANCHOR_LABELS[e.anchor]}${e.updatedAt ? ` (${new Date(e.updatedAt).toLocaleDateString('en-US')})` : ''}\n\n${stripHtml(e.html)}`)
.join('\n\n')
return `## ${section?.title ?? sectionId}${section?.reference ? ` (${section.reference})` : ''}\n\n${blocks}`
}).join('\n\n---\n\n')
@@ -2925,6 +2973,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
value={noteQuery}
onChange={e => setNoteQuery(e.target.value)}
/>
+
+
+
+
{visible.length === 0 ? (
No notes match “{noteQuery.trim()}” in this study.
) : (
@@ -2944,7 +2996,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
{lessonNumber || '•'}
{section?.title ?? 'Saved note'}
- {section?.reference ?? ''}
+ {section?.reference ?? ''}{lastEdited(note) ? ` · edited ${formatRelativeTime(lastEdited(note))}` : ''}
{noteText(note).slice(0, 110)}
@@ -2985,22 +3037,23 @@ export function ColossiansStudyNotesPage({ content }: Props) {
type="button"
className="notes-copy-btn"
onClick={() => {
- const text = Object.entries(parseNoteMap(active.note))
- .filter(([, html]) => html?.trim())
- .map(([key, html]) => `${ANCHOR_LABELS[key] ?? key}\n${stripHtml(html)}`)
+ const text = parseNoteEntries(active.note)
+ .map(e => `${ANCHOR_LABELS[e.anchor]}\n${stripHtml(e.html)}`)
.join('\n\n')
navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.'))
}}
>Copy note
{(() => {
- const anchorMap = parseNoteMap(active.note)
- const blocks = Object.entries(anchorMap).filter(([, html]) => html?.trim())
+ const blocks = parseNoteEntries(active.note)
if (blocks.length === 0) return No notes saved for this lesson yet.
- return blocks.map(([key, html]) => (
-
- {ANCHOR_LABELS[key] ?? key}
-
+ return blocks.map(entry => (
+
+
+ {ANCHOR_LABELS[entry.anchor]}
+ {entry.updatedAt && · edited {new Date(entry.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}}
+
+
))
})()}
diff --git a/test/note-format.test.js b/test/note-format.test.js
new file mode 100644
index 0000000..04b2332
--- /dev/null
+++ b/test/note-format.test.js
@@ -0,0 +1,32 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { parseNoteEntries, serializeNoteEntries, noteEntriesToText } from '../shared/note-format.js'
+
+test('legacy anchor map upgrades to entries and round-trips as v2', () => {
+ const entries = parseNoteEntries(JSON.stringify({ scripture: 'a
', general: '', commentary: 'b
' }))
+ assert.deepEqual(entries.map(e => e.anchor), ['scripture', 'commentary'])
+ const again = parseNoteEntries(serializeNoteEntries(entries))
+ assert.equal(again.length, 2)
+ assert.equal(JSON.parse(serializeNoteEntries(entries)).v, 2)
+})
+
+test('plain-text legacy notes become a general entry', () => {
+ const entries = parseNoteEntries('just some text')
+ assert.equal(entries.length, 1)
+ assert.equal(entries[0].anchor, 'general')
+})
+
+test('multiple entries per anchor are kept and empty ones dropped', () => {
+ const raw = serializeNoteEntries([
+ { id: '1', anchor: 'general', html: 'one
', updatedAt: '2026-01-01T00:00:00.000Z' },
+ { id: '2', anchor: 'general', html: 'two
', updatedAt: null },
+ { id: '3', anchor: 'general', html: ' ', updatedAt: null },
+ ])
+ assert.equal(parseNoteEntries(raw).length, 2)
+ assert.equal(serializeNoteEntries([]), '')
+})
+
+test('plain-text export strips markup', () => {
+ const text = noteEntriesToText(serializeNoteEntries([{ id: '1', anchor: 'scripture', html: 'Hi & bye
', updatedAt: null }]))
+ assert.equal(text, 'Scripture Text\nHi & bye')
+})