Compare commits
20
Commits
No files matched your search
@@ -15,7 +15,8 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
||||
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
||||
|
||||
## Teaching together
|
||||
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
||||
- [x] **Comments between co-teachers.** Comments on a chunk (under "More"), merged between copies and ticked off when dealt with.
|
||||
Still to do: address a comment to one person, and notify them.
|
||||
- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3"
|
||||
(server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes.
|
||||
- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own
|
||||
@@ -23,7 +24,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
||||
|
||||
## Reading
|
||||
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
||||
the BSB and NET, which have no audio.
|
||||
the NET and other translations that have no matching audio (the BSB and ESV already have narration).
|
||||
- [x] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
||||
|
||||
## Decided against, for now
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 90 KiB |
@@ -11,6 +11,7 @@ import { startReminderScheduler } from './reminders.js';
|
||||
import { startBackupScheduler } from './backups.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { registerIqBibleRoutes } from './iqbible.js';
|
||||
import { registerStrongsRoutes } from './strongs.js';
|
||||
import { registerDeviceRoutes } from './devices.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { userPayload } from './userPayload.js';
|
||||
@@ -85,6 +86,7 @@ registerProjectRoutes(app);
|
||||
registerReaderRoutes(app);
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
registerIqBibleRoutes(app, requireAuth);
|
||||
registerStrongsRoutes(app, requireAuth);
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
|
||||
registerSharingRoutes(app);
|
||||
registerAdminRoutes(app);
|
||||
|
||||
@@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) {
|
||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||
answerNote: text(raw.answerNote, 1000).trim(),
|
||||
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
|
||||
pinned: raw.pinned === true,
|
||||
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are
|
||||
test('open prayers are the ones not answered or deleted', () => {
|
||||
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('pin, rank and status are kept when valid and dropped when not', () => {
|
||||
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
|
||||
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// "Where else does this word appear?": every verse that uses a Strong's number, grouped by book. Built once, in
|
||||
// memory, from the interlinear files the Reader already ships (public/interlinear, or dist/interlinear in a build),
|
||||
// the first time anyone asks. Greek (G…) and Hebrew (H…) numbers both work.
|
||||
import { existsSync } from 'fs';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { bookNameFor } from './bibleApi.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Strong's numbers come as "G4102", "g4102", "H7225" or sometimes with a letter suffix ("H1234a").
|
||||
export function normalizeStrongs(input) {
|
||||
const m = String(input ?? '').trim().match(/^([GH])0*(\d{1,5})[a-z]?$/i);
|
||||
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
|
||||
}
|
||||
|
||||
// One book's interlinear file -> adds each verse it uses a number in to the index (once per verse).
|
||||
export function indexBook(index, book, data) {
|
||||
for (const [chapter, verses] of Object.entries(data ?? {})) {
|
||||
for (const [verse, words] of Object.entries(verses ?? {})) {
|
||||
const seen = new Set();
|
||||
for (const w of Array.isArray(words) ? words : []) {
|
||||
const s = normalizeStrongs(w?.s);
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
let entry = index.get(s);
|
||||
if (!entry) { entry = { count: 0, books: new Map() }; index.set(s, entry); }
|
||||
let refs = entry.books.get(book);
|
||||
if (!refs) { refs = []; entry.books.set(book, refs); }
|
||||
refs.push([Number(chapter), Number(verse)]);
|
||||
entry.count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The answer for one number: { strongs, total, books: [{ book, name, count, refs: [[chapter, verse], ...] }] },
|
||||
// books in the order the index was built (Bible order when files are indexed in canon order).
|
||||
export function occurrences(index, strongs) {
|
||||
const key = normalizeStrongs(strongs);
|
||||
const entry = key && index.get(key);
|
||||
if (!entry) return { strongs: key, total: 0, books: [] };
|
||||
const books = [...entry.books].map(([book, refs]) => ({
|
||||
book, name: bookNameFor(book) ?? book, count: refs.length,
|
||||
refs: refs.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]),
|
||||
}));
|
||||
return { strongs: key, total: entry.count, books };
|
||||
}
|
||||
|
||||
// Canon order so the books come back Genesis → Revelation, whatever order the directory lists them.
|
||||
const CANON = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
||||
|
||||
export async function buildIndex(dir) {
|
||||
const index = new Map();
|
||||
const files = new Set(await readdir(dir));
|
||||
for (const book of CANON) {
|
||||
if (!files.has(`${book}.json`)) continue;
|
||||
try { indexBook(index, book, JSON.parse(await readFile(join(dir, `${book}.json`), 'utf8'))); } catch { /* a damaged file just leaves that book out */ }
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function interlinearDir() {
|
||||
return [join(__dirname, '..', 'dist', 'interlinear'), join(__dirname, '..', 'public', 'interlinear')].find(existsSync) ?? null;
|
||||
}
|
||||
|
||||
let pending = null;
|
||||
const getIndex = () => {
|
||||
if (!pending) {
|
||||
const dir = interlinearDir();
|
||||
pending = (dir ? buildIndex(dir) : Promise.resolve(new Map())).catch((err) => { pending = null; throw err; });
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
export function registerStrongsRoutes(app, requireAuth) {
|
||||
// GET /api/strongs/:number/occurrences
|
||||
app.get('/api/strongs/:number/occurrences', requireAuth, async (req, res) => {
|
||||
const key = normalizeStrongs(req.params.number);
|
||||
if (!key) return res.status(400).json({ error: 'Use a Strong’s number like G4102 or H7225.' });
|
||||
try {
|
||||
res.json(occurrences(await getIndex(), key));
|
||||
} catch (err) {
|
||||
console.error('GET /api/strongs occurrences error:', err);
|
||||
res.status(500).json({ error: 'Could not look up that word.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { indexBook, normalizeStrongs, occurrences } from './strongs.js';
|
||||
|
||||
test('normalizeStrongs accepts the usual spellings and rejects the rest', () => {
|
||||
expect(normalizeStrongs('g4102')).toBe('G4102');
|
||||
expect(normalizeStrongs(' H07225 ')).toBe('H7225');
|
||||
expect(normalizeStrongs('H1234a')).toBe('H1234');
|
||||
expect(normalizeStrongs('4102')).toBeNull();
|
||||
expect(normalizeStrongs('')).toBeNull();
|
||||
expect(normalizeStrongs(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('occurrences groups verses by book and counts a verse once however often the word repeats', () => {
|
||||
const index = new Map();
|
||||
indexBook(index, 'JHN', {
|
||||
3: { 16: [{ s: 'G25' }, { s: 'G25' }, { s: 'G2316' }], 17: [{ s: 'G2316' }] },
|
||||
1: { 1: [{ s: 'G3056' }, { s: 'G2316' }] },
|
||||
});
|
||||
indexBook(index, 'ROM', { 5: { 8: [{ s: 'G25' }, { s: 'G2316' }] } });
|
||||
|
||||
const love = occurrences(index, 'g25');
|
||||
expect(love.total).toBe(2);
|
||||
expect(love.books.map((b) => [b.book, b.name, b.count])).toEqual([['JHN', 'John', 1], ['ROM', 'Romans', 1]]);
|
||||
|
||||
const god = occurrences(index, 'G2316');
|
||||
expect(god.total).toBe(4);
|
||||
expect(god.books[0].refs).toEqual([[1, 1], [3, 16], [3, 17]]); // sorted by chapter then verse
|
||||
});
|
||||
|
||||
test('an unknown or invalid number comes back empty', () => {
|
||||
const index = new Map();
|
||||
expect(occurrences(index, 'G99999')).toEqual({ strongs: 'G99999', total: 0, books: [] });
|
||||
expect(occurrences(index, 'nonsense')).toEqual({ strongs: null, total: 0, books: [] });
|
||||
});
|
||||
|
||||
test('malformed interlinear data is skipped, not thrown', () => {
|
||||
const index = new Map();
|
||||
expect(() => indexBook(index, 'GEN', { 1: { 1: null, 2: 'x', 3: [null, { s: 5 }, { s: 'H430' }] } })).not.toThrow();
|
||||
expect(occurrences(index, 'H430').total).toBe(1);
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('Search palette (⌘K)', () => {
|
||||
await user.type(await paletteBox(), 'abounds');
|
||||
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||
await user.click(hit);
|
||||
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Escape closes it', async () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
|
||||
// descendants a few generations out. From Theographic Bible Metadata.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadPerson } from '../hooks/useVerseStudy.js';
|
||||
import { relatives, walk } from '../utils/family.js';
|
||||
|
||||
function Branch({ node, onOpen }) {
|
||||
return (
|
||||
<li>
|
||||
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Tree({ id, onOpen }) {
|
||||
const [state, setState] = useState({ loading: true });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
|
||||
.then(([up, down]) => { if (live) setState({ up, down }); })
|
||||
.catch(() => { if (live) setState({ error: true }); });
|
||||
return () => { live = false; };
|
||||
}, [id]);
|
||||
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||
const block = (title, node, empty) => (
|
||||
<div>
|
||||
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||
</div>
|
||||
);
|
||||
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||
}
|
||||
|
||||
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
|
||||
export default function Family({ person, renderPerson }) {
|
||||
const groups = relatives(person);
|
||||
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
|
||||
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||
{groups.map((g) => (
|
||||
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||
{g.people.map((p) => (
|
||||
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||
</details>
|
||||
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import Family from './FamilyTree.jsx';
|
||||
|
||||
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||
const RECORDS = {
|
||||
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
|
||||
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
|
||||
fam_abel: { id: 'fam_abel', name: 'Abel' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
const id = String(url).match(/people\/(.+)\.json/)?.[1];
|
||||
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
|
||||
}));
|
||||
});
|
||||
|
||||
test('the relatives named in the record are listed, and clicking one opens them', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
const family = screen.getByLabelText('Family');
|
||||
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the family tree shows ancestors and descendants', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
await user.click(screen.getByText('Family tree'));
|
||||
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
|
||||
const tree = screen.getByText('Ancestors').closest('div').parentElement;
|
||||
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
|
||||
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a person with no recorded family shows nothing', () => {
|
||||
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// What an original-language word's grammar label means, in plain English.
|
||||
import { grammarNotes } from '../utils/grammar.js';
|
||||
|
||||
export default function GrammarPanel({ label, className = '' }) {
|
||||
const notes = grammarNotes(label);
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||
{notes.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{notes.map((n) => (
|
||||
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Home card: the prayers you pinned, in the order you set, with a one-tap "I prayed". Hidden when nothing is pinned.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { STATUSES, pinned } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
export default function PinnedPrayers() {
|
||||
const { prayers, togglePrayed, openPrayer } = useApp();
|
||||
const all = pinned(prayers);
|
||||
const list = all.slice(0, 5);
|
||||
const total = all.length;
|
||||
if (!list.length) return null;
|
||||
const today = localDateString();
|
||||
return (
|
||||
<section aria-label="Pinned prayers" className="rounded-xl border border-[var(--r-accent)] border-l-4 bg-[var(--r-bar)] px-4 py-3 text-sm shadow-sm">
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||
<h2 className="flex items-center gap-1.5 font-semibold text-[var(--r-accent)]"><svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z" /></svg>Praying for <span className="text-xs font-normal text-[var(--r-sub)]">{total > list.length ? `${list.length} of ${total}` : total}</span></h2>
|
||||
<button type="button" onClick={openPrayer} className="text-xs text-[var(--r-accent)] hover:underline">Open journal →</button>
|
||||
</div>
|
||||
<ul className="divide-y divide-[var(--r-line)]">
|
||||
{list.map((p) => {
|
||||
const done = p.prayedOn.includes(today);
|
||||
const days = p.prayedOn.length;
|
||||
return (
|
||||
<li key={p.id} className="flex items-start gap-3 py-2.5 first:pt-1.5 last:pb-0.5">
|
||||
<button type="button" onClick={() => togglePrayed(p.id)} aria-pressed={done} aria-label={`${done ? 'Prayed today' : 'I prayed'}: ${p.text.slice(0, 40)}`}
|
||||
title={done ? 'Prayed today (tap to undo)' : 'Mark as prayed today'}
|
||||
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${done ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] text-transparent hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]'}`}>✓</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
{(p.forWhom || p.status) && (
|
||||
<p className="mb-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||
{p.forWhom && <span>{p.forWhom}</span>}
|
||||
{p.status && <span className="rounded-full border border-[var(--r-accent)] px-1.5 py-px text-[10px] normal-case tracking-normal text-[var(--r-accent)]">{STATUSES[p.status]}</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="line-clamp-3 leading-snug">{p.text}</p>
|
||||
{p.status === 'partial' && p.answerNote && <p className="mt-1 line-clamp-2 text-xs italic text-[var(--r-sub)]">{p.answerNote}</p>}
|
||||
{days > 0 && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Prayed {days} day{days === 1 ? '' : 's'}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import PinnedPrayers from './PinnedPrayers.jsx';
|
||||
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
const show = (prayers) => {
|
||||
const ctx = { prayers, togglePrayed: vi.fn(), openPrayer: vi.fn() };
|
||||
const view = render(<AppContext.Provider value={ctx}><PinnedPrayers /></AppContext.Provider>);
|
||||
return { ctx, view };
|
||||
};
|
||||
|
||||
test('shows nothing when no prayer is pinned', () => {
|
||||
const { view } = show([newPrayer({ text: 'Unpinned' }, T0)]);
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('lists open pinned prayers in order, lets you mark one prayed, and opens the journal', async () => {
|
||||
const a = newPrayer({ text: 'For Sam', forWhom: 'Sam' }, T0);
|
||||
const b = newPrayer({ text: 'Wisdom' }, T0 + 1);
|
||||
const c = newPrayer({ text: 'Done already' }, T0 + 2);
|
||||
let list = [a, b, c];
|
||||
list = setPinned(setPinned(setPinned(list, b.id, true, T0 + 5), a.id, true, T0 + 6), c.id, true, T0 + 7);
|
||||
list = list.map((p) => (p.id === c.id ? markAnswered(p, '', T0 + 9) : p));
|
||||
const user = userEvent.setup();
|
||||
const { ctx } = show(list);
|
||||
const rows = screen.getAllByRole('listitem');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('Wisdom');
|
||||
expect(rows[1]).toHaveTextContent('Sam');
|
||||
await user.click(screen.getAllByRole('button', { name: /I prayed/ })[0]);
|
||||
expect(ctx.togglePrayed).toHaveBeenCalledWith(b.id);
|
||||
await user.click(screen.getByRole('button', { name: /Open journal/ }));
|
||||
expect(ctx.openPrayer).toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export default function RecallHistory() {
|
||||
const { counts, total, recent } = recallSummary(history);
|
||||
|
||||
return (
|
||||
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||
<section aria-label="Recall history" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
||||
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
||||
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse
|
||||
// (BSB) right there, or open it in the Bible reader. The text itself is never changed.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { fetchChapter } from '../utils/bibleText.js';
|
||||
import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js';
|
||||
|
||||
const MAX_VERSES = 12;
|
||||
const CHAPTER_PREVIEW = 6;
|
||||
|
||||
function Preview({ r }) {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const [state, setState] = useState({ loading: true });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setState({ loading: true });
|
||||
fetchChapter('BSB', r.book, r.chapter)
|
||||
.then(({ verses }) => {
|
||||
const from = r.verse ?? 1;
|
||||
const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1;
|
||||
const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES);
|
||||
if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length });
|
||||
})
|
||||
.catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); });
|
||||
return () => { live = false; };
|
||||
}, [r.book, r.chapter, r.verse, r.endVerse]);
|
||||
|
||||
return (
|
||||
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
|
||||
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
|
||||
{state.verses && (state.verses.length === 0
|
||||
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
|
||||
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
|
||||
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
|
||||
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
|
||||
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScriptureRefs({ text }) {
|
||||
const refs = useMemo(() => uniqueReferences(text), [text]);
|
||||
const [open, setOpen] = useState(null);
|
||||
if (refs.length === 0) return null;
|
||||
const openRef = refs.find((r) => labelFor(r) === open);
|
||||
return (
|
||||
<div className="mt-1.5" aria-label="References in this note">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
|
||||
{refs.map((r) => {
|
||||
const label = labelFor(r);
|
||||
return (
|
||||
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
|
||||
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
{openRef && <Preview r={openRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import ScriptureRefs from './ScriptureRefs.jsx';
|
||||
|
||||
const jump = vi.fn();
|
||||
const open = vi.fn();
|
||||
beforeEach(() => {
|
||||
jump.mockClear(); open.mockClear();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
|
||||
})));
|
||||
});
|
||||
const setup = (text) => render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
|
||||
|
||||
test('references in a note become chips, and a note with none shows nothing', () => {
|
||||
const { container } = setup('Nothing to see here.');
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
|
||||
expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
|
||||
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||
const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
|
||||
expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
|
||||
expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
|
||||
await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
|
||||
expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
|
||||
expect(open).toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failure to load the verse still leaves the link to the Bible', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
const user = userEvent.setup();
|
||||
setup('Psalm 23:1');
|
||||
await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
|
||||
const preview = await screen.findByRole('region');
|
||||
expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
|
||||
expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Finish → Series progress: every session of the study with where it stands (studied, ready to teach, taught) and
|
||||
// what is still open on it, so a long study doesn't lose track of itself.
|
||||
import { seriesProgress, STAGES } from '../utils/seriesProgress.js';
|
||||
|
||||
const PILL = ['border border-[var(--r-line)] text-[var(--r-sub)]', 'bg-[var(--r-bar)] border border-[var(--r-line)]', 'border border-[var(--r-accent)] text-[var(--r-accent)]', 'bg-[var(--r-accent)] text-[var(--r-on)]'];
|
||||
|
||||
export default function SeriesProgress({ project, onOpen }) {
|
||||
const { rows, counts, taught, total, next } = seriesProgress(project);
|
||||
if (total < 2) return null; // a series needs more than one session
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Series progress">
|
||||
<h2 className="text-sm font-medium">Series progress</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
|
||||
{taught} of {total} session{total === 1 ? '' : 's'} taught
|
||||
{next ? `. Next up: session ${next.number}, ${next.reference}${next.title ? ` (${next.title})` : ''}.` : '. The series is finished.'}
|
||||
{' '}Worked out from what you’ve written: notes make a session “Studied”, an outline, script or questions make it “Ready to teach”, and session notes make it “Taught”.
|
||||
</p>
|
||||
<div className="mb-3 flex h-2 overflow-hidden rounded-full bg-[var(--r-line)]" role="img" aria-label={STAGES.map((s, i) => `${counts[i]} ${s.toLowerCase()}`).join(', ')}>
|
||||
{counts.map((n, i) => n > 0 && <span key={STAGES[i]} style={{ width: `${(n / total) * 100}%`, opacity: [0, 0.35, 0.65, 1][i] }} className={i === 0 ? '' : 'bg-[var(--r-accent)]'} />)}
|
||||
</div>
|
||||
<ul className="divide-y divide-[var(--r-line)]">
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
|
||||
<span className="w-6 shrink-0 text-xs text-[var(--r-sub)]">{r.number}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{onOpen
|
||||
? <button type="button" onClick={() => onOpen(r.id)} className="text-left hover:underline">{r.reference}{r.title ? ` · ${r.title}` : ''}</button>
|
||||
: <span>{r.reference}{r.title ? ` · ${r.title}` : ''}</span>}
|
||||
</span>
|
||||
{(r.openFollowUps > 0 || r.openComments > 0) && (
|
||||
<span className="text-xs text-[var(--r-sub)]">
|
||||
{[r.openFollowUps > 0 && `${r.openFollowUps} follow-up${r.openFollowUps === 1 ? '' : 's'}`, r.openComments > 0 && `${r.openComments} comment${r.openComments === 1 ? '' : 's'}`].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs ${PILL[r.stage]}`}>{STAGES[r.stage]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import IqCommentary from './iq/IqCommentary.jsx';
|
||||
import Family from './FamilyTree.jsx';
|
||||
import {
|
||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||
} from '../hooks/useVerseStudy.js';
|
||||
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
|
||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||
{!data.description?.length && <p>No description available.</p>}
|
||||
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||
{data.minYear != null && (
|
||||
<p className="text-[var(--r-sub,#64748b)]">
|
||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||
|
||||
@@ -60,8 +60,8 @@ function Card({ iq }) {
|
||||
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
||||
{verse && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="whitespace-nowrap text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
|
||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
|
||||
// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
|
||||
// are folded below. Scripture references in an entry open in the Reader.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
|
||||
|
||||
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
|
||||
function Entry({ entry, credit, open: startOpen }) {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const [open, setOpen] = useState(startOpen);
|
||||
const p = preview(entry.definition);
|
||||
const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
|
||||
return (
|
||||
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||
<h4 className="font-medium">{entry.term}</h4>
|
||||
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
|
||||
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
|
||||
{refs.length > 0 && (
|
||||
<p className="mt-2 flex flex-wrap gap-1.5">
|
||||
{refs.map((r) => (
|
||||
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
|
||||
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqDictionary() {
|
||||
const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } });
|
||||
const [typed, setTyped] = useState('');
|
||||
const [asked, setAsked] = useState('');
|
||||
const list = useIq('dictionaries');
|
||||
const dictionaries = list.data?.data ?? [];
|
||||
const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0];
|
||||
const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked });
|
||||
const { exact, others } = pickEntries(result.data?.data, asked);
|
||||
const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : '';
|
||||
const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } };
|
||||
const ask = () => setAsked(typed.trim().slice(0, 100));
|
||||
|
||||
return (
|
||||
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
|
||||
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} />
|
||||
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
|
||||
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
|
||||
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
|
||||
</div>
|
||||
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
|
||||
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
|
||||
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
|
||||
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
|
||||
)}
|
||||
{exact && <Entry entry={exact} credit={credit} open={false} />}
|
||||
{others.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
|
||||
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../../context/AppContext.js';
|
||||
import IqDictionary from './IqDictionary.jsx';
|
||||
|
||||
const LIST = { count: 2, data: [
|
||||
{ id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
|
||||
{ id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
|
||||
] };
|
||||
const ANSWER = { count: 2, data: [
|
||||
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
|
||||
{ term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
|
||||
] };
|
||||
|
||||
const jump = vi.fn();
|
||||
const open = vi.fn();
|
||||
const asked = [];
|
||||
beforeEach(() => {
|
||||
asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
asked.push(String(url));
|
||||
const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||
}));
|
||||
});
|
||||
|
||||
test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||
await user.click(screen.getByText('Bible dictionary'));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
|
||||
const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
|
||||
expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
|
||||
expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
|
||||
await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
|
||||
expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
|
||||
expect(open).toHaveBeenCalled();
|
||||
expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
|
||||
});
|
||||
|
||||
test('the chosen dictionary is remembered and used for the next lookup', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||
await user.click(screen.getByText('Bible dictionary'));
|
||||
await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
|
||||
expect(localStorage.getItem('dictionary')).toBe('smith');
|
||||
await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
|
||||
await screen.findByRole('heading', { name: 'Talent' });
|
||||
expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(-
|
||||
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||
return (
|
||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="fixed-bar safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
<HomeButton onClick={onBack} />
|
||||
<button type="button" onClick={onOpenNavigator}
|
||||
@@ -44,7 +44,7 @@ export function BottomBar({
|
||||
}) {
|
||||
const active = 'text-[var(--r-accent)]';
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||
<div className="fixed-bar fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
||||
|
||||
@@ -36,7 +36,7 @@ function OriginalTab({ words }) {
|
||||
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
||||
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||
{canSpeak && (
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s, w.t)}
|
||||
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function DataSection() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||
{authUser && (
|
||||
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||
<a href="/api/account/export" download onClick={() => markBackedUp()} className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||
)}
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||
@@ -106,7 +106,7 @@ export default function DataSection() {
|
||||
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
||||
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{['BSB', 'NET'].map((t) => (
|
||||
{['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
|
||||
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../../context/AppContext.js';
|
||||
import { ToastProvider } from '../Toasts.jsx';
|
||||
import { lastBackupAt } from '../../utils/backup.js';
|
||||
import DataSection from './DataSection.jsx';
|
||||
|
||||
const value = {
|
||||
authUser: { email: 'a@b.test' }, readerBookmarks: {}, readerBookmarkNotes: {}, readerTextHighlights: [], readerColorLabels: {}, readerInkByPage: {},
|
||||
readingPlansState: { progress: [], customPlans: [], activity: [] }, importReaderMarks: vi.fn(), importReadingPlans: vi.fn(), refreshProjectIndex: vi.fn(), prayers: [], importPrayers: vi.fn(),
|
||||
};
|
||||
const setup = () => {
|
||||
render(<AppContext.Provider value={value}><ToastProvider><DataSection /></ToastProvider></AppContext.Provider>);
|
||||
return userEvent.setup();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
URL.createObjectURL = vi.fn(() => 'blob:x');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
document.addEventListener('click', (e) => { if (e.target.closest?.('a[download]')) e.preventDefault(); }); // no real navigation in the test
|
||||
});
|
||||
|
||||
test('downloading the backup file is remembered, so Home stops asking', async () => {
|
||||
const user = setup();
|
||||
expect(lastBackupAt()).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: 'Download backup' }));
|
||||
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||
});
|
||||
|
||||
test('downloading from the account is remembered too', async () => {
|
||||
const user = setup();
|
||||
expect(lastBackupAt()).toBeNull();
|
||||
await user.click(screen.getByRole('link', { name: 'Download from my account' }));
|
||||
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Comments for the people teaching this study together, shown in the notes' "More" area. Open ones can be marked done.
|
||||
import { useState } from 'react';
|
||||
import { addComment, removeComment, toggleComment } from '../../utils/chunkComments.js';
|
||||
|
||||
const when = (at) => new Date(at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
export default function ChunkComments({ chunk, updateChunk, author }) {
|
||||
const comments = chunk.comments ?? [];
|
||||
const [text, setText] = useState('');
|
||||
const set = (next) => updateChunk(chunk.id, { comments: next });
|
||||
const post = () => {
|
||||
const next = addComment(comments, { by: author, text });
|
||||
if (next !== comments) { set(next); setText(''); }
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--r-sub)]">Comments <span className="opacity-70">(notes between the people teaching this study; tick one off when it’s dealt with)</span></p>
|
||||
{comments.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-1.5" aria-label="Comments">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="flex items-start gap-2 rounded-xl border border-[var(--r-line)] px-2.5 py-2 text-sm">
|
||||
<input type="checkbox" aria-label={`Done: ${c.text}`} checked={!!c.done} onChange={() => set(toggleComment(comments, c.id))} className="mt-1 accent-[var(--r-accent)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block whitespace-pre-wrap ${c.done ? 'text-[var(--r-sub)] line-through' : ''}`}>{c.text}</span>
|
||||
<span className="text-[11px] text-[var(--r-sub)]">{c.by} · {when(c.at)}</span>
|
||||
</span>
|
||||
<button type="button" aria-label={`Delete comment: ${c.text}`} onClick={() => set(removeComment(comments, c.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<input aria-label="Add a comment" value={text} onChange={(e) => setText(e.target.value)} maxLength={1000} placeholder="Write a comment, then Enter"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); post(); } }}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||
<button type="button" onClick={post} disabled={!text.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import SessionNotes from './SessionNotes.jsx';
|
||||
import FirstDraft from './FirstDraft.jsx';
|
||||
import ChunkComments from './ChunkComments.jsx';
|
||||
import { openCommentCount } from '../../utils/chunkComments.js';
|
||||
import { bookBackground, hasBookInfo } from '../../utils/bookInfo.js';
|
||||
import ScriptureRefs from '../ScriptureRefs.jsx';
|
||||
import { usePasteCheck } from './PastePrompt.jsx';
|
||||
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||
@@ -21,7 +25,7 @@ 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, focusRequest }) {
|
||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest, author, bookAbbrev, bookName }) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||
});
|
||||
@@ -108,6 +112,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<div className="px-4 pb-4">
|
||||
<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} />
|
||||
<ScriptureRefs text={chunk[field]} />
|
||||
{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 && (
|
||||
@@ -140,7 +145,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<div className="rounded-2xl border border-[var(--r-line)]">
|
||||
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
|
||||
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
|
||||
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}{openCommentCount(chunk.comments) ? ` · ${openCommentCount(chunk.comments)} open comment${openCommentCount(chunk.comments) === 1 ? '' : 's'}` : ''}</span></span>
|
||||
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{more && (
|
||||
@@ -150,12 +155,18 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
||||
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
||||
<ScriptureRefs text={chunk.generalNotes} />
|
||||
{hasBookInfo(bookAbbrev) && !(chunk.generalNotes ?? '').includes('at a glance') && (
|
||||
<button type="button" onClick={() => updateChunk(chunk.id, { generalNotes: `${(chunk.generalNotes ?? '').trimEnd()}${chunk.generalNotes?.trim() ? '\n\n' : ''}${bookBackground(bookAbbrev, bookName)}` })}
|
||||
className="mt-1 block text-xs text-[var(--r-accent)] hover:underline">Add {bookName ?? 'book'} at a glance (author, date, theme)</button>
|
||||
)}
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||
</label>
|
||||
<ChunkComments chunk={chunk} updateChunk={updateChunk} author={author} />
|
||||
<FirstDraft chunk={chunk} />
|
||||
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||
{saveTemplate && (
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
{phone && (
|
||||
<>
|
||||
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
|
||||
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
|
||||
<nav aria-label="Main" className="fixed-bar fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
|
||||
{NAV.map((n) => (
|
||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
||||
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// A section of the Study page lifted into a large window over the page, so there's room to read commentary or
|
||||
// write notes without the column's cramped width. Esc, the backdrop or Close puts it back.
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function PopOut({ title, onClose, children }) {
|
||||
const panel = useRef(null);
|
||||
useEffect(() => { panel.current?.focus(); }, []);
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden="true" />
|
||||
<div ref={panel} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1}
|
||||
className="absolute inset-2 flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] shadow-2xl focus:outline-none sm:inset-4 lg:inset-x-[7vw] lg:inset-y-5 xl:inset-x-[12vw]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--r-line)] px-4 py-2.5">
|
||||
<h2 className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">Close (Esc)</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,11 @@ export const RESOURCE_TABS = [
|
||||
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
||||
];
|
||||
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||
// leadTab: { id, label, node } shown as the first tab, in place of the resources, when it is the selected one (the Study page
|
||||
// puts the notes there, so notes and resources share one column).
|
||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId, leadTab }) {
|
||||
const iq = useIqAvailable();
|
||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
||||
const tabs = [...(leadTab ? [leadTab] : []), ...RESOURCE_TABS.filter((t) => !t.needsIq || iq)];
|
||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
|
||||
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||
@@ -37,7 +39,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||
<div role="tablist" aria-label="Study resources" className={`flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5 ${leadTab ? 'sm:pr-11' : ''}`}>
|
||||
{tabs.map((t) => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
@@ -46,6 +48,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{leadTab && tab === leadTab.id && leadTab.node}
|
||||
{held && (
|
||||
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
|
||||
<p className="font-medium">Observe first</p>
|
||||
@@ -68,8 +71,8 @@ export function useResourceTab() {
|
||||
const [tab, setTabState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('studyResourceTab');
|
||||
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
|
||||
} catch { return 'commentary'; }
|
||||
return saved === 'notes' || RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'notes';
|
||||
} catch { return 'notes'; }
|
||||
});
|
||||
const setTab = (id) => {
|
||||
setTabState(id);
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// interlinear words; click an original word to add it to the chunk's word study.
|
||||
import { useState } from 'react';
|
||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||
import GrammarPanel from '../GrammarPanel.jsx';
|
||||
import { markUnits } from '../../utils/biblicalUnits.js';
|
||||
|
||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||
const [openVerse, setOpenVerse] = useState(null);
|
||||
const [editingNote, setEditingNote] = useState(null);
|
||||
const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index }
|
||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||
return (
|
||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||
@@ -20,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
||||
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||
</button>
|
||||
<span>{v.text}</span>
|
||||
<span>{markUnits(v.text).map((part, i) => (part.note
|
||||
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
|
||||
: <span key={i}>{part.text}</span>))}</span>
|
||||
{onVerseNote && (
|
||||
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||
@@ -38,6 +43,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||
return (
|
||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
|
||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||
@@ -45,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
|
||||
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// "Where else is this word used?": the verses that share a Strong's number, by book, so a word study can show
|
||||
// how an author (or the whole Bible) uses it. Books open to their verse list; Copy puts the whole list on the clipboard.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getStrongsOccurrences } from '../../syncService.js';
|
||||
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
// "John 3:16, 17; 4:1" style list for one book.
|
||||
export function refsText(book) {
|
||||
const chapters = [];
|
||||
for (const [chapter, verse] of book.refs) {
|
||||
const last = chapters[chapters.length - 1];
|
||||
if (last && last.chapter === chapter) last.verses.push(verse); else chapters.push({ chapter, verses: [verse] });
|
||||
}
|
||||
return `${book.name} ${chapters.map((c) => `${c.chapter}:${c.verses.join(', ')}`).join('; ')}`;
|
||||
}
|
||||
|
||||
export function occurrencesText(strongs, data) {
|
||||
const lines = data.books.map((b) => `${refsText(b)} (${b.count})`);
|
||||
return `${strongs} appears in ${data.total} verse${data.total === 1 ? '' : 's'}:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
export default function WordOccurrences({ strongs, onClose }) {
|
||||
const [state, setState] = useState({ status: 'loading' });
|
||||
const [open, setOpen] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
getStrongsOccurrences(strongs).then((r) => {
|
||||
if (cancelled) return;
|
||||
setState(r.ok ? { status: 'ready', data: r.data } : { status: 'error', error: r.error });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [strongs]);
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(occurrencesText(strongs, state.data)); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { /* clipboard unavailable */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3" aria-label={`Where ${strongs} is used`}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<p className="text-sm font-medium">Where {strongs} is used</p>
|
||||
<button type="button" onClick={onClose} className="ml-auto text-xs text-[var(--r-sub)] hover:underline">Close</button>
|
||||
</div>
|
||||
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Counting… (the first look-up takes a few seconds)</p>}
|
||||
{state.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{state.error}</p>}
|
||||
{state.status === 'ready' && (state.data.total === 0
|
||||
? <p className="text-xs text-[var(--r-sub)]">No verses found for {strongs}.</p>
|
||||
: (
|
||||
<>
|
||||
<p className="mb-2 text-xs text-[var(--r-sub)]">{state.data.total} verse{state.data.total === 1 ? '' : 's'} in {state.data.books.length} book{state.data.books.length === 1 ? '' : 's'}.</p>
|
||||
<ul className="space-y-1">
|
||||
{state.data.books.map((b) => (
|
||||
<li key={b.book} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)]">
|
||||
<button type="button" onClick={() => setOpen(open === b.book ? null : b.book)} aria-expanded={open === b.book}
|
||||
className="flex w-full items-center justify-between px-2.5 py-1.5 text-left text-sm">
|
||||
<span>{b.name}</span><span className="text-xs text-[var(--r-sub)]">{b.count}</span>
|
||||
</button>
|
||||
{open === b.book && <p className="border-t border-[var(--r-line)] px-2.5 py-2 text-xs leading-relaxed">{refsText(b).slice(b.name.length + 1)}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={copy} className={`${btn} mt-2`}>{copied ? 'Copied!' : 'Copy the list'}</button>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import WordOccurrences, { occurrencesText, refsText } from './WordOccurrences.jsx';
|
||||
import { getStrongsOccurrences } from '../../syncService.js';
|
||||
|
||||
vi.mock('../../syncService.js', () => ({ getStrongsOccurrences: vi.fn() }));
|
||||
|
||||
const data = { strongs: 'G25', total: 4, books: [
|
||||
{ book: 'JHN', name: 'John', count: 3, refs: [[3, 16], [3, 35], [14, 15]] },
|
||||
{ book: 'ROM', name: 'Romans', count: 1, refs: [[5, 8]] },
|
||||
] };
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test('refs are grouped by chapter and the copied list names every book', () => {
|
||||
expect(refsText(data.books[0])).toBe('John 3:16, 35; 14:15');
|
||||
expect(occurrencesText('G25', data)).toBe('G25 appears in 4 verses:\nJohn 3:16, 35; 14:15 (3)\nRomans 5:8 (1)');
|
||||
});
|
||||
|
||||
test('shows the books with counts and opens one to list its verses', async () => {
|
||||
getStrongsOccurrences.mockResolvedValue({ ok: true, data });
|
||||
render(<WordOccurrences strongs="G25" onClose={() => {}} />);
|
||||
expect(await screen.findByText('4 verses in 2 books.')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /John/ }));
|
||||
expect(screen.getByText('3:16, 35; 14:15')).toBeInTheDocument();
|
||||
expect(getStrongsOccurrences).toHaveBeenCalledWith('G25');
|
||||
});
|
||||
|
||||
test('says so when the word is not found, and when the look-up fails', async () => {
|
||||
getStrongsOccurrences.mockResolvedValueOnce({ ok: true, data: { strongs: 'G1', total: 0, books: [] } });
|
||||
const { unmount } = render(<WordOccurrences strongs="G1" onClose={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('No verses found for G1.')).toBeInTheDocument());
|
||||
unmount();
|
||||
getStrongsOccurrences.mockResolvedValueOnce({ ok: false, error: 'Could not look up that word.' });
|
||||
render(<WordOccurrences strongs="G2" onClose={() => {}} />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not look up that word.');
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import GrammarPanel from '../GrammarPanel.jsx';
|
||||
import IqDictionary from '../iq/IqDictionary.jsx';
|
||||
import WordOccurrences from './WordOccurrences.jsx';
|
||||
|
||||
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
@@ -14,6 +17,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
|
||||
const iq = useIqAvailable();
|
||||
const [lexicon, setLexicon] = useState(false);
|
||||
const [where, setWhere] = useState(false);
|
||||
const strongs = normalizeStrongs(word.strongNumber || word.query);
|
||||
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
|
||||
const field = (label, key, placeholder) => (
|
||||
@@ -32,6 +36,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
|
||||
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
|
||||
{where && strongs && <WordOccurrences strongs={strongs} onClose={() => setWhere(false)} />}
|
||||
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
|
||||
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
|
||||
@@ -41,7 +46,8 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
|
||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
||||
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||
{strongs && <button type="button" onClick={() => setWhere((o) => !o)} aria-pressed={where} className={btn} title="Every verse that uses this word, by book">Where else</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber, word.transliteration)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -50,6 +56,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||
</div>
|
||||
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||||
{field('Short definition', 'shortDefinition')}
|
||||
{word.definitionHtml && (
|
||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||
@@ -76,8 +83,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||
|
||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
|
||||
return (
|
||||
<div>
|
||||
{iq && <IqDictionary />}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// added on your phone appears on your computer. Signing out clears this device's copy.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
||||
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
|
||||
import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
const KEY = 'prayers';
|
||||
@@ -42,6 +42,9 @@ export function usePrayers() {
|
||||
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
||||
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
||||
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
||||
const pinPrayer = (id, on) => set(setPinned(ref.current, id, on));
|
||||
const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir));
|
||||
const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note));
|
||||
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
||||
const deletePrayer = (id) => {
|
||||
const before = ref.current.find((p) => p.id === id);
|
||||
@@ -70,5 +73,5 @@ export function usePrayers() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
||||
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
||||
}
|
||||
@@ -200,17 +200,16 @@ export function useProjectLibrary({
|
||||
}
|
||||
};
|
||||
|
||||
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
||||
const setProjectSeries = (id, series) => {
|
||||
// Changes one top-level field of a stored study (from Home, without opening it) and syncs it.
|
||||
const patchStoredStudy = (id, patch) => {
|
||||
const loaded = loadProjectById(id);
|
||||
if (!loaded) return;
|
||||
const name = String(series ?? '').trim().slice(0, 80);
|
||||
if ((loaded.series ?? '') === name) return;
|
||||
const updated = { ...loaded, series: name, lastEdited: Date.now() };
|
||||
if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return;
|
||||
const updated = { ...loaded, ...patch, lastEdited: Date.now() };
|
||||
saveProjectToStorage(updated);
|
||||
unsyncedStudies.add(id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
|
||||
if (project?.id === id) setProject((current) => (current ? { ...current, ...patch } : current));
|
||||
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||
if (result.ok) {
|
||||
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
||||
@@ -221,6 +220,12 @@ export function useProjectLibrary({
|
||||
});
|
||||
};
|
||||
|
||||
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
||||
const setProjectSeries = (id, series) => patchStoredStudy(id, { series: String(series ?? '').trim().slice(0, 80) });
|
||||
|
||||
// Moves a study to (or back from) the "Finished & archived" section on Home.
|
||||
const setProjectArchived = (id, archived) => patchStoredStudy(id, { archived: !!archived });
|
||||
|
||||
// Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
|
||||
const updateStoredChunk = (id, chunkId, change) => {
|
||||
const loaded = loadProjectById(id);
|
||||
@@ -318,6 +323,7 @@ export function useProjectLibrary({
|
||||
openStudyChunk,
|
||||
renameProjectInStorage,
|
||||
setProjectSeries,
|
||||
setProjectArchived,
|
||||
saveChunkRecall,
|
||||
saveChunkPatch,
|
||||
deleteProject,
|
||||
|
||||
@@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
|
||||
});
|
||||
|
||||
test('archiving a study keeps it, flags it in the index, syncs it, and restoring clears the flag', async () => {
|
||||
saveProjectToStorage(study('s1'));
|
||||
const hook = setup();
|
||||
act(() => hook.result.current.setProjectArchived('s1', true));
|
||||
expect(loadProjectById('s1').archived).toBe(true);
|
||||
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(true);
|
||||
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalledWith(expect.objectContaining({ id: 's1', archived: true }), expect.anything()));
|
||||
sync.saveRemoteProject.mockClear();
|
||||
act(() => hook.result.current.setProjectArchived('s1', true)); // already archived: nothing to do
|
||||
expect(sync.saveRemoteProject).not.toHaveBeenCalled();
|
||||
act(() => hook.result.current.setProjectArchived('s1', false));
|
||||
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(false);
|
||||
});
|
||||
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||
lexeme: w.o || entry?.lemma || '',
|
||||
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||
partOfSpeech: '',
|
||||
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
|
||||
shortDefinition: entry?.kjv_def || w.g || '',
|
||||
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||
loading: false,
|
||||
|
||||
@@ -79,6 +79,14 @@ input,
|
||||
|
||||
/* Top bars leave room for the status bar / notch when the app is installed or full-screen
|
||||
(env() is 0 in an ordinary browser tab). */
|
||||
/* iOS Safari/PWA can leave fixed and sticky bars stranded mid-screen after the toolbar collapses or the page
|
||||
rubber-bands. Giving them their own compositing layer keeps them pinned to the viewport edge. */
|
||||
.fixed-bar {
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SlidesExport from '../components/SlidesExport.jsx';
|
||||
import ImportNotes from '../components/ImportNotes.jsx';
|
||||
import HandoutExport from '../components/HandoutExport.jsx';
|
||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||
import SeriesProgress from '../components/SeriesProgress.jsx';
|
||||
import VersionHistory from '../components/VersionHistory.jsx';
|
||||
import CoTeachers from '../components/CoTeachers.jsx';
|
||||
import LinkRules from '../components/LinkRules.jsx';
|
||||
@@ -63,6 +64,8 @@ export default function ExportPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SeriesProgress project={project} />
|
||||
|
||||
<HandoutExport project={project} />
|
||||
|
||||
<SlidesExport project={project} />
|
||||
|
||||
+125
-81
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||
import BookCover from '../components/BookCover.jsx';
|
||||
import DailyFive from '../components/DailyFive.jsx';
|
||||
import PinnedPrayers from '../components/PinnedPrayers.jsx';
|
||||
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||
import OfflineBadge from '../components/OfflineBadge.jsx';
|
||||
import { useOfflineSave } from '../hooks/useOfflineSave.js';
|
||||
@@ -49,6 +50,7 @@ export default function HomePage() {
|
||||
resumeProject,
|
||||
renameProjectInStorage,
|
||||
setProjectSeries,
|
||||
setProjectArchived,
|
||||
deleteProject,
|
||||
handlePlayBookAudio,
|
||||
handleStopBookAudio,
|
||||
@@ -67,6 +69,7 @@ export default function HomePage() {
|
||||
}); // 'recent' | 'title' | 'passage'
|
||||
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false); // the "Finished & archived" section is folded away until opened
|
||||
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
|
||||
const [seriesEditingId, setSeriesEditingId] = useState(null);
|
||||
const [seriesValue, setSeriesValue] = useState('');
|
||||
@@ -118,25 +121,95 @@ export default function HomePage() {
|
||||
}, 300);
|
||||
}, [homeSearch]);
|
||||
|
||||
const tagList = tagCounts(projectIndex);
|
||||
// Finished studies are set aside in their own section; the tag and series filters follow the active ones
|
||||
const activeIndex = projectIndex.filter((e) => !e.archived);
|
||||
const tagList = tagCounts(activeIndex);
|
||||
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
|
||||
const seriesNames = seriesList(projectIndex);
|
||||
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
|
||||
.sort((a, b) => {
|
||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||
});
|
||||
const seriesNames = seriesList(activeIndex);
|
||||
const byHomeSort = (a, b) => {
|
||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||
};
|
||||
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }).sort(byHomeSort);
|
||||
const archivedStudies = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, archived: true }).sort(byHomeSort);
|
||||
const archivedCount = projectIndex.length - activeIndex.length;
|
||||
const filtering = !!homeSearch.trim() || homeTags.length > 0 || seriesFilter !== '';
|
||||
const archivedOpen = showArchived || ((!!homeSearch.trim() || homeTags.length > 0) && archivedStudies.length > 0);
|
||||
// With series in use (and no single series chosen) the studies are grouped under their series name
|
||||
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
|
||||
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
|
||||
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||
|
||||
const renderEntry = (entry) => (
|
||||
<div key={entry.id} className={homeView === 'list'
|
||||
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
|
||||
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
|
||||
{homeView === 'list'
|
||||
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
|
||||
: <BookCover summary={entry.chapterSummary} className="h-24" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renamingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
||||
else if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
className={`${fieldCls} flex-1`} />
|
||||
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
|
||||
<h2 className="text-base font-medium">{entry.title}</h2>
|
||||
{sharedStudies?.[entry.id] !== undefined && (
|
||||
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
||||
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
|
||||
</div>
|
||||
)}
|
||||
{seriesEditingId === entry.id && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
|
||||
className={`${fieldCls} flex-1`} />
|
||||
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
|
||||
{entry.tags?.length > 0 && (
|
||||
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
||||
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
||||
{entry.tags.slice(0, 3).map((tag) => (
|
||||
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
||||
))}
|
||||
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
||||
</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>
|
||||
{sharedStudies?.[entry.id] === undefined && (
|
||||
<button type="button" onClick={() => setProjectArchived(entry.id, !entry.archived)} title={entry.archived ? 'Move back to your studies' : 'Set this study aside as finished'}
|
||||
className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)] hover:bg-black/5">{entry.archived ? 'Restore' : 'Archive'}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="My Studies"
|
||||
active="studies"
|
||||
maxWidth="max-w-6xl xl:max-w-[88rem]"
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
|
||||
@@ -145,21 +218,15 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<section className="min-w-0">
|
||||
{projectIndex.length > 0 && (() => {
|
||||
const age = backupAge(lastBackupAt());
|
||||
return (
|
||||
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
|
||||
{age.text}.
|
||||
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<DailyFive />
|
||||
<main className="mx-auto grid max-w-6xl gap-y-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:max-w-[88rem] xl:grid-cols-[auto_minmax(0,1fr)_21rem]">
|
||||
{/* Left column: the recall and daily-verse cards (collapses when neither has anything to show) */}
|
||||
<aside className="empty:hidden lg:col-span-2 xl:sticky xl:top-16 xl:col-span-1 xl:max-h-[calc(100vh-5rem)] xl:w-80 xl:self-start xl:overflow-y-auto xl:pr-6">
|
||||
<WeeklyRecall />
|
||||
<RecallHistory />
|
||||
<IqDailyCard />
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 lg:pr-6">
|
||||
<DailyFive />
|
||||
{autoRestoredCount !== null && (
|
||||
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||
)}
|
||||
@@ -236,7 +303,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 && (
|
||||
{visible.length === 0 && archivedStudies.length === 0 && (
|
||||
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
|
||||
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
|
||||
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
|
||||
@@ -277,75 +344,42 @@ export default function HomePage() {
|
||||
</h3>
|
||||
)}
|
||||
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
|
||||
{section.items.map((entry) => (
|
||||
<div key={entry.id} className={homeView === 'list'
|
||||
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
|
||||
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
|
||||
{homeView === 'list'
|
||||
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
|
||||
: <BookCover summary={entry.chapterSummary} className="h-24" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renamingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
||||
else if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
className={`${fieldCls} flex-1`} />
|
||||
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
|
||||
<h2 className="text-base font-medium">{entry.title}</h2>
|
||||
{sharedStudies?.[entry.id] !== undefined && (
|
||||
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
||||
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
|
||||
</div>
|
||||
)}
|
||||
{seriesEditingId === entry.id && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
|
||||
className={`${fieldCls} flex-1`} />
|
||||
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
|
||||
{entry.tags?.length > 0 && (
|
||||
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
||||
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
||||
{entry.tags.slice(0, 3).map((tag) => (
|
||||
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
||||
))}
|
||||
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
||||
</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>
|
||||
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{section.items.map(renderEntry)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visible.length === 0 && archivedStudies.length > 0 && !filtering && (
|
||||
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">Everything is finished or archived. Open the section below to bring a study back.</p>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && (
|
||||
<div className="mt-6 border-t border-[var(--r-line)] pt-4">
|
||||
<button type="button" onClick={() => setShowArchived((o) => !o)} aria-expanded={archivedOpen} aria-controls="archived-studies"
|
||||
className="flex w-full items-center gap-2 text-left text-sm font-medium">
|
||||
<span aria-hidden="true" className="text-xs text-[var(--r-sub)]">{archivedOpen ? '▾' : '▸'}</span>
|
||||
Finished & archived <span className="text-xs font-normal text-[var(--r-sub)]">{filtering && archivedStudies.length !== archivedCount ? `${archivedStudies.length} of ${archivedCount}` : archivedCount}</span>
|
||||
</button>
|
||||
{archivedOpen && (
|
||||
<div id="archived-studies" className={`mt-3 ${homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}`}>
|
||||
{archivedStudies.length === 0
|
||||
? <p className="text-sm text-[var(--r-sub)]">No archived studies match.</p>
|
||||
: archivedStudies.map(renderEntry)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<aside className="space-y-4 lg:sticky lg:top-16 lg:max-h-[calc(100vh-5rem)] lg:self-start lg:overflow-y-auto">
|
||||
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
|
||||
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
|
||||
|
||||
<PinnedPrayers />
|
||||
<RecallHistory />
|
||||
|
||||
{/* BSB audio player — always shown, so it's easy to find */}
|
||||
<AudioPlayer
|
||||
title="Listen to BSB Audio"
|
||||
@@ -378,6 +412,16 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{projectIndex.length > 0 && (() => {
|
||||
const age = backupAge(lastBackupAt());
|
||||
return (
|
||||
<p className={`flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
|
||||
{age.text}.
|
||||
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</aside>
|
||||
</main>
|
||||
</PageShell>
|
||||
|
||||
+68
-13
@@ -1,10 +1,10 @@
|
||||
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
|
||||
// prayed, and the answers when they come.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
|
||||
import { STATUSES, filterPrayers, daysToAnswer, labelFor, live, parsePrayerImport, parseVerses, pinned as pinnedList, prayersToJson, prayersToMarkdown } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
@@ -23,11 +23,12 @@ function VerseChips({ verses, onOpen }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
|
||||
function PrayerCard({ prayer, today, actions, onOpenVerse, pinPos }) {
|
||||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer' | 'partial'
|
||||
const [text, setText] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const prayedToday = prayer.prayedOn.includes(today);
|
||||
const isPinned = !!pinPos;
|
||||
const days = daysToAnswer(prayer);
|
||||
return (
|
||||
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
@@ -41,11 +42,23 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
|
||||
{(prayer.forWhom || isPinned || prayer.status) && (
|
||||
<p className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||
{isPinned && <span className="text-[var(--r-accent)]">Pinned</span>}
|
||||
{prayer.status && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 normal-case tracking-normal">{STATUSES[prayer.status]}</span>}
|
||||
{prayer.forWhom && <span>For {prayer.forWhom}</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
||||
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
||||
</div>
|
||||
)}
|
||||
{prayer.status === 'partial' && prayer.answerNote && mode !== 'partial' && (
|
||||
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||
<p className="text-xs font-medium text-[var(--r-accent)]">Partly answered</p>
|
||||
<p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>
|
||||
</div>
|
||||
)}
|
||||
{prayer.answeredAt && (
|
||||
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
|
||||
@@ -61,6 +74,15 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'partial' && (
|
||||
<div className="mt-3">
|
||||
<textarea aria-label="What has been answered so far?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="What has been answered so far? (optional)" className={`${field} w-full`} />
|
||||
<div className="mt-2 flex gap-3 text-xs">
|
||||
<button type="button" onClick={() => { actions.status(prayer.id, 'partial', note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!prayer.answeredAt && (
|
||||
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
|
||||
@@ -68,7 +90,20 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
</button>
|
||||
)}
|
||||
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
|
||||
<span className="ml-auto flex gap-2">
|
||||
<span className="ml-auto flex flex-wrap gap-2">
|
||||
{!prayer.answeredAt && isPinned && (
|
||||
<>
|
||||
<button type="button" onClick={() => actions.move(prayer.id, -1)} disabled={pinPos.index === 0} aria-label="Move up" title="Move up" className={`${small} disabled:opacity-40`}>↑</button>
|
||||
<button type="button" onClick={() => actions.move(prayer.id, 1)} disabled={pinPos.index === pinPos.count - 1} aria-label="Move down" title="Move down" className={`${small} disabled:opacity-40`}>↓</button>
|
||||
</>
|
||||
)}
|
||||
{!prayer.answeredAt && <button type="button" onClick={() => actions.pin(prayer.id, !isPinned)} aria-pressed={isPinned} className={`${small} ${isPinned ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>{isPinned ? 'Unpin' : 'Pin'}</button>}
|
||||
{!prayer.answeredAt && (
|
||||
<>
|
||||
<button type="button" onClick={() => actions.status(prayer.id, prayer.status === 'waiting' ? '' : 'waiting')} aria-pressed={prayer.status === 'waiting'} className={`${small} ${prayer.status === 'waiting' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Waiting</button>
|
||||
<button type="button" onClick={() => { setNote(prayer.status === 'partial' ? prayer.answerNote : ''); setMode('partial'); }} aria-pressed={prayer.status === 'partial'} className={`${small} ${prayer.status === 'partial' ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>Partly answered</button>
|
||||
</>
|
||||
)}
|
||||
{prayer.answeredAt
|
||||
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
|
||||
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
|
||||
@@ -81,12 +116,13 @@ function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
}
|
||||
|
||||
export default function PrayerPage() {
|
||||
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, pinPrayer, movePrayer, setPrayerStatus, importPrayers, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState('open');
|
||||
const [query, setQuery] = useState('');
|
||||
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
|
||||
const today = localDateString();
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// "Pray about this verse" from the Reader arrives with the verse filled in
|
||||
useEffect(() => {
|
||||
@@ -105,19 +141,31 @@ export default function PrayerPage() {
|
||||
setTab('open');
|
||||
};
|
||||
const actions = {
|
||||
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
|
||||
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer, pin: pinPrayer, move: movePrayer, status: setPrayerStatus,
|
||||
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
|
||||
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
|
||||
};
|
||||
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
|
||||
const pinOrder = useMemo(() => pinnedList(prayers).map((p) => p.id), [prayers]);
|
||||
const download = (json = false) => {
|
||||
const url = URL.createObjectURL(new Blob([json ? prayersToJson(prayers) : prayersToMarkdown(prayers)], { type: json ? 'application/json' : 'text/markdown;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'prayer-journal.md';
|
||||
a.href = url; a.download = json ? 'prayer-journal.json' : 'prayer-journal.md';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
const onImportFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
let result;
|
||||
try { result = parsePrayerImport(await file.text(), prayers); } catch { result = { prayers: [], skipped: 0, error: 'Couldn’t read that file.' }; }
|
||||
if (result.error) { toast(result.error); return; }
|
||||
if (result.prayers.length) importPrayers(result.prayers);
|
||||
toast(`Imported ${result.prayers.length} prayer${result.prayers.length === 1 ? '' : 's'}${result.skipped ? ` (${result.skipped} already here or skipped)` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
|
||||
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
|
||||
@@ -149,7 +197,14 @@ export default function PrayerPage() {
|
||||
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
|
||||
</div>
|
||||
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
|
||||
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className={small}>Import</button>
|
||||
<input ref={fileRef} type="file" accept=".json,.md,.txt,text/markdown,application/json,text/plain" onChange={onImportFile} className="hidden" aria-label="Import prayers file" />
|
||||
{live(prayers).length > 0 && (
|
||||
<>
|
||||
<button type="button" onClick={() => download(false)} className={small}>Download</button>
|
||||
<button type="button" onClick={() => download(true)} className={small} title="Keeps every detail, including the days you prayed">Download JSON</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
@@ -159,7 +214,7 @@ export default function PrayerPage() {
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Prayers">
|
||||
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
|
||||
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} pinPos={pinOrder.includes(p.id) ? { index: pinOrder.indexOf(p.id), count: pinOrder.length } : null} />)}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -4,14 +4,14 @@ import { vi, test, expect } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { ToastProvider } from '../components/Toasts.jsx';
|
||||
import PrayerPage from './PrayerPage.jsx';
|
||||
import { markAnswered, newPrayer } from '../utils/prayers.js';
|
||||
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
|
||||
|
||||
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
function setup(initial = [], extra = {}) {
|
||||
const ctx = {
|
||||
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
||||
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), pinPrayer: vi.fn(), movePrayer: vi.fn(), setPrayerStatus: vi.fn(), importPrayers: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
||||
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
|
||||
};
|
||||
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
|
||||
@@ -78,3 +78,33 @@ test('search narrows the list', async () => {
|
||||
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Peace at home')).toBeNull();
|
||||
});
|
||||
|
||||
test('prayers can be pinned, reordered, and marked waiting or partly answered', async () => {
|
||||
const a = newPrayer({ text: 'First request' }, T0);
|
||||
const b = newPrayer({ text: 'Second request' }, T0 + 1);
|
||||
const list = setPinned(setPinned([a, b], a.id, true, T0 + 5), b.id, true, T0 + 6);
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup(list);
|
||||
const items = screen.getAllByRole('listitem');
|
||||
expect(items[0]).toHaveTextContent('First request');
|
||||
expect(within(items[0]).getByRole('button', { name: 'Move up' })).toBeDisabled();
|
||||
await user.click(within(items[0]).getByRole('button', { name: 'Move down' }));
|
||||
expect(ctx.movePrayer).toHaveBeenCalledWith(a.id, 1);
|
||||
await user.click(within(items[1]).getByRole('button', { name: 'Unpin' }));
|
||||
expect(ctx.pinPrayer).toHaveBeenCalledWith(b.id, false);
|
||||
await user.click(within(items[0]).getByRole('button', { name: 'Waiting' }));
|
||||
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'waiting');
|
||||
await user.click(within(items[0]).getByRole('button', { name: 'Partly answered' }));
|
||||
await user.type(screen.getByLabelText('What has been answered so far?'), 'Job interview came');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(ctx.setPrayerStatus).toHaveBeenCalledWith(a.id, 'partial', 'Job interview came');
|
||||
});
|
||||
|
||||
test('importing a file adds only new prayers', async () => {
|
||||
const have = newPrayer({ text: 'Already here' }, T0);
|
||||
const file = new File([JSON.stringify([{ text: 'Already here' }, { text: 'Brand new' }])], 'p.json', { type: 'application/json' });
|
||||
const ctx = setup([have]);
|
||||
await userEvent.setup().upload(screen.getByLabelText('Import prayers file'), file);
|
||||
expect(ctx.importPrayers).toHaveBeenCalledWith([expect.objectContaining({ text: 'Brand new' })]);
|
||||
expect(await screen.findByText(/Imported 1 prayer/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -100,7 +100,7 @@ function WordCard({ word, chapterRef, onSpeak }) {
|
||||
{word.transliteration && <p className="mt-1 text-base italic text-[var(--r-sub)]">{word.transliteration}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
|
||||
{word.lexeme && <button type="button" onClick={() => onSpeak(word.lexeme, word.strongNumber, word.transliteration)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Hear pronunciation">Speak</button>}
|
||||
{word.strongNumber && <span className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-xs text-[var(--r-sub)]">{word.strongNumber}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// write the final script beside your study notes. Claude prompts and the
|
||||
// pronunciation guide live here because this is where they're needed.
|
||||
import { useState } from 'react';
|
||||
import ScriptureRefs from '../components/ScriptureRefs.jsx';
|
||||
import PresentMode from '../components/PresentMode.jsx';
|
||||
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
@@ -174,6 +175,7 @@ export default function ScriptPage() {
|
||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
<ScriptureRefs text={script} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
+75
-64
@@ -1,6 +1,6 @@
|
||||
// The Study stage: the chunk outline on the left, the passage and your
|
||||
// Observe / Interpret / Apply notes in the middle, and commentary, word study,
|
||||
// cross-references and people & places on the right — all for the selected chunk.
|
||||
// The Study stage: the passage in the middle, with the chunk list as a card that slides over it from the left edge,
|
||||
// and one adjustable column on the right where Notes (Observe / Interpret / Apply) and the resources (commentary, word
|
||||
// study, cross-references, people & places) are tabs — all for the selected chunk.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useCrossRefs } from '../hooks/useCrossRefs.js';
|
||||
@@ -12,6 +12,8 @@ import StudioShell from '../components/study/StudioShell.jsx';
|
||||
import OutlineRail from '../components/study/OutlineRail.jsx';
|
||||
import ScripturePane from '../components/study/ScripturePane.jsx';
|
||||
import NotesPane from '../components/study/NotesPane.jsx';
|
||||
import PopOut from '../components/study/PopOut.jsx';
|
||||
import { commentAuthor } from '../utils/chunkComments.js';
|
||||
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
|
||||
import CompareSheet from '../components/study/CompareSheet.jsx';
|
||||
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
|
||||
@@ -21,7 +23,7 @@ const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
|
||||
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
|
||||
|
||||
export default function StudyPage() {
|
||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
||||
const { project, allChunks, projectIndex, authUser, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
|
||||
|
||||
// Short-lived status line (word suggestions, lookups, …)
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
@@ -42,30 +44,16 @@ export default function StudyPage() {
|
||||
// The right column's width can be dragged (remembered on this device)
|
||||
const gridRef = useRef(null);
|
||||
const rightPanelRef = useRef(null);
|
||||
const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef });
|
||||
const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef, maxAbsolute: 960, reserve: 480 });
|
||||
|
||||
const [tab, setTab] = useResourceTab();
|
||||
const [openWordId, setOpenWordId] = useState(null);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
// Either half of the right column can be folded away so the other fills it (remembered on this device).
|
||||
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 [popped, setPopped] = useState(false); // the notes-and-resources column lifted into a large window
|
||||
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
|
||||
const next = { ...cur, [which]: !cur[which] };
|
||||
if (next[which] && next[other]) next[other] = false;
|
||||
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||
return next;
|
||||
});
|
||||
// The chunk list is a card that slides over the passage (remembered on this device).
|
||||
const [chunksOpen, setChunksOpenState] = useState(() => { try { return localStorage.getItem('study-chunks-open') === 'on'; } catch { return false; } });
|
||||
const setChunksOpen = (open) => { setChunksOpenState(open); try { localStorage.setItem('study-chunks-open', open ? 'on' : 'off'); } catch { /* storage unavailable */ } };
|
||||
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
|
||||
|
||||
const resources = useStudyResources({
|
||||
@@ -76,9 +64,10 @@ export default function StudyPage() {
|
||||
const crossRefs = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
|
||||
const wordStudy = useWordStudy({ project, allChunks, updateProject, notify });
|
||||
|
||||
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
|
||||
const goPrev = () => { if (globalIndex > 0) select(allChunks[globalIndex - 1].id); };
|
||||
const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) select(allChunks[globalIndex + 1].id); };
|
||||
const select = (id) => { updateProject((current) => ({ ...current, selectedChunkId: id })); setChunksOpen(false); };
|
||||
const step = (to) => updateProject((current) => ({ ...current, selectedChunkId: to })); // prev/next keep the card as it is
|
||||
const goPrev = () => { if (globalIndex > 0) step(allChunks[globalIndex - 1].id); };
|
||||
const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) step(allChunks[globalIndex + 1].id); };
|
||||
|
||||
// Arrow keys move between chunks; Escape closes the word picker
|
||||
useEffect(() => {
|
||||
@@ -86,15 +75,17 @@ export default function StudyPage() {
|
||||
const tag = event.target?.tagName;
|
||||
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
|
||||
if (event.key === 'Escape') {
|
||||
if (popped && !wordStudy.suggestModal) { setPopped(false); return; }
|
||||
if (chunksOpen && !wordStudy.suggestModal && !editable) { setChunksOpen(false); return; }
|
||||
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 || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (editable || popped || 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 (NOTE_KEYS[event.key]) { event.preventDefault(); setTab('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
|
||||
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); setTab(TAB_KEYS[event.key.toLowerCase()]); }
|
||||
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -112,15 +103,40 @@ export default function StudyPage() {
|
||||
if (id) { setTab('words'); setOpenWordId(id); }
|
||||
};
|
||||
|
||||
const notesPane = selectedChunk && chapter && (
|
||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
|
||||
author={commentAuthor(authUser)} bookAbbrev={project?.chapters?.[chapterIndex]?.bookAbbrev} bookName={project?.chapters?.[chapterIndex]?.book}
|
||||
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
|
||||
);
|
||||
// Notes and the resources share one column as tabs; Notes is the first.
|
||||
const workPane = selectedChunk && chapter && (
|
||||
<ResourcePane
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
leadTab={{ id: 'notes', label: 'Notes', node: notesPane }}
|
||||
/>
|
||||
);
|
||||
const chunkPosition = selectedChunk ? `${globalIndex + 1}/${allChunks.length}` : '';
|
||||
|
||||
return (
|
||||
<StudioShell stage="study" fixedHeight>
|
||||
<div ref={gridRef} style={rightPanel.width ? { '--study-right': `${rightPanel.width}px` } : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[15rem_minmax(0,1fr)_var(--study-right,28rem)] lg:overflow-hidden xl:grid-cols-[16rem_minmax(0,1fr)_var(--study-right,32rem)]">
|
||||
<aside className="hidden min-h-0 border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:block">
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[2rem_minmax(0,1fr)_var(--study-right,28rem)] lg:overflow-hidden xl:grid-cols-[2rem_minmax(0,1fr)_var(--study-right,32rem)]">
|
||||
{/* The chunk list: a tab on the left edge, and a card that slides over the passage (wide screens) */}
|
||||
<div className="hidden min-h-0 items-start justify-center border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:flex">
|
||||
<button type="button" onClick={() => setChunksOpen(!chunksOpen)} aria-expanded={chunksOpen} aria-controls="study-chunks-card" title="Show or hide the chunk list"
|
||||
className="mt-3 rounded-lg px-1 py-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] [writing-mode:vertical-rl]">
|
||||
Chunks{chunkPosition ? ` · ${chunkPosition}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div id="study-chunks-card" inert={!chunksOpen}
|
||||
className={`absolute bottom-0 left-8 top-0 z-20 hidden w-72 max-w-[40%] border-r border-[var(--r-line)] bg-[var(--r-bar)] shadow-xl transition-[transform,visibility] duration-200 lg:block ${chunksOpen ? 'visible translate-x-0' : 'invisible -translate-x-full'}`}>
|
||||
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<main className="min-h-0 lg:overflow-y-auto">
|
||||
<main className="min-h-0 lg:overflow-y-auto" onMouseDown={() => { if (chunksOpen) setChunksOpen(false); }}>
|
||||
{selectedChunk && chapter ? (
|
||||
<div className="mx-auto max-w-3xl space-y-4 px-5 py-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -153,47 +169,24 @@ export default function StudyPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside ref={rightPanelRef} className="relative flex min-h-0 flex-col gap-3 border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:border-l lg:border-t-0">
|
||||
<aside ref={rightPanelRef} aria-label="Notes and resources" className="relative flex min-h-[32rem] flex-col border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:min-h-0 lg:border-l lg:border-t-0">
|
||||
{/* Drag handle on the column's left edge (wide screens only) */}
|
||||
<div {...rightPanel.handleProps(rightPanelRef)}
|
||||
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
|
||||
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
|
||||
</div>
|
||||
{selectedChunk && chapter && (
|
||||
<>
|
||||
<section aria-label="Notes" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
|
||||
<h2 className="shrink-0">
|
||||
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
|
||||
</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} focusRequest={focusRequest}
|
||||
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
|
||||
<h2 className="shrink-0">
|
||||
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||
Commentary & resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
||||
<ResourcePane
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
<section aria-label="Notes and resources panel" className="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)]">
|
||||
<PopButton label="Open in a larger window" onClick={() => setPopped(true)} />
|
||||
{popped ? <PoppedNote onBack={() => setPopped(false)} /> : workPane}
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{popped && selectedChunk && chapter && (
|
||||
<PopOut title="Notes and resources" onClose={() => setPopped(false)}>{workPane}</PopOut>
|
||||
)}
|
||||
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
|
||||
<SuggestWordsSheet wordStudy={wordStudy} />
|
||||
{statusMessage && (
|
||||
@@ -204,3 +197,21 @@ export default function StudyPage() {
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PopButton({ label, onClick }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} title={label}
|
||||
className="absolute right-2 top-2 z-10 hidden rounded-lg bg-[var(--r-bg)] p-1.5 text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] sm:block">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-8 8M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4" /></svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PoppedNote({ onBack }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<p className="text-sm text-[var(--r-sub)]">Your notes and resources are open in a larger window.</p>
|
||||
<button type="button" onClick={onBack} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Bring it back</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-1
@@ -318,4 +318,8 @@ export async function adminGetProject(id) {
|
||||
|
||||
export async function adminDeleteProject(id) {
|
||||
return request('DELETE', `/admin/projects/${id}`);
|
||||
}
|
||||
}
|
||||
// Every verse that uses a Strong's number, grouped by book: { strongs, total, books: [{ book, name, count, refs }] }
|
||||
export async function getStrongsOccurrences(strongs) {
|
||||
return request('GET', `/strongs/${encodeURIComponent(strongs)}/occurrences`);
|
||||
}
|
||||
+29
-18
@@ -419,33 +419,47 @@ describe('Study workspace', () => {
|
||||
|
||||
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
||||
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /commentary/i }));
|
||||
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
|
||||
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
||||
await user.click(screen.getByRole('tab', { name: /^notes/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^more/i }));
|
||||
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
||||
});
|
||||
|
||||
test('commentary opens once you have written enough of your own Observe notes', async () => {
|
||||
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /commentary/i }));
|
||||
expect(screen.getByText('Observe first')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('tab', { name: /^notes/i }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
|
||||
await user.click(screen.getByRole('tab', { name: /commentary/i }));
|
||||
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('either half of the right column can be folded, and the other always stays open', async () => {
|
||||
test('notes and resources share one column as tabs, with Notes first and open', async () => {
|
||||
const user = await studyOneChunk();
|
||||
const notes = screen.getByRole('button', { name: /your notes/i });
|
||||
const resources = screen.getByRole('button', { name: /commentary & resources/i });
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(notes);
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(resources); // folding the last open half opens the other one
|
||||
expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||
const tabs = screen.getAllByRole('tab').map((t) => t.textContent.replace(/\s·\s\d+$/, ''));
|
||||
expect(tabs.slice(0, 3)).toEqual(['Notes', 'Commentary', 'Word study']);
|
||||
expect(screen.getByRole('tab', { name: /^notes/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('tab', { name: /word study/i }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('tab', { name: /^notes/i }));
|
||||
expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the chunk list is a card on the left edge that opens and closes', async () => {
|
||||
const user = await studyOneChunk();
|
||||
const toggle = screen.getByRole('button', { name: /^chunks · 1\/1$/i });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.keyboard('{Escape}');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
|
||||
@@ -578,18 +592,15 @@ describe('App bar', () => {
|
||||
});
|
||||
|
||||
describe('Study layout', () => {
|
||||
test('notes sit in their own box above the resources, not under the passage', async () => {
|
||||
test('notes are the first tab of the right column, not under the passage', async () => {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await findChunkCounter(1, 1);
|
||||
const notes = screen.getByRole('region', { name: 'Notes' });
|
||||
const resources = screen.getByRole('region', { name: 'Resources' });
|
||||
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
|
||||
// notes come first in the right column
|
||||
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const column = screen.getByRole('region', { name: 'Notes and resources panel' });
|
||||
expect(within(column).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||
expect(within(column).getAllByRole('tab')[0]).toHaveTextContent('Notes');
|
||||
// and the passage column no longer holds them
|
||||
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -249,3 +249,29 @@ describe('Greek word lookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Pop-out window on the Study page', () => {
|
||||
test('notes and resources open in a larger window, show a placeholder in the column, and Esc brings them back', async () => {
|
||||
await goToStudyWith3Chunks();
|
||||
expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i }));
|
||||
const dialog = await screen.findByRole('dialog', { name: /notes and resources/i });
|
||||
expect(dialog).toContainElement(screen.getByRole('tab', { name: /word study/i })); // the tabs moved into the window
|
||||
expect(screen.getAllByRole('tab', { name: /word study/i })).toHaveLength(1);
|
||||
expect(screen.getByText(/are open in a larger window/i)).toBeInTheDocument();
|
||||
expect(dialog).toContainElement(screen.getByRole('textbox', { name: /observe/i }));
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(await findChunkCounter(1, 3)).toBeInTheDocument(); // chunk keys do nothing while it is open
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Bring it back" closes the window', async () => {
|
||||
await goToStudyWith3Chunks();
|
||||
fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i }));
|
||||
await screen.findByRole('dialog', { name: /notes and resources/i });
|
||||
fireEvent.click(screen.getByRole('button', { name: /bring it back/i }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ export const READER_TRANSLATIONS = [
|
||||
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
|
||||
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
|
||||
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
|
||||
{ id: 'KJV', name: 'King James Version', helloao: 'eng_kjv', notice: 'The King James Version is in the public domain.' },
|
||||
{ id: 'ASV', name: 'American Standard Version (1901)', helloao: 'eng_asv', notice: 'The American Standard Version (1901) is in the public domain.' },
|
||||
{ id: 'LSV', name: 'Literal Standard Version', helloao: 'eng_lsv', notice: 'Literal Standard Version © 2020 Covenant Press and the Covenant Christian Coalition. Free to read and share. lsvbible.com' },
|
||||
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
||||
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
||||
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
||||
@@ -16,6 +19,8 @@ export const READER_TRANSLATIONS = [
|
||||
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
|
||||
];
|
||||
|
||||
// The address code helloao uses for a translation (NET is 'eng_net', the KJV 'eng_kjv'; BSB is its own)
|
||||
export const helloaoCode = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.helloao ?? id ?? 'BSB';
|
||||
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
|
||||
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ export function parseBibleChapter(data) {
|
||||
.filter((item) => item?.type === 'verse')
|
||||
.map((item) => ({
|
||||
number: item.number,
|
||||
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
|
||||
text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Ancient weights, measures and money, with a modern guide. Hover a unit in the passage to see it. The sizes are the
|
||||
// usual round figures; the real ones varied by place and period, so every note says "about".
|
||||
const UNITS = [
|
||||
// length
|
||||
{ words: ['cubit', 'cubits'], note: 'A cubit: about 18 inches (45 cm), the length from elbow to fingertip.' },
|
||||
{ words: ['span', 'spans'], note: 'A span: about 9 inches (23 cm), a hand stretched wide. Half a cubit.' },
|
||||
{ words: ['handbreadth', 'handbreadths', 'handsbreadth'], note: 'A handbreadth: about 3 inches (7.5 cm), the width of four fingers.' },
|
||||
{ words: ['fathom', 'fathoms'], note: 'A fathom: about 6 feet (1.8 m), the span of outstretched arms.' },
|
||||
{ words: ['furlong', 'furlongs', 'stadia', 'stadion'], note: 'A stadion (furlong): about 607 feet (185 m), an eighth of a Roman mile.' },
|
||||
{ words: ['mile', 'miles'], note: 'A Roman mile: about 4,860 feet (1.48 km), a little shorter than a modern mile.' },
|
||||
// weight
|
||||
{ words: ['talent', 'talents'], note: 'A talent: about 75 pounds (34 kg) of silver or gold. Worth around 6,000 days of a labourer’s wages.' },
|
||||
{ words: ['mina', 'minas', 'pound', 'pounds'], note: 'A mina: about 1¼ pounds (0.57 kg). A hundred days’ wages. (Older versions call it a "pound".)', only: ['mina', 'minas'] },
|
||||
{ words: ['shekel', 'shekels'], note: 'A shekel: about 0.4 ounce (11 g). The usual weight of silver for payments.' },
|
||||
{ words: ['beka'], note: 'A beka: about 0.2 ounce (5.7 g). Half a shekel.' },
|
||||
{ words: ['gerah', 'gerahs'], note: 'A gerah: about 0.02 ounce (0.6 g). A twentieth of a shekel.' },
|
||||
// money
|
||||
{ words: ['denarius', 'denarii'], note: 'A denarius: a silver Roman coin, about a day’s wage for a labourer.' },
|
||||
{ words: ['drachma', 'drachmas'], note: 'A drachma: a Greek silver coin, about a day’s wage.' },
|
||||
{ words: ['penny', 'pennies', 'pence'], note: 'In older versions, a "penny" is usually a denarius, about a day’s wage.' },
|
||||
{ words: ['farthing', 'farthings'], note: 'A farthing: a small Roman copper coin, a fraction of a day’s wage.' },
|
||||
{ words: ['lepton', 'mite', 'mites'], note: 'A lepton ("mite"): the smallest Greek coin, a fraction of a day’s wage.' },
|
||||
{ words: ['quadrans'], note: 'A quadrans: a small Roman copper coin worth a fraction of a day’s wage.' },
|
||||
// volume
|
||||
{ words: ['ephah', 'ephahs'], note: 'An ephah: about 22 liters (⅗ bushel) of grain. Ten omers.' },
|
||||
{ words: ['omer', 'omers'], note: 'An omer: about 2.2 liters (2 quarts) of grain. A tenth of an ephah.' },
|
||||
{ words: ['homer', 'homers'], note: 'A homer: about 220 liters (6 bushels), a donkey load. Ten ephahs.' },
|
||||
{ words: ['seah', 'seahs'], note: 'A seah: about 7 liters (7½ quarts) of grain. A third of an ephah.' },
|
||||
{ words: ['bath', 'baths'], note: 'A bath: about 22 liters (6 gallons) of liquid. The same size as an ephah.' },
|
||||
{ words: ['hin'], note: 'A hin: about 3.7 liters (4 quarts) of liquid.' },
|
||||
{ words: ['cor', 'cors', 'kor'], note: 'A cor: about 220 liters (6 bushels). The same as a homer.' },
|
||||
];
|
||||
|
||||
// word -> note, for the words that are safe to mark in running text ("pound" is left out: it is a common word and only the mina is meant in a few verses)
|
||||
const INDEX = new Map();
|
||||
for (const u of UNITS) for (const w of u.words) {
|
||||
if (u.only && !u.only.includes(w)) continue; // "pounds" and "penny" are common words with other meanings; "bath" too
|
||||
INDEX.set(w, u.note);
|
||||
}
|
||||
const RE = new RegExp(`\\b(${[...INDEX.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'gi');
|
||||
|
||||
export const unitNote = (word) => INDEX.get(String(word).toLowerCase()) ?? null;
|
||||
|
||||
// Splits a verse into plain pieces and unit words: [{ text, note? }]. Text that has no unit is a single piece.
|
||||
export function markUnits(text) {
|
||||
const parts = [];
|
||||
let at = 0;
|
||||
for (const m of String(text ?? '').matchAll(RE)) {
|
||||
if (m.index > at) parts.push({ text: text.slice(at, m.index) });
|
||||
parts.push({ text: m[0], note: INDEX.get(m[0].toLowerCase()) });
|
||||
at = m.index + m[0].length;
|
||||
}
|
||||
if (at < text.length) parts.push({ text: text.slice(at) });
|
||||
return parts.length ? parts : [{ text: text ?? '' }];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { markUnits, unitNote } from './biblicalUnits.js';
|
||||
|
||||
const noted = (text) => markUnits(text).filter((p) => p.note).map((p) => p.text);
|
||||
|
||||
test('units in a verse are found with their meaning, in any form', () => {
|
||||
expect(noted('The ark was two and a half cubits long and a handbreadth thick.')).toEqual(['cubits', 'handbreadth']);
|
||||
expect(noted('He gave him five talents, and she lost one drachma; it cost a denarius.')).toEqual(['talents', 'drachma', 'denarius']);
|
||||
expect(noted('An ephah of flour, an omer of manna, and a hin of oil.')).toEqual(['ephah', 'omer', 'hin']);
|
||||
expect(noted('about twenty-five or thirty stadia')).toEqual(['stadia']);
|
||||
expect(unitNote('Talent')).toMatch(/75 pounds/);
|
||||
});
|
||||
|
||||
test('the pieces rejoin to the original text, and a verse with no units is one piece', () => {
|
||||
const text = 'five talents of silver (a talent is heavy)';
|
||||
expect(markUnits(text).map((p) => p.text).join('')).toBe(text);
|
||||
expect(markUnits('For God so loved the world')).toEqual([{ text: 'For God so loved the world' }]);
|
||||
expect(markUnits('')).toEqual([{ text: '' }]);
|
||||
});
|
||||
|
||||
test('ordinary words are not mistaken for units', () => {
|
||||
expect(noted('He took a pound of flesh; a span of the river; the mile stone')).toEqual(['span', 'mile']);
|
||||
expect(noted('Bathsheba and Cornelius, the stadium crowd, a corner and a hinge')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a mina is a mina, and the older word pound is left alone', () => {
|
||||
expect(noted('ten minas and a mina')).toEqual(['minas', 'mina']);
|
||||
expect(unitNote('pound')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// A short "book at a glance" for each book of the Bible, to start a study's background notes. Authors and dates are
|
||||
// the traditional or commonly held ones, and scholars differ on many of them, so the text says so.
|
||||
// a: author d: approximate date g: kind of writing t: the book's main theme
|
||||
const INFO = {
|
||||
GEN: { a: 'Moses (traditional)', d: 'written c. 1445–1405 BC; events from creation to about 1800 BC', g: 'narrative', t: 'God creates, humanity falls, and God begins a plan of blessing through Abraham’s family.' },
|
||||
EXO: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'narrative and law', t: 'God rescues Israel from Egypt, makes a covenant with them, and comes to live among them.' },
|
||||
LEV: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'law', t: 'How a sinful people can live near a holy God: sacrifice, priesthood and holiness.' },
|
||||
NUM: { a: 'Moses', d: 'c. 1445–1405 BC', g: 'narrative and law', t: 'Israel’s failure to trust God in the wilderness, and God’s faithfulness to bring the next generation toward the land.' },
|
||||
DEU: { a: 'Moses', d: 'c. 1405 BC', g: 'Moses’ farewell sermons, in covenant form', t: 'Love and obey the LORD as Israel prepares to enter the land.' },
|
||||
JOS: { a: 'Joshua (traditional)', d: 'c. 1400–1370 BC', g: 'narrative', t: 'God keeps His promise and gives Israel the land.' },
|
||||
JDG: { a: 'Unknown (Samuel is traditional)', d: 'events c. 1380–1050 BC', g: 'narrative', t: 'Israel’s repeated cycle of sin and rescue, and the need for a faithful king.' },
|
||||
RUT: { a: 'Unknown (Samuel is traditional)', d: 'events in the time of the judges', g: 'narrative', t: 'God’s faithful care, and a kinsman-redeemer, in ordinary lives.' },
|
||||
'1SA': { a: 'Unknown', d: 'events c. 1100–1010 BC', g: 'narrative', t: 'Israel asks for a king; Saul fails and David is chosen.' },
|
||||
'2SA': { a: 'Unknown', d: 'events c. 1010–970 BC', g: 'narrative', t: 'David’s reign, the promise of an everlasting throne, and his sin and its consequences.' },
|
||||
'1KI': { a: 'Unknown (Jeremiah is traditional)', d: 'events c. 970–850 BC', g: 'narrative', t: 'Solomon’s glory, the kingdom divided, and the prophets’ call back to the LORD.' },
|
||||
'2KI': { a: 'Unknown (Jeremiah is traditional)', d: 'events c. 850–560 BC', g: 'narrative', t: 'Both kingdoms fall into exile because they would not turn from idolatry.' },
|
||||
'1CH': { a: 'Unknown (Ezra is traditional)', d: 'c. 450–430 BC', g: 'history', t: 'Israel’s story retold for those returning from exile, centered on David and the temple.' },
|
||||
'2CH': { a: 'Unknown (Ezra is traditional)', d: 'c. 450–430 BC', g: 'history', t: 'The kings of Judah, with the temple and faithful worship as the measure of each reign.' },
|
||||
EZR: { a: 'Ezra (traditional)', d: 'events c. 538–458 BC', g: 'history', t: 'The return from exile, rebuilding the temple and renewing the law.' },
|
||||
NEH: { a: 'Nehemiah', d: 'events c. 445–430 BC', g: 'memoir and history', t: 'Rebuilding Jerusalem’s wall and the people’s covenant renewal.' },
|
||||
EST: { a: 'Unknown (Mordecai is a suggestion)', d: 'events c. 483–470 BC', g: 'narrative', t: 'God quietly preserves His people, though He is never named.' },
|
||||
JOB: { a: 'Unknown', d: 'uncertain; events perhaps in the patriarchal age', g: 'wisdom poetry', t: 'Why the righteous suffer, and who God is when we cannot understand.' },
|
||||
PSA: { a: 'David and others (Asaph, the sons of Korah, Moses, Solomon and unnamed writers)', d: 'c. 1400–430 BC', g: 'poetry and songs', t: 'Praise, lament, trust and thanksgiving: Israel’s prayer book.' },
|
||||
PRO: { a: 'Solomon, with other sages', d: 'c. 970–700 BC', g: 'wisdom', t: 'The fear of the LORD is the beginning of wisdom for daily life.' },
|
||||
ECC: { a: 'Solomon (“the Teacher”) by tradition', d: 'c. 935 BC', g: 'wisdom', t: 'Life “under the sun” is fleeting; fear God and keep His commands.' },
|
||||
SNG: { a: 'Solomon', d: 'c. 965 BC', g: 'love poetry', t: 'The beauty of faithful married love.' },
|
||||
ISA: { a: 'Isaiah', d: 'c. 740–680 BC', g: 'prophecy', t: 'Judgment for sin and the promise of a coming Servant-King who brings salvation.' },
|
||||
JER: { a: 'Jeremiah', d: 'c. 627–580 BC', g: 'prophecy', t: 'Judgment on Judah, and the promise of a new covenant.' },
|
||||
LAM: { a: 'Jeremiah (traditional)', d: 'c. 586 BC', g: 'poetry (laments)', t: 'Grief over Jerusalem’s fall, and hope in the LORD’s steadfast love.' },
|
||||
EZK: { a: 'Ezekiel', d: 'c. 593–570 BC', g: 'prophecy', t: 'God’s glory leaves and returns; judgment, then a new heart and a restored people.' },
|
||||
DAN: { a: 'Daniel', d: 'c. 605–530 BC', g: 'narrative and apocalyptic prophecy', t: 'God rules over the nations and keeps His faithful in exile.' },
|
||||
HOS: { a: 'Hosea', d: 'c. 755–710 BC', g: 'prophecy', t: 'Israel’s unfaithfulness and God’s faithful, pursuing love.' },
|
||||
JOL: { a: 'Joel', d: 'uncertain; c. 835 or 500 BC', g: 'prophecy', t: 'The day of the LORD: judgment, repentance and the outpouring of the Spirit.' },
|
||||
AMO: { a: 'Amos', d: 'c. 760–750 BC', g: 'prophecy', t: 'Justice and righteousness, not empty religion.' },
|
||||
OBA: { a: 'Obadiah', d: 'uncertain; probably c. 586 BC', g: 'prophecy', t: 'Judgment on Edom’s pride and cruelty; the LORD’s kingdom will come.' },
|
||||
JON: { a: 'Jonah', d: 'c. 785–760 BC', g: 'narrative', t: 'God’s mercy reaches even Israel’s enemies, and His prophet resists it.' },
|
||||
MIC: { a: 'Micah', d: 'c. 735–700 BC', g: 'prophecy', t: 'Judgment for injustice, and a ruler to come from Bethlehem.' },
|
||||
NAM: { a: 'Nahum', d: 'c. 663–612 BC', g: 'prophecy', t: 'God’s justice falls on Nineveh.' },
|
||||
HAB: { a: 'Habakkuk', d: 'c. 609–598 BC', g: 'prophecy and prayer', t: '“The righteous shall live by faith,” even when God’s ways are hard to understand.' },
|
||||
ZEP: { a: 'Zephaniah', d: 'c. 640–621 BC', g: 'prophecy', t: 'The day of the LORD is coming, and a humble remnant will be gathered.' },
|
||||
HAG: { a: 'Haggai', d: '520 BC', g: 'prophecy', t: 'Put God’s house first and finish rebuilding the temple.' },
|
||||
ZEC: { a: 'Zechariah', d: 'c. 520–480 BC', g: 'prophecy and visions', t: 'Encouragement to rebuild, and the coming king who is humble and brings peace.' },
|
||||
MAL: { a: 'Malachi', d: 'c. 435–420 BC', g: 'prophecy', t: 'Return to the LORD with real faithfulness; a messenger will prepare His way.' },
|
||||
MAT: { a: 'Matthew (an apostle)', d: 'c. AD 55–65', g: 'Gospel', t: 'Jesus is the promised Messiah and King, and His disciples are to make disciples.' },
|
||||
MRK: { a: 'Mark (with Peter’s testimony)', d: 'c. AD 50–60', g: 'Gospel', t: 'Jesus is the Son of God who serves and gives His life as a ransom.' },
|
||||
LUK: { a: 'Luke, a physician and companion of Paul', d: 'c. AD 58–62', g: 'Gospel', t: 'Jesus came to seek and save the lost, for all kinds of people.' },
|
||||
JHN: { a: 'John (an apostle)', d: 'c. AD 85–95', g: 'Gospel', t: 'Jesus is the Son of God; believe in Him and have life.' },
|
||||
ACT: { a: 'Luke', d: 'c. AD 62', g: 'history', t: 'The Spirit spreads the gospel from Jerusalem to Rome through the church.' },
|
||||
ROM: { a: 'Paul', d: 'c. AD 57', g: 'letter', t: 'The gospel: righteousness from God by faith, for Jew and Gentile.' },
|
||||
'1CO': { a: 'Paul', d: 'c. AD 55', g: 'letter', t: 'Correcting a divided and disorderly church by the cross and by love.' },
|
||||
'2CO': { a: 'Paul', d: 'c. AD 56', g: 'letter', t: 'Paul defends his ministry; God’s power shows in weakness.' },
|
||||
GAL: { a: 'Paul', d: 'c. AD 48–55', g: 'letter', t: 'We are justified by faith, not by works of the law.' },
|
||||
EPH: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'In Christ, God unites Jew and Gentile into one church, and calls it to walk worthily.' },
|
||||
PHP: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Joy in Christ, whatever the circumstances.' },
|
||||
COL: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Christ is supreme over all and sufficient for us.' },
|
||||
'1TH': { a: 'Paul', d: 'c. AD 50–51', g: 'letter', t: 'Stand firm and live to please God as you wait for Christ’s return.' },
|
||||
'2TH': { a: 'Paul', d: 'c. AD 51–52', g: 'letter', t: 'Keep working and stand firm; the day of the Lord has not yet come.' },
|
||||
'1TI': { a: 'Paul', d: 'c. AD 62–64', g: 'letter', t: 'How to order the church and guard sound teaching.' },
|
||||
'2TI': { a: 'Paul', d: 'c. AD 66–67', g: 'letter', t: 'Paul’s last words: guard the gospel, endure, and preach the word.' },
|
||||
TIT: { a: 'Paul', d: 'c. AD 62–64', g: 'letter', t: 'Sound teaching shows in good works and godly leaders.' },
|
||||
PHM: { a: 'Paul', d: 'c. AD 60–62', g: 'letter', t: 'Receive a runaway slave back as a brother in Christ.' },
|
||||
HEB: { a: 'Unknown', d: 'c. AD 60–69', g: 'letter and sermon', t: 'Jesus is better than the angels, Moses and the priesthood; hold on to Him.' },
|
||||
JAS: { a: 'James, the brother of Jesus', d: 'c. AD 45–49', g: 'letter', t: 'Faith that is real shows itself in works and wise living.' },
|
||||
'1PE': { a: 'Peter (an apostle)', d: 'c. AD 62–64', g: 'letter', t: 'Hope and holy living for Christians suffering as exiles.' },
|
||||
'2PE': { a: 'Peter', d: 'c. AD 64–68', g: 'letter', t: 'Grow in knowledge of Christ and beware false teachers.' },
|
||||
'1JN': { a: 'John (an apostle)', d: 'c. AD 85–95', g: 'letter', t: 'How to know you have eternal life: walk in light, obey, and love.' },
|
||||
'2JN': { a: 'John', d: 'c. AD 85–95', g: 'letter', t: 'Walk in truth and love, and do not welcome false teachers.' },
|
||||
'3JN': { a: 'John', d: 'c. AD 85–95', g: 'letter', t: 'Support those who serve the truth, and watch for the proud.' },
|
||||
JUD: { a: 'Jude, the brother of James', d: 'c. AD 65–80', g: 'letter', t: 'Contend for the faith against those who pervert grace.' },
|
||||
REV: { a: 'John', d: 'c. AD 95', g: 'apocalyptic prophecy and letters', t: 'Jesus Christ wins; stay faithful until He comes and makes all things new.' },
|
||||
};
|
||||
|
||||
export const hasBookInfo = (abbrev) => Object.hasOwn(INFO, String(abbrev ?? '').toUpperCase());
|
||||
|
||||
// The "at a glance" text for the Background notes, or '' for an unknown book.
|
||||
export function bookBackground(abbrev, name) {
|
||||
const i = INFO[String(abbrev ?? '').toUpperCase()];
|
||||
if (!i) return '';
|
||||
return `${name ?? abbrev} at a glance\nAuthor: ${i.a}\nDate: ${i.d}\nKind of writing: ${i.g}\nTheme: ${i.t}\n(Authors and dates are traditional or commonly held; scholars differ on some.)`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { bookOptions } from './bibleUtils.jsx';
|
||||
import { bookBackground, hasBookInfo } from './bookInfo.js';
|
||||
|
||||
test('every book of the Bible has an at-a-glance entry', () => {
|
||||
expect(bookOptions.filter((b) => !hasBookInfo(b.abbrev)).map((b) => b.abbrev)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the background text names the book and its details; an unknown book gives nothing', () => {
|
||||
const text = bookBackground('luk', 'Luke');
|
||||
expect(text).toMatch(/^Luke at a glance\nAuthor: Luke/);
|
||||
expect(text).toContain('Theme:');
|
||||
expect(bookBackground('XXX', 'Nope')).toBe('');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Comments on a chunk, for the people teaching a study together: "Can we cut this point?", "Add the Romans 5 link".
|
||||
// They live on the chunk (chunk.comments) so they sync and merge with the rest of the study, and a comment can be
|
||||
// marked done once it has been dealt with.
|
||||
import { makeId } from './bibleUtils.jsx';
|
||||
|
||||
const MAX_TEXT = 1000;
|
||||
const MAX_COMMENTS = 200;
|
||||
|
||||
// "sam@example.com" -> "sam"; no account -> "Me"
|
||||
export const commentAuthor = (user) => {
|
||||
const local = String(user?.email ?? '').split('@')[0].trim();
|
||||
return local ? local.slice(0, 40) : 'Me';
|
||||
};
|
||||
|
||||
// The chunk's comments with one more at the end, or the same list when the text is empty.
|
||||
export function addComment(comments = [], { by, text, now = Date.now() }) {
|
||||
const t = String(text ?? '').trim().slice(0, MAX_TEXT);
|
||||
if (!t) return comments;
|
||||
return [...comments, { id: makeId(), by: by || 'Me', at: now, text: t, done: false }].slice(-MAX_COMMENTS);
|
||||
}
|
||||
|
||||
export const toggleComment = (comments = [], id) => comments.map((c) => (c.id === id ? { ...c, done: !c.done } : c));
|
||||
export const removeComment = (comments = [], id) => comments.filter((c) => c.id !== id);
|
||||
export const openCommentCount = (comments = []) => comments.filter((c) => !c.done).length;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { addComment, commentAuthor, openCommentCount, removeComment, toggleComment } from './chunkComments.js';
|
||||
import { mergeChunk } from './mergeStudies.js';
|
||||
|
||||
test('the author is the part of the email before the @, or "Me"', () => {
|
||||
expect(commentAuthor({ email: 'sam@example.com' })).toBe('Sam'.toLowerCase());
|
||||
expect(commentAuthor(null)).toBe('Me');
|
||||
expect(commentAuthor({ email: '' })).toBe('Me');
|
||||
});
|
||||
|
||||
test('adding trims the text, ignores empty comments, and stamps author and time', () => {
|
||||
const list = addComment([], { by: 'sam', text: ' Cut point 3? ', now: 5 });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({ by: 'sam', at: 5, text: 'Cut point 3?', done: false });
|
||||
expect(addComment(list, { by: 'sam', text: ' ' })).toBe(list);
|
||||
});
|
||||
|
||||
test('toggling, removing and counting open comments', () => {
|
||||
let list = addComment(addComment([], { by: 'a', text: 'one' }), { by: 'b', text: 'two' });
|
||||
expect(openCommentCount(list)).toBe(2);
|
||||
list = toggleComment(list, list[0].id);
|
||||
expect(openCommentCount(list)).toBe(1);
|
||||
expect(removeComment(list, list[1].id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('merging two copies of a chunk keeps comments from both and the newer done-state', () => {
|
||||
const shared = { id: 'c1', text: 'both', by: 'a', at: 1, done: false };
|
||||
const mine = { id: 'k', editedAt: 20, comments: [{ ...shared, done: true }, { id: 'm', text: 'mine', by: 'a', at: 2, done: false }] };
|
||||
const theirs = { id: 'k', editedAt: 10, comments: [shared, { id: 't', text: 'theirs', by: 'b', at: 3, done: false }] };
|
||||
const { chunk } = mergeChunk(mine, theirs);
|
||||
expect(chunk.comments.map((c) => c.id).sort()).toEqual(['c1', 'm', 't']);
|
||||
expect(chunk.comments.find((c) => c.id === 'c1').done).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Bible dictionary lookups (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith). The route is
|
||||
// /dictionaries/{id}?q=term and answers { count, data: [{ term, slug, definition, scripture_refs?, citations? }] },
|
||||
// matching the word anywhere in an entry, so the entry actually named for the word is put first.
|
||||
import { parsePassageRef } from './passageRef.js';
|
||||
|
||||
const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, '');
|
||||
|
||||
// rows -> { exact: the entry whose heading is the word (or null), others: the rest }
|
||||
export function pickEntries(rows, q) {
|
||||
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r.definition === 'string') : [];
|
||||
const want = norm(q);
|
||||
const exact = list.find((r) => norm(r.term) === want) ?? null;
|
||||
return { exact, others: list.filter((r) => r !== exact) };
|
||||
}
|
||||
|
||||
// "Exodus 4:27-30", "Psalms 106:19", "Leviticus 8" -> { book, chapter, verse } for the Reader, or null
|
||||
export function refTarget(text) {
|
||||
const cleaned = String(text ?? '').replace(/\s*[-–,].*$/, '').trim();
|
||||
const hit = parsePassageRef(cleaned)[0];
|
||||
return hit ? { book: hit.book, chapter: hit.chapter, verse: hit.verse ?? 1, label: String(text).trim() } : null;
|
||||
}
|
||||
|
||||
// A long definition is cut at a sentence end near `max` characters for the preview; the full text stays one click away.
|
||||
export function preview(text, max = 420) {
|
||||
const t = String(text ?? '').trim();
|
||||
if (t.length <= max) return { text: t, cut: false };
|
||||
const upTo = t.slice(0, max);
|
||||
const end = Math.max(upTo.lastIndexOf('. '), upTo.lastIndexOf('; '));
|
||||
return { text: `${end > max * 0.5 ? upTo.slice(0, end + 1) : upTo.replace(/\s+\S*$/, '')}…`, cut: true };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { pickEntries, preview, refTarget } from './dictionary.js';
|
||||
|
||||
const rows = [
|
||||
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone… a denarius…' },
|
||||
{ term: 'Denarius', slug: 'denarius', definition: 'A Roman silver coin.', scripture_refs: ['Matthew 20:2'] },
|
||||
{ term: 'Farthing', slug: 'farthing', definition: 'mentions the denarius' },
|
||||
];
|
||||
|
||||
test('the entry named for the word comes first, the entries that only mention it follow', () => {
|
||||
const { exact, others } = pickEntries(rows, ' denarius ');
|
||||
expect(exact.term).toBe('Denarius');
|
||||
expect(others.map((o) => o.term)).toEqual(['Alabaster', 'Farthing']);
|
||||
expect(pickEntries(rows, 'penny').exact).toBeNull();
|
||||
expect(pickEntries(rows, 'penny').others).toHaveLength(3);
|
||||
expect(pickEntries(null, 'x')).toEqual({ exact: null, others: [] });
|
||||
expect(pickEntries([{ term: 'Bad' }], 'bad').exact).toBeNull(); // an entry without a definition is ignored
|
||||
});
|
||||
|
||||
test('Scripture references in an entry are turned into places in the Reader', () => {
|
||||
expect(refTarget('Exodus 4:27-30')).toMatchObject({ book: 'EXO', chapter: 4, verse: 27, label: 'Exodus 4:27-30' });
|
||||
expect(refTarget('Psalms 106:19')).toMatchObject({ book: 'PSA', chapter: 106, verse: 19 });
|
||||
expect(refTarget('Leviticus 8')).toMatchObject({ book: 'LEV', chapter: 8, verse: 1 });
|
||||
expect(refTarget('1 Chronicles 2:10')).toMatchObject({ book: '1CH', chapter: 2, verse: 10 });
|
||||
expect(refTarget('nonsense')).toBeNull();
|
||||
});
|
||||
|
||||
test('a long definition is previewed up to a sentence end', () => {
|
||||
const long = `${'First sentence is here. '.repeat(30)}`;
|
||||
const p = preview(long, 100);
|
||||
expect(p.cut).toBe(true);
|
||||
expect(p.text.endsWith('…')).toBe(true);
|
||||
expect(p.text.length).toBeLessThan(110);
|
||||
expect(preview('Short.', 100)).toEqual({ text: 'Short.', cut: false });
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Family connections from the Theographic person records (father, mother, siblings, partners, children).
|
||||
const GROUPS = [['father', 'Father'], ['mother', 'Mother'], ['partners', 'Partner'], ['siblings', 'Siblings'], ['children', 'Children']];
|
||||
|
||||
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []).filter((p) => p && p.id && p.name);
|
||||
|
||||
// -> [{ label: 'Children', people: [{ id, name }] }] for the relations this person has
|
||||
export function relatives(person) {
|
||||
return GROUPS.map(([key, label]) => ({ label: key === 'partners' && asList(person?.partners).length > 1 ? 'Partners' : label, people: asList(person?.[key]).map(({ id, name }) => ({ id, name })) }))
|
||||
.filter((g) => g.people.length > 0);
|
||||
}
|
||||
|
||||
// Walks up (parents) or down (children) from one person, `depth` generations, never visiting anyone twice and
|
||||
// stopping after `max` people so a long line like Adam's doesn't fetch the whole Bible.
|
||||
// load(id) -> person record or null. -> { id, name, more: bool, kids: [node…] } (kids are the parents when going up)
|
||||
export async function walk(load, id, { direction = 'down', depth = 2, max = 40 } = {}) {
|
||||
const seen = new Set([id]);
|
||||
let budget = max;
|
||||
const links = (p) => (direction === 'down' ? asList(p?.children) : [...asList(p?.father), ...asList(p?.mother)]);
|
||||
async function visit(personId, name, level) {
|
||||
const person = await load(personId);
|
||||
const next = links(person).filter((l) => !seen.has(l.id));
|
||||
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
|
||||
if (level >= depth) { node.more = next.length > 0; return node; }
|
||||
const taking = next.filter(() => budget > 0 && (budget -= 1) >= 0);
|
||||
taking.forEach((l) => seen.add(l.id));
|
||||
node.more = taking.length < next.length;
|
||||
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
|
||||
return node;
|
||||
}
|
||||
const root = await load(id);
|
||||
return visit(id, root?.name ?? '', 0);
|
||||
}
|
||||
|
||||
// Counts the people below a node (not counting it)
|
||||
export const countKids = (node) => node.kids.reduce((n, k) => n + 1 + countKids(k), 0);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { countKids, relatives, walk } from './family.js';
|
||||
|
||||
const p = (id, name, extra = {}) => ({ id, name, ...extra });
|
||||
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||
const people = {
|
||||
adam: p('adam', 'Adam', { partners: [ref('eve', 'Eve')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||
eve: p('eve', 'Eve', { partners: [ref('adam', 'Adam')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||
cain: p('cain', 'Cain', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enoch', 'Enoch')] }),
|
||||
abel: p('abel', 'Abel', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')] }),
|
||||
seth: p('seth', 'Seth', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enos', 'Enos')], siblings: [ref('cain', 'Cain'), ref('abel', 'Abel')] }),
|
||||
enoch: p('enoch', 'Enoch', { father: [ref('cain', 'Cain')], children: [ref('irad', 'Irad')] }),
|
||||
enos: p('enos', 'Enos', { father: [ref('seth', 'Seth')] }),
|
||||
};
|
||||
const load = async (id) => people[id] ?? null;
|
||||
const names = (n) => ({ [n.name]: n.kids.map(names) });
|
||||
|
||||
test('relations are grouped and named, and empty ones are left out', () => {
|
||||
expect(relatives(people.seth).map((g) => [g.label, g.people.map((x) => x.name)])).toEqual([
|
||||
['Father', ['Adam']], ['Mother', ['Eve']], ['Siblings', ['Cain', 'Abel']], ['Children', ['Enos']],
|
||||
]);
|
||||
expect(relatives(people.adam).map((g) => g.label)).toEqual(['Partner', 'Children']);
|
||||
expect(relatives({ id: 'x', name: 'X', partners: [ref('a', 'A'), ref('b', 'B')] })[0].label).toBe('Partners');
|
||||
expect(relatives(null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('descendants are walked a few generations down', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 2 });
|
||||
expect(names(tree)).toEqual({ Adam: [{ Cain: [{ Enoch: [] }] }, { Abel: [] }, { Seth: [{ Enos: [] }] }] });
|
||||
expect(countKids(tree)).toBe(5);
|
||||
const shallow = await walk(load, 'adam', { direction: 'down', depth: 1 });
|
||||
expect(shallow.kids.find((k) => k.name === 'Cain').more).toBe(true); // Cain has children not shown
|
||||
});
|
||||
|
||||
test('ancestors go up through both parents, without visiting anyone twice', async () => {
|
||||
const up = await walk(load, 'enoch', { direction: 'up', depth: 3 });
|
||||
expect(names(up)).toEqual({ Enoch: [{ Cain: [{ Adam: [] }, { Eve: [] }] }] });
|
||||
});
|
||||
|
||||
test('a long line stops at the limit and says there is more', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 3, max: 2 });
|
||||
expect(countKids(tree)).toBeLessThanOrEqual(2 + 1); // two taken at the top, each may take nothing more
|
||||
expect(tree.more).toBe(true);
|
||||
});
|
||||
|
||||
test('someone missing from the data still appears by name', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 3 });
|
||||
const enoch = tree.kids[0].kids[0];
|
||||
expect(enoch.kids[0].name).toBe('Irad'); // Enoch's child Irad has no record but is named by the link
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active -
|
||||
// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never
|
||||
// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and
|
||||
// are a guide, not a rule: the context decides.
|
||||
|
||||
// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest
|
||||
// mean the Hebrew thing, not the Greek one.
|
||||
const HEBREW_STEMS = [
|
||||
['qal', 'The basic, simple action ("he kept").'],
|
||||
['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'],
|
||||
['piel', 'Intensive or repeated action ("he kept carefully").'],
|
||||
['pual', 'The passive of the Piel ("it was kept carefully").'],
|
||||
['hithpael', 'Reflexive or repeated action ("he kept himself busy").'],
|
||||
['hiphil', 'Causing the action ("he made someone keep").'],
|
||||
['hophal', 'The passive of the Hiphil ("he was made to keep").'],
|
||||
];
|
||||
const HEBREW_FORMS = [
|
||||
['perfect', 'Action seen as complete, often past ("he kept").'],
|
||||
['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'],
|
||||
['imperative', 'A command ("keep!").'],
|
||||
['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'],
|
||||
['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'],
|
||||
['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'],
|
||||
];
|
||||
const GREEK_TENSES = [
|
||||
['present', 'Ongoing or repeated action ("is doing").'],
|
||||
['imperfect', 'Past action that was ongoing or repeated ("was doing").'],
|
||||
['future', 'Action that will happen ("will do").'],
|
||||
['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'],
|
||||
['perfect', 'A finished action whose results still stand ("has done").'],
|
||||
['pluperfect', 'A finished action whose results stood at some past time ("had done").'],
|
||||
];
|
||||
const MOODS = [
|
||||
['indicative', 'States something as a fact or asks a question.'],
|
||||
['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'],
|
||||
['imperative', 'A command or request.'],
|
||||
['optative', 'A wish or a remote possibility ("may it be").'],
|
||||
['infinitive', 'The verb as a noun ("to do").'],
|
||||
['participle', 'A verb used like an adjective ("-ing", "the one who…").'],
|
||||
];
|
||||
const VOICES = [
|
||||
['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'],
|
||||
['middle', 'The subject acts on itself or for its own benefit.'],
|
||||
['passive', 'The subject receives the action ("was done").'],
|
||||
['active', 'The subject does the action.'],
|
||||
];
|
||||
const CASES = [
|
||||
['nominative', 'The subject of the sentence, or a name for something.'],
|
||||
['genitive', 'Belonging, source or description. Often "of".'],
|
||||
['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'],
|
||||
['accusative', 'The direct object, the thing acted on.'],
|
||||
['vocative', 'The one being spoken to.'],
|
||||
];
|
||||
const PARTS = [
|
||||
['noun', 'A person, place or thing.'],
|
||||
['verb', 'An action or state.'],
|
||||
['adjective', 'Describes a noun.'],
|
||||
['adverb', 'Describes a verb, adjective or another adverb.'],
|
||||
['article', 'The word "the".'],
|
||||
['conjunction', 'Joins words or clauses ("and", "but", "for").'],
|
||||
['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'],
|
||||
['personal pronoun', 'Stands in for a person ("he", "you").'],
|
||||
['relative pronoun', 'Links a clause back to a noun ("who", "which").'],
|
||||
['demonstrative pronoun', 'Points something out ("this", "that").'],
|
||||
['reflexive pronoun', 'Points back to the subject ("himself").'],
|
||||
['interrogative pronoun', 'Asks a question ("who?", "what?").'],
|
||||
['pronoun', 'Stands in for a noun.'],
|
||||
['particle', 'A small word that adds emphasis or links ideas.'],
|
||||
['interjection', 'An exclamation.'],
|
||||
['numeral', 'A number.'],
|
||||
];
|
||||
const PREFIXES = [
|
||||
['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'],
|
||||
['preposition-b', '"In", "with" or "by", joined to the front of the next word.'],
|
||||
['preposition-l', '"To" or "for", joined to the front of the next word.'],
|
||||
['preposition-k', '"Like" or "as", joined to the front of the next word.'],
|
||||
['preposition-m', '"From", joined to the front of the next word.'],
|
||||
['construct', 'Linked to the next word, usually "of".'],
|
||||
];
|
||||
const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' };
|
||||
|
||||
const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' };
|
||||
|
||||
// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say.
|
||||
export function grammarNotes(label) {
|
||||
const text = String(label ?? '').toLowerCase();
|
||||
if (!text.trim()) return [];
|
||||
const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text));
|
||||
const found = [];
|
||||
const add = (table, tableName) => {
|
||||
for (const [term, meaning] of table) {
|
||||
const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text);
|
||||
if (m) found.push({ at: m.index, term, meaning, tableName });
|
||||
}
|
||||
};
|
||||
add(PARTS, 'part');
|
||||
if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); }
|
||||
add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood');
|
||||
if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice');
|
||||
add(CASES, 'case');
|
||||
add(PREFIXES, 'prefix');
|
||||
// the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun")
|
||||
const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1;
|
||||
const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix')));
|
||||
const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/);
|
||||
const number = text.match(/\b(singular|plural|dual)\b/);
|
||||
const first = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
||||
const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning }));
|
||||
if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` });
|
||||
return notes;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { grammarNotes } from './grammar.js';
|
||||
|
||||
const terms = (label) => grammarNotes(label).map((n) => n.term);
|
||||
|
||||
test('a Greek verb is explained part by part', () => {
|
||||
const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular');
|
||||
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']);
|
||||
expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/);
|
||||
expect(notes.at(-1).meaning).toBe('he / she / it / they (one).');
|
||||
});
|
||||
|
||||
test('nouns and articles give their case', () => {
|
||||
expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']);
|
||||
expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']);
|
||||
expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative');
|
||||
});
|
||||
|
||||
test('moods, participles and voices that share words are not double counted', () => {
|
||||
expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']);
|
||||
expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']);
|
||||
});
|
||||
|
||||
test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => {
|
||||
const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular');
|
||||
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']);
|
||||
expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/);
|
||||
expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/);
|
||||
});
|
||||
|
||||
test('Hebrew words with attached prefixes explain the prefix', () => {
|
||||
const notes = grammarNotes('Preposition-b | Noun - feminine singular');
|
||||
expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b']));
|
||||
expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/);
|
||||
});
|
||||
|
||||
test('nothing to explain gives an empty list', () => {
|
||||
expect(grammarNotes('')).toEqual([]);
|
||||
expect(grammarNotes(null)).toEqual([]);
|
||||
expect(grammarNotes('???')).toEqual([]);
|
||||
});
|
||||
+34
-1
@@ -30,12 +30,41 @@ export function questionsFor(chunk) {
|
||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||
}
|
||||
|
||||
const STOP = new Set(['the','and','but','for','nor','yet','who','whom','whose','that','this','these','those','with','from','into','unto','upon','shall','will','have','has','had','was','were','been','are','not','his','her','him','their','them','they','you','your','our','out','all','when','then','than','what','which','there','here','also','because','therefore','said','says']);
|
||||
|
||||
// The words to leave blank in a fill-in passage: the English words you studied in this session (the word-study
|
||||
// glosses) that appear in it; if there are none, the longer words that repeat, since a repeated word is usually
|
||||
// what the passage is about. Returns lowercase words, most useful first, at most 12.
|
||||
export function blankWordsFor(chunk, verses) {
|
||||
const text = verses.map((v) => v.text).join(' ');
|
||||
const counts = new Map();
|
||||
for (const w of text.toLowerCase().match(/[a-z]+(?:['’][a-z]+)?/g) ?? []) counts.set(w, (counts.get(w) ?? 0) + 1);
|
||||
const glossed = (chunk.greekWords ?? []).flatMap((w) => String(w.englishGloss ?? '').toLowerCase().match(/[a-z]+/g) ?? [])
|
||||
.filter((w) => w.length > 2 && !STOP.has(w) && counts.has(w));
|
||||
const chosen = [...new Set(glossed)];
|
||||
if (chosen.length === 0) {
|
||||
const repeated = [...counts].filter(([w, n]) => n >= 2 && w.length >= 5 && !STOP.has(w)).sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([w]) => w);
|
||||
chosen.push(...repeated);
|
||||
}
|
||||
return chosen.slice(0, 12);
|
||||
}
|
||||
|
||||
// A verse's text with the chosen words replaced by a blank line of about the same length (each occurrence).
|
||||
export function blankOut(text, words) {
|
||||
const set = new Set(words);
|
||||
// split keeps the words (odd entries) apart from the punctuation between them, so both are escaped
|
||||
return String(text ?? '').split(/([A-Za-z]+(?:['’][A-Za-z]+)?)/).map((part, i) => (
|
||||
i % 2 === 1 && set.has(part.toLowerCase()) ? `<span class="blank">${' '.repeat(Math.max(8, part.length + 2))}</span>` : esc(part)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
|
||||
export const HANDOUT_LAYOUTS = [
|
||||
{ id: 'standard', label: 'Standard: passage, outline, questions' },
|
||||
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
|
||||
{ id: 'large', label: 'Large print: passage and questions' },
|
||||
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
|
||||
{ id: 'blanks', label: 'Fill in the blanks: key words left out of the passage' },
|
||||
];
|
||||
|
||||
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||
@@ -46,6 +75,7 @@ function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
|
||||
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
|
||||
const blanks = layout === 'blanks' ? blankWordsFor(chunk, verses) : [];
|
||||
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
|
||||
<header>
|
||||
@@ -54,7 +84,8 @@ function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
|
||||
<p class="meta">Name: ____________________________ Date: ________________</p>
|
||||
</header>
|
||||
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
|
||||
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${layout === 'blanks' && blanks.length ? blankOut(v.text, blanks) : esc(v.text)}</p>`).join('')}</div>` : ''}
|
||||
${blanks.length ? `<p class="bank"><b>Word bank:</b> ${[...blanks].sort().map(esc).join(' · ')}</p>` : ''}
|
||||
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
|
||||
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
|
||||
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
|
||||
@@ -78,6 +109,8 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true, layo
|
||||
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
|
||||
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
|
||||
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
|
||||
.blank { display: inline-block; border-bottom: 1.5px solid #1f1b16; line-height: 1; vertical-align: baseline; }
|
||||
.bank { font: 10.5pt -apple-system, Helvetica, Arial, sans-serif; color: #6b6458; margin: .6em 0 0; }
|
||||
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
|
||||
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
|
||||
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
|
||||
import { blankOut, blankWordsFor, buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
|
||||
|
||||
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, observation: '', interpretation: '', application: '', ...x });
|
||||
const project = {
|
||||
@@ -62,3 +62,25 @@ test('layouts change what the handout prints', () => {
|
||||
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
|
||||
expect(large).toContain('class="page large"');
|
||||
});
|
||||
|
||||
test('blank words are the studied glosses that appear in the passage, else the longer words that repeat', () => {
|
||||
const verses = [{ number: 1, text: 'Grace and peace. Grace to you, and peace to everyone.' }, { number: 2, text: 'Walk in grace.' }];
|
||||
expect(blankWordsFor({ greekWords: [{ englishGloss: 'peace' }, { englishGloss: 'absent' }] }, verses)).toEqual(['peace']);
|
||||
expect(blankWordsFor({ greekWords: [] }, verses)).toEqual(['grace', 'peace']);
|
||||
expect(blankWordsFor({}, [{ number: 1, text: 'In the beginning.' }])).toEqual([]);
|
||||
});
|
||||
|
||||
test('blankOut replaces every occurrence, keeps other words, and escapes the rest', () => {
|
||||
const out = blankOut('Grace & peace; grace again <b>', ['grace']);
|
||||
expect(out.match(/class="blank"/g)).toHaveLength(2);
|
||||
expect(out).toContain('& peace');
|
||||
expect(out).toContain('<b>');
|
||||
expect(out).not.toMatch(/Grace|grace/);
|
||||
});
|
||||
|
||||
test('the fill-in-the-blanks layout blanks the passage and prints a sorted word bank', () => {
|
||||
const html = buildHandoutHtml({ ...project, chapters: [{ ...project.chapters[0], verses: [{ number: 1, text: 'Condemnation and condemnation.' }] }] }, ['a'], { layout: 'blanks' });
|
||||
expect(html).toContain('class="blank"');
|
||||
expect(html).toContain('Word bank:</b> condemnation');
|
||||
expect(html).not.toContain('Condemnation and');
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export function mergeChunk(mine, theirs) {
|
||||
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0];
|
||||
out.fiveMinutes = union(newer.fiveMinutes, older.fiveMinutes);
|
||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||
if (newer.comments?.length || older.comments?.length) out.comments = mergeById(newer.comments, older.comments).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
||||
out.recalls = union(newer.recalls, older.recalls);
|
||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||
return { chunk: out, changed: !same(out, mine) };
|
||||
|
||||
@@ -57,3 +57,21 @@ test('a study works offline once every book in it is saved in its translation',
|
||||
test('licensed translations cannot be saved offline', () => {
|
||||
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
|
||||
});
|
||||
|
||||
import { READER_TRANSLATIONS, helloaoCode } from './bibleText.js';
|
||||
import { parseBibleChapter } from './bibleUtils.jsx';
|
||||
|
||||
test('the KJV, ASV and LSV are free translations that can be saved offline and are fetched by their helloao code', () => {
|
||||
for (const [id, code] of [['KJV', 'eng_kjv'], ['ASV', 'eng_asv'], ['LSV', 'eng_lsv'], ['NET', 'eng_net'], ['BSB', 'BSB']]) {
|
||||
expect(READER_TRANSLATIONS.some((t) => t.id === id)).toBe(true);
|
||||
expect(helloaoCode(id)).toBe(code);
|
||||
expect(canSaveOffline(id)).toBe(true);
|
||||
}
|
||||
expect(canSaveOffline('ESV')).toBe(false);
|
||||
expect(READER_TRANSLATIONS.find((t) => t.id === 'LSV').notice).toMatch(/Covenant Press/);
|
||||
});
|
||||
|
||||
test('a paragraph mark in the KJV text is dropped from the verse', () => {
|
||||
const verses = parseBibleChapter({ chapter: { content: [{ type: 'verse', number: 16, content: [{ text: '¶ For God so loved the world' }] }] } });
|
||||
expect(verses[0].text).toBe('For God so loved the world');
|
||||
});
|
||||
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
|
||||
return BOOKS.filter((b) => b.key.startsWith(key));
|
||||
}
|
||||
|
||||
// The one book a typed name means when it is the book's full name or a known short form ("Rom", "1 Cor", "Jn"), never a
|
||||
// mere beginning of a name: "Number" is not Numbers, "Act" is not Acts. For finding references inside prose.
|
||||
export function exactBook(typed) {
|
||||
const key = squash(typed);
|
||||
if (!key) return null;
|
||||
return BOOKS.find((b) => b.key === key) ?? (ALIASES[key] ? BOOKS.find((b) => b.abbrev === ALIASES[key]) : null) ?? null;
|
||||
}
|
||||
|
||||
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
|
||||
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
|
||||
export function parsePassageRef(text) {
|
||||
|
||||
+127
-5
@@ -1,7 +1,7 @@
|
||||
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
|
||||
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
|
||||
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
|
||||
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
|
||||
// answeredAt, answerNote, status: '' | 'waiting' | 'partial', pinned, rank }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
|
||||
import { formatPassage, parsePassageRef } from './passageRef.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
@@ -10,7 +10,7 @@ const MAX_DAYS = 400;
|
||||
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
|
||||
|
||||
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
|
||||
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' };
|
||||
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '', status: '', pinned: false, rank: null };
|
||||
}
|
||||
|
||||
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
|
||||
@@ -21,8 +21,33 @@ export function togglePrayedToday(prayer, today, now = Date.now()) {
|
||||
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
|
||||
}
|
||||
|
||||
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now);
|
||||
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now);
|
||||
// Answering takes a prayer off the pinned list and clears any "waiting" / "partly answered" mark
|
||||
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000), status: '', pinned: false, rank: null }, now);
|
||||
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '', status: '' }, now);
|
||||
|
||||
// Not a clean yes: "waiting" (still waiting on God) or "partial" (partly answered; the note says how). Both stay open.
|
||||
export const STATUSES = { waiting: 'Waiting', partial: 'Partly answered' };
|
||||
export function setStatus(prayer, status, note = '', now = Date.now()) {
|
||||
const next = STATUSES[status] ? status : '';
|
||||
return touch(prayer, { status: next, answerNote: next === 'partial' ? String(note ?? '').trim().slice(0, 1000) : '' }, now);
|
||||
}
|
||||
|
||||
// Pinned prayers keep an order (`rank`, lowest first). Pinning puts one at the end; these return a new list.
|
||||
export const pinned = (list) => live(list).filter((p) => p.pinned && !p.answeredAt).sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
export function setPinned(list, id, on, now = Date.now()) {
|
||||
const top = pinned(list).reduce((m, p) => Math.max(m, p.rank ?? 0), 0);
|
||||
return list.map((p) => (p.id === id && !p.deleted ? touch(p, on ? { pinned: true, rank: top + 1 } : { pinned: false, rank: null }, now) : p));
|
||||
}
|
||||
// dir: -1 moves the prayer up the pinned list, 1 moves it down
|
||||
export function movePinned(list, id, dir, now = Date.now()) {
|
||||
const order = pinned(list).map((p) => p.id);
|
||||
const at = order.indexOf(id);
|
||||
const to = at + dir;
|
||||
if (at < 0 || to < 0 || to >= order.length) return list;
|
||||
[order[at], order[to]] = [order[to], order[at]];
|
||||
const rank = new Map(order.map((pid, i) => [pid, i + 1]));
|
||||
return list.map((p) => (rank.has(p.id) && p.rank !== rank.get(p.id) ? touch(p, { rank: rank.get(p.id) }, now) : p));
|
||||
}
|
||||
|
||||
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
|
||||
export function mergePrayers(a, b, now = Date.now()) {
|
||||
@@ -59,7 +84,14 @@ export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
|
||||
return live(list)
|
||||
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
|
||||
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
|
||||
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0)));
|
||||
.sort((x, y) => {
|
||||
if (tab === 'answered') return (y.answeredAt ?? 0) - (x.answeredAt ?? 0);
|
||||
const px = x.pinned && !x.answeredAt;
|
||||
const py = y.pinned && !y.answeredAt;
|
||||
if (px !== py) return px ? -1 : 1;
|
||||
if (px) return (x.rank ?? 0) - (y.rank ?? 0);
|
||||
return (y.updatedAt ?? 0) - (x.updatedAt ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
|
||||
@@ -73,6 +105,9 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
||||
for (const p of rows) {
|
||||
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
|
||||
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
|
||||
if (p.pinned && !p.answeredAt) out.push(' - Pinned');
|
||||
if (p.status === 'waiting') out.push(' - Waiting');
|
||||
if (p.status === 'partial') out.push(` - Partly answered${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
||||
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
||||
}
|
||||
out.push('');
|
||||
@@ -82,3 +117,90 @@ export function prayersToMarkdown(list, { date = new Date() } = {}) {
|
||||
if (!items.length) out.push('No prayers yet.', '');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---- Import -------------------------------------------------------------------------------------------------
|
||||
// Reads a file made by "Download" (markdown) or "Download JSON" (also a full backup, or a bare list of prayers).
|
||||
// Returns { prayers, skipped } where `prayers` are new entries only: ones already in `existing` (same id, or the
|
||||
// same words for the same person) are left out.
|
||||
const key = (p) => `${String(p.forWhom ?? '').trim().toLowerCase()}|${String(p.text ?? '').trim().toLowerCase().replace(/\s+/g, ' ')}`;
|
||||
const stamp = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
|
||||
const dateMs = (str, fallback) => { const t = Date.parse(str); return Number.isFinite(t) ? t : fallback; };
|
||||
|
||||
function fromJson(raw, now) {
|
||||
const rows = Array.isArray(raw) ? raw : Array.isArray(raw?.prayers) ? raw.prayers : null;
|
||||
if (!rows) return null;
|
||||
const out = [];
|
||||
let skipped = 0;
|
||||
for (const r of rows) {
|
||||
if (!r || typeof r.text !== 'string' || !r.text.trim() || r.deleted) { skipped += 1; continue; }
|
||||
const base = newPrayer({ text: r.text, forWhom: typeof r.forWhom === 'string' ? r.forWhom : '', verses: Array.isArray(r.verses) ? r.verses.filter((v) => v && typeof v.book === 'string' && Number.isInteger(v.chapter)) : [] }, now);
|
||||
out.push({
|
||||
...base,
|
||||
id: typeof r.id === 'string' && /^[\w-]{1,64}$/.test(r.id) ? r.id : base.id,
|
||||
prayedOn: (Array.isArray(r.prayedOn) ? r.prayedOn.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)) : []).sort().slice(-MAX_DAYS),
|
||||
createdAt: stamp(r.createdAt, now), updatedAt: stamp(r.updatedAt, now),
|
||||
answeredAt: stamp(r.answeredAt, null), answerNote: typeof r.answerNote === 'string' ? r.answerNote.trim().slice(0, 1000) : '',
|
||||
status: STATUSES[r.status] ? r.status : '', pinned: r.pinned === true && !r.answeredAt, rank: Number.isFinite(r.rank) ? r.rank : null,
|
||||
});
|
||||
}
|
||||
return { prayers: out, skipped };
|
||||
}
|
||||
|
||||
function fromMarkdown(text, now) {
|
||||
const out = [];
|
||||
let section = '';
|
||||
let cur = null;
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
const head = line.match(/^##\s+(.+?)\s*$/);
|
||||
if (head) { section = head[1].toLowerCase(); cur = null; continue; }
|
||||
const item = line.match(/^- (?:\*\*(.+?):\*\* )?(.+)$/);
|
||||
if (item && section) {
|
||||
let body = item[2].trim();
|
||||
let verses = [];
|
||||
const tail = body.match(/\s\(([^()]*)\)$/);
|
||||
if (tail) {
|
||||
const r = parseVerses(tail[1]);
|
||||
if (r.verses.length && !r.unknown.length) { verses = r.verses; body = body.slice(0, tail.index).trim(); }
|
||||
}
|
||||
cur = { ...newPrayer({ text: body, forWhom: item[1] ?? '', verses }, now) };
|
||||
if (section === 'answered') cur.answeredAt = now;
|
||||
out.push(cur);
|
||||
continue;
|
||||
}
|
||||
const sub = cur && line.match(/^\s+- (.+)$/);
|
||||
if (!sub) continue;
|
||||
const m = sub[1];
|
||||
let x;
|
||||
if ((x = m.match(/^Asked (.+?)(?: · prayed on \d+ days?)?$/))) { cur.createdAt = dateMs(x[1], now); cur.updatedAt = cur.createdAt; }
|
||||
else if ((x = m.match(/^Answered ([^:]+?)(?:: (.*))?$/))) { cur.answeredAt = dateMs(x[1], now); cur.answerNote = (x[2] ?? '').slice(0, 1000); }
|
||||
else if (m === 'Pinned') { cur.pinned = !cur.answeredAt; }
|
||||
else if (m === 'Waiting') cur.status = 'waiting';
|
||||
else if ((x = m.match(/^Partly answered(?:: (.*))?$/))) { cur.status = 'partial'; cur.answerNote = (x[1] ?? '').slice(0, 1000); }
|
||||
}
|
||||
// assign ranks to pinned ones in file order
|
||||
let rank = 0;
|
||||
for (const p of out) if (p.pinned) { rank += 1; p.rank = rank; }
|
||||
return { prayers: out, skipped: 0 };
|
||||
}
|
||||
|
||||
export function parsePrayerImport(fileText, existing = [], now = Date.now()) {
|
||||
const trimmed = String(fileText ?? '').trim();
|
||||
let parsed = null;
|
||||
if (/^[[{]/.test(trimmed)) { try { parsed = fromJson(JSON.parse(trimmed), now); } catch { parsed = null; } }
|
||||
if (!parsed && !/^[[{]/.test(trimmed)) parsed = fromMarkdown(trimmed, now);
|
||||
if (!parsed) return { prayers: [], skipped: 0, error: 'That file isn’t a prayer journal.' };
|
||||
const have = live(existing);
|
||||
const ids = new Set((existing ?? []).map((p) => p.id));
|
||||
const seen = new Set(have.map(key));
|
||||
const prayers = [];
|
||||
let skipped = parsed.skipped;
|
||||
for (const p of parsed.prayers) {
|
||||
if (ids.has(p.id) || seen.has(key(p))) { skipped += 1; continue; }
|
||||
seen.add(key(p));
|
||||
prayers.push(p);
|
||||
}
|
||||
if (!prayers.length && !skipped) return { prayers, skipped, error: 'No prayers found in that file.' };
|
||||
return { prayers, skipped };
|
||||
}
|
||||
|
||||
export const prayersToJson = (list) => JSON.stringify({ kind: 'prayer-journal', version: 1, prayers: live(list) }, null, 2);
|
||||
@@ -68,3 +68,52 @@ test('the Markdown separates what is being prayed for from what was answered', (
|
||||
expect(md).toContain(': Hired!');
|
||||
expect(prayersToMarkdown([])).toContain('No prayers yet.');
|
||||
});
|
||||
|
||||
import { movePinned, parsePrayerImport, pinned, prayersToJson, setPinned, setStatus } from './prayers.js';
|
||||
|
||||
test('pinning orders prayers, moving swaps them, and answering unpins', () => {
|
||||
let list = ['a', 'b', 'c'].map((t, i) => newPrayer({ text: t }, T0 + i));
|
||||
const [a, b, c] = list.map((p) => p.id);
|
||||
list = setPinned(list, b, true, T0 + 10);
|
||||
list = setPinned(list, a, true, T0 + 11);
|
||||
expect(pinned(list).map((p) => p.id)).toEqual([b, a]);
|
||||
list = movePinned(list, a, -1, T0 + 12);
|
||||
expect(pinned(list).map((p) => p.id)).toEqual([a, b]);
|
||||
expect(movePinned(list, a, -1)).toBe(list); // already first
|
||||
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)[0]).toBe(a);
|
||||
expect(filterPrayers(list, { tab: 'open' }).map((p) => p.id)).toEqual([a, b, c]);
|
||||
list = list.map((p) => (p.id === a ? markAnswered(p, '', T0 + 20) : p));
|
||||
expect(pinned(list).map((p) => p.id)).toEqual([b]);
|
||||
});
|
||||
|
||||
test('waiting and partly answered stay open; partial keeps a note; answering clears them', () => {
|
||||
let p = setStatus(newPrayer({ text: 'x' }, T0), 'partial', ' half way ', T0 + 1);
|
||||
expect(p).toMatchObject({ status: 'partial', answerNote: 'half way', answeredAt: null });
|
||||
p = setStatus(p, 'waiting', 'ignored', T0 + 2);
|
||||
expect(p).toMatchObject({ status: 'waiting', answerNote: '' });
|
||||
expect(setStatus(p, '', '', T0 + 3).status).toBe('');
|
||||
expect(markAnswered(p, 'done', T0 + 4)).toMatchObject({ status: '', answeredAt: T0 + 4 });
|
||||
});
|
||||
|
||||
test('import reads our markdown and JSON, and skips prayers already in the journal', () => {
|
||||
const src = [
|
||||
{ ...newPrayer({ text: 'Wisdom', forWhom: 'Sam', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0), pinned: true, rank: 1, status: 'waiting' },
|
||||
markAnswered(newPrayer({ text: 'A job' }, T0), 'Hired', T0 + DAY),
|
||||
];
|
||||
const md = prayersToMarkdown(src, { date: new Date(T0) });
|
||||
const fromMd = parsePrayerImport(md, [], T0);
|
||||
expect(fromMd.prayers).toHaveLength(2);
|
||||
expect(fromMd.prayers[0]).toMatchObject({ text: 'Wisdom', forWhom: 'Sam', status: 'waiting', pinned: true, answeredAt: null });
|
||||
expect(fromMd.prayers[0].verses[0]).toMatchObject({ book: 'JAS', chapter: 1, verse: 5 });
|
||||
expect(fromMd.prayers[1]).toMatchObject({ text: 'A job', answerNote: 'Hired' });
|
||||
expect(fromMd.prayers[1].answeredAt).toBeTruthy();
|
||||
|
||||
const fromJson = parsePrayerImport(prayersToJson(src), [], T0);
|
||||
expect(fromJson.prayers.map((p) => p.id)).toEqual(src.map((p) => p.id));
|
||||
expect(fromJson.prayers[0]).toMatchObject({ pinned: true, status: 'waiting' });
|
||||
|
||||
expect(parsePrayerImport(prayersToJson(src), [src[0]], T0)).toMatchObject({ skipped: 1, prayers: [expect.objectContaining({ text: 'A job' })] });
|
||||
expect(parsePrayerImport(md, [src[0]], T0).prayers.map((p) => p.text)).toEqual(['A job']); // same words, same person
|
||||
expect(parsePrayerImport('{"nope":1}', []).error).toBeTruthy();
|
||||
expect(parsePrayerImport('', []).error).toBeTruthy();
|
||||
});
|
||||
@@ -94,6 +94,7 @@ export function saveProjectToStorage(project) {
|
||||
lastEdited: updated.lastEdited,
|
||||
chapterSummary: buildChapterSummary(updated),
|
||||
series: (updated.series ?? '').trim(),
|
||||
archived: !!updated.archived,
|
||||
tags,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Finds Scripture references in your own writing ("see Rom 8:28-30", "Psalm 23", "1 Cor 13:4") so they can be
|
||||
// linked to the verse. Only full book names and the usual short forms count, and the chapter must exist.
|
||||
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||
import { exactBook } from './passageRef.js';
|
||||
|
||||
// Standard short forms that passageRef doesn't list because it matches the start of a name (strict matching can't).
|
||||
// Left out on purpose: "Mar" (March), "Act" and "Number" (ordinary words).
|
||||
const EXTRA = {
|
||||
gen: 'GEN', exo: 'EXO', lev: 'LEV', num: 'NUM', deu: 'DEU', jos: 'JOS', jdg: 'JDG', rut: 'RUT', '1sa': '1SA', '2sa': '2SA', '1sam': '1SA', '2sam': '2SA',
|
||||
'1ki': '1KI', '2ki': '2KI', '1kin': '1KI', '2kin': '2KI', '1ch': '1CH', '2ch': '2CH', ezr: 'EZR', neh: 'NEH', est: 'EST', pro: 'PRO', ecc: 'ECC',
|
||||
isa: 'ISA', lam: 'LAM', eze: 'EZK', ezk: 'EZK', dan: 'DAN', hos: 'HOS', amo: 'AMO', oba: 'OBA', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB',
|
||||
zep: 'ZEP', hag: 'HAG', zec: 'ZEC', mal: 'MAL', luk: 'LUK', joh: 'JHN', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH', col: 'COL',
|
||||
'1th': '1TH', '2th': '2TH', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', heb: 'HEB', jas: 'JAS', '1pe': '1PE', '2pe': '2PE', '1jo': '1JN', '2jo': '2JN', '3jo': '3JN', rev: 'REV',
|
||||
};
|
||||
const squash = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const bookFor = (name) => exactBook(name) ?? (EXTRA[squash(name)] ? bookOptions.find((b) => b.abbrev === EXTRA[squash(name)]) ?? null : null);
|
||||
|
||||
// A capitalised name (up to three words, "Song of Solomon") then a chapter, then optionally :verse and -endVerse
|
||||
const RE = /\b((?:[1-3]\s?)?[A-Z][A-Za-z]*\.?(?:\s(?:of\s)?[A-Z][a-z]*\.?){0,2})\s+(\d{1,3})(?::(\d{1,3})(?:\s?[-–—]\s?(\d{1,3}))?)?/g;
|
||||
|
||||
// -> [{ text, start, end, book, bookName, chapter, verse, endVerse }] in the order they appear (verse/endVerse may be null)
|
||||
export function findReferences(text) {
|
||||
const found = [];
|
||||
const source = String(text ?? '');
|
||||
for (const m of source.matchAll(RE)) {
|
||||
const words = m[1].split(/\s+/);
|
||||
// "See Romans 8" captures "See Romans": try the name without its first words until a book matches
|
||||
for (let drop = 0; drop < words.length; drop += 1) {
|
||||
const name = words.slice(drop).join(' ').replace(/\.$/, '');
|
||||
const book = bookFor(name);
|
||||
if (!book) continue;
|
||||
const chapter = Number(m[2]);
|
||||
if (chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 0)) break;
|
||||
const lead = m[1].length - words.slice(drop).join(' ').length;
|
||||
const start = m.index + lead;
|
||||
const verse = m[3] ? Number(m[3]) : null;
|
||||
const endVerse = m[4] && Number(m[4]) > verse ? Number(m[4]) : null;
|
||||
found.push({ text: source.slice(start, m.index + m[0].length), start, end: m.index + m[0].length, book: book.abbrev, bookName: book.name, chapter, verse, endVerse });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The references with repeats collapsed, by what they point to
|
||||
export function uniqueReferences(text) {
|
||||
const seen = new Set();
|
||||
return findReferences(text).filter((r) => { const k = `${r.book}.${r.chapter}.${r.verse}.${r.endVerse}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
}
|
||||
|
||||
export const labelFor = (r) => `${r.bookName} ${r.chapter}${r.verse ? `:${r.verse}${r.endVerse ? `–${r.endVerse}` : ''}` : ''}`;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { findReferences, labelFor, uniqueReferences } from './scriptureRefs.js';
|
||||
import { exactBook } from './passageRef.js';
|
||||
|
||||
const refs = (text) => findReferences(text).map((r) => labelFor(r));
|
||||
|
||||
test('references of every common shape are found', () => {
|
||||
expect(refs('See Romans 8:28 and Psalm 23, then 1 Cor 13:4-7.')).toEqual(['Romans 8:28', 'Psalms 23', '1 Corinthians 13:4–7']);
|
||||
expect(refs('Compare Jn 3:16; Gen. 1:1 and Song of Solomon 2:4.')).toEqual(['John 3:16', 'Genesis 1:1', 'Song 2:4']);
|
||||
expect(refs('Phil 2:5-11 shows it, as does Heb 11')).toEqual(['Philippians 2:5–11', 'Hebrews 11']);
|
||||
});
|
||||
|
||||
test('standard short forms work, and look-alike words do not', () => {
|
||||
expect(refs('Gen 1:1; Exod 3; Deut 6:4; 1 Sam 17; 2 Kgs 5; Zech 9:9; Rev 21:4')).toEqual(['Genesis 1:1', 'Exodus 3', 'Deuteronomy 6:4', '1 Samuel 17', '2 Kings 5', 'Zechariah 9:9', 'Revelation 21:4']);
|
||||
expect(refs('Mar 5 is the date')).toEqual([]);
|
||||
});
|
||||
|
||||
test('the matched text and its position are exact, even when a word like "See" comes first', () => {
|
||||
const text = 'Look: See Romans 8:28 here';
|
||||
const [r] = findReferences(text);
|
||||
expect(r.text).toBe('Romans 8:28');
|
||||
expect(text.slice(r.start, r.end)).toBe('Romans 8:28');
|
||||
expect(r).toMatchObject({ book: 'ROM', chapter: 8, verse: 28, endVerse: null });
|
||||
});
|
||||
|
||||
test('ordinary words and impossible chapters are not references', () => {
|
||||
expect(refs('Number 5 on the list, Act 3 of the play, Chapter 4, Session 2, Day 12')).toEqual([]);
|
||||
expect(refs('Romans 99:1 and John 40')).toEqual([]);
|
||||
expect(refs('in 1999 and 3 days, Thanks 5')).toEqual([]);
|
||||
expect(refs('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('repeats collapse and a reversed range is kept as a single verse', () => {
|
||||
expect(uniqueReferences('Rom 8:28 ... Romans 8:28 ... Rom 8:29').map(labelFor)).toEqual(['Romans 8:28', 'Romans 8:29']);
|
||||
expect(findReferences('Rom 8:30-28')[0]).toMatchObject({ verse: 30, endVerse: null });
|
||||
});
|
||||
|
||||
test('only a full book name or a known short form is a book', () => {
|
||||
expect(exactBook('Romans')?.abbrev).toBe('ROM');
|
||||
expect(exactBook('1 Cor')?.abbrev).toBe('1CO');
|
||||
expect(exactBook('Jn')?.abbrev).toBe('JHN');
|
||||
expect(exactBook('Number')).toBeNull();
|
||||
expect(exactBook('Act')).toBeNull();
|
||||
expect(exactBook('')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Where each session of a series stands, worked out from what has been written (nothing extra to keep up to date):
|
||||
// Not started -> Studied (notes written) -> Ready to teach (an outline, script or questions) -> Taught (session notes).
|
||||
import { sessionsFor, outlineForSession } from './handout.js';
|
||||
import { openCommentCount } from './chunkComments.js';
|
||||
|
||||
export const STAGES = ['Not started', 'Studied', 'Ready to teach', 'Taught'];
|
||||
|
||||
const has = (s) => String(s ?? '').trim().length > 0;
|
||||
|
||||
export function stageOf(project, chunk) {
|
||||
if (['teachBack', 'attendees', 'groupNotes'].some((f) => has(chunk[f]))) return 3;
|
||||
if (has(chunk.finalScript) || has(chunk.discussionQuestions) || outlineForSession(project, chunk).length) return 2;
|
||||
if (['observation', 'interpretation', 'application', 'generalNotes'].some((f) => has(chunk[f]))) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One row per session: where it is, and what is still open on it.
|
||||
export function seriesProgress(project) {
|
||||
const rows = sessionsFor(project).map(({ chunk, number, reference }) => ({
|
||||
id: chunk.id, number, reference,
|
||||
title: String(chunk.episodeTitle ?? '').trim(),
|
||||
stage: stageOf(project, chunk),
|
||||
openFollowUps: (chunk.followUps ?? []).filter((f) => !f.done).length,
|
||||
openComments: openCommentCount(chunk.comments),
|
||||
}));
|
||||
const counts = STAGES.map((_, i) => rows.filter((r) => r.stage === i).length);
|
||||
const next = rows.find((r) => r.stage < 3) ?? null; // the first session still to teach
|
||||
return { rows, counts, taught: counts[3], total: rows.length, next };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { seriesProgress, stageOf } from './seriesProgress.js';
|
||||
|
||||
const chunk = (id, x = {}) => ({ id, startVerse: 1, endVerse: 2, ...x });
|
||||
const project = (chunks, outline = []) => ({ title: 'Romans', outline, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks }] });
|
||||
|
||||
test('a session moves from not started to taught as things get written', () => {
|
||||
const p = project([chunk('a')]);
|
||||
expect(stageOf(p, chunk('a'))).toBe(0);
|
||||
expect(stageOf(p, chunk('a', { observation: 'Notes' }))).toBe(1);
|
||||
expect(stageOf(p, chunk('a', { observation: 'Notes', discussionQuestions: 'Why?' }))).toBe(2);
|
||||
expect(stageOf(p, chunk('a', { observation: 'Notes', groupNotes: 'They liked it' }))).toBe(3);
|
||||
expect(stageOf(p, chunk('a', { attendees: ' ' }))).toBe(0);
|
||||
});
|
||||
|
||||
test('an outline point linked to the session counts as ready to teach', () => {
|
||||
const p = project([chunk('a', { application: 'x' })], [{ id: 'o', text: 'Point', chunkId: 'a', children: [] }]);
|
||||
expect(stageOf(p, p.chapters[0].chunks[0])).toBe(2);
|
||||
});
|
||||
|
||||
test('the series summary counts stages, open follow-ups and comments, and names the next session to teach', () => {
|
||||
const p = project([
|
||||
chunk('a', { groupNotes: 'Done', followUps: [{ id: 'f1', text: 'x', done: false }, { id: 'f2', text: 'y', done: true }] }),
|
||||
chunk('b', { observation: 'Started', comments: [{ id: 'c1', done: false }, { id: 'c2', done: true }] }),
|
||||
chunk('c'),
|
||||
]);
|
||||
const s = seriesProgress(p);
|
||||
expect(s.counts).toEqual([1, 1, 0, 1]);
|
||||
expect(s.taught).toBe(1);
|
||||
expect(s.total).toBe(3);
|
||||
expect(s.rows[0].openFollowUps).toBe(1);
|
||||
expect(s.rows[1].openComments).toBe(1);
|
||||
expect(s.next.id).toBe('b');
|
||||
});
|
||||
|
||||
test('a finished series has no next session', () => {
|
||||
expect(seriesProgress(project([chunk('a', { teachBack: 'x' })])).next).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { originalLanguage, speakableText, speakOriginalWord } from './wordStudy.js';
|
||||
|
||||
let synth;
|
||||
beforeEach(() => {
|
||||
synth = { voices: [], cancel: vi.fn(), resume: vi.fn(), speak: vi.fn(), getVoices: () => synth.voices, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
window.speechSynthesis = synth;
|
||||
globalThis.SpeechSynthesisUtterance = class { constructor(text) { this.text = text; } };
|
||||
});
|
||||
afterEach(() => { delete window.speechSynthesis; delete globalThis.SpeechSynthesisUtterance; });
|
||||
|
||||
test('the language comes from the word itself, then from the Strong\'s prefix', () => {
|
||||
expect(originalLanguage('אָדָם', '')).toBe('he');
|
||||
expect(originalLanguage('λόγος', 'H1')).toBe('el');
|
||||
expect(originalLanguage('word', 'H430')).toBe('he');
|
||||
expect(originalLanguage('word', 'g25')).toBe('el');
|
||||
expect(originalLanguage('word', '')).toBeNull();
|
||||
});
|
||||
|
||||
test('cantillation marks and punctuation are removed before speaking', () => {
|
||||
expect(speakableText('בְּרֵאשִׁ֖ית׃')).toBe('בְּרֵאשִׁית');
|
||||
expect(speakableText(' λόγος. ')).toBe('λόγος');
|
||||
});
|
||||
|
||||
test('uses a Greek voice when the device has one', async () => {
|
||||
const voice = { lang: 'el-GR', name: 'Melina' };
|
||||
synth.voices = [{ lang: 'en-US' }, voice];
|
||||
expect(await speakOriginalWord('λόγος', 'G3056', 'lógos')).toBe('original');
|
||||
const utt = synth.speak.mock.calls[0][0];
|
||||
expect(utt.text).toBe('λόγος');
|
||||
expect(utt.voice).toBe(voice);
|
||||
expect(synth.cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads the transliteration in the default voice when there is no Greek or Hebrew voice', async () => {
|
||||
synth.voices = [{ lang: 'en-US' }];
|
||||
expect(await speakOriginalWord('אָדָם', 'H121', '’ā·ḏām')).toBe('transliteration');
|
||||
const utt = synth.speak.mock.calls[0][0];
|
||||
expect(utt.text).toBe('’ā ḏām');
|
||||
expect(utt.lang).toBe('en-US');
|
||||
});
|
||||
|
||||
test('says it is unavailable when there is no voice and no transliteration, or no speech support', async () => {
|
||||
synth.voices = [{ lang: 'en-US' }];
|
||||
expect(await speakOriginalWord('λόγος', 'G3056')).toBe('unavailable');
|
||||
expect(synth.speak).not.toHaveBeenCalled();
|
||||
delete window.speechSynthesis;
|
||||
expect(await speakOriginalWord('λόγος', 'G3056', 'logos')).toBe('unavailable');
|
||||
});
|
||||
|
||||
test('waits for the voice list when the browser has not loaded it yet', async () => {
|
||||
const voice = { lang: 'he-IL' };
|
||||
synth.addEventListener = vi.fn((_, cb) => { synth.voices = [voice]; cb(); });
|
||||
expect(await speakOriginalWord('אָדָם', 'H121', 'adam')).toBe('original');
|
||||
});
|
||||
@@ -30,11 +30,13 @@ export function groupBySeries(entries) {
|
||||
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
|
||||
}
|
||||
|
||||
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
|
||||
// archived: false (default) leaves out finished/archived studies, true keeps only them, null keeps all.
|
||||
export function filterStudies(index, { query = '', tags = [], series = '', archived = false } = {}) {
|
||||
const raw = String(query).trim().toLowerCase();
|
||||
const tagOnly = raw.startsWith('#');
|
||||
const q = tagOnly ? raw.slice(1).trim() : raw;
|
||||
return index.filter((entry) => {
|
||||
if (archived !== null && !!entry.archived !== archived) return false;
|
||||
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
|
||||
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
|
||||
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
|
||||
|
||||
@@ -52,3 +52,11 @@ test('a series can be chosen as a filter, and the search finds studies by series
|
||||
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
|
||||
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('archived studies are left out unless asked for', () => {
|
||||
const idx = [{ id: 'a', title: 'Live' }, { id: 'b', title: 'Done', archived: true }, { id: 'c', title: 'Done too', archived: true, tags: ['hope'] }];
|
||||
expect(filterStudies(idx).map((e) => e.id)).toEqual(['a']);
|
||||
expect(filterStudies(idx, { archived: true }).map((e) => e.id)).toEqual(['b', 'c']);
|
||||
expect(filterStudies(idx, { archived: true, tags: ['hope'] }).map((e) => e.id)).toEqual(['c']);
|
||||
expect(filterStudies(idx, { archived: null }).map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
@@ -36,6 +36,50 @@ export const STUDY_TEMPLATES = [
|
||||
},
|
||||
starter: '',
|
||||
},
|
||||
{
|
||||
id: 'character',
|
||||
name: 'Character study',
|
||||
description: 'Follow a person through the text: who they are, what they did, what God did through them.',
|
||||
prompts: {
|
||||
observation: 'Who is this person here?\n• What do we learn about their background, family, and role?\n• What do they say, do, and feel in this passage?\n• How do others describe or respond to them?',
|
||||
interpretation: 'What does their story show?\n• What strengths and failures stand out?\n• What does this reveal about God\'s character and purposes?\n• How does their story connect to the rest of the Bible?',
|
||||
application: 'Where do I see myself?\n• What should I imitate, or avoid?\n• What does God want me to trust Him with?',
|
||||
},
|
||||
starter: 'Who they are (name meaning, family, role):\n\nKey moments in this passage:\n\nStrengths / failures:\n\nWhat God does through them:\n',
|
||||
},
|
||||
{
|
||||
id: 'topical',
|
||||
name: 'Topical study',
|
||||
description: 'Gather what Scripture says about one subject, passage by passage.',
|
||||
prompts: {
|
||||
observation: 'What does this passage say about the topic?\n• Quote or paraphrase the key statement.\n• Who is speaking, and in what situation?\n• Which words tie it to the topic?',
|
||||
interpretation: 'How does this fit the whole picture?\n• Does it agree with, add to, or qualify the other passages?\n• What did it mean in its own context before you apply it?',
|
||||
application: 'What does this change?\n• What do I believe differently now?\n• What will I do about it this week?',
|
||||
},
|
||||
starter: 'Topic:\n\nWhat this passage adds:\n\nCompare with (other passages):\n\nSummary so far:\n',
|
||||
},
|
||||
{
|
||||
id: 'word',
|
||||
name: 'Word study',
|
||||
description: 'Trace one original-language word: its meaning, where it appears, and how the author uses it here.',
|
||||
prompts: {
|
||||
observation: 'Look at the word in this verse.\n• Greek or Hebrew word, Strong\'s number, and how the English renders it.\n• Its grammar (tense, voice, mood, or stem) and what that signals.\n• Where else does this author use it?',
|
||||
interpretation: 'What does it mean here?\n• Which sense of the word fits the context?\n• What would be lost if a different English word were used?',
|
||||
application: 'Why does it matter?\n• What does this word teach me to believe or do?',
|
||||
},
|
||||
starter: 'Word (original, transliteration, Strong\'s):\n\nRange of meanings:\n\nUsed elsewhere (use “Where else” on the Words tab):\n\nMeaning in this verse:\n',
|
||||
},
|
||||
{
|
||||
id: 'verse',
|
||||
name: 'Verse by verse',
|
||||
description: 'Walk slowly through a passage, one verse at a time, then pull it together.',
|
||||
prompts: {
|
||||
observation: 'Verse by verse.\n• Write a short note on each verse: what it says, and what stands out.\n• Mark the connecting words (for, so, but).',
|
||||
interpretation: 'Putting it together.\n• What is the main point of this section?\n• How does each verse support it?\n• How does it fit the verses before and after?',
|
||||
application: 'Respond.\n• What is the one truth to hold on to?\n• What is the one step to take?',
|
||||
},
|
||||
starter: 'Verse notes:\nv. \nv. \nv. \n\nMain point:\n',
|
||||
},
|
||||
];
|
||||
|
||||
export const getTemplate = (id, userTemplates = []) => STUDY_TEMPLATES.find((t) => t.id === id) ?? userTemplates.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
|
||||
|
||||
@@ -10,7 +10,7 @@ test('unknown or missing templates are the blank one', () => {
|
||||
});
|
||||
|
||||
test('templates change the prompts and may pre-fill background notes', () => {
|
||||
expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional']);
|
||||
expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional', 'character', 'topical', 'word', 'verse']);
|
||||
expect(templatePrompt('sermon', 'application')).toMatch(/room/);
|
||||
expect(blankChunk(1, 5, null, 'sermon').generalNotes).toMatch(/Big idea/);
|
||||
expect(blankChunk(1, 5).generalNotes).toBe('');
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
|
||||
// offline" downloads go through the same address, so a saved book builds a study with no connection.
|
||||
// Failures say what's wrong and what to do, instead of a generic error.
|
||||
import { helloaoCode } from './bibleText.js';
|
||||
|
||||
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
|
||||
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
|
||||
const code = helloaoCode(translation || 'BSB');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
|
||||
|
||||
+46
-14
@@ -175,23 +175,55 @@ export const formatCrossRef = (ref) => {
|
||||
return `${bookName} ${ref.chapter}:${verseRange}`;
|
||||
};
|
||||
|
||||
export const speakOriginalWord = (word, strongsNum) => {
|
||||
if (!window.speechSynthesis) return;
|
||||
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
||||
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
|
||||
const utt = new SpeechSynthesisUtterance(word + '.');
|
||||
utt.lang = lang;
|
||||
// Which language a word is in: its own letters first (a word looked up by a Strong's number may not have one), then the
|
||||
// Strong's prefix. Returns 'he', 'el', or null.
|
||||
export const originalLanguage = (word, strongsNum) => {
|
||||
if (/[\u0590-\u05FF]/.test(word ?? '')) return 'he';
|
||||
if (/[\u0370-\u03FF\u1F00-\u1FFF]/.test(word ?? '')) return 'el';
|
||||
const prefix = String(strongsNum ?? '').trim().charAt(0).toUpperCase();
|
||||
return prefix === 'H' ? 'he' : prefix === 'G' ? 'el' : null;
|
||||
};
|
||||
|
||||
// What to hand the voice: no cantillation marks (they can stop a voice reading the word), no Hebrew maqaf or
|
||||
// sof pasuq, no stray punctuation.
|
||||
export const speakableText = (word) => String(word ?? '').replace(/[\u0591-\u05AF\u05BD\u05C0\u05C3]/g, '').replace(/\u05BE/g, ' ').replace(/[.,;:·׃]/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Browsers fill the voice list in after the page loads, so the first call can see none. Waits briefly for them.
|
||||
const loadVoices = () => new Promise((resolve) => {
|
||||
const synth = window.speechSynthesis;
|
||||
const now = synth.getVoices();
|
||||
if (now.length) { resolve(now); return; }
|
||||
const done = () => { synth.removeEventListener?.('voiceschanged', done); resolve(synth.getVoices()); };
|
||||
synth.addEventListener?.('voiceschanged', done);
|
||||
window.setTimeout(done, 800);
|
||||
});
|
||||
|
||||
// Says the original word in a Greek or Hebrew voice. Many phones and computers have no such voice installed, so
|
||||
// without one it reads the transliteration in the default voice instead of staying silent.
|
||||
// Resolves to 'original', 'transliteration' or 'unavailable' (nothing could be spoken).
|
||||
export const speakOriginalWord = async (word, strongsNum, transliteration = '') => {
|
||||
const synth = typeof window !== 'undefined' ? window.speechSynthesis : null;
|
||||
if (!synth || typeof SpeechSynthesisUtterance === 'undefined') return 'unavailable';
|
||||
const language = originalLanguage(word, strongsNum);
|
||||
const text = speakableText(word);
|
||||
const voices = await loadVoices();
|
||||
const voice = language && voices.find((v) => v.lang?.toLowerCase().startsWith(language));
|
||||
const fallback = String(transliteration ?? '').replace(/[·.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const useOriginal = !!(voice && text);
|
||||
const say = useOriginal ? text : fallback;
|
||||
if (!say) return 'unavailable';
|
||||
|
||||
synth.cancel(); // also clears a stuck queue, which some browsers get into
|
||||
const utt = new SpeechSynthesisUtterance(say);
|
||||
utt.rate = 0.85;
|
||||
if (match) utt.voice = match;
|
||||
if (useOriginal) { utt.voice = voice; utt.lang = voice.lang; } else utt.lang = 'en-US';
|
||||
// Chain a silent tail so the audio session fades rather than clicks shut
|
||||
utt.onend = () => {
|
||||
const tail = new SpeechSynthesisUtterance(' ');
|
||||
const tail = new SpeechSynthesisUtterance(' ');
|
||||
tail.volume = 0;
|
||||
tail.lang = lang;
|
||||
if (match) tail.voice = match;
|
||||
window.speechSynthesis.speak(tail);
|
||||
synth.speak(tail);
|
||||
};
|
||||
window.speechSynthesis.speak(utt);
|
||||
synth.resume?.(); // Chrome can leave the engine paused
|
||||
synth.speak(utt);
|
||||
return useOriginal ? 'original' : 'transliteration';
|
||||
};
|
||||
Reference in new issue
Block a user