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) { {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') +})