resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume
diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx
index 1bd5897..3e32897 100644
--- a/src/pages/StudyPage.jsx
+++ b/src/pages/StudyPage.jsx
@@ -16,6 +16,10 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
+// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares.
+const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
+const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' };
+
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
@@ -47,6 +51,13 @@ export default function StudyPage() {
const [folded, setFolded] = useState(() => {
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
});
+ const [focusRequest, setFocusRequest] = useState(null);
+ const showHalf = (which) => setFolded((cur) => {
+ if (!cur[which]) return cur;
+ const next = { ...cur, [which]: false };
+ try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
+ return next;
+ });
const toggleFold = (which) => setFolded((cur) => {
const other = which === 'notes' ? 'resources' : 'notes';
// folding the last open half opens the other one instead, so the column is never empty
@@ -76,11 +87,15 @@ export default function StudyPage() {
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
+ else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
return;
}
- if (editable || wordStudy.suggestModal) return;
+ if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
+ else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
+ else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
+ else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -154,7 +169,7 @@ export default function StudyPage() {
- startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx
index b6a62ac..2b167db 100644
--- a/src/test/App.home.test.jsx
+++ b/src/test/App.home.test.jsx
@@ -468,6 +468,35 @@ describe('Study workspace', () => {
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
});
+ test('a note keeps earlier versions that can be restored', async () => {
+ const user = await studyOneChunk();
+ const observe = screen.getByRole('textbox', { name: 'Observe' });
+ fireEvent.focus(observe);
+ fireEvent.change(observe, { target: { value: 'First thought about the passage.' } });
+ fireEvent.blur(observe);
+ fireEvent.focus(observe);
+ fireEvent.change(observe, { target: { value: 'Completely rewritten.' } });
+ fireEvent.blur(observe);
+ await user.click(screen.getByRole('button', { name: 'History' }));
+ const list = screen.getByRole('list', { name: /observe history/i });
+ expect(within(list).getByText(/first thought about the passage/i)).toBeInTheDocument();
+ await user.click(within(list).getAllByRole('button', { name: 'Restore' }).find((b) => !b.disabled));
+ expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('First thought about the passage.');
+ });
+
+ test('keyboard: 1 / 2 / 3 jump into a note, Esc leaves it, and V opens Compare', async () => {
+ const user = await studyOneChunk();
+ await user.keyboard('2');
+ expect(screen.getByRole('textbox', { name: 'Interpret' })).toHaveFocus();
+ await user.keyboard('{Escape}');
+ expect(screen.getByRole('textbox', { name: 'Interpret' })).not.toHaveFocus();
+ await user.keyboard('3');
+ expect(screen.getByRole('textbox', { name: 'Apply' })).toHaveFocus();
+ await user.keyboard('{Escape}');
+ await user.keyboard('v');
+ expect(screen.getByRole('dialog', { name: /compare/i })).toBeInTheDocument();
+ });
+
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('button', { name: /compare…/i }));
diff --git a/src/utils/importNotes.js b/src/utils/importNotes.js
new file mode 100644
index 0000000..780dba1
--- /dev/null
+++ b/src/utils/importNotes.js
@@ -0,0 +1,90 @@
+// Bring notes written elsewhere (a Word file or Markdown from another app) into a study. A passage heading starts
+// each section ("## John 3:16–18"), and labelled lines or sub-headings ("Observe: …", "### Interpret") hold the
+// notes. Sections are matched to your chunks by passage. Nothing already written is overwritten.
+import { parsePassageRef } from './passageRef.js';
+import { withPaste } from './ownWork.js';
+
+const LABELS = [
+ [/^observ(?:e|ation|ations)$/i, 'observation'],
+ [/^interpret(?:ation)?$/i, 'interpretation'],
+ [/^appl(?:y|ication)$/i, 'application'],
+ [/^(?:background|notes?|general notes)$/i, 'generalNotes'],
+ [/^(?:discussion )?questions$/i, 'discussionQuestions'],
+];
+const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', discussionQuestions: 'Questions' };
+export const importFieldName = (f) => FIELD_NAMES[f] ?? f;
+
+const clean = (line) => line.replace(/^\s*#+\s*/, '').replace(/\*\*|__/g, '').trim();
+const labelOf = (word) => LABELS.find(([re]) => re.test(word.trim()))?.[1] ?? null;
+
+// A heading that is just a passage ("John 3:16-18", "Session 2: Romans 8:1–4") -> { book, chapter, verse, text } or null.
+export function passageHeading(line) {
+ const text = clean(line).replace(/^(?:session|lesson|week|episode|part)\s*\d+\s*[:.\-–—]\s*/i, '');
+ const m = text.match(/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3}))?(?:\s*[-–—]\s*\d{1,3}(?:\s*:\s*\d{1,3})?)?\s*$/);
+ if (!m || text.length > 60) return null;
+ const ref = parsePassageRef(`${m[1]} ${m[2]}${m[3] ? `:${m[3]}` : ''}`)[0];
+ return ref ? { book: ref.book, chapter: ref.chapter, verse: m[3] ? Number(m[3]) : null, text } : null;
+}
+
+// -> [{ heading, ref, observation?, interpretation?, application?, generalNotes?, discussionQuestions? }]
+export function parseNotesText(raw) {
+ const sections = [];
+ let current = null;
+ let field = null;
+ const append = (value) => {
+ if (!current || !field) return;
+ current[field] = current[field] ? `${current[field]}\n${value}` : value;
+ };
+ for (const line of String(raw ?? '').replace(/\r/g, '').split('\n')) {
+ const heading = passageHeading(line);
+ if (heading) { current = { heading: heading.text, ref: heading }; sections.push(current); field = null; continue; }
+ if (!current) continue;
+ const stripped = clean(line).replace(/^[-*•]\s+/, '');
+ const labelled = stripped.match(/^([A-Za-z ]{3,22}?)\s*[:\-–]\s*(.*)$/);
+ const bare = /^\s*(?:#+\s|\*\*|__)/.test(line) ? labelOf(stripped.replace(/[:\-–]\s*$/, '')) : null; // "### Observe", "**Observe**"
+ if (labelled && labelOf(labelled[1])) { field = labelOf(labelled[1]); current[field] = current[field] ?? ''; if (labelled[2].trim()) append(labelled[2].trim()); continue; }
+ if (bare) { field = bare; current[field] = current[field] ?? ''; continue; }
+ if (field) append(line.trim());
+ }
+ return sections.map((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, typeof v === 'string' ? v.replace(/^\n+|\n+$/g, '').trim() : v])))
+ .filter((s) => Object.keys(FIELD_NAMES).some((f) => s[f]));
+}
+
+// Which chunk a section belongs to: the one whose verses include the heading's verse (or the first in that chapter).
+export function matchChunk(project, ref) {
+ for (const chapter of project?.chapters ?? []) {
+ if (chapter.bookAbbrev !== ref.book || Number(chapter.chapter) !== ref.chapter) continue;
+ const chunks = chapter.chunks ?? [];
+ const hit = ref.verse == null ? chunks[0] : chunks.find((c) => ref.verse >= c.startVerse && ref.verse <= c.endVerse);
+ if (hit) return hit;
+ }
+ return null;
+}
+
+const MARKER = '— imported —';
+
+// -> { project, matched: [{ heading, fields }], unmatched: [heading] }. Where a note already has text, the imported
+// text is added below a marker instead of replacing it. `own: false` records the text as pasted, not your own work.
+export function applyImportedNotes(project, sections, { own = true, now = Date.now() } = {}) {
+ const patches = new Map(); // chunk id -> merged chunk
+ const matched = [];
+ const unmatched = [];
+ for (const section of sections) {
+ const chunk = matchChunk(project, section.ref);
+ if (!chunk) { unmatched.push(section.heading); continue; }
+ const next = { ...(patches.get(chunk.id) ?? chunk) };
+ const fields = [];
+ for (const f of Object.keys(FIELD_NAMES)) {
+ const incoming = section[f];
+ if (!incoming) continue;
+ const have = (next[f] ?? '').trim();
+ if (have.includes(incoming)) continue;
+ next[f] = have ? `${next[f].replace(/\s+$/, '')}\n\n${MARKER}\n${incoming}` : incoming;
+ if (!own && ['observation', 'interpretation', 'application'].includes(f)) next.aiPastes = withPaste({ aiPastes: next.aiPastes }, f, incoming) ?? { ...(next.aiPastes ?? {}), [f]: [...(next.aiPastes?.[f] ?? []), incoming] };
+ fields.push(importFieldName(f));
+ }
+ if (fields.length) { next.editedAt = now; patches.set(chunk.id, next); matched.push({ heading: section.heading, fields }); }
+ }
+ const out = { ...project, chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => patches.get(c.id) ?? c) })) };
+ return { project: out, matched, unmatched };
+}
diff --git a/src/utils/importNotes.test.js b/src/utils/importNotes.test.js
new file mode 100644
index 0000000..871aa4d
--- /dev/null
+++ b/src/utils/importNotes.test.js
@@ -0,0 +1,60 @@
+import { test, expect } from 'vitest';
+import { parseNotesText, passageHeading, applyImportedNotes, matchChunk } from './importNotes.js';
+import { ownWords } from './ownWork.js';
+
+const SAMPLE = `# My John study
+
+## John 3:16-18
+Observe: God loved the world.
+He gave his Son.
+Interpret: Love is shown by giving.
+### Apply
+Trust him this week.
+
+## Session 2: John 3:19–21
+**Observation:** Light and darkness.
+Questions:
+- Why do people love darkness?
+`;
+
+test('headings that are a passage are recognised, others are not', () => {
+ expect(passageHeading('## John 3:16-18')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
+ expect(passageHeading('Session 2: John 3:19–21')).toMatchObject({ chapter: 3, verse: 19 });
+ expect(passageHeading('Romans 8')).toMatchObject({ book: 'ROM', chapter: 8, verse: null });
+ expect(passageHeading('# My John study')).toBeNull();
+ expect(passageHeading('Observe: God loved')).toBeNull();
+});
+
+test('sections and their labelled notes are read from Markdown', () => {
+ const sections = parseNotesText(SAMPLE);
+ expect(sections).toHaveLength(2);
+ expect(sections[0]).toMatchObject({ observation: 'God loved the world.\nHe gave his Son.', interpretation: 'Love is shown by giving.', application: 'Trust him this week.' });
+ expect(sections[1].observation).toBe('Light and darkness.');
+ expect(sections[1].discussionQuestions).toContain('Why do people love darkness?');
+});
+
+const project = () => ({ id: 'p', title: 'T', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [
+ { id: 'a', startVerse: 16, endVerse: 18, observation: '', interpretation: 'Mine already.' },
+ { id: 'b', startVerse: 19, endVerse: 21, observation: '' },
+] }] });
+
+test('notes land in the chunk that holds the verse, and nothing written is overwritten', () => {
+ const sections = [...parseNotesText(SAMPLE), { heading: 'Romans 8', ref: { book: 'ROM', chapter: 8, verse: null }, observation: 'x' }];
+ const { project: out, matched, unmatched } = applyImportedNotes(project(), sections, { now: 5 });
+ const a = out.chapters[0].chunks[0];
+ expect(a.observation).toContain('God loved the world.');
+ expect(a.interpretation).toBe('Mine already.\n\n— imported —\nLove is shown by giving.');
+ expect(out.chapters[0].chunks[1].observation).toBe('Light and darkness.');
+ expect(matched).toHaveLength(2);
+ expect(unmatched).toEqual(['Romans 8']);
+ expect(matchChunk(project(), { book: 'JHN', chapter: 3, verse: 20 }).id).toBe('b');
+});
+
+test('importing as "not mine" records the text as pasted, so it is not counted as your own work', () => {
+ const long = Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ');
+ const sections = [{ heading: 'John 3:16', ref: { book: 'JHN', chapter: 3, verse: 16 }, observation: long }];
+ const mine = applyImportedNotes(project(), sections, { own: true }).project.chapters[0].chunks[0];
+ const notMine = applyImportedNotes(project(), sections, { own: false }).project.chapters[0].chunks[0];
+ expect(ownWords(mine, 'observation')).toBe(30);
+ expect(ownWords(notMine, 'observation')).toBe(0);
+});
diff --git a/src/utils/noteHistory.js b/src/utils/noteHistory.js
new file mode 100644
index 0000000..26d3d31
--- /dev/null
+++ b/src/utils/noteHistory.js
@@ -0,0 +1,23 @@
+// Earlier versions of a single note (Observe, Interpret, Apply…), kept on this device so one note can be rolled
+// back without restoring the whole study. A version is saved when you start and when you stop editing a note, so
+// whatever was there before a big rewrite is always a click away.
+export const MAX_VERSIONS = 12;
+const keyFor = (chunkId, field) => `note-history:${chunkId}:${field}`;
+
+// -> [{ at, text }], newest first
+export function listNoteVersions(chunkId, field) {
+ try {
+ const raw = JSON.parse(localStorage.getItem(keyFor(chunkId, field)) || '[]');
+ return Array.isArray(raw) ? raw.filter((v) => v && typeof v.text === 'string') : [];
+ } catch { return []; }
+}
+
+// Keeps `text` as the newest version unless it is blank or the same as the newest one. Returns the list.
+export function recordNoteVersion(chunkId, field, text, now = Date.now()) {
+ const list = listNoteVersions(chunkId, field);
+ const value = text ?? '';
+ if (!value.trim() || list[0]?.text === value) return list;
+ const next = [{ at: now, text: value }, ...list].slice(0, MAX_VERSIONS);
+ try { localStorage.setItem(keyFor(chunkId, field), JSON.stringify(next)); } catch { /* storage full or unavailable */ }
+ return next;
+}
diff --git a/src/utils/noteHistory.test.js b/src/utils/noteHistory.test.js
new file mode 100644
index 0000000..3e31a9a
--- /dev/null
+++ b/src/utils/noteHistory.test.js
@@ -0,0 +1,26 @@
+import { beforeEach, expect, test } from 'vitest';
+import { MAX_VERSIONS, listNoteVersions, recordNoteVersion } from './noteHistory.js';
+
+beforeEach(() => localStorage.clear());
+
+test('versions are kept newest first, and blanks and repeats are skipped', () => {
+ recordNoteVersion('c1', 'observation', 'first', 1);
+ recordNoteVersion('c1', 'observation', 'first', 2); // same as the newest
+ recordNoteVersion('c1', 'observation', ' ', 3); // blank
+ recordNoteVersion('c1', 'observation', 'second', 4);
+ expect(listNoteVersions('c1', 'observation').map((v) => v.text)).toEqual(['second', 'first']);
+ expect(listNoteVersions('c1', 'application')).toEqual([]);
+ expect(listNoteVersions('c2', 'observation')).toEqual([]);
+});
+
+test('only the latest versions are kept', () => {
+ for (let i = 0; i < MAX_VERSIONS + 5; i += 1) recordNoteVersion('c1', 'observation', `v${i}`, i);
+ const list = listNoteVersions('c1', 'observation');
+ expect(list).toHaveLength(MAX_VERSIONS);
+ expect(list[0].text).toBe(`v${MAX_VERSIONS + 4}`);
+});
+
+test('a damaged store reads as empty', () => {
+ localStorage.setItem('note-history:c1:observation', '{nope');
+ expect(listNoteVersions('c1', 'observation')).toEqual([]);
+});
diff --git a/src/utils/offline.js b/src/utils/offline.js
index 4327001..0fb3d3c 100644
--- a/src/utils/offline.js
+++ b/src/utils/offline.js
@@ -43,3 +43,18 @@ export async function downloadChapters(translation, refs, { onProgress, signal,
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
return { done, failed };
}
+
+// What a study needs from the network to open offline. Your notes are always kept on this device; only the
+// Bible text must have been saved. `saved` is loadSavedBooks(). -> { state: 'ready' | 'partial' | 'licensed',
+// translation, missing: [{ abbrev, name }] }
+export function offlineStatus(project, saved = loadSavedBooks()) {
+ const translation = project?.translation ?? 'BSB';
+ if (!canSaveOffline(translation)) return { state: 'licensed', translation, missing: [] };
+ const books = [];
+ for (const chapter of project?.chapters ?? []) {
+ if (chapter.bookAbbrev && !books.some((b) => b.abbrev === chapter.bookAbbrev)) books.push({ abbrev: chapter.bookAbbrev, name: chapter.book });
+ }
+ const have = saved[translation] ?? [];
+ const missing = books.filter((b) => !have.includes(b.abbrev));
+ return { state: missing.length ? 'partial' : 'ready', translation, missing };
+}
diff --git a/src/utils/offline.test.js b/src/utils/offline.test.js
index da6f9d7..6631345 100644
--- a/src/utils/offline.test.js
+++ b/src/utils/offline.test.js
@@ -42,3 +42,18 @@ test('stops when told to', async () => {
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
expect(fetchMock.mock.calls.length).toBeLessThan(5);
});
+
+import { offlineStatus } from './offline.js';
+
+test('a study works offline once every book in it is saved in its translation', () => {
+ const project = { translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN' }, { book: 'John', bookAbbrev: 'JHN' }, { book: 'Titus', bookAbbrev: 'TIT' }] };
+ expect(offlineStatus(project, { BSB: ['JHN', 'TIT'] })).toMatchObject({ state: 'ready', missing: [] });
+ const partial = offlineStatus(project, { BSB: ['JHN'] });
+ expect(partial.state).toBe('partial');
+ expect(partial.missing).toEqual([{ abbrev: 'TIT', name: 'Titus' }]);
+ expect(offlineStatus(project, { NET: ['JHN', 'TIT'] }).state).toBe('partial'); // saved in a different translation
+});
+
+test('licensed translations cannot be saved offline', () => {
+ expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
+});