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 <noreply@anthropic.com>
This commit is contained in:
1 parent
648c3206df
commit
67645cf1df
14 files changed
+421
-7
No files matched your search
@@ -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 (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
|
||||
<h2 className="text-sm font-medium">Import notes</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">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.</p>
|
||||
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
|
||||
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
|
||||
</label>
|
||||
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{preview && sections.length > 0 && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
|
||||
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
|
||||
</ul>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
|
||||
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
|
||||
if (status.state === 'licensed') {
|
||||
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
|
||||
}
|
||||
const next = status.missing[0];
|
||||
return (
|
||||
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
|
||||
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
|
||||
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section aria-label="Study notes" className="space-y-2">
|
||||
@@ -72,9 +106,21 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||
onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
{pasteCheck.prompt(field)}
|
||||
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
||||
{historyField === field && (
|
||||
<ul aria-label={`${label} history`} className="mt-1.5 space-y-1.5 rounded-xl border border-[var(--r-line)] p-2 text-xs">
|
||||
{versions.length === 0 && <li className="text-[var(--r-sub)]">No earlier versions yet. They are saved as you edit.</li>}
|
||||
{versions.map((v) => (
|
||||
<li key={v.at} className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0"><span className="text-[var(--r-sub)]">{new Date(v.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · {words(v.text)} words · </span><span className="font-serif">{v.text.length > 70 ? `${v.text.slice(0, 70)}…` : v.text}</span></span>
|
||||
<button type="button" onClick={() => restore(field, v.text)} disabled={v.text === chunk[field]} className="shrink-0 text-[var(--r-accent)] hover:underline disabled:opacity-40 disabled:no-underline">Restore</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{pasted > 0 && (
|
||||
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
||||
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// The Export stage: take the finished study out as a file, or share it read-only.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import ImportNotes from '../components/ImportNotes.jsx';
|
||||
import HandoutExport from '../components/HandoutExport.jsx';
|
||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||
import VersionHistory from '../components/VersionHistory.jsx';
|
||||
@@ -63,6 +64,8 @@ export default function ExportPage() {
|
||||
|
||||
<HandoutExport project={project} />
|
||||
|
||||
<ImportNotes />
|
||||
|
||||
<SessionSchedule project={project} />
|
||||
|
||||
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||
import BookCover from '../components/BookCover.jsx';
|
||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||
import RecallHistory from '../components/RecallHistory.jsx';
|
||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||
@@ -55,6 +57,9 @@ export default function HomePage() {
|
||||
handleSeekAudioTo,
|
||||
} = useApp();
|
||||
const narrators = useLazyNarrators(audioNarrator);
|
||||
const offline = useOfflineSave();
|
||||
// Each study's books and translation, read once per list change (the badge itself follows what's saved).
|
||||
const studyBooks = useMemo(() => Object.fromEntries(projectIndex.map((e) => [e.id, loadProjectById(e.id)])), [projectIndex]);
|
||||
const [homeSearch, setHomeSearch] = useState('');
|
||||
const [homeSort, setHomeSort] = useState(() => {
|
||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||
@@ -320,6 +325,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
||||
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
|
||||
<button type="button" onClick={() => 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</button>
|
||||
|
||||
+17
-2
@@ -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() {
|
||||
</button>
|
||||
</h2>
|
||||
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
|
||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify}
|
||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
|
||||
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user