From 67645cf1df759028a5aeafe53b278a104254a77c Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:01:11 -0400 Subject: [PATCH] App quality: offline badge, per-note history, keyboard study mode, notes import - Study cards say whether the Bible text works offline, with a button to save the missing book (notes are always kept on the device). - Each note keeps its last 12 versions with Restore (History under each note). - Keyboard-only study: 1/2/3 write in Observe/Interpret/Apply, C/W/X/P switch resource tabs, V compares, Esc leaves a note box; listed under "?". - Export > Import notes: bring Word/Markdown/text notes into the matching chunks without overwriting anything; can mark them as not your own. Co-Authored-By: Claude Sonnet 5.5 --- src/components/ImportNotes.jsx | 61 ++++++++++++++++++++ src/components/OfflineBadge.jsx | 21 +++++++ src/components/ShortcutsHelp.jsx | 4 ++ src/components/study/NotesPane.jsx | 54 ++++++++++++++++-- src/pages/ExportPage.jsx | 3 + src/pages/HomePage.jsx | 8 ++- src/pages/StudyPage.jsx | 19 ++++++- src/test/App.home.test.jsx | 29 ++++++++++ src/utils/importNotes.js | 90 ++++++++++++++++++++++++++++++ src/utils/importNotes.test.js | 60 ++++++++++++++++++++ src/utils/noteHistory.js | 23 ++++++++ src/utils/noteHistory.test.js | 26 +++++++++ src/utils/offline.js | 15 +++++ src/utils/offline.test.js | 15 +++++ 14 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 src/components/ImportNotes.jsx create mode 100644 src/components/OfflineBadge.jsx create mode 100644 src/utils/importNotes.js create mode 100644 src/utils/importNotes.test.js create mode 100644 src/utils/noteHistory.js create mode 100644 src/utils/noteHistory.test.js diff --git a/src/components/ImportNotes.jsx b/src/components/ImportNotes.jsx new file mode 100644 index 0000000..f15cd11 --- /dev/null +++ b/src/components/ImportNotes.jsx @@ -0,0 +1,61 @@ +// Export → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage. +import { useState } from 'react'; +import { useApp } from '../context/AppContext.js'; +import { useToast } from './Toasts.jsx'; +import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js'; + +export default function ImportNotes() { + const { project, updateProject } = useApp(); + const { toast } = useToast(); + const [file, setFile] = useState(''); + const [sections, setSections] = useState(null); // null until a file is read + const [own, setOwn] = useState(true); + const [error, setError] = useState(''); + + const choose = async (e) => { + const f = e.target.files?.[0]; + e.target.value = ''; + setError(''); setSections(null); + if (!f) return; + try { + let text; + if (/\.docx$/i.test(f.name)) { + const { default: mammoth } = await import('mammoth'); + text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value; + } else text = await f.text(); + const found = parseNotesText(text); + setFile(f.name); + setSections(found); + if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.'); + } catch { setError('That file couldn’t be read.'); } + }; + + const preview = sections ? applyImportedNotes(project, sections, { own }) : null; + const add = () => { + updateProject((current) => applyImportedNotes(current, sections, { own }).project); + toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`); + setSections(null); + }; + + return ( +
+

Import notes

+

Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.

+ + {error &&

{error}

} + {preview && sections.length > 0 && ( +
+

{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.

+
    + {preview.matched.map((m) =>
  • ✓ {m.heading}: {m.fields.join(', ')}
  • )} + {preview.unmatched.map((h) =>
  • No chunk for {h}, skipped
  • )} +
+ + +
+ )} +
+ ); +} diff --git a/src/components/OfflineBadge.jsx b/src/components/OfflineBadge.jsx new file mode 100644 index 0000000..293a159 --- /dev/null +++ b/src/components/OfflineBadge.jsx @@ -0,0 +1,21 @@ +// On a study card: whether its Bible text works without a connection, and a button to fix that. +import { DownloadProgress } from './OfflineSave.jsx'; +import { offlineStatus } from '../utils/offline.js'; + +export default function OfflineBadge({ project, offline }) { + if (!project) return null; + const status = offlineStatus(project, offline.saved); + const label = 'mt-1 text-xs'; + if (offline.job) return
; + if (status.state === 'ready') return

✓ Works offline

; + if (status.state === 'licensed') { + return

Notes work offline; {status.translation} text needs a connection

; + } + const next = status.missing[0]; + return ( +

+ Needs a connection for {status.missing.map((b) => b.name).join(', ')} + +

+ ); +} diff --git a/src/components/ShortcutsHelp.jsx b/src/components/ShortcutsHelp.jsx index 3037830..3c61e7c 100644 --- a/src/components/ShortcutsHelp.jsx +++ b/src/components/ShortcutsHelp.jsx @@ -22,6 +22,10 @@ const BY_PAGE = { ], study: [ ['← →', 'Previous / next chunk'], + ['1 2 3', 'Write in Observe / Interpret / Apply'], + ['Esc', 'Leave a note box (then keys work again)'], + ['C W X P', 'Commentary / Word study / Cross-refs / People & places'], + ['V', 'Compare passages or translations'], ], }; diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index 46bba1e..67c644e 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -1,9 +1,10 @@ // Observe / Interpret / Apply as an accordion (one open at a time), plus a "More" // area for background notes and tags so the page stays quiet. -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import SessionNotes from './SessionNotes.jsx'; import FirstDraft from './FirstDraft.jsx'; import { usePasteCheck } from './PastePrompt.jsx'; +import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js'; import DictateButton, { appendSpoken } from '../DictateButton.jsx'; import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js'; import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js'; @@ -20,11 +21,25 @@ const SECTIONS = [ const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length; -export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray }) { +export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest }) { const [open, setOpen] = useState(() => { try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; } }); const [more, setMore] = useState(false); + // A keyboard jump (1 / 2 / 3 on the Study page): open that note and put the cursor in it. + const areas = useRef({}); + const wanted = useRef(null); + useEffect(() => { + if (!focusRequest) return; + wanted.current = focusRequest.field; + setOpen(focusRequest.field); + }, [focusRequest]); + useEffect(() => { + if (wanted.current && wanted.current === open) { + const el = areas.current[open]; + if (el) { el.focus(); const end = el.value.length; el.setSelectionRange(end, end); wanted.current = null; } + } + }); const [tplName, setTplName] = useState(''); const [tplOpen, setTplOpen] = useState(false); const [tagInput, setTagInput] = useState(''); @@ -42,6 +57,25 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav const share = ownShare(chunk); const pasteCheck = usePasteCheck(chunk, updateChunk); + // Earlier versions of one note: kept when you start and stop editing it; "History" lists them with Restore. + const [historyField, setHistoryField] = useState(null); + const [versions, setVersions] = useState([]); + useEffect(() => { setHistoryField(null); }, [chunk.id]); + const keepVersion = (field) => { + const list = recordNoteVersion(chunk.id, field, chunk[field]); + if (historyField === field) setVersions(list); + }; + const openHistory = (field) => { + if (historyField === field) { setHistoryField(null); return; } + recordNoteVersion(chunk.id, field, chunk[field]); + setVersions(listNoteVersions(chunk.id, field)); + setHistoryField(field); + }; + const restore = (field, text) => { + recordNoteVersion(chunk.id, field, chunk[field]); // so restoring can itself be undone + updateChunk(chunk.id, { [field]: text }); + setVersions(listNoteVersions(chunk.id, field)); + }; return (
@@ -72,9 +106,21 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav {isOpen && (
-