Compare commits

...
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 c271f49694 Grammar panel: one flowing line per term so it reads in a narrow column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:28:07 -04:00
nmemmertandClaude Sonnet 5.5 79d9b091f9 Link Scripture references in notes and scripts to the verse (chips with a BSB preview)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:23:09 -04:00
nmemmertandClaude Sonnet 5.5 f05d38ecde Units hover notes and a family tree for people (Theographic)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:20:20 -04:00
nmemmertandClaude Sonnet 5.5 fdb7f16d8a Bible dictionary lookup in the Word study tab (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:18:01 -04:00
nmemmertandClaude Sonnet 5.5 fa9e825444 Explain grammar labels in plain English on original words and in word-study cards
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:17:02 -04:00
nmemmertandClaude Sonnet 5.5 d5cc93f9bd Add the KJV, ASV and LSV (free helloao translations; savable offline)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:14:25 -04:00
nmemmertandClaude Sonnet 5.5 6a89ee058f Merge fix-backup-nudge: account download counts as a backup
Build and Push Docker Image / build-and-push (push) Successful in 4m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:55 -04:00
nmemmertandClaude Sonnet 5.5 5e80d307a7 Count "Download from my account" as a backup for the Home nudge
Only "Download backup" recorded the date, so downloading from the account left
Home saying "You haven't downloaded a backup yet". Both now record it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:20 -04:00
nmemmertandClaude Sonnet 5.5 1bc515164a Merge studying-and-housekeeping: daily five minutes, reading list, browser tests
Build and Push Docker Image / build-and-push (push) Successful in 8m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:13:03 -04:00
nmemmertandClaude Sonnet 5.5 3ee1535c87 Real-browser tests (Playwright) and housekeeping
- npm run test:e2e builds the app, runs it on port 3101 with an empty throwaway
  database and drives Chrome (the installed one; no browser download) through
  notes and the paste check, the daily five minutes and weekly recall,
  translation compare (Bible text faked), and two-screen presenting.
- Vitest skips e2e/; ESLint knows the e2e folder and playwright.config.js.
- .gitignore: the local demo video, Playwright output and sign-in file.
- README and TODO describe the browser tests and what CI still needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:00:15 -04:00
nmemmertandClaude Sonnet 5.5 eccc5e509d Studying: daily five minutes, reading list, plan-day studies use your template
- Home: "Your five minutes": a rotating Observe/Interpret/Apply prompt on the
  passage whose matching note is thinnest in your own words, a 5:00 clock and a
  box that appends to that note. Pasting is blocked; sessions show in Insights.
- Study: a Reading list tab (R) for the study: books and articles to consult,
  with a note and a tick; merges across devices.
- Starting a study from a reading-plan day now uses the notes template you last
  picked when starting a study by hand (logic moved to utils/planStudy.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:49 -04:00
nmemmertandClaude Sonnet 5.5 0781fa3476 Merge more-study-features: Insights, compare, Finish stage, presenter view and more
Build and Push Docker Image / build-and-push (push) Successful in 5m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:25:15 -04:00
65 changed files with 1634 additions and 42 deletions

No files matched your search

+8
View File
@@ -13,3 +13,11 @@ coverage/
.env .env
.session-secret .session-secret
server/data/backups/ server/data/backups/
# Playwright
e2e/.auth.json
test-results/
playwright-report/
# a local demo video, not part of the app
demo/
+8 -1
View File
@@ -151,10 +151,17 @@ Private / personal use.
## Development ## Development
```bash ```bash
npm run test:run # all tests npm run test:run # all unit and component tests
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
npm run lint # ESLint (errors fail; warnings are advice) npm run lint # ESLint (errors fail; warnings are advice)
``` ```
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
and run `npx playwright install` to use Playwright's own browser.
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created. Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic). Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
+3 -2
View File
@@ -9,8 +9,9 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
"sign out other devices"; optionally a login alert through ntfy. "sign out other devices"; optionally a login alert through ntfy.
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a - [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`). "restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in, - [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
creating a study and syncing; so far everything is tested in simulation. The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
and cross-device sync to them.
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin). - [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
## Teaching together ## Teaching together
+12
View File
@@ -0,0 +1,12 @@
// Makes one account in the throwaway database and keeps its sign-in for the other tests.
import { test as setup, expect } from '@playwright/test';
setup('create an account and sign in', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /need an account\? sign up/i }).click();
await page.locator('input[type="email"]').fill('e2e@example.test');
await page.locator('input[type="password"]').fill('E2e-Test-Password-7391');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible();
await page.context().storageState({ path: 'e2e/.auth.json' });
});
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { resumeButton, seedStudy } from './helpers.js';
// Fake the free Bible text so the test needs no internet: every translation answers with its own wording.
const WORDING = {
BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' },
NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' },
};
test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => {
await page.route('https://bible.helloao.org/api/**', async (route) => {
const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase();
const words = WORDING[id] ?? WORDING.BSB;
await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } });
});
const { title } = await seedStudy(page);
await resumeButton(page, title).click();
await page.getByRole('button', { name: /compare…/i }).click();
await page.getByRole('tab', { name: 'Translations' }).click();
const dialog = page.getByRole('dialog', { name: /compare/i });
await expect(dialog.getByText('Berean Standard Bible')).toBeVisible();
await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible();
await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked
await dialog.getByLabel(/highlight the words that differ/i).uncheck();
await expect(dialog.locator('mark')).toHaveCount(0);
// a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page
await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"');
await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/);
await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.');
await dialog.getByRole('button', { name: 'Close' }).click();
await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy
await page.keyboard.press('3');
await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.');
await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/);
});
+51
View File
@@ -0,0 +1,51 @@
// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text.
const DAY = 86400000;
const verses = [
{ number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' },
{ number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' },
{ number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' },
{ number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' },
];
export const NOTE_TEXT = {
observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.',
interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.',
application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.',
};
export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.';
// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage
// for the signed-in account, then reloads.
export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) {
await page.goto('/');
await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k)));
await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => {
const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1];
const now = Date.now();
const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra });
const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra });
const a = chunk(`${id}-a`, 16, 18, { ...notes });
const b = chunk(`${id}-b`, 19, 19, {});
// a title of its own: studies from earlier tests sync back through the account, so titles must not repeat
const title = `John 3 (${id.slice(-6)})`;
const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : [];
const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] };
localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project));
const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id);
index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] });
localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index));
}, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY });
await page.reload();
return { id, title: `John 3 (${id.slice(-6)})` };
}
// The Resume button on one study's card on Home
export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' });
// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup)
export async function pasteInto(locator, text) {
await locator.evaluate((el, value) => {
const dt = new DataTransfer();
dt.setData('text', value);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, text);
}
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
import { pasteInto, seedStudy } from './helpers.js';
test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => {
await seedStudy(page);
// daily five minutes
await expect(page.getByText(/your five minutes:/i)).toBeVisible();
await page.getByRole('button', { name: 'Start' }).first().click();
const box = page.getByRole('textbox', { name: /your five minutes/i });
await expect(page.getByRole('timer')).toContainText('4:');
await pasteInto(box, 'text from somewhere else');
await expect(page.getByText(/pasting is off here/i)).toBeVisible();
await expect(box).toHaveValue('');
await box.fill('Three things I notice in the passage.');
await page.getByRole('button', { name: /save to my notes/i }).click();
await expect(page.getByText(/saved to your notes/i)).toBeVisible();
// weekly recall: write from memory, compare with the notes, rate it
await page.getByRole('button', { name: 'Start' }).click();
await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.');
await page.getByRole('button', { name: 'Show my notes' }).click();
await expect(page.getByText('Your notes', { exact: true })).toBeVisible();
await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to');
await page.getByRole('button', { name: 'Partly' }).click();
await expect(page.getByText(/recall saved/i)).toBeVisible();
// it shows in the history and in Insights
await page.getByRole('button', { name: /recall history/i }).click();
await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible();
await page.getByRole('button', { name: 'Insights' }).click();
await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible();
await expect(page.getByText('who Jesus was talking to')).toBeVisible();
});
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js';
test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => {
const { title } = await seedStudy(page, { withOutline: true });
await resumeButton(page, title).click();
await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click();
await page.getByRole('button', { name: 'Present' }).click();
const presenter = page.getByRole('dialog', { name: /presenting/i });
// one screen: notes stay hidden until turned on
await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible();
await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0);
// a second window (the projector) connects and follows along
const audience = await context.newPage();
await audience.goto('/?audience=1');
await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible();
await expect(presenter.getByText('Only you see this')).toBeVisible();
await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only
await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0);
expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/);
await page.keyboard.press('ArrowRight');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
// B blacks the audience screen out, and again brings it back
await page.keyboard.press('b');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0);
await page.keyboard.press('b');
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
// closing the presentation tells the audience window
await presenter.getByRole('button', { name: 'Close' }).click();
await expect(audience.getByText(/presentation has ended/i)).toBeVisible();
});
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies).
set -euo pipefail
cd "$(dirname "$0")/.."
export DATA_DIR="$(mktemp -d)"
export PORT=3101
export NODE_ENV=production
npx vite build --logLevel error
exec node server/index.js
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js';
test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => {
const { title } = await seedStudy(page);
await expect(page.getByRole('heading', { name: title })).toBeVisible();
await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge
await resumeButton(page, title).click();
await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible();
// keyboard: 2 jumps into Interpret, Escape leaves it
await page.keyboard.press('2');
await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused();
// a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours
await page.keyboard.press('1');
const observe = page.getByRole('textbox', { name: 'Observe' });
const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' ');
await pasteInto(observe, pasted);
await observe.fill(`${NOTE_TEXT.observation} ${pasted}`);
await expect(page.getByText(/is this from ai\?/i)).toBeVisible();
await expect(page.getByText(/24 words here were pasted/i)).toBeVisible();
await page.getByRole('button', { name: 'No, I wrote it' }).click();
await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0);
await expect(page.getByText(/were pasted/i)).toHaveCount(0);
// either half of the right column can be folded
const resources = page.getByRole('button', { name: /commentary & resources/i });
await resources.click();
await expect(resources).toHaveAttribute('aria-expanded', 'false');
await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other
await expect(resources).toHaveAttribute('aria-expanded', 'true');
// the reading list belongs to the study
await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open
await page.getByRole('tab', { name: /reading list/i }).click();
await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John');
await page.keyboard.press('Enter');
await page.getByRole('checkbox', { name: /read: carson on john/i }).check();
await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
});
+7 -1
View File
@@ -22,10 +22,16 @@ export default [
}, },
}, },
{ {
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'], files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] }, rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
}, },
{
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
files: ['e2e/**/*.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
},
{ {
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'], files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } }, languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
+46
View File
@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -1635,6 +1636,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -6190,6 +6207,35 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+3 -1
View File
@@ -15,7 +15,8 @@
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh", "backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh", "restore": "bash scripts/restore-data.sh",
"lint": "eslint ." "lint": "eslint .",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -37,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
+18
View File
@@ -0,0 +1,18 @@
// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome.
// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download).
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' },
webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false },
projects: [
{ name: 'setup', testMatch: /auth\.setup\.js/ },
{ name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } },
],
});
+5 -20
View File
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
import { isAudienceWindow } from './utils/presentSync.js'; import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js'; import { useHistoryNav } from './hooks/useHistoryNav.js';
@@ -425,29 +426,13 @@ const AppInner = () => {
const studyReadingPlanDay = async (dayRefs, title) => { const studyReadingPlanDay = async (dayRefs, title) => {
setStudyBuildBusy(true); setStudyBuildBusy(true);
try { try {
const lastTemplate = lastTemplateId(); // the notes template you picked last time you started a study by hand
const templateDef = reader.userTemplates?.find((t) => t.id === lastTemplate);
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => { const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
const data = await fetchStudyChapter('BSB', book, chapter); const data = await fetchStudyChapter('BSB', book, chapter);
const verses = parseBibleChapter(data); return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
const chunk = {
id: makeId(),
startVerse: verses[0]?.number ?? 1,
endVerse: verses.at(-1)?.number ?? 1,
spilloverEndVerse: null,
observation: '', interpretation: '', application: '',
crossReferences: [], greekWords: [], generalNotes: '',
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
};
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
})); }));
const newProject = { const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
id: makeId(),
title: title || 'Reading Plan Study',
translation: 'BSB',
lastEdited: Date.now(),
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
chapters,
};
setProject(newProject); setProject(newProject);
setCurrentPage('study'); setCurrentPage('study');
} catch (err) { } catch (err) {
+66
View File
@@ -0,0 +1,66 @@
// Home card: your five minutes for today. A short prompt on a passage from your studies, a five-minute clock, and a
// box to write in. What you write is added to that passage's notes. Pasting is turned off here on purpose.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { FIELD_NAMES, MINUTES, dailyPatch, pickDaily } from '../utils/dailyFive.js';
import { formatClock } from '../utils/present.js';
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
export default function DailyFive() {
const { projectIndex, saveChunkPatch } = useApp();
const [step, setStep] = useState('card'); // card -> write -> done
const [text, setText] = useState('');
const [left, setLeft] = useState(MINUTES * 60);
const [pasteTried, setPasteTried] = useState(false);
const pick = useMemo(() => pickDaily(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
const started = useRef(null);
useEffect(() => {
if (step !== 'write') return undefined;
started.current = Date.now();
const t = window.setInterval(() => setLeft(Math.max(0, MINUTES * 60 - Math.round((Date.now() - started.current) / 1000))), 1000);
return () => window.clearInterval(t);
}, [step]);
if (step === 'done') return <div role="status" className={box}>Saved to your notes. Same time tomorrow.</div>;
if (!pick) return null;
const { prompt } = pick;
const save = () => {
saveChunkPatch(pick.projectId, pick.chunk.id, dailyPatch(pick.chunk, prompt.field, text));
setStep('done');
};
if (step === 'card') {
return (
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
<span><span className="font-medium">Your five minutes:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]} · {pick.projectTitle}</span></span>
<button type="button" onClick={() => { setText(''); setLeft(MINUTES * 60); setPasteTried(false); setStep('write'); }} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
</div>
);
}
return (
<section aria-label="Your five minutes" className={box}>
<div className="flex items-baseline justify-between gap-3">
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]}</span></p>
<span role="timer" aria-label="Time left" className={`font-mono text-sm tabular-nums ${left === 0 ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'}`}>{left === 0 ? 'Time’s up, finish your thought' : formatClock(left)}</span>
</div>
{pick.verses.length > 0 && (
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg bg-[var(--r-bg)] p-3 font-serif text-[13px] leading-relaxed">
{pick.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
</div>
)}
<p className="mt-2 text-xs text-[var(--r-sub)]">{prompt.text}</p>
<textarea aria-label="Your five minutes" value={text} onChange={(e) => setText(e.target.value)} rows={6} autoFocus
onPaste={(e) => { e.preventDefault(); setPasteTried(true); }}
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
{pasteTried && <p role="note" className="mt-1 text-xs text-[var(--r-accent)]">Pasting is off here. Type it in your own words; that’s what the five minutes are for.</p>}
<div className="mt-2 flex gap-2">
<button type="button" disabled={!text.trim()} onClick={save} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Save to my notes</button>
<button type="button" onClick={() => setStep('card')} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
</div>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { render, screen, fireEvent } 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 { saveProjectToStorage } from '../utils/projectStorage.js';
import DailyFive from './DailyFive.jsx';
import { promptFor } from '../utils/dailyFive.js';
const project = () => ({
id: 'p1', title: 'John', translation: 'BSB', lastEdited: 1,
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }],
chunks: [{ id: 'c1', startVerse: 16, endVerse: 16, observation: '', interpretation: '', application: '', editedAt: 1 }] }],
});
function setup() {
localStorage.clear();
saveProjectToStorage(project());
const saveChunkPatch = vi.fn();
const value = { projectIndex: [{ id: 'p1' }], saveChunkPatch };
render(<AppContext.Provider value={value}><DailyFive /></AppContext.Provider>);
return { saveChunkPatch, user: userEvent.setup() };
}
beforeEach(() => localStorage.clear());
test('the card names a passage and what kind of note the five minutes are for', () => {
setup();
expect(screen.getByText(/your five minutes/i)).toBeInTheDocument();
expect(screen.getByText(/John 3:16/)).toBeInTheDocument();
});
test('pasting is blocked, and what you type is saved to the matching note', async () => {
const { saveChunkPatch, user } = setup();
await user.click(screen.getByRole('button', { name: 'Start' }));
expect(screen.getByText('For God so loved the world.')).toBeInTheDocument();
expect(screen.getByRole('timer')).toHaveTextContent('5:00');
const box = screen.getByRole('textbox', { name: /your five minutes/i });
fireEvent.paste(box, { clipboardData: { getData: () => 'text from somewhere else' } });
expect(screen.getByText(/pasting is off here/i)).toBeInTheDocument();
expect(box).toHaveValue('');
expect(screen.getByRole('button', { name: /save to my notes/i })).toBeDisabled();
await user.type(box, 'God gave.');
await user.click(screen.getByRole('button', { name: /save to my notes/i }));
const field = promptFor().field;
expect(saveChunkPatch).toHaveBeenCalledWith('p1', 'c1', expect.objectContaining({ [field]: 'God gave.', fiveMinutes: [expect.objectContaining({ field })] }));
expect(screen.getByRole('status')).toHaveTextContent(/saved to your notes/i);
});
+61
View File
@@ -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>
);
}
+46
View File
@@ -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();
});
+19
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+47
View File
@@ -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();
});
+1 -1
View File
@@ -24,7 +24,7 @@ const BY_PAGE = {
['← →', 'Previous / next chunk'], ['← →', 'Previous / next chunk'],
['1 2 3', 'Write in Observe / Interpret / Apply'], ['1 2 3', 'Write in Observe / Interpret / Apply'],
['Esc', 'Leave a note box (then keys work again)'], ['Esc', 'Leave a note box (then keys work again)'],
['C W X P', 'Commentary / Word study / Cross-refs / People & places'], ['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
['V', 'Compare passages or translations'], ['V', 'Compare passages or translations'],
], ],
}; };
+2
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { useIqAvailable } from '../hooks/useIq.js'; import { useIqAvailable } from '../hooks/useIq.js';
import IqCommentary from './iq/IqCommentary.jsx'; import IqCommentary from './iq/IqCommentary.jsx';
import Family from './FamilyTree.jsx';
import { import {
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync, COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
} from '../hooks/useVerseStudy.js'; } 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)]"> <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 ?? []).map((d, i) => <p key={i}>{d}</p>)}
{!data.description?.length && <p>No description available.</p>} {!data.description?.length && <p>No description available.</p>}
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
{data.minYear != null && ( {data.minYear != null && (
<p className="text-[var(--r-sub,#64748b)]"> <p className="text-[var(--r-sub,#64748b)]">
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''} Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
+75
View File
@@ -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>
);
}
+52
View File
@@ -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);
});
+2 -2
View File
@@ -81,7 +81,7 @@ export default function DataSection() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button> <button type="button" onClick={download} className={btn}>Download backup</button>
{authUser && ( {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> <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" /> <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> <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} /> : ( {offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3"> <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}> <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}`} {(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button> </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);
});
+3
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx'; import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx'; import FirstDraft from './FirstDraft.jsx';
import ScriptureRefs from '../ScriptureRefs.jsx';
import { usePasteCheck } from './PastePrompt.jsx'; import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js'; import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx'; import DictateButton, { appendSpoken } from '../DictateButton.jsx';
@@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<div className="px-4 pb-4"> <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} <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} /> 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)} {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> <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 && ( {historyField === field && (
@@ -150,6 +152,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })} <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}`} /> 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> <span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
<ScriptureRefs text={chunk.generalNotes} />
</label> </label>
<label className="block text-xs text-[var(--r-sub)]"> <label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span> Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
+32
View File
@@ -0,0 +1,32 @@
// A reading list for the whole study: the books, commentaries and articles you plan to consult, with a note on
// each and a tick when done. It belongs to the study, not one chunk.
import { makeId } from '../../utils/bibleUtils.jsx';
export default function ReadingListTab({ project, updateProject }) {
const items = project?.readingList ?? [];
const set = (next) => updateProject((current) => ({ ...current, readingList: next(current.readingList ?? []) }));
const add = (title) => { const t = title.trim(); if (t) set((list) => [...list, { id: makeId(), title: t, note: '', done: false }]); };
const patch = (id, change) => set((list) => list.map((i) => (i.id === id ? { ...i, ...change } : i)));
const open = items.filter((i) => !i.done).length;
return (
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--r-sub)]">Books and articles to consult for this study{items.length ? ` · ${open} to read, ${items.length - open} done` : ''}. Read the passage and write your own notes first; this list is for afterwards.</p>
<ul className="space-y-2">
{items.map((i) => (
<li key={i.id} className="rounded-xl border border-[var(--r-line)] p-2.5">
<div className="flex items-center gap-2">
<input type="checkbox" aria-label={`Read: ${i.title}`} checked={!!i.done} onChange={() => patch(i.id, { done: !i.done })} className="accent-[var(--r-accent)]" />
<span className={`min-w-0 flex-1 ${i.done ? 'text-[var(--r-sub)] line-through' : 'font-medium'}`}>{i.title}</span>
<button type="button" aria-label={`Remove ${i.title}`} onClick={() => set((list) => list.filter((x) => x.id !== i.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</div>
<input aria-label={`Note on ${i.title}`} value={i.note ?? ''} onChange={(e) => patch(i.id, { note: e.target.value })} placeholder="Where in it, or what to look for"
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
</li>
))}
</ul>
<input aria-label="Add to the reading list" placeholder="Add a book or article, then Enter"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
className="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" />
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx'; import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx'; import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx'; import PeopleChunkTab from './PeopleChunkTab.jsx';
import ReadingListTab from './ReadingListTab.jsx';
import IqChunkAids from '../iq/IqChunkAids.jsx'; import IqChunkAids from '../iq/IqChunkAids.jsx';
import { useIqAvailable } from '../../hooks/useIq.js'; import { useIqAvailable } from '../../hooks/useIq.js';
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js'; import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
@@ -14,10 +15,11 @@ export const RESOURCE_TABS = [
{ id: 'words', label: 'Word study' }, { id: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' }, { id: 'refs', label: 'Cross-refs' },
{ id: 'people', label: 'People & places' }, { id: 'people', label: 'People & places' },
{ id: 'readings', label: 'Reading list' },
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key { 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, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) { export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const iq = useIqAvailable(); const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq); const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
if (tab === 'aids' && !iq) tab = 'commentary'; if (tab === 'aids' && !iq) tab = 'commentary';
@@ -32,7 +34,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
if (!seeing || chunk.firstDraft) return; if (!seeing || chunk.firstDraft) return;
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false }); updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps }, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length }; const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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">
@@ -55,6 +57,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />} {tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />} {tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />} {tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />} {!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
</div> </div>
</div> </div>
+10 -1
View File
@@ -2,10 +2,13 @@
// interlinear words; click an original word to add it to the chunk's word study. // interlinear words; click an original word to add it to the chunk's word study.
import { useState } from 'react'; import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; 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 }) { export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
const [openVerse, setOpenVerse] = useState(null); const [openVerse, setOpenVerse] = useState(null);
const [editingNote, setEditingNote] = 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]; const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return ( return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4"> <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' : ''}`}> 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} {v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button> </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 && ( {onVerseNote && (
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)} <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" 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()); const added = w.s && addedStrongs.has(w.s.toUpperCase());
return ( return (
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)} <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') : '')} 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`}> 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> <span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
@@ -45,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
</button> </button>
); );
})} })}
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
)}
</div> </div>
)} )}
</div> </div>
+5
View File
@@ -6,6 +6,8 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
import { useIqAvailable } from '../../hooks/useIq.js'; import { useIqAvailable } from '../../hooks/useIq.js';
import { normalizeStrongs } from '../../utils/iqbible.js'; import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.jsx'; import IqLexicon from '../iq/IqLexicon.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
import IqDictionary from '../iq/IqDictionary.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 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'; const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
@@ -50,6 +52,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')} {field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')} {field('English word', 'englishGloss', 'e.g. grace')}
</div> </div>
{word.parsing && <GrammarPanel label={word.parsing} />}
{field('Short definition', 'shortDefinition')} {field('Short definition', 'shortDefinition')}
{word.definitionHtml && ( {word.definitionHtml && (
<details className="rounded-lg border border-[var(--r-line)] p-2.5"> <details className="rounded-lg border border-[var(--r-line)] p-2.5">
@@ -76,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) { export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy; 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 ( return (
<div> <div>
{iq && <IqDictionary />}
<div className="mb-3 flex flex-wrap gap-2"> <div className="mb-3 flex flex-wrap gap-2">
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn} <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> title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
+3 -1
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { fetchStudyChapter } from '../utils/studyText.js'; import { fetchStudyChapter } from '../utils/studyText.js';
import { templateStarter } from '../utils/studyTemplates.js'; import { templateStarter } from '../utils/studyTemplates.js';
import { rememberTemplate } from '../utils/planStudy.js';
// Every chunk starts blank; all note fields exist from the start. // Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') { export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
setShowAddChapterForm(false); setShowAddChapterForm(false);
setErrorMessage(''); setErrorMessage('');
} else { } else {
// Creating a brand-new project // Creating a brand-new project; remember the template so studies started from a reading plan can use it too
rememberTemplate(setup.template);
const newProject = { const newProject = {
id: makeId(), id: makeId(),
title: setup.title, title: setup.title,
+9 -3
View File
@@ -221,8 +221,8 @@ export function useProjectLibrary({
}); });
}; };
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study. // Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
const saveChunkRecall = (id, chunkId, recall) => { const updateStoredChunk = (id, chunkId, change) => {
const loaded = loadProjectById(id); const loaded = loadProjectById(id);
if (!loaded) return; if (!loaded) return;
const updated = { const updated = {
@@ -230,7 +230,7 @@ export function useProjectLibrary({
lastEdited: Date.now(), lastEdited: Date.now(),
chapters: loaded.chapters.map((ch) => ({ chapters: loaded.chapters.map((ch) => ({
...ch, ...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)), chunks: ch.chunks.map((c) => (c.id === chunkId ? change(c) : c)),
})), })),
}; };
saveProjectToStorage(updated); saveProjectToStorage(updated);
@@ -247,6 +247,11 @@ export function useProjectLibrary({
}); });
}; };
// A weekly recall (what you remembered, and how it went) on one chunk.
const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] }));
// Notes written from Home (the daily five minutes): counts as editing the chunk.
const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() }));
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back). // removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => { const finishDelete = (id) => {
@@ -314,6 +319,7 @@ export function useProjectLibrary({
renameProjectInStorage, renameProjectInStorage,
setProjectSeries, setProjectSeries,
saveChunkRecall, saveChunkRecall,
saveChunkPatch,
deleteProject, deleteProject,
pullLatestFromServer, pullLatestFromServer,
}; };
+1
View File
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
lexeme: w.o || entry?.lemma || '', lexeme: w.o || entry?.lemma || '',
transliteration: w.t || entry?.translit || entry?.xlit || '', transliteration: w.t || entry?.translit || entry?.xlit || '',
partOfSpeech: '', partOfSpeech: '',
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
shortDefinition: entry?.kjv_def || w.g || '', shortDefinition: entry?.kjv_def || w.g || '',
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '', definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
loading: false, loading: false,
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js'; import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx'; import BookCover from '../components/BookCover.jsx';
import DailyFive from '../components/DailyFive.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx'; import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx'; import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js'; import { useOfflineSave } from '../hooks/useOfflineSave.js';
@@ -155,6 +156,7 @@ export default function HomePage() {
</p> </p>
); );
})()} })()}
<DailyFive />
<WeeklyRecall /> <WeeklyRecall />
<RecallHistory /> <RecallHistory />
<IqDailyCard /> <IqDailyCard />
+1 -1
View File
@@ -39,7 +39,7 @@ export default function InsightsPage() {
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} /> <Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
<Stat label="Recalls done" value={week.recalls} /> <Stat label="Recalls done" value={week.recalls} />
</div> </div>
{week.teachBacks > 0 && <p className="mt-2 text-xs text-[var(--r-sub)]">{week.teachBacks} teach-it-back summar{week.teachBacks === 1 ? 'y' : 'ies'} written.</p>} {(week.teachBacks > 0 || week.fiveMinutes > 0) && <p className="mt-2 text-xs text-[var(--r-sub)]">{[week.fiveMinutes > 0 && `${week.fiveMinutes} five-minute session${week.fiveMinutes === 1 ? '' : 's'}`, week.teachBacks > 0 && `${week.teachBacks} teach-it-back summar${week.teachBacks === 1 ? 'y' : 'ies'}`].filter(Boolean).join(' · ')} this week.</p>}
</section> </section>
<section aria-label="Open follow-ups" className={card}> <section aria-label="Open follow-ups" className={card}>
+2
View File
@@ -2,6 +2,7 @@
// write the final script beside your study notes. Claude prompts and the // write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed. // pronunciation guide live here because this is where they're needed.
import { useState } from 'react'; import { useState } from 'react';
import ScriptureRefs from '../components/ScriptureRefs.jsx';
import PresentMode from '../components/PresentMode.jsx'; import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx'; import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js'; 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 })} <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." 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)]" /> 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> </div>
)} )}
</main> </main>
+3 -3
View File
@@ -16,9 +16,9 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import CompareSheet from '../components/study/CompareSheet.jsx'; import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx'; import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p open the resource tabs; v compares. // Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p, r open the resource tabs; v compares.
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' }; const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people' }; const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
export default function StudyPage() { export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp(); const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
@@ -183,7 +183,7 @@ export default function StudyPage() {
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}> <div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
<ResourcePane <ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project} tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify} updateChunk={updateChunk} updateProject={updateProject} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs} resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId} openWordId={openWordId} setOpenWordId={setOpenWordId}
/> />
+12
View File
@@ -509,6 +509,18 @@ describe('Study workspace', () => {
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.'); expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
}); });
test('reading list: add a book to the study, tick it off, and it is remembered with the study', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('tab', { name: /reading list/i }));
await user.type(screen.getByRole('textbox', { name: /add to the reading list/i }), 'Carson on John{Enter}');
const tick = screen.getByRole('checkbox', { name: /read: carson on john/i });
expect(tick).not.toBeChecked();
await user.click(tick);
expect(screen.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
await user.click(screen.getByRole('tab', { name: /commentary/i }));
expect(screen.getByRole('tab', { name: /reading list/i })).toBeInTheDocument(); // none left to read, so no count
});
test('people & places: lists what is tagged in the chunk', async () => { test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]); const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i })); await user.click(screen.getByRole('tab', { name: /people & places/i }));
+5
View File
@@ -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.' }, 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', { 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.' }, 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, { id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' }, notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true, { 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' }, 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 translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true; export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
+1 -1
View File
@@ -485,7 +485,7 @@ export function parseBibleChapter(data) {
.filter((item) => item?.type === 'verse') .filter((item) => item?.type === 'verse')
.map((item) => ({ .map((item) => ({
number: item.number, number: item.number,
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(), text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(),
})); }));
} }
+55
View File
@@ -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 ?? '' }];
}
+29
View File
@@ -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();
});
+53
View File
@@ -0,0 +1,53 @@
// Daily five minutes: one short prompt a day, on a passage from your studies where that kind of note is thinnest.
// What you write is added to the matching note (Observe, Interpret or Apply) and counts as your own words.
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
import { ownWords } from './ownWork.js';
export const MINUTES = 5;
export const PROMPTS = [
{ field: 'observation', text: 'Read the passage slowly. Write three things you notice: a repeated word, who is speaking, something that surprises you.' },
{ field: 'interpretation', text: 'What would the first hearers have understood? Write one or two sentences about the situation they were in.' },
{ field: 'application', text: 'Write one thing you will believe differently, and one thing you will do, because of this passage.' },
{ field: 'observation', text: 'What does this passage say about God? Say it using only what the text itself says.' },
{ field: 'interpretation', text: 'How does this passage connect to what comes just before or after it? Write how it fits.' },
{ field: 'application', text: 'Who is one person this passage speaks to? Write what you would say to them.' },
{ field: 'observation', text: 'Read it aloud. Which word or phrase stands out most? Write down why.' },
];
export const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
const dayNumber = (now) => { const d = new Date(now); return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86400000); };
export const promptFor = (now = Date.now()) => PROMPTS[dayNumber(now) % PROMPTS.length];
const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
// Did you already do your five minutes today?
export function doneToday(projects, now = Date.now()) {
return (projects ?? []).some((p) => (p?.chapters ?? []).some((ch) => (ch.chunks ?? []).some((c) => (c.fiveMinutes ?? []).some((f) => sameDay(f.at, now)))));
}
// Today's prompt and the passage to use it on: the chunk whose notes for that prompt have the fewest words of
// your own (oldest first on a tie). null when there are no studies, or today's five minutes are done.
export function pickDaily(projects, now = Date.now()) {
if (doneToday(projects, now)) return null;
const prompt = promptFor(now);
const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
const [best] = all.sort((a, b) => ownWords(a.chunk, prompt.field) - ownWords(b.chunk, prompt.field) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
if (!best) return null;
return {
prompt, projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
verses: getChunkVerseEntries(best.project, best.chapterIndex, best.chunk).map((v) => ({ number: v.number, text: v.text })),
};
}
const marker = (now) => `— five minutes, ${new Date(now).toLocaleDateString([], { month: 'short', day: 'numeric' })} —`;
// The patch that adds what you wrote to the chunk (below what's already there) and records the session.
export function dailyPatch(chunk, field, text, now = Date.now()) {
const added = text.trim();
const have = (chunk[field] ?? '').replace(/\s+$/, '');
return {
[field]: have ? `${have}\n\n${marker(now)}\n${added}` : added,
fiveMinutes: [...(chunk.fiveMinutes ?? []), { at: now, field }],
};
}
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from 'vitest';
import { PROMPTS, dailyPatch, doneToday, pickDaily, promptFor } from './dailyFive.js';
const NOW = new Date('2026-10-14T12:00:00').getTime();
const DAY = 86400000;
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 2, observation: '', interpretation: '', application: '', editedAt: 1, ...extra });
const study = (chunks) => ({ id: 'p', title: 'John', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }], chunks }] });
test('the prompt changes each day and cycles through all of them', () => {
const seen = new Set(Array.from({ length: PROMPTS.length }, (_, i) => promptFor(NOW + i * DAY).text));
expect(seen.size).toBe(PROMPTS.length);
expect(promptFor(NOW)).toBe(promptFor(NOW + 3600_000));
});
test('the passage chosen is the one with the fewest words of your own for that prompt', () => {
const field = promptFor(NOW).field;
const thick = chunk('thick', { [field]: text(80) });
const thin = chunk('thin', { [field]: text(3) });
const pasted = text(60);
const fake = chunk('fake', { [field]: pasted, aiPastes: { [field]: [pasted] } }); // pasted words count for nothing
const pick = pickDaily([study([thick, thin, fake])], NOW);
expect(pick.chunk.id).toBe('fake');
expect(pick.reference).toContain('John');
expect(pick.verses).toHaveLength(2);
expect(pickDaily([], NOW)).toBeNull();
});
test('once today is done, nothing more is offered until tomorrow', () => {
const done = chunk('a', { fiveMinutes: [{ at: NOW - 3600_000, field: 'observation' }] });
expect(doneToday([study([done])], NOW)).toBe(true);
expect(pickDaily([study([done, chunk('b')])], NOW)).toBeNull();
expect(pickDaily([study([done, chunk('b')])], NOW + DAY)).not.toBeNull();
});
test('what you write is added below your existing note and the session is recorded', () => {
const empty = dailyPatch(chunk('a'), 'observation', ' Three things. ', NOW);
expect(empty.observation).toBe('Three things.');
expect(empty.fiveMinutes).toEqual([{ at: NOW, field: 'observation' }]);
const more = dailyPatch(chunk('a', { observation: 'Earlier.\n' }), 'observation', 'Later.', NOW);
expect(more.observation).toMatch(/^Earlier\.\n\n— five minutes, .+ —\nLater\.$/);
});
+30
View File
@@ -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 };
}
+35
View File
@@ -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 });
});
+35
View File
@@ -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);
+50
View File
@@ -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
});
+111
View File
@@ -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;
}
+41
View File
@@ -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([]);
});
+1
View File
@@ -25,6 +25,7 @@ export function weeklySummary(projects, now = Date.now()) {
ownWords: ownWordsTotal, ownWords: ownWordsTotal,
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null, ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0), recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length, teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
}; };
} }
+5
View File
@@ -46,3 +46,8 @@ test('slipped lines and follow-ups are collected, open follow-ups first', () =>
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']); expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']); expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
}); });
test('five-minute sessions this week are counted', () => {
const p = [study([chunk('a', { fiveMinutes: [{ at: NOW - 1000, field: 'observation' }, { at: NOW - 3 * WEEK_MS, field: 'application' }] })])];
expect(weeklySummary(p, NOW).fiveMinutes).toBe(1);
});
+2 -1
View File
@@ -41,6 +41,7 @@ export function mergeChunk(mine, theirs) {
const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]); const noteKeys = new Set([...Object.keys(newer.verseNotes ?? {}), ...Object.keys(older.verseNotes ?? {})]);
out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])])); out.verseNotes = Object.fromEntries([...noteKeys].map((k) => [k, mergeText(newer.verseNotes?.[k], older.verseNotes?.[k])]));
out.firstDraft = [newer.firstDraft, older.firstDraft].filter(Boolean).sort((a, b) => a.at - b.at)[0]; 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); out.followUps = mergeById(newer.followUps, older.followUps);
out.recalls = union(newer.recalls, older.recalls); out.recalls = union(newer.recalls, older.recalls);
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined; out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
@@ -77,7 +78,7 @@ export function mergeStudies(mine, theirs) {
} }
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id))); const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return { return {
project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null }, project: { ...mine, chapters, ...(mine.readingList || theirs.readingList ? { readingList: mergeById(mine.readingList, theirs.readingList) } : {}), selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
stats, stats,
}; };
} }
+12
View File
@@ -49,3 +49,15 @@ test('identical copies merge to themselves', () => {
expect(project).toEqual(s); expect(project).toEqual(s);
expect(stats).toEqual({ added: 0, merged: 0 }); expect(stats).toEqual({ added: 0, merged: 0 });
}); });
import { mergeStudies as mergeBoth } from './mergeStudies.js';
test('merging two copies keeps every reading-list item, mine winning when both changed one', () => {
const base = { id: 'p', title: 'T', chapters: [] };
const mine = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: true }, { id: 'b', title: 'Carson', done: false }] };
const theirs = { ...base, readingList: [{ id: 'a', title: 'Mounce', done: false }, { id: 'c', title: 'Stott', done: false }] };
const { project } = mergeBoth(mine, theirs);
expect(project.readingList.map((i) => i.id).sort()).toEqual(['a', 'b', 'c']);
expect(project.readingList.find((i) => i.id === 'a').done).toBe(true);
expect(mergeBoth(base, base).project.readingList).toBeUndefined();
});
+18
View File
@@ -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', () => { test('licensed translations cannot be saved offline', () => {
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed'); 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');
});
+8
View File
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
return BOOKS.filter((b) => b.key.startsWith(key)); 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") // "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. // give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) { export function parsePassageRef(text) {
+33
View File
@@ -0,0 +1,33 @@
// A study started from one day of a reading plan: a chunk for each whole chapter in the day, with the notes template
// you last used when starting a study by hand (so the prompts and starter text are the ones you're used to).
import { templateStarter } from './studyTemplates.js';
export const lastTemplateId = () => { try { return localStorage.getItem('last-template') || 'blank'; } catch { return 'blank'; } };
export const rememberTemplate = (id) => { try { localStorage.setItem('last-template', id || 'blank'); } catch { /* storage unavailable */ } };
// chapters: [{ bookAbbrev, bookName, chapter, verses: [{ number, text }] }]; ids come from `makeId`.
export function planStudyProject({ chapters, title, templateId = 'blank', templateDef = null, makeId, now = Date.now() }) {
const built = chapters.map(({ bookAbbrev, bookName, chapter, verses }) => ({
book: bookName, bookAbbrev, chapter: String(chapter), verses,
chunks: [{
id: makeId(),
startVerse: verses[0]?.number ?? 1,
endVerse: verses.at(-1)?.number ?? 1,
spilloverEndVerse: null,
observation: '', interpretation: '', application: '',
crossReferences: [], greekWords: [], generalNotes: '',
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
...templateStarter(templateDef ?? templateId),
}],
}));
return {
id: makeId(),
title: title || 'Reading Plan Study',
translation: 'BSB',
templateId,
...(templateDef ? { templateDef } : {}),
lastEdited: now,
selectedChunkId: built[0]?.chunks[0]?.id ?? null,
chapters: built,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, beforeEach } from 'vitest';
import { lastTemplateId, planStudyProject, rememberTemplate } from './planStudy.js';
import { STUDY_TEMPLATES } from './studyTemplates.js';
beforeEach(() => localStorage.clear());
let n = 0;
const makeId = () => `id${n += 1}`;
const chapters = [
{ bookAbbrev: 'JHN', bookName: 'John', chapter: 3, verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }] },
{ bookAbbrev: 'JHN', bookName: 'John', chapter: 4, verses: [{ number: 1, text: 'c' }] },
];
test('a plan day becomes one whole-chapter chunk per chapter', () => {
const project = planStudyProject({ chapters, title: 'Day 4', makeId });
expect(project.title).toBe('Day 4');
expect(project.chapters.map((c) => [c.chapter, c.chunks[0].startVerse, c.chunks[0].endVerse])).toEqual([['3', 1, 2], ['4', 1, 1]]);
expect(project.selectedChunkId).toBe(project.chapters[0].chunks[0].id);
expect(planStudyProject({ chapters, makeId }).title).toBe('Reading Plan Study');
});
test('it starts from the template you used last, with its starter text', () => {
const withStarter = STUDY_TEMPLATES.find((t) => Object.keys(t).some((k) => k === 'starter' || k === 'starters'));
const id = withStarter?.id ?? 'blank';
rememberTemplate(id);
expect(lastTemplateId()).toBe(id);
const project = planStudyProject({ chapters, templateId: lastTemplateId(), makeId });
expect(project.templateId).toBe(id);
if (withStarter) {
const chunk = project.chapters[0].chunks[0];
expect(Object.values(chunk).some((v) => typeof v === 'string' && v.length > 20)).toBe(true);
}
});
test('your own template travels with the study', () => {
const def = { id: 'mine', name: 'Mine', prompts: {}, starters: { observation: 'Who? What? Where?' } };
const project = planStudyProject({ chapters, templateId: 'mine', templateDef: def, makeId });
expect(project.templateDef).toBe(def);
expect(project.chapters[0].chunks[0].observation).toBe('Who? What? Where?');
});
test('with no template remembered, it is blank', () => {
expect(lastTemplateId()).toBe('blank');
});
+51
View File
@@ -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}` : ''}` : ''}`;
+45
View File
@@ -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();
});
+3 -1
View File
@@ -1,8 +1,10 @@
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for // 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. // 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. // 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 } = {}) { export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB'); const code = helloaoCode(translation || 'BSB');
let res; let res;
try { try {
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal }); res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
}, },
}, },
test: { test: {
exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e)
environment: 'jsdom', environment: 'jsdom',
environmentOptions: { environmentOptions: {
jsdom: { url: 'http://localhost:3000/' }, jsdom: { url: 'http://localhost:3000/' },